feat(plugin): add MangoHud log parser with unit tests
This commit is contained in:
@@ -9,6 +9,91 @@ except ImportError:
|
|||||||
decky = None
|
decky = None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mangohud_log(log_content: str) -> dict:
|
||||||
|
"""Parse a MangoHud log file's content and return FPS stats.
|
||||||
|
|
||||||
|
Returns: {fpsAvg, fpsLow, fpsHigh, fpsOnePercentLow, tdpWatts, error?}
|
||||||
|
"""
|
||||||
|
lines = log_content.strip().split('\n')
|
||||||
|
|
||||||
|
header_idx = None
|
||||||
|
fps_col = 0
|
||||||
|
frametime_col = None
|
||||||
|
gpu_power_col = None
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith('#'):
|
||||||
|
continue
|
||||||
|
if 'fps' in stripped.lower() and ',' in stripped:
|
||||||
|
columns = [c.strip().lower() for c in stripped.split(',')]
|
||||||
|
if 'fps' in columns:
|
||||||
|
fps_col = columns.index('fps')
|
||||||
|
if 'frametime' in columns:
|
||||||
|
frametime_col = columns.index('frametime')
|
||||||
|
if 'gpu_power' in columns:
|
||||||
|
gpu_power_col = columns.index('gpu_power')
|
||||||
|
header_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if header_idx is None:
|
||||||
|
return {"error": "Could not find FPS column in log header"}
|
||||||
|
|
||||||
|
fps_values = []
|
||||||
|
frametime_values = []
|
||||||
|
gpu_power_values = []
|
||||||
|
|
||||||
|
for line in lines[header_idx + 1:]:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith('#'):
|
||||||
|
continue
|
||||||
|
if stripped.startswith(('97%', 'AVG', '1%', '0.1%', '5%')):
|
||||||
|
continue
|
||||||
|
parts = [p.strip() for p in stripped.split(',')]
|
||||||
|
try:
|
||||||
|
fps = float(parts[fps_col])
|
||||||
|
fps_values.append(fps)
|
||||||
|
if frametime_col is not None and frametime_col < len(parts):
|
||||||
|
frametime_values.append(float(parts[frametime_col]))
|
||||||
|
if gpu_power_col is not None and gpu_power_col < len(parts):
|
||||||
|
gpu_power_values.append(float(parts[gpu_power_col]))
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not fps_values:
|
||||||
|
return {"error": "No FPS data found in log"}
|
||||||
|
|
||||||
|
fps_avg = round(sum(fps_values) / len(fps_values), 1)
|
||||||
|
fps_low = round(min(fps_values), 1)
|
||||||
|
fps_high = round(max(fps_values), 1)
|
||||||
|
|
||||||
|
# 1% low: average the slowest 1% of frame times (largest frametimes),
|
||||||
|
# then convert to FPS. Falls back to the lowest FPS percentile if no
|
||||||
|
# frametime data is available.
|
||||||
|
if frametime_values:
|
||||||
|
sorted_ft = sorted(frametime_values)
|
||||||
|
one_percent_count = max(1, int(len(sorted_ft) * 0.01))
|
||||||
|
worst_ft = sorted_ft[-one_percent_count:]
|
||||||
|
avg_worst_ft = sum(worst_ft) / len(worst_ft)
|
||||||
|
fps_one_percent_low = round(1000.0 / avg_worst_ft, 1) if avg_worst_ft > 0 else None
|
||||||
|
else:
|
||||||
|
sorted_fps = sorted(fps_values)
|
||||||
|
one_percent_idx = max(0, int(len(sorted_fps) * 0.01))
|
||||||
|
fps_one_percent_low = round(sorted_fps[one_percent_idx], 1)
|
||||||
|
|
||||||
|
tdp_watts = None
|
||||||
|
if gpu_power_values:
|
||||||
|
tdp_watts = round(sum(gpu_power_values) / len(gpu_power_values), 1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"fpsAvg": fps_avg,
|
||||||
|
"fpsLow": fps_low,
|
||||||
|
"fpsHigh": fps_high,
|
||||||
|
"fpsOnePercentLow": fps_one_percent_low,
|
||||||
|
"tdpWatts": tdp_watts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Plugin:
|
class Plugin:
|
||||||
async def _main(self):
|
async def _main(self):
|
||||||
if decky:
|
if decky:
|
||||||
@@ -117,3 +202,26 @@ benchmark_percentiles=97,AVG,1,0.1
|
|||||||
with open(config_path, 'r') as f:
|
with open(config_path, 'r') as f:
|
||||||
return {"exists": True, "content": f.read(), "path": config_path}
|
return {"exists": True, "content": f.read(), "path": config_path}
|
||||||
return {"exists": False, "content": "", "path": config_path}
|
return {"exists": False, "content": "", "path": config_path}
|
||||||
|
|
||||||
|
async def read_and_parse_mangohud_log(self, log_path: str = "/tmp/deckyvault-mangohud.log") -> dict:
|
||||||
|
"""RPC: Read the MangoHud log file and return parsed FPS stats.
|
||||||
|
Returns parsed stats dict or {error: str}."""
|
||||||
|
if not os.path.exists(log_path):
|
||||||
|
return {"error": f"MangoHud log not found at {log_path}. Make sure MangoHud is enabled and logging."}
|
||||||
|
try:
|
||||||
|
with open(log_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
if not content.strip():
|
||||||
|
return {"error": "MangoHud log is empty. Recording may have been too short."}
|
||||||
|
return parse_mangohud_log(content)
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Failed to read log: {str(e)}"}
|
||||||
|
|
||||||
|
async def clear_mangohud_log(self, log_path: str = "/tmp/deckyvault-mangohud.log") -> dict:
|
||||||
|
"""RPC: Delete the MangoHud log file so the next recording starts fresh."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(log_path):
|
||||||
|
os.remove(log_path)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
|||||||
|
# MangoHud v0.8.4
|
||||||
|
# note: session started at 2026-06-28 14:30:00
|
||||||
|
# preset: 0
|
||||||
|
fps,frametime,cpu_load,gpu_load,cpu_temp,gpu_temp,gpu_power,cpu_power
|
||||||
|
60,16.67,45,80,55,65,15,10
|
||||||
|
62,16.13,46,82,55,65,15,10
|
||||||
|
58,17.24,44,78,56,66,14,10
|
||||||
|
61,16.39,45,81,55,65,15,10
|
||||||
|
59,16.95,44,79,56,66,14,10
|
||||||
|
60,16.67,45,80,55,65,15,10
|
||||||
|
63,15.87,47,83,55,65,15,10
|
||||||
|
57,17.54,43,77,56,66,14,10
|
||||||
|
60,16.67,45,80,55,65,15,10
|
||||||
|
61,16.39,46,81,55,65,15,10
|
||||||
|
# benchmark summary
|
||||||
|
97%, 62
|
||||||
|
AVG, 60
|
||||||
|
1%, 57
|
||||||
|
0.1%, 57
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Tests for MangoHud log parsing."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Parser functions — these will be imported from main.py once implemented.
|
||||||
|
# For now we define them here to test the logic, then move to main.py.
|
||||||
|
|
||||||
|
def parse_mangohud_log(log_content: str) -> dict:
|
||||||
|
"""Parse a MangoHud log file's content and return FPS stats.
|
||||||
|
|
||||||
|
Returns: {fpsAvg, fpsLow, fpsHigh, fpsOnePercentLow, tdpWatts, error?}
|
||||||
|
"""
|
||||||
|
lines = log_content.strip().split('\n')
|
||||||
|
|
||||||
|
# Find the header row (first non-comment, non-empty line that looks like column names)
|
||||||
|
header_idx = None
|
||||||
|
fps_col = 0
|
||||||
|
frametime_col = None
|
||||||
|
gpu_power_col = None
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith('#'):
|
||||||
|
continue
|
||||||
|
# Check if this is a header (contains 'fps')
|
||||||
|
if 'fps' in stripped.lower() and ',' in stripped:
|
||||||
|
columns = [c.strip().lower() for c in stripped.split(',')]
|
||||||
|
if 'fps' in columns:
|
||||||
|
fps_col = columns.index('fps')
|
||||||
|
if 'frametime' in columns:
|
||||||
|
frametime_col = columns.index('frametime')
|
||||||
|
if 'gpu_power' in columns:
|
||||||
|
gpu_power_col = columns.index('gpu_power')
|
||||||
|
header_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if header_idx is None:
|
||||||
|
return {"error": "Could not find FPS column in log header"}
|
||||||
|
|
||||||
|
# Extract data rows (lines after header that start with a number)
|
||||||
|
fps_values = []
|
||||||
|
frametime_values = []
|
||||||
|
gpu_power_values = []
|
||||||
|
|
||||||
|
for line in lines[header_idx + 1:]:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith('#'):
|
||||||
|
continue
|
||||||
|
# Check if it's a summary line (e.g. "97%,\t62" or "AVG,\t60")
|
||||||
|
if stripped.startswith(('97%', 'AVG', '1%', '0.1%', '5%')):
|
||||||
|
continue
|
||||||
|
parts = [p.strip() for p in stripped.split(',')]
|
||||||
|
try:
|
||||||
|
fps = float(parts[fps_col])
|
||||||
|
fps_values.append(fps)
|
||||||
|
if frametime_col is not None and frametime_col < len(parts):
|
||||||
|
ft = float(parts[frametime_col])
|
||||||
|
frametime_values.append(ft)
|
||||||
|
if gpu_power_col is not None and gpu_power_col < len(parts):
|
||||||
|
gp = float(parts[gpu_power_col])
|
||||||
|
gpu_power_values.append(gp)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not fps_values:
|
||||||
|
return {"error": "No FPS data found in log"}
|
||||||
|
|
||||||
|
# Compute stats
|
||||||
|
fps_avg = round(sum(fps_values) / len(fps_values), 1)
|
||||||
|
fps_low = round(min(fps_values), 1)
|
||||||
|
fps_high = round(max(fps_values), 1)
|
||||||
|
|
||||||
|
# 1% low: average the slowest 1% of frame times (largest frametimes),
|
||||||
|
# then convert to FPS. Falls back to the lowest FPS percentile if no
|
||||||
|
# frametime data is available.
|
||||||
|
if frametime_values:
|
||||||
|
sorted_ft = sorted(frametime_values)
|
||||||
|
one_percent_count = max(1, int(len(sorted_ft) * 0.01))
|
||||||
|
worst_ft = sorted_ft[-one_percent_count:]
|
||||||
|
avg_worst_ft = sum(worst_ft) / len(worst_ft)
|
||||||
|
fps_one_percent_low = round(1000.0 / avg_worst_ft, 1) if avg_worst_ft > 0 else None
|
||||||
|
else:
|
||||||
|
# Fall back: sort FPS values, take 1st percentile from bottom
|
||||||
|
sorted_fps = sorted(fps_values)
|
||||||
|
one_percent_idx = max(0, int(len(sorted_fps) * 0.01))
|
||||||
|
fps_one_percent_low = round(sorted_fps[one_percent_idx], 1)
|
||||||
|
|
||||||
|
tdp_watts = None
|
||||||
|
if gpu_power_values:
|
||||||
|
tdp_watts = round(sum(gpu_power_values) / len(gpu_power_values), 1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"fpsAvg": fps_avg,
|
||||||
|
"fpsLow": fps_low,
|
||||||
|
"fpsHigh": fps_high,
|
||||||
|
"fpsOnePercentLow": fps_one_percent_low,
|
||||||
|
"tdpWatts": tdp_watts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tests ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_parse_basic_log():
|
||||||
|
log = """\
|
||||||
|
# MangoHud v0.8.4
|
||||||
|
fps,frametime,cpu_load,gpu_load,cpu_temp,gpu_temp,gpu_power,cpu_power
|
||||||
|
60,16.67,45,80,55,65,15,10
|
||||||
|
62,16.13,46,82,55,65,15,10
|
||||||
|
58,17.24,44,78,56,66,14,10
|
||||||
|
"""
|
||||||
|
result = parse_mangohud_log(log)
|
||||||
|
assert "error" not in result
|
||||||
|
assert result["fpsAvg"] == 60.0
|
||||||
|
assert result["fpsLow"] == 58.0
|
||||||
|
assert result["fpsHigh"] == 62.0
|
||||||
|
assert result["tdpWatts"] == 14.7 # avg of 15,15,14 = 14.667 -> 14.7
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_log_with_summary_section():
|
||||||
|
"""The summary section (97%, AVG, 1%, 0.1%) should be skipped as data."""
|
||||||
|
log = """\
|
||||||
|
# MangoHud v0.8.4
|
||||||
|
fps,frametime,cpu_load,gpu_power
|
||||||
|
60,16.67,45,15
|
||||||
|
62,16.13,46,15
|
||||||
|
58,17.24,44,14
|
||||||
|
# benchmark summary
|
||||||
|
97%, 62
|
||||||
|
AVG, 60
|
||||||
|
1%, 57
|
||||||
|
0.1%, 57
|
||||||
|
"""
|
||||||
|
result = parse_mangohud_log(log)
|
||||||
|
assert "error" not in result
|
||||||
|
assert result["fpsAvg"] == 60.0
|
||||||
|
# Should not have tried to parse summary lines as data
|
||||||
|
assert result["fpsLow"] == 58.0
|
||||||
|
assert result["fpsHigh"] == 62.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_empty_log_returns_error():
|
||||||
|
result = parse_mangohud_log("")
|
||||||
|
assert "error" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_log_without_fps_column():
|
||||||
|
log = """\
|
||||||
|
# no fps here
|
||||||
|
cpu_load,gpu_load
|
||||||
|
45,80
|
||||||
|
"""
|
||||||
|
result = parse_mangohud_log(log)
|
||||||
|
assert "error" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_log_without_gpu_power():
|
||||||
|
"""tdpWatts should be None if gpu_power column is absent."""
|
||||||
|
log = """\
|
||||||
|
fps,frametime,cpu_load
|
||||||
|
60,16.67,45
|
||||||
|
62,16.13,46
|
||||||
|
"""
|
||||||
|
result = parse_mangohud_log(log)
|
||||||
|
assert result["tdpWatts"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_one_percent_low_from_frametime():
|
||||||
|
"""1% low should be computed from frame times when available."""
|
||||||
|
log = """\
|
||||||
|
fps,frametime
|
||||||
|
60,16.67
|
||||||
|
30,33.33
|
||||||
|
60,16.67
|
||||||
|
60,16.67
|
||||||
|
60,16.67
|
||||||
|
60,16.67
|
||||||
|
60,16.67
|
||||||
|
60,16.67
|
||||||
|
60,16.67
|
||||||
|
60,16.67
|
||||||
|
"""
|
||||||
|
result = parse_mangohud_log(log)
|
||||||
|
# The 33.33ms frame time is the worst — 1% low should be ~30 fps
|
||||||
|
assert result["fpsOnePercentLow"] is not None
|
||||||
|
assert result["fpsOnePercentLow"] <= 35 # roughly 1000/33.33 = 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_fixture_file():
|
||||||
|
"""Parse the actual fixture file."""
|
||||||
|
fixture_path = os.path.join(
|
||||||
|
os.path.dirname(__file__), "fixtures", "sample_mangohud.log"
|
||||||
|
)
|
||||||
|
with open(fixture_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
result = parse_mangohud_log(content)
|
||||||
|
assert "error" not in result
|
||||||
|
assert result["fpsAvg"] == 60.1 # avg of the 10 data rows
|
||||||
|
assert result["fpsLow"] == 57.0
|
||||||
|
assert result["fpsHigh"] == 63.0
|
||||||
|
assert result["tdpWatts"] is not None
|
||||||
Reference in New Issue
Block a user