@@ -0,0 +1,589 @@
#!/usr/bin/env python3
""" Графический полигон HD (#44): фиксированные сцены, сравнение кадров, мерцание и GPU-бюджет.
python3 tools/hd_bench.py --list # сцены и их параметры
python3 tools/hd_bench.py # все сцены -> run-home-bench/out/<время>/
python3 tools/hd_bench.py --only polygon_noon # одна сцена (годится и при занятой игре)
python3 tools/hd_bench.py --env HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off
python3 tools/hd_bench.py --baseline run-home-bench/out/before --out run-home-bench/out/after
Каждый прогон: свежая копия миров Polygon и Hills из run-home/saves в run-home-bench/home, мод airdef,
игра офскрин на сокете run-home-bench/c.sock (одна за раз; чужой игре run-home/ctl.sock не мешаем).
Для каждой сцены: время суток, ясная погода, телепорт, пауза на устаканивание, кадр, ещё один кадр
(мерцание), `bench 4` (GPU avg, мс) и средние проходы из run-home-bench/c.log при HORIZON_SHADER_PACK_TIMING=1.
Итог: report.md и results.json в папке вывода.
Коды выхода: 0 — всё в порогах и бюджете; 1 — есть превышение; 2 — ошибка запуска или аргументов.
Только стандартная библиотека; при установленном Pillow сравнение быстрее.
"""
import argparse
import datetime
import json
import os
import re
import shutil
import struct
import subprocess
import sys
import time
import zlib
from dataclasses import asdict , dataclass
from pathlib import Path
ROOT = Path ( __file__ ) . resolve ( ) . parent . parent
BENCH = ROOT / " run-home-bench "
SOCK = BENCH / " c.sock " # короткий путь: лимит Unix-сокета около 104 байт
LOG = SOCK . with_suffix ( " .log " ) # лог игры рядом с сокетом (как ctl.log в run-home)
HOME = BENCH / " home " # HORIZON_HOME одного прогона, пересоздаётся каждый раз
CTL = ROOT / " horizon " / " target " / " fast " / " horizon-ctl "
SAVES = ROOT / " run-home " / " saves "
OTHER_SOCK = " run-home/ctl.sock " # чужая игра (другой агент): только читаем, не трогаем
DIFF_LEVEL = 20 # порог «заметной» разницы по каналу, из 255
DIFF_GAIN = 6 # усиление разницы в diff-картинках
TIMING_RE = re . compile ( r " shader pack GPU time per pass .*?: (.*) = ([ \ d.]+) \ s*$ " )
@dataclass
class Shot :
name : str
world : str
time : int # время суток: 0 рассвет, 1000 утро, 6000 полдень, 12000 закат, 18000 полночь
pose : tuple # x, y, z, yaw, pitch (телепорт `tp @s`)
note : str
# Позы проверены в игре: полигон с техникой, река Hills (-26.5 72 0.5, взгляд на реку и холм), лес Hills на восток
# перед сравнением и поправь здесь, если кадр не то, что написано в note.
SHOTS = [
Shot ( " polygon_noon " , " Polygon " , 6000 , ( 40.5 , 76.0 , 40.5 , 135.0 , 25.0 ) ,
" полигон с техникой, полдень, белые материалы " ) ,
Shot ( " hills_river_noon " , " Hills " , 6000 , ( - 26.5 , 72.0 , 0.5 , - 150.0 , 8.0 ) ,
" река на Hills, полдень: вода, преломление, блики " ) ,
Shot ( " hills_river_sunset " , " Hills " , 12000 , ( - 26.5 , 72.0 , 0.5 , - 150.0 , 8.0 ) ,
" река на Hills, закат: тинт неба и воды " ) ,
Shot ( " hills_river_night " , " Hills " , 18000 , ( - 26.5 , 72.0 , 0.5 , - 150.0 , 8.0 ) ,
" река на Hills, ночь: луна, туман " ) ,
Shot ( " forest_morning_sun " , " Hills " , 1000 , ( - 26.5 , 71.0 , 0.5 , - 90.0 , 3.0 ) ,
" лес на Hills, утро, взгляд на восток (солнце ~15 градусов над горизонтом) " ) ,
Shot ( " sky_noon " , " Polygon " , 6000 , ( 40.5 , 76.0 , 40.5 , 0.0 , - 40.0 ) ,
" небо и облака, полдень, взгляд вверх " ) ,
]
class BenchError ( RuntimeError ) :
pass
def die ( msg , code = 2 ) :
print ( f " hd_bench: { msg } " , file = sys . stderr )
sys . exit ( code )
# --- процессы и игра -------------------------------------------------------------------------
def pgrep ( pattern ) :
return subprocess . run ( [ " pgrep " , " -f " , pattern ] , capture_output = True ) . returncode == 0
def ctl_ok ( * args ) :
r = subprocess . run ( [ str ( CTL ) , " --socket " , str ( SOCK ) , " --timeout " , " 3 " , * args ] ,
capture_output = True , text = True , timeout = 20 )
return r . returncode == 0
def bench_running ( ) :
""" Наша игра на c.sock: по командной строке (--control-socket) или по живому сокету. """
if pgrep ( str ( SOCK . relative_to ( ROOT ) ) ) :
return True
if SOCK . exists ( ) :
if ctl_ok ( " status " ) :
return True
SOCK . unlink ( ) # мёртвый сокет от прошлого прогона
return False
def ctl ( * args , timeout = 30.0 ) :
cmd = [ str ( CTL ) , " --socket " , str ( SOCK ) , " --timeout " , f " { timeout : g } " , * map ( str , args ) ]
try :
r = subprocess . run ( cmd , capture_output = True , text = True , timeout = timeout + 60 )
except subprocess . TimeoutExpired as e :
raise BenchError ( f " horizon-ctl { ' ' . join ( map ( str , args ) ) } : таймаут " ) from e
if r . returncode != 0 :
raise BenchError ( f " horizon-ctl { ' ' . join ( map ( str , args ) ) } : "
f " { ( r . stdout or r . stderr ) . strip ( ) [ : 300 ] } " )
return r . stdout . strip ( )
def game_env ( extra , timing ) :
env = dict ( os . environ )
env . update ( {
" HORIZON_HOME " : str ( HOME ) ,
" HORIZON_NO_AUDIO " : " 1 " ,
" HORIZON_LAUNCHER_NO_SELF_UPDATE " : " 1 " ,
} )
if timing :
env [ " HORIZON_SHADER_PACK_TIMING " ] = " 1 "
env . update ( extra )
return env
def prepare_home ( ) :
if HOME . exists ( ) :
shutil . rmtree ( HOME )
( HOME / " saves " ) . mkdir ( parents = True )
for world in sorted ( { s . world for s in SHOTS } ) :
src = SAVES / world
if not src . is_dir ( ) :
die ( f " нет мира { src } " )
shutil . copytree ( src , HOME / " saves " / world ,
ignore = shutil . ignore_patterns ( " session.lock " , " *.lock " ) )
def install_mod ( env ) :
subprocess . run ( [ sys . executable , str ( ROOT / " tools " / " airdef.py " ) , " install " ] ,
env = { * * env , " HORIZON_HOME " : str ( HOME ) } , check = True , cwd = ROOT )
def launch ( world , env , game , resources ) :
if SOCK . exists ( ) :
SOCK . unlink ( )
cmd = [ str ( CTL ) , " --socket " , str ( SOCK ) , " launch " , " --world " , world , " --offscreen " , " --wait " ,
" --game " , str ( game ) , " --resources " , str ( resources ) ]
r = subprocess . run ( cmd , capture_output = True , text = True , env = env , cwd = HOME , timeout = 600 )
if r . returncode != 0 :
raise BenchError ( f " launch { world } : { ( r . stdout or r . stderr ) . strip ( ) [ : 400 ] } " )
ctl ( " command " , " gamemode creative " )
ctl ( " key " , " t " , " tap " ) # чат, как игрок: /airdef shader hd
ctl ( " type " , " /airdef shader hd " )
ctl ( " key " , " return " , " tap " )
time . sleep ( 0.5 )
ctl ( " key " , " f1 " , " tap " ) # скрыть HUD (один раз на запуск)
def stop_game ( ) :
try :
ctl ( " quit " , timeout = 20 )
except BenchError :
pass
for _ in range ( 100 ) :
if not ctl_ok ( " status " ) :
break
time . sleep ( 0.2 )
if SOCK . exists ( ) and not ctl_ok ( " status " ) :
SOCK . unlink ( )
# --- кадры: PNG без Pillow и с ним -------------------------------------------------------------
def _paeth ( a , b , c ) :
p = a + b - c
pa , pb , pc = abs ( p - a ) , abs ( p - b ) , abs ( p - c )
return a if pa < = pb and pa < = pc else ( b if pb < = pc else c )
def read_png_rgb ( path ) :
""" (w, h, bytes RGB). Только 8-бит RGB/RGBA без чередования (так пишет игра). """
data = Path ( path ) . read_bytes ( )
if data [ : 8 ] != b " \x89 PNG \r \n \x1a \n " :
raise BenchError ( f " { path } : не PNG " )
pos , idat , w = 8 , [ ] , 0
while pos < len ( data ) :
( ln , ) = struct . unpack ( " >I " , data [ pos : pos + 4 ] )
kind = data [ pos + 4 : pos + 8 ]
body = data [ pos + 8 : pos + 8 + ln ]
pos + = 12 + ln
if kind == b " IHDR " :
w , h , depth , ctype , _ , _ , interlace = struct . unpack ( " >IIBBBBB " , body )
elif kind == b " IDAT " :
idat . append ( body )
elif kind == b " IEND " :
break
if depth != 8 or interlace or ctype not in ( 2 , 6 ) :
raise BenchError ( f " { path } : нужен 8-бит RGB/RGBA без чередования " )
bpp = 3 if ctype == 2 else 4
stride = w * bpp
raw = zlib . decompress ( b " " . join ( idat ) )
out = bytearray ( w * h * 3 )
prev = bytearray ( stride )
p = 0
for y in range ( h ) :
ft = raw [ p ]
line = raw [ p + 1 : p + 1 + stride ]
p + = 1 + stride
cur = bytearray ( stride )
for i in range ( stride ) :
x = line [ i ]
a = cur [ i - bpp ] if i > = bpp else 0
b = prev [ i ]
c = prev [ i - bpp ] if i > = bpp else 0
if ft == 1 :
x + = a
elif ft == 2 :
x + = b
elif ft == 3 :
x + = ( a + b ) >> 1
elif ft == 4 :
x + = _paeth ( a , b , c )
cur [ i ] = x & 255
base = y * w * 3
if bpp == 3 :
out [ base : base + w * 3 ] = cur
else :
out [ base : base + w * 3 : 3 ] = cur [ 0 : : 4 ]
out [ base + 1 : base + w * 3 : 3 ] = cur [ 1 : : 4 ]
out [ base + 2 : base + w * 3 : 3 ] = cur [ 2 : : 4 ]
prev = cur
return w , h , bytes ( out )
def write_png_rgb ( path , w , h , rgb ) :
raw = b " " . join ( b " \x00 " + rgb [ y * w * 3 : ( y + 1 ) * w * 3 ] for y in range ( h ) )
def chunk ( kind , body ) :
return struct . pack ( " >I " , len ( body ) ) + kind + body + struct . pack ( " >I " , zlib . crc32 ( kind + body ) & 0xFFFFFFFF )
Path ( path ) . write_bytes ( b " \x89 PNG \r \n \x1a \n "
+ chunk ( b " IHDR " , struct . pack ( " >IIBBBBB " , w , h , 8 , 2 , 0 , 0 , 0 ) )
+ chunk ( b " IDAT " , zlib . compress ( raw , 6 ) )
+ chunk ( b " IEND " , b " " ) )
try : # необязательно: быстрее декодирование и сравнение
from PIL import Image , ImageChops , ImageStat
except ImportError : # pragma: no cover
Image = None
def compare_frames ( a , b , diff_out = None ) :
""" Средняя разница по каналам (0..255) и доля пикселей с разницей > DIFF_LEVEL, в % . """
if Image is not None :
A = Image . open ( a ) . convert ( " RGB " )
B = Image . open ( b ) . convert ( " RGB " )
if A . size != B . size :
raise BenchError ( f " размеры кадров разные: { A . size } и { B . size } " )
d = ImageChops . difference ( A , B )
mean = sum ( ImageStat . Stat ( d ) . mean ) / 3
r , g , bl = d . split ( )
mx = ImageChops . lighter ( ImageChops . lighter ( r , g ) , bl )
hist = mx . histogram ( )
total = A . size [ 0 ] * A . size [ 1 ]
pct = 100.0 * sum ( hist [ DIFF_LEVEL + 1 : ] ) / total
if diff_out :
d . point ( lambda v : min ( 255 , v * DIFF_GAIN ) ) . save ( diff_out )
return { " mean " : mean , " pct " : pct , " size " : list ( A . size ) }
wa , ha , pa = read_png_rgb ( a )
wb , hb , pb = read_png_rgb ( b )
if ( wa , ha ) != ( wb , hb ) :
raise BenchError ( f " размеры кадров разные: { wa } x { ha } и { wb } x { hb } " )
total_abs = 0
over = 0
diff = bytearray ( len ( pa ) ) if diff_out else None
for i in range ( 0 , len ( pa ) , 3 ) :
dr = abs ( pa [ i ] - pb [ i ] )
dg = abs ( pa [ i + 1 ] - pb [ i + 1 ] )
db = abs ( pa [ i + 2 ] - pb [ i + 2 ] )
total_abs + = dr + dg + db
if max ( dr , dg , db ) > DIFF_LEVEL :
over + = 1
if diff is not None :
diff [ i ] = min ( 255 , dr * DIFF_GAIN )
diff [ i + 1 ] = min ( 255 , dg * DIFF_GAIN )
diff [ i + 2 ] = min ( 255 , db * DIFF_GAIN )
if diff_out :
write_png_rgb ( diff_out , wa , ha , bytes ( diff ) )
npx = wa * ha
return { " mean " : total_abs / ( 3.0 * npx ) , " pct " : 100.0 * over / npx , " size " : [ wa , ha ] }
# --- тайминги -----------------------------------------------------------------------------------
def read_from ( path , offset ) :
try :
with open ( path , " rb " ) as f :
f . seek ( offset )
return f . read ( ) . decode ( " utf-8 " , " replace " )
except FileNotFoundError :
return " "
def parse_pass_reports ( text ) :
reports = [ ]
for line in text . splitlines ( ) :
m = TIMING_RE . search ( line )
if not m :
continue
passes = { }
for part in m . group ( 1 ) . split ( " , " ) :
name , _ , ms = part . rpartition ( " " )
try :
passes [ name ] = float ( ms )
except ValueError :
pass
reports . append ( ( passes , float ( m . group ( 2 ) ) ) )
return reports
def average_passes ( reports ) :
if not reports :
return { } , None
sums , counts = { } , { }
for passes , _ in reports :
for name , ms in passes . items ( ) :
sums [ name ] = sums . get ( name , 0.0 ) + ms
counts [ name ] = counts . get ( name , 0 ) + 1
avg = { n : sums [ n ] / counts [ n ] for n in sums }
total = sum ( avg . values ( ) )
return avg , total
def bench_gpu_avg ( reply ) :
try :
return float ( json . loads ( reply ) [ " gpu " ] [ " avg_ms " ] )
except ( ValueError , KeyError , TypeError ) :
return None
# --- сцена ------------------------------------------------------------------------------------
def run_shot ( shot , out , settle , bench_secs , timing ) :
x , y , z , yaw , pitch = shot . pose
ctl ( " command " , f " time set { shot . time } " )
ctl ( " command " , " weather clear " )
ctl ( " command " , f " tp @s { x } { y } { z } { yaw } { pitch } " )
ctl ( " wait-ready " , 120 , timeout = 150 )
time . sleep ( settle )
first = out / f " { shot . name } .png "
ctl ( " screenshot " , str ( first . resolve ( ) ) )
time . sleep ( 0.5 )
second = out / f " { shot . name } _still2.png "
ctl ( " screenshot " , str ( second . resolve ( ) ) )
result = { " frame " : first . name , " still2 " : second . name , " gpu_avg_ms " : None , " passes " : { } ,
" pass_total_ms " : None , " bench_json " : None }
offset = LOG . stat ( ) . st_size if LOG . exists ( ) else 0
reply = ctl ( " bench " , bench_secs , timeout = bench_secs + 60 )
( out / f " { shot . name } _bench.json " ) . write_text ( reply + " \n " )
result [ " bench_json " ] = f " { shot . name } _bench.json "
result [ " gpu_avg_ms " ] = bench_gpu_avg ( reply )
if timing :
time . sleep ( 1.0 ) # последний отчёт о проходах
reports = parse_pass_reports ( read_from ( LOG , offset ) )
result [ " passes " ] , result [ " pass_total_ms " ] = average_passes ( reports )
if not reports :
result [ " warning " ] = " нет строк проходов в логе (мало таймед-кадров?) "
return result
def group_by_world ( shots ) :
order = [ ]
for s in shots :
if s . world not in order :
order . append ( s . world )
return [ ( w , [ s for s in shots if s . world == w ] ) for w in order ]
def git_rev ( ) :
r = subprocess . run ( [ " git " , " -C " , str ( ROOT ) , " rev-parse " , " --short " , " HEAD " ] ,
capture_output = True , text = True )
return r . stdout . strip ( ) or " ? "
def judge ( entry , args ) :
reasons = [ ]
if entry . get ( " error " ) :
reasons . append ( " ошибка: " + entry [ " error " ] )
gpu = entry . get ( " gpu_avg_ms " )
if gpu is not None and gpu > args . budget_ms :
reasons . append ( f " GPU { gpu : .2f } мс > бюджета { args . budget_ms : g } " )
if gpu is None and not entry . get ( " error " ) :
reasons . append ( " нет GPU-таймингов (bench) " )
fl = entry . get ( " flicker " )
if fl and fl [ " pct " ] > args . max_flicker :
reasons . append ( f " мерцание { fl [ ' pct ' ] : .2f } % > { args . max_flicker : g } % " )
base = entry . get ( " vs_baseline " )
if base :
if base . get ( " error " ) :
reasons . append ( base [ " error " ] )
else :
if base [ " mean " ] > args . max_mean :
reasons . append ( f " разница с базой { base [ ' mean ' ] : .2f } > { args . max_mean : g } " )
if base [ " pct " ] > args . max_pct :
reasons . append ( f " пикселей >20/255 { base [ ' pct ' ] : .2f } % > { args . max_pct : g } % " )
return reasons
def write_report ( out , entries , args , env_extra , size , baseline ) :
lines = [ ]
add = lines . append
add ( f " # HD-полигон: { out . name } " )
add ( " " )
add ( f " - коммит: ` { git_rev ( ) } `, дата: { datetime . datetime . now ( ) : %Y-%m-%d %H:%M } " )
add ( f " - разрешение кадра: { size [ 0 ] } x { size [ 1 ] } " if size else " - разрешение кадра: ? " )
add ( f " - окружение игры: " + ( " , " . join ( f " ` { k } = { v } ` " for k , v in sorted ( env_extra . items ( ) ) ) or " по умолчанию " ) )
add ( f " - бюджет GPU: { args . budget_ms : g } мс (bench { args . bench_secs : g } с), мерцание <= { args . max_flicker : g } % " )
if baseline :
add ( f " - сравнение с : ` { baseline } ` (средняя разница <= { args . max_mean : g } , пикселей >20/255 <= { args . max_pct : g } %) " )
add ( " " )
add ( " | сцена | мир | время | GPU avg, мс | бюджет | мерцание % >20 | к базе: mean | к базе: % >20 | итог | " )
add ( " |---|---|---|---|---|---|---|---|---| " )
for e in entries :
gpu = f " { e [ ' gpu_avg_ms ' ] : .2f } " if e . get ( " gpu_avg_ms " ) is not None else " - "
fl = f " { e [ ' flicker ' ] [ ' pct ' ] : .2f } " if e . get ( " flicker " ) else " - "
b = e . get ( " vs_baseline " ) or { }
bm = f " { b [ ' mean ' ] : .2f } " if " mean " in b else " - "
bp = f " { b [ ' pct ' ] : .2f } " if " pct " in b else " - "
status = " OK " if not e [ " fail " ] else " FAIL: " + " ; " . join ( e [ " fail " ] )
budget = " да " if e . get ( " gpu_avg_ms " ) is not None and e [ " gpu_avg_ms " ] < = args . budget_ms else ( " нет " if e . get ( " gpu_avg_ms " ) is not None else " - " )
add ( f " | { e [ ' name ' ] } | { e [ ' world ' ] } | { e [ ' time ' ] } | { gpu } | { budget } | { fl } | { bm } | { bp } | { status } | " )
passes = [ ]
for e in entries :
for p in e . get ( " passes " , { } ) :
if p not in passes :
passes . append ( p )
if passes :
add ( " " )
add ( " ## Проходы GPU, среднее по отчётам лога, мс " )
add ( " " )
add ( " | проход | " + " | " . join ( e [ " name " ] for e in entries ) + " | " )
add ( " |---| " + " ---| " * len ( entries ) )
for p in passes :
add ( f " | { p } | " + " | " . join ( f " { e [ ' passes ' ] [ p ] : .3f } " if p in e . get ( " passes " , { } ) else " - " for e in entries ) + " | " )
totals = [ f " { e [ ' pass_total_ms ' ] : .3f } " if e . get ( " pass_total_ms " ) is not None else " - " for e in entries ]
add ( " | **итого** | " + " | " . join ( totals ) + " | " )
add ( " " )
add ( " ## Кадры " )
for e in entries :
add ( " " )
add ( f " ### { e [ ' name ' ] } : { e [ ' note ' ] } " )
add ( " " )
add ( f " ![ { e [ ' name ' ] } ]( { e [ ' frame ' ] } ) " )
if e . get ( " diff_image " ) :
add ( " " )
add ( f " Разница с базой (x { DIFF_GAIN } ):  " )
if e . get ( " warning " ) :
add ( " " )
add ( f " > { e [ ' warning ' ] } " )
if e . get ( " error " ) :
add ( " " )
add ( f " > ошибка: { e [ ' error ' ] } " )
( out / " report.md " ) . write_text ( " \n " . join ( lines ) + " \n " )
def parse_args ( argv ) :
p = argparse . ArgumentParser ( description = " Графический полигон HD: кадры, мерцание, GPU-бюджет. " )
p . add_argument ( " --list " , action = " store_true " , help = " показать сцены и выйти " )
p . add_argument ( " --only " , action = " append " , default = [ ] , metavar = " СЦЕНА " , help = " только эти сцены (можно несколько раз) " )
p . add_argument ( " --out " , help = " папка вывода (по умолчанию run-home-bench/out/<время>) " )
p . add_argument ( " --baseline " , help = " папка прошлого прогона для сравнения кадров " )
p . add_argument ( " --env " , action = " append " , default = [ ] , metavar = " КЛЮЧ=ЗНАЧЕНИЕ " , help = " переменная для игры, например HORIZON_HD_TAA=0 " )
p . add_argument ( " --budget-ms " , type = float , default = 18.0 , help = " бюджет GPU на кадр, мс (по умолчанию 18) " )
p . add_argument ( " --bench-secs " , type = float , default = 4.0 , help = " сколько секунд bench (по умолчанию 4) " )
p . add_argument ( " --settle " , type = float , default = 4.0 , help = " пауза после телепорта, с (по умолчанию 4) " )
p . add_argument ( " --max-mean " , type = float , default = 3.0 , help = " порог средней разницы с базой, 0..255 " )
p . add_argument ( " --max-pct " , type = float , default = 5.0 , help = " порог пикселей >20/255 с базой, %% " )
p . add_argument ( " --max-flicker " , type = float , default = 5.0 , help = " порог мерцания (два кадра подряд), %% " )
p . add_argument ( " --no-pass-timing " , action = " store_true " , help = " не включать HORIZON_SHADER_PACK_TIMING " )
p . add_argument ( " --game " , help = " horizon-client (по умолчанию как в tools/airdef.py) " )
p . add_argument ( " --resources " , help = " подготовленные ресурсы Minecraft 26.3 " )
return p . parse_args ( argv )
def main ( argv = None ) :
args = parse_args ( argv )
if args . list :
for s in SHOTS :
print ( f " { s . name : 22 } { s . world : 8 } время { s . time : 5 } поза { s . pose } { s . note } " )
return 0
shots = [ s for s in SHOTS if not args . only or s . name in args . only ]
unknown = set ( args . only ) - { s . name for s in SHOTS }
if unknown :
die ( f " нет сцен: { ' , ' . join ( sorted ( unknown ) ) } (см. --list) " )
env_extra = { }
for item in args . env :
if " = " not in item :
die ( f " --env ждёт КЛЮЧ=ЗНАЧЕНИЕ, получено: { item } " )
k , v = item . split ( " = " , 1 )
env_extra [ k ] = v
if bench_running ( ) :
die ( " уже запущена игра на run-home-bench/c.sock: один прогон за раз " )
if pgrep ( OTHER_SOCK ) :
if len ( shots ) > 1 :
die ( f " работает чужая игра на { OTHER_SOCK } : запусти одну сцену (--only) или дождись конца теста " )
print ( f " hd_bench: внимание: работает чужая игра на { OTHER_SOCK } , снимок будет под нагрузкой " ,
file = sys . stderr )
out = Path ( args . out ) if args . out else BENCH / " out " / datetime . datetime . now ( ) . strftime ( " % Y % m %d - % H % M % S " )
out = out . resolve ( )
out . mkdir ( parents = True , exist_ok = True )
sys . path . insert ( 0 , str ( ROOT / " tools " ) )
import airdef # tools/airdef.py: ресурсы и путь к игре
game = Path ( args . game ) if args . game else airdef . built_game ( )
resources = Path ( args . resources ) if args . resources else airdef . resources ( )
timing = not args . no_pass_timing
env = game_env ( env_extra , timing )
prepare_home ( )
install_mod ( env )
entries = [ ]
baseline = Path ( args . baseline ) . resolve ( ) if args . baseline else None
try :
for world , group in group_by_world ( shots ) :
try :
launch ( world , env , game , resources )
except BenchError as e :
for s in group :
entries . append ( { * * asdict ( s ) , " error " : str ( e ) , " fail " : [ f " ошибка: { e } " ] } )
stop_game ( )
continue
for s in group :
entry = { * * asdict ( s ) , " frame " : f " { s . name } .png " }
try :
entry . update ( run_shot ( s , out , args . settle , args . bench_secs , timing ) )
fl = compare_frames ( out / f " { s . name } .png " , out / f " { s . name } _still2.png " )
entry [ " flicker " ] = fl
except BenchError as e :
entry [ " error " ] = str ( e )
if baseline and not entry . get ( " error " ) :
base_png = baseline / f " { s . name } .png "
if base_png . exists ( ) :
try :
diff_name = f " { s . name } _diff.png "
m = compare_frames ( base_png , out / f " { s . name } .png " , out / diff_name )
entry [ " vs_baseline " ] = m
entry [ " diff_image " ] = diff_name
except BenchError as e :
entry [ " vs_baseline " ] = { " error " : str ( e ) }
else :
entry [ " vs_baseline " ] = { " error " : f " в базе нет { base_png . name } " }
entry [ " fail " ] = judge ( entry , args )
entries . append ( entry )
stop_game ( )
finally :
stop_game ( )
size = None
for e in entries :
if e . get ( " vs_baseline " ) and " size " in e [ " vs_baseline " ] :
size = e [ " vs_baseline " ] [ " size " ]
break
if size is None and entries and entries [ 0 ] . get ( " flicker " ) :
size = entries [ 0 ] [ " flicker " ] [ " size " ]
results = { " out " : str ( out ) , " env " : env_extra , " budget_ms " : args . budget_ms ,
" baseline " : str ( baseline ) if baseline else None , " shots " : entries }
( out / " results.json " ) . write_text ( json . dumps ( results , ensure_ascii = False , indent = 2 ) + " \n " )
write_report ( out , entries , args , env_extra , size , str ( baseline ) if baseline else None )
failed = [ e for e in entries if e [ " fail " ] ]
for e in entries :
print ( f " { e [ ' name ' ] : 22 } GPU { e . get ( ' gpu_avg_ms ' ) if e . get ( ' gpu_avg_ms ' ) is None else round ( e [ ' gpu_avg_ms ' ] , 2 ) } мс "
f " мерцание { e [ ' flicker ' ] [ ' pct ' ] : .2f } % " if e . get ( " flicker " ) else f " { e [ ' name ' ] : 22 } { e . get ( ' error ' , ' ' ) } " )
for r in e [ " fail " ] :
print ( f " FAIL: { r } " )
print ( f " отчёт: { out / ' report.md ' } " )
return 1 if failed else 0
if __name__ == " __main__ " :
try :
sys . exit ( main ( ) )
except BenchError as e :
die ( str ( e ) , code = 2 )