普通视图

发现新文章,点击刷新页面。
昨天以前elmagnifico

AC录像转行车线与轨迹分析

2026年4月6日 00:00

Foreword

AC模拟器跑完的结果或者录像一直都有,但是缺少具体分析,也没找到类似的分析工具,不如自己写一个,刚好利用Cursor来完全做一个项目,我不写一行代码,仅仅做分析和指导方向,看看是否AI能实现我的全部要求,也能观察转成AI写代码时,我们的输入到底要做到什么程度,这个东西才足够好用或者能够工程化。

ACReplay2AILine

第一个需求其实是比较简单的,分析acreplay的文件格式,然后将其转变成ideal_line.ai的格式。

做之前第一步是询问AI,是否能实现将录像轨迹转成AI行车线,AI表示可以,并且给了几个方案,这里我审过以后,确认了基础实现的技术线路。

image-20260406163337535

如果要我去找AC相关mod的制作信息并且了解清楚行车线和地图相关数据关系,还是比较耗时的,这里AI直接快速解决了问题。

核心结论就是只要非内置的行车线,就可以自行替代,而现在mod级别都不会内置行车线,恰恰方便了行车线的替换逻辑

当然也问了一下是否能根据车型、参数设置、赛道等等直接生成最优行车线,这里由于数据不全,所以AI回答也比较模糊,实际上AC有类似的Mod,但是那种行车线还是有延迟,而且不是很准。

接着就是先做一个最小的MVP,给出最核心的需求,先看一下是否能够实现。

根据回放1.388.acreplay文件转成zhuhai\data\ideal_line.ai格式,并替代

实际上给出这些命令,Cursor就已经完成了核心转换逻辑,实际测试确实替代了老的行车线,但是行车线还存在一些重复的部分并且刹车和油门的提示是错误的,但是轨迹基本都是正确的。

  • 仔细回看,实际上给出来的命令并没有说明要做到刹车油门提示正确,只是说替换一个轨迹而已,所以AI也只是做了这么多内容

基于上面的逻辑,让AI补充提取逻辑

结合记录中的刹车和油门信号,补充到行车线中使用红色或者绿色提示

到这一步,AI直接理解了,并且刹车和油门提示正确了,但是还是有问题,acreplay中飞行圈有时候不一定是第一圈,存在半圈或者开场圈的一点点路径,AI把这部分内容也弄进去,导致一部分轨迹是错误的

根据记录的计时点开始和结束位置,提取轨迹路径

给完这个以后,AI自动理解了计时开始应该从0,结束的时间应该比较长,到这里提取出来的轨迹就是相对完美的了,实际生成的ideal_line.ai已经是我要的轨迹线了

给出更多acreplay文件进行测试,AI自动发现了飞行圈选择的问题,他自己增加了参数选择第n圈,但是实际上我们需要的是最快圈速的那一圈,这一步应该自动选择,而不是还要用户输入

自动识别acreplay中圈速最快的一圈作为提取的轨迹

到这里ACReplay2AILine就完全正常工作了

#Requires -Version 5.1
<#
.SYNOPSIS
  用 acrp 解析录像,将轨迹写入任意赛道的 data\ideal_line.ai(版本 7)。
.DESCRIPTION
  简易用法(acrp.exe 与脚本同目录为默认):
    powershell -ExecutionPolicy Bypass -File .\BuildIdealLineFromReplay.ps1 `
      -Replay "C:\path\lap.acreplay" -TrackFolder "C:\...\content\tracks\zhuhai"

  多车手录像请指定 -DriverName。已导出 JSON 时可省略 -Replay,改用 -JsonPath。

  -TrackFolder: 赛道根目录(其下应有 data\ideal_line.ai,除非用 -IdealLinePath 覆盖)。
  -AcRpPath:  默认 = 脚本目录\acrp.exe

  路径可为绝对路径(如 C:\...\x.acreplay)、相对当前目录、或 ~ 开头(用户主目录);首尾引号会自动去掉。

  轨迹与赛道:ideal_line 只按录像里的世界坐标 x/y/z 重采样,与「目标赛道文件夹」无自动校验,
  请自行保证录像对应该赛道。计时线模式:在起点 currentLap 等于 -Lap 的若干区间中,直接取弧长最长的一段作为一圈(出场短段自然被排除)。

  -Lap 对应录像 JSON 里的 currentLap 整型(通常第 1 圈=0,第 2 圈=1 …),不限于 0/1;第 N 圈飞行一般传 N-1。
  不确定时用 -ShowLapHints 列出每个计时区间起点的 currentLap。
#>
[CmdletBinding()]
param(
    [string]$Replay,
    [string]$TrackFolder,
    [string]$AcRpPath,
    [string]$DriverName,
    [string]$JsonPath,
    [string]$CsvPath,
    [string]$IdealLinePath,
    [int]$Lap = 0,
    [bool]$UseTimingLine = $true,
    [double]$MinSegmentMeters = 50.0,
    [double]$DedupePlanarMin = 0.05,
    [switch]$WhatIf,
    [switch]$KeepTempJson,
    [switch]$ShowLapHints
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Resolve-FsPath([string]$Path) {
    if ($null -eq $Path -or [string]::IsNullOrWhiteSpace($Path)) { return $Path }
    $p = $Path.Trim()
    while ($p.Length -ge 2 -and $p.StartsWith('"') -and $p.EndsWith('"')) {
        $p = $p.Substring(1, $p.Length - 2).Trim()
    }
    if ($p.StartsWith('~')) {
        $rest = $p.Substring(1).TrimStart('\', '/')
        $p = if ($rest) { Join-Path $HOME $rest } else { $HOME }
    }
    return [IO.Path]::GetFullPath($p)
}

function Show-Usage {
    Write-Host @"
用法:
  BuildIdealLineFromReplay.ps1 -Replay <录像.acreplay> -TrackFolder <赛道文件夹> [选项]

必填(二选一):
  -Replay         Assetto Corsa 录像路径
  -TrackFolder     赛道根目录(内含 data\ideal_line.ai)

或已手动用 acrp 导出:
  -JsonPath / -CsvPath  与 -IdealLinePath(或 -TrackFolder)

常用选项:
  -DriverName      多车时指定车手名(传给 acrp --driver-name)
  -AcRpPath        默认: 脚本所在目录\acrp.exe
  -IdealLinePath   默认: <TrackFolder>\data\ideal_line.ai
  -Lap             与录像 currentLap 一致(第 2 圈多为 1,第 3 圈多为 2,依此类推)
  -ShowLapHints    只打印计时线分段与每段起点 currentLap,不写 ideal_line(仅需 JSON)
  -MinSegmentMeters  计时线模式下,若「该 Lap 最长区间」弧长仍小于此值(m)则放弃切段(防数据损坏),默认 50
  -UseTimingLine:`$false  关闭计时线截取
  -WhatIf          只预览不写文件

示例:
  powershell -ExecutionPolicy Bypass -File .\BuildIdealLineFromReplay.ps1 `
    -Replay ".\my.acreplay" -TrackFolder "..\zhuhai"
"@
}

$scriptDir = $null
if ($PSCommandPath) {
    $scriptDir = Split-Path -LiteralPath $PSCommandPath
} elseif ($PSScriptRoot) {
    $scriptDir = $PSScriptRoot
} else {
    try {
        $exePath = [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName
        if ($exePath -and (Test-Path -LiteralPath $exePath)) {
            $scriptDir = Split-Path -LiteralPath $exePath
        }
    } catch { }
    if (-not $scriptDir) {
        $a0 = [Environment]::GetCommandLineArgs()[0]
        if ($a0 -and (Test-Path -LiteralPath $a0)) {
            $scriptDir = Split-Path -LiteralPath $a0
        } else {
            $scriptDir = (Get-Location).Path
        }
    }
}
if (-not $scriptDir) { throw 'Cannot resolve script directory (expected exe or .ps1 path).' }
if (-not $AcRpPath -or [string]::IsNullOrWhiteSpace($AcRpPath)) {
    $AcRpPath = Join-Path $scriptDir 'acrp.exe'
} else {
    $AcRpPath = Resolve-FsPath $AcRpPath
}

$useJson = $false
$tempWork = $null

if ($Replay) {
    if (-not $TrackFolder) { throw "使用 -Replay 时必须同时指定 -TrackFolder(赛道根目录)。" }
    if (-not (Test-Path -LiteralPath $AcRpPath)) {
        throw "找不到 acrp.exe: $AcRpPath (可设置 -AcRpPath,或把 acrp.exe 放在脚本同目录)"
    }
    $replayFull = Resolve-FsPath $Replay
    if (-not (Test-Path -LiteralPath $replayFull)) { throw "找不到录像: $replayFull" }

    $trackFull = Resolve-FsPath $TrackFolder
    if (-not (Test-Path -LiteralPath $trackFull -PathType Container)) {
        throw "赛道目录不存在: $trackFull"
    }
    if (-not $IdealLinePath) {
        # "指定在哪里就在哪里":默认不再强制落到 data 子目录。
        $IdealLinePath = Resolve-FsPath (Join-Path $trackFull 'ideal_line.ai')
    } else {
        # 相对路径按当前工作目录解析,不再强制挂到 TrackFolder。
        $IdealLinePath = Resolve-FsPath $IdealLinePath
    }

    $tempWork = Join-Path ([IO.Path]::GetTempPath()) ('ac_ideal_' + [guid]::NewGuid().ToString('N'))
    New-Item -ItemType Directory -Path $tempWork -Force | Out-Null
    $outPrefix = Join-Path $tempWork 'acrp_out'

    $argList = New-Object System.Collections.Generic.List[string]
    [void]$argList.Add('-o')
    [void]$argList.Add($outPrefix)
    if ($DriverName) {
        [void]$argList.Add('--driver-name')
        [void]$argList.Add($DriverName)
    }
    [void]$argList.Add($replayFull)

    Write-Host "运行 acrp: $AcRpPath"
    $proc = Start-Process -FilePath $AcRpPath -ArgumentList $argList.ToArray() -Wait -PassThru -NoNewWindow
    if ($proc.ExitCode -ne 0) {
        if (-not $KeepTempJson) { Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue }
        throw "acrp.exe 退出码 $($proc.ExitCode)"
    }

    $jsonFiles = @(Get-ChildItem -LiteralPath $tempWork -Filter *.json -File | Sort-Object LastWriteTime -Descending)
    if ($jsonFiles.Count -eq 0) {
        if (-not $KeepTempJson) { Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue }
        throw "acrp 未在临时目录生成 JSON: $tempWork"
    }
    if ($jsonFiles.Count -gt 1 -and -not $DriverName) {
        if (-not $KeepTempJson) { Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue }
        throw "生成多个 JSON(多车?),请添加 -DriverName 指定车手。文件: $($jsonFiles.Name -join ', ')"
    }
    $JsonPath = $jsonFiles[0].FullName
    Write-Host "已解析: $JsonPath"
    $useJson = $true
} elseif ($JsonPath -or $CsvPath) {
    if ($JsonPath -and $CsvPath) { throw "请只指定 -JsonPath 或 -CsvPath 其中之一。" }
    if ($JsonPath) {
        $JsonPath = Resolve-FsPath $JsonPath
        if (-not (Test-Path -LiteralPath $JsonPath)) { throw "找不到 JSON: $JsonPath" }
        $useJson = $true
    } else {
        $CsvPath = Resolve-FsPath $CsvPath
        if (-not (Test-Path -LiteralPath $CsvPath)) { throw "找不到 CSV: $CsvPath" }
    }
    if (-not $IdealLinePath) {
        if (-not $TrackFolder) {
            if ($ShowLapHints -and $JsonPath) {
                $IdealLinePath = Join-Path ([IO.Path]::GetTempPath()) '_BuildIdealLine_skip.ai'
            } else {
                throw "使用 -JsonPath/-CsvPath 且未指定 -IdealLinePath 时,需要 -TrackFolder。"
            }
        } else {
            # "指定在哪里就在哪里":默认不再强制落到 data 子目录。
            $IdealLinePath = Resolve-FsPath (Join-Path (Resolve-FsPath $TrackFolder) 'ideal_line.ai')
        }
    } else {
        # 相对路径按当前工作目录解析,不再依赖 TrackFolder 作为基准。
        $IdealLinePath = Resolve-FsPath $IdealLinePath
    }
} else {
    Show-Usage
    throw "请提供 -Replay 与 -TrackFolder,或提供 -JsonPath / -CsvPath。"
}

if (-not $ShowLapHints) {
    if (-not (Test-Path -LiteralPath $IdealLinePath) -and $TrackFolder) {
        $trackBase = Resolve-FsPath $TrackFolder
        $fallbackTemplate = Join-Path $trackBase 'data\ideal_line.ai'
        if (Test-Path -LiteralPath $fallbackTemplate) {
            $outDir = Split-Path -Parent $IdealLinePath
            if ($outDir -and -not (Test-Path -LiteralPath $outDir)) {
                New-Item -ItemType Directory -Path $outDir -Force | Out-Null
            }
            Copy-Item -LiteralPath $fallbackTemplate -Destination $IdealLinePath -Force
            Write-Host "未找到目标 ideal_line.ai,已从模板复制: $fallbackTemplate -> $IdealLinePath"
        }
    }
    if (-not (Test-Path -LiteralPath $IdealLinePath)) {
        if ($tempWork -and -not $KeepTempJson) { Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue }
        throw "找不到 ideal_line.ai: $IdealLinePath"
    }
}

# --- 解析轨迹并写 ideal_line ---

function Parse-CsvLine([string]$line) {
    $cells = New-Object System.Collections.Generic.List[string]
    $cur = New-Object System.Text.StringBuilder
    $inQ = $false
    for ($i = 0; $i -lt $line.Length; $i++) {
        $c = $line[$i]
        if ($c -eq '"') {
            $inQ = -not $inQ
        } elseif (($c -eq ',') -and -not $inQ) {
            [void]$cells.Add($cur.ToString())
            [void]$cur.Clear()
        } else {
            [void]$cur.Append($c)
        }
    }
    [void]$cells.Add($cur.ToString())
    return ,$cells.ToArray()
}

function Get-SfCrossingIndices($j) {
    $cross = New-Object System.Collections.Generic.List[int]
    $nF = $j.currentLapTime.Count
    for ($i = 1; $i -lt $nF; $i++) {
        $a = [int]$j.currentLapTime[$i - 1]
        $b = [int]$j.currentLapTime[$i]
        $lapInc = [int]$j.currentLap[$i] - [int]$j.currentLap[$i - 1]
        if (($a - $b -gt 500) -or ($lapInc -gt 0)) {
            $prev = if ($cross.Count -gt 0) { $cross[$cross.Count - 1] } else { -9999 }
            if (($i - $prev) -gt 2) { [void]$cross.Add($i) }
        }
    }
    return $cross
}

function Measure-ArcJson($j, [int]$i0, [int]$i1Exclusive) {
    $s = 0.0
    $px = $null; $py = $null; $pz = $null
    for ($i = $i0; $i -lt $i1Exclusive; $i++) {
        $x = [double]$j.x[$i]; $y = [double]$j.y[$i]; $z = [double]$j.z[$i]
        if ($null -ne $px) {
            $dx = $x - $px; $dy = $y - $py; $dz = $z - $pz
            $s += [Math]::Sqrt($dx * $dx + $dy * $dy + $dz * $dz)
        }
        $px = $x; $py = $y; $pz = $z
    }
    return $s
}

function Select-TimingSegment($j, [int]$Lap, [double]$MinSegmentMeters) {
    $cross = Get-SfCrossingIndices $j
    if ($cross.Count -lt 2) {
        return @{ Start = -1; End = -1; Length = 0.0; Mode = 'no_crossings' }
    }
    $bestLen = -1.0
    $bestA = -1
    $bestB = -1
    for ($k = 0; $k -lt $cross.Count - 1; $k++) {
        $a = $cross[$k]
        $b = $cross[$k + 1]
        if ([int]$j.currentLap[$a] -ne $Lap) { continue }
        $len = Measure-ArcJson $j $a $b
        if ($len -gt $bestLen) {
            $bestLen = $len
            $bestA = $a
            $bestB = $b
        }
    }
    if ($bestA -lt 0) {
        return @{ Start = -1; End = -1; Length = 0.0; Mode = 'no_match' }
    }
    if ($bestLen -lt $MinSegmentMeters) {
        return @{ Start = -1; End = -1; Length = $bestLen; Mode = 'segment_too_short' }
    }
    return @{ Start = $bestA; End = $bestB; Length = $bestLen; Mode = 'longest_for_lap' }
}

if ($ShowLapHints) {
    if (-not $useJson) { throw "-ShowLapHints 仅支持 JSON(-Replay 或 -JsonPath),不支持 CSV。" }
    $jh = Get-Content -LiteralPath $JsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
    if (-not $jh.currentLap -or -not $jh.currentLapTime) {
        throw "JSON 缺少 currentLap 或 currentLapTime,无法分析计时线。"
    }
    $nH = $jh.currentLap.Count
    if ($jh.currentLapTime.Count -ne $nH) { throw "currentLap 与 currentLapTime 长度不一致。" }
    $xc = Get-SfCrossingIndices $jh
    Write-Host "=== ShowLapHints: $JsonPath ==="
    Write-Host "帧数=$nH  检测到计时线交叉索引数=$($xc.Count)"
    Write-Host "(过线后该帧的 currentLap 即「已开始计时的那一圈」编号,通常从 0 递增)"
    for ($ki = 0; $ki -lt $xc.Count; $ki++) {
        $ix = $xc[$ki]
        Write-Host ("  交叉#{0}: frame={1}  currentLap={2}  currentLapTime={3} ms" -f $ki, $ix, [int]$jh.currentLap[$ix], [int]$jh.currentLapTime[$ix])
    }
    for ($ki = 0; $ki -lt $xc.Count - 1; $ki++) {
        $a = $xc[$ki]
        $b = $xc[$ki + 1]
        $alen = Measure-ArcJson $jh $a $b
        $lapAtStart = [int]$jh.currentLap[$a]
        Write-Host ("  区间 frame {0}..{1}: 起点 currentLap={2}  弧长约 {3:F1} m  (同 Lap 多段时脚本取最长段)" -f $a, $b, $lapAtStart, $alen)
    }
    Write-Host "当前默认 -Lap=$Lap;若飞行圈是「第 3 圈」且 AC 从 0 编号,多为 -Lap 2。"
    if ($tempWork -and (Test-Path -LiteralPath $tempWork) -and -not $KeepTempJson) {
        Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue
    }
    exit 0
}

try {
$pts = New-Object System.Collections.Generic.List[object]
$hasPedals = $false
$timingMode = 'n/a'
if ($useJson) {
    $j = Get-Content -LiteralPath $JsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
    if (-not $j.x -or -not $j.y -or -not $j.z) { throw "JSON 缺少 x/y/z 数组(请确认为 acrp 导出)。" }
    if (-not $j.currentLap) { throw "JSON 缺少 currentLap 数组。" }
    $nF = $j.x.Count
    if ($j.y.Count -ne $nF -or $j.z.Count -ne $nF -or $j.currentLap.Count -ne $nF) {
        throw "JSON 中 x/y/z/currentLap 长度不一致。"
    }
    if ($j.gas -and $j.brake -and ($j.gas.Count -eq $nF) -and ($j.brake.Count -eq $nF)) {
        $hasPedals = $true
    }

    $iStart = 0
    $iEnd = $nF
    $timingUsed = $false
    if (-not $UseTimingLine) {
        $timingMode = 'timing_disabled'
    } elseif ($j.currentLapTime -and ($j.currentLapTime.Count -eq $nF)) {
        $seg = Select-TimingSegment $j $Lap $MinSegmentMeters
        if ($seg.Start -ge 0) {
            $iStart = $seg.Start
            $iEnd = $seg.End
            $timingUsed = $true
            $timingMode = $seg.Mode
        } elseif ($seg.Mode -eq 'segment_too_short') {
            $timingMode = 'segment_too_short'
            Write-Warning ("计时线切段: 该 Lap 下最长区间仅 {0:F1} m,低于 -MinSegmentMeters ({1} m),已放弃切段。可调小 -MinSegmentMeters 或检查录像。" -f $seg.Length, $MinSegmentMeters)
        } elseif ($seg.Mode -eq 'no_crossings') {
            $timingMode = 'no_crossings'
            Write-Warning "录像中未检测到计时线交叉(currentLapTime/圈数变化),已按整段 -Lap 过滤取点。"
        } else {
            $timingMode = 'lap_filter_pending'
        }
    } else {
        $timingMode = 'no_currentLapTime'
        Write-Warning "JSON 无 currentLapTime 或与帧数不一致,已跳过计时线切段,仅按 -Lap 过滤。"
    }

    for ($i = $iStart; $i -lt $iEnd; $i++) {
        if (-not $timingUsed) {
            if ([int]$j.currentLap[$i] -ne $Lap) { continue }
        }
        $g = if ($hasPedals) { [int]$j.gas[$i] } else { 0 }
        $bk = if ($hasPedals) { [int]$j.brake[$i] } else { 0 }
        if ($g -lt 0) { $g = 0 } elseif ($g -gt 255) { $g = 255 }
        if ($bk -lt 0) { $bk = 0 } elseif ($bk -gt 255) { $bk = 255 }
        [void]$pts.Add([pscustomobject]@{
                X = [float][double]$j.x[$i]
                Y = [float][double]$j.y[$i]
                Z = [float][double]$j.z[$i]
                G = $g
                Bk = $bk
            })
    }
    if ($UseTimingLine -and -not $timingUsed) {
        if ($timingMode -eq 'lap_filter_pending') { $timingMode = 'no_segment_for_lap' }
        Write-Warning "未找到起点 currentLap=$Lap 的计时区间(或交叉点不足),已回退为整段 Lap 过滤。可运行 -ShowLapHints 查看每段起点应对的 -Lap,或 -UseTimingLine:`$false。"
    }
} else {
    $timingMode = 'csv'
    $hdr = Get-Content -LiteralPath $CsvPath -TotalCount 1 -Encoding UTF8
    $names = Parse-CsvLine $hdr
    $ixX = [array]::IndexOf($names, 'position.x')
    $ixY = [array]::IndexOf($names, 'position.y')
    $ixZ = [array]::IndexOf($names, 'position.z')
    $ixLap = [array]::IndexOf($names, 'currentLap')
    $ixGas = [array]::IndexOf($names, 'gas')
    $ixBrake = [array]::IndexOf($names, 'brake')
    if ($ixX -lt 0 -or $ixY -lt 0 -or $ixZ -lt 0) { throw "CSV 缺少 position.x/y/z 列,请确认由 acreplay-parser 导出。" }
    if ($ixLap -lt 0) { throw "CSV 缺少 currentLap 列。" }
    if ($ixGas -ge 0 -and $ixBrake -ge 0) { $hasPedals = $true }

    $reader = [IO.StreamReader]::new($CsvPath, [Text.Encoding]::UTF8, $true)
    try {
        [void]$reader.ReadLine()
        while ($null -ne ($line = $reader.ReadLine())) {
            if ([string]::IsNullOrWhiteSpace($line)) { continue }
            $c = Parse-CsvLine $line
            if ($c.Count -le [Math]::Max($ixX, [Math]::Max($ixY, [Math]::Max($ixZ, $ixLap)))) { continue }
            $lapVal = 0
            [void][int]::TryParse($c[$ixLap].Trim(), [ref]$lapVal)
            if ($lapVal -ne $Lap) { continue }
            $x = [double]::Parse($c[$ixX].Trim(), [Globalization.CultureInfo]::InvariantCulture)
            $y = [double]::Parse($c[$ixY].Trim(), [Globalization.CultureInfo]::InvariantCulture)
            $z = [double]::Parse($c[$ixZ].Trim(), [Globalization.CultureInfo]::InvariantCulture)
            $g = 0; $bk = 0
            if ($hasPedals) {
                [void][int]::TryParse($c[$ixGas].Trim(), [ref]$g)
                [void][int]::TryParse($c[$ixBrake].Trim(), [ref]$bk)
            }
            if ($g -lt 0) { $g = 0 } elseif ($g -gt 255) { $g = 255 }
            if ($bk -lt 0) { $bk = 0 } elseif ($bk -gt 255) { $bk = 255 }
            [void]$pts.Add([pscustomobject]@{ X = [float]$x; Y = [float]$y; Z = [float]$z; G = $g; Bk = $bk })
        }
    } finally { $reader.Close() }
}

if ($DedupePlanarMin -gt 0 -and $pts.Count -gt 2) {
    $dd = New-Object System.Collections.Generic.List[object]
    [void]$dd.Add($pts[0])
    for ($di = 1; $di -lt $pts.Count; $di++) {
        $a = $dd[$dd.Count - 1]
        $b = $pts[$di]
        $dh = [Math]::Sqrt([double](($b.X - $a.X) * ($b.X - $a.X) + ($b.Z - $a.Z) * ($b.Z - $a.Z)))
        if ($dh -ge $DedupePlanarMin) { [void]$dd.Add($b) }
    }
    $pts = $dd
}

if ($pts.Count -lt 200) { throw "该圈采样点过少 ($($pts.Count)),请检查 -DriverName / -Lap / -UseTimingLine。" }

$clean = New-Object System.Collections.Generic.List[object]
[void]$clean.Add($pts[0])
for ($i = 1; $i -lt $pts.Count; $i++) {
    $a = $clean[$clean.Count - 1]
    $b = $pts[$i]
    $d = [Math]::Sqrt([double](($b.X - $a.X) * ($b.X - $a.X) + ($b.Z - $a.Z) * ($b.Z - $a.Z)))
    if ($d -lt 80.0) { [void]$clean.Add($b) }
}
$pts = $clean
if ($pts.Count -lt 200) { throw "过滤跳变后点数不足 ($($pts.Count))。" }

$segLen = New-Object double[] ($pts.Count)
$cum = New-Object double[] ($pts.Count)
$cum[0] = 0.0
for ($i = 1; $i -lt $pts.Count; $i++) {
    $dx = [double]$pts[$i].X - [double]$pts[$i - 1].X
    $dy = [double]$pts[$i].Y - [double]$pts[$i - 1].Y
    $dz = [double]$pts[$i].Z - [double]$pts[$i - 1].Z
    $segLen[$i] = [Math]::Sqrt($dx * $dx + $dy * $dy + $dz * $dz)
    $cum[$i] = $cum[$i - 1] + $segLen[$i]
}
$replayTotal = $cum[$pts.Count - 1]
if ($replayTotal -lt 100.0) { throw "该圈弧长异常短 ($replayTotal m),请换 -Lap 或检查录像。" }

function Get-PointAtDistance([object[]]$p, [double[]]$c, [double]$dist) {
    if ($dist -le 0) { return $p[0] }
    $max = $c[$p.Length - 1]
    if ($dist -ge $max) { return $p[$p.Length - 1] }
    $lo = 0
    $hi = $p.Length - 1
    while ($hi - $lo -gt 1) {
        $mid = [int](($lo + $hi) / 2)
        if ($c[$mid] -le $dist) { $lo = $mid } else { $hi = $mid }
    }
    $i = $lo
    $t = if (($c[$i + 1] - $c[$i]) -gt 1e-6) { ($dist - $c[$i]) / ($c[$i + 1] - $c[$i]) } else { 0.0 }
    $ax = [double]$p[$i].X; $ay = [double]$p[$i].Y; $az = [double]$p[$i].Z
    $bx = [double]$p[$i + 1].X; $by = [double]$p[$i + 1].Y; $bz = [double]$p[$i + 1].Z
    return [pscustomobject]@{
        X = [float]($ax + $t * ($bx - $ax))
        Y = [float]($ay + $t * ($by - $ay))
        Z = [float]($az + $t * ($bz - $az))
    }
}

function Get-Pedal01AtDistance([object[]]$p, [double[]]$c, [double]$dist, [bool]$pickGas) {
    if ($dist -le 0) {
        $v = if ($pickGas) { [double]$p[0].G } else { [double]$p[0].Bk }
        return [float]($v / 255.0)
    }
    $max = $c[$p.Length - 1]
    if ($dist -ge $max) {
        $v = if ($pickGas) { [double]$p[$p.Length - 1].G } else { [double]$p[$p.Length - 1].Bk }
        return [float]($v / 255.0)
    }
    $lo = 0
    $hi = $p.Length - 1
    while ($hi - $lo -gt 1) {
        $mid = [int](($lo + $hi) / 2)
        if ($c[$mid] -le $dist) { $lo = $mid } else { $hi = $mid }
    }
    $i = $lo
    $tt = if (($c[$i + 1] - $c[$i]) -gt 1e-6) { ($dist - $c[$i]) / ($c[$i + 1] - $c[$i]) } else { 0.0 }
    $va = if ($pickGas) { [double]$p[$i].G } else { [double]$p[$i].Bk }
    $vb = if ($pickGas) { [double]$p[$i + 1].G } else { [double]$p[$i + 1].Bk }
    return [float](($va + $tt * ($vb - $va)) / 255.0)
}

$bytes = [IO.File]::ReadAllBytes($IdealLinePath)
$ver = [BitConverter]::ToInt32($bytes, 0)
if ($ver -ne 7) { throw "ideal_line 版本为 $ver,本脚本仅按版本 7 处理。" }
$n = [BitConverter]::ToInt32($bytes, 4)
if ($n -lt 10) { throw "点数异常: $n" }

$oldLens = New-Object float[] $n
for ($i = 0; $i -lt $n; $i++) {
    $o = 16 + $i * 20 + 12
    $oldLens[$i] = [BitConverter]::ToSingle($bytes, $o)
}
$oldMax = [double]$oldLens[$n - 1]
if ($oldMax -lt 1.0) { throw "原线累计长度异常。" }

if ($WhatIf) {
    $pedalNote = if ($hasPedals) { "写入 Gas/Brake" } else { "无油门刹车数据,不改颜色" }
    Write-Host "WhatIf: $IdealLinePath | $n 点 | Lap=$Lap | timing=$timingMode | 采样 $($pts.Count) | 弧长 $replayTotal m | 原线长 $oldMax m | $pedalNote"
    exit 0
}

$bak = $IdealLinePath + ".bak_" + (Get-Date -Format "yyyyMMdd_HHmmss")
Copy-Item -LiteralPath $IdealLinePath -Destination $bak -Force
Write-Host "已备份: $bak"

$newLens = New-Object float[] $n
for ($i = 0; $i -lt $n; $i++) {
    $frac = [double]$oldLens[$i] / $oldMax
    $d = $frac * $replayTotal
    $newLens[$i] = [float]$d
    $q = Get-PointAtDistance $pts $cum $d
    $o = 16 + $i * 20
    [Array]::Copy([BitConverter]::GetBytes($q.X), 0, $bytes, $o, 4)
    [Array]::Copy([BitConverter]::GetBytes($q.Y), 0, $bytes, $o + 4, 4)
    [Array]::Copy([BitConverter]::GetBytes($q.Z), 0, $bytes, $o + 8, 4)
    [Array]::Copy([BitConverter]::GetBytes($newLens[$i]), 0, $bytes, $o + 12, 4)
}

$PointExtraStride = 72
$nEx = [BitConverter]::ToInt32($bytes, 16 + 20 * $n)
$extraStart = 16 + 20 * $n + 4
if ($hasPedals -and ($nEx -eq $n) -and (($bytes.Length - $extraStart) -ge ($n * $PointExtraStride))) {
    $ptArr = $pts.ToArray()
    for ($i = 0; $i -lt $n; $i++) {
        $d = [double]$newLens[$i]
        $gas01 = Get-Pedal01AtDistance $ptArr $cum $d $true
        $brake01 = Get-Pedal01AtDistance $ptArr $cum $d $false
        $eo = $extraStart + $i * $PointExtraStride
        [Array]::Copy([BitConverter]::GetBytes($gas01), 0, $bytes, $eo + 4, 4)
        [Array]::Copy([BitConverter]::GetBytes($brake01), 0, $bytes, $eo + 8, 4)
    }
    Write-Host "已更新 PointsExtra 的 Gas/Brake。"
} elseif ($hasPedals) {
    Write-Warning "PointsExtra 与点数不匹配,已跳过颜色写入。"
}

[IO.File]::WriteAllBytes($IdealLinePath, $bytes)
Write-Host "完成: $IdealLinePath"
}
finally {
    if ($tempWork -and (Test-Path -LiteralPath $tempWork) -and -not $KeepTempJson) {
        Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue
    }
}

到这里ACReplay2AILine基本完成,但是还是存在一些问题,他只能修改ailine,并不能凭空生成,所以需要先把原本赛道的ailine拿到,才能改,然后其中未修改的值,可能有一部分是不对的或者不匹配的。

image-20260406174421500

ACReplay Analysis

实现了上面的回放转行车线,我就思考是否可以把轨迹内容输出,并且和地图匹配到一起,这样就能单独看到自己在每个弯的刹车点、速度和开油点和对应的速度了,这样做分析完就可以模板化操作了。

实际想得还是太简单了,游戏内mod的数据给的太少了,赛道边界信息啥的都没给,而且现实中的T1-T14是人为定义的,实际上游戏内根本没有这个弯道定义,游戏内只是对赛道进行了3个segment的分段,要把现实和游戏的轨迹匹配上就有点困难了。

然后再说一个,游戏内是没有经纬度信息的,使用的xyz坐标系统,而现实T1-T14都不是,他们的经纬度信息缺少,这让匹配就更难了。

开始的几次尝试基本都失败了,回放轨迹和赛道匹配不上,比例大小都不正确,其实是缺少了赛道的宽度具体赛道边界曲线信息。

基于此放弃了赛道匹配,直接画轨迹,这个部分没问题,但是弯道匹配还是有误,T1-T14怎么都对不上,反复调整代码也不行,这个流程估计耗时两三个小时,最后放弃了。

直接使用轨迹和刹车点、开油点的逻辑,把每次操作的位置和此时时速都标识出来

image-20260406175033265

然后就得到了这样一张图,我称为 Action 图(操作图),可以清晰看到每个位置大概以多少速度刹车、大概在什么位置开油。

  • 不过还是有点小问题,中间不给油或者保持油门的细节没有

这里比较麻烦的点是油门和刹车的判断,刹多少算刹车,持续多久算一次?同理油门,一开始沟通时没有给到这部分信息,让AI自主判断,但是结果是比较差的,出现各种奇怪情况,比如油门默认高时,不算油门上升,刹车默认高也不算刹车,上升比例要求的太多了,导致细节反馈不出来

反馈给AI以后再次生成,依然错误,甚至越改越偏。但是由于基础代码被改了,没有 commit,导致最后一错到底,无法纠正回来,只好放弃掉这部分 AI,重新梳理逻辑,再重新对话写代码。

反复调试,增加约束调节以后,总算得到了一个正确的图,并且增加用例测试,得到的结果都还行

#Requires -Version 5.1
<#
.SYNOPSIS
  Generic replay lap analyzer: detect brake/throttle onsets and render trajectory markers.
  If replay/corners data files are missing, they are auto-generated from the provided replay.
#>
param(
    [string]$JsonPath = '',
    [string]$TrackFolder = '',
    [string]$CornersJson = '',
    [string]$ReplayPath = '',
    [string]$AcRpPath = '',
    [string]$DriverName = '',
    [string]$OutputPath = '',
    [int]$Lap = 0,
    [bool]$AutoFastestLap = $true,
    [double]$MinSegmentMeters = 50.0,
    [int]$ImageWidth = 1800,
    [int]$ImageHeight = 1350,
    [double]$InnerMarginPercent = 5.0,
    [float]$FontSizeTitle = 20.0,
    [float]$FontSizeMarker = 14.0,
    [double]$BrakeMinSeconds = 0.3,
    [double]$ThrottleMinSeconds = 0.5,
    [int]$BrakePedalThreshold = 25,
    [int]$GasPedalThreshold = 180,
    [double]$GasReapplyMinSeconds = 0.06,
    [int]$GasReapplyThreshold = 60,
    [int]$GasReapplyDelta = 20,
    [int]$GasReapplyBrakeMax = 20,
    [bool]$AllowOverlapThrottleBetweenBrakes = $true,
    [double]$SectorExpandMeters = 20.0,
    [switch]$DebugEventTrace,
    [string]$DebugOutputPath = '',
    [switch]$HideCornerCenterLabel,
    [switch]$NoVerticalFlip,
    [switch]$FlipWorldZ
)

$ErrorActionPreference = 'Stop'
# PS2EXE 嵌入执行时 $PSScriptRoot / $PSCommandPath 可能为空;
# 优先使用进程主模块路径,确保在“当前目录不等于exe目录”时也能稳定定位工具目录。
$toolDir = $null
if ($PSCommandPath) {
    $toolDir = Split-Path -LiteralPath $PSCommandPath
} elseif ($PSScriptRoot) {
    $toolDir = $PSScriptRoot
} else {
    try {
        $exePath = [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName
        if ($exePath -and (Test-Path -LiteralPath $exePath)) {
            $toolDir = Split-Path -LiteralPath $exePath
        }
    } catch { }
    if (-not $toolDir) {
        $a0 = [Environment]::GetCommandLineArgs()[0]
        if ($a0 -and (Test-Path -LiteralPath $a0)) {
            $toolDir = Split-Path -LiteralPath $a0
        } else {
            $toolDir = (Get-Location).Path
        }
    }
}
if (-not $toolDir) { throw 'Cannot resolve tool directory (expected exe or .ps1 path).' }
if (-not $TrackFolder) { $TrackFolder = Join-Path (Split-Path $toolDir -Parent) 'zhuhai' }
Add-Type -AssemblyName System.Drawing

$capPath = Join-Path $toolDir 'draw_trajectory_captions.json'
$cap = [pscustomobject]@{ sf = 'S/F'; titlePrefix = 'Replay Lap Analysis'; legend = 'Blue=track Orange=S/F Red=brake Green=throttle' }
if (Test-Path -LiteralPath $capPath) {
    $cj = Get-Content -LiteralPath $capPath -Raw -Encoding UTF8 | ConvertFrom-Json
    if ($cj.sf) { $cap.sf = [string]$cj.sf }
    if ($cj.titlePrefix) { $cap.titlePrefix = [string]$cj.titlePrefix }
    if ($cj.legend) { $cap.legend = [string]$cj.legend }
}

function Resolve-FsPath([string]$Path) {
    if ([string]::IsNullOrWhiteSpace($Path)) { return $Path }
    $p = $Path.Trim()
    while ($p.Length -ge 2 -and $p.StartsWith('"') -and $p.EndsWith('"')) {
        $p = $p.Substring(1, $p.Length - 2).Trim()
    }
    if ($p.StartsWith('~')) {
        $rest = $p.Substring(1).TrimStart('\', '/')
        $p = if ($rest) { Join-Path $HOME $rest } else { $HOME }
    }
    return [IO.Path]::GetFullPath($p)
}

function Get-FileStem([string]$pathOrName, [string]$fallback) {
    if ([string]::IsNullOrWhiteSpace($pathOrName)) { return $fallback }
    $nm = [IO.Path]::GetFileNameWithoutExtension($pathOrName)
    if ([string]::IsNullOrWhiteSpace($nm)) { return $fallback }
    return $nm
}

function Clamp-Int([int]$v, [int]$lo, [int]$hi) {
    if ($v -lt $lo) { return $lo }
    if ($v -gt $hi) { return $hi }
    return $v
}

function Get-SfCrossingIndices($j) {
    $cross = New-Object System.Collections.Generic.List[int]
    if (-not $j.currentLapTime -or ($j.currentLapTime.Count -ne $j.x.Count)) { return $cross }
    for ($i = 1; $i -lt $j.currentLapTime.Count; $i++) {
        $a = [int]$j.currentLapTime[$i - 1]; $b = [int]$j.currentLapTime[$i]
        $lapInc = [int]$j.currentLap[$i] - [int]$j.currentLap[$i - 1]
        if (($a - $b -gt 500) -or ($lapInc -gt 0)) {
            $prev = if ($cross.Count -gt 0) { $cross[$cross.Count - 1] } else { -9999 }
            if (($i - $prev) -gt 2) { [void]$cross.Add($i) }
        }
    }
    return $cross
}

function Measure-ArcJson($j, [int]$i0, [int]$i1Exclusive) {
    $s = 0.0; $px = $null; $py = $null; $pz = $null
    for ($i = $i0; $i -lt $i1Exclusive; $i++) {
        $x = [double]$j.x[$i]; $y = [double]$j.y[$i]; $z = [double]$j.z[$i]
        if ($null -ne $px) {
            $dx = $x - $px; $dy = $y - $py; $dz = $z - $pz
            $s += [Math]::Sqrt($dx * $dx + $dy * $dy + $dz * $dz)
        }
        $px = $x; $py = $y; $pz = $z
    }
    return $s
}

function Select-TimingSegment($j, [int]$LapVal, [double]$MinSeg) {
    $cross = Get-SfCrossingIndices $j
    if ($cross.Count -lt 2) { return @{ Start = -1; End = -1; Length = 0.0; Mode = 'no_crossings' } }
    $bestLen = -1.0; $bestA = -1; $bestB = -1
    for ($k = 0; $k -lt $cross.Count - 1; $k++) {
        $a = $cross[$k]; $b = $cross[$k + 1]
        if ([int]$j.currentLap[$a] -ne $LapVal) { continue }
        $len = Measure-ArcJson $j $a $b
        if ($len -gt $bestLen) { $bestLen = $len; $bestA = $a; $bestB = $b }
    }
    if ($bestA -lt 0) { return @{ Start = -1; End = -1; Length = 0.0; Mode = 'no_match' } }
    if ($bestLen -lt $MinSeg) { return @{ Start = -1; End = -1; Length = $bestLen; Mode = 'segment_too_short' } }
    return @{ Start = $bestA; End = $bestB; Length = $bestLen; Mode = 'ok' }
}

function Select-FastestTimingSegment($j, [double]$MinSeg) {
    $cross = Get-SfCrossingIndices $j
    if ($cross.Count -lt 2) { return @{ Start = -1; End = -1; Length = 0.0; Mode = 'no_crossings'; Lap = -1; TimeMs = -1 } }

    $best = $null
    for ($k = 0; $k -lt $cross.Count - 1; $k++) {
        $a = $cross[$k]; $b = $cross[$k + 1]
        $lapVal = [int]$j.currentLap[$a]
        $len = Measure-ArcJson $j $a $b
        if ($len -lt $MinSeg) { continue }

        $timeMs = -1
        if ($j.PSObject.Properties.Name -contains 'currentLapTime') {
            $ti = [int]$j.currentLapTime[[Math]::Max($a, $b - 1)]
            if ($ti -gt 0) { $timeMs = $ti }
        }
        if ($timeMs -le 0) {
            $dt = Get-FrameDtSeconds $j
            $timeMs = [int][Math]::Round(($b - $a) * $dt * 1000.0)
        }

        $cand = @{
            Start = $a
            End = $b
            Length = $len
            Mode = 'ok'
            Lap = $lapVal
            TimeMs = $timeMs
        }
        if ($null -eq $best -or $cand.TimeMs -lt $best.TimeMs) {
            $best = $cand
        }
    }

    if ($null -eq $best) { return @{ Start = -1; End = -1; Length = 0.0; Mode = 'no_valid_segment'; Lap = -1; TimeMs = -1 } }
    return $best
}

function Get-SpeedKmh($j, [int]$fi) {
    $vx = [double]$j.velocityX[$fi]; $vy = [double]$j.velocityY[$fi]; $vz = [double]$j.velocityZ[$fi]
    return [Math]::Sqrt($vx * $vx + $vy * $vy + $vz * $vz) * 3.6
}

function Get-FrameDtSeconds($j) {
    if ($j.PSObject.Properties.Name -contains 'recordingInterval') {
        $ri = [double]$j.recordingInterval
        if ($ri -gt 0 -and $ri -le 100.0) { return $ri / 1000.0 }
        if ($ri -gt 100.0) { return 1.0 / $ri }
    }
    return (1.0 / 60.0)
}

function Get-BoundariesFromSegmentEnds([double[]]$ends) {
    if ($ends.Count -ne 14) { throw 'segmentEndFraction must have 14 elements, last=1.0' }
    if ([Math]::Abs($ends[13] - 1.0) -gt 0.001) { throw 'segmentEndFraction[13] must be 1.0' }
    $b = New-Object double[] 15
    $b[0] = 0.0
    for ($i = 0; $i -lt 14; $i++) { $b[$i + 1] = $ends[$i] }
    return $b
}

function Find-KRangeForArc([double[]]$sArr, [double]$lapLen, [double]$f0, [double]$f1, [int]$m) {
    $s0 = [Math]::Max(0.0, $f0 * $lapLen)
    $s1 = [Math]::Min($lapLen, $f1 * $lapLen)
    $k0 = 0
    for ($k = 0; $k -lt $m; $k++) {
        if ($sArr[$k] -ge $s0) { $k0 = $k; break }
    }
    $k1 = $m - 1
    for ($k = $m - 1; $k -ge 0; $k--) {
        if ($sArr[$k] -le $s1) { $k1 = $k; break }
    }
    if ($k1 -lt $k0) { $k1 = $k0 }
    return $k0, $k1
}

function Find-KClosestToS([double[]]$sArr, [double]$targetS, [int]$k0, [int]$k1) {
    $best = $k0
    $bd = [Math]::Abs($sArr[$k0] - $targetS)
    for ($k = $k0; $k -le $k1; $k++) {
        $d = [Math]::Abs($sArr[$k] - $targetS)
        if ($d -lt $bd) { $bd = $d; $best = $k }
    }
    return $best
}

function Find-FirstSustainedAbove([int[]]$vals, [int]$k0, [int]$k1, [int]$thr, [int]$minFrames) {
    if ($minFrames -lt 1) { $minFrames = 1 }
    for ($start = $k0; $start -le $k1; $start++) {
        if ($vals[$start] -lt $thr) { continue }
        $ok = $true
        for ($i = 0; $i -lt $minFrames; $i++) {
            $kk = $start + $i
            if ($kk -gt $k1) { $ok = $false; break }
            if ($vals[$kk] -lt $thr) { $ok = $false; break }
        }
        if ($ok) { return $start }
    }
    return -1
}

function Find-FirstRisingSustainedAbove([int[]]$vals, [int]$k0, [int]$k1, [int]$thr, [int]$minFrames) {
    if ($minFrames -lt 1) { $minFrames = 1 }
    $st0 = [Math]::Max(1, $k0)
    for ($start = $st0; $start -le $k1; $start++) {
        # Rising edge: previous frame below threshold, current frame reaches threshold.
        if ($vals[$start - 1] -ge $thr) { continue }
        if ($vals[$start] -lt $thr) { continue }
        $ok = $true
        for ($i = 0; $i -lt $minFrames; $i++) {
            $kk = $start + $i
            if ($kk -gt $k1) { $ok = $false; break }
            if ($vals[$kk] -lt $thr) { $ok = $false; break }
        }
        if ($ok) { return $start }
    }
    return -1
}

function Find-FirstGasReapply([int[]]$gasVals, [int[]]$brkVals, [int]$k0, [int]$k1, [int]$minGas, [int]$minDelta, [int]$brakeMax, [int]$minFrames) {
    if ($minFrames -lt 1) { $minFrames = 1 }
    $st0 = [Math]::Max(1, $k0)
    for ($start = $st0; $start -le $k1; $start++) {
        if ($brkVals[$start] -gt $brakeMax) { continue }
        if ($gasVals[$start] -lt $minGas) { continue }
        if (($gasVals[$start] - $gasVals[$start - 1]) -lt $minDelta) { continue }
        $ok = $true
        for ($i = 0; $i -lt $minFrames; $i++) {
            $kk = $start + $i
            if ($kk -gt $k1) { $ok = $false; break }
            if ($gasVals[$kk] -lt $minGas) { $ok = $false; break }
            if ($brkVals[$kk] -gt $brakeMax) { $ok = $false; break }
        }
        if ($ok) { return $start }
    }
    return -1
}

function Find-FirstGasReapplyOverlap([int[]]$gasVals, [int]$k0, [int]$k1, [int]$minGas, [int]$minDelta, [int]$minFrames) {
    if ($minFrames -lt 1) { $minFrames = 1 }
    $st0 = [Math]::Max(1, $k0)
    for ($start = $st0; $start -le $k1; $start++) {
        if ($gasVals[$start] -lt $minGas) { continue }
        if (($gasVals[$start] - $gasVals[$start - 1]) -lt $minDelta) { continue }
        $ok = $true
        for ($i = 0; $i -lt $minFrames; $i++) {
            $kk = $start + $i
            if ($kk -gt $k1) { $ok = $false; break }
            if ($gasVals[$kk] -lt $minGas) { $ok = $false; break }
        }
        if ($ok) { return $start }
    }
    return -1
}

function Get-ContiguousRunEnd([int[]]$vals, [int]$start, [int]$k1, [int]$thr) {
    $e = $start
    for ($k = $start; $k -le $k1; $k++) {
        if ($vals[$k] -ge $thr) { $e = $k } else { break }
    }
    return $e
}

function Get-LongestRunAbove([int[]]$vals, [int]$k0, [int]$k1, [int]$thr) {
    if ($k1 -lt $k0) { return 0 }
    $best = 0
    $cur = 0
    for ($k = $k0; $k -le $k1; $k++) {
        if ($vals[$k] -ge $thr) {
            $cur++
            if ($cur -gt $best) { $best = $cur }
        } else {
            $cur = 0
        }
    }
    return $best
}

function New-CjkDrawingFont([float]$emSize, [System.Drawing.FontStyle]$style) {
    $unit = [System.Drawing.GraphicsUnit]::Point
    foreach ($n in @('Microsoft YaHei UI', 'Microsoft YaHei', 'SimHei', 'Segoe UI')) {
        try {
            $fam = New-Object System.Drawing.FontFamily $n
            if ($fam.IsStyleAvailable($style)) { return [System.Drawing.Font]::new($fam, $emSize, $style, $unit) }
        } catch { }
    }
    return [System.Drawing.Font]::new('Segoe UI', $emSize, $style, $unit)
}

function Ensure-ReplayJson([string]$TargetJsonPath, [string]$ReplayPathIn, [string]$AcRpPathIn, [string]$DriverNameIn) {
    if (Test-Path -LiteralPath $TargetJsonPath) { return }
    $acrp = if ([string]::IsNullOrWhiteSpace($AcRpPathIn)) { Join-Path $toolDir 'acrp.exe' } else { Resolve-FsPath $AcRpPathIn }
    if (-not (Test-Path -LiteralPath $acrp)) {
        throw "Replay JSON missing and acrp.exe not found: $acrp"
    }

    $replay = $ReplayPathIn
    if ([string]::IsNullOrWhiteSpace($replay)) {
        $rp = @(Get-ChildItem -LiteralPath $toolDir -Filter *.acreplay -File | Sort-Object LastWriteTime -Descending)
        if ($rp.Count -lt 1) { throw "Replay JSON missing and no .acreplay found in $toolDir" }
        $replay = $rp[0].FullName
    } else {
        $replay = Resolve-FsPath $replay
    }
    if (-not (Test-Path -LiteralPath $replay)) { throw "Replay file not found: $replay" }

    $outDir = Split-Path -Parent $TargetJsonPath
    if ($outDir -and -not (Test-Path -LiteralPath $outDir)) {
        New-Item -ItemType Directory -Path $outDir -Force | Out-Null
    }

    $tempWork = Join-Path ([IO.Path]::GetTempPath()) ('ac_lap_' + [guid]::NewGuid().ToString('N'))
    New-Item -ItemType Directory -Path $tempWork -Force | Out-Null
    $outPrefix = Join-Path $tempWork 'acrp_out'
    try {
        $argList = New-Object System.Collections.Generic.List[string]
        [void]$argList.Add('-o')
        [void]$argList.Add($outPrefix)
        if (-not [string]::IsNullOrWhiteSpace($DriverNameIn)) {
            [void]$argList.Add('--driver-name')
            [void]$argList.Add($DriverNameIn)
        }
        [void]$argList.Add($replay)

        Write-Host "Generating replay JSON via acrp: $replay"
        $proc = Start-Process -FilePath $acrp -ArgumentList $argList.ToArray() -Wait -PassThru -NoNewWindow
        if ($proc.ExitCode -ne 0) { throw "acrp.exe exit code $($proc.ExitCode)" }

        $jsonFiles = @(Get-ChildItem -LiteralPath $tempWork -Filter *.json -File | Sort-Object LastWriteTime -Descending)
        if ($jsonFiles.Count -lt 1) { throw "acrp generated no JSON in: $tempWork" }
        if ($jsonFiles.Count -gt 1 -and [string]::IsNullOrWhiteSpace($DriverNameIn)) {
            throw "acrp generated multiple JSON files; pass -DriverName to pick one."
        }
        Copy-Item -LiteralPath $jsonFiles[0].FullName -Destination $TargetJsonPath -Force
        Write-Host "Generated: $TargetJsonPath"
    } finally {
        Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue
    }
}

function Build-CornerJsonFromReplay($j, [string]$TargetPath, [int]$LapVal, [double]$MinSegMeters, [double]$DedupMinGapMeters, [bool]$AutoFastestLapVal) {
    $seg = if ($AutoFastestLapVal) { Select-FastestTimingSegment $j $MinSegMeters } else { Select-TimingSegment $j $LapVal $MinSegMeters }
    if ($seg.Mode -ne 'ok' -or $seg.Start -lt 0) { throw "Cannot build corners: timing segment $($seg.Mode)" }
    $iStart = $seg.Start; $iEnd = $seg.End
    $idx = New-Object System.Collections.Generic.List[int]
    for ($i = $iStart; $i -lt $iEnd; $i++) { [void]$idx.Add($i) }
    if ($idx.Count -lt 200) { throw "Cannot build corners: too few frames ($($idx.Count))" }

    $m = $idx.Count
    $s = New-Object double[] $m
    $brk = New-Object int[] $m
    for ($k = 0; $k -lt $m; $k++) {
        $fi = $idx[$k]
        if ($k -gt 0) {
            $pi = $idx[$k - 1]
            $dx = [double]$j.x[$fi] - [double]$j.x[$pi]
            $dy = [double]$j.y[$fi] - [double]$j.y[$pi]
            $dz = [double]$j.z[$fi] - [double]$j.z[$pi]
            $s[$k] = $s[$k - 1] + [Math]::Sqrt($dx * $dx + $dy * $dy + $dz * $dz)
        }
        $brk[$k] = [int]$j.brake[$fi]
    }
    $lapLen = $s[$m - 1]
    if ($lapLen -lt 100.0) { throw "Cannot build corners: lap length abnormal ($lapLen)" }

    $cand = New-Object System.Collections.Generic.List[object]
    for ($k = 1; $k -lt $m; $k++) {
        $prev = $brk[$k - 1]; $cur = $brk[$k]
        $isOnset = ($cur -ge 35 -and $prev -lt 25) -or ($cur -ge 22 -and $prev -lt 12) -or (($cur - $prev) -ge 20 -and $cur -ge 18)
        if ($isOnset) {
            [void]$cand.Add([pscustomobject]@{
                K = $k
                S = $s[$k]
                Fraction = ($s[$k] / $lapLen)
                Score = ($cur + [Math]::Max(0, $cur - $prev))
            })
        }
    }
    if ($cand.Count -lt 14) { throw "Cannot build corners: brake onset candidates <14 ($($cand.Count))" }

    $selected = New-Object System.Collections.Generic.List[object]
    foreach ($c in ($cand | Sort-Object Score -Descending)) {
        if ($selected.Count -ge 14) { break }
        $ok = $true
        foreach ($slt in $selected) {
            $d = [Math]::Abs($c.S - $slt.S)
            $dc = [Math]::Min($d, $lapLen - $d)
            if ($dc -lt $DedupMinGapMeters) { $ok = $false; break }
        }
        if ($ok) { [void]$selected.Add($c) }
    }
    if ($selected.Count -lt 14) {
        foreach ($c in ($cand | Sort-Object Score -Descending)) {
            if ($selected.Count -ge 14) { break }
            $exists = $false
            foreach ($slt in $selected) { if ([int]$slt.K -eq [int]$c.K) { $exists = $true; break } }
            if (-not $exists) { [void]$selected.Add($c) }
        }
    }
    if ($selected.Count -lt 14) { throw "Cannot build corners: selected <14 ($($selected.Count))" }

    $bf = @($selected | Sort-Object Fraction | Select-Object -First 14 | ForEach-Object { [double]$_.Fraction })
    $ends = @()
    for ($i = 0; $i -lt 13; $i++) { $ends += [Math]::Round((($bf[$i] + $bf[$i + 1]) / 2.0), 6) }
    $ends += 1.0

    $b = @(0.0) + $ends
    $center = @()
    for ($i = 0; $i -lt 14; $i++) { $center += [Math]::Round((($b[$i] + $b[$i + 1]) / 2.0), 6) }

    $obj = [ordered]@{
        _comment = "Auto-generated by DrawZhuhaiLapCorners.ps1 from replay brake onsets."
        _comment2 = "segmentEndFraction[13] fixed at 1.0; cornerCenterFraction is sector midpoint."
        segmentEndFraction = $ends
        cornerCenterFraction = $center
    }
    $outDir = Split-Path -Parent $TargetPath
    if ($outDir -and -not (Test-Path -LiteralPath $outDir)) {
        New-Item -ItemType Directory -Path $outDir -Force | Out-Null
    }
    ($obj | ConvertTo-Json -Depth 6) | Set-Content -LiteralPath $TargetPath -Encoding UTF8
    Write-Host "Generated: $TargetPath"
}

$ReplayPath = Resolve-FsPath $ReplayPath
$legacyJson = Join-Path $toolDir 'zhuhai_replay_out_elmagnifico.json'
$legacyCorners = Join-Path $toolDir 'zhuhai_t1_t14_apex_fractions.json'

if ([string]::IsNullOrWhiteSpace($JsonPath)) {
    if (-not [string]::IsNullOrWhiteSpace($ReplayPath)) {
        $rpDir = Split-Path -Parent $ReplayPath
        $rpStem = Get-FileStem $ReplayPath 'replay'
        $JsonPath = Join-Path $rpDir ($rpStem + '_replay.json')
    } elseif (Test-Path -LiteralPath $legacyJson) {
        $JsonPath = $legacyJson
    } else {
        throw "Please provide -ReplayPath or -JsonPath."
    }
}
if ([string]::IsNullOrWhiteSpace($CornersJson)) {
    if (-not [string]::IsNullOrWhiteSpace($ReplayPath)) {
        $rpDir = Split-Path -Parent $ReplayPath
        $rpStem = Get-FileStem $ReplayPath 'replay'
        $CornersJson = Join-Path $rpDir ($rpStem + '_corners.json')
    } elseif (Test-Path -LiteralPath $legacyCorners) {
        $CornersJson = $legacyCorners
    } else {
        $jDir = Split-Path -Parent $JsonPath
        $jStem = Get-FileStem $JsonPath 'replay'
        $CornersJson = Join-Path $jDir ($jStem + '_corners.json')
    }
}
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
    if (-not [string]::IsNullOrWhiteSpace($ReplayPath)) {
        $rpDir = Split-Path -Parent $ReplayPath
        $rpStem = Get-FileStem $ReplayPath 'replay'
        $OutputPath = Join-Path $rpDir ($rpStem + '_brake_throttle_points.png')
    } else {
        $jDir = Split-Path -Parent $JsonPath
        $jStem = Get-FileStem $JsonPath 'replay'
        $OutputPath = Join-Path $jDir ($jStem + '_brake_throttle_points.png')
    }
}
if ([string]::IsNullOrWhiteSpace($DebugOutputPath)) {
    $DebugOutputPath = [IO.Path]::ChangeExtension($OutputPath, '.debug.csv')
}

$JsonPath = Resolve-FsPath $JsonPath
$CornersJson = Resolve-FsPath $CornersJson
$OutputPath = Resolve-FsPath $OutputPath
$DebugOutputPath = Resolve-FsPath $DebugOutputPath
Ensure-ReplayJson $JsonPath $ReplayPath $AcRpPath $DriverName

$j = Get-Content -LiteralPath $JsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
if (-not (Test-Path -LiteralPath $CornersJson)) {
    Build-CornerJsonFromReplay $j $CornersJson $Lap $MinSegmentMeters 28.0 $AutoFastestLap
}

$apexObj = Get-Content -LiteralPath $CornersJson -Raw -Encoding UTF8 | ConvertFrom-Json
if (-not $apexObj.segmentEndFraction) { throw 'CornersJson needs segmentEndFraction[14] ending with 1.0' }
$se = @([double[]]@($apexObj.segmentEndFraction))
$boundaries = Get-BoundariesFromSegmentEnds $se
$cornerCenter = $null
if ($apexObj.cornerCenterFraction) {
    $cornerCenter = @([double[]]@($apexObj.cornerCenterFraction))
    if ($cornerCenter.Count -ne 14) { throw 'cornerCenterFraction must have 14 elements if set' }
}

$nF = $j.x.Count
if ($j.velocityX.Count -ne $nF) { throw 'JSON needs velocityX/Y/Z same length as x.' }

$dt = Get-FrameDtSeconds $j
$brkFrames = [int][math]::Ceiling($BrakeMinSeconds / $dt)
$gasFrames = [int][math]::Ceiling($ThrottleMinSeconds / $dt)
$gasReapplyFrames = [int][math]::Ceiling($GasReapplyMinSeconds / $dt)
Write-Host "Frame dt=${dt}s  brake>=${BrakeMinSeconds}s -> ${brkFrames} frames  throttle>=${ThrottleMinSeconds}s -> ${gasFrames} frames"

$seg = if ($AutoFastestLap) { Select-FastestTimingSegment $j $MinSegmentMeters } else { Select-TimingSegment $j $Lap $MinSegmentMeters }
$iStart = 0; $iEnd = $nF; $timingUsed = $false
if ($seg.Mode -eq 'ok' -and $seg.Start -ge 0) {
    $iStart = $seg.Start; $iEnd = $seg.End; $timingUsed = $true
    if ($AutoFastestLap) {
        $Lap = [int]$seg.Lap
        Write-Host "Timing (fastest lap): lap=$Lap time_ms=$($seg.TimeMs) frames $($seg.Start)..$($seg.End) length_m=$([math]::Round($seg.Length,1))"
    } else {
        Write-Host "Timing: frames $($seg.Start)..$($seg.End) length_m=$([math]::Round($seg.Length,1))"
    }
} else {
    Write-Warning "Timing: $($seg.Mode)"
}

$idx = New-Object System.Collections.Generic.List[int]
for ($i = $iStart; $i -lt $iEnd; $i++) {
    if (-not $timingUsed) {
        if ([int]$j.currentLap[$i] -ne $Lap) { continue }
    }
    [void]$idx.Add($i)
}
if ($idx.Count -lt 200) { throw "Too few frames: $($idx.Count)" }

$m = $idx.Count
$s = New-Object double[] $m
$sp = New-Object double[] $m
$brk = New-Object int[] $m
$gas = New-Object int[] $m
$xs = New-Object double[] $m
$zs = New-Object double[] $m
for ($k = 0; $k -lt $m; $k++) {
    $fi = $idx[$k]
    $xs[$k] = [double]$j.x[$fi]; $zs[$k] = [double]$j.z[$fi]
    if ($k -gt 0) {
        $pi = $idx[$k - 1]
        $dx = [double]$j.x[$fi] - [double]$j.x[$pi]
        $dy = [double]$j.y[$fi] - [double]$j.y[$pi]
        $dz = [double]$j.z[$fi] - [double]$j.z[$pi]
        $s[$k] = $s[$k - 1] + [Math]::Sqrt($dx * $dx + $dy * $dy + $dz * $dz)
    }
    $sp[$k] = Get-SpeedKmh $j $fi
    $brk[$k] = [int]$j.brake[$fi]
    $gas[$k] = [int]$j.gas[$fi]
}

$lapLen = $s[$m - 1]
if ($lapLen -lt 100.0) { throw "Lap length abnormal: $lapLen" }

$xmin = ($xs | Measure-Object -Minimum).Minimum
$xmax = ($xs | Measure-Object -Maximum).Maximum
$zmin = ($zs | Measure-Object -Minimum).Minimum
$zmax = ($zs | Measure-Object -Maximum).Maximum
$innerFrac = [Math]::Max(0.0, [Math]::Min(0.45, $InnerMarginPercent / 100.0))
$bmpW = $ImageWidth; $bmpH = $ImageHeight
$iw = $bmpW * (1.0 - 2.0 * $innerFrac); $ih = $bmpH * (1.0 - 2.0 * $innerFrac)
$rw = [Math]::Max(1e-9, $xmax - $xmin); $rz = [Math]::Max(1e-9, $zmax - $zmin)
$sc = [Math]::Min($iw / $rw, $ih / $rz)
$offX = $bmpW * $innerFrac + ($iw - $sc * $rw) / 2.0
$offZ = $bmpH * $innerFrac + ($ih - $sc * $rz) / 2.0

$pxi = New-Object int[] $m
$pzi = New-Object int[] $m
for ($k = 0; $k -lt $m; $k++) {
    $pxd = $offX + ($xs[$k] - $xmin) * $sc
    if ($FlipWorldZ.IsPresent) { $pzd = $offZ + ($zs[$k] - $zmin) * $sc }
    else { $pzd = $offZ + ($zmax - $zs[$k]) * $sc }
    $pxi[$k] = Clamp-Int ([int][Math]::Round($pxd)) 0 ($bmpW - 1)
    $pzi[$k] = Clamp-Int ([int][Math]::Round($pzd)) 0 ($bmpH - 1)
}
if (-not $NoVerticalFlip.IsPresent) {
    for ($k = 0; $k -lt $m; $k++) { $pzi[$k] = $bmpH - 1 - $pzi[$k] }
}

$bmp = New-Object System.Drawing.Bitmap $bmpW, $bmpH
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAlias
$g.Clear([System.Drawing.Color]::White)
$fontTitle = New-CjkDrawingFont $FontSizeTitle ([System.Drawing.FontStyle]::Bold)
$fontMk = New-CjkDrawingFont $FontSizeMarker ([System.Drawing.FontStyle]::Bold)
$brushTxt = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(240, 30, 30, 30))
$penTrace = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(200, 40, 90, 200)), 3
$brushRed = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(230, 200, 40, 40))
$brushGreen = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(230, 30, 150, 50))
$brushSf = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 200, 130, 0))
$penLeader = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(160, 90, 90, 90)), 1.0

for ($k = 1; $k -lt $m; $k++) {
    $g.DrawLine($penTrace, $pxi[$k - 1], $pzi[$k - 1], $pxi[$k], $pzi[$k])
}

$occupied = New-Object 'System.Collections.Generic.List[System.Drawing.RectangleF]'

function Test-RectOverlap([System.Drawing.RectangleF]$a, [System.Drawing.RectangleF]$b, [float]$pad) {
    $ax1 = $a.Left - $pad; $ay1 = $a.Top - $pad; $ax2 = $a.Right + $pad; $ay2 = $a.Bottom + $pad
    $bx1 = $b.Left - $pad; $by1 = $b.Top - $pad; $bx2 = $b.Right + $pad; $by2 = $b.Bottom + $pad
    return -not (($ax2 -lt $bx1) -or ($ax1 -gt $bx2) -or ($ay2 -lt $by1) -or ($ay1 -gt $by2))
}

function New-LabelPlacement {
    param($Graphics, $Font, [string]$Text, [int]$cx, [int]$cy, [int]$imgW, [int]$imgH, $Occupied, [float[]]$OffsetCandidates)
    $sz = $Graphics.MeasureString($Text, $Font)
    $w = $sz.Width + 6; $h = $sz.Height + 4
    $pad = [float]4
    for ($ci = 0; $ci -lt $OffsetCandidates.Length; $ci += 2) {
        $tx = [float]($cx + $OffsetCandidates[$ci]); $ty = [float]($cy + $OffsetCandidates[$ci + 1])
        if ($tx + $w -gt $imgW - 4) { $tx = [float]($imgW - 4 - $w) }
        if ($tx -lt 4) { $tx = 4 }
        if ($ty + $h -gt $imgH - 4) { $ty = [float]($imgH - 4 - $h) }
        if ($ty -lt 4) { $ty = 4 }
        $rc = [System.Drawing.RectangleF]::new($tx, $ty, $w, $h)
        $hit = $false
        foreach ($o in $Occupied) { if (Test-RectOverlap $rc $o $pad) { $hit = $true; break } }
        if (-not $hit) {
            [void]$Occupied.Add($rc)
            return @{ Tx = $tx; Ty = $ty; W = $w; H = $h }
        }
    }
    # Fallback: radial search around anchor to minimize collisions in dense areas.
    for ($rad = 26.0; $rad -le 190.0; $rad += 12.0) {
        for ($ang = 0.0; $ang -lt 360.0; $ang += 20.0) {
            $rx = [Math]::Cos($ang * [Math]::PI / 180.0) * $rad
            $ry = [Math]::Sin($ang * [Math]::PI / 180.0) * $rad
            $tx = [float]($cx + $rx)
            $ty = [float]($cy + $ry)
            if ($tx + $w -gt $imgW - 4) { $tx = [float]($imgW - 4 - $w) }
            if ($tx -lt 4) { $tx = 4 }
            if ($ty + $h -gt $imgH - 4) { $ty = [float]($imgH - 4 - $h) }
            if ($ty -lt 4) { $ty = 4 }
            $rc = [System.Drawing.RectangleF]::new($tx, $ty, $w, $h)
            $hit = $false
            foreach ($o in $Occupied) { if (Test-RectOverlap $rc $o $pad) { $hit = $true; break } }
            if (-not $hit) {
                [void]$Occupied.Add($rc)
                return @{ Tx = $tx; Ty = $ty; W = $w; H = $h }
            }
        }
    }
    # Last resort: place at corner to guarantee visibility.
    $tx0 = [float]4; $ty0 = [float]4
    $rc0 = [System.Drawing.RectangleF]::new($tx0, $ty0, $w, $h)
    [void]$Occupied.Add($rc0)
    return @{ Tx = $tx0; Ty = $ty0; W = $w; H = $h }
}

function Draw-StringWithLeader {
    param($Graphics, $Font, $Brush, $PenL, [int]$cx, [int]$cy, [string]$Text, $Place)
    $Graphics.DrawString($Text, $Font, $Brush, $Place.Tx, $Place.Ty)
    $mx = $Place.Tx + $Place.W / 2.0; $my = $Place.Ty + $Place.H / 2.0
    $Graphics.DrawLine($PenL, [float]$cx, [float]$cy, $mx, $my)
}

$sfOff = [float[]]@(20.0, -28.0, -120.0, -28.0, 20.0, 22.0)
$sfPl = New-LabelPlacement $g $fontMk $cap.sf $pxi[0] $pzi[0] $bmpW $bmpH $occupied $sfOff
$g.FillEllipse($brushSf, $pxi[0] - 10, $pzi[0] - 10, 20, 20)
Draw-StringWithLeader $g $fontMk $brushTxt $penLeader $pxi[0] $pzi[0] $cap.sf $sfPl

$bOff = [float[]]@(16.0, -28.0, -120.0, -28.0, 20.0, 22.0, -130.0, 24.0, 95.0, -34.0, 110.0, 12.0)
$gOff = [float[]]@(-16.0, 26.0, 90.0, 26.0, -26.0, -18.0, 110.0, -24.0, -120.0, 30.0, 24.0, 44.0)

$prevBrakeRunEnd = -1
$prevGasRunEnd = -1
$events = New-Object System.Collections.Generic.List[object]
$debugRows = New-Object System.Collections.Generic.List[object]
for ($ti = 0; $ti -lt 14; $ti++) {
    $f0 = $boundaries[$ti]; $f1 = $boundaries[$ti + 1]
    $sLo = [Math]::Max(0.0, ($f0 * $lapLen) - $SectorExpandMeters)
    $sHi = [Math]::Min($lapLen, ($f1 * $lapLen) + $SectorExpandMeters)
    $ff0 = $sLo / $lapLen
    $ff1 = $sHi / $lapLen
    $k0, $k1 = Find-KRangeForArc $s $lapLen $ff0 $ff1 $m

    # Avoid repeated brake markers when one long brake run spans adjacent sectors.
    $searchK0 = [Math]::Max($k0, $prevBrakeRunEnd + 1)
    $bk = Find-FirstRisingSustainedAbove $brk $searchK0 $k1 $BrakePedalThreshold $brkFrames
    $brkEnd = -1
    if ($bk -ge 0) {
        $brkEnd = Get-ContiguousRunEnd $brk $bk $k1 $BrakePedalThreshold
        if ($brkEnd -gt $prevBrakeRunEnd) { $prevBrakeRunEnd = $brkEnd }
    }

    $gasFrom = $k0
    if ($brkEnd -ge 0) { $gasFrom = [Math]::Min($k1, $brkEnd + 1) }
    $gasSearchK0 = [Math]::Max($gasFrom, $prevGasRunEnd + 1)

    $tkRise = Find-FirstRisingSustainedAbove $gas $gasSearchK0 $k1 $GasPedalThreshold $gasFrames
    $tkSustain = -1
    $tkReapply = -1
    $tk = $tkRise
    $tkSource = 'rise'
    if ($tk -lt 0) {
        # Fallback: if no clean rising edge exists in this window, still capture first sustained high-gas point.
        $tkSustain = Find-FirstSustainedAbove $gas $gasSearchK0 $k1 $GasPedalThreshold $gasFrames
        $tk = $tkSustain
        $tkSource = 'sustain'
    }
    if ($tk -lt 0) {
        # Fallback 2: capture lower-threshold throttle reapply when speed rises but full gas threshold isn't reached.
        $tkReapply = Find-FirstGasReapply $gas $brk $gasSearchK0 $k1 $GasReapplyThreshold $GasReapplyDelta $GasReapplyBrakeMax $gasReapplyFrames
        $tk = $tkReapply
        $tkSource = 'reapply'
    }
    if ($tk -lt 0) { $tkSource = 'none' }
    if ($tk -ge 0) {
        $gasEnd = Get-ContiguousRunEnd $gas $tk $k1 $GasPedalThreshold
        if ($gasEnd -gt $prevGasRunEnd) { $prevGasRunEnd = $gasEnd }
    }

    if ($bk -ge 0) {
        [void]$events.Add([pscustomobject]@{
            K = $bk
            Kind = 'brake'
            Sector = ($ti + 1)
            Source = 'rise'
            GasValue = 0
            Speed = [int][math]::Round($sp[$bk], 0)
            Px = $pxi[$bk]
            Py = $pzi[$bk]
        })
    }

    if ($tk -ge 0) {
        [void]$events.Add([pscustomobject]@{
            K = $tk
            Kind = 'gas'
            Sector = ($ti + 1)
            Source = $tkSource
            GasValue = [int]$gas[$tk]
            Speed = [int][math]::Round($sp[$tk], 0)
            Px = $pxi[$tk]
            Py = $pzi[$tk]
        })
    }

    if ($DebugEventTrace.IsPresent) {
        $secMaxGas = ($gas[$k0..$k1] | Measure-Object -Maximum).Maximum
        $secMaxBrk = ($brk[$k0..$k1] | Measure-Object -Maximum).Maximum
        [void]$debugRows.Add([pscustomobject]@{
            Phase = 'sector'
            Sector = ('T{0}' -f ($ti + 1))
            k0 = $k0
            k1 = $k1
            searchBrakeK0 = $searchK0
            bk = $bk
            brkEnd = $brkEnd
            gasSearchK0 = $gasSearchK0
            tkRise = $tkRise
            tkSustain = $tkSustain
            tkReapply = $tkReapply
            tkPicked = $tk
            tkSource = $tkSource
            secMaxGas = $secMaxGas
            secMaxBrk = $secMaxBrk
        })
    }
}

$markerId = 0
$orderedEvents = @($events | Sort-Object K, Kind)

# Global补漏:若两次刹车之间无油门点,则在中间区间再做一次补油搜索。
$brakeEvents = @($orderedEvents | Where-Object { $_.Kind -eq 'brake' } | Sort-Object K)
if ($brakeEvents.Count -ge 2) {
    for ($bi = 0; $bi -lt $brakeEvents.Count - 1; $bi++) {
        $kA = [int]$brakeEvents[$bi].K
        $kB = [int]$brakeEvents[$bi + 1].K
        if (($kB - $kA) -lt 3) { continue }

        $hasGasBetween = $false
        foreach ($ev2 in $orderedEvents) {
            if ($ev2.Kind -eq 'gas' -and $ev2.K -gt $kA -and $ev2.K -lt $kB) {
                $hasGasBetween = $true
                break
            }
        }
        if ($hasGasBetween) { continue }

        $g0 = $kA + 1
        $g1 = $kB - 1
        $tkMid = Find-FirstRisingSustainedAbove $gas $g0 $g1 $GasPedalThreshold $gasFrames
        if ($tkMid -lt 0) {
            $tkMid = Find-FirstSustainedAbove $gas $g0 $g1 $GasPedalThreshold $gasFrames
        }
        if ($tkMid -lt 0) {
            $tkMid = Find-FirstGasReapply $gas $brk $g0 $g1 $GasReapplyThreshold $GasReapplyDelta $GasReapplyBrakeMax $gasReapplyFrames
        }
        if ($tkMid -lt 0 -and $AllowOverlapThrottleBetweenBrakes) {
            # Only in brake-to-brake gaps: allow overlap throttle reapply without brake-max constraint.
            $tkMid = Find-FirstGasReapplyOverlap $gas $g0 $g1 $GasReapplyThreshold $GasReapplyDelta $gasReapplyFrames
            if ($tkMid -lt 0) {
                # If gas is already high in this gap (no rise edge), capture the first sustained high-gas sample.
                $tkMid = Find-FirstSustainedAbove $gas $g0 $g1 $GasReapplyThreshold $gasReapplyFrames
            }
            if ($tkMid -lt 0) {
                # Final fallback for brake-to-brake gap: pick max-gas point in gap to avoid missing obvious refill.
                $bestK = -1
                $bestG = -1
                for ($kk = $g0; $kk -le $g1; $kk++) {
                    if ($gas[$kk] -gt $bestG) { $bestG = $gas[$kk]; $bestK = $kk }
                }
                if ($bestG -ge $GasReapplyThreshold) { $tkMid = $bestK }
            }
        }
        if ($DebugEventTrace.IsPresent) {
            [void]$debugRows.Add([pscustomobject]@{
                Phase = 'global_gap_probe'
                Sector = ('T{0}->T{1}' -f $brakeEvents[$bi].Sector, $brakeEvents[$bi + 1].Sector)
                k0 = $g0
                k1 = $g1
                searchBrakeK0 = ''
                bk = $kA
                brkEnd = $kB
                gasSearchK0 = $g0
                tkRise = ''
                tkSustain = ''
                tkReapply = ''
                tkPicked = $tkMid
                tkSource = if ($tkMid -ge 0) { 'global_probe_hit' } else { 'global_probe_miss' }
                secMaxGas = ($gas[$g0..$g1] | Measure-Object -Maximum).Maximum
                secMaxBrk = ($brk[$g0..$g1] | Measure-Object -Maximum).Maximum
            })
        }
        if ($tkMid -ge 0) {
            [void]$events.Add([pscustomobject]@{
                K = $tkMid
                Kind = 'gas'
                Sector = 0
                Source = 'global_gap_fill_overlap_ok'
                GasValue = [int]$gas[$tkMid]
                Speed = [int][math]::Round($sp[$tkMid], 0)
                Px = $pxi[$tkMid]
                Py = $pzi[$tkMid]
            })
            if ($DebugEventTrace.IsPresent) {
                [void]$debugRows.Add([pscustomobject]@{
                    Phase = 'global_gap_fill'
                    Sector = ('T{0}->T{1}' -f $brakeEvents[$bi].Sector, $brakeEvents[$bi + 1].Sector)
                    k0 = $g0
                    k1 = $g1
                    searchBrakeK0 = ''
                    bk = $brakeEvents[$bi].K
                    brkEnd = $brakeEvents[$bi + 1].K
                    gasSearchK0 = $g0
                    tkRise = ''
                    tkSustain = ''
                    tkReapply = ''
                    tkPicked = $tkMid
                    tkSource = 'global_gap_fill'
                    secMaxGas = ($gas[$g0..$g1] | Measure-Object -Maximum).Maximum
                    secMaxBrk = ($brk[$g0..$g1] | Measure-Object -Maximum).Maximum
                })
            }
        }
    }
    $orderedEvents = @($events | Sort-Object K, Kind)
}

# Second-pass robust补漏(仅连续刹车之间):
# If a brake-to-brake gap still has no gas marker, insert one at max-gas position in that gap.
if ($AllowOverlapThrottleBetweenBrakes) {
    $orderedEvents = @($events | Sort-Object K, Kind)
    $brakeEvents2 = @($orderedEvents | Where-Object { $_.Kind -eq 'brake' } | Sort-Object K)
    if ($brakeEvents2.Count -ge 2) {
        for ($bi2 = 0; $bi2 -lt $brakeEvents2.Count - 1; $bi2++) {
            $kA2 = [int]$brakeEvents2[$bi2].K
            $kB2 = [int]$brakeEvents2[$bi2 + 1].K
            if (($kB2 - $kA2) -lt 3) { continue }

            $hasGasBetween2 = $false
            foreach ($evx in $orderedEvents) {
                if ($evx.Kind -eq 'gas' -and $evx.K -gt $kA2 -and $evx.K -lt $kB2) {
                    $hasGasBetween2 = $true
                    break
                }
            }
            if ($hasGasBetween2) { continue }

            $g02 = $kA2 + 1
            $g12 = $kB2 - 1
            $bestK2 = -1
            $bestG2 = -1
            for ($kk2 = $g02; $kk2 -le $g12; $kk2++) {
                if ($gas[$kk2] -gt $bestG2) { $bestG2 = $gas[$kk2]; $bestK2 = $kk2 }
            }
            if ($bestK2 -ge 0 -and $bestG2 -ge $GasReapplyThreshold) {
                [void]$events.Add([pscustomobject]@{
                    K = $bestK2
                    Kind = 'gas'
                    Sector = 0
                    Source = 'global_gap_force_max'
                    GasValue = [int]$gas[$bestK2]
                    Speed = [int][math]::Round($sp[$bestK2], 0)
                    Px = $pxi[$bestK2]
                    Py = $pzi[$bestK2]
                })
            }
        }
        $orderedEvents = @($events | Sort-Object K, Kind)
    }
}

# Rule: between two consecutive brake points, keep at most one gas point.
if ($orderedEvents.Count -gt 0) {
    $removeIdx = New-Object 'System.Collections.Generic.HashSet[int]'
    $brakeIdx = New-Object System.Collections.Generic.List[int]
    for ($i = 0; $i -lt $orderedEvents.Count; $i++) {
        if ($orderedEvents[$i].Kind -eq 'brake') { [void]$brakeIdx.Add($i) }
    }
    for ($bi3 = 0; $bi3 -lt $brakeIdx.Count - 1; $bi3++) {
        $ia = $brakeIdx[$bi3]
        $ib = $brakeIdx[$bi3 + 1]
        $gasCandidates = New-Object System.Collections.Generic.List[int]
        for ($i = $ia + 1; $i -lt $ib; $i++) {
            if ($orderedEvents[$i].Kind -eq 'gas') { [void]$gasCandidates.Add($i) }
        }
        if ($gasCandidates.Count -le 1) { continue }
        # Keep earliest gas marker between two brake markers.
        $keep = $gasCandidates | Sort-Object { [int]$orderedEvents[$_].K } | Select-Object -First 1
        foreach ($gi in $gasCandidates) {
            if ($gi -ne $keep) { [void]$removeIdx.Add([int]$gi) }
        }
    }
    if ($removeIdx.Count -gt 0) {
        $filtered = New-Object System.Collections.Generic.List[object]
        for ($i = 0; $i -lt $orderedEvents.Count; $i++) {
            if (-not $removeIdx.Contains($i)) { [void]$filtered.Add($orderedEvents[$i]) }
        }
        $orderedEvents = $filtered.ToArray()
    }
}

foreach ($ev in $orderedEvents) {
    $markerId++
    $lbl = ('A{0} {1} km/h' -f $markerId, $ev.Speed)
    if ($ev.Kind -eq 'brake') {
        $pl = New-LabelPlacement $g $fontMk $lbl $ev.Px $ev.Py $bmpW $bmpH $occupied $bOff
        $g.FillEllipse($brushRed, $ev.Px - 7, $ev.Py - 7, 14, 14)
    } else {
        $pl = New-LabelPlacement $g $fontMk $lbl $ev.Px $ev.Py $bmpW $bmpH $occupied $gOff
        $g.FillEllipse($brushGreen, $ev.Px - 7, $ev.Py - 7, 14, 14)
    }
    Draw-StringWithLeader $g $fontMk $brushTxt $penLeader $ev.Px $ev.Py $lbl $pl
}

if ($DebugEventTrace.IsPresent) {
    $aRows = New-Object System.Collections.Generic.List[object]
    $aId = 0
    foreach ($ev in $orderedEvents) {
        $aId++
        [void]$aRows.Add([pscustomobject]@{
            Phase = 'A_sequence'
            Sector = if ($ev.Sector -gt 0) { 'T' + $ev.Sector } else { '-' }
            A = 'A' + $aId
            Kind = $ev.Kind
            Source = $ev.Source
            K = $ev.K
            AbsFrame = $idx[$ev.K]
            ArcS_m = [Math]::Round($s[$ev.K], 3)
            Speed_kmh = $ev.Speed
        })
    }

    $gapRows = New-Object System.Collections.Generic.List[object]
    $brOnly = @($aRows | Where-Object { $_.Kind -eq 'brake' })
    for ($gi = 0; $gi -lt $brOnly.Count - 1; $gi++) {
        $a = $brOnly[$gi]
        $b = $brOnly[$gi + 1]
        $ka = [int]$a.K; $kb = [int]$b.K
        if (($kb - $ka) -lt 2) { continue }
        $lo = $ka + 1; $hi = $kb - 1
        $hasGas = ($aRows | Where-Object { $_.Kind -eq 'gas' -and [int]$_.K -gt $ka -and [int]$_.K -lt $kb } | Select-Object -First 1)
        $maxGas = ($gas[$lo..$hi] | Measure-Object -Maximum).Maximum
        $maxBrk = ($brk[$lo..$hi] | Measure-Object -Maximum).Maximum
        $run180 = Get-LongestRunAbove $gas $lo $hi 180
        $run60 = Get-LongestRunAbove $gas $lo $hi 60
        $run40 = Get-LongestRunAbove $gas $lo $hi 40
        [void]$gapRows.Add([pscustomobject]@{
            Phase = 'brake_gap'
            Sector = ($a.A + '->' + $b.A)
            A = ''
            Kind = ''
            Source = if ($hasGas) { 'has_gas' } else { ("no_gas(run180={0},run60={1},run40={2})" -f $run180, $run60, $run40) }
            K = "$lo..$hi"
            AbsFrame = "$($idx[$lo])..$($idx[$hi])"
            ArcS_m = [Math]::Round(($s[$lo] + $s[$hi]) / 2.0, 3)
            Speed_kmh = ''
            MaxGas = $maxGas
            MaxBrake = $maxBrk
        })
    }

    $all = @($debugRows + $aRows + $gapRows) | ForEach-Object {
        [pscustomobject]@{
            Phase = if ($_.PSObject.Properties.Name -contains 'Phase') { $_.Phase } else { '' }
            Sector = if ($_.PSObject.Properties.Name -contains 'Sector') { $_.Sector } else { '' }
            A = if ($_.PSObject.Properties.Name -contains 'A') { $_.A } else { '' }
            Kind = if ($_.PSObject.Properties.Name -contains 'Kind') { $_.Kind } else { '' }
            Source = if ($_.PSObject.Properties.Name -contains 'Source') { $_.Source } else { '' }
            K = if ($_.PSObject.Properties.Name -contains 'K') { $_.K } else { '' }
            AbsFrame = if ($_.PSObject.Properties.Name -contains 'AbsFrame') { $_.AbsFrame } else { '' }
            ArcS_m = if ($_.PSObject.Properties.Name -contains 'ArcS_m') { $_.ArcS_m } else { '' }
            Speed_kmh = if ($_.PSObject.Properties.Name -contains 'Speed_kmh') { $_.Speed_kmh } else { '' }
            MaxGas = if ($_.PSObject.Properties.Name -contains 'MaxGas') { $_.MaxGas } else { '' }
            MaxBrake = if ($_.PSObject.Properties.Name -contains 'MaxBrake') { $_.MaxBrake } else { '' }
            k0 = if ($_.PSObject.Properties.Name -contains 'k0') { $_.k0 } else { '' }
            k1 = if ($_.PSObject.Properties.Name -contains 'k1') { $_.k1 } else { '' }
            searchBrakeK0 = if ($_.PSObject.Properties.Name -contains 'searchBrakeK0') { $_.searchBrakeK0 } else { '' }
            bk = if ($_.PSObject.Properties.Name -contains 'bk') { $_.bk } else { '' }
            brkEnd = if ($_.PSObject.Properties.Name -contains 'brkEnd') { $_.brkEnd } else { '' }
            gasSearchK0 = if ($_.PSObject.Properties.Name -contains 'gasSearchK0') { $_.gasSearchK0 } else { '' }
            tkRise = if ($_.PSObject.Properties.Name -contains 'tkRise') { $_.tkRise } else { '' }
            tkSustain = if ($_.PSObject.Properties.Name -contains 'tkSustain') { $_.tkSustain } else { '' }
            tkReapply = if ($_.PSObject.Properties.Name -contains 'tkReapply') { $_.tkReapply } else { '' }
            tkPicked = if ($_.PSObject.Properties.Name -contains 'tkPicked') { $_.tkPicked } else { '' }
            tkSource = if ($_.PSObject.Properties.Name -contains 'tkSource') { $_.tkSource } else { '' }
            secMaxGas = if ($_.PSObject.Properties.Name -contains 'secMaxGas') { $_.secMaxGas } else { '' }
            secMaxBrk = if ($_.PSObject.Properties.Name -contains 'secMaxBrk') { $_.secMaxBrk } else { '' }
        }
    }
    $all | Export-Csv -LiteralPath $DebugOutputPath -NoTypeInformation -Encoding UTF8
    $a1415 = $gapRows | Where-Object { $_.Sector -eq 'A14->A15' } | Select-Object -First 1
    if ($null -ne $a1415) {
        Write-Host ("Debug A14->A15: source={0} maxGas={1} maxBrake={2} gapK={3}" -f $a1415.Source, $a1415.MaxGas, $a1415.MaxBrake, $a1415.K)
    }
    Write-Host "Debug trace saved: $DebugOutputPath"
}

$sub = ('dt={0}ms brake>={1}s thr={2} gas>={3} expand={4}m' -f [int]($dt * 1000), $BrakeMinSeconds, $ThrottleMinSeconds, $GasPedalThreshold, $SectorExpandMeters)
$title = $cap.titlePrefix + '  Lap=' + $Lap + '  L=' + [math]::Round($lapLen, 0) + 'm  ' + $sub + '  ' + (Get-Date -Format 'yyyy-MM-dd HH:mm')
$g.DrawString($title, $fontTitle, $brushTxt, 10.0, 8.0)
$leg = $cap.legend + '  |  ' + $sub
$g.DrawString($leg, $fontMk, $brushTxt, 10.0, [float]($bmpH - 42))

$bmp.Save($OutputPath, [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
$penTrace.Dispose(); $penLeader.Dispose()
$brushRed.Dispose(); $brushGreen.Dispose(); $brushTxt.Dispose(); $brushSf.Dispose()
$fontTitle.Dispose(); $fontMk.Dispose()
Write-Host "Saved: $OutputPath"

打包exe

没想到打包exe,这个简单的需求反而是最麻烦的,最难处理的。

AI生成的都是powershell的脚本,我想把它打包成一个exe,可以方便使用一些。

打包 exe 重写了三遍,第一遍打包 exe 还要调用脚本,那这个 exe 的意义何在;第二遍打包各种路径弄不对;第三遍打包增加测试方法以后,总算给出来一个能用的 exe 了。

#Requires -Version 5.1
# 将 BuildIdealLineFromReplay.ps1 / DrawZhuhaiLapCorners.ps1 打成 exe。
# 先输出到 %TEMP% 再复制到 tools,避免目标 exe 被占用时 PS2EXE 无法删除旧文件导致打包失败。
$ErrorActionPreference = 'Stop'
$here = $PSScriptRoot
Import-Module (Join-Path $here 'ps2exe-module\ps2exe.psd1') -Force

function Stop-ToolProcess([string]$exeFileName) {
    $base = [IO.Path]::GetFileNameWithoutExtension($exeFileName)
    Get-Process -Name $base -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
}

function Copy-ExeToTools {
    param([string]$TempExe, [string]$DestExe)
    Copy-Item -LiteralPath $TempExe -Destination $DestExe -Force
}

$targets = @(
    @{ In = 'BuildIdealLineFromReplay.ps1'; Out = 'BuildIdealLineFromReplay.exe'; Title = 'BuildIdealLineFromReplay'; ConHost = $true },
    @{ In = 'DrawZhuhaiLapCorners.ps1'; Out = 'DrawZhuhaiLapCorners.exe'; Title = 'DrawZhuhaiLapCorners'; ConHost = $false }
)
foreach ($t in $targets) {
    $inPath = Join-Path $here $t.In
    $outPath = Join-Path $here $t.Out
    Write-Host "Building $outPath ..."
    Stop-ToolProcess $t.Out
    Start-Sleep -Milliseconds 400
    $tmp = Join-Path $env:TEMP ('ps2exe_' + [guid]::NewGuid().ToString('N') + '_' + $t.Out)
    try {
        # Draw:System.Drawing 用 -STA;-conHost 会导致脚本未跑完、PNG 不落盘。
        # BuildIdealLine:-conHost 便于无控制台/部分自动化场景结束等待。
        if ($t.ConHost) {
            Invoke-ps2exe -inputFile $inPath -outputFile $tmp -conHost -title $t.Title
        } else {
            Invoke-ps2exe -inputFile $inPath -outputFile $tmp -STA -noConsole:$false -title $t.Title
        }
        Copy-ExeToTools -TempExe $tmp -DestExe $outPath
        Write-Host "  -> $outPath"
    } finally {
        Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
    }
}
Write-Host 'Done.'

Summary

最终生成的代码如下,也一起打包了exe

https://github.com/elmagnificogi/ACRecord2AILine.git

对于AI来完成一个项目一些前提:

  1. 项目是否可行,前期需要一些验证性的方案摸底,确定技术方案是否可行,以及AI使用何种方案进行
  2. 需求需要明确,越细致越好
  3. AI生成的结果需要有基础的测试用例,能量化到具体数值、行为、结果最好,图片化的结果比较麻烦需要人工反馈,给AI自己识别还是存在一定误差的
  4. 建议最好把需求点拆成一个阶段一个阶段的,每一步都完成验证以后再进行下一步,而不是一个总体目标和测试结果,会导致AI自己卡在其中反复迭代,无限消耗token,还得不到要的结果
    • 这一点要着重强调,实际上Cursor有200K的上下文,而AI一旦陷入其中就会出现上下文不够用的情况,继而导致核心需求或者描述的上下文被丢失,AI进入恶性循环中,再也出不去

再完成转exe和画分析图的过程中都出现了上下文不够用的情况,我只好重新总结要求,新开一个agent再次进行,而这样再次进行的上下文大概只用了1/3就完成了所有新工作

当AI出现试错或者走向错误分支的时候,这部分上下文可能会占用很多tokens消耗,最好人工发现以后,简单总结,避免再走向错误的道路中

image-20260406195552167

一共就写这么不到 2000 行的代码,2 个需求 + 一个 CI 打包,去掉我 4 月前几天的消耗,大概 400 万 tokens,完成这个消耗了 5400 万 tokens,这里面有很多 cache,但是总体量就得有这么多,平均一行代码消耗 2 万多 tokens,还是很恐怖的。

这么一个需求消耗了接近1/3的Cursor用量,核算下来大概是5刀,30来块钱,看起来挺少的,但是总共耗时大概是7-8小时,是我全程辅助以后的结果。

如果给我7-8小时,纯工作时间,估计也能做到差不多的程度,但是消耗的脑力就很多了,我需要从头开始学习和实验。

后续如果再用AI做需求,再完善一下方法论,再给到AI应该会更快更好一些。

烟花三月下扬州

2026年3月9日 00:00

Foreword

“烟花三月下扬州”,但我去的是公历 3 月,真不算个合适的日子,如果是四五月份来,体验应该会好不少。

扬州

三月的扬州温度比较低,大概 2–10℃,也就只有短暂的中午有阳光洒在身上时会觉得暖和一点。稍微来点小风就又冷飕飕的了,我是不会说主要是因为我穿得太少了,从深圳过来没带厚衣服,确实有点扛不住江南的阴冷。

扬州整体都是青砖石瓦的矮房子,很少高楼大厦那种现代建筑,街边随处可见的亭台楼阁,确实很江南。再加上这里文人墨客众多,稍微走几步就是某某的故居、某某的纪念馆。地处江南,冬天不会下雪,树木也都是绿色的,很是诗情画意。

文昌阁

刚好住在文昌阁附近,走路大概就五六十米的样子。文昌阁不大,也不高,而且在路中央,正常没办法走过去,不过这条路不一般。

image-20260309193435393

国庆路,但是车道让位给了假山园林。是的,你没听错,故意抹去了两三个车道留给假山园林。基本从这里走过去就到了扬州的核心景点附近,算是景点入口了。

image-20260309193936688

往前再走一点,路中心还有一个亭子,这个可以走过去——四望亭。比较有名的饭店怡园就在亭子后,以早茶出名,没想到广式早茶,扬州也有。

image-20260309194031495

东关街

东关街是一大片老街区,就是印象中的江南水乡的样子,青砖石瓦。街道两旁免不了被商业化,但是从我的感觉来说,比深圳或者重庆等地方的古镇还是要好一些的。商业化程度不是那么高,不是千篇一律的臭豆腐、大鱿鱼、内蒙烤串,又或是各种本地特产、奶茶。这里的街区本质上还是为本地人服务的,并不会因为是景区就提价、专宰游客。

我是淡季加工作日去的,平日里人也不少,不过采耳、洗脚店的密度那是真的高,走几步就有一家。

image-20260309195046669

八戒烤猪蹄是家网红店,刚好前面也出了点舆情,我去的时候基本没人。

刚开始还没感觉扬州有多少桥、多少河流,后面就发现那是真的多啊。

image-20260309194203790

逸圃

逸圃,门票 15,建议不要去。地方很小,其实就是以前有钱地主家的宅子。要说考究,真的一般,传统的风水、园林、造景我认为都有点不及格。

个园

image-20260309195348760

个园,门票 45。咋说呢,还是有点小,前面的祖屋没啥看头,主要看点是园林,园林这部分相对比较大,造景比逸圃强多了,能看出一点点文人雅士的情调了。

旺季会有千秋粉黛表演,大概就是吴侬软语、弹唱之类的表演

瘦西湖

瘦西湖算是扬州最有名的景点了。我之前一直以为瘦西湖是小一点的西湖,实际上根本没啥“完整的大湖”,反倒是很多支流。古人的“二十四桥明月夜”,说的就是这里。

瘦西湖挺大,一般都是南门进来,但是我从北门进的,北门人比较少。越往南走人越多,实际很少人全逛完,大部分人半途就走了。

还好北门人少,下车的时候,小广场有个小栅栏,大概也就15cm高,一边回消息一边走路,脑子一抽遇到栅栏跳了一下,然后就被绊倒了,摔了个狗吃屎,整个人扑到在地上,超级尴尬,还好手机啥的没摔飞,就是身上一堆擦伤

image-20260309195904973

image-20260309200525258

亭台楼榭

image-20260309200620746

瘦西湖有很多黑天鹅、绿头鸭,游客拔草喂,它们也吃,都是野生的,在扬州过冬。

image-20260309200551697

柳岸垂荫

image-20260309201643851

这时刚好是梅花开放的时节,其他花基本都谢了,整个瘦西湖是比较清新、素雅的

image-20260309201823676

瘦西湖的商业化也是比较克制的,也有可能是淡季,出摊比较少。至少北门进来基本没啥商业化,走到南门附近才会多一些。也不像一些景区把摊位、各种大招牌都挂在景区内,风格很突兀。

在茶馆里,坐在窗边,喝个茶,看个景,还是挺不错的,单人茶价格也不是太逆天。

大运河博物馆

大运河博物馆需要预约,周一不开门。预约要提前好几天,否则根本约不到。淡季周末提前一天都不行,得提前 2 天。我是跑过去才发现进不去,很是尴尬,运营没有照顾线下的人群,没有线下票,这点就很难受。

围绕博物馆建起了一圈私房菜馆,周边也是水路区隔,整体建设也是仿照二十四桥的概念来的,什么铁索桥,木桥,赵州桥,每座桥的设计都不一样。

宋夹城

image-20260309202804774

宋夹城就在瘦西湖边上,可以认为是个大型体育公园,是一座小围城,免费,主要是给本地人开放散步的。边上是绕城步道,凉风习习。

皮市街

对比东关街,皮市街明显就是那种重度商业化以后的游客街了。皮市街稍微往前走一点就是何园,何园类似个园,感觉千篇一律,所以这次就没去了。

江泽民故居

这里也需要预约,只能在外面看看,没啥特殊的,就是个小宅子而已。

吃喝

扬州,淮扬菜,没啥特别的,烧饼挺多的,倒是挺好吃的。吃了一家很小的淮南牛肉汤,那个饼是真的脆,很多层,一口下去疯狂掉渣。

本地有名的饭店就是大毛,不过吃的内容一般般吧,没感觉有多特色,鱼头泡油条比较少见,味道嘛,我只记得油条比较好吃了。

扬州炒饭,我感觉不如纯蛋炒饭好吃,很普通,没啥香味,也没啥怪味,说是扬州刚好是苏南苏北中间,所以比较中庸一些,不甜。

Summary

扬州古城小巷里依然有很多人还住在这里,看起来是翻新过,但是并不是那种推倒重建的翻新,旧的东西还在。不像广东这边,古建筑基本给干没了,翻新以后直接没人住,都变成商业化的门面,翻新风格还是全国统一,真的没意思。

作为古城,人文气息确实丰富,很多东西小而精致,城建规划时依然保留了老城的精气神,很是难得,下次有机会去苏州再对比一下。

Su7 Ultra 赛道总结

2026年2月24日 00:00

Foreword

年过完了,总结一下今年玩车事宜

1.31 小米官方赛道日

第二次小米珠海赛道日,本来没抢到名额,后来点进去一看有人退了,刚好捡漏报上了。

PPT 总算换成了有经验的人来讲。第一次庆磊那场讲得挺糊弄的,后来出了不少事。这次没经验的新手都被要求认真听完,该注意的点都讲了,还请了十个教练,A 组新手基本挨个上车指导。

不过桩桶摆放还是有点问题,放在路肩上,开得快的要吃路肩,桩桶就会被带到路中间——大直道 200 多时速的那种,一言难尽。

好在有教练和各方面兜底,没出大事故,进波波池的不少,但车损都很小。

这次比之前快了不到 1s,跑出 1:49,勉强进了一分五十,感觉还有不少提升空间。

带了老爹老妈去赛道体验了一下。一开始主办方不让带人,还好提前买了保险,又是录视频又是签后果自负,总算能正常带人上去了。没 push,只是让老爹感受了一下 260 公里每小时的速度。同场还有车主带了父亲来,但他不够强硬,最后还是没给他带上路。其实之前都随便带人的,也没人管,只是我刚好借头盔被看到了

懂车帝 - 车手启航计划

紧接着又有懂车帝的赛道日。和之前一样,要先发文章再选人,我把之前的文章让 AI 融合了一篇发到懂车帝,结果被推荐了,流量挺大(7w+),提前就锁定了报名席位。

31f25d8ea1a0965f8c8b999254251d1

车手启航计划主要是给新手体验用的,可以慢慢走赛道,有点意思。平常都开得太快,很难慢下来仔细看路。模拟器里的赛道和现实差别不小:模拟器里路都是平整的,现实中珠海赛道维护得其实挺差,很多路段都有起伏,会直接影响电控。

教练是真正的 1V1 指导,每辆车都跟,就是只有 2 圈,有点少,拿不到完整圈速。不过教练还是有点用,指出我几个弯走线不对,后面练习节试了一下,但车太多,没跑出圈速,还是 1:50。

这次嘉宾是 杨慢慢追风逐日,给他看了不少 Ultra 的车载,他点评了一下,也分享了比较快的 GT3 车载,挺有收获。

懂车帝的活动基本都免费,连早餐、午餐都包,体验确实不错,小米还得好好学。当天还碰到 盛嘉成 在拍 U9X 的片子,现场还有一辆 U9,为了给它充电甚至单独拉了一台充电保障车过来,挺牛的。

车手启航计划是 L1 级别,懂车帝后面还有 L2、L3 更高级的培训,可以期待。唯一的问题是活动在 APP 里搜不到,活动海报也不知道是从哪儿发出来的。

GT7 - Su7 Ultra 上线

image-20260224102159477

之前一直说 Su7 Ultra 要上 GT7,拖了很久,总算来了。本以为会以 DLC 形式加入,结果只是给一张奖券,有点拉。拿奖券报名折腾了半天,也就对应游戏里一点货币而已。

image-20260224102223196

之前没玩过 GT7,专门借了台 PS5 来玩。不得不说 GT7 确实有点东西,各个品牌在游戏里都有数字博物馆,只是小米这个有点抽象:很多页面全是手机,最后几页才是车,甚至还能在游戏里直接登录小米官网。

画质、手感都一般,路感不够清晰,压路肩的反馈不明显。当然也可能是模拟器适配的问题,但我这已经是官方适配了。

GT7 对新手比较友好的一点是,默认有过弯自动减速,可以一路踩死油门,只管走线就行。雨战里算是第一次真正感受到漂移,反打救了几次车,漂移有点入门感觉了。

赛道总结

赛道日统计

5.25,ZIC官方 5.31,麻涌TopOne 8.24,ZIC官方 10.24,PT赛道日 11.20,懂车帝 12.8,PT赛道日 12.27,小米 1.27,PT赛道日 1.31,小米 2.7,懂车帝

今年一共去了十个赛道日,圈速从第一次的 2:15 跑到现在的 1:49,算是勉勉强强入门(杨慢慢说现实赛道至少得跑个三四百圈才算入门,我现实只有七十多圈,模拟器大概快两千圈了)。

装备统计

轮毂统计: 325后轮毂2个 305后轮毂2个 265前轮毂4个

赛轮散件: 赛轮PT01 265轮胎2个

赛轮全新1套: 赛轮PT01 325轮胎2个 赛轮PT01 265轮胎2个

赛轮半新1套: 赛轮PT01 325轮胎2个 赛轮PT01 265轮胎2个

长续航1套: 倍耐力小米原厂长续航305轮胎2个 倍耐力小米原厂长续航265轮胎2个

卫途残废1套: 卫途P1 305轮胎2个,已磨平 卫途P1 265轮胎2个,已磨平

费用统计

  • 参加各种赛道日:约 1w
  • 轮胎、轮毂:约 2w
  • 刹车片(换过一次):接近 1w
  • 衍生费用(住宿、路费、保险等):小于 1w

总体大概 4w 多,还算能接受。26 年再玩一年估计也在 10w 以内,算比较有性价比的玩法了。

保险

新年车的保险也要续费了。因为出了两次险,保费只降了 2k,对比其他人还是偏高一点。

image-20260224112308283

和小地方比,这车险价格就差得多了。

Summary

年前基本就这么多赛道日了,下次赛道日可能就是四五月份了,据说保时捷包场了一两个月,是真的牛逼。

2025游戏短评

2026年1月28日 00:00

Foreword

又到了一年一度短评环节

Steam 自建的 2025 年游戏回顾

https://s.team/y25/kwqdrkq?l=schinese

今年游玩

今年一共玩了 30 款游戏,比去年还少一些,七成都是新游戏。今年还是太忙了,加上练车、弄一些其他的事,游戏时间就偏少了。

大江湖之苍龙与白鸟

image-20250122172524627

这游戏刚出时就遇到更换发行商,导致一直跳票,本来是和大侠同时期的游戏,一直拖到了大侠都出DLC了,这游戏才出正式版。

整体不错,没想到 24 年竟然有人做出来当年金庸群侠传的感觉,味道比神仙醋做的更正宗,文本确实可以,不过也有点可惜,游戏本可以做得更好的,运营宣发经历了很多变数导致游戏实际销量不行。有些设定过于 old school 了,如果可以重新稍微改变一下数值,升级困难、刷级也很麻烦,新一代稍微降低一点点数值的膨胀,应该是非常不错的。

ZERO Sievert/零希沃特

img

当时玩的时候刚好是鸭科夫出 Demo,鸭科夫和零希沃特二选一,我选了零希沃特。像素风,但是硬核,搜打撤,游戏确实做得不错,作为独立游戏挺好的。可惜开发时间太长,又过于硬核,劝退了很多人,有点可惜。对比之下,鸭科夫能爆火,而同类型的它只能默默无闻。

雾锁王国

image-20250122171401542

一直等不到正式版,但是也直接玩了,游戏有点缝合,但是本质上还是不错的,地图超级大,肝度拉满,联机版塞尔达。虽然没正式版,但是最终 boss 基本有了,勉强打过了,游戏里还有好几块未开发完成的区域。槽点就是小副本简直恶心人,各种绕路、各种解密,又臭又长,如果他解谜水平有塞尔达 5% 我估计就很好了,全都是要跳跃,反复跑趟子,考验视力的解密,搞多了真的难受。

鸭科夫

img

25 年爆火的游戏,Demo 时就已经非常火爆了,口碑爆棚。正式版游戏品质还不错,兼顾了搜打撤和画质,同时游戏难度又处于老少皆宜、随时可调的状态,内容里还有一些梗和有意思的小点,很快就出圈了。地图不算多,剧情也不长,配合一些 mod 以后,甚至可以 mod 联机,很有意思。

如果日后再出一个官方联机版,那就完美了,mod级的东西,可用性还是太差了。

蜡笔小新-煤炭镇的小白

img

第一次玩蜡笔小新,发现是个收集、跑圈子的游戏,看来只适合小孩子玩

巴别塔 :混乱的幸存者

img

类吸血鬼幸存者,但是可以刷装备,词缀又类似暗黑,以为很好玩,其实一般般,数值设计有问题,局外成长吓死人。

绝世好武功

image-20250122173111981

我以为等了一年多能咋样,现在看还是一坨。

bug太多了,数值不合理的地方也太多了,主线引导一坨,莫名其妙的问题,内置帮助里也是一堆错误的地方或者是描述根本对不上的,如果不去群里问,根本不知道下一步咋弄。

我感觉制作组自己都没玩过,我都不知道这是咋开发出来的,建议别买

游戏有一些亮点,但是不多,可玩性还是太差了,缝了一堆乱七八糟的东西,东拼西凑的,对整体可玩性的帮助太少了,建议重做,好好梳理一下

卡死的 NPC 很多,好几个 NPC 莫名其妙站在那里,动也不动,死也不会死,不知道在干啥,反正就是风雨无阻我就站着。

数值上的问题就更多了,王八拳秒天下,其他武功烂的一比,好歹平衡一下啥都能玩啊。

一旦被通缉,要去大内消罪恶,然后进入死循环,一堆人绑上你,蜘蛛人啊,战斗打到没完没了。

如果你招募了NPC,他如果还是剧情npc,触发剧情直接复刻一个一模一样的

类似的细节问题真的太多了,不像是一个开发了一年的作品

剧情也短的不行,大部分奇遇也找不到攻略或者说明,真的有点难受,网上过期的内容比比皆是

各种莫名其妙的按钮,开启势力,根npc对话修复奇遇,这得是多偷懒才能把这种东西写到和npc互动的对话里啊,你加个事件检测不行嘛?再差加个定时检测不行嘛,代入感奇差

看他的功能设计,就和新手不负责的产品一样,只做制作人提到的点,多一点思考都没有,很多设定比demo还demo。

神力科莎 Assetto Corsa

img

今年练车的时候玩了好久,配合 CM 使用,总体体验还是非常不错的,画面够看,老机器运行也没啥问题,不得不说经典还是经典。

Monster Hunter Wilds

image-20250122165921390

怪猎荒野试玩时就卡卡的,各种三角形,硬是顶着老机器打通了,体验极差。后面换了新机器,发现还是一样,破游戏就是不能优化了呗,有点无语,这代荒野没有肝的动力了,内容过少,每次更新玩个一两天就弃坑了

动物栏:桌面牧场

img

桌面养动物游戏,可玩点略少,挂机也就那样,不如其他游戏

Sephiria

image-20250122164847406

像素风的Roguelite游戏,游戏难度比较高,也是背包管理类型,格子各种搭配可以组合出来一些特效,手感也不错。

后期难度极高,打了好多次,还是靠轮椅勉强过的,各种攻略视频很多,总体很不错。

枪豆人

img

多人乘船,后坐力会互相影响,要有人要控船,有人要打怪,还有各种机制,挺欢乐的小游戏,掌握技巧以后就是流程有点短了,没玩够的感觉

PEAK

img

攀岩游戏,但是把,感觉合作性还是有点低,没有队友也能攀,不用机制也能玩,只要你愿意绕路就行,给玩家的引导或者攀爬的动力有点不足,爬完一个又一个,问题是啥也没得到,奖励机制太弱了。

梦之形

image-20250122170240448

梦之形,总算出了,期待了一年,一经发售就小火了一下,正式版中套路更多了,难度也更高一些,玩了好久总算把所有人的成就都解锁了。角色还是有点不太平衡,游戏内关卡还是少了一点,后续每次都是重复内容了。

死亡日:狂杀末路

img

幸存者版的搜打撤结合了一下加拿大的死亡之路的意思,无脑,简单,就是内容还是少了点,后期爽度拉满,boss太弱了,只要遇到基本秒死。

巨人杀手:暗黑之潮

img

高清版幸存者,游戏还行,玩多了这个类型,稍微有点腻了,没有做出什么新东西

Ratopia

img

鼠托邦,横板类缺氧、环世界的游戏,生存压力啥的都还好,一开始不知道机制重开了好几把,后续会了以后就不难了。

可以控制女王做一些类似泰拉瑞亚的冒险,就是这部分内容稍微有点少,而且也比较简单,如果再完善完善可能比较有意思。

Sons Of The Forest

img

森林之子,森林一直都没玩,直接玩森林之子,难度就还好,四个人随便群殴,很少团灭,带个NPC很弱智,也很脆,动不动就死了还得用代码复活,指挥NPC干活也有点鸡肋,好多事情都干不了

地图超级大,很多物品都隐藏在各个洞穴里,洞穴冒险超级掉san值,滑翔伞以后,总算是可以快速移动了,一直到通关才会传送,问题是这会已经要退游了,传送还有啥用。

森林系列特色估计就是这个洞穴冒险了,其他游戏很少有这样的。

满庭芳:宋上繁华

img

这是我玩的最难受的模拟经营游戏了,这个游戏数值不知道是不是有啥大病,把经济系统设计得非常脆弱,普通模拟经营就是面多了加水,水多了加面就行了,很少会崩溃。但是它不行,就算是低难度,这个平衡只要被破坏了,你还没及时发现,那就无力回天,直接进入恶循环,再也爬不出来了。至于后续更新的一些官职挑战,还是一个问题,这个数值设计有大问题,几年内就要上交一个特别不合理的数值的财政收入,你这当的是官嘛,一切要为这个财政收入努力,也太离谱了。

唯一有点意思的就是画风了,古建筑风格,很独特,很中国。

战地风云™ 6

img

战地 6 免费体验的时候玩了几天,还行,挂没太注意到。好久不玩 FPS,枪法太烂了,对枪经常打不过,只能靠一些小众点位去偷鸡。

不正经的卡牌传说

img

抄袭传奇,素材、音效、数值

抄你好歹改改传奇的一些垃圾设定啊,好家伙全部照搬

道士招了狗不能招骷髅,必须要退游戏才能召骷髅;进洞视野一个大黑圈,有毛病嘛?想增加代入感,你都选卡牌了,还代入感个毛线啊;蜡烛毛用没有,并不能点亮洞穴,设定完全是怕脑袋乱来,内测测了个毛线

打boss,就是拼血药,血药不能停,有血药你就能耗死boss,纯弱智设定

槽点实在太多了,说不完,只能说一个人做的游戏,连基础游戏的体验和玩法乐趣都没搞明白就做了

这个游戏群就是一群舔狗,还都是老登;作者直言不卖3w份,不更新游戏,是玩家该了你的

  • 国产游戏最大的问题就是群蜜罐,一群人舔得作者舒服了,不知道外面世界是啥样了都

狗屎,爆率低到离谱,开了加速 = 我的游戏时间 *10,你都打不全你要的套装;新开的暗殿也是一坨狗屎;

作者套皮传奇的数据、模型、音效等等内容,奇葩言论实在是太多了。

游戏模型人物是个卡牌在城镇里走路,很奇怪,但是游戏还是有一点小火的。

这么多年传奇的受众一直都在,他们从未被满足,也不会被满足,所以他刚好切中了这个小小的赛道,做的几个游戏都是传奇套皮,而玩他游戏的也一直都是这么群人。

往好里说这个游戏也确实是保持了当年的味道,传奇不就是一个你愿意付出时间去打怪,不断的刷,就能看到你人物的成长嘛,只要你努力,付出就会有汇报,只是比较慢而已,对比抽卡等等这种完全靠运气的东西,这种机制是稳定的,符合人性的。

酒馆好时光

img

模拟酒馆的游戏,不过内容还是太少了,没啥难度

无尽之潮

img

游戏有点意思,但是节奏有点太快了,割草是爽,但是超级割草就有点不爽了。然后游戏内很多机制都是有bug或者是不生效的,游戏引导又没有,剧情也基本没有,玩起来怪怪的。

他想做类似POE的游戏,只是画面、手感等等入门门槛都没弄好,成长曲线也是,刷起来没快感,只是无尽的通关通关。数值设定上也有些不合理的地方,竟然有指数级计算的效果,那其他东西基本都成了摆设。

有意思的地方在于你可以无缝切换机体,就是切换角色,build,但是,但是这个游戏完全没有利用这个机制的意思,是完全没有,这种特性很不一样,但是没抓住。

失落城堡2

img

前几年刚出Demo的时候玩了一下,那会一堆bug,现在还没做完,但是很接近1.0版本了。又尝试了一下,发现现在的失落城堡,已经很像2D平面版得怪猎了,武器模仿的像,甚至有些小机制也是怪猎系列的东西。

通关还是可以的,曲线也平滑,局外成长很快就能刷满了,最高难度也能消化得了。

我们到了吗

img

RV there yet,这个英文名起的超级好,还是个双关谐音梗。游戏就是大家合作开着房车翻山越岭,到达一个又一个检查点,房车视野很差,还是手动挡,各种牵引过关,还是很有意思的。

恶意不息

img

万万没想到,有人做了45度俯视角下的老头环,关键他还能4人联机,还能刷装备、素材,这就有意思了。游戏是开放世界,地图做的挺不错的,就是这个45度俯视角经常遮挡关键地方或者误判角度,导致跳跃失误。

能刷怪、能组build、还有小副本,整体成长很线性,就是限制进入世界的4个人,必须锁死这个坑位,每次换人都得踢掉一个加一个,这个有点过度设计了,就变成一个房间可以最多4个人加入就好了,不锁坑位就行了。

而且其实这个游戏更多人应该也是可以一起玩的,没必要强行限定4人。

游戏剧情也挺长的,开放世界,一个地图无缝接着另一个地图,游戏内武器超级丰富,想玩啥都可以,问题修复的很快,基本一天一个版本,持续在修,只是游戏在刚开始阶段没设计好,有很多差评,但是现在接近1.0版本了,基本都是好评

已买待玩

无主之地4

img

本来要联机的,但是无主4自己作死,不支持3080以下的显卡,不给优化,属实离谱,欠骂。日后有空了再玩吧

iRacing

img

近年崛起的Esports游戏,很接近正规的现实赛车,据说手感、拟真程度都比AC好,但是竟然2025年还有游戏是订阅制,而且游戏内还得内购各种车或者赛道,挺离谱的。

新游期待

主要是一些Demo游戏的游玩体验,感觉不错

Underboard

img

Rogue的自走棋,比我以前玩过的稍微好一点,看后续正式版怎么样,距离发售没几天了

Chained Beasts

img

又是一个锁链游戏,但是这次是四个人随机链接在一起,角斗场里RogueLite,坚持到最后,Demo体验还可以,稍微简单了一些,看看正式版是否还会丰富一下内容。

旅人竞技场

img

单人角斗场RogueLite,要build,但是Demo还是内容少了点,手感也有点差,看正式版吧

腐朽默示

img

之前一直说试玩版不能联机,缺了点东西。这次可以联机了,但是限制2人,还是不错的,玩起来挺有意思的

就是Demo版本数值设计上还是有点问题,这个人走出家没几分钟就要吃要喝,简直是个饭桶。

罗马之城 Nova Roma

img

罗马时代的模拟经营游戏,难度也不高,有意思的点在于可以引水,可以造水坝,主动控水,人工建湖

DDoD

img

测试内容太少了,走到安全区域就不知道干啥了,其他的搜、打僵尸就还行吧

Schedule I

img

绝命毒师电子模拟版,就是没中文,玩起来稍微有点难受

我不是魔王

img

土豆兄弟联机版,网络太差了,联机体验一坨,其他的大差不差、

风暴怕死队

img

类失落城堡1的游戏,手感真好,Demo的流程也非常长,局外成长比较少,曲线平滑,玩了还想玩那种,期待正式版

冒险家商店

img

Demo有点无聊,就是卖东西,配装,打怪,卖东西全自动,没啥操作感,和你是不似乎店主一点关系都没有。

配装也很无聊,就那么几个东西可以来回倒腾一下,打怪也是全自动,让你选个门,门都是一样的选他干啥?

over the hill

img

类似RV there yet,但这次是越野车

Grounded 2

img

禁闭求生出2了,可惜还是EA,游戏还是半成品,刚出就被骂惨了,等他1.0完善再体验吧

云之国2

img

看起来像塞尔达的联机版,画风也比较像,期待正式版,目前已经EA可玩了

放置之路:古神复苏

img

流放之路,放置版,等个Demo看看水平吧

侠影录

img

有点当年剑侠情缘的感觉,但是暖雪版

极限竞速:地平线6

img

这作来到了日本,东京,算是JDM朝圣了,非常期待,五月份就能玩了

拾光旅人

img

类似房车旅行,但是能自己改造房车,探索世界

太吾绘卷

img

还有一个太吾绘卷也要正式版了,距离他第一次发售已经要8年了,这八年真是不容易啊,把一个游戏做到极致。

从一个人做到一个团队,经历了风风雨雨,团队算是组好了,后面再研发新的游戏估计思路就好多了,开发也更平顺了。

Summary

今年的游戏体验整体,玩得不多但挑得更狠,真正合我胃口的,还是那些有自己味道、能长期刷、肯用心打磨细节的小而精作品;大作和套皮作品,只要在优化或数值上稍有不慎,就会被毫不留情地拉黑。

期待26年的新游戏,独立游戏也越来越好了,很小众的赛道只要找对了,就是有人买单的

Agent Skills实践

2026年1月23日 00:00

Foreword

最近 Skills 稍微有点火,哪哪都是在讨论。这里我也结合一下日常工作的实际场景来实践一下,看看 Agent Skills 能干些啥,以及它和之前说过的 MCP 有什么区别。

Skills

https://github.com/anthropics/skills

Skills是由Anthropic提出的,官方给了开源的skills的模板,很快就接入了其他各大模型

核心概念

Agent Skills 是一个用于为 AI 智能体扩展专门能力的开放标准。Skills 会把特定领域的知识和工作流封装起来,智能体可以调用这些 Skills 来执行对应的任务。

大白话解说:Skills 就是把平常我们命令 AI 去做的一些「复合模板 + Prompt + 规则」的事情,收敛成一个技能。这件事本身可能很复杂(需要若干步骤才能做完),也可能比较简单,一个命令就能搞定。

再说一下就是套路或者方法论,只要是有迹可循,可以模仿完成,并且有条理清晰的步骤的事情,它就可以变成一个Skill。

举个例子,我平常写 Blog 文章,经常需要 AI 帮我找一下错别字,拼音很容易打错了,自己又不一定能发现。我自己常用的 Prompt 就是下面这样,其实挺简单的:

帮我检查一下当前文档中是否存在的书写错误的情况,并修改,忽略标点符号、中英混排的问题

平常这种 Prompt,要么就单纯存一份 Prompt,要么弄成一条规则,但每次还是得手动提醒 Agent 去触发这个流程。

那如果这个 Prompt 再复杂一点,比如还想让 Agent 帮我再润色一下文章,那我就还得再提一个 Prompt:

阅读当前文档,并模仿文档中书写的风格和语气,对全文进行润色,忽略标点符号、中英混排的问题

你可能会说,直接把这两个 Prompt 合并不就完了?确实可以,但有些时候,你可能并不想让它帮你润色。

也可能有些时候,你只是想让它顺手帮你改一下标点符号。这种情况下,这个 Prompt 每次都不太一样,但来来回回核心就那两句。

这个时候,按照 Skills 的思路,我们就可以把这几个 Prompt 抽出来,转化成一个 Skill。以后要用的时候只需要一句话就能触发,不用再反复粘贴那几段文字。

Cursor实践

按照上面的例子,我就用 Cursor 实践一下。其他 Agent 的使用方式大同小异,这里就不逐个展开了。

image-20260123152037745

Cursor Settings中新建一个Skills,其实只是问了一下Agent,用处不大

需要先了解一些信息,以便创建合适的技能。

请提供:

1.技能名称和用途:这个技能要做什么?解决什么问题?

2.存储位置:

  • 个人技能(~/.cursor/skills/):所有项目可用

  • 项目技能(.cursor/skills/):仅当前项目

3.触发场景:什么情况下应自动使用这个技能?

4.具体需求:需要哪些特殊知识、工具或流程?

5.输出格式:是否有特定模板、格式或风格要求?

直接访问Cursor官方说明

https://cursor.com/cn/docs/context/skills

先在当前项目路径下新建一个 skills 的存储路径:

.cursor/skills/

一个完整一点的 skills 目录结构大概是下面这样,这里先看个概念:

.cursor/
└── skills/
    └── ArticleReview/
        ├── SKILL.md

SKILL.md 按照下面的内容来写,格式和我这篇文章类似,也是用 YAML 在开头做一些基础说明:

  • disable-model-invocation 关闭自动调用,这里不关闭,允许
---
name: ArticleReview
description: 文章内容审查
disable-model-invocation: false
---

# 文章内容审查

为 Agent 提供的详细指令。

## 使用时机

- 在以下情况使用此技能
- 如果文档名称符合“年-月-日-文章标题”的规则,类似:2015-11-11-RaspberryStartup-5
- 如果只是要审查文章,那么只用执行指令的1、2、3步骤即可
- 如果要审查润色文章,那么执行所有步骤

## 指令

- 1.帮我检查一下当前文档中是否存在的书写错误的情况
- 2.忽略标点符号、中英混排的问题
- 3.完成上述检查和修改后,先向我简要汇报修改点,等待我确认
- 4.阅读当前文档,并模仿文档中书写的风格和语气,对全文进行润色

不小心手误打错了,Cursor 也会直接帮你检查并给出优化建议。

image-20260123162144404

正确识别以后,就可以在这里看到我们刚才创建的 Skills 了:

image-20260123171105353

接着试一下,看能否正常触发我们刚刚配置好的「审查流程」:

image-20260123171153095

确认没问题之后,一个简单的 Skill 就算搭建完成了。如果后面还有类似的工作流,就可以直接在这个模板的基础上继续拓展。

进阶

.cursor/
└── skills/
    └── ArticleReview/
        ├── SKILL.md
        ├── scripts/
        │   ├── deploy.sh
        │   └── validate.py
        ├── references/
        │   └── REFERENCE.md
        └── assets/
            └── config-template.json

上面的例子还是比较简单的。如果我们希望把技能做得更复杂、更模块化,可以使用类似上面这样的目录结构:

同样的所有说明都是在SKILL.md中,在这里对内部的脚本、工具、参考元、素材等内容进行引用或者说明,这部分内容就会作为Prompt喂给Agent,说明中可以直接使用相对路径来调用其他内容

其次,SKILL中也可以调用MCP指令等内容

不仅如此,还可以再进一步,这是一个SKILL,但是如果是多个呢?

.cursor/
└── skills/
    └── ArticleReview1/
        ├── SKILL.md
        ├── S1.md
        ├── S2.md
        ├── S3.md

如果你把一整个步骤写得特别细、特别多,这会导致 Token 消耗一下就特别多,而实际上只需要执行其中某一条或者某几条的时候,SKILL 就会显得有点过于臃肿了。

SKILL.md就提供了类似目录的方式,你可以把一个方式方法拆解成N个步骤,然后每个步骤给他一个单独的md文档,具体执行的时候,只有被选中的这个步骤细节才会被作为Prompt喂给AI,这样Token消耗量就下去了

他就有点类似于经验书了,可以按照门类或者方法细节,逐步进行归类说明,相当于是把你的人类经验提供给了Agent,这样他就能帮你按照你的方法去实现任务目标了。

更复杂的功能,参考官方模板和示例,已经给的比较全了,干各种事情都有

image-20260123214937144

与MCP区别

感觉之前对于MCP的预判说大了,MCP对比Skills有点更底层了,它更像是把不同软件或者接口串联起来的工具,更接近开发层面,而Skills则是更接近用户侧的东西,把AI玩出花来。

MCP为Skills提供了底层技术支持,Skills则是把底层接口的各种玩法进行总结,这样用户侧就可以非常简单的让AI介入到自己的工作或者生活中,真正的实现提效

Summary

Agent Skills 确实挺不错,其实有点像是各种AI综合赋能的平台,通过无码化或者低代码化快速将一些常用的功能进行综合。他们的实现思路都是比较类似的,只是Agent Skills更文本化,门槛更低一些,不需要你真的懂底下的技术层面的内容,你只要把你能套路化、模板化的经验公式说清楚,AI就能帮你完成。

Quote

v2ex里这个帖子讲的也太抽象了,明明是个很简单的概念

https://www.v2ex.com/t/1187373

广东自驾游之粤北篇-环南昆山

2026年1月19日 00:00

Foreword

这次往粤北走,补一下之前没去的地方,主要是为了跑山来玩一下。

环南昆山罗浮山

图片

官方给的线路,这个要走200多公里,估计下来得五六个小时才能环完

线路

重新规划了一下线路,实际从油田收费站下高速,就开始往跑山线路上走,打算走一个外八侧线路,本来预计4-5个小时能跑完

image-20260112015356570

线路:惠州再回楼农庄–>七星敦水库–>南昆山国家森林公园–>永汉河驿站–>增江竹畔驿站–>十三坝驿站–>酥醪景区–>下浪大幕–>罗浮山风景名胜区-2号门地上停车场–>博罗县吃晚饭–>广济高速回深圳

全程不走弯路大概100公里左右,导航时间3小时左右,算上停车游玩打卡什么的,可能要五六个小时

  • 其实这个里程可以完全不着急充电的,走到最后博罗县里再去充也来得及

沙迳服务区

这次路线规划,最大的问题是忘了电量这事,走到快下高速的地方去沙迳服务区充电,结果沙迳服务区在维修中,虽然能进去,也提供一点点服务,但是充电真的太挫了,反复插了好几次才正常充上电,后来再插就完全不行了。

南昆山

下了高速,还是得找地方充电,前面只充了一半的电,找了几个发现都有点问题,充电充不上,有点尴尬。

从惠州再回楼农庄到七星敦水库这段路,感觉车也没少多少,路也很短,很快就开到了,有点意犹未尽。

这里有机车俱乐部,很多机车压弯,也能遇到一些性能车在这里跑山,不过还是车多人多,没法全力跑

98748ad88438e647fc50a65886f9812

路是真的不错,看起来都是很新的铺装路面

中间导航去了南昆山的川龙瀑布,门票20,很小的瀑布,爬山也就一两百个台阶

image-20260119113734131

小景区人也比较少,还有一个山路滑车,无动力那种,价格也不便宜,不推荐

南昆山没有准确的定义,是一片很大的地方,各种圈个小地方就作为一个景点,独立收费了

永汉河驿站

往永汉河驿站走的路上,有一处观景停车的位置,刚好能看到日落

799136a031cf822a5cb3789d7317ad3

往永汉河这边走的时候,路上的变化就多了,各种弯,高低起伏,开起来还是挺爽的,就是副驾有点难受,容易晕。

  • 轮胎压到土,然后速度还高,确实容易打滑,中间后轮明显滑了,尾巴甩出去了,还好电控及时拉回来了

回看行程记录,速度也就70多,多数时间都是五六十往下,没有push,对向来车也时不时就有一辆。

看得出来惠州文旅是想把驿站作为开车兜风的中途休息点,可以充电、喝咖啡、吃饭等等,但是永汉河驿站这里空有一个建筑,充电没建好,还不能用,建筑里除了保安,啥都没有,连打卡都打不了。周边是个小镇子,镇子里各种东西还是挺丰富的。

中饭、晚饭就是各种农庄解决,价格还行吧,没特别难吃,也没多好吃。

其他

由于时间关系,后续几站(增江竹畔驿站、十三坝驿站、酥醪景区、下浪大幕、罗浮山)来不及去了,以后有空再说吧

Summary

官方宣传和实物差太多了,这次组织了三辆车一起走,带了对讲机,跟车还是挺有用的,可以提前通知后面车走线要注意的情况,就是头车跑远了,容易联系不到后面的车。充电也忘了提前规划,没想到会出现一路找充电的情况,太拖累人了。

Quote

https://mp.weixin.qq.com/s?__biz=MzA4ODA1OTkxMw==&mid=2650504942&idx=1&sn=204ee34ba60bef309bda08bb20d9276c&chksm=89b9750ae9d086b2d6821a668a9d2222c726b6fe040a7b71992f858bb133278098c9529676cb&scene=27

https://mp.weixin.qq.com/s?__biz=MzA4ODA1OTkxMw==&mid=2650503567&idx=1&sn=0d28401cb75e6bb6649a1aac081c5b41&scene=21&poc_token=HNjcY2mjyUrRuKC_8sXEgdxbUAGNtPBeU9JX-n2f

广东自驾游之粤西篇

2026年1月5日 00:00

Foreword

元旦刚好只有三天工作,连着前后的周末,一下可以休八天,去玩小米活动以后就准备准备,出发继续之前的粤西自驾之路,刚好在群里看到了一些户外的营地,感觉位置也顺路,那就过去看看。

自驾游

粤西大致路线

image-20250930145610391

粤西

这次没去广州、佛山、顺德,之前去过了,就跳过了,一路直接赶往肇庆

肇庆

肇庆是真的有点远,200多公里,要开2个半小时,这还是路上不堵的情况,以后要常去GIC真是有点麻烦。

晚上到达,直接在星湖景区周边逛了一下,夜景也还行,远处能看到一个寺庙一样的塔楼立在空中,还挺有意思的。

image-20260104201534658

image-20260104201647264

星湖景区,包括七星岩,默认晚上是没有门票的,也就是你可以随便进去逛,晚上有些偏室内的景点可能不开门,但是户外的这种都是没问题的,而且也有灯效,不是全黑状态,其实建议晚上去逛逛就好,室内给你看的东西也没啥,不值门票。

万万没想到,肇庆这里的景区竟然是我后面几天逛过的,最好的了。

七星岩

image-20260104202141748

七星岩是星湖景区的一个景点,大区域就是星湖景区,附近住的地方还挺多的,基本就是围绕湖建设的,开发还是比较完善的。

但是景区内真的乏善可陈,景区非常非常大,里面包含很多个景点,但是当你真的走进去就能发现其实实际可看的东西不多,它更像是西湖之于杭州的那样一个存在,适合晚间散散步什么的,看点倒是没啥。

鼎湖山

鼎湖山离星湖进去没多远,二十来分钟就能到,最主要的就是这个大鼎,很多人买了带各种吉祥话语的彩球从周边抛进去,讨个好彩头,彩球山下买也很便宜,5块钱10个,人也挺多的,学校组团来游玩。

image-20260104202258443

鼎湖山由于主要在山上,建议还是买游览车,直接送到各个景点门口,不然走的话得走七八公里,我去的时候刚好遇到有一处塌方,导致有两个景点之间需要走路过去。

image-20260104202606799

景区内还有一些二次收费的景点,比如蝴蝶谷需要买船票进入景区,湖心岛其实很小很小,蝴蝶谷也非常小,确实有一点蝴蝶,养了几个小猫咪和鸟,我去刚好还看到了猫咪追蝴蝶的经典场景。

image-20260104202801844

蝴蝶谷出来就是庆云寺,庆云寺没啥看的,就是一个寺庙而已,要看飞水谭,稍微远一点,需要走山路(爬山),大概一公里的样子,看完以后还得再走另外一公里(爬山)回到庆云寺,然后再坐车返回。

image-20260104202857301

将军山

夜晚看到的悬浮塔楼就是将军山上的,实际过来以后是需要30的门票上山,可以直接开车上去,但是吧,这个塔楼实在是太破败了,基本等于0人维护,游客也基本没有。

image-20260104203317694

他唯一的价值大概就是看一下落日,远眺肇庆和夜晚点灯后作为远山上的点缀。将军山基本上肇庆跟前的最高点了,星湖景区基本都能看全,它本身离星湖景区也不远,十分钟就能到。

image-20260104203535915

塔楼上有很多关公雕像,但是大刀都被掰断了,有人挺搞笑的,给关公换上了香烟,然后跟前一堆关公拿香烟。

古城墙

肇庆还有一段古城墙,朝天门

image-20260104203730774

城墙不高,比西安的低很多,城墙内可以认为是一个景区,有很多商贩,可能是淡季?人也很少很少。朝天门背后刚好就是博物馆,不过我来的太晚了已经关门了,停车场倒是免费可以随便停。

image-20260104203759005

云浮

蟠龙洞

蟠龙洞没去,先去了罗定,发现蟠龙洞需要回头,就取消了

罗定县

罗定好像也没啥好玩的,后面才发现罗定其实和云浮离好远,但是罗定又归云浮管理。会来这里就是因为看到了露营里有这个点:

广东省罗定云浮市新兴风车山12号风车,实际这个地方离罗定也很远,属于是山沟沟里的景点。上山以后就没有导航了,要靠路牌走了,还好路牌比较清晰,岔路也不多。12号风车这里又有一堆大爷大妈,放着几个大音响,鬼哭狼嚎,贼难听。无语,呆了几分钟就走了。

image-20260104204824396

实际上那里风车非常多,再往里走一些,就能看到很多没人也没各种设施的风车点。

image-20260104204743018

风车超级大,除了前面几个风车有点商业化,被人建了一些破烂铁皮房子,其他的都比较原始,适合来看日落或者日出。

之前看攻略说最好是SUV去,实际上我开Ultra也没问题,路还行,稍微有点技巧不至于刮底盘。

信宜

黄凿顶

黄凿顶是信宜附近的风车山,实际没去,去完上一个对这种地方已经失去兴趣了。

马安竹海

接着去了信宜的马安竹海,但是实际这个地方离信宜城区也超级远,走过了各种村子,然后路还在修,各种单行道错车。

马安竹海是有两个入口的,但是都需要门票,车还需要额外的车票,人就45,车30,也就是75元才能进去。进去以后,有啥呢?是真的啥都没有。

image-20260104205813277

游客中心,核心景区就这么些,半小时就能走遍了。除了几家民宿和一个大一点的酒店,再就没啥别的东西了,连个摆摊的你可能都遇不到,这里农户就这么几家。

至于竹海,抱歉没给你路,你也不能去逛上面看似无限多的竹林。我从另外一个入口出去,一路走了可能接近一个小时,全都是高低起伏的崎岖山路,才总算走到了另外一个入口,看路过的一些小牌子,似乎这里是给自行车做爬山赛或者速降赛的场地,怪不得修了这么一条路。但是这么长的路中间,真的是啥也没有,没有任何景点,没有任何可以停车的地方,路又很窄,不能开快。

来这个地方可以说是妥妥的大怨种了,来这真的不如直接去城郊的村里。

茂名

石镬坳

石镬坳是茂名附近的风车山,实际没去,去完上一个对这种地方已经失去兴趣了。

中国第一滩

image-20260104210653750

好歹我也去过好多不同的沙滩了,这种还真是第一次见,这里的沙子基本不会被冲走,而且水远处看有点黄黄的,实际冲上来以后就很透彻,沙子踩着也是偏硬的脚感,不会陷下去,有点意思。

沙滩公共洗脚的基本都坏了,但是旁边便利店有很多淋浴的地方,洗个脚只要1块钱,真的是有点便宜了。海滩也非常大,停车位很多,周边商业化还行,不过有点老旧了。

电白博物馆

小地方人好多,停车有点困难,停在了稍微远一点的一个小区,后来一看停了快2小时,停车费2元,真便宜

博物馆还行,主要是介绍一下电白区的历史,是做瓷器起家的,主艺术家是陈金章,岭南画派,不过展出的原作不太多,大概只有十几幅的样子,主要是国画融合水彩、水粉进行创作。主要展出还是以三峡为主题的,气势磅礴的山水为主吧。

虽然是小地方,还是区的博物馆,但是整个布展还是可以的,人物介绍,画作展示,还用了环绕屏,做出来了3D水墨世界的一些小动画,出乎意料,比一些大馆里的还好一些。

阳江

佛子坳

阳江附近的风车山,实际没去,去完上一个对这种地方已经失去兴趣了。

海陵岛

海陵岛,拉中拉,主要还是住错地方了,住到了村里,然后村周边那可是真的有点荒,什么海滨路,全都没有。

后来发现应该是住海边的,靠近海上丝绸之路博物馆的周边,建设相对现代化一些,而远处是真的不行,很拉胯。

鉴于之前已经去过南澳岛,一对比,发现粤西这边还是太穷了,发展远远跟不上粤东。

江门

开平碉楼

开平碉楼真的拉胯,前面看别人说这里什么环境不好,到处垃圾,我这次去还真没有。

image-20260104211527555

但是这个碉楼呢,我只能说这就是,完全不能进去,只能纯看,没有任何说明,没有导游,路边全都是类似骗子那种。能进去的碉楼,笑死,那是别人家,20一位,文物是他家的老漆木椅,我无语了。

image-20260104211723328

真的是不知道这种东西是怎么被宣传出来的,要啥没啥,更别说他景区地图上的其他地方了,全是村里已经破败的老房子了,这东西也没任何提示牌,没有任何保护性的东西,只有这点建筑外型有点点特殊。

上川岛

有了海陵岛之鉴,上川岛虽然可以开车上去,但是我选择跳过去下个地方了

中山

红卫宝岛

image-20260104211956210

之前是看别人说这里风景还行,来了以后,还是啥都没有。可以体验一下船载车上岛,车票30,人3块钱一个来回。岛上是真的除了大棚,真的没啥可以看的了,没有小电驴或者车,逛岛上的路还真有点远。

岛上只有一个小村子,村子里看起来好像是想做一点旅游业的,但是公共设施基本都处于施工一半的状态,但是看样子就算建了,也不咋会有人来,小村子连个商业点,小商店都很少很少了,村里人点个蜜雪都是从外面坐船带进来的。

翠亨国家湿地公园

公园就在高速旁边,下高速就能看到,但是这里虫子超级多,走过去密密麻麻的,来的晚了,17点就不给进了,要进公园需要坐船才能进去,停车区域还是挺大挺多的,人也多,以后有空了再去吧。

Summary

粤西整体确实更穷一些,旅游业发展也确实更挫一些,唯一好点的也就是肇庆了,其他地方是真的不行,连个拿得出手的景点我感觉都凑不出来。

到这里基本上只有粤北没咋去过了,其他地方能去的都去了,下次可能就是走出广东,去其他省看看了。

小米珠海官方赛道日与Ultra年度之夜

2025年12月29日 00:00

Foreword

小米官方赛道日

报名

小米Zic是12.15开抢,果不其然没抢到,还好群里有老哥抢到了不去,刚好就换成了我

  • 抢名额是真的恶心,一共就60个名额,42秒就抢完了

活动日程,这日程排得是真紧张,一点空隙都没有

image-20251222195010137

这次多了一个晚宴,晚宴同时进行模拟器比赛,晚宴还好可以后续再补付费,不然晚宴去不了还挺遗憾的

image-20251222105824791

赛前整备

17号小米官方就发了赛轮88折的轮胎券,总算等到了买了2个265,2个325的新胎

之前用的卫途P01,算了一下,之前大概跑了六七个飞行圈,剩下的轮胎估计还能跑个2节,可能不太够,所以还是换了一对新胎上去,后轮磨损不明显,就没换。

  • 又加购了2个轮毂,这样就有一套265+325的轮胎轮毂了,后续日程就能换成普通长续航胎长期用了,需要下赛道就换赛道轮毂。

模拟器练习

为了仿真车,把量产车的刹车力度下调10%,在这种情况下刷圈,跑了一周,最后能跑的最快也就是

image-20251228215242866

能看到理论极限大概是37左右,前五个弯就有1秒的差距,但是我总是把握不好,开不出来。

后来知道Gary可以用量产车跑到34,真的太恐怖了,这种不看实际走线,无法想象是怎么开的。

赛道日当天

签到

image-20251229005558208

所有人被分为ABC三组,C组是最快的,B组其次,A组都是新人,一组20人。我被分在B组,但是有些没跑过的莫名其妙也被分在了C组。各组有各组的停车位,然后公共的散热P房,这个还挺好的,挺有序的,上场或者下场也不会混乱。

当天还是一样,先开车手会,新手上课,讲讲注意事项,但是这里做的不够好,导致后面出了n多事情都是前期这里没弄好的造成的。

这次有一个教练,教练可以上车指导,有小米汽车的几个产品经理,汽车APP负责人,运营负责人等等相关人员,来的还是挺多的,但是介绍的还是有点少,记不住,后面聊的时候也只和其中2个主要聊了聊。

练习

A组新手组,一开始被提醒的比较多,大多数也都开得不快,又或者是开的耐力模式,圈速基本都在2分钟左右,但是依然出了红灯,大概三台车就出去了,还有个认识的老哥上墙了,属实亏大了,维修成本大概二十万左右了,直接卖车,拿残值(20w+)了,修太亏了。

B组依然没好多少,我上去刚暖胎好,刚push过了T1,就看到有车在波波池里了,果不其然,一圈没结束就红灯了,只好下去了。

由于B组红的太快了,又重新计时再来一次,好家伙,我上去一圈,又红了,又下来了。然后此时赛道上满是石头,大直线有石头,弯心是石头,龙门架下还有坨黑影不知道什么东西,中间路上还有带出来的草,无语了。

一轮过后,赛道情况过差,所以整体暂停,进行赛道清理,这时冷静下来,一看我的车玻璃碎了

image-20251228174957127

再看一眼群里,一堆人都被崩碎了,无语了,下了这么多次赛道,第一次被崩了。

问了一下,大概修复一下200以内就能搞定了,换玻璃,等我无忧快到期了再换吧,后面还是有概率再次被崩的。

image-20251228214754389

  • 后续修补完成,只有一个小痕迹了

等到赛道清理干净,B组重新开始,又上了一次,但是前面还是慢车太多了,不好超车,导致圈速又做不出来,跑下来都只有1.57,纯纯浪费我的轮胎,于是早早下去了,等下午圈速赛再说吧

中间穿插了一个集体合影,但是很奇怪,合影是反过来的,逆光,逆赛道,属实有点离谱。再结合之前的赛道上拖车下来全都是逆行,我有点难说,之前都是正过来的,就这次非逆行下来。

圈速赛

圈速赛同场只有五辆车,而且间隔一个弯的发车,即使如此,我依然追上了前车,然后暖胎没暖起来,飞驰圈在T1刹车胎压还只有2.3,车机直接开始报警,中间追上的前车似乎看出来了我比较快,选择给我让路,但是可能还是新手,让的是非行车线,我只能勉强超了他,最后跑完,散热了两圈,下来一看1.50.8。

image-20251228180608110

本来计划的这次换了新轮胎,可以试一试跑148或者146的,我对比了一下上次职业车手跑的,很多地方速度都不高,甚至最高速我都比他快好多。

一共跑了大概3节,胎浪费了一半,可惜了。最后圈速赛还是有一些出事故的,最后统计大概有接近十辆车冲出赛道了。

这次跑的比较快了,查看了一下刹车片和轮胎,确实是有比较明显的磨损了,刹车片可能少了2-3mm,轮胎直接减少40%寿命,之前比较慢,磨损都不明显。

圈速跑完以后原本应该还有点练习时间的,可惜中间各种事故耽搁太久了,也就没有了。

我们跑的多的都知道暖胎1圈,冲1圈,散2圈,就可以回来了,但是小米的赛制里还是暖胎1圈,冲2圈,散1圈,实际Ultra这个车的散热激烈驾驶(圈速150以内)都是扛不住这么跑的,然而小米的工作人员都自信的以为可以冲2散1或者是冲1散1,这只能是没下过赛道,被自己公司的文案忽悠晕了的人才能这么以为的。

赛道日的体验基本到这里就结束了,后面还有摄影师给你一个牌子+圈速合照,归还设备以后还会再给一个带圈速的纪念相册,小米仪式感每次都做得还行

年度之夜

image-20251229010537063

晚宴就在ZIC内一个车手俱乐部里举行,中间就是之前的六个模拟器大赛争夺冠军,赛制是跑30分钟+几圈吧,然后看总体用时最短的人。过去看了一眼,基本上前4都是每圈1.27几的人,真的太恐怖了,圈圈如此,都练成了肌肉记忆。

也仔细看了一下他们的比赛设置,确实和我默认的不太一样,他们是光头胎,胎压很低,抓地力更好一些,跑起来更快一点。

image-20251228235025801

有一个老哥(张辽)好像发生了什么争执,直接退赛了,我碰到的天津老哥(刘xx)还挺好的,边开车边聊天,后来被张辽说不要影响车手开车,我们聊天的时候老哥从第四开到了第三,我就去看第一开车了。偷学一下第一的走线,确实有点厉害,他们和模拟器的推荐的最佳线路稍微有点不一样,但是刹车的时机基本一致。知道他们走线方式以后,复刻一下估计我也能跑到28、29。

天津老哥心态还是很好的,他们这比赛的六个人都很熟悉,经常一起比赛的,老哥开的时候还在劝架…第几都无所谓,免费从天津来珠海旅游一圈,还挺好的。最后三十分钟到了,他们又跑了几圈就整体结束了,老哥最后几分钟前失误了一下,从第三又掉到了第四没机会上领奖台了。

后面我体验了一下他们的六轴体感座椅,体感上稍微有点弱,但是确实能够感觉的出来是推头了还是甩尾了,不过速度感一般,还是比不过车里的感觉。

晚宴就是自助餐,一般般,跟食堂差不多水平的,中间穿插抽奖环节,哎,这个抽奖实在是有点难说,你要说你抽一次就算了,你还抽那么多次,然后抽到的全是群里面最活跃的人,其他车主不熟悉的可能觉得很合理,靠,我看一眼我就知道不对劲。当然奖给他们也没问题,毕竟就靠这几个人带活整个群,少了他们这个群估计没多少活跃度了。

image-20251229010720180

晚宴内容基本就是这几个最活跃的车主,讲讲他们的历程,台下一堆捧哏的小弟,之前别人说小米有宗教信仰,我还有点嗤之以鼻,我在现场我是稍微有点感觉的,狂热粉和冷静路人感对比还是很明显的。

晚宴的模拟器比赛是4个车主上去比的,嘴上说着不要大佬上,但是实际上还是3个车手加一个新手,高翔负责解说,新手上去鱼雷了几下,加上几次飞车贡献了全场的看点,还是挺有意思的。

UltraClub运营应该是比较一般的,但是现场小米说明年还会继续,现在的赛道服务依然还会有,活动会更多,做到每2周就有一个赛道活动,更激进一点每周都有一个活动(不一定是赛道)一年有52周,去掉一些节假日以后,能做30-40周活动就不得了了

image-20251229010348056

后续我发现,整个UltraClub的赛道预约,真正下赛道的人,除去那几个KOC,我还算是前几个预约下赛道的用户之一,所有预约过赛道服务的也就二百六十多人,也就是是1.7%的用户真的下过赛道,但是这个里面估计只有60人不到是经常下的,其他人都属于玩一下就算了,可以想见这个圈子的基本盘是多么的小。

image-20251228220906046

最后散场给每人一个说是小米定制中心做的伴手礼,纯手工,然后,肉眼可见的工艺水平和瑕疵,我难以言表,这样的东西夸不出来一点,你说你是淘宝临时买的也好啊,自掉身价。

产品交流

与小米的工程师和产品交流了一下Ultra的问题和后续想要添加的一些功能,再结合周边其他车主发现的问题,其实阿灿是不知道Ultra量产车的情况的,他不对此负责,原型车和量产应该是完全两个独立项目组。实际量产车后续的更新迭代和他应该没啥关系,所以出现我们以为的灿哥都懂,但是实际上是他根本不知道。而Ultra在珠海的表现或者实际车能开的情况,可以说小米内部应该没有人关注,至少所有产品都不清楚这个车在ZIC做每个时段的圈速,这个车的表现应该是啥样的,或者说要啥样的能力、性能,车辆的消耗,都没有基础的估计或者预测。

那就不用说其他赛道,小米只刷了这4个大赛道,其了解程度也只做到了这般,可想而知其他小赛道的情况就更不容乐观了。

更不用说小米的维修工程师或者专业的车辆工程师,看到刹车盘镀层脱落了,是明显的掉块了,还能认为是没问题的,甚至是多个维修的工程师都看过,都能出一个没问题的结论,着实有点离谱了。

小米对于赛道版、空悬版实际的驾驶的能力 了解也是明显不足的,赛道版明显的刹车过热问题,视而不见,可以想见小米的这群搞车的,还是太高高在上了,营销的赛道,实际赛道没下过多少,真实赛道的玩车流程没走过,那产品怎么做好。

小米的产品还是偏保守一些,很多涉及HUD或者赛道大师等相关问题都不太敢轻易决定是否要做,怕因为用户偏听偏信了车机的内容,导致了安全事故,最后怪到了车企头上。

但是这样过于保守,你如何突破创新呢。Ultra营销开了个好头,但是中间更新造成的风波,比如锁马力这种事情,可不就是过于保守,定下这种失智的决定,然后被群喷,自此开始不断被攻击、诟病。总感觉营销和产品没有绑定到一起,营销走到了前面,但是产品力跟不上,甚至在日常的迭代中还能开倒车,简直无法相信。

现在舆论被黑,虽然黑的点不太对,只是因为他们没有真的买小米的产品,没有真的自己去用,一旦你真的去用,去体验,就会发现可以喷的地方还多着呢,产品力跟不上营销话术,这种脱节产生的问题,以后还会有的,现在只是刚开始罢了。

赛道这个圈子这么小,可以说是好事,小米你只要不傻,你就知道这里机会大着呢,你随便做一做就能把这个小圈子热度给带起来,把小流量搞大。而真正做赛车、玩赛道的是吉利系,可惜吉利系没想着怎么去破圈,还是在圈内玩,而有破圈能力的只有小米,赛车文化需要广泛的群众基础,现在看来这个基础还不够,如果小米深耕一下,其实里面应该还是有不少机会的。

Summary

小米的赛道日的组织,有些地方是考虑比较不错的,但是在新手课,下赛道,避免发生事故等等方面做的还是太弱了。如果你想要营销这个车的赛道属性,想带更多用户去下赛道,你就得是个小白用户,就得自己走一走赛道的路,看看你自己会发生什么事情,又怎么避免。但是从现场看,小米的工作人员除非是工程师,否则都是没有赛道经验的。是的,基本可以说是一点都没有,他们可能下过赛道,但是是别人带着,龟速行驶,走过赛道,这种就等于是没有,这不是一个正常的或者是普通用户接触到赛道的流程或者途径。

你想要带更多的人去赛道,想弄起来赛道文化,你得把门槛降低,这个门槛不仅仅是参与门槛,还有后续的问题,比如赛道上产生车损的概率,比如事后维修的成本降低,比如安全问题,普通人是签了保险、签了各种免责来了,但是上了一次赛道,产生了几万或者10w的费用,你觉得他还会来一次或者还会跟别人说你车好、赛道好、赛道安全的概率有多少?他身边的人都会以他为戒,再不会碰你赛道的东西了。

赛道是个高危的活动,但是你要冲着给他做成低危,才是你的本事,能让普通用户体验到这个东西的乐趣,才会有更多人跟着你玩,一上来给人玩成事故车,这谁还跟你玩啊。

广东自驾游之南澳篇

2025年12月10日 00:00

Foreword

继上次十一爆胎,行程中断以后,一直没找机会再去。这次刚好在MOC深圳小米车友会中有人组织去南澳,出发和返回时间也还行,就一起了。

南澳岛

出发

深圳出发是六台车,4台Ultra+2个yu7,我是最后到的,大家都在等我到了才一起走。

组织人也没啥经验,加上深圳这边周五出发(19:30)路上还是比较堵的,刚开始大家都没走到一起,比较分散。

后续在第一个服务区才重新汇集到一起,但是开上路以后基本还是各走各的。

到南澳岛这个距离比较尴尬,刚好是367公里,刚好卡在了Ultra的续航里程上。你电稍微没充满或者多踩了几脚油门,那就不够,就得中间充电了。yu7是没啥压力,可以随便开。

中间还是充了一次电,然后才一口气开到南澳。

image-20251209191106495

其中一个Ultra满电没充跑了一路,勉强到达汕头充电站,非常极限了。

第一晚

南澳岛定了一个别墅,六间房,刚好六组人,各自睡各自的。还有汕头的车友等我们过来以后再过来。

当天到达已经0点了,别墅挺大的,烧烤的炉子也有,但是困难的地方从这里就开始了。

打算到了就开始烧烤,串、调料都提前买好了带过来,现场没啥饮料、零食,也没夹碳的夹子,临时外卖点了过来。

image-20251209193846215

第一步点火没问题,但是要把碳烧着,贼困难,火力太小了。现场都是没经验的小年轻,也不咋帮忙,都是等吃等喝的,都不会点燃碳。纸倒是能烧着,但是没有足够多的纸来烧,加热到碳能着。买的那个碳又特别难点着,就这个地方卡了两个小时。

总算是汕头开烧烤店的车友过来了,简称烧烤哥,人贼有经验。纸上蘸油,然后把碳垒个小炉子,留好通风口,这个纸就能烧很久。然后再用吹风机(真·吹风机,吹头的那种,没办法临时凑活),小风力吹就能快速逼出来大火苗(其实这里做错了)。就这么反复折腾弄了1个小时,总算是点燃了一部分碳,可以烤起来了,这会已经3点了。

image-20251209192113142

碳互相点燃也很费事,火力太小,中间又调整了好几次,大概弄到了4点,才算是大家都吃饱了。别墅里娱乐设施比较少,就一个麻将桌、一个卡拉OK,其他也没啥。至于小泳池什么的,你不嫌脏可以玩,晚上又太冷了,不会有人下水的。

image-20251209192216026

最后弄到五点多基本都散去休息了。

第二天

前一天这种组织程度,第二天果然汕头的车友都溜号了。第二天活动13点才开始(硬虎卡丁车),光是等人就等了2小时,很离谱了。然后MOC组织人又要完成一些拍照、拉横幅、打卡的目标,反复停车、拍照,弄完差不多已经快3点了。

image-20251209192436999

image-20251209192508203

image-20251209192851776

等开上卡丁车,都三点多了。卡丁车还不是包场的,一共跑2节,店家还送了教练的RingTaxi,算是3节吧。整个开完就快5点了,店家确实不错,还单独给封闭赛道颁个奖。

image-20251209192951770

这个妹子第一次跑卡丁车,跑得贼快,最后成绩比我还快一点点,挺厉害的。店家都没见过,邀请她去车队。

image-20251209194003038

山顶落日(导航到葫芦山),落日的晚霞还挺好看的。

3ba76863308427407ec87ea48c1d228

085bb7681627ddd6f2c4e0d5f6a76bd

晚上活动依然是烧烤,不过这次烧烤哥不来了,坑怕了。这次我负责点碳,吸取了之前的经验,这次很顺利,一个小时基本上就把碳都点燃了,正常开始烧烤了。

不过由于没有更多的活动安排,汕头来的几个妹子都早早撤退了,剩下我们几个别墅住的聊了聊,说是明天早起环岛。

第三天

果然,早起不靠谱,只有我跟另外一个银行的哥们起来了,其他人该睡的继续睡。吃过早饭我就独自环岛了,由于我明天还需要去珠海,得提前撤退,不能和他们一样弄到晚上再走。

南澳岛开车环岛还可以,岛周边停车的地方也挺多的,大大小小的沙滩都可以停车。个别有点特色的地方也建有停车位,不是节假日,人也不是特别多。

image-20251209194050772

这里就是一个鱼排的地方吧,勉强算是有点特色。

image-20251209194109677

南澳岛的森林公园值得一去,路修的还可以。主要是自驾上去需要20门票,直接劝退一帮人。路上车很少,想停车也可以停,观景点也有预留车位。课本里的23°北回归线就在这里,牌子写的是不能停车打卡,实际上遇到的车还是停在这里打卡。

image-20251209194133188

image-20251209194146913

公园里的观景点,能看到直通南澳岛的大桥。

image-20251209194206037

这个小红灯塔没必要去,超多人,基本都是大爷大妈,感觉是被忽悠来的,没啥内容,就能拍一下,没了。

返程

返程遇到一个奇葩情况,汕头的西港高架桥堵车了,然后只要有大车经过,这个桥就晃起来了。我坐在车里感觉到了共振,感觉有安全隐患,不知道有没有人反馈过。

image-20251209194250710

不出意外,返程到惠来段被拍了超速,还好只是不到20%,交了150的罚款,没扣分。来的时候也超了,但是可能没抓到。

珠海赛道

南澳回来就立马去了珠海,第二天赛道日。这次四点式弄好,上赛道身体不乱甩了,这次目标是干到1.55内就行了,有信心多了。

第一节差点撞了个Ultra,然后继续push又刹晚了,t9又进陶粒池了。还好这次我学乖了,不停车直接出来,只是兜了一点陶粒,其他没啥问题,就先回P房了。

充完电,再继续刷一次,这次第一圈就成功干到1.53了。

image-20251209214430671

还剩余一些电,再充一次估计来不及,就想着刷完得了。

前两圈遇到其他车了,没办法push,一直等到他下场,我push一圈。

image-20251209214933327

成功刷进1.52,实际还有不少空间。中间有个弯刹的也有点问题,本来可以快3秒多的,估计下次跑进1.50不是难。

Summary

汕头这次没咋逛,早茶啥的也没机会吃,看以后还有没有机会吧。

神力科莎赛车模拟进阶

2025年11月18日 00:00

Foreword

神力科莎的车辆配置,前面玩的时候都用默认,后来发现实际上这里有些选项可能是要调整的,调整以后可能车速会快很多,这种一般都是针对一个赛道做一个配置

车辆配置

CM中的车辆配置以后需要单独另存一个配置,然后每次在进入AC以后再选择对应的配置进行加载,才能使得这个配置生效,否则都是默认的配置。

轮胎选择

CM中轮胎可能会有这么几个选项

image-20251116004750087

Pirelli 是倍耐力轮胎,Slick是光头胎,也就是轮胎表面无花纹、沟槽的意思

  • D表示是干地,W是湿地,对应适合的地面条件

  • DS,S 是 Soft,DS 也是软胎,适合低速、短时间需要最大性能的赛道,磨损很快

  • DM,M是Medium,DM轮胎是倍耐力赛车光头胎系列中的一种,专为追求极致速度和性能的赛车设计。其配方较软,允许的工作温度较高,接触面积大,能在每个刹车和转弯中带来明显优势,适合新手

  • DH,DH轮胎同样属于倍耐力的赛车光头胎系列,是Dry Hard的缩写,意味着其具有较高的硬度和耐磨性。相比DM轮胎,DH可能在某些特定赛道或条件下,如需要更高硬度和耐磨性的场合,表现出更稳定的性能。

  • DHH,这是比 DH 更进阶的子系列,超硬

image-20251116010351141

接着就是胎压,一般建议胎压是要低一些的,跑起来轮胎会发热,胎压自动就上去了。胎压也需要一个合适的值,过高会导致轮胎变硬,接触面积缩小,抓地力下降,过低则会磨到轮毂,阻力变大。

空气动力

image-20251116010906267

Front splitter,是分流器,这是前分流器的设定,可以增加车的下压力,我感觉可以理解为前铲

Rear wing,尾翼大小,过大会造成阻力变大,过小会失去下压力

轮胎角度设置

image-20251116010930868

Camber,外倾角,这里可以调节左右外倾角的差异,实际是指车轮和地面的夹角,影响轮胎与地面接触的面积,进而影响抓地力

Toe,束角,俯视看车,轮胎内八和外八的角度就叫束角

  • 前轮正束角可以增强入弯稳定性,但同时在过弯时会产生更多的转向不足。后轮正束可以保持车辆整体稳定性。

  • 前轮负束角会使转向反应更加直接,但是会更倾向于转向过度。

  • 后轮负束角会引起车辆非常不稳定,一般不推荐这样的设定。

这两个值都可以对应到每个轮胎,所以一共就有4个

阻尼

image-20251116010959460

大白话说就是减震器的相关选项,比如空悬和绞牙避震,一般绞牙可以调整参数比较多

Bump是压缩阻尼,一般来说越硬性能越好,但是人坐在里面等于要被颠死

Rebound是回弹阻尼

动力

image-20251116011007959

Drivetrain,主要是动力分配,Front distribution,就是前轮分配20%动力,那么就更偏后驱

Center preload,中心预压,用来减少车身侧倾,提升过弯稳定性

下面是刹车力度,Brake bias是刹车比,指前后轮分配的刹车比例,大于50%表示是靠前轮刹车。通常,刹车比靠前使车子在刹车时更加稳定,同时在入弯时会引起转向不足的问题。刹车比靠后会导致刹车时车身不稳定以及刹车距离变长,并且增加入弯时转向过度。

悬挂

image-20251116011020594

Suspensions,悬挂,主要是悬挂相关的一些设置。

Packer rate,控制避震器压缩行程末端的刚度,限制悬挂的最大压缩量,防止底盘触底,说白了刚度越强性能越高

Wheel rate,Wheel Rate 表示作用在车轮中心的垂直力与车轮垂直位移的比值,单位通常是 N/m 或 lb/in,越高,对应更硬的悬架,响应更快

Height,是悬挂的高度,可以用来改变车身姿态和重心,平衡配重

Arb front,Arb rear,前后防倾杆的硬度设置,越大越硬,转向更灵敏,但容易出现转向过度

幽灵车

好不容易开出了一把最快速度,但是发现忘记录制了,而且之前的录制记录已经被其他录制覆盖了,别担心,你还有幽灵车,幽灵车会存储在下面这个路径里,这里记录了你最快的一圈的走位

c:\users\你的用户名\documents\assettocorsa\ghostcar
  • 每个地图每个车型都有一个.ghost格式的文件

但是这个格式不能转成acreplay,当你发现你重开不出来更好的一圈的时候,还是很难受的。但是你可以把别人开得更好的幽灵车记录拿过来,然后就能学习,跟车了

时间回溯

AC也有类似地平线的时间回溯功能的Mod,但是并不是那么好用

bilibili.com/video/BV1ecfTYaEVC

可以参考视频介绍的

用起来也非常简单,拖入AC里,然后绑定一个按键,你就能直接回溯了,reset可以重置回溯的数据记录

  • AC里回溯的时候,方向盘等不会联动,也不会重新反应,你需要自己回溯一下

时间回溯主要用来甩出去或者确定刹车点的,不过由于要你人的操作也回溯,很容易操作失误或者延迟,这样会导致一些极限性的地方还是要重跑一下,而不是回溯。

提前警告,使用时间回溯可能会导致配合使用的DeltaTime类型的Mod发生计时问题(计时错误),所以要用这种方式最好注意一下规避,否则查原因好难查

Summary

模拟器的上限可真高啊

Quote

https://zhuanlan.zhihu.com/p/371780334

https://www.assettocorsa.net/forum/index.php?threads/where-does-the-ghost-car-replay-save-to.14708/

https://www.assettocorsa.net/forum/index.php?threads/how-to-convert-a-ghost-into-a-replay-file.6290/

https://www.assettocorsa.net/forum/index.php?threads/adding-ghost-s-to-replays.2649/

https://www.reddit.com/r/ACCompetizione/comments/wk3hn3/where_do_you_save_a_ghost_car_file_and_how_do_you/?tl=zh-hans

小米模拟器冠军挑战赛

2025年11月17日 00:00

Foreword

小米整了个模拟器冠军挑战赛,挑战的六个人都是模拟器界的冠军车手,比如高翔、Gray等等比较知名的模拟器冠军车手了

统一赛制统一服务器,前六名可以去珠海参加决赛,前三名可以拿模拟器大奖,全国30个门店可以参与

https://web.community.car.miui.com/activity/universal?actId=207874295&siteChannel=2&skipLocal=true

模拟器冠军挑战赛

image-20251116195237097

简单说一下规则:

  • 非小米车主一样可以报名

  • 每个人每天都能挑战一次,从小米汽车APP报名即可,选择离自己近的门店就行

  • 门店内5分钟模拟器测试,15分钟线上比赛,取最快圈速,全国取前六位

image-20251116195417465

比赛使用Su7 Ultra Prototype车的mod和珠海赛道mod,mod我放到下面位置了

我用夸克网盘给你分享了「negi_su7…1116.rar」,点击链接或复制整段内容,打开「夸克APP」即可获取。 /~40ae39ARtl~:/ 链接:https://pan.quark.cn/s/83e0760f4476

  • mod和外面下载到的有所不同,建议使用官方的,轮胎用的是DHH,不知道为啥

体验

15号知道这个消息提前练了一晚上,原型车竟然突破了我以前的132,甚至以前的理论极限131都突破了

image-20251116200801167

直接到了130的美妙新世界,但是开130的时候也有一些失误,实际上我估计再开好一点可以到129甚至128

16号过去,还好群里说了一下很多门店都没准备好,我直接去了深业车城的店,这里已经弄好了,可以直接去玩

image-20251116200030110

一个门店现场只有1台机器可以试玩,如果前面有人的话要等一等

image-20251116200023253

开车倒是不限制视角,就是车的参数啥的都不能动,都用默认的

等我体验的时候发现这个方向盘超级重,刹车也特别硬,平常家里是模拟量产车的,这种原型车模拟还是非常硬核的,前5分钟没开出一把正常圈速,都是1.40多了

  • 建议带手套,手汗还是挺多的
  • 建议赛车鞋或者是比较窄的鞋子,他的油门和刹车离得比较近,容易同时踩到

image-20251116195946859

进入15分钟比赛,更是一堆鱼雷选手,然后慢车挡道,反复回P房好几次,总算正常跑了一圈,圈速是1.33.6几,然后时间没到我就停了,热得我一身汗,目前这个门店里最快的了。

等我晚上再过来,依然是最快的了,说明这个东西上手还是有一定难度,但是很多人没开过,也能跑到136、134,挺有天赋了。

Summary

比赛一共持续21天,有兴趣可以去玩一下,自己也能练习一下,16号当天全国最快的已经是129了,估计21天最后能进决赛的都是126左右了

3D米家,智能家居未来

2025年11月14日 00:00

Foreword

时隔好久,小米总算是做了我大概10年前智能家居的想法,先分享一下米家的测试版APP,建议使用平板安装,手机性能不够

夸克网盘:https://pan.quark.cn/s/5a91ea45e5c9?pwd=4vbk 提取码:4vbk

百度网盘:https://pan.baidu.com/s/1QIdUnmUBVlz4GnW2cFi8GQ?pwd=88vr

3D中控

简单说,米家总算是把你家加入了米家,智能家居才迈出了第二步吧。

我这里是拿手机体验的,效果还是挫了一点,最好还是拿平板,功能全一些,可以参考上面的视频内容

image-20251113235259376

流程也比较简单,导入你家的户型图或者是直接搜索你的小区,拿网络上资源或者类似的户型图作为底,再手动修改

image-20251113235317586

就可以对自动识别的空间和房间进行匹配,缺少的空间可以手动拖入,创建出来

image-20251113235422198

接着就可以把实际的智能家居拖到房间的各个地方,然后就可以选择生成了

  • 目前支持定位的智能家居还是比较少的

image-20251113235523368

生成的3D户型模型,是可以直接点空间中的家具交互的,比如打开窗帘、打开空调

这里还是得吐槽一下小米,一个3D内容显示这么卡,功能还不给全,还要平板来体验,你是真的开倒车,人家QQ都能嵌入一个UE4,你米家嵌入一个也不过分吧。

再退一步,你不行,你用浏览器云端做一个也很简单吧,那么多做在线户型图装修预览的,随便结合一下,真的很简单。这种需要精细调整的东西,还是得键鼠操作做出来的才好啊

智能家居未来

在我看来智能家居大概是三步:

  1. 大量铺货,先让每个家有一定数量的智能家居,要低价,普惠,做惠民方向
  2. 智能家居的3D模式,每个智能设备的定位,人的定位数据,设备使用数据,数据的积累
  3. AI和数据分析介入,真的可以实现一键或者一句话自动帮你做好智能家居的联动,而不是你一个个任务、一个个低代码的拉线流程图去做

先说基础的定位数据,很久之前小米出的UWB手机、UWB电视,他们是想做智能家居定位的,但是可惜他们动得太早了,早年UWB使用的频段处于灰色地带,后续说不让用就不让用了,你做的产品根本没办法大量推出去,这个噱头直接干没了

image-20251113235916314

2024年新规以后,这个UWB的频段也被限制的比较小,很多之前的产品目前都算是违规了,UWB现在还是初期,问题其实很多,UWB本身就不够成熟,市场也是,所以这个方向目前不确定性还是非常高的。

当初小米肯定想的是如果每个智能家居带一个小小的UWB,那就直接有空间定位了,甚至不需要这个户型图就能实现每个人和物的定位了,直接就能反向给你生成户型,想象还是美好的,可惜了。

只是这一步3D户型图,我是没想到是时隔4年(21年UWB产品),小米才想起来要做,真的太慢了。

除了定位的数据,设备在何时、何地、被谁使用,这个数据也需要有,如果纯依赖监控摄像头太容易出问题了,本来很多人就介意家里视频数据,更别说万一出个漏,那就出大事了,家庭的防御系统是远远弱于商业级别的,很容易被入侵。

  • 果然后续小米出了一个本地视频模型,分析摄像头数据的

img

监控摄像头的方式去获取用户习惯,我觉得可以晚一些做,想办法先拿普通智能家居被使用的数据来做智能化,后续再考虑视频相关的

在第二步,这个用户数据其实非常敏感,我建议是最好这里收集的定位数据,特别是人的,要结合一款本地化的产品做数据收集,可以是NAS,可以是路由器,也可以是中枢网关类的产品,这部分数据是要存储到本地的,用户授权以后才能拿去训练或者学习,小米提供云端模型的服务,用户提供数据就能直接给出来智能家居的整套自动化流程,这样这个第三步才能实现。

在这个领域其实还有第四步,具身智能成为智能家居的一部分,简单举个例子,吃完饭以后,可以是机器人自动收碗筷、自动送去洗碗机,喝水可以是智能家居自动提醒我,机器人自动帮我倒好拿过来,人真的可以被当猪养了

Summary

在这里预言一下,小米汽车不是雷军最后一次创业,后续的具身智能或者智能家居才是,这个东西一定会做到智能家居里去,最后会结合在一起,做惠民,他的未来前景绝对是比车、比房、比手机要大得多的,是真正的未来生活。人车家的联动目前过于弱了,而且联动方式本身也不强,家的前景更大更好。

车载智能屏幕

2025年10月31日 00:00

Foreword

最近入手了一块车载智能屏幕,抱着“看看内部做得如何、能不能改装接入现有智能系统”的想法,顺手拆解了一遍,记录一下这套小屏的完整方案。

车载智能屏幕

image-20251029221556639

包装走的是极简路线:长条纸盒里只有一根 4 m 的 Type-C 供电线、一只蓝牙遥控器、屏幕本体以及吸盘支架,没有任何多余的固定或泡棉,拆开就能直接上车。

上车后需要搭配 iPixel Color App 使用。App 的 DIY 自由度蛮高,支持导入 PNG、GIF、图片序列等多种素材,还能自建动画。界面以卡片式分类呈现,操作逻辑比较直观,调色、预览也都是即时反馈。

image-20251029221948156

遗憾的是,这块屏幕尚未提供米家或 HomeKit 等生态的接入能力,导致它只能通过手机 App 或遥控器手动切换图案,无法跟随车辆开/关机、自动配合行车状态或智能家居场景联动。

给的遥控看起来也是个简单的蓝牙遥控,估计也是个电容遥控,想要改一下可能也比较费劲

遥控部分预置了几个固定图案,无法绑定自定义素材;和 App 端几乎无限制的 DIY 能力相比,遥控器只能算应急切换,更多时候还是得掏手机操作。

查了一下产品源,应该是这三个公司中某一个主导做的,深圳市希顿科技有限公司/深圳市永杰信电子有限公司/天浪创新科技(深圳)有限公司,都是小公司,官网模板用的都是差不多的

拆解

image-20251029221716633

拆机门槛不高,外壳背面四角各有一颗十字螺丝,拧下之后用塑料撬片沿着卡扣一圈滑开即可,整个过程不到五分钟。

image-20251029222302865

屏幕本体实际上是一块大尺寸 PCB,底壳上则是 Type-C 供电板,两者通过一根四芯排线连接,排线两端都做了点胶固定,抗震表现应该还行。

侧面有一个小按钮,按钮是用来旋转屏幕内容的,这个还挺细节的,旋转本身会被记忆,断电不会丢

设计方案

image-20251029222327181

可以清楚地看到使用的芯片

JL AC23BP,是杰理的AC23BP1U293,应该是个蓝牙5.3的芯片,作为主控

GD 25Q64ESIG,是GD的存储芯片,64Mb的一个flash,还挺大的,看来可以存储不少图片

可以看到这个东西竟然还带了个小麦克风,不愧是杰理,专业做蓝牙音频方面的,这个屏幕都带麦克风

APP里有音频采集,但是用的是手机的麦克风,那这个屏幕里的麦克风到底是给谁用的呢?

  • 难道说是监听?
  • 还有一个可能想的是未来这个屏幕接入智能语音,用语音控屏幕?但是后来没后续了?看板子的丝印是23年投板生产的,业务没做起来

主控板采取“邮票板”形式焊接在大 PCB 上,四周通过密集焊点和点胶固定。如果有同尺寸的替换方案,理论上拆下邮票板再换一块就能彻底更换系统逻辑。

还有一种逆向思路,由于是手机蓝牙直接控制的,那么我只要做个类似的蓝牙控制器,模拟手机发送的内容给他,然后把这个和米家开关啥的联系上,作为一个被控方,就能接入米家了,虽然做不了那么多事情,但是用4控的按键,就能显示出来十八种状态了

那么只要提前录好十六种状态对应的图片或者内容即可

Summary

这个小屏幕做的还挺好的,APP的交互也还行,只是看起来似乎没卖好,销量很小,大部分都出给国外了

CAN协议层对比

2025年10月29日 00:00

Foreword

在工业领域,需要使用可靠性非常强的协议,并且对于延迟要求也很高,如果同时这个协议本身也具有高拓展性,那就更好了,这里主要是从CAN出发,看看相关在CAN之上的协议层有哪些可以选择。

类CAN协议层

CANaerospace

image-20251029155130798

CANaerospace是专为航空电子系统设计的通信协议,基于CAN总线技术,用于满足机载设备对实时性和可靠性的严苛要求。CANaerospace底层对CAN的payload重新定义了,将整个CAN总线设计成了网状,允许一对一和一对n通信。

设计的思路还是比较简单的,说白了就是给各个节点约定好的数据类型和数据值,具体这个数据干啥用的靠Service Code和Message Code区分

DDS

img

DDS主要是用在自动驾驶方面的技术栈,核心思路也是把传感器之类的东西变成总线式的交互,这样防止系统过于复杂,底层还是靠CAN来实现数据链路层

CANopen

https://www.analog.com/cn/lp/001/primer-network-management-CANopen-protocols.html

image-20251029161728083

CANopen,类似地按照节点和对象字典来区分,CANopen把节点ID给固定死了,设备能用的一开始就规定死了

┌──────────────┬─────────┬──────────────────┬─────────┐
│ CAN 标识符    │  RTR    │   数据字段        │   CRC   │
│   COB-ID     │         │   0-8 字节        │         │
│   11 bits    │ 1 bit   │                  │         │
└──────────────┴─────────┴──────────────────┴─────────┘

UAVCAN

image-20251029152221339

https://legacy.uavCAN.org/

DroneCAN 和 Cyphal 都是早先一个叫做UAVCAN的项目。在2022年,该项目分为两个部分:原始版本的 UAVCAN (UAVCAN v0) 更名为 DroneCAN,较新的 UAVCAN v1 更名为 Cyphal。这两项协议之间的差异在Cyphal vs. DroneCAN中作了概述。

总体来说UAVCAN在当时的0.9版本已经较为广泛使用了,不好再做改动,所以在这里进行了分化。

UAVCAN对于发布订阅模型支持是不太完善的,传输层和应用层的解耦也没做好,还有一些其他缺点,最后导致了版本分化

DroneCAN

https://droneCAN.github.io/Specification/1._Introduction/

image-20251029164537810

DroneCAN只支持29bits的扩展标识符,老协议不支持。

DroneCAN在无人机领域使用非常广泛,近百万的设备在用,这是他目前的优势,可以接入的设备都比较多,但是缺点也很明显,而且未来发展的趋势基本也都是往Cyphal走了

支持DroneCAN的传感器或者模块也比较多了

电调(ESC): Zubax Orel, CUAV NEO, Holybro GNSS: Here3, Zubax GNSS 电源模块: CUAV HV PM, Pomegranate Systems 气压计、磁力计、激光测距等

Cyphal

https://opencyphal.org/

https://forum.opencyphal.org/t/the-cyphal-guide/778

https://github.com/OpenCyphal

┌──────────────────────────────────────────┐
│   应用层 (Application Layer)              │
│   - 诊断、配置、物理量定义等                │
├──────────────────────────────────────────┤
│   表示层 (Presentation Layer)             │
│   - DSDL 数据结构描述语言                  │
│   - 序列化/反序列化规则                    │
├──────────────────────────────────────────┤
│   传输层 (Transport Layer)                |
│   - Cyphal/CAN, Cyphal/UDP, Cyphal/Serial│
└──────────────────────────────────────────┘

Cyphal核心机制还是发布订阅模型,也支持C/S模式下的请求响应模型,在表示层使用了DSDL去描述数据类型,简化了上层做数据转换的压力

同时Cyphal在协议层就允许不同版本之间进行通信,有足够大的兼容性

资源占用对比

Flash 占用(代码大小)

协议 最小配置 典型配置 完整功能 相对大小
DroneCAN ~10 KB 15-30 KB 40-60 KB ⭐⭐ 小
Cyphal ~15 KB 30-50 KB 60-100 KB ⭐⭐⭐ 中等
CANopen ~20 KB 40-80 KB 100-200 KB ⭐⭐⭐⭐ 大
CANaerospace ~15 KB 25-40 KB 50-80 KB ⭐⭐⭐ 中等

RAM 占用(运行时内存)

协议 静态 RAM 动态 RAM(每连接) 栈空间 总需求
DroneCAN 2-5 KB 0.5-1 KB 1-2 KB ~4-8 KB
Cyphal 4-8 KB 1-2 KB 2-4 KB ~8-15 KB
CANopen 8-15 KB 2-4 KB 2-4 KB ~15-25 KB
CANaerospace 3-6 KB 1-2 KB 1-2 KB ~6-10 KB

CPU 占用(处理开销)

协议 消息解析 协议开销 实时性 CPU 负载
DroneCAN 简单快速 优秀 ~1-3% @ 500Kbps
Cyphal 中等复杂 中等 良好 ~3-5% @ 500Kbps
CANopen 较复杂 较高 中等 ~5-10% @ 500Kbps
CANaerospace 中等 优秀 ~2-4% @ 500Kbps

最小硬件要求对比

协议 最小 Flash 最小 RAM 推荐 MCU 示例芯片
DroneCAN 32 KB 8 KB Cortex-M0+ STM32F0, STM32G0
Cyphal 64 KB 16 KB Cortex-M3 STM32F1, STM32L4
CANopen 64 KB 16 KB Cortex-M3 STM32F1, STM32F4
CANaerospace 48 KB 12 KB Cortex-M0+ STM32F0, STM32G4

占用比我想得还要大,如果只是一些小模块使用,确实有点太过了,特别是对成本敏感的项目

Summary

CANopen: █████████████████████ 1000万+ 设备

DroneCAN: ████████ 50-100万 设备

Cyphal: ███ 1-5万 设备

CANaerospace: ██ 数千-1万 设备

Quote

https://docs.px4.io/main/zh/CAN/index

技术?管理?

2025年10月28日 00:00

Foreword

程序员总会遇到这样的问题,走技术道路还是管理道路?以前也有亲戚或者爸妈同事的孩子(计算机出身)想问问我以后的路要怎么走,以我个人经历来谈一谈

技术成长

少年时代

接触程序比较早,属于是初三、高一的时候就已经在写程序了,只是那会是玩按键精灵、VB 语法和大漠插件,那会觉得会一个语言好厉害,C++ 好牛,C、C++、VB 都学了,写点我自己想要的自动化程序已经是没啥难度的了。

  • VB实现的复杂脚本已经是大几千行代码了,业务也很复杂

在那个年代,搜索引擎还是很好用的,想要啥就能搜到啥,没有那么多废话。但是在外挂这个偏门领域,想要再进一步有点困难了,当年感觉 Windows 的 Hook 简直就是天神下凡,有啥你想要,但是现有的库或者接口做不到的,Hook 都可以做到,各种奇巧淫技。但是这东西只是偏门,而且用起来恶心,很多东西没有明文说明,都是摸索出来的经验,现在想想那会还真是糊里糊涂地在写代码,拿着一堆没搞明白干啥用的东西,底层逻辑也不清晰地就糊上去了。

至于当时的面向对象、面向过程,对象是没搞明白,但是过程可太熟悉了,做了无数个过程代码,多少算是锻炼了年少时的逻辑思维能力吧。当年的计算机二级,辅导过各种大学生,想来也挺搞笑的,初中生辅导大学生。

高中时第一次知道了物联网,感觉这个概念可太牛了,那会大概是10、11年,那会估计物联网专业的都不知道他们的物联网能干啥,那会我已经自己想通了电视、冰箱、洗衣机等等家电联网的逻辑,想想那会可真是领先了一大批人。

学生时代

大学专业也毫不犹豫地选了和计算机相关的专业,以我当时的自信,吊打一下大二大三应该也不是问题,计算机的课大部分不上我也知道咋回事,课本都自己看,自己学了。老师讲的不一定有用,但是我自己看过的内容后来都被我用上了,很多我能发现或者意识到的问题,都是基于过往的积累,如果当时没有看过,工作的时候就完全不会想起来这个内容。

但是也有一个方面发现落后了别人好多,校内的算法比赛,第一次看到题懵逼了,这是啥?后来比完看到差距以后,才明白别人从高中就开始在信息竞赛里天天训练的脑子还是真牛,自此以后再也没参加过算法相关比赛了。

刚开始还记得初心是做物联网,买了些开发板,学习了嵌入式相关的知识,学完以后觉得可牛逼了,没啥智能家居我做不出来了,那会也确实如此,很多智能家居的东西还是非常简单的,看一眼就知道用啥模块可以实现了,但是对于整个物联网生态还缺少体系化的思考,也没意识到这个东西后续是个万亿级别的市场,在当年还是非常有机会的。

后来参加电赛,接触到四旋翼,从那会开始就走偏了,忘记了物联网的初心,去搞飞机相关的内容了,当年四旋翼也没多少人搞,很多东西都没有,那会也不太会翻墙,少了一整个Google和GitHub,那会已经可以组起来一个较大的嵌入式工程了,软硬都了解,想做啥基本都可以做了。

同时也走了一些弯路,当时弄了一些 Windows Phone 的 C# 开发,后续这个东西停了,就废了,Web 那会还不认识 Spring,傻傻地拿 HTML 写前端,真的有点坑到当年的队友了。

学生时代技术已经十分发散了,C 为主,C#、C++ 为辅,后来又自学了 Python,基本上主力语言都摸了一遍,不看语法,大部分语言都能直接看懂了,接触过的技术栈也非常杂:嵌入式、.NET Framework、MFC、HTML、Windows Phone、Unity3D、LaTeX。

好在那会补全了我的一些知识体系,至少从底层、编译、操作系统、应用、UI,由下到上这么一系列东西让我体验过了一遍。大三后期又上了很多软件工程的课(当年上的时候已经被淘汰了),但是作为一个工程学,它的核心思想竟然后续都被我吸收了,这是我没想到的,也没想到这个简单的工程思想竟然在实际工作中遇不到有人会,真的挺离谱的,软件工程也成为了我后续发展的主要考虑对象。

工作时代

等到了工作时,算是对我自己的专业知识、以前漏掉的一些知识内容重新查漏补缺,再次巩固我自己的专业度,深究每一段代码的含义,彻底拿下一方面的技术。在这个基础上,又往其他方向扩展了一下,算法方面,多年没有深究,再次接触,系统学习了一些算法知识(本质上是数学,图论),捡起来以前离散数学,发现很多问题迎刃而解了,有了更优的解,解了很多实际中的问题。

在唱衰互联网的时候,又学习了一下 Spring Boot 整个前后端相关的知识内容,又做了一个实际项目,对于整个网络端业务体系,算是有了一个深入了解,后续再做联网相关内容的时候就能考虑得更完善了,不再对我来说是黑盒了。

  • 现在想想,这部分内容如果是学生时代学习的,物联网的概念说不定就能串起来了

到这里基本上我算是从底层到上层,整条数据链路都打通了,技术栈相对完整了,所有内容我一个人都能做了。

总结

纯语言、纯某个技术栈,有上限吗?有,也没有,要挖深,可以无限往下弄,但是工作、业务真的需要吗?普通人有可能涉及物理或者数学的理论基础吗?不行的,多数情况下,你所做的业务能完成工程化就已经非常好了,足以吊打很多野路子了。

当纯技术我发现没有更多突破的时候,我开始转向技术的周边,比如整个编译架构,CI/CD,比如整个工程从需求到落地需要经历的每一个环节是不是还能做得更好,自动化,工程迭代能否用更少的人力,更小的改动,达成更大的目的?整体架构对于业务支撑是否拥有足够的包容度和扩展性,能否轻松改几行代码或者复制粘贴几个文件就能达成一个新硬件支持?我开始深究从第一行代码到业务覆盖,是否能给出一套方法论或者工程指导,让我或者其他人达成一个高效的迭代开发体系。这些想法我都先用在了我自己的代码、工程中,我自己验证每一步是否可行、是否高效,当我验证完,就把这一套推广给其他人了,大家使用一套类似的方法论,甚至可以对此进行二次优化,逐步做得更好。

从技术转向工程,算是完成了第一次转型。

技术?管理?

然而每个人的精力是有限的,我总不能什么都管,有了工程体系,我就可以考虑逐步把手上的技术交给其他人去做,只要工程体系没有大问题,那么随着需求迭代开发就没问题。

手上技术拿得越多,技术越广,最后都要面临把一个个单项技术分出去,分得越多,最后手上剩下的越少?就越专注?好像也不是。刚开始是不断捡起,但是随着东西越来越多,开始学会放下,学会交付给别人,否则一定是分身乏术的。而随着手上的东西越来越少,也就有时间去思考超过技术、工程本身的东西,从更高的角度去看问题,去审视每个人所在的位置,每次遇到的问题根源是什么,此时开始偏向管理,工程转向管理,第二次转型。

纯技术工作对于我已经没有挑战了,转型管理也是必然,管理本质上还是为了业务和人员服务,如何让大家更好的工作、业务更好的开展罢了。

当然并不是每个人都要这么做,每个技术都有各自所在业务的深度,有的技术是需要不断突破,不断创新的,这种持续做技术完全没问题。而有的技术,当你做到一定程度,可以拿下整个技术栈的时候,你是选择躺平了呢,还是继续寻找突破点?因人而异吧。

技术与产品

技术本身与业务是不分家的,如果纯纯只做技术,不了解业务,只能说你技术肯定做的不行,好的技术一定是非常了解业务的。当技术搞定时,此时还有一个角度可以玩,那就是去做业务:产品经理,这种类型的产品拥有无与伦比的优势,天生了解技术细节,能跳出技术本身,看到业务的矛盾点和方向,可以让产品发展得更好,让需求少反复几次,让技术实现少走弯路。

我在做管理的同时就发现了,很多问题可能不是技术的问题,不是工程的问题,它可能是产品的问题,整个产品的定义或者细节中漏了一些东西,甚至产品本身就是定义不全的,而这些东西对于落地非常关键,一旦被模糊了,那么就会导致落地时有问题,在落地阶段反复修改之前的功能或者定义。与之对应的,我又做了第三次转型,同时兼管产品,确保产品实现的效果是我想要的,每个细节都有具体的定义,不再模糊放过问题,这些都会导致后期再进行维护时带来很多其他问题。

以前觉得别人说代码设计,要瞻前顾后,写个类,写那么多冗余、抽象的东西,毫无意义。但是今天当你站在实际业务前,看到业务里各种前后兼容的丑陋设计时,就会感觉到,前期留下的冗余代码在此时是多么的细节,但是这种能力不是每个人在当时都能看到,都能做到的,只能说在后期一次次重构的时候会多想一想业务,把代码平台本身的扩展性设计得更大,代码包容性更强,那么业务的变动都在代码考虑之内就毫无难度。

经验教训

对业务不够了解,不应该去做重构或者是创新,这样的玩法只会浪费时间,凭空做了很多没有价值的创新和重构,特别是新人刚加入项目。

做协议,必须对上下层都懂,否则只会以偏概全,最后做出来的结果别人不愿意用,最后不了了之。

培养一个资深测试,特别是对于业务无比了解的测试,价值远大于一个产品和技术。

不要让拿不下业务的产品进行需求开发,只会把产品带偏。

超过半小时的会议,会议内容一定过于发散了,要尽量缩短会议时间,快速沟通矛盾点,没问题的地方直接跳过,不要废话。

方法论

以自己当前的经验,总结一套方法论,其实就是对于业务开发的方法论,希望新人也好,还是有经验的人也好,可以从这里学习,知道自己在什么位置,应该做什么,有经验的人可以帮助完善这一整套体系。

  • 不一定是方法论,也可以是模板,或者什么其他名词,总而言之可以让大家按部就班地完成一整套新产品的设计和实现

以目前我的角度来看,无论大、小公司都有一套自己的方法论,这套方法论被反复在内部的产品上验证,它可能不是100%会成功,但是成功的概率是远大于一帮乌合之众的凑到一起做出来的。

从我的角度,不一定要模仿谁的方法论,每个公司或者项目都可以走出自己的路,很多东西是要结合当下的情况来定的,很多别人的经验没办法直接搬过来用,更多的是借鉴学习别人的解决问题或者矛盾的方式方法,并不是抛弃自己来时路,抛弃自己曾经用过的方法。

Summary

一些粗浅的想法。

广东自驾游之粤东篇

2025年10月9日 00:00

Foreword

据说十一开始的前后两天,路上都非常堵,那我只能等到人不多的时候再自驾,逛一下广东周边。时间还是有点紧张,边走边记。

自驾游

粤东大致路线:

image-20250930145715075

广州

一饭封神

image-20251005002013729

本来想去曾师傅·宋川菜珠江新城店,但是曾师傅的双人“一饭封神”体验餐十一不能用,只能现场点菜,这就比较麻烦了。

后来还是先去了客·AKEN’S KITCHEN 私房菜,398 元一个人,一共大概九个菜。

image-20251005001138046

  • 法国 AOP 黄油葱香软法包
  • 西班牙伊比利亚火腿萝卜糕
  • 忠信镇黑蒜炖鸽子排骨汤
  • 盐焗鸽腿酿鲍鱼
  • 客家酒糟虾汤烧黄鱼
  • 安格斯牛肉馅酿煮客家油豆腐
  • 发酵番茄酸汤河源米粉
  • 清汤枸杞叶
  • 非遗刀工豆腐花

image-20251005001112186

感觉一般,没有什么特别好吃的。吃完大概七分饱的样子,工艺属性拉满,仪式感高一点,其他的一般般吧。

还是推荐去吃大厨,这种小厨、私房的东西还是有点拉,与米其林的服务比起来还是差了一些。

增城

莲塘古村建筑群

距离广州老远了,这也能算广州?我有点没想到。

image-20251005001245860

一去就看到别人村里在摆宴。看了下,刚好是 10 月 3 日,还没开始,结婚的是两个姓陈的,而旁边就是陈氏祠堂,大户人家。

而里面的古村早就没人住了,能进去的就两间,全是马克思。

image-20251005001726129

古村里除了我以外一个游客都没有,一个都没有,甚至连工作人员都没有,这个地方真的是坑爹。

古村后面还有个小山,也是很久没人来了,我随便逛了下,糊了一脸蜘蛛网

小楼镇邓山村

邓山村也非常偏,但它也是广州的。以为真的是个观星小镇,其实啥都没有,连个充电桩都是坏的。

image-20251005002034092

古榄园是唯一一个能勉强算得上景点的地方,同样缺乏维护,更像是村里的景点,不大,一眼望得到头,十五分钟就能走一个来回。

image-20251005002345979

邓山村的路上有一个小水坝,有不少家长带孩子来玩水,也有搭帐篷、露营的。

正果老街

image-20251005002619049

以前可能是真的老街,卖一些老东西、特产,各种鱼干啥的。但是现在这条街翻修了,变成了仿古建筑,狗屎一堆。石板路没铺平,高高低低的,走起来很别扭,很多商铺都没人,没装修好。

不值得去,卖的那些东西,广东基本都能买得到。晚上住在这里的民宿,垃圾的一批。

image-20251005002830380

老街附近的正果寺没啥好看的,但是寺里有个小茶桌,放了个小乌龟,真的超级有才。特别是后院里还标着狗粮、乌龟粮,配合这个小茶桌上的小乌龟,别有一番风味。

蒙花布村

难评,也是河边的沙滩,晒得要死,有人租遮阳棚。河边本身禁止游泳,但这里依然开放,缺少救生员,都是家长带孩子来玩水、玩泥巴的。

image-20251008232056480

从化

溪头村

开车一个多小时,下了高速以后基本就到了,但是发现 1.3 公里的路堵 1 小时以上,那不用想了,不是里面爆满,就是路上出事了。趁我还没进去,直接掉头走人。

溪头村是有可能是那种可以溯溪玩的,可惜了,看起来可能和前面去的邓山村类似,都是一下高速,走一段就到景区了,估计也是个村景。

之前广州公众号里推的小镇,南平静休小镇、西和小镇、米埗小镇、生态设计小镇、吕田小镇、莲麻小镇,实话实说都是只能观赏 15 分钟就得走的地方,但是距离一个比一个远,基本距离广州市区都是 1-2 小时车程。

国道

好久没有走过村里的国道了。有的修得真的不错,肉眼可见地好;有的正在修,很难走;还有那种大小补丁的路,一颠一颠的。

国道路过的大大小小的村落,其实都很偏,但是国道把这些小村子都串起来了。

云髻山

门票很便宜,34 元。云髻山不算特别高,走慢一点,大概 45 分钟到 1 小时就能登顶,中间会路过三个瀑布,还行。

image-20251008232544527

image-20251008232721589

总的来说勉勉强强吧,算是比其他几个地方好一点点。但是总体景区的指引缺少,卖水的都很少,全都是卖豆花、糖水什么的,很奇怪。山路台阶不太好走,台阶特别高,窄的又特别窄,放不下脚。

人不算特别多,停车到景区山脚下就行。

龙川霍山风景区

时间太紧了,这个地方本来打算去的,但是看了看,这地方光是走路就得 2 小时起步,就算有电梯辅助,也少不了多少。

梅州

晚上去梅州路上第一次遇到高速堵车,大概是几个货车撞了,一个翻车,几个追尾,堵了一个半小时一动不动的那种。

叶剑英纪念馆、千佛塔、泮坑瀑布就不去了,红色、佛教没兴趣。瀑布由于要去黄满磜,这里就不去了,不过听说也不错。

客家博物馆

image-20251008232958236

客家博物馆附近有点堵,停车不容易,建议稍微开远一点,路边有很多停车的地方。

客家博物馆广场挺大,但没啥内容,空大。人都挤到博物馆内了,主要是客家起源、发展,大概了解一下。

值得吐槽的是客家的冰箱贴可真贵啊,随便一个就二三十,重庆可是五块两个啊,这个价格有点离谱了,广东都这么有钱的吗?

江北老街

image-20251008233214527

江北老街,有点以前去厦门的那种风情街的意思,看起来都是新改造出来的,卖的自然也是游客价,略贵。

走出老街以后,吃了个掩面,确实挺便宜的,但是好像没我以前深圳吃的便宜,好像烧卖也是这里的特产?很多烧卖店,就是卖相有点难看了。

雁南飞茶田景区

雁南飞茶田景区包含两个部分,一个是茶田,一个是桥溪村。进去要坐巴士,当然你也可以纯走进去,稍微远了点,建议能坐就坐,里面还有得走呢。

image-20251008234052764

茶田类似梯田,可以拍照打卡,其余景色都一般般,只能说看一看,没啥玩乐的,纯逛。

image-20251008234110143

桥溪村需要做巴士走盘山路,会路过一个大水坝,但是也只能远观,上去以后就是村口了

image-20251008233644388

桥溪村应该是最拉的,服务人员还说限制时间,但是实际上地图所有标注的xx堂、xx楼全是别人家,你只能看看外表面,那里就是本地人自己住的房子,没有任何特殊的点,就纯骗,过来的人都有点不明所以。

image-20251008233710137

然后桥溪就有个小溪一样的地方,有些位置可以给你稍微玩水,再就是栈道,栈道尽头是“养心谷”(三个大字)没了,对,没了,你还得原路返回。旁边也能爬山,不高。

只能说这个景点有点离谱,适合拍照。如果人多可以租一个接驳车,大概可以坐七八个人,在景区内逛比较方便,其他的就真的一般般。

揭阳

揭阳的路真的糟,车道线不明显,全磨没了,晚上根本看不见,大家都随便走,然后路上各种鼓包,我悬架弄到最高都感觉有点刮底盘。

揭阳学宫

image-20251008234330382

有点孔庙的意思,带小孩逛逛还行,其他的就一般般。围绕整个学宫弄了个揭阳古城,周边是些吃喝玩乐的地方。

黄满磜瀑布群

行程中止,后续的景点只能下次再看了。

爆胎

起因

image-20251007113020760

万万没想到,离开揭阳前,从停车场出来就撞上了这个石墩子,然后爆胎,右前铲也被干爆了。

这个石墩子在车内 360 完全看不到,由于刚出停车场门口,本来近距离报警就一直响,就没注意。加上这地方有个台阶还是个坡,能走的路就一点点,停车场出来不能直行(石墩子刚好挡在右侧),得先左打才能转出来。这个石墩子对比其他墩子都被蹭得黢黑,坑了不少人。

  • 墩子旁的垃圾桶,是我撞完他才放过去的,可能他们也觉得这个东西有点问题

image-20251007114936648

回看视频,行驶速度就5km每小时,但是就是干爆了轮胎,有点奇怪,车胎爆了以后,车机立马就提示了,然后就是小米道路救援,看本地是否能维修或者处理。以我上次的经验,这个前铲刮了、然后前保险杆有点翘皮了、轮胎爆了,必然要走报销,而且一两天都弄不完。

救援

协商了半天,揭阳本地修不了ultra,也没备件,最近只有汕头才行,但是行程就剩2天,咋都弄不完,那就拖回深圳处理。正常小米道路救援只能拖回邻近的城市,但是十一期间加上ultra会员,可以直接拖回用车城市,免费。

由于爆胎了,客服又说最好别移动了,会导致轮毂受损,失圆,换轮毂又是一笔不小的费用,但是我停在了停车场出口上,还好这个停车场是双出入口,我堵了一个,还有另一个,停车场的老哥直接立了个牌子让他们绕去那边,不然一直有车bb,谁也顶不住。

但是等拖车等了接近4个小时,从11点多等到16点,拖车才来。来了以后,小米流程很复杂,这也要拍照,那也要拍照,把拖车司机折磨疯了,等拖的时候拖车要横过来,卡住整个马路,然后发现拖不上去,会直接刮底,刮前铲,就只能重新协商,要我本人同意,自己开上拖车,失圆责任重新甩给我,无奈,所有人都等了一个多小时,只能接受,重新开上拖车,大概就10m,就能闻到右轮的焦皮味。

当晚我到家以后,车也同时到了小米售后中心,但是这个卸车没有接车人,司机直接把车卸路中间,人走了,不知道这该怪谁, 小米愿意提供服务,但是中间环节又有漏洞,难顶。

打车券

image-20251007114614440

小米客服说上了拖车以后就会发放打车券,500。我被重复发了,给了 1000。

可是打不到车,揭阳回深圳太难了,加价到一千都没人接单。而这个打车券是高德的,只能打车,不能顺风车也不能拼车,很无奈,而且还有时限,也暂时用不上,浪费了。

image-20251007114659497

揭阳到深圳的高铁是一张票都没有啊,十一还是太恐怖了。

顺风车之旅

约顺风车

于是乎只能滴滴打顺风车了,顺风车倒是不贵,独享也就430多,但是体验真的是一言难尽,怪不得那么多人喷顺风车,真的不是没道理。

顺风车一共打了六次,最后一次才真的上车,基本上前五个,每个都要私下加价,是直言不讳的说要加钱,一问加多少,加300,车费就430多,加300?你是真的敢叫,且不说还是些烂车。实际从揭阳到深圳大概300多公里,算上堵车最多是5小时,如果车费700多,算算时薪100+,这些司机是一个比一个黑。

最后一个打到的顺风车,算是比较好的体验了。

顺风内幕

最后一个司机也是职业顺风车司机,但是不是坑人的那种,大家聊了一路,大概了解了一下顺风车的内幕。前面不走的这种司机,不但加你价格,还要中途带人,一趟下来必须得盆满钵满才行。由于我是独享,那就更不愿意带你了,而你要是拼车,那算是完了,厉害的人家开2小时都出不了本市,贼离谱,一定要把拼车拼满才能走,时刻要保证车上满载,否则就得各种拼人,真的离谱。然后平台也默认拼车人的时间是没有上限的,你今天一天都耗到车上也完全没问题,这就导致了看似便宜的拼车,实际时间成本无限高。

平台本身在整个行进中是不允许司机和乘客同时关闭APP(杀后台)的,一旦同时关闭了,立马就有人工电话打进来询问情况,要求你和司机同时开启,这个也挺合理的,确实能保证安全。

行进过程中,如果司机开太久了(3小时),会强制要求司机休息(20分钟),否则之后就不给司机派单,直接拿捏你司机。

司机开的乐道L60,开顺风车的时候,直接智驾,其实3小时疲劳根本用不上,如果你不赶时间的话,智驾高速上驾驶比你自己稳多了。

  • 聊天他就知道我很熟智驾所以不敢开,一直到后面聊熟了他才敢用智驾,顺风车开智驾不专心会被投诉

顺风而行

从揭阳去深圳,路上基本走沈海高速,但是沈海是真的堵啊,整个高速大概就有三四个堵车点,其中两个一个堵车14公里,一个堵车11公里,不绕,今晚就高速上过了。

司机和我一人一个导航,我百度,他高德,互补导航,我们各种在堵车前下高速,走过拥堵以后再上高速,这么来回走了2趟,总算是走过了所有拥堵路段,一路上就能看到很多类似的绕路车辆,基本都是粤A、粤B的。比较符合导航刻板印象的是,在一个红绿灯,所有高德导航都往左走去了乡间小道,而所有百度都直行走了国道或者省道。

乡间小单单行道,但是距离近,直接横穿了,但是晚上很多车,很多村里摩托,行车压力还是大一些的。

国道省道,那就是两车道或者三车道起步了,路宽也亮,摩托也少,就是绕路有点远。

而在路上,小米车主果然都是最符合刻板印象的车主,一路超车,单行道也要找机会各种超

最后走了接近5个半小时,中间停车充电了不到20分钟吧,总算回到了深圳。

整体也算是挺有意思的一趟,上次坐顺风车可能还是五年前了,大晚上走了好多村子,确实看到了好多以前没见过的。

广东的村子有的富有的穷,但是总体村里的道路都算不错的,有钱的村子,路边的家家户户都有灯带装饰,路灯上挂着灯笼,可能本地人看多了不觉得有啥,但是外地人光是路过就会觉得哎这个村子不错,然后晚上各处都在放烟花庆祝中秋,穷一些的村子就没这些了,有条路就不错了。

修车

十月六号回深圳以后就开始修车了,先是定损,轮毂毫无疑问失圆,同时导致刹车盘、卡钳也连带出了一些问题,这就要追加保险,保险优先还是三方维修,然后维修结果小米直接拒绝,认定影响安全驾驶,就换盘和卡钳,这一折腾就一周起步。整个修完花了14天,修完以后刚好赶到赛道活动,去了以后发现有问题,车偏右,还是偶发的,之前已经发现了,说是能弄好,但是我实际开发现还是有问题。于是又送回去修了2天。到这里这车修了16天,然后补偿就是8号的那个500代金券,有点离谱,直接投诉。

后续补偿方案的时候又发现我在后台里没有代步车权益,说是系统出问题漏发了?我说我怎么每次问代步车都跟我说没有,奇了怪了。

补偿方案,七天代步消耗,兑换成1500积分*7补偿给我,门店店长直接转账我500(但是后续店长又会怎样罚款对接我的服务人员),但是修车评价要给好评。至于之前反馈的拖车的问题,门店这个级别无能为力,这个在小米内部应该是另外一个部门处理的。

之前已经了解到小米对于门店的要求非常之高,动辄起步罚款就是10w起步,解雇都是小事。车主手上的反馈是直接进入小米内部的,一旦有问题,对应门店、服务人员全都要被连带,店长还拿服务人员跟我打感情牌,讨价还价(道德绑架我,给我多少就要扣服务人员多少),你真的敢啊,全程我都有录音。

后续反思,严格要求之下,确实可以让小米的服务做得更好,但是这种实际发生的问题,并没有解决,这个事情只是在我或者在这个店里解决了,对于小米其他店、其他客户的服务一点好处都没有。小米流程上的疏漏,店内能正确反馈上去引起重视,从而更合理的解决吗?还是店里最后只在乎自己的利益,能拿到小米的授权肯定是块大肥肉了,这种中心和分部之间的博弈怎么才能做得更好。不仅仅是这个门店店长,小米整个交付、售后体系的每个人都非常在乎客户的这个评价,会直接或者严重影响到他个人,会追着你要好评。

如果一个系统里,每次客户提交的结果都是好评,那小米怎么得到正确反馈?怎么优化自己的流程?反而是那种被真实评价,真实反馈涉及到的服务人员成了整个体系里的替罪羊。

这部分问题下次有机会可以跟小米的产品沟通一下,看看怎么解决。

智驾

实际开了三天吧,走了935公里,基本能智驾就智驾,80%都是智驾开的,我只负责上下高速和城区或者村里。

长途驾驶或者堵车,基本都是很轻松的,高速上只要不乱搞,你不会主动发生事故,大部分情况也都能应付得过来,开车反倒变成了一种休息,景区里逛累了,开车去下个景点的时候休息一下,如此反复,还挺好的。

没升级到端到端之前,普通的NOA,还是挺好用的,130定速巡航,大弯都能稳稳的过,但是后面升级到了HAD就不行了,大弯会突然要接管,吓死个人。

然后城区使用HAD,开车决策还是过于肉了,超车变道也很慢,每次判断之前一定都会减速,然后后面跟的车,就会闪你或者b你,影响你的智驾使用体验。

如果没有其他车干扰,那这个东西确实能用,确实你可以不接管,完全给车自己开,但是现实情况人会受到外界其他车主的影响,而且行驶过程中遇到人行道、路口、红绿灯等等完全没有阻碍的情况下,他也会默认减速,就导致乘坐体验不太爽。

智驾过程中也遇到了极端天气情况,超大暴雨,一瞬间就啥都看不到了,智驾会提示接管。但是如果没那么极端,比如前后摄像头人眼已经不太能看清了(有水雾、脏东西等)似乎也能正常智驾,不会提示遮挡或者接管,不知道是靠什么实现的。

Summary

以下行程就以后有机会再说吧,节假日就不乱跑了,真的太堵了。广东是真的人多车多,还是有钱惹的祸啊。

  • 汕头,早茶

  • 潮州
  • 汕尾,二马路,迎囍皇宫,品清湖,红海湾,上海外滩,汕尾风车岛

感觉自驾还是得多带一个人,可以帮忙看看导航,提前规划路线,能省好多事,说不定我也能不撞石墩子了呢?

Quote

https://mp.weixin.qq.com/s/l5U26QKp87wbi4HKJAm23A

https://www.v2ex.com/t/803573

https://post.smzdm.com/p/688340/

河源游记

2025年9月30日 00:00

Foreword

七月份去了河源,太忙了,一直没空记录。

河源

总体来说河源还是个小县城的样子,去河源的路有点难顶,高速一直在修,路上限速 80,车还挺多,很多大车靠左走,非常离谱。

万绿湖

image-20250930172413055

万绿湖的云都特别好看

5f332874e0983c26ea6ce4e668aacd10

万绿湖内的民宿可以看清整个景区,其实不大。万绿湖游玩主要是坐游船去湖里的各个岛上逛。

万绿湖名义上是广东的水库,一般不允许下去游泳什么的,但是由于湖面特别大,所以沿湖有一些缺口,就有人过去露营、游泳、玩水。

image-20250930171540898

有一说一,正好是雨季,晚霞特别好看。

万绿湖附近都是以吃鱼为主的餐厅,感觉一般,客家菜很多,待了几天,每天桌上都有一个客家豆腐,吃吐了。

新丰江音乐喷泉

image-20250930170523102

曾经的亚洲第一高喷泉位于河源大桥与珠河大桥之间的新丰江中心,每到夜晚时分,喷泉水型多样,变幻莫测,融声光水色于一体,十分壮观。喷泉表演一般在晚上八点左右开始,持续约半小时,在欢快愉悦的乐曲中,绚丽多彩的喷泉水柱随着音乐舞动,气势如虹。不论是专程前往,还是沿着新丰江漫步经过时,这里都值得去看看。

据说喷泉高度有 190 米,估计现在应该没这么高了,这玩意都 20 多年了,喷泉配合的广播、音乐还是 20 世纪的味道,这二十来年估计一点都没变,只能说 20 年前这里是真有钱啊。

喷泉一共大概持续25分钟,前面就是各种循环没啥特殊的,就最后1分钟才喷最高高度,也就那么回事。

晚上很多老人、妇女领着、抱着小孩来散步。隔江看还好,喷泉跟前停车可真的老困难了,江边的停车位都是满的,技术不行进都进不去,别说停车了。

茶山公园

image-20250930170734455

车停在河源市政府,晚上市政府停车位很空,里面的不知道是保安还是啥,直接招手让我们进去,说免费停,还挺好的。

从市政府走过去,电视塔下的就是茶山公园,就是一些普通的街健的器具,有一些摆摊的老人,人不算特别多。

图片

茶山公园有几个奇奇怪怪的罗马柱,风格不太搭。旁边的公厕还在改造,造型是个大橘子。

河源电视塔

img

电视塔一直播着各种广告,在城里挺显眼的。

Summary

番外:河源机动车审批很离谱,各种按道理上不了路的车,在河源都能上,很多稀奇古怪的车,可能挂的都是河源的牌,粤 P 开头。

博客十年-百万访问

2025年9月25日 00:00

Foreword

从建立这个博客开始,到现在(2025.9.25)已经快 10 年了,最近一看访问量有一百万了。

image-20250925103224205

Blog

Blog 经过多次修整,目前相对比较稳定了,能满足我自己用,也不太需要二次开发。

2015.11.1 上线

2017.1.9

  • 修正about页面的私有评论

2018.2.9

  • 当前版本删除了双语文章支持
  • catalog正常工作
  • 修改了head字体颜色为黑色
  • 删除了Hux的portfolio等
  • 修复c/c++代码注释问题(可能引起tab和space造成的不对齐的情况)

2021.4.28

  • Hux长期不更新,Hux用的js的cdn基本国内都挂了(导致网页加载时间极长),所以将部分link转成bootcdn部分直接作为本地文件存储了

  • 修改代码中注释的渲染方式,看起来更明显了(之前的灰色斜体中文简直不是给人看的)

  • 删除了一些没啥用的文件

  • 移除Hux残留的一些编译文件

  • 移除代码中早已过期的注释的内容

2021.4.30

  • 添加latex公式支持(Hux自己的挂了2年了)
  • 移除less等编译时用的内容
  • 增加页面导航显示的排序

2022.2.23

  • 替换Google追踪的代码,移除无用的相关代码
  • 整理网站tag,所有图片替换成自己的图床
  • 修改License,使用GPL
  • 切换CDN为cloudflare
  • 切换fontawesome-webfont为4.7.0,支持qq telgram等图标

2022.12.14

  • 增加网站统计
  • 增加页面统计
  • 增加动态效果
  • 增加顶部进度条
  • 增加返回顶部按钮
  • 增加页面谷歌翻译
  • 背景显示彩带

2023.2.4

  • 增加置顶功能

2023.3.25

  • 支持Mermaid
  • 增加导航页的顺序(about和tag顺序就可以设置了)
  • 增加统计偏移量

2023.4.10

  • 增加水印功能

2023.7.17

  • 修复目录栏滚动条
  • 修复引用链接过长,未分行处理

2023.12.12

  • 所有动态效果和谷歌翻译变成可配置,默认关闭,会影响性能,cpu消耗较高

2025.11.1,Blog在线十周年

百万PV

最初的 Blog 用的域名是 elmagnifico.me,用了一年多,后来才换成了 elmagnifico.tech。

22年底,基本23年才算是接入了统计数据,之前的PV、UV一直没有统计,就估计了一个值,加上去做偏移了

site_pv_num = parseInt(site_pv,10) + 572573;
site_uv_num = parseInt(site_uv,10) + 490731;

接入统计以后,3年大概就有40-50w的pv了

Folo

image-20251107150452433

Folo上被订阅了2000多,直接减少了我n多pv和uv

Summary

感谢Github,小Blog托管10年,接口都没变过,即使被DDOS,也没掉过线

Blog早年搬迁到自己的服务器了,但是Github pages一直在备份,从没出过问题

坚持写了十年,好多我自己关注的博主都断更或者变年更了。Blog从一开始的代码问题记录,变成了现在我自己的小众知识库、生活分享;接触的东西越来越多,没办法记住所有的内容,不如写一篇文章记录,需要的时候来翻一翻;本来定的目标是每周至少写一篇,后来发现一个月能发四篇就不错了,现在可能一个月能写一两个文章,想不起来要写啥主题了

Plane最佳实践

2025年9月18日 00:00

Foreword

之前写了 Plane 的基础指南,但是用了这么久以后,发现当时的理解还是有一些偏差,这里再补充一下 Plane 的最佳实践

Plane

https://github.com/makeplane/plane

Plane 是开源的项目管理工具,目前还是非常好用的,而且开源版本还没有为了商业版做各种恶心人阉割的操作,目前还是值得推荐的

最近 Plane 的版本号从 0.28 直升到了 1.0,意味着官方认为多数功能已经做完了,只是这个 1.0 版本到底更新了啥内容没有详细介绍,目前更新日志还对不上。

Plane 也接入了各种语言,目前中文也可以使用,虽然还有一点点没翻译到位

最佳实践

还是以团队形式来说,如果只是小团队,每个人可能都身兼多职,没有生产等环节,单纯做软件开发,那么这个工具按照我之前的流程使用基本就够了。

但是如果团队规模比较大,涉及到实际生产,那么之前介绍的用法就有一些过于简单了。

以下都是基于社区版的 Plane 进行的项目管理,社区版本缺少任务流转、模板、细化权限等内容(商业版有),但用下面的方式可以一定程度上人为补全缺少的功能。

下面的实践都是基于一个小项目组,比如 10 人以内。超过 10 人以后就应该再拆分一个项目组,对于整个项目来说可能还得有各组之间对齐的项目面板。

敏捷开发

如果只是敏捷开发,那么只需要在整个 workspace 做好工作规划即可,然后把规划后的内容逐步放入每个周期中。周期排得够多以后,每个周期都有一定的工作范围、实现目标,那么基本上整个项目规划自然而然就串起来了

image-20250917200806243

整个项目的完成时间或者节点就可以通过周期视图来看出来了,只不过Plane的这个周期稍微有点不适合而已。

对应的就可以用Plane中的module模块或者Label来把各个模块打上标记,从而通过筛选后看到各个阶段完成的目标是什么。

到这里只是规定好了Plane每个部分怎么用,但是具体到每个人他应该怎么用Plane,哪些事情应该由他来做,哪些事情应该由别人来做,其实很多时候就是这个没定义清楚,导致大家不知道什么是他要做,什么是别人要做的,就会觉得这个东西有点难用了

完善状态

在开始之前,还需要完善一下 Plane 中工作项的状态

image-20250918105836552

  • 需求完善:需求细节还没有,但是已经有这个方向的想法了
  • 待做:需求细节已有,产品主动切换到这个状态
  • 开发中:研发进行中,研发主动切换到这个状态
  • 等待测试:研发完成,等待测试进行,研发主动切换到这个状态
  • 测试中:测试进行中,测试主动切换到这个状态
  • 完成:产品和测试同时确认完成,产品主动切换到这个状态
  • 延期完成:产生了任务延期,产品主动切换到这个状态
  • 取消:取消任务,产品/研发/测试主动切换到这个状态

实操

先设定一个团队组成:

  • 产品经理 1
  • 研发负责人 1
  • 研发若干
  • 测试若干

首先是产品经理做好需求和规划,将这个部分全部建立对应的工作项。如果长期需求还没写好,那可以先把近期内的都先建好,任务状态是需求完善

第二步研发负责人将需求进行分解,拆解成若干研发任务,这部分任务就自然地建在需求下面,作为子项,任务状态是待做

第三步测试负责人提出测试计划、测试用例,可能还有对应的自动化测试任务,也作为子项,任务状态是待做

image-20250917203218708

完成以后类似此图,依此类推,就可以建立出来若干个产品需求

第四步需要所有人一起核对排期,任务时间,确定这个需求总时间大概是多,比如七天或者八天,近期打算做的任务都需要这样大概排期一下。

第五步产品将需求排入周期中

image-20250917203701227

  • 这里对需求的大小有一些管控,如果需求过大超过周期,需求需要分两步去做
  • 同样如果一个周期填不满,那么需要补充一部分下一个需求的内容到这个周期,等下个周期来了,再使用周期迁移把未完成的部分整体移动到新周期内

image-20250918104523951

第六步周期正式开始了,此时研发开始填他各自的任务,时间,将每阶段完成情况回复到任务内,同理测试,任务状态是开发中或者是测试中

第七步开发基本完成,测试需要额外建立一个测试 Bug,并且一一指定到对应研发,任务状态是开发中或者是测试中

image-20250918104910792

第八步,产品确认是否需求一一完成,符合预期,任务状态是完成或者是延期完成

到这里基本一个小循环、小周期就完成了,后续基本按照这个模式继续往下走就行了。

制造业的流程管控

上面说完了纯软的,但是到软硬都有的制造业,这里又有很多不一样的东西,导致上面的流程不足,需要额外再利用 Plane 中的模块和视图内容

img

这里借一张图,制造业的时候,就不止一两个团队在项目中了,光是研发中就有5个团队介入了,这个时候Plane要怎么管理才能显得不乱呢。

研发过程类似上述的敏捷开发,这里不再重提,主要是说一下,在整个项目过程中各种职能的人要关注的内容如何用Plane实现

项目经理

项目经理需要关注各个阶段的内容,各个项目组是否按照预期完成

所以项目经理需要建立几个大的模块,这个模块就按照项目阶段来分,比如 EVT、DVT、PVT、MP

具体的任务就需要项目经理和各个项目负责人(产品/研发)确认这个内容属于哪个模块

image-20250918112419571

后续项目经理就要关注各个阶段的任务是否有完成,要协调各个组的事宜。

研发

研发这个部分就比较简单,按照敏捷开发流程来就行了

测试

此测试非各个小项目组内的测试,这个是做可靠性、产品方案验证等内容的测试,是对批量的测试,而不是简单单体级别的测试

测试在这个过程中也依赖研发的支持,有些内容需要先开发完成才能进行测试,这个过程也需要项目经理进行协调、统筹

image-20250918113340813

可靠性测试的内容,报告需要上传进Plane,Plane需要放开附件上传大小的限制,否则很多报告不够

生产

到了生产阶段,基本前面的流程都跑完了,这里关注的就是研发交接给生产的内容是否 OK,生产发现的问题追踪,改版改款的变更等内容

每个生产批次也一样可以建立一个模块,具体的变更或者交接内容都存放在这里即可

质量

当批量出货或者在各个阶段出现质量问题时,质量就需要单独建立自己的质量追踪面板

image-20250918113227955

在量产过程中发生的各种问题,质量就可以通过质量追踪面板去管理或者查看各个任务的情况

Summary

Plane 目前是这么玩的,可能有一些理想化,要把整个项目组的人都协调进去,教会他们使用 Plane 需要花一些时间,跑几次流程以后大家熟悉了,走起来就顺了。

Plane 的商业化进度真的是挺慢的,甚至我写的插件和我们开放出来的 Plane,Plane 销售都认为我是在二次销售免费版 Plane,有点搞笑了。

Plane 取消了本地部署的商业版本,只保留在线版本,有点可惜了,很多商业环境是不允许使用这种在线版本的,更何况服务器还在国外。Plane 销售甚至还想拉我做中国区代理,结果被我拒了。

Plane 的商业化实际还是会走向 Jira 等软件的老路,从简洁变复杂,最终年轻的勇者也会成为巨龙,再等下一个勇者。Plane 目前看起来距离飞书的项目管理还有不小的差距,目前只能赢在免费、轻量,大家用起来简单而已。

❌
❌