[feat] fps benchmark one-key testing

This commit is contained in:
Purp1e
2025-11-05 02:24:17 +08:00
parent d6ce9bd5f3
commit 543c3344d1
9 changed files with 562 additions and 131 deletions

View File

@@ -12,28 +12,34 @@ import { Refresh, SettingConfig } from "@icon-park/react"
// import { version } from "@tauri-apps/plugin-os"
import { useEffect, useState } from "react"
import { type AllSystemInfo, allSysInfo } from "tauri-plugin-system-info-api"
import { FpsTest } from "@/components/cstb/FpsTest"
export default function Page() {
return (
<Card className="h-full">
<CardHeader>
<CardIcon type="menu">
<SettingConfig />
</CardIcon>
<CardTool>
{/* <ToolButton>
<UploadOne />
云同步
</ToolButton> */}
<ToolButton>
<Refresh />
</ToolButton>
</CardTool>
</CardHeader>
<section className="flex flex-col gap-4 overflow-hidden rounded-lg">
<div className="flex flex-col gap-4 overflow-y-auto h-full hide-scrollbar">
<Card>
<CardHeader>
<CardIcon type="menu">
<SettingConfig />
</CardIcon>
<CardTool>
{/* <ToolButton>
<UploadOne />
云同步
</ToolButton> */}
<ToolButton>
<Refresh />
</ToolButton>
</CardTool>
</CardHeader>
<CardBody>
<HardwareInfo />
</CardBody>
</Card>
<CardBody>
<HardwareInfo />
</CardBody>
</Card>
<FpsTest />
</div>
</section>
)
}

View File

@@ -24,4 +24,8 @@ a {
/* 隐藏滚动条 */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}

View File

@@ -0,0 +1,371 @@
"use client"
import { useSteamStore } from "@/store/steam"
import { invoke } from "@tauri-apps/api/core"
import { Card, CardBody, CardHeader, CardIcon } from "../window/Card"
import { addToast, Button, Chip, Spinner, Switch } from "@heroui/react"
import { useState, useEffect, useRef, useCallback } from "react"
import { TestTube, Power } from "@icon-park/react"
const BENCHMARK_MAPS = [
{
name: "de_dust2_benchmark",
workshopId: "3240880604",
map: "de_dust2_benchmark",
label: "Dust2 Benchmark",
},
{
name: "de_ancient",
workshopId: "3472126051",
map: "de_ancient",
label: "Ancient",
},
]
// 解析性能报告,提取时间戳和性能数据
function parseVProfReport(rawReport: string): { timestamp: string; data: string } | null {
if (!rawReport) return null
const lines = rawReport.split("\n")
let timestamp = ""
let inPerformanceSection = false
const performanceLines: string[] = []
for (const line of lines) {
// 提取时间戳:格式如 "11/05 01:51:27 [VProf] -- Performance report --"
const timestampMatch = line.match(
/(\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[VProf\]\s+--\s+Performance\s+report\s+--/
)
if (timestampMatch) {
timestamp = timestampMatch[1]
inPerformanceSection = true
// 也包含 Performance report 这一行,但移除时间戳
const lineWithoutTimestamp = line.trim().replace(/^\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}\s+/, "")
performanceLines.push(lineWithoutTimestamp)
continue
}
// 如果在性能报告部分
if (inPerformanceSection) {
const trimmedLine = line.trim()
// 只收集包含 [VProf] 的行
if (trimmedLine.includes("[VProf]")) {
// 移除行首的时间戳格式MM/DD HH:mm:ss
// 例如:"11/05 02:13:56 [VProf] ..." -> "[VProf] ..."
const lineWithoutTimestamp = trimmedLine.replace(/^\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}\s+/, "")
performanceLines.push(lineWithoutTimestamp)
}
// 如果遇到空行且已经有数据,可能是报告结束,但不直接结束,因为可能还有更多数据
// 如果后续没有 [VProf] 行的数据,空行会被过滤掉
}
}
if (performanceLines.length === 0) return null
return {
timestamp,
data: performanceLines.join("\n").trim(),
}
}
// 比较时间戳格式MM/DD HH:mm:ss
// 返回 true 如果 timestamp1 晚于 timestamp2
function compareTimestamps(timestamp1: string, timestamp2: string): boolean {
// 解析时间戳MM/DD HH:mm:ss
const parseTimestamp = (ts: string) => {
const match = ts.match(/(\d{2})\/(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/)
if (!match) return null
const [, month, day, hour, minute, second] = match.map(Number)
return { month, day, hour, minute, second }
}
const ts1 = parseTimestamp(timestamp1)
const ts2 = parseTimestamp(timestamp2)
if (!ts1 || !ts2) return false
// 使用当前年份作为基准
const now = new Date()
const currentYear = now.getFullYear()
// 创建日期对象,尝试当前年份
let date1 = new Date(currentYear, ts1.month - 1, ts1.day, ts1.hour, ts1.minute, ts1.second)
let date2 = new Date(currentYear, ts2.month - 1, ts2.day, ts2.hour, ts2.minute, ts2.second)
// 如果 date1 早于 date2可能是跨年了比如 date1 是 1月date2 是 12月
// 在这种情况下,给 date1 加一年
if (date1 < date2) {
// 检查是否可能是跨年(月份相差很大)
const monthDiff = (ts1.month - ts2.month + 12) % 12
if (monthDiff > 6) {
// 可能是跨年,给 date1 加一年
date1 = new Date(currentYear + 1, ts1.month - 1, ts1.day, ts1.hour, ts1.minute, ts1.second)
}
}
return date1 > date2
}
export function FpsTest() {
const steam = useSteamStore()
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<string | null>(null)
const [testTimestamp, setTestTimestamp] = useState<string | null>(null)
const [selectedMap, setSelectedMap] = useState<string | null>(null)
const [autoCloseGame, setAutoCloseGame] = useState(false)
const [isMonitoring, setIsMonitoring] = useState(false)
const monitoringIntervalRef = useRef<NodeJS.Timeout | null>(null)
// 记录测试开始的时间戳(用于过滤旧数据)
const testStartTimestampRef = useRef<string | null>(null)
// 读取结果函数
const readResult = useCallback(
async (silent = false): Promise<boolean> => {
if (!steam.state.cs2Dir) {
if (!silent) {
addToast({ title: "请先配置 CS2 路径", variant: "flat" })
}
return false
}
try {
// 获取 console.log 路径
const consoleLogPath = await invoke<string>("get_console_log_path", {
csPath: steam.state.cs2Dir,
})
// 读取 VProf 报告
const report = await invoke<string>("read_vprof_report", {
consoleLogPath: consoleLogPath,
})
if (report && report.trim().length > 0) {
const parsed = parseVProfReport(report)
if (parsed) {
// 如果设置了测试开始时间且是自动监听silent=true验证报告时间戳是否晚于测试开始时间
// 手动读取silent=false时允许读取任何结果
if (silent && testStartTimestampRef.current) {
// 如果报告时间戳早于或等于测试开始时间,则视为旧数据,忽略
if (!compareTimestamps(parsed.timestamp, testStartTimestampRef.current)) {
// 这是旧数据,不处理
return false
}
}
setTestResult(parsed.data)
setTestTimestamp(parsed.timestamp)
// 成功读取后,清除测试开始时间戳(测试已完成)
testStartTimestampRef.current = null
if (!silent) {
addToast({ title: "已读取测试结果" })
}
return true
} else if (!silent) {
addToast({
title: "未能解析测试结果",
variant: "flat",
})
}
} else if (!silent) {
addToast({
title: "未能读取测试结果,请确保测试已完成",
variant: "flat",
})
}
return false
} catch (error) {
if (!silent) {
console.error("读取结果失败:", error)
addToast({
title: `读取结果失败: ${error instanceof Error ? error.message : String(error)}`,
variant: "flat",
})
}
return false
}
},
[steam.state.cs2Dir]
)
// 关闭游戏
const closeGame = useCallback(async () => {
try {
await invoke("kill_game")
addToast({ title: "已关闭CS2" })
setIsMonitoring(false)
if (monitoringIntervalRef.current) {
clearInterval(monitoringIntervalRef.current)
monitoringIntervalRef.current = null
}
} catch (error) {
console.error("关闭游戏失败:", error)
addToast({
title: `关闭游戏失败: ${error instanceof Error ? error.message : String(error)}`,
variant: "flat",
})
}
}, [])
// 开始监控文件更新
useEffect(() => {
if (isMonitoring && steam.state.cs2Dir) {
// 每2秒检查一次文件更新
monitoringIntervalRef.current = setInterval(async () => {
const success = await readResult(true) // 静默读取
if (success) {
// 读取成功,停止监控
setIsMonitoring(false)
if (monitoringIntervalRef.current) {
clearInterval(monitoringIntervalRef.current)
monitoringIntervalRef.current = null
}
// 如果启用了自动关闭游戏,则关闭游戏
if (autoCloseGame) {
setTimeout(() => {
void closeGame()
}, 2000) // 延迟2秒关闭让用户看到结果
}
}
}, 2000) // 每2秒检查一次
} else {
// 停止监控
if (monitoringIntervalRef.current) {
clearInterval(monitoringIntervalRef.current)
monitoringIntervalRef.current = null
}
}
// 清理函数
return () => {
if (monitoringIntervalRef.current) {
clearInterval(monitoringIntervalRef.current)
monitoringIntervalRef.current = null
}
}
}, [isMonitoring, steam.state.cs2Dir, autoCloseGame, readResult, closeGame])
const startTest = async (mapConfig: (typeof BENCHMARK_MAPS)[0]) => {
if (!steam.state.steamDir || !steam.state.cs2Dir) {
addToast({ title: "请先配置 Steam 和 CS2 路径", variant: "flat", color: "warning" })
return
}
setTesting(true)
setSelectedMap(mapConfig.name)
setTestResult(null)
setTestTimestamp(null)
// 记录测试开始时间戳格式MM/DD HH:mm:ss
const now = new Date()
const month = String(now.getMonth() + 1).padStart(2, "0")
const day = String(now.getDate()).padStart(2, "0")
const hour = String(now.getHours()).padStart(2, "0")
const minute = String(now.getMinutes()).padStart(2, "0")
const second = String(now.getSeconds()).padStart(2, "0")
testStartTimestampRef.current = `${month}/${day} ${hour}:${minute}:${second}`
try {
const launchOption = `-allow_third_party_software -condebug -conclearlog +map_workshop ${mapConfig.workshopId} ${mapConfig.map}`
// 启动游戏
await invoke("launch_game", {
steamPath: `${steam.state.steamDir}\\steam.exe`,
launchOption: launchOption,
server: "worldwide",
})
addToast({ title: `已启动 ${mapConfig.label} 测试,正在自动监听结果...` })
setTesting(false)
// 开始自动监听文件更新
setIsMonitoring(true)
} catch (error) {
console.error("启动测试失败:", error)
addToast({
title: `启动测试失败: ${error instanceof Error ? error.message : String(error)}`,
variant: "flat",
})
setTesting(false)
setIsMonitoring(false)
// 启动失败,清除测试开始时间戳
testStartTimestampRef.current = null
}
}
return (
<Card className="w-full">
<CardHeader>
<CardIcon>
<TestTube size={16} />
</CardIcon>
</CardHeader>
<CardBody>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
{BENCHMARK_MAPS.map((mapConfig) => (
<Button
key={mapConfig.name}
size="sm"
isDisabled={testing || isMonitoring}
onPress={() => {
void startTest(mapConfig)
}}
className="px-4 font-medium py-1.5 transition bg-blue-200 dark:bg-blue-900/60 rounded-full select-none"
>
{testing && selectedMap === mapConfig.name ? (
<Spinner size="sm" className="mr-2" />
) : null}
{mapConfig.label}
</Button>
))}
<Button
size="sm"
isDisabled={isMonitoring}
onPress={() => {
void readResult()
}}
className="px-4 font-medium py-1.5 transition bg-green-200 dark:bg-green-900/60 rounded-full select-none"
>
</Button>
<Button
size="sm"
onPress={() => {
void closeGame()
}}
className="px-4 font-medium py-1.5 transition bg-orange-200 dark:bg-orange-900/60 rounded-full select-none"
>
<Power className="mr-1" size={14} />
</Button>
<Switch size="sm" isSelected={autoCloseGame} onValueChange={setAutoCloseGame} className="ml-4">
</Switch>
{isMonitoring && (
<Chip size="sm" color="primary" variant="flat">
...
</Chip>
)}
</div>
{testResult && (
<div className="mt-2">
<div className="flex items-center gap-2 mb-2">
<Chip size="sm"></Chip>
{testTimestamp && (
<Chip size="sm" variant="flat" color="default">
: {testTimestamp}
</Chip>
)}
</div>
<pre className="p-3 overflow-auto font-mono text-xs rounded-md bg-black/5 dark:bg-white/5">
{testResult}
</pre>
</div>
)}
</div>
</CardBody>
</Card>
)
}

View File

@@ -1,5 +1,5 @@
import { appConfigDir } from "@tauri-apps/api/path"
import { setStoreCollectionPath } from "@tauri-store/valtio"
import { commands } from "@tauri-store/shared"
import { appStore } from "./app"
import { steamStore } from "./steam"
import { toolStore } from "./tool"
@@ -10,5 +10,6 @@ export async function init() {
await toolStore.start()
await steamStore.start()
const appConfigDirPath = await appConfigDir()
await setStoreCollectionPath(path.resolve(appConfigDirPath, "cstb"))
const setPath = commands.setStoreCollectionPath("valtio")
await setPath(path.resolve(appConfigDirPath, "cstb"))
}