diff --git a/plugins/decky-vault/main.py b/plugins/decky-vault/main.py index b3f85a3..66f0ba7 100644 --- a/plugins/decky-vault/main.py +++ b/plugins/decky-vault/main.py @@ -9,6 +9,91 @@ except ImportError: 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: async def _main(self): if decky: @@ -116,4 +201,27 @@ benchmark_percentiles=97,AVG,1,0.1 if os.path.exists(config_path): with open(config_path, 'r') as f: return {"exists": True, "content": f.read(), "path": config_path} - return {"exists": False, "content": "", "path": config_path} \ No newline at end of file + 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)} \ No newline at end of file diff --git a/plugins/decky-vault/tests/__pycache__/__init__.cpython-314.pyc b/plugins/decky-vault/tests/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..152b0b9 Binary files /dev/null and b/plugins/decky-vault/tests/__pycache__/__init__.cpython-314.pyc differ diff --git a/plugins/decky-vault/tests/__pycache__/test_mangohud_parser.cpython-314-pytest-9.1.1.pyc b/plugins/decky-vault/tests/__pycache__/test_mangohud_parser.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..bfa3e44 Binary files /dev/null and b/plugins/decky-vault/tests/__pycache__/test_mangohud_parser.cpython-314-pytest-9.1.1.pyc differ diff --git a/plugins/decky-vault/tests/__pycache__/test_settings.cpython-314-pytest-9.1.1.pyc b/plugins/decky-vault/tests/__pycache__/test_settings.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..1f14602 Binary files /dev/null and b/plugins/decky-vault/tests/__pycache__/test_settings.cpython-314-pytest-9.1.1.pyc differ diff --git a/plugins/decky-vault/tests/fixtures/sample_mangohud.log b/plugins/decky-vault/tests/fixtures/sample_mangohud.log new file mode 100644 index 0000000..d896ecc --- /dev/null +++ b/plugins/decky-vault/tests/fixtures/sample_mangohud.log @@ -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 \ No newline at end of file diff --git a/plugins/decky-vault/tests/test_mangohud_parser.py b/plugins/decky-vault/tests/test_mangohud_parser.py new file mode 100644 index 0000000..2d33197 --- /dev/null +++ b/plugins/decky-vault/tests/test_mangohud_parser.py @@ -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 \ No newline at end of file