Add bounded aircraft control and verify current packaged runtime closure
This commit is contained in:
@@ -23,7 +23,7 @@ try:dest.mkdir(parents=True,exist_ok=False)
|
||||
except FileExistsError:p.error('Evidence already exists; refusing to overwrite')
|
||||
for name in ['console.log','script.log','error.log','crash.log','rereintroduced-hq.log','headless-launch.log','runner.json','game-startup-report.json']:
|
||||
if (source/name).is_file():shutil.copyfile(source/name,dest/(name+'.txt'))
|
||||
for capture in sorted(source.glob('err-*.png')):
|
||||
for capture in sorted([*source.glob('err-*.png'), *source.glob('err-*.bmp')]):
|
||||
if capture.is_file():shutil.copyfile(capture,dest/capture.name)
|
||||
(dest/'source-sha256.json').write_bytes(manifest_bytes)
|
||||
(dest/PROVENANCE).write_bytes(encoded(provenance))
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ def check():
|
||||
assert set(mapped)==set(inventory['script_paths']), 'Original inventory has untracked or unknown entries'
|
||||
print(f'PASS: {len(mapped)} original script entries tracked across {len(feature_ids)} unfinished responsibilities')
|
||||
print(f'PASS: {len(ids)} isolated addons, acyclic dependencies, {len(resources)} unique resources, exact vanilla references')
|
||||
for name in ['test_participant_selection.py', 'test_native_shared_map_ownership.py', 'test_shared_map_ownership.py', 'test_intel_registration.py', 'test_mortar_activity_completion.py', 'test_packed_chat_game.py', 'test_native_mortar_refresh.py', 'test_archive_packed.py', 'test_mortar_refresh.py', 'test_packed_core_game.py', 'test_pak_inventory.py', 'test_native_mortar_salvo.py', 'test_spectator_guidance.py', 'test_native_package.py', 'test_item_station_teardown.py', 'test_source_bundle.py', 'test_native_flight_footprint.py', 'test_native_flight_input_diagnostics.py', 'test_mortar_logistics.py', 'test_mortar_courier_storage.py','test_powered_flight_pad.py','test_prison_escape_policy.py','test_powered_flight_policy.py','test_flight_action_runner.py','test_native_local_admission_probe.py','test_mortar_report_ledger.py','test_dedicated_native.py','test_native_mortar_transfer.py','test_mortar_aim_renewal.py','test_dedicated_preflight.py','test_mortar_transactions.py','test_native_mortar_probe.py','test_diagnostic_no_splash.py','test_casualty_incapacitation.py','test_casualty_remove_probe.py','test_extraction_placement_policy.py','test_guard_cancellation_policy.py','test_casualty_ground_policy.py','test_casualty_reservation_death.py','test_casualty_loading_policy.py','test_initial_guard_policy.py','test_headless_game_runner.py','test_vanilla_game_control.py','test_archive_game_control.py','test_site_discovery_policy.py','test_faction_catalog_scope.py','test_mission_rules.py','test_admin_setting_policy.py', 'test_player_admission_policy.py','test_progression_identity.py','test_discovery_map_position.py','test_local_admission.py','test_prison_placement_policy.py', 'test_recovery_policy.py','test_time_policy.py','test_environment_policy.py','test_environment_restart_runner.py', 'test_inventory_enumeration_policy.py','test_radio_defence_policy.py','test_radio_feedback_policy.py','test_performance_comparison.py','test_release_check.py','test_settings_migration.py','hq_network_regression.py','helicopter_availability_regression.py','mortar_salvo_regression.py']:
|
||||
for name in ['test_native_stats.py', 'test_aircraft_translation.py', 'test_spawn_retry_budget.py', 'test_action_feedback.py', 'test_native_aircraft_control.py', 'test_aircraft_flight_policy.py', 'test_aircraft_flight_envelope.py', 'test_ordinary_screenshot.py', 'test_participant_selection.py', 'test_native_shared_map_ownership.py', 'test_shared_map_ownership.py', 'test_intel_registration.py', 'test_mortar_activity_completion.py', 'test_packed_chat_game.py', 'test_native_mortar_refresh.py', 'test_archive_packed.py', 'test_mortar_refresh.py', 'test_packed_core_game.py', 'test_pak_inventory.py', 'test_native_mortar_salvo.py', 'test_spectator_guidance.py', 'test_native_package.py', 'test_item_station_teardown.py', 'test_source_bundle.py', 'test_native_flight_footprint.py', 'test_native_flight_input_diagnostics.py', 'test_mortar_logistics.py', 'test_mortar_courier_storage.py','test_powered_flight_pad.py','test_prison_escape_policy.py','test_powered_flight_policy.py','test_flight_action_runner.py','test_native_local_admission_probe.py','test_mortar_report_ledger.py','test_dedicated_native.py','test_native_mortar_transfer.py','test_mortar_aim_renewal.py','test_dedicated_preflight.py','test_mortar_transactions.py','test_native_mortar_probe.py','test_diagnostic_no_splash.py','test_casualty_incapacitation.py','test_casualty_remove_probe.py','test_extraction_placement_policy.py','test_guard_cancellation_policy.py','test_casualty_ground_policy.py','test_casualty_reservation_death.py','test_casualty_loading_policy.py','test_initial_guard_policy.py','test_headless_game_runner.py','test_vanilla_game_control.py','test_archive_game_control.py','test_site_discovery_policy.py','test_faction_catalog_scope.py','test_mission_rules.py','test_admin_setting_policy.py', 'test_player_admission_policy.py','test_progression_identity.py','test_discovery_map_position.py','test_local_admission.py','test_prison_placement_policy.py', 'test_recovery_policy.py','test_time_policy.py','test_environment_policy.py','test_environment_restart_runner.py', 'test_inventory_enumeration_policy.py','test_radio_defence_policy.py','test_radio_feedback_policy.py','test_performance_comparison.py','test_release_check.py','test_settings_migration.py','hq_network_regression.py','helicopter_availability_regression.py','mortar_salvo_regression.py']:
|
||||
subprocess.run([sys.executable,str(ROOT/'tests'/name)],check=True)
|
||||
print('All repository checks passed. Native compilation and multiplayer tests are separate requirements.')
|
||||
if __name__=='__main__':check()
|
||||
|
||||
@@ -5,6 +5,8 @@ import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
@@ -46,17 +48,68 @@ def sandbox(runtime, source, run):
|
||||
'--chdir', '/tmp/runtime']
|
||||
|
||||
|
||||
def engine_args(route):
|
||||
PROJECTS = {
|
||||
'core': ('2528466DE220D91C', '{AD62322D5213C440}Missions/ERR_Everon_Base.conf'),
|
||||
'chat': ('FA804F411BDDF2C6', '{C1E20667DCE324ED}Missions/ERR_Everon.conf'),
|
||||
}
|
||||
ADDON_GUIDS = {
|
||||
'core': PROJECTS['core'][0],
|
||||
'ai': '5192138CB9D5B911',
|
||||
'intel': 'D31039C7C53E348A',
|
||||
'map': '9AAEE9041D79809E',
|
||||
'chat': PROJECTS['chat'][0],
|
||||
}
|
||||
WORLD = '{B6C516F86302C679}Worlds/ERR_Everon.ent'
|
||||
SYSTEMS = '{C50579A2EC48E8EF}Configs/Systems/MissionSystems.conf'
|
||||
|
||||
|
||||
def engine_args(route, project='core'):
|
||||
if route not in ('header', 'diag-world', 'vanilla-everon') or project not in PROJECTS:
|
||||
raise ValueError('Unsupported diagnostic route or project')
|
||||
if route == 'vanilla-everon' and project != 'core':
|
||||
raise ValueError('vanilla-everon cannot be combined with --project chat')
|
||||
args = ['/tmp/runtime/ArmaReforgerServer' + ('Diag' if route == 'diag-world' else ''),
|
||||
'-profile', '/tmp/work/profile', '-noBackend', '-noSound', '-noSplash', '-noThrow',
|
||||
'-bindIP', '127.0.0.1', '-bindPort', '2001', '-maxFPS', '30', '-logStats', '5000']
|
||||
if route == 'vanilla-everon':
|
||||
return args + ['-server', 'Missions/21_GM_Eden.conf']
|
||||
args += ['-addonsDir', '/tmp/source/addons', '-addons', '2528466DE220D91C']
|
||||
addon, header = PROJECTS[project]
|
||||
args += ['-addonsDir', '/tmp/source/addons', '-addons', addon]
|
||||
if route == 'header':
|
||||
return args + ['-server', '{AD62322D5213C440}Missions/ERR_Everon_Base.conf']
|
||||
return args + ['-server', '{B6C516F86302C679}Worlds/ERR_Everon.ent',
|
||||
'-worldSystemsConfig', '{C50579A2EC48E8EF}Configs/Systems/MissionSystems.conf']
|
||||
return args + ['-server', header]
|
||||
return args + ['-server', WORLD, '-worldSystemsConfig', SYSTEMS]
|
||||
|
||||
|
||||
def preflight_addons(source, route, project='core'):
|
||||
"""Presence/identity checks only; native loading must validate RDB contents."""
|
||||
engine_args(route, project) # also reject incompatible direct helper calls
|
||||
if route == 'vanilla-everon':
|
||||
return
|
||||
addons = source / 'addons'
|
||||
if addons.is_symlink() or not addons.is_dir():
|
||||
raise ValueError('Expected a regular addons directory in diagnostic source')
|
||||
closure = ('core',) if project == 'core' else tuple(ADDON_GUIDS)
|
||||
for addon in closure:
|
||||
folder = addons / addon
|
||||
if folder.is_symlink() or not folder.is_dir():
|
||||
raise ValueError('Missing or linked required addon directory: ' + addon)
|
||||
for name in ('addon.gproj', 'resourceDatabase.rdb'):
|
||||
path = folder / name
|
||||
try:
|
||||
info = path.lstat()
|
||||
except FileNotFoundError as error:
|
||||
raise ValueError('Missing required addon input: ' + str(path)) from error
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size <= 0:
|
||||
raise ValueError('Required addon input must be a nonempty regular file: ' + str(path))
|
||||
project_file = folder / 'addon.gproj'
|
||||
if project_file.stat().st_size > 65536:
|
||||
raise ValueError('Unsupported oversized addon project: ' + str(project_file))
|
||||
try:
|
||||
definition = project_file.read_text(encoding='utf-8')
|
||||
except UnicodeDecodeError as error:
|
||||
raise ValueError('Addon project is not UTF-8: ' + str(project_file)) from error
|
||||
if re.findall(r'\bGUID\s+"([A-Fa-f0-9]{16})"', definition) != [ADDON_GUIDS[addon]]:
|
||||
raise ValueError('Required addon identity mismatch: ' + str(project_file))
|
||||
|
||||
|
||||
def main():
|
||||
@@ -65,8 +118,12 @@ def main():
|
||||
parser.add_argument('--runtime', type=Path, default=Path('/tmp/erri-dedicated-runtime/server'))
|
||||
parser.add_argument('--archive', type=Path, required=True)
|
||||
parser.add_argument('--route', choices=['header', 'diag-world', 'vanilla-everon'], default='header')
|
||||
parser.add_argument('--project', choices=['core', 'chat'], default='core',
|
||||
help='Core base (default) or Chat with all five declared successor addons')
|
||||
parser.add_argument('--seconds', type=int, default=90)
|
||||
args = parser.parse_args()
|
||||
if args.route == 'vanilla-everon' and args.project != 'core':
|
||||
parser.error('vanilla-everon cannot be combined with --project chat')
|
||||
if not 1 <= args.seconds <= 110:
|
||||
parser.error('Runtime bound must be 1..110 seconds (plus at most 10 seconds owned cleanup)')
|
||||
source, runtime, archive = args.source.resolve(), args.runtime.resolve(), args.archive.resolve()
|
||||
@@ -76,10 +133,14 @@ def main():
|
||||
parser.error('Source, archive and runtime must not overlap')
|
||||
if archive.exists() or archive.is_symlink():
|
||||
parser.error('Archive must be new')
|
||||
try:
|
||||
preflight_addons(source, args.route, args.project)
|
||||
except (ValueError, OSError) as error:
|
||||
parser.error(str(error))
|
||||
origin = runtime.parent / 'runtime-origin.json'
|
||||
if not origin.is_file() or json.loads(origin.read_text()).get('app_id') != 1874900:
|
||||
parser.error('Stable private runtime origin missing')
|
||||
binary = runtime / Path(engine_args(args.route)[0]).name
|
||||
binary = runtime / Path(engine_args(args.route, args.project)[0]).name
|
||||
with binary.open('rb') as stream:
|
||||
elf = stream.read(20)
|
||||
if binary.is_symlink() or len(elf) != 20 or elf[:6] != b'\x7fELF\x02\x01' or int.from_bytes(elf[18:20], 'little') != 62:
|
||||
@@ -92,6 +153,7 @@ def main():
|
||||
manifest(source)
|
||||
frozen = archive / 'source'
|
||||
shutil.copytree(source, frozen, ignore=shutil.ignore_patterns('.git', '__pycache__', 'evidence', 'generated'))
|
||||
preflight_addons(frozen, args.route, args.project)
|
||||
initial = manifest(frozen)
|
||||
(archive / 'source-manifest.json').write_text(json.dumps(initial, indent=2)+'\n')
|
||||
base = sandbox(runtime, frozen, archive)
|
||||
@@ -102,11 +164,12 @@ def main():
|
||||
if preflight.returncode:
|
||||
print('Isolation refused; no server launched:', archive)
|
||||
return 2
|
||||
command = base + ['/usr/bin/python3', '-c', PROBE, netns] + engine_args(args.route)
|
||||
record = {'command': command, 'engine': engine_args(args.route), 'seconds':args.seconds,
|
||||
command = base + ['/usr/bin/python3', '-c', PROBE, netns] + engine_args(args.route, args.project)
|
||||
record = {'command': command, 'engine': engine_args(args.route, args.project), 'seconds':args.seconds,
|
||||
'route': args.route, 'project': None if args.route == 'vanilla-everon' else args.project,
|
||||
'binarySHA256':hashlib.sha256(binary.read_bytes()).hexdigest(),
|
||||
'serverLaunched':False,'realPlayers':0,'multiplayerProven':False,
|
||||
'scope':'vanilla Everon control; no successor loaded' if args.route == 'vanilla-everon' else 'successor empty-server startup'}
|
||||
'scope':'vanilla Everon control; no successor loaded' if args.route == 'vanilla-everon' else f'successor {args.project} empty-server startup'}
|
||||
(archive / 'command.json').write_text(json.dumps(record, indent=2)+'\n')
|
||||
lock_fd = os.open('/tmp/erri-dedicated-native-diagnostic.lock', os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(lock_fd, 'a') as lock:
|
||||
|
||||
@@ -345,9 +345,11 @@ def validate_paths(root, steam, compat, profile):
|
||||
refuse(f'Unexpected profile symlink: {path}')
|
||||
|
||||
|
||||
def command_for(root, steam, compat, profile, project, packed=None):
|
||||
def command_for(root, steam, compat, profile, project, packed=None, capture_prison_view=False):
|
||||
if project not in {'core', 'chat', CONTROL}:
|
||||
refuse('Unsupported ordinary game project/control')
|
||||
if capture_prison_view and (project == CONTROL or packed is not None):
|
||||
refuse('Prison view capture requires loose successor source with its opt-in observer')
|
||||
command = ['gamescope', '--backend', 'headless', '-W', '1280', '-H', '720',
|
||||
'-w', '1280', '-h', '720', '-r', '30', '--',
|
||||
str(steam/'steamapps/common/Proton - Experimental/proton'), 'run',
|
||||
@@ -364,6 +366,8 @@ def command_for(root, steam, compat, profile, project, packed=None):
|
||||
command += ['-profile', 'Z:'+str(profile)]
|
||||
if project != CONTROL:
|
||||
command += ['-errOrdinaryScenario', project, '-noSplash']
|
||||
if capture_prison_view:
|
||||
command += ['-errOrdinaryScreenshot', '1']
|
||||
return command + ['-noBackend', '-noSound', '-noThrow', '-disableCrashReporter', '-logconsole']
|
||||
|
||||
|
||||
@@ -492,6 +496,8 @@ class StartupMonitor:
|
||||
|
||||
def run(a):
|
||||
packed = packed_request(a)
|
||||
if getattr(a, 'capture_prison_view', False) and (a.project == CONTROL or packed is not None):
|
||||
refuse('Prison view capture requires loose successor source with its opt-in observer')
|
||||
steam, compat = a.steam.resolve(), a.compat.resolve()
|
||||
profile = packed['session']/'profile' if packed else a.profile.resolve()
|
||||
if not 60 <= a.timeout <= 1800 or not 5 <= a.settle <= 120:
|
||||
@@ -517,7 +523,7 @@ def run(a):
|
||||
|
||||
|
||||
def run_locked(a, steam, compat, profile, packed=None):
|
||||
command = command_for(ROOT, steam, compat, profile, a.project, packed)
|
||||
command = command_for(ROOT, steam, compat, profile, a.project, packed, getattr(a, 'capture_prison_view', False))
|
||||
separator = command.index("--")
|
||||
proton, executable = Path(command[separator+1]), Path(command[separator+3])
|
||||
if not proton.is_file() or not executable.is_file():
|
||||
@@ -680,6 +686,7 @@ def main():
|
||||
packed_options.add_argument('--packed-addons', type=Path, help='Unqualified complete core/ai/intel/map/chat packed directories; requires --project chat')
|
||||
parser.add_argument('--packed-session', type=Path, help='New /tmp/erri-packed-core-<name> or erri-packed-chat-<name> session; owns a fresh input copy and profile')
|
||||
parser.add_argument('--inspect-processes', action='store_true', help='Read-only process preflight report; never launch, lock, write a profile or configure audio')
|
||||
parser.add_argument('--capture-prison-view', action='store_true', help='Opt-in single genuine local prison screenshot; loose successor source only, no camera or input changes')
|
||||
parser.add_argument('--timeout', type=int, default=180)
|
||||
parser.add_argument('--settle', type=int, default=15)
|
||||
parser.add_argument('--failure-grace', type=int, default=0, help='Keep a failed run alive for diagnostic logs only, at most 0..30 seconds within the original timeout')
|
||||
|
||||
@@ -11,7 +11,7 @@ p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--steam', type=Path, default=Path.home()/'.local/share/Steam')
|
||||
p.add_argument('--compat', type=Path, required=True, help='Disposable cloned Proton compatdata directory outside Steam')
|
||||
p.add_argument('--profile', type=Path, required=True, help='Separate disposable successor Workbench profile')
|
||||
p.add_argument('--smoke', choices=['map-ownership', 'mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory')
|
||||
p.add_argument('--smoke', choices=['aircraft-control', 'aircraft-cancel', 'aircraft-translate', 'map-ownership', 'mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory')
|
||||
p.add_argument('--station-teardown-diagnostics', action='store_true', help='Opt-in bounded native deletion diagnostics; preserves all validation errors')
|
||||
p.add_argument('--flight-autohover', action='store_true', help='Opt-in owned native helicopter autohover experiment; requires Core powered-flight and actor-local action inputs')
|
||||
p.add_argument('--flight-action-inputs', action='store_true', help='Experimental exact-AI ActionManager inputs; Core powered-flight fixture only')
|
||||
@@ -27,6 +27,7 @@ if a.smoke == 'mortar-courier' and (a.project != 'chat' or a.rounds != 1 or a.ti
|
||||
p.error('mortar-courier requires --project chat --rounds 1 --timeout at least 500')
|
||||
if a.smoke in {'mortar-refresh', 'mortar-salvo', 'mortar-expiry', 'mortar-transfer', 'mortar-fire'} and (a.project != 'chat' or a.rounds != 1 or a.timeout < 400):
|
||||
p.error('mortar-refresh/mortar-salvo/mortar-expiry/mortar-transfer/mortar-fire requires --project chat --rounds 1 --timeout at least 400')
|
||||
if a.smoke in {'aircraft-control', 'aircraft-cancel', 'aircraft-translate'} and (a.project != 'chat' or a.rounds != 1 or a.timeout < 420): p.error('aircraft-control/aircraft-cancel/aircraft-translate requires Chat, one round, and at least 420 seconds')
|
||||
if a.smoke == 'map-ownership' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 180): p.error('map-ownership requires Chat, one round, and at least 180 seconds')
|
||||
if a.smoke == 'powered-translate' and (a.project != 'core' or not a.flight_action_inputs or not a.flight_autohover): p.error('powered-translate requires Core with flight-action-inputs and flight-autohover')
|
||||
if a.flight_autohover and (a.project != 'core' or a.smoke not in {'powered-flight', 'powered-translate'} or not a.flight_action_inputs): p.error('flight-autohover requires Core powered-flight with flight-action-inputs')
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read the installed native -logStats format; export coarse records, not frames."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
MAX_INPUT_BYTES = 64 * 1024 * 1024
|
||||
MAX_LINE_BYTES = 16 * 1024
|
||||
MAX_RECORDS = 100_000
|
||||
MAX_COUNT = (1 << 63) - 1
|
||||
_NUMBER = r'([^\s,()]+)'
|
||||
_COUNT = r'([0-9]+)'
|
||||
_PATTERN = re.compile(
|
||||
r'([0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})[ \t]+DEFAULT[ \t]*:[ \t]*'
|
||||
r'FPS: ' + _NUMBER + r', frame time \(avg: ' + _NUMBER +
|
||||
r' ms, min: ' + _NUMBER + r' ms, max: ' + _NUMBER +
|
||||
r' ms, mean: ' + _NUMBER + r' ms, median: ' + _NUMBER +
|
||||
r' ms\), Mem: ' + _COUNT + r' kB, Player: ' + _COUNT +
|
||||
r', AI: ' + _COUNT + r', AIChar: ' + _COUNT + r', Veh: ' + _COUNT +
|
||||
r' \(' + _COUNT + r'\), Proj \(S: ' + _COUNT + r', M: ' + _COUNT +
|
||||
r', G: ' + _COUNT + r' \| ' + _COUNT + r'\)' +
|
||||
r'(?:, Freezes\(cnt: ' + _COUNT + r', max: ' + _NUMBER + r' ms, len: ' + _COUNT + r'\))?' +
|
||||
r', Streaming\(Dynam: ' + _COUNT +
|
||||
r', Static: ' + _COUNT + r', Disabled: ' + _COUNT + r' \| New: ' + _COUNT +
|
||||
r', Del: ' + _COUNT + r', Bump: ' + _COUNT + r'\)'
|
||||
)
|
||||
# Only the native DEFAULT category is accepted. A changed/truncated native stats
|
||||
# record must fail the export, rather than disappearing among unrelated log lines.
|
||||
_CANDIDATE = re.compile(r'\bDEFAULT\b[^\r\n]*(?:\bFPS\b|\bframe time\b)')
|
||||
_DECIMAL = re.compile(r'[0-9]+(?:\.[0-9]+)?')
|
||||
|
||||
|
||||
class StatsError(ValueError):
|
||||
"""Unsupported or malformed telemetry; no partial successful export."""
|
||||
|
||||
|
||||
def _float(text: str, line: int) -> float:
|
||||
if len(text) > 64 or not _DECIMAL.fullmatch(text):
|
||||
raise StatsError(f'line {line}: expected finite nonnegative decimal, got {text!r}')
|
||||
value = float(text)
|
||||
if not math.isfinite(value):
|
||||
raise StatsError(f'line {line}: nonfinite telemetry')
|
||||
return value
|
||||
|
||||
|
||||
def _count(text: str, line: int) -> int:
|
||||
if len(text) > 19 or int(text) > MAX_COUNT:
|
||||
raise StatsError(f'line {line}: count exceeds supported integer range')
|
||||
return int(text)
|
||||
|
||||
|
||||
def parse_bytes(data: bytes, source: str = '<memory>') -> dict:
|
||||
if len(data) > MAX_INPUT_BYTES:
|
||||
raise StatsError('input exceeds 64 MiB limit; split logs outside this parser')
|
||||
try:
|
||||
text = data.decode('utf-8-sig')
|
||||
except UnicodeDecodeError as error:
|
||||
raise StatsError('input is not UTF-8 text') from error
|
||||
records = []
|
||||
for line_number, raw_line in enumerate(text.splitlines(), 1):
|
||||
if len(raw_line.encode('utf-8')) > MAX_LINE_BYTES:
|
||||
raise StatsError(f'line {line_number}: line exceeds 16 KiB limit')
|
||||
if not _CANDIDATE.search(raw_line):
|
||||
continue
|
||||
match = _PATTERN.fullmatch(raw_line)
|
||||
if not match:
|
||||
raise StatsError(f'line {line_number}: malformed or unsupported native statistics record')
|
||||
fields = match.groups()
|
||||
timestamp = fields[0]
|
||||
hour, minute, second = map(int, (timestamp[:2], timestamp[3:5], timestamp[6:8]))
|
||||
if hour > 23 or minute > 59 or second > 59:
|
||||
raise StatsError(f'line {line_number}: invalid native clock timestamp')
|
||||
numbers = [_float(value, line_number) for value in fields[1:7]]
|
||||
fps, avg, minimum, maximum, mean, median = numbers
|
||||
if minimum > maximum or any(value < minimum or value > maximum for value in (avg, mean, median)):
|
||||
raise StatsError(f'line {line_number}: inconsistent frame-time summary bounds')
|
||||
counts = [_count(value, line_number) for value in fields[7:17]]
|
||||
memory, player, ai, ai_char, vehicle, vehicle_parenthesized, s, m, g, projectile_trailing = counts
|
||||
freezes = None
|
||||
if fields[17] is not None:
|
||||
freezes = {'cnt': _count(fields[17], line_number),
|
||||
'max_ms': _float(fields[18], line_number),
|
||||
'len': _count(fields[19], line_number)}
|
||||
dynamic, static, disabled, new, deleted, bump = [_count(value, line_number) for value in fields[20:]]
|
||||
records.append({
|
||||
'line_number': line_number,
|
||||
'timestamp': timestamp,
|
||||
'window_duration_ms': None,
|
||||
'fps': fps,
|
||||
'frame_time_summary_ms': dict(avg=avg, min=minimum, max=maximum, mean=mean, median=median),
|
||||
'memory_kB': memory,
|
||||
'native_populations': {'Player': player, 'AI': ai, 'AIChar': ai_char,
|
||||
'Veh': {'value': vehicle, 'parenthesized': vehicle_parenthesized}},
|
||||
'native_projectiles': {'S': s, 'M': m, 'G': g, 'trailing': projectile_trailing},
|
||||
'native_freezes': freezes,
|
||||
'native_streaming': {'Dynam': dynamic, 'Static': static, 'Disabled': disabled,
|
||||
'New': new, 'Del': deleted, 'Bump': bump},
|
||||
'ai_only_duration_ms': None,
|
||||
'raw_line': raw_line,
|
||||
})
|
||||
if len(records) > MAX_RECORDS:
|
||||
raise StatsError('statistics record limit exceeded')
|
||||
if not records:
|
||||
raise StatsError('no supported native statistics records found')
|
||||
return {
|
||||
'schema_version': 1,
|
||||
'kind': 'native-server-coarse-statistics',
|
||||
'source': {'path': source, 'sha256': hashlib.sha256(data).hexdigest(), 'bytes': len(data)},
|
||||
'performance_acceptance': 'not_evaluated',
|
||||
'unavailable': {
|
||||
'ai_only_duration_ms': 'Native -logStats records contain no AI-only execution duration.',
|
||||
'raw_frame_samples': 'Each record contains coarse summaries, not individual frames.',
|
||||
'percentiles': 'Slow-tail percentiles cannot be recovered from these summaries.',
|
||||
'window_duration_ms': 'The line does not identify its exact sampling window.',
|
||||
'calendar_date': 'Record timestamps contain only the native clock time; no date inferred.',
|
||||
},
|
||||
'limitations': [
|
||||
'Native labels are preserved; AI and AIChar are not equated or treated as AI execution time.',
|
||||
'Vehicle parenthesized and projectile trailing numbers are retained without inferred scope.',
|
||||
'Streaming counts are not treated as a complete loaded-world entity census.',
|
||||
'An absent Freezes block is null, not zero; cnt and len retain native labels without inferred duration or frame counts.',
|
||||
'Records retain source order, including clock rollover; no elapsed intervals or missing windows are invented.',
|
||||
'Parser success does not check engine/script errors, source provenance, equivalent workloads or release qualification.',
|
||||
'This output is not compare_performance.py v1 frame CSV input.',
|
||||
],
|
||||
'record_count': len(records),
|
||||
'records': records,
|
||||
}
|
||||
|
||||
|
||||
def parse_file(path: Path) -> dict:
|
||||
with path.open('rb') as handle:
|
||||
data = handle.read(MAX_INPUT_BYTES + 1)
|
||||
return parse_bytes(data, str(path.resolve()))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('log', type=Path, help='One existing UTF-8 native console log; read only')
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
result = parse_file(args.log)
|
||||
except (OSError, StatsError) as error:
|
||||
print(f'native statistics export refused: {error}', file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(result, indent=2, sort_keys=True, allow_nan=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user