Files
horizon/tools/pacing_bench.py
masa-masa-0314andCodex b3259b8b37 Tools: keep dimension pacing scenarios alive in flight
Synchronize creative flight through normal movement before End and Nether measurements, validate each dimension hop, and reject dead portal fixtures instead of reporting a false portal failure. Refs #7702.

Co-Authored-By: Codex <[email protected]>
2026-11-03 16:06:34 +08:00

595 lines
32 KiB
Python

#!/usr/bin/env python3
"""Frame pacing benchmark: every frame of long stutter-prone sessions, and what the slow ones did.
perf_bench.py measures throughput on short flights; this measures smoothness (1% and 0.1% lows,
p99/p99.9, the worst frame, frames over 33 ms) in the situations where players see hitches:
fly16 the village flight of perf_bench, 16 blocks/s, 12 s
ocean perf_bench's ocean flight (16 blocks/s, 12 s): Horizon's worst lows on Windows at RD 32
fly64 a 1,920-block straight line at 64 blocks/s (30 s), new terrain all the way
teleport 8 teleports of 1,500-3,000 blocks, 5 s recorded after each
elytra a 990-block straight line at 33 blocks/s (30 s, an elytra's cruise), new terrain all the way
nether the overworld -> Nether -> overworld (/execute in ... run tp), 8 s recorded after each
portal through a Nether portal twice (an obsidian frame built at two far places), 8 s recorded after each
join joining a dedicated server (seed 1234, the same view distance; its spawn chunks, entities and
tab list arrive at once), recorded from `connect` until 5 s after the view is loaded: a game
of its own, started on the title screen
inventory the inventory and the pause menu opened and closed 20 times each
mobs 68 mob types summoned one by one in view (NoAI), 0.6 s recorded after each: first-sight costs
items 50 item entities of different models, the same way
block_entities 34 block entities set one by one in view, the same way
screens the creative inventory and its 12 tabs, then the survival inventory and its recipe book,
opened for the first time and once more
reload F3+T, 15 s recorded
tnt 50 TNT set off on a stone block, 10 s recorded (world changes: block updates, light and
remeshing in one frame; each built in the air in front of the camera)
fill /fill of a 64x64x16 stone volume (two fills, the default block limit), then cleared
fluids water, then lava, poured down a 16-block slope, 15 s each
fire fire lit at 5 places on a 25x25 grove's canopy of leaves over logs, 25 s
pistons an observer clock driving 20 pistons, 15 s
end the overworld -> the End -> overworld twice (/execute in ... run tp), 8 s recorded after each
weather rain, thunder, a lightning bolt, then clear, 4 s recorded after each: first-use costs
particles 53 particle kinds, 60 of each (/particle ... force), 0.6 s recorded after each
post a glowing mob, then spectating a creeper, a spider, an enderman and a cave spider (their
post chains), 2 s recorded after each
First-use costs (pipelines compiled in the frame) block the render thread in the driver's or the OS's
shader compiler: the frame's wall time grows, its CPU time hardly. The driver's shader cache hides them
after the first run on a machine: give the game a resources folder whose shaders differ to see them cold.
One game per render distance, from a fresh world (seed 1234, creative), driven over horizon-ctl.
The game runs with HORIZON_SPIKES so each slow frame logs its timed spans; the report keeps the
slowest frames' span lines next to each scenario's figures.
python3 tools/pacing_bench.py --build target/fast --label main --distances 32
python3 tools/pacing_bench.py --compare main mybranch
python3 tools/pacing_bench.py --max-fps 60 ... # a player's frame cap: the limiter's pacing shows
python3 tools/pacing_bench.py --vsync --max-fps 120 --size 1920x1080 ... # vanilla's defaults, in a window
Results: .local/pacing-bench/<label>-rd<N>.json. Use alternated A/B runs on a loaded machine.
"""
import argparse
import json
import math
import os
import re
import subprocess
import time
from pathlib import Path
try:
from . import lab
from . import perf_bench as pb
except ImportError:
import lab
import perf_bench as pb
WORK = lab.ROOT / ".local/pacing-bench"
SCENARIOS = ["fly16", "ocean", "fly64", "elytra", "teleport", "nether", "portal", "inventory", "mobs", "items",
"block_entities", "screens", "reload", "tnt", "fill", "fluids", "fire", "pistons", "join",
"end", "weather", "particles", "post"]
WORLD_CHANGES = ["tnt", "fill", "fluids", "fire", "pistons"]
# First sight: what each kind of thing costs the first frame it is on screen.
PARTICLES = ["flame", "smoke", "large_smoke", "heart", "crit", "enchanted_hit", "enchant", "portal", "end_rod",
"explosion", "explosion_emitter", "firework", "bubble", "splash", "rain", "dripping_water", "falling_lava",
"cloud", "poof", "soul_fire_flame", "soul", "ash", "white_ash", "spore_blossom_air", "cherry_leaves",
"glow", "wax_on", "electric_spark", "scrape", "sculk_soul", "sonic_boom", "note", "happy_villager",
"angry_villager", "totem_of_undying", "witch", "dragon_breath", "squid_ink", "glow_squid_ink", "nautilus",
"dolphin", "campfire_cosy_smoke", "lava", "snowflake", "trial_spawner_detection", "vault_connection",
"gust", "small_gust", "infested", "item_slime", "item_snowball", "white_smoke", "egg_crack"]
MOBS = ["zombie", "skeleton", "creeper", "spider", "enderman", "cow", "pig", "sheep", "chicken", "horse", "donkey",
"villager", "iron_golem", "wolf", "cat", "fox", "bee", "axolotl", "frog", "allay", "warden", "wither",
"ghast", "blaze", "piglin", "hoglin", "strider", "shulker", "guardian", "elder_guardian", "squid",
"glow_squid", "dolphin", "turtle", "panda", "polar_bear", "llama", "camel", "sniffer", "armadillo", "breeze",
"bogged", "creaking", "slime", "magma_cube", "phantom", "vex", "evoker", "ravager", "witch", "drowned",
"husk", "stray", "zombie_villager", "parrot", "rabbit", "goat", "mooshroom", "ocelot", "salmon", "cod",
"tropical_fish", "pufferfish", "bat", "snow_golem", "wandering_trader", "armor_stand", "ender_dragon"]
ITEMS = ["diamond_sword", "bow", "crossbow", "trident", "shield", "compass", "clock", "recovery_compass", "spyglass",
"elytra", "totem_of_undying", "player_head", "creeper_head", "white_banner", "red_bed", "chest",
"ender_chest", "shulker_box", "conduit", "decorated_pot", "filled_map", "potion", "splash_potion",
"tipped_arrow", "firework_rocket", "written_book", "leather_chestplate", "diamond_helmet", "turtle_helmet",
"carved_pumpkin", "oak_sign", "glass_pane", "iron_bars", "oak_fence", "torch", "lantern", "bell",
"enchanting_table", "anvil", "brewing_stand", "cauldron", "hopper", "campfire", "lectern", "mace",
"wolf_armor", "goat_horn", "bundle", "honey_bottle", "dragon_egg"]
BLOCK_ENTITIES = ["chest", "trapped_chest", "ender_chest", "oak_sign", "oak_hanging_sign", "white_banner",
"red_bed", "shulker_box", "player_head", "skeleton_skull", "dragon_head", "enchanting_table",
"lectern", "bell", "conduit", "decorated_pot", "campfire", "soul_campfire", "beacon",
"end_portal", "end_gateway", "brewing_stand", "spawner", "trial_spawner", "vault",
"chiseled_bookshelf", "suspicious_sand", "creaking_heart",
"sculk_shrieker", "sculk_catalyst", "jukebox", "piston", "copper_golem_statue",
"oak_shelf"]
SPIKE_RE = re.compile(r"slow frame ([\d.]+) ms")
GAP_RE = re.compile(r", ([\d.]+) ms before it")
def spike_ms(line):
"""A spike line's frame time plus the time before the frame (between frames)."""
gap = GAP_RE.search(line)
return float(SPIKE_RE.search(line).group(1)) + (float(gap.group(1)) if gap else 0.0)
def stats(frame_ms):
"""p50/p99/p99.9/max, 1% and 0.1% lows (average of the slowest 1%/0.1%, as FPS), frames over 33 ms
and over twice the median."""
values = sorted(frame_ms)
n = len(values)
if not n:
return {"frames": 0}
pct = lambda p: values[min(n, max(1, math.ceil(n * p))) - 1]
low = lambda per: 1000.0 / (sum(values[n - math.ceil(n / per):]) / math.ceil(n / per))
median = pct(0.5)
return {
"frames": n,
"seconds": round(sum(values) / 1000.0, 2),
"avg_fps": round(n * 1000.0 / sum(values), 1),
"p50_ms": round(median, 2),
"p99_ms": round(pct(0.99), 2),
"p999_ms": round(pct(0.999), 2),
"max_ms": round(values[-1], 2),
"low_1pct_fps": round(low(100), 1),
"low_01pct_fps": round(low(1000), 1),
"over_33ms": sum(v > 33.34 for v in values),
"over_2x_median": sum(v > 2 * median for v in values),
}
def record(control, action, seconds):
"""Resets the frame stats, runs action(), waits `seconds` and returns every frame time since."""
control("stats reset")
action()
time.sleep(seconds)
reply = control("stats raw reset")
return reply.get("frame_ms") or [], reply.get("cpu_ms") or []
def living_player(control, scenario):
state = control("state")
if not state.get("player") or state["player"].get("health", 0) <= 0 or state.get("screen") == "dead":
raise RuntimeError(f"{scenario}: the player is dead or missing")
return state
def prepare_flight(control, scenario):
state = living_player(control, scenario)
if state.get("gamemode") != "creative":
raise RuntimeError(f"{scenario}: creative flight is required")
def toggle():
# Hold each press across a tick: an instantaneous tap can be missed by movement.
try:
control("key space down")
control("wait 2")
control("key space up")
control("wait 2")
control("key space down")
control("wait 2")
finally:
control("key space up")
control("wait 2")
# `fly` changes the camera's flying flag only. Toggle through normal movement so
# the server knows too, and its abilities packet on a dimension change keeps it.
if state["player"].get("flying"):
toggle()
if living_player(control, scenario)["player"].get("flying"):
raise RuntimeError(f"{scenario}: could not reset creative flight")
toggle()
if not living_player(control, scenario)["player"].get("flying"):
raise RuntimeError(f"{scenario}: could not enable creative flight")
def scenario(name, control, distance):
frames, cpu = [], []
def add(result, step=None):
frames.extend(result[0])
cpu.extend(result[1])
if step:
print(f" {name} {step}: worst frame {max(result[0], default=0):.1f} ms", flush=True)
if name in ("fly16", "ocean"):
_, start, end, pitch = next(s for s in pb.SCENES if s[0] == {"fly16": "village"}.get(name, name))
control("tp {} {} {}".format(*start))
control("fly {0} {1} {2} {0} {1} {2} 0.1 {3}".format(*start, pitch))
pb.wait_loaded(control, lambda p: True, 300, pb.view_chunks(distance))
time.sleep(2)
reply = control("fly {} {} {} {} {} {} 12 {} raw".format(*start, *end, pitch))
add((reply.get("frame_ms") or [], reply.get("cpu_ms") or []))
elif name in ("fly64", "elytra"):
start, end = {
"fly64": ((3000.0, 140.0, -3000.0), (3000.0, 140.0, -1080.0)),
"elytra": ((-3000.0, 160.0, 3000.0), (-2010.0, 160.0, 3000.0)),
}[name]
control("tp {} {} {}".format(*start))
control("fly {0} {1} {2} {0} {1} {2} 0.1 20".format(*start))
pb.wait_loaded(control, lambda p: True, 300, pb.view_chunks(distance))
time.sleep(2)
reply = control("fly {} {} {} {} {} {} 30 20 raw".format(*start, *end))
add((reply.get("frame_ms") or [], reply.get("cpu_ms") or []))
elif name == "teleport":
targets = [(-3000, 150, 2000), (1500, 150, 2500), (4000, 150, -500), (0, 150, -3500),
(-2500, 150, -1500), (2500, 150, 1000), (-500, 150, 4000), (3500, 150, 3500)]
for x, y, z in targets:
add(record(control, lambda: (control(f"tp {x} {y} {z}"),
control(f"fly {x} {y} {z} {x} {y} {z} 0.1 10")), 5))
elif name in ("nether", "end"):
prepare_flight(control, name)
trips = {
"nether": [("the_nether", (100, 100, 100)), ("overworld", (800, 150, 800)),
("the_nether", (-300, 100, 200)), ("overworld", (-2400, 150, 1600))],
"end": [("the_end", (100, 80, 0)), ("overworld", (900, 150, -900)),
("the_end", (-300, 80, 400)), ("overworld", (-1200, 150, 900))],
}
for dim, (x, y, z) in trips[name]:
# Through chat, as a player types it: `command` waits on the server thread in the frame.
add(record(control, lambda: control(f"chat /execute in minecraft:{dim} run tp @s {x} {y} {z}"), 8),
f"{dim} {x}")
state = living_player(control, name)
if state.get("dimension") != f"minecraft:{dim}" or not state["player"].get("flying"):
raise RuntimeError(f"{name}: expected a flying player in minecraft:{dim}, "
f"got {state.get('dimension')} (flying={state['player'].get('flying')})")
elif name == "weather":
x, y, z = 4000, 150, 4000
control(f"command execute in minecraft:overworld run tp @s {x} {y} {z}")
control(f"fly {x} {y} {z} {x} {y} {z} 0.1 10")
pb.wait_loaded(control, lambda p: True, 300, pb.view_chunks(distance))
time.sleep(2)
for step, command in [("rain", "weather rain"), ("thunder", "weather thunder"),
("lightning", f"summon minecraft:lightning_bolt {x + 8} {y - 20} {z + 8}"),
("clear", "weather clear")]:
add(record(control, lambda: control(f"chat /{command}"), 4), step)
elif name == "post":
x, y, z = 5600, 200, 5600
control(f"command execute in minecraft:overworld run tp @s {x} {y} {z} 0 10")
control(f"fly {x} {y} {z} {x} {y} {z} 0.1 10")
pb.wait_loaded(control, lambda p: True, 300, pb.view_chunks(distance))
time.sleep(2)
for i, mob in enumerate(["creeper", "spider", "enderman", "cave_spider"]):
control(f"chat /summon minecraft:{mob} {x + 3 * i} {y} {z + 6} {{NoAI:1b,NoGravity:1b,Tags:[\"p{i}\"]}}")
time.sleep(1)
add(record(control, lambda: control("chat /effect give @e[tag=p0] minecraft:glowing 60"), 2), "glowing")
add(record(control, lambda: control("chat /gamemode spectator"), 2), "spectator")
for i, mob in enumerate(["creeper", "spider", "enderman", "cave_spider"]):
add(record(control, lambda: control(f"chat /spectate @e[tag=p{i},limit=1]"), 2), f"spectate {mob}")
add(record(control, lambda: control("chat /spectate"), 2), "stop")
control("chat /gamemode creative")
elif name == "particles":
x, y, z = 5000, 200, 5000
control(f"command execute in minecraft:overworld run tp @s {x} {y} {z} 0 10")
control(f"fly {x} {y} {z} {x} {y} {z} 0.1 10")
pb.wait_loaded(control, lambda p: True, 300, pb.view_chunks(distance))
time.sleep(2)
for kind in PARTICLES:
add(record(control, lambda: control(f"chat /particle minecraft:{kind} {x} {y} {z + 5} 1 1 1 0.05 60 force"),
0.6), kind)
elif name == "portal":
living_player(control, name)
# A creative player crosses a portal after one tick in it (Player.getPortalWaitTime).
for x, y, z in [(1200, 120, -1200), (-1800, 120, 600)]:
control(f"command execute in minecraft:overworld run tp @s {x + 3} {y} {z}")
control(f"fly {x + 3} {y} {z} {x + 3} {y} {z} 0.1 10")
pb.wait_loaded(control, lambda p: True, 300, pb.view_chunks(distance))
control(f"command fill {x} {y} {z - 1} {x} {y + 4} {z + 2} minecraft:obsidian")
control(f"command fill {x} {y + 1} {z} {x} {y + 3} {z + 1} minecraft:nether_portal[axis=z]")
time.sleep(1)
add(record(control, lambda: control(f"tp {x + 0.5} {y + 1} {z + 1.0}"), 8))
dimension = str(living_player(control, name).get("dimension"))
if not dimension.endswith("the_nether"):
raise RuntimeError(f"the portal at {x} {y} {z} led to {dimension}, not the Nether")
elif name in ("mobs", "items", "block_entities"):
# Hovering in the air facing south; each thing appears 6-12 blocks ahead, in a new spot.
x, y, z = 6000, 200, 6000
control(f"command execute in minecraft:overworld run tp @s {x} {y} {z} 0 10")
control(f"fly {x} {y} {z} {x} {y} {z} 0.1 10")
pb.wait_loaded(control, lambda p: True, 300, pb.view_chunks(distance))
time.sleep(2)
things = {"mobs": MOBS, "items": ITEMS, "block_entities": BLOCK_ENTITIES}[name]
for i, thing in enumerate(things):
at = (x - 12 + (i % 8) * 3, y - 1 + (i // 8) % 3 * 2, z + 6 + i // 24 * 4)
if name == "mobs":
command = f"summon minecraft:{thing} {at[0]} {at[1]} {at[2]} {{NoAI:1b,NoGravity:1b,Silent:1b}}"
elif name == "items":
command = (f"summon minecraft:item {at[0]} {at[1]} {at[2]} "
f"{{NoGravity:1b,PickupDelay:32767,Age:-32768,Item:{{id:\"minecraft:{thing}\",count:1}}}}")
else:
command = f"setblock {at[0]} {at[1]} {at[2]} minecraft:{thing}"
# Through chat, as a player types it: `command` waits on the server thread in the frame.
add(record(control, lambda: control(f"chat /{command}"), 0.6))
elif name == "screens":
# First opens: the creative inventory and each of its tabs, then (in survival) the inventory
# and its recipe book; each opened a second time to compare. Tabs and the recipe book button
# are clicked where vanilla puts them at GUI scale 3 of 1280x720 (gui 427x240).
left, top = (427 - 195) // 2, (240 - 136) // 2
tabs = [(left + 27 * i + 13, top - 12) for i in range(6)] + \
[(left + 27 * i + 13, top + 136 + 12) for i in range(6)]
for n in (1, 2):
add(record(control, lambda: control("key e tap"), 1), f"creative inventory, open {n}")
for i, (tx, ty) in enumerate(tabs):
add(record(control, lambda: control(f"click-at {tx} {ty}"), 0.6), f"tab {i}, open {n}")
control("key esc tap")
time.sleep(0.5)
control("chat /gamemode survival")
time.sleep(1)
for n in (1, 2):
add(record(control, lambda: control("key e tap"), 1), f"inventory, open {n}")
add(record(control, lambda: control("click-at 239 107"), 1), f"recipe book shown, open {n}")
add(record(control, lambda: control("click-at 239 107"), 0.6), f"recipe book hidden, open {n}")
control("key esc tap")
time.sleep(0.5)
control("chat /gamemode creative")
elif name == "reload":
# F3+T: every resource reloaded, as a player presses it.
def reload():
control("key f3 down")
control("key t tap")
control("key f3 up")
add(record(control, reload, 15), "F3+T")
elif name in WORLD_CHANGES:
for step, result in world_change(name, control, distance, lambda action, seconds: record(control, action, seconds)):
add(result, step)
elif name == "inventory":
def spam():
for _ in range(20):
control("open-inventory")
time.sleep(0.15)
control("key esc tap")
time.sleep(0.15)
for _ in range(20):
control("pause")
time.sleep(0.15)
control("resume")
time.sleep(0.15)
add(record(control, spam, 0.5))
return frames, cpu
def world_change(name, control, distance, record):
"""Builds `name`'s setup in the air (y 200, away from terrain) in front of the camera, then
records it running: a list of (step, (frame times, cpu times))."""
cx, y, cz = 9000 + 300 * WORLD_CHANGES.index(name), 200, 9000
control(f"command execute in minecraft:overworld run tp @s {cx} {y + 22} {cz - 34} 0 30")
control(f"fly {cx} {y + 22} {cz - 34} {cx} {y + 22} {cz - 34} 0.1 30")
pb.wait_loaded(control, lambda p: True, 300, pb.view_chunks(distance))
# Typed in chat, as a player does: `command` blocks the frame on the server tick.
def chat(command):
control(f"chat /{command}")
time.sleep(0.06)
steps = []
if name == "tnt":
chat(f"fill {cx - 10} {y - 10} {cz - 10} {cx + 10} {y - 1} {cz + 10} minecraft:stone")
chat(f"fill {cx - 2} {y} {cz - 2} {cx + 2} {y + 1} {cz + 2} minecraft:tnt")
time.sleep(2)
steps.append(("50 TNT", record(lambda: chat(f"setblock {cx + 3} {y} {cz} minecraft:redstone_block"), 10)))
elif name == "fill":
fills = [(y, y + 7), (y + 8, y + 15)]
steps.append(("fill 64x64x16 stone", record(lambda: [chat(
f"fill {cx - 32} {lo} {cz - 32} {cx + 31} {hi} {cz + 31} minecraft:stone") for lo, hi in fills], 5)))
steps.append(("clear it", record(lambda: [chat(
f"fill {cx - 32} {lo} {cz - 32} {cx + 31} {hi} {cz + 31} minecraft:air") for lo, hi in fills], 5)))
elif name == "fluids":
# Two 9-wide slopes falling 16 blocks over 16, side by side: water on one, lava on the other.
for i in range(16):
chat(f"fill {cx - 14} {y - 20} {cz + i} {cx + 14} {y - i} {cz + i} minecraft:stone")
chat(f"fill {cx - 14} {y - 20} {cz + 16} {cx + 14} {y - 15} {cz + 30} minecraft:stone")
time.sleep(2)
steps.append(("water", record(lambda: chat(f"fill {cx - 13} {y + 1} {cz} {cx - 5} {y + 1} {cz} minecraft:water"), 15)))
steps.append(("lava", record(lambda: chat(f"fill {cx + 5} {y + 1} {cz} {cx + 13} {y + 1} {cz} minecraft:lava"), 15)))
elif name == "fire":
# A 25x25 grove: grass, a log every 4 blocks, persistent leaves around them.
chat(f"fill {cx - 12} {y - 1} {cz - 12} {cx + 12} {y - 1} {cz + 12} minecraft:grass_block")
chat(f"fill {cx - 12} {y + 3} {cz - 12} {cx + 12} {y + 6} {cz + 12} minecraft:oak_leaves[persistent=true]")
for x in range(cx - 12, cx + 13, 4):
for z in range(cz - 12, cz + 13, 4):
chat(f"fill {x} {y} {z} {x} {y + 5} {z} minecraft:oak_log")
time.sleep(2)
spots = [(cx - 8, cz - 8), (cx + 8, cz - 8), (cx, cz), (cx - 8, cz + 8), (cx + 8, cz + 8)]
steps.append(("fire spreading", record(lambda: [chat(f"setblock {x} {y + 7} {z} minecraft:fire")
for x, z in spots], 25)))
elif name == "pistons":
# Two observers facing each other pulse every other tick into a dust line each way; each
# line powers the stone it lies on, and 10 pistons beside that stone push a block.
chat(f"fill {cx - 12} {y - 1} {cz - 1} {cx + 12} {y - 1} {cz + 3} minecraft:stone")
for xs in (range(cx + 1, cx + 11), range(cx - 11, cx - 1)):
for x in xs:
chat(f"setblock {x} {y} {cz} minecraft:stone")
chat(f"setblock {x} {y + 1} {cz} minecraft:redstone_wire")
chat(f"setblock {x} {y} {cz + 1} minecraft:piston[facing=south]")
chat(f"setblock {x} {y} {cz + 2} minecraft:oak_planks")
chat(f"setblock {cx} {y} {cz} minecraft:stone")
time.sleep(2)
def start():
chat(f"setblock {cx} {y + 1} {cz} minecraft:observer[facing=west]")
chat(f"setblock {cx - 1} {y + 1} {cz} minecraft:observer[facing=east]")
steps.append(("20-piston clock", record(start, 15)))
return steps
def launch(args, distance, tag):
"""A fresh game on its title screen: (process, control, log path, log file, game dir, socket)."""
import shutil
game_dir = WORK / f"game-{args.label}-rd{distance}{tag}"
shutil.rmtree(game_dir, ignore_errors=True)
pb.write_options(game_dir, distance)
if args.max_fps:
options = game_dir / "options.txt"
text = options.read_text()
if "maxFps:260\n" not in text:
raise RuntimeError("perf_bench's options no longer set maxFps:260")
options.write_text(text.replace("maxFps:260\n", f"maxFps:{args.max_fps}\n"))
if args.vsync:
options = game_dir / "options.txt"
text = options.read_text()
if "enableVsync:false\n" not in text:
raise RuntimeError("perf_bench's options no longer set enableVsync:false")
options.write_text(text.replace("enableVsync:false\n", "enableVsync:true\n"))
sock = Path("/tmp") / f"horizon-pacing-{os.getpid()}{tag}.sock"
sock.unlink(missing_ok=True)
client = Path(args.build).resolve() / "horizon-client"
command = [str(client), "--resources", str(Path(args.resources).resolve()), "--control-socket", str(sock),
"--no-pause", "--weather", "clear", "--time", "6000"]
if args.size:
width, _, height = args.size.partition("x")
command += ["--width", width, "--height", height]
if args.offscreen:
command.append("--offscreen")
env = lab.game_env(HORIZON_TELEMETRY_URL="off", HORIZON_SPIKES=str(args.spike_ms))
for kv in args.env:
k, _, v = kv.partition("=")
env[k] = v
log_path = WORK / f"{args.label}-rd{distance}{tag}.log"
log = log_path.open("w")
proc = subprocess.Popen(command, cwd=game_dir, env=env, stdout=log, stderr=subprocess.STDOUT)
control = pb.Control(sock)
pb.poll(control, lambda s: s.get("screen") == "title" and s.get("renderer"), 300)
return proc, control, log_path, log, game_dir, sock
def dedicated_server(args, distance, directory):
"""A dedicated server of the same build (seed 1234, offline, the client's view distance), once it is up:
(server, port)."""
from test_dedicated_server_parity import Server
directory.mkdir(parents=True, exist_ok=True)
port = lab.free_port()
properties = {
"online-mode": "false", "white-list": "false", "level-seed": str(pb.SEED), "gamemode": "creative",
"spawn-protection": "0", "view-distance": str(distance), "simulation-distance": "12",
"pause-when-empty-seconds": "-1", "server-port": str(port),
}
(directory / "server.properties").write_text("".join(f"{k}={v}\n" for k, v in properties.items()),
encoding="iso-8859-1")
(directory / "eula.txt").write_text("eula=true\n")
binary = Path(args.build).resolve() / "horizon-client"
server = Server([binary, "--resources", Path(args.resources).resolve(), "--dedicated", "--nogui"],
directory, "server")
server.wait_for(r"Done \(", 900)
return server, port
def play(args, distance, names, result):
"""One game through `names`: in a fresh world, or (`join` alone) joining a dedicated server."""
import shutil
joining = names == ["join"]
proc, control, log_path, log, game_dir, sock = launch(args, distance, "-join" if joining else "")
server = None
try:
if joining:
server, port = dedicated_server(args, distance, WORK / f"server-{args.label}-rd{distance}")
else:
control(f"open {pb.WORLD} {pb.SEED} creative")
pb.wait_loaded(control, lambda p: True, args.timeout, pb.view_chunks(distance))
for name in names:
mark = log_path.stat().st_size
if name == "join":
control("stats reset")
control(f"connect 127.0.0.1:{port}")
pb.wait_loaded(control, lambda p: True, args.timeout, pb.view_chunks(distance))
time.sleep(5)
reply = control("stats raw reset")
frames, cpu = reply.get("frame_ms") or [], reply.get("cpu_ms") or []
else:
frames, cpu = scenario(name, control, distance)
log.flush()
with log_path.open(errors="replace") as f:
f.seek(mark)
spikes = [line.strip() for line in f if "slow frame" in line]
spikes.sort(key=lambda line: -spike_ms(line))
entry = stats(frames)
entry["cpu"] = stats(cpu)
entry["slowest_spans"] = spikes[:12]
entry["spike_lines"] = len(spikes)
entry["frame_ms"] = [round(v, 2) for v in frames]
result["scenarios"][name] = entry
print(f"rd{distance} {name}: " + ", ".join(f"{k} {v}" for k, v in entry.items()
if k not in ("cpu", "slowest_spans", "frame_ms")), flush=True)
finally:
try:
control("quit", timeout=60)
except (OSError, ValueError, RuntimeError):
pass
pb.stop(proc)
log.close()
sock.unlink(missing_ok=True)
shutil.rmtree(game_dir, ignore_errors=True)
if server:
server.stop()
shutil.rmtree(WORK / f"server-{args.label}-rd{distance}", ignore_errors=True)
def run(args, distance):
names = args.scenarios.split(",")
result = {"label": args.label, "distance": distance, "load_before": pb.load_average(), "scenarios": {}}
in_world = [n for n in names if n != "join"]
if in_world:
play(args, distance, in_world, result)
if "join" in names:
play(args, distance, ["join"], result)
result["load_after"] = pb.load_average()
return result
COLUMNS = [("p50_ms", "p50"), ("p99_ms", "p99"), ("p999_ms", "p99.9"), ("max_ms", "max"),
("low_1pct_fps", "1% low"), ("low_01pct_fps", "0.1% low"), ("over_33ms", ">33 ms")]
def compare(labels):
"""Markdown table per scenario: the median over a label's runs (files <label>-rd<N>*.json)."""
runs = {}
for label in labels:
for path in sorted(WORK.glob(f"{label}-rd*.json")):
data = json.loads(path.read_text())
if data["label"] != label:
continue
runs.setdefault((data["distance"], label), []).append(data)
for distance in sorted({d for d, _ in runs}):
names = [n for n in SCENARIOS if any(n in r["scenarios"] for (d, _), rs in runs.items() if d == distance
for r in rs)]
for name in names:
print(f"\n**RD {distance}, {name}** (median of n runs)\n")
print("| build | n | " + " | ".join(c for _, c in COLUMNS) + " |")
print("|---|---|" + "---|" * len(COLUMNS))
for label in labels:
entries = [r["scenarios"][name] for r in runs.get((distance, label), []) if name in r["scenarios"]]
if not entries:
continue
cells = []
for key, _ in COLUMNS:
values = sorted(e.get(key, 0) for e in entries)
cells.append(f"{values[len(values) // 2]:g}")
print(f"| {label} | {len(entries)} | " + " | ".join(cells) + " |")
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--label", default="horizon")
parser.add_argument("--build", default=str(lab.ROOT / "target/fast"))
parser.add_argument("--resources", default=str(lab.ROOT / ".local/horizon-client/26.3"))
parser.add_argument("--distances", default="32")
parser.add_argument("--scenarios", default=",".join(SCENARIOS))
parser.add_argument("--timeout", type=float, default=600)
parser.add_argument("--spike-ms", type=float, default=20.0, help="HORIZON_SPIKES threshold (CPU ms)")
parser.add_argument("--offscreen", action="store_true")
parser.add_argument("--max-fps", type=int, help="frame cap (options.txt maxFps; default 260 = none): measures "
"the frame limiter's pacing, and at a player's 60 each frame takes in more streamed work")
parser.add_argument("--vsync", action="store_true", help="VSync on (options.txt enableVsync), in a window: "
"frames wait for the display's refresh, as most players' do")
parser.add_argument("--size", help="window size WxH (default the game's 1280x720); a small one keeps a software "
"renderer (lavapipe) from hiding the CPU side")
parser.add_argument("--env", action="append", default=[], help="KEY=VALUE for the game")
parser.add_argument("--run", type=int, default=0, help="run number, kept in the file name")
parser.add_argument("--compare", nargs="+", metavar="LABEL")
args = parser.parse_args()
if args.compare:
compare(args.compare)
return
WORK.mkdir(parents=True, exist_ok=True)
for distance in map(int, args.distances.split(",")):
result = run(args, distance)
out = WORK / f"{args.label}-rd{distance}-{args.run}.json"
out.write_text(json.dumps(result))
print(f"wrote {out}")
if __name__ == "__main__":
main()