talontownsend/fh6-traction-control
GitHub: talontownsend/fh6-traction-control
为 Forza Horizon 6 打造的实时牵引力控制与自适应 ABS 系统,通过 PID 增益调度和在线学习应对大延迟与跨车辆跨路面的抓地力差异。
Stars: 0 | Forks: 0
# FH6 TC
**一款为赛车游戏开发的实时牵引力控制和 ABS 系统,因为该游戏本身并未内置此功能。**
它插入在物理控制器和 Forza Horizon 6 之间,以 62.5 Hz 的频率读取游戏自身的每个车轮的滑移率遥测数据,并通过调节油门和刹车将轮胎保持在对摩擦圆的设定值上。ABS 的设定值并不是固定的:它是**在你驾驶时学习得来的**,因为正确的数值在不同路面和轮胎配方下会有大约 4 倍的差异。
使用 Python 编写,适用于 Windows。程序不会修改任何游戏文件,也不会触碰进程内存;从控制理论的角度来看,它就是一个位于输入路径上的控制器。
```
physical pad (winmm) -> slip-based PID -> virtual Xbox pad (ViGEm) -> game
^ |
| UDP telemetry, 62.5 Hz |
+--------------------------------------------------------------------+
```
## 成果
在 Legend Island Time Attack 排行榜的 X 级(999 性能指数,游戏中最快的级别)中**在全球 130,698 名玩家中排名第 47 位**,位列全球前 1%。该成绩是在本软件介入回路的情况下创下的。该次跑圈的成绩实际上曾达到过更高的排名:有一段时间,它是西半球最快的纪录。

这辆车是 Lotus Evija FE,一台输出约为 2000 马力的电动超级跑车,被调校为**后轮驱动**。这种组合几乎是牵引力控制器所能面临的最糟糕情况:从静止状态瞬间输出全扭矩,没有前轴来吸收任何动力,并且所有动力都要通过两个轮胎传递。这也是增益调度器所需要覆盖的 plant gain 范围的极端情况,在这种情况下,固定增益的环路会出现极限环振荡,而不是将轮胎保持在抓地力极限上。
排行榜的成绩既衡量了驾驶员,也衡量了工具,因此请将其视为一种显而易见的证据:控制环路在极限状态下是稳定、快速且足够有用的,值得在追求具有竞争力的单圈时保持开启,而不是在关键时刻必须将其关闭。

## 为什么这是一个真正的控制问题
这个想法的简单实现版本是行不通的,主要有三个原因,它们决定了整个设计:
**传输延迟大约在 100 到 120 毫秒之间**,这是指从发送油门值到在遥测数据中看到由此产生的滑移率之间的时间。对于闭环来说,这是极其巨大的延迟。在纸面上看起来合理的增益会将系统驱动到 2.5 Hz 的极限环振荡:油门来回锯齿式波动,导致汽车比没有任何辅助时还要慢。微分项无法挽救这种情况,因为它的输入也存在滞后。这里的增益被刻意设定得较为温和,并且在模拟延迟被控对象的离线测试中验证了稳定裕度。
**不同车辆间的 plant gain 相差一个数量级。** 每单位油门产生的滑移率取决于扭矩、重量、轮胎和路面。单一的一组 PID 增益无法同时适用于 200 马力的掀背车和 1000 马力的超级跑车。控制环路通过**在线增益调度** 解决了这个问题:它持续地将测得的滑移率除以一个传输延迟之前发送的油门值来估算 plant gain(采用延迟匹配,因为将当前值与当前值比较会导致估算偏低),然后根据参考增益与该估算值的比率来缩放 PID 增益。这样,环路增益在差异巨大的车辆之间就能保持大致恒定。
**一个调节型控制器会掩盖它学习所需的证据。** 这是最有意思的一点,也是下文自适应 ABS 的核心。
## 自适应 ABS:学习一个无法固定的设定值
达到峰值抓地力时的制动滑移率并不是一个固定的数值。在该游戏中测量的结果是:使用光头胎的高下压力赛车在标称滑移率极限的 1100% 时仍能保持完全的制动抓地力,而拉力胎在泥土路面上大约在 150% 时就会失去抓地力。这种由**轮胎配方和路面**引起的差异跨度达 4 到 7 倍,因此没有任何固定的设定值或按车型划分的表格能够涵盖所有情况。
因此,ABS 的设定值是在实时学习中获得。开启 LEARN 后,学习器会从驾驶员当前激活的设定值开始,并在此基础上进行自适应调整:

*左图:从保守的 100% 设定值开始,学习器向上探测,发现这辆车的真实极限接近 1100%。右图:同一辆车在跑圈中间从柏油路开到了泥土路。测得的抓地力从约 3.6g 下降到约 2.4g,学习到的上限在两次硬制动内随之下降,然后保持稳定。这两个面板都是对记录的跑圈数据的回放,而不是模拟。*
右侧的结果可以通过本代码库复现。代码库中附带了该跑圈的一个精简摘录,`analysis/replay_learner.py` 会将其输入到与实时引擎相同的学习器中,此过程无需游戏或硬件介入:
```
$ python analysis/replay_learner.py
Measured braking grip under matched conditions
before the transition: 3.64g median over 16 frames
after the transition: 2.45g median over 45 frames
grip lost across the transition: 33%
Learner replayed from scratch (fresh state, same code as the engine)
started at 400% of the nominal slip limit
ceiling before the transition: 761% peak
ceiling at the end: 151%
```
### 探测问题
一旦控制器在设定上限处对滑移率进行调节,它就会阻止滑移率超过该上限。而发现当前设定值之上还存在更多抓地力所需的证据,恰恰正是控制器所抑制的证据。被动观察无法摆脱保守的起点:在测试台上测量的结果是,在长达六分半钟的时间里,上限保持不动,而 49 帧差一点就达标的证据一直停留在阈值以下。
有两种机制可以打破这种僵局:
**切断瞬间的过冲是免费的开环探测。** 由于传输延迟的存在,在发出切断指令后的前大约 8 帧里,车轮上反映出的仍然是*全部的*制动扭矩。在此窗口期间,在对轮胎榨取极限的同时,滑移率会冲破上限。这是对当前设定值之上的路面真实测量数据,在每次硬制动时都能免费获取。
**主动探测脉冲。** 在直线硬制动期间,控制器会短暂释放切断指令(持续 120 毫秒,最多每 2 秒一次,绝不在弯道中,绝不在轮胎未承重时),并让路面在满扭矩下给出反馈。如果设定上限之上的抓地力仍然保持,则将上限向上调;如果抓地力崩溃,则将其向下调。
### 判定什么才算作证据
这里的许多工程工作都在于拒绝从具有误导性的数据帧中学习。下面这些门控的存在,都是因为简单版本的逻辑在真实的记录数据上产生了明显的错误结论:
| 门控 (Gate) | 存在原因 |
|---|---|
| 仅限踏板踩到底 (>=90%) | 在 65% 踏板力度下,汽车会以 65% 的最大减速度刹车,*且还有剩余抓地力*,这与抓地力下降的情况难以区分。接受部分踩下踏板的帧会导致在正常驾驶时上限向下渗漏偏移。 |
| 延迟匹配的执行器状态 | 门控判断的是一个传输延迟之前发出的扭矩,而不是当前的指令,因此无论是控制器自身的切断指令还是释放刹车,都无法伪装成路面失去了抓地力。 |
| 抓地力参考值与速度平方拟合 | 下压力随 v^2 缩放,因此可用的抓地力与速度相关。如果使用在 380 km/h 下学到的单一参考值,会将诚实的 170 km/h 制动判定为抓地力不足并阻碍学习。系统会拟合三个速度带的节点,并在每一帧根据其自身速度进行评估。 |
| 总 g 向量、纵向滑移率 | 证据根据 hypot(减速度, 侧向加速度) 进行评估,因此花在轻微转向修正上的抓地力仍然算作抓地力;而滑移信号仅使用抱死方向的数据,这样转弯就不会使其虚高。要求绝对直线制动会导致学习器在真实赛道上缺乏可用数据。 |
| 垂直加速度区间 | 在坡顶制动会使轮胎卸载,读数看起来完全就像是总抓地力崩溃:自由落体状态下的垂直加速度接近 -9.8 m/s^2,滑移率饱和,减速度消失。悬空(离地)帧无法提供任何参考。 |
| 滑移率崩溃剔除 | 滑移率下降速度超过 10/s 是指在切断指令后且扭矩减小时车轮重新恢复抓地力。其低减速度是控制器自身的行为造成的,而不是路面原因。 |
| 向下跳变的双事件确认 | 真实的轮胎或路面变化会在*每次*刹车时都导致抓地力崩溃;而在单次冲出赛道后跨越草地刹车则只算作一个事件。要求在两次独立的制动事件中保持一致性才能将它们区分开来。 |
### 弯道安全性
学习到的上限是一个直线数值,如果在弯道中要求施加与直线上相同的制动压力,会导致汽车打转。因此,实时的上限会通过摩擦圆进行降额:
```
live cap = learned ceiling * max(0.25, 1 - (lateral / lateral_budget)^2)
```
侧向预算本身也是通过学习获得的,并且**仅在制动时**观察到的侧向载荷中学习:空气动力学高速弯在未施加刹车时能产生超过 8g 的侧向载荷,如果让这些数据膨胀预算,就会使降额机制形同虚设。这就是让循迹刹车 变得安全的原因。学习器绝不会要求施加超过弯道剩余抓地力所能提供的刹车力度。
## 架构
三个模块,四个线程,一个纯逻辑核心。
| 模块 | 职责 |
|---|---|
| `traction_control.py` | 控制引擎和 CLI。包含纯控制器逻辑、自适应学习器、输入链路管道以及离线测试套件。 |
| `tc_gui.py` | tkinter GUI,HidHide 编排,以及一个合成引擎,使得整个界面无需游戏、手柄或安装任何驱动程序即可运行。 |
| `fh6_telemetry.py` | Forza 的“Data Out” UDP 数据包解析器,包括字节偏移布局以及 Forza Horizon 特有的 Sled 和 Dash 数据块之间的间隙处理。 |
| `analysis/` | 通过学习器离线回放记录的跑圈数据,外加一个内置的样本摘录。 |
引擎中的核心类型:
- **`TractionController`** 是调节律,被刻意设计为不包含 I/O 操作。它被实例化两次,一次用于油门,一次用于刹车,具有不同的设定值表和车轮选择。因为它是纯逻辑的,基本上所有的控制行为都可以在没有硬件的情况下进行单元测试。
- **`AbsLearner`** 拥有自适应设定值:抓地力参考、探测证据分类以及摩擦圆降额。同样是纯逻辑的。
- **`TCEngine`** 是将它们与真实设备联系起来的线程化主循环:读取物理手柄输入,应用两个控制器,写入虚拟手柄,为学习器提供服务,馈送给记录器和泄漏检测器。
- **`DualSenseRumble`** 转发力反馈,并通过原始 HID 输出报告驱动控制器的灯光。
线程:控制环路以大约 250 Hz 的频率运行;接收器线程负责清空 UDP 遥测数据,确保环路永远不会因为网络而阻塞;GUI 以 30 Hz 的频率基于快照进行渲染;写入线程独占向物理控制器发送 HID 输出的操作;控制台状态打印被完全移出了控制路径,因此即便是控制台卡住,也不会导致输入链路停滞。
## Windows 输入栈相关工作
让调制后的输入信号真正传输到游戏中,结果比控制问题本身还要困难,而且其中大部分问题都是通过经验诊断出来的。
**游戏会同时读取每一个已连接的控制器。** 如果物理手柄和虚拟手柄同时可见,原始扳机的输入就会覆盖掉每一次切断指令。解决方法是使用 HidHide 隐藏物理设备,同时将该程序加入白名单,以便它仍能读取设备。HidHide 无法对 XInput 游戏隐藏 XInput 设备,但对于 DirectInput HID 手柄是有效的,而 DualSense 正是以这种方式呈现的。
**隐藏操作是在进程打开设备时进行评估的**,因此如果游戏已经在运行,它将保留其句柄并继续造成数据泄漏。程序没有要求用户关闭所有进程,而是会检测正在占用设备的进程,并重新连接控制器的 USB 节点,这相当于拔掉重插:现有的句柄会失效,且无法通过隐藏层重新打开。
**Steam Input 是隐藏机制无法关闭的第二个泄漏途径**,因为 Steam 会自行读取控制器,并作为其自身的虚拟设备将其重新广播给游戏。一旦你知道该关注什么,这种现象在遥测数据中的特征就变得非常明显:游戏回显的油门值会在精确的发送值和原始扳机值之间来回交替。
**泄漏检测是基于占空比的,而不是基于平均值的。** 间歇性的泄漏会使 6 秒的平均值接近指令值,因此平均值检测会报告一切正常,而实际上输入路径已经明显断开了。通过统计杂散帧可以捕获到这种情况。游戏会在遥测中回显其接受的输入,这使得整个链条可以从外部进行验证:内置的泄漏测试会发送一个已知的油门指令,并读回游戏实际接收到的值。
**力反馈和控制器灯光**在隐藏物理设备后不得不进行重构,因为游戏的震动反馈被发送给了虚拟手柄,而虚拟手柄并没有马达。引擎订阅虚拟手柄的反馈通知,并向 DualSense 写入原始的 HID 输出报告(USB 报告 `0x02` 和带有 CRC 的蓝牙报告 `0x31`),在同一个报告中携带马达数值和灯光状态。事实证明,五个玩家 LED 灯是被连接为三个镜像通道,而不是五个可独立寻址的通道,因此等级编码表现为从中心向外延伸的数量。
## 测试
控制逻辑在设计上是纯逻辑的,因此无需安装游戏、控制器或任何驱动程序即可进行测试:
```
python traction_control.py --selftest # ~190 assertions, no hardware
python tc_gui.py --smoke # GUI construction and render
python tc_gui.py --demo # full GUI on synthetic telemetry
```
测试套件不仅包含单元断言。它还包括**延迟被控对象模拟**,即在多个 plant gain 下,驱动控制器控制具有实测传输延迟的合成车辆,并断言其峰峰值振荡是有界的。正是这些测试发现了更高的增益会产生极限环振荡,这也是增益调度范围如此设定的原因。学习器在两个方向上具有已知抓地力拐点的合成制动事件下进行了测试,并且对每个证据门控都进行了单独测试。
除了离线测试外,记录器还会写入完整的遥测数据流以及学习器完整的逐帧决策轨迹(它看到了什么,做出了什么决定,以及哪个门控拒绝了某一帧),以便事后可以审查赛道上的行为,并通过修改后的逻辑进行回放。学习器中的大多数常量都是通过这些回放设定或纠正的,而不是凭感觉设定的,并且上表中的每一个门控之所以被添加,都是因为回放显示以前的版本得出了明显错误的结论。
`analysis/replay_learner.py` 就是这个工作流的一个缩影:它通过学习器重新运行捆绑的记录数据,并打印出关键的测量结果,因此本 README 中的声明是可以被验证的,而不仅仅是盲目相信。
## 快速开始
**环境要求:** Windows 10 或 11,Python 3.12,一个 DirectInput 控制器(基于 DualSense 开发),Forza Horizon 6。
虚拟手柄需要安装 [ViGEmBus](https://github.com/nefarius/ViGEmBus);强烈建议安装 [HidHide](https://github.com/nefarius/HidHide),因为如果没有它,游戏会同时接收两个控制器的输入,从而导致切断指令失效。
```
python -m venv .venv
.venv\Scripts\pip install -r requirements.txt
```
然后:
1. 安装 **ViGEmBus** 并重启计算机。
2. 在游戏中:设置,HUD 和 Gameplay,**Data Out: ON**,IP 设为 `127.0.0.1`,端口设为 `7777`。
3. 使用 **`Launch FH6 TC.bat`** 启动,或者运行 `python tc_gui.py`。
4. 通过 GUI 的 *Calibrate pad* 按钮**校准手柄**一次。
5. 安装 **HidHide**,重启计算机,然后使用 GUI 的 *Hide from game* 面板隐藏物理手柄并将 Python 加入白名单。通过 *Leak test* 按钮进行确认。
6. 如果游戏通过 Steam 运行,请**为其禁用 Steam Input**(游戏属性,控制器,禁用 Steam Input),然后完全退出并重新打开 Steam。
隐藏范围仅限于应用程序的生命周期内:隐藏功能在启动时开启,退出时关闭,因此控制器在其他地方的行为会保持正常。如果发现在预期隐藏时该功能却处于关闭状态,一个看门狗 会重新将其开启。
手头没有游戏或控制器?`Demo (no game needed).bat` 可以在合成的遥测数据上运行整个界面。
## 使用说明
**模式**适用于每个通道,分别独立应用于油门和刹车:
| 模式 | 设定值 | 体验 |
|---|---|---|
| OFF | 透传 | 不对任何输入进行调制 |
| LOW | 150% | 运动模式:允许侧滑和弹射起步,抑制烧胎 |
| MEDIUM | 100% | 恰好保持在抓地力极限边缘 |
| HIGH | 85% | 留在摩擦圆内部,对静止起步进行起步控制 |
| CUSTOM | 30-250% TC, 30-600% ABS | 任意设定值,增益根据锚点模式进行插值 |
可以通过 GUI 切换模式,或者通过键盘(F5-F8 控制牵引力,F1-F4 控制 ABS,只有在游戏获得焦点时按键才有效,这样其他地方的误触按键就不会意外关闭辅助系统),或者按住一个组合修饰键并拨动方向键通过控制器切换。修饰键是可以选择的,因为在伸向方向键的同时还要按住 Back 键对于一根拇指来说是两项任务;使用一个扳机键可以将组合操作分配给双手完成。快速敲击该修饰键仍然会执行其正常的游戏内功能。
**预设** 会同时设置两个通道:
| 预设 | TC | ABS |
|---|---|---|
| Offroad | 250%,仅纵向 | 150%,仅纵向 |
| Touge | 125%,仅纵向 | 400%,仅纵向 |
| Circuit | 105%,前轮纵向 | 400%,全摩擦圆 |
**按车轴的仅纵向开关**使一个通道仅监视该车轴上的前向和后向滑移率,而忽略侧向滑移。后轮的仅纵向设置是对漂移友好的牵引力控制:漂移角不再计入设定值的影响,但真正的车轮空转仍然会被切断。这种权衡在代码中有记录:它会禁用该车轴在弯道中的过度转向防护。
**控制器的灯光是一个状态显示器。** 每个激活的通道会将其设定值映射到各自范围内的色相,绿色代表严格的辅助,红色代表宽松的辅助,灯光条会显示两者的混合状态;在开启 LEARN 时,色相会随着上限的自适应调整而发生漂移。正在积极执行切断操作的通道会以大约 5 Hz 的频率在其自身颜色下闪烁灯光条。按住组合修饰键会将灯光条变为白色,并在玩家 LED 灯上显示模式等级。

## 注意事项和限制
- 两个通道都只会*减少*输入。转向和所有按键都会原封不动地通过,程序不会施加驾驶员未请求的油门或刹车。
- 只能有一个进程接收游戏的 Data Out 数据流。不要同时运行另一个遥测数据消费程序。
- ABS 在速度低于约 11 km/h 时会自动断开,以便汽车始终能够完全停稳,并且每次切断指令都会保留一些刹车压力。
- 学习到的设定值属于会话状态,不会被持久化保存,因为正确的数值是当前汽车和轮胎的固有属性。
- 仅在一台测试设备上针对一款游戏进行了测试。遥测数据格式在近期的 Forza 系列作品中是通用的,但这里的滑移率比例校准是基于当前这一款游戏测量出来的。
## 许可证
MIT。详情见 [LICENSE](LICENSE)。
与该游戏的开发者或发行商没有任何隶属关系、认可或联系。这是一个个人项目,仅读取已公开文档的遥测输出并合成控制器输入;它不修改任何游戏文件,也不读取任何游戏内存。
标签:PID控制器, Python, 外设输入模拟, 无后门, 游戏辅助, 自动控制系统, 逆向工具, 遥测数据处理