From e48efe86b132f41f2f6279ab47ed40b6a3a03509 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 28 Jun 2026 07:54:32 +0800 Subject: [PATCH] feat(plugin): add MangoHud log parser with unit tests --- plugins/decky-vault/main.py | 110 +++++++++- .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 191 bytes ...ngohud_parser.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 15409 bytes ...test_settings.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 7000 bytes .../tests/fixtures/sample_mangohud.log | 19 ++ .../decky-vault/tests/test_mangohud_parser.py | 199 ++++++++++++++++++ 6 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 plugins/decky-vault/tests/__pycache__/__init__.cpython-314.pyc create mode 100644 plugins/decky-vault/tests/__pycache__/test_mangohud_parser.cpython-314-pytest-9.1.1.pyc create mode 100644 plugins/decky-vault/tests/__pycache__/test_settings.cpython-314-pytest-9.1.1.pyc create mode 100644 plugins/decky-vault/tests/fixtures/sample_mangohud.log create mode 100644 plugins/decky-vault/tests/test_mangohud_parser.py 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 0000000000000000000000000000000000000000..152b0b9a027b8a5c74b5e7666f2b83e3200a3f2b GIT binary patch literal 191 zcmdPq)c^nh literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bfa3e44bd410de2465b525ab115db44d529dc26d GIT binary patch literal 15409 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 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