Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions scripts/fps-exporter/backoff.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use strict';

const INITIAL_BACKOFF_MS = 5000;
const MAX_BACKOFF_MS = 60000;
const HEALTHY_RUN_RESET_MS = 60000;

function nextBackoffMs(currentBackoffMs) {
return Math.min(currentBackoffMs * 2, MAX_BACKOFF_MS);
}

function backoffAfterExit(currentBackoffMs, runDurationMs) {
if (runDurationMs >= HEALTHY_RUN_RESET_MS) {
return INITIAL_BACKOFF_MS;
}
return nextBackoffMs(currentBackoffMs);
}

module.exports = { INITIAL_BACKOFF_MS, MAX_BACKOFF_MS, HEALTHY_RUN_RESET_MS, nextBackoffMs, backoffAfterExit };
19 changes: 19 additions & 0 deletions scripts/fps-exporter/backoff.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { INITIAL_BACKOFF_MS, MAX_BACKOFF_MS, backoffAfterExit } = require('./backoff.js');

test('backoffAfterExit: doubles backoff after a short-lived run (crash loop)', () => {
const next = backoffAfterExit(INITIAL_BACKOFF_MS, 1000);
assert.equal(next, INITIAL_BACKOFF_MS * 2);
});

test('backoffAfterExit: caps backoff at MAX_BACKOFF_MS', () => {
const next = backoffAfterExit(MAX_BACKOFF_MS, 1000);
assert.equal(next, MAX_BACKOFF_MS);
});

test('backoffAfterExit: resets to INITIAL_BACKOFF_MS after a healthy long run', () => {
const next = backoffAfterExit(MAX_BACKOFF_MS, 120000);
assert.equal(next, INITIAL_BACKOFF_MS);
});
3 changes: 3 additions & 0 deletions scripts/fps-exporter/connector-map.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"0": "Unknown(4294967295)"
}
198 changes: 198 additions & 0 deletions scripts/fps-exporter/dump-display-topology.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
<#
.SYNOPSIS
Map PresentMon's numeric VidPnSourceId (the "output" label on game_fps/display_fps/
frames_dropped_total) to each monitor's real connector type (HDMI/DisplayPort/DVI/...)
and friendly name, via the Windows QueryDisplayConfig API.

.DESCRIPTION
MUST be run from your normal interactive desktop session — not elevated, not over RDP,
not from a Windows Service. QueryDisplayConfig reports the topology of whichever session
calls it; the fps-exporter service runs in Session 0 (non-interactive, no real monitors
attached in the WDDM sense), and an RDP session sees RDP's own virtual "Remote Display
Adapter" instead of your physical monitors — confirmed empirically earlier the same day
this script was written, via a completely different tool (EnumDisplaySettings) hitting the
exact same problem. Run this at your own keyboard, on the physical console session.

Writes connector-map.json next to this script, e.g.:
{"0": "DisplayPort", "1": "HDMI"}
fps-exporter.js reads that file once at startup (see loadConnectorMap() in fps-exporter.js)
and attaches a connector="..." label wherever the output ID matches. Missing or stale is
harmless — panels just fall back to the numeric output ID with no connector label.

UNTESTED IN THIS REPO'S DEV ENVIRONMENT — the struct layouts below are hand-written from
the documented Win32 DisplayConfig API (stable, unchanged in the SDK for years), but no
execution of this exact script has been verified end-to-end. Sanity-check the output:
cross-reference monitorFriendlyDeviceName in the console output against Settings > System
> Display > Advanced display > "Display information" for each monitor. If a friendly name
comes back garbled or empty, don't trust that entry's connector type either — the win32
call may have partially failed for that target even though it returned SUCCESS overall.

.EXAMPLE
.\scripts\fps-exporter\dump-display-topology.ps1
#>

Add-Type @"
using System;
using System.Runtime.InteropServices;
using System.Collections.Generic;

public class DisplayTopology {
[StructLayout(LayoutKind.Sequential)]
public struct LUID { public uint LowPart; public int HighPart; }

[StructLayout(LayoutKind.Sequential)]
public struct DISPLAYCONFIG_RATIONAL { public uint Numerator; public uint Denominator; }

[StructLayout(LayoutKind.Sequential)]
public struct DISPLAYCONFIG_PATH_SOURCE_INFO {
public LUID adapterId;
public uint id;
public uint modeInfoIdx;
public uint statusFlags;
}

[StructLayout(LayoutKind.Sequential)]
public struct DISPLAYCONFIG_PATH_TARGET_INFO {
public LUID adapterId;
public uint id;
public uint modeInfoIdx;
public uint outputTechnology;
public uint rotation;
public uint scaling;
public DISPLAYCONFIG_RATIONAL refreshRate;
public uint scanLineOrdering;
public int targetAvailable;
public uint statusFlags;
}

[StructLayout(LayoutKind.Sequential)]
public struct DISPLAYCONFIG_PATH_INFO {
public DISPLAYCONFIG_PATH_SOURCE_INFO sourceInfo;
public DISPLAYCONFIG_PATH_TARGET_INFO targetInfo;
public uint flags;
}

[StructLayout(LayoutKind.Sequential)]
public struct DISPLAYCONFIG_MODE_INFO {
public uint infoType;
public uint id;
public LUID adapterId;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 48)]
public byte[] modeInfo;
}

[StructLayout(LayoutKind.Sequential)]
public struct DISPLAYCONFIG_DEVICE_INFO_HEADER {
public uint type;
public uint size;
public LUID adapterId;
public uint id;
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct DISPLAYCONFIG_TARGET_DEVICE_NAME {
public DISPLAYCONFIG_DEVICE_INFO_HEADER header;
public uint flags;
public uint outputTechnology;
public ushort edidManufactureId;
public ushort edidProductCodeId;
public uint connectorInstance;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
public string monitorFriendlyDeviceName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string monitorDevicePath;
}

const uint QDC_ONLY_ACTIVE_PATHS = 0x00000002;
const int DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME = 2;
const int ERROR_SUCCESS = 0;

[DllImport("user32.dll")]
static extern int GetDisplayConfigBufferSizes(uint flags, out uint numPathArrayElements, out uint numModeInfoArrayElements);

[DllImport("user32.dll")]
static extern int QueryDisplayConfig(uint flags, ref uint numPathArrayElements, [Out] DISPLAYCONFIG_PATH_INFO[] pathArray,
ref uint numModeInfoArrayElements, [Out] DISPLAYCONFIG_MODE_INFO[] modeInfoArray, IntPtr currentTopologyId);

[DllImport("user32.dll", CharSet = CharSet.Unicode)]
static extern int DisplayConfigGetDeviceInfo(ref DISPLAYCONFIG_TARGET_DEVICE_NAME requestPacket);

public static string TechName(uint tech) {
switch (tech) {
case 0: return "VGA";
case 1: return "SVideo";
case 2: return "CompositeVideo";
case 3: return "ComponentVideo";
case 4: return "DVI";
case 5: return "HDMI";
case 6: return "LVDS";
case 8: return "D_JPN";
case 9: return "SDI";
case 10: return "DisplayPort";
case 11: return "DisplayPort (embedded)";
case 12: return "UDI";
case 13: return "UDI (embedded)";
case 14: return "SDTVDongle";
case 15: return "Miracast";
case 16: return "IndirectWired";
case 17: return "IndirectVirtual";
case 0x80000000: return "Internal";
default: return "Unknown(" + tech + ")";
}
}

public static string Run() {
uint pathCount, modeCount;
int rc = GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, out pathCount, out modeCount);
if (rc != ERROR_SUCCESS) return "ERROR: GetDisplayConfigBufferSizes failed, code " + rc;

var paths = new DISPLAYCONFIG_PATH_INFO[pathCount];
var modes = new DISPLAYCONFIG_MODE_INFO[modeCount];
rc = QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, ref pathCount, paths, ref modeCount, modes, IntPtr.Zero);
if (rc != ERROR_SUCCESS) return "ERROR: QueryDisplayConfig failed, code " + rc;

var sb = new System.Text.StringBuilder();
for (uint i = 0; i < pathCount; i++) {
var p = paths[i];
var nameReq = new DISPLAYCONFIG_TARGET_DEVICE_NAME();
nameReq.header.type = (uint)DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME;
nameReq.header.size = (uint)Marshal.SizeOf(typeof(DISPLAYCONFIG_TARGET_DEVICE_NAME));
nameReq.header.adapterId = p.targetInfo.adapterId;
nameReq.header.id = p.targetInfo.id;
string friendlyName = "<name lookup failed>";
if (DisplayConfigGetDeviceInfo(ref nameReq) == ERROR_SUCCESS) {
friendlyName = string.IsNullOrEmpty(nameReq.monitorFriendlyDeviceName) ? "<empty>" : nameReq.monitorFriendlyDeviceName;
}
sb.AppendLine(p.sourceInfo.id + "\t" + TechName(p.targetInfo.outputTechnology) + "\t" + friendlyName);
}
return sb.ToString();
}
}
"@

Write-Output "=== Raw topology: sourceId <tab> connector <tab> friendly name ==="
Write-Output "Sanity-check the friendly names against Settings > System > Display > Advanced display before trusting the map below."
Write-Output ""
$raw = [DisplayTopology]::Run()
if ($raw -like "ERROR:*") {
Write-Error $raw
exit 1
}
Write-Output $raw

$map = @{}
$rows = $raw -split "`n" | Where-Object { $_.Trim() -ne "" }
foreach ($row in $rows) {
$parts = $row -split "`t"
if ($parts.Count -ge 2) {
$map[$parts[0].Trim()] = $parts[1].Trim()
}
}

$outPath = Join-Path $PSScriptRoot "connector-map.json"
$map | ConvertTo-Json | Set-Content -Path $outPath -Encoding utf8
Write-Output ""
Write-Output "Wrote $outPath :"
Get-Content $outPath
Write-Output ""
Write-Output "Restart the FpsExporter service (or just fps-exporter.js if running manually) to pick this up — it's only read at startup."
139 changes: 139 additions & 0 deletions scripts/fps-exporter/fps-exporter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
'use strict';

const { spawn } = require('node:child_process');
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');

const { parseHeader, parseRow } = require('./presentmon-csv.js');
const { MetricsAggregator } = require('./metrics-aggregator.js');
const { INITIAL_BACKOFF_MS, backoffAfterExit } = require('./backoff.js');

// Hardcoded, not derived from os.homedir()/%LOCALAPPDATA% — this runs as a LocalSystem
// Windows Service (see install-service.ps1), and under that account both resolve to
// C:\Windows\system32\config\systemprofile, not the interactive user's profile. This is a
// single-user personal machine, so a fixed absolute path is correct here, not a compromise.
const ALLOWLIST = ['iRacingSim64DX11.exe', 'chrome.exe'];
const PRESENTMON_PATH = 'C:\\Users\\winth\\Tools\\PresentMon\\PresentMon.exe';
const CONNECTOR_MAP_PATH = path.join(__dirname, 'connector-map.json');
const METRICS_PORT = 9101;
const METRICS_HOST = '127.0.0.1';
const LOG_DIR = 'C:\\Users\\winth\\AppData\\Local\\FpsExporter';
const LOG_FILE = path.join(LOG_DIR, 'fps-exporter.log');

function log(message) {
const line = `${new Date().toISOString()} ${message}\n`;
process.stdout.write(line);
try {
fs.mkdirSync(LOG_DIR, { recursive: true });
fs.appendFileSync(LOG_FILE, line);
} catch (err) {
process.stderr.write(`failed to write log file: ${err.message}\n`);
}
}

function loadConnectorMap() {
// Optional: {"0":"DisplayPort","1":"HDMI"} keyed by PresentMon's VidPnSourceId, produced by
// dump-display-topology.ps1 run from the interactive desktop session (this service runs in
// Session 0, which cannot see real monitor topology itself — see that script's header comment).
// Read once at startup; the physical monitor layout doesn't change often enough to warrant
// watching the file, and a stale/missing map just means output IDs render without a connector
// label rather than with a wrong one.
if (!fs.existsSync(CONNECTOR_MAP_PATH)) {
log(`no connector map at ${CONNECTOR_MAP_PATH} — outputs will be labeled by numeric ID only`);
return {};
}
try {
const map = JSON.parse(fs.readFileSync(CONNECTOR_MAP_PATH, 'utf8'));
log(`loaded connector map: ${JSON.stringify(map)}`);
return map;
} catch (err) {
log(`failed to parse connector map at ${CONNECTOR_MAP_PATH}: ${err.message} — ignoring it`);
return {};
}
}

const aggregator = new MetricsAggregator(ALLOWLIST, loadConnectorMap());

function startPresentMon(backoffMs) {
log(`starting PresentMon.exe (backoff was ${backoffMs}ms)`);
const startedAt = Date.now();
const child = spawn(PRESENTMON_PATH, [
'--process_name', 'iRacingSim64DX11.exe',
'--process_name', 'chrome.exe',
'--output_stdout',
'--no_console_stats',
'--write_display_metadata',
]);

let headerCols = null;
let carry = '';

child.stdout.on('data', (chunk) => {
carry += chunk.toString('utf8');
const lines = carry.split('\n');
carry = lines.pop();

for (const rawLine of lines) {
const line = rawLine.trim();
if (line.length === 0) continue;

if (headerCols === null) {
headerCols = parseHeader(line);
continue;
}

try {
const row = parseRow(headerCols, line);
aggregator.recordRow(row, Date.now());
} catch (err) {
log(`skipping unparseable row: ${err.message}`);
}
}
});

child.stderr.on('data', (chunk) => {
log(`PresentMon stderr: ${chunk.toString('utf8').trim()}`);
});

child.on('exit', (code, signal) => {
const runDurationMs = Date.now() - startedAt;
log(`PresentMon.exe exited (code=${code}, signal=${signal}, ran for ${runDurationMs}ms)`);
const nextBackoff = backoffAfterExit(backoffMs, runDurationMs);
setTimeout(() => startPresentMon(nextBackoff), nextBackoff);
});

child.on('error', (err) => {
log(`failed to spawn PresentMon.exe: ${err.message}`);
});
}

function startMetricsServer() {
const server = http.createServer((req, res) => {
if (req.url === '/metrics') {
const body = aggregator.renderPrometheusText(Date.now());
res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4' });
res.end(body);
} else {
res.writeHead(404);
res.end();
}
});

server.on('error', (err) => {
log(`metrics server failed to start: ${err.message}`);
process.exit(1);
});

server.listen(METRICS_PORT, METRICS_HOST, () => {
log(`metrics endpoint listening on http://${METRICS_HOST}:${METRICS_PORT}/metrics`);
});
}

if (!fs.existsSync(PRESENTMON_PATH)) {
log(`PresentMon.exe not found at ${PRESENTMON_PATH}`);
process.exit(1);
}

startMetricsServer();
startPresentMon(INITIAL_BACKOFF_MS);
Loading
Loading