《GTA San Andreas》20 年 Bug 在 Windows 11 24H2 中浮出水面

2025年4月23日 阅读时长:14 分钟

Introduction

SilentPatch GitHub issue tracker 上,我收到一个相当具体的 Bug 报告:

Skimmer 飞机在 Windows 11 24H2 中消失

当我将我的 Windows 升级到 24H2 版本后,Skimmer 飞机完全从游戏中消失了。它无法通过作弊器生成,也无法在它通常的刷新点找到。我同时使用了我的修改版(在更新之前,一切正常)和仅安装了 SilentPatch 的原版(我尝试了 2018、2020 和最新的 SilentPatch 版本),飞机仍然不存在。

如果这是我第一次听说这个问题,我可能会认为它很可疑,并怀疑有更多因素在起作用,而不仅仅是 Windows 11 24H2。然而,在 GTAForums 上,自去年 11 月以来,我一直在收到关于这个完全相同问题的评论。其中一些人说 SilentPatch 导致了这个问题,但另一些人则表示,即使是在完全没有 Mod 的游戏中也会发生同样的情况:

显然,在 Windows 11 24h2 更新上玩游戏时,Skimmer 无法生成,希望这个 Bug 得到修复。

编辑:所以我认为我确认了,我设置了一个带有 Windows 11 23h2 的虚拟机,该死的飞机可以正常生成,并且将同一个虚拟机更新到 24h2 后,Skimmer 就坏了,为什么 2024 年的一个小功能更新会破坏 2005 年游戏中的一架随机飞机,没人知道。

在最新的 SilentPatch 更新之后,游戏中没有 Skimmer,当我尝试使用 RZL-Trainer 或 Cheat Menu by Grinch 生成它时,游戏会冻结,我必须通过任务管理器关闭它。

[…] 我被迫更新到 24H2,现在更新后,我和其他人一样,在 GTA SA 中遇到了 Skimmer 的问题。这意味着 Mod 或任何其他东西都不是导致问题的原因,问题出现在最新的 Windows 更新之后。

我的家用电脑仍然运行 Windows 10 22H2,而我的工作电脑运行 Windows 11 23H2,毫不奇怪,两台机器都没有重现这个问题——Skimmer 在水面上正常生成,通过脚本创建一个并让 CJ 进入驾驶座也能正常工作。

也就是说,我还要求一些升级到 24H2 的人测试他们的机器,他们遇到了这个 Bug。通过聊天给出指示进行“远程”调试的尝试没有取得任何进展,所以我自己在我的机器上设置了一个 24H2 虚拟机。我将游戏复制到虚拟机上,设置了从主机操作系统进行远程调试,前往 Skimmer 通常生成的地点,果然,它不在那里。所有其他飞机和船只仍然正常生成,只有这架飞机没有:

Skimmer 消失了。但其他飞机还在。

然后我用脚本生成了一架 Skimmer,并把 CJ 放在里面,结果他被发射到了 1.0287648030984853e+0031 = 10.3 千万亿亿亿米 ,或者 10.3 千万亿亿千米 ,或者 1.087 兆光年 的高空 😆

科学家声称发现了一种没有人见过的“新颜色”。

安装了 SilentPatch 后,游戏在将玩家发射到空中后不久就会冻结,因为游戏代码卡在一个循环中。如果没有 SilentPatch,游戏不会冻结,但相反,它会屈服于一种著名的“烧屏效应”,这种效应通常发生在相机被发射到无限远或接近无限远的地方时。有趣的是,即使动画完全放弃了浮点值的不准确性,你仍然可以勉强辨认出飞机的形状:

Investigating the bug

What is broken?

好了,别再瞎搞了;现在我知道这是一个真正的 Bug,我需要找出根本原因。在这一点上,无法确定是游戏本身的问题,还是我真的在处理 24H2 中引入的 API Bug,因为看看有多少游戏在这个操作系统版本中存在问题,这两种可能性都有。

我没有太多可用的信息,但安装了 SilentPatch 后游戏冻结的事实为我提供了一个很好的起点。进入水上飞机后,游戏在 CPlane::PreRender 中一个非常小的循环中冻结,试图将旋翼叶片的角度归一化到 0-360 度的范围内:

this->m_fBladeAngle = CTimer::ms_fTimeStep * this->m_fBladeSpeed + this->m_fBladeAngle;
while (v12 > 6.2831855)
{
 this->m_fBladeAngle = this->m_fBladeAngle - 6.2831855;
}

在调试会话中,this->m_fBladeSpeed3.73340132e+29。这个值显然非常大,大到足以使通过 6.2831855 递减该值完全无效,因为这两个值的浮点指数存在差异。1 但是为什么叶片速度如此之高呢?叶片速度源自以下公式:

this->m_fBladeSpeed = (v34 - this->m_fBladeSpeed) * CTimer::ms_fTimeStep / 100.0 + this->m_fBladeSpeed;

其中 v34 与飞机的海拔高度成正比。这与最初的发现相符——如前所述,“烧屏效应”通常发生在相机非常远离地图中心或处于很高的高度时。

是什么导致飞机射得如此之高?有两种可能性:

  1. 飞机一开始就生成在高空中。
  2. 飞机在地面上生成,然后在下一帧中射向空中。

至于这个测试,我自己使用测试脚本生成 Skimmer,所以我可以从游戏 SCM(脚本)解释器中使用的函数开始,该函数名为 CCarCtrl::CreateCarForScript。此函数在提供的坐标处生成具有指定 ID 的车辆,这些坐标来自我的测试脚本,所以我知道它们是正确的。但是,此函数会稍微转换提供的 Z 坐标:

if (posZ <= 100.0)
{
 posZ = CWorld::FindGroundZForCoord(posX, posY);
}
posZ += newVehicle->GetDistanceFromCentreOfMassToBaseOfModel();

CEntity::GetDistanceFromCentreOfMassToBaseOfModel 包含多个代码路径,但在此示例中使用的代码路径只是获取模型边界框的负最大 Z 值:

return -CModelInfo::ms_modelInfoPtrs[this->m_wModelIndex]->pColModel->bbox.sup.z;

在这一点上,我预计这个值是不正确的,所以我查看了 Skimmer 的边界框值,结果发现最大 Z 值确实已损坏:

- *(RwBBox**)0x00B2AC48 RwBBox *
 - sup RwV3d
   x -5.39924574 float
   y -6.77431822 float
   z -4.30747210e+33 float
 - inf RwV3d
   x 5.42313004 float
   y 4.02343750 float
   z 1.87021971 float

如果边界框的所有组件都已损坏,我会怀疑是某些内存损坏,就像另一个代码写入超过边界并覆盖这些值一样,但具体是 sup.z 已损坏,它既不是边界框中的第一个也不是最后一个字段。再一次,我脑海中浮现出两种可能性:

  1. 碰撞文件读取不正确,某些字段保持未初始化状态,或者读取不相关的数据而不是边界框?鉴于此问题可能是一个操作系统 Bug,这种可能性很小,但并非不可能。
  2. 边界框读取正确,但随后使用一个非常不正确的值对其进行更新。

pColModel 处设置的数据断点显示,在初始设置时,边界框是正确的,并且 Z 坐标的值是完全合理的:

- *(RwBBox**)0x00B2AC48 RwBBox *
 - sup RwV3d
  x -5.39924574 float
  y -6.77431822 float
  z -2.21952772 float
 - inf RwV3d
  x 5.42313004 float
  y 4.02343750 float
  z 1.87021971 float

事实证明,第一次生成具有特定模型的车辆时,游戏会在函数 SetupSuspensionLines 中设置悬架,并更新边界框的 Z 坐标以反映汽车的自然悬架高度:

if (pSuspensionLines[0].p1.z < colModel->bbox.sup.z)
{
 colModel->bbox.sup.z = pSuspensionLines[0].p1.z;
}

这就是事情第一次出错的地方。悬架线是使用来自 handling.cfg 的值和来自 vehicles.ide 的车轮比例参数的组合来计算的:

for (int i = 0; i < 4; i++)
{
 CVector posn;
 modelInfo->GetWheelPosn(i, posn);
 posn.z += pHandling->fSuspensionUpperLimit;
 colModel->lines[i].p0 = posn;
 float wheelScale = i != 0 && i != 2 ? modelInfo->m_frontWheelScale : modelInfo->m_rearWheelScale;
 posn.z += pHandling->fSuspensionLowerLimit - pHandling->fSuspensionUpperLimit;
 posn.z -= wheelScale / 2.0;
 colModel->lines[i].p1 = posn;
}

既然我知道 colModel->lines[0].p1 已损坏,这意味着 pHandling->fSuspensionLowerLimitpHandling->fSuspensionUpperLimitwheelScale 是伪造的。Skimmer 的 handling.cfg 值似乎与游戏中任何其他飞机没有任何不同,但随后我在 vehicles.ide 中发现了一些有趣的东西。Skimmer 的行如下所示:

460, skimmer, skimmer, plane, SEAPLANE, SKIMMER, null, ignore, 5, 0, 0

将此行与游戏中任何其他飞机进行比较,例如 Rustler:

476, rustler, rustler, plane, RUSTLER, RUSTLER, rustler, ignore, 10, 0, 0, -1, 0.6, 0.3, -1

该行更短,并且缺少最后四个参数,此外,缺少的参数中有两个是前轮和后轮比例! 这对于船只来说是正常的,但 Skimmer 是唯一省略这些参数的飞机。

重新添加这些参数是否修复了水上飞机?毫不奇怪,它确实修复了!

But why and how?

我有一个可能的解释,为什么 Rockstar 最初会在数据中犯这个特定的错误——在 Vice City 中,Skimmer 被定义为一艘,因此默认情况下没有定义这些值!当在 San Andreas 中,他们将 Skimmer 的车辆类型更改为飞机时,有人忘记添加这些现在需要的额外参数。由于此游戏很少验证其数据的完整性,因此这个错误只是被忽略了。

问题解决了?还不完全是,因为对于 SilentPatch,我需要从代码中修复它。查看 CFileLoader::LoadVehicleObject 的伪代码揭示了这个 Bug 的真实本质:游戏假定所有参数始终都存在于定义行中,并且除了两个参数之外,它不会默认任何参数,也不会检查 sscanf 的返回值,因此,对于所有船只和 Skimmer 而言,这些参数保持未初始化状态:

void CFileLoader::LoadVehicleObject(const char* line)
{
 int objID = -1;
 char modelName[24];
 char texName[24];
 char type[8];
 char handlingID[16];
 char gameName[32];
 char anims[16];
 char vehClass[16];
 int frq;
 int flags;
 int comprules;
 int wheelModelID; // Uninitialized!
 float frontWheelScale, rearWheelScale; // Uninitialized!
 int wheelUpgradeClass = -1; // Funny enough, this one IS initialized
 int TxdSlot = CTxdStore::FindTxdSlot("vehicle");
 if (TxdSlot == -1)
 {
  TxdSlot = CTxdStore::AddTxdSlot("vehicle");
 }
 sscanf(line, "%d %s %s %s %s %s %s %s %d %d %x %d %f %f %d", &objID, modelName, texName, type, handlingID,
    gameName, anims, vehClass, &frq, &flags, &comprules, &wheelModelID, &frontWheelScale, &rearWheelScale,
    &wheelUpgradeClass);
 // More processing here...
}

鉴于这些症状,这些未初始化的值一直到最近都必须评估为小的、有效的浮点值,而在 Windows 11 24H2 中,它们失控了,并触发了边界框计算。

在 SilentPatch 中,修复很简单——我包装了对 sscanf 的调用,并为最后四个参数提供了合理的默认值:

static int (*orgSscanf)(const char* s, const char* format, ...);
static int sscanf_Defaults(const char* s, const char* format, int* objID, char* modelName, char* texName, char* type,
   char* handlingID, char* gameName, char* anims, char* vehClass, int* frequency, int* flags, int* comprules,
   int* wheelModelID, float* frontWheelSize, float* rearWheelSize, int* wheelUpgradeClass)
{
 *wheelModelID = -1;
 // Why 0.7 and not 1.0, I'll explain later
 *frontWheelSize = 0.7;
 *rearWheelSize = 0.7;
 *wheelUpgradeClass = -1;
 return orgSscanf(s, format, objID, modelName, texName, type, handlingID, gameName, anims, vehClass,
         frequency, flags, comprules, wheelModelID, frontWheelSize, rearWheelSize, wheelUpgradeClass);
}

Fixed! 补丁的又一次兼容性胜利。

如果这是一个常规 Bug,我就会在这里结束这篇文章。然而,就这个兔子洞而言,这个修复的发现只会引发更多问题——为什么这个 Bug 现在才被打破?是什么让游戏在出现这个问题的情况下正常工作了 20 多年,然后 Windows 11 的一个新更新突然挑战了这个现状?

最后,这是由 Windows 11 24H2 中的问题引起的,还是这只是一个不幸的巧合,星星排列得恰到好处?

Here be dragons – the true root cause

Diving deeper

在这一点上,可行的理论是,CFileLoader::LoadVehicleObject 中未初始化的局部变量碰巧具有合理的值,直到 Windows 11 24H2 中 某些 东西发生了变化,并且这些值变得“不合理”。我所知道的不能是 CRT(以及因此的 sscanf 调用)的原因——San Andreas 使用静态编译的 CRT,因此任何操作系统级别的热修复程序都不会应用于它。但是,考虑到 Windows 11 中大量的安全增强功能,我不能排除其中一项增强功能,例如 内核模式硬件强制堆栈保护,最终以游戏存在 Bug 的函数不喜欢的方式扰乱堆栈。

我设置了一个实验,在专门解析 Skimmer 行(车辆 ID 460)的 sscanf 调用之前,我进入了调试器,并且观察到的变量值支持了这一说法。在我的 Windows 10 机器上,它们恰好都是 0.7

frontWheelSize 0x01779f14 {0.699999988}
rearWheelSize  0x01779f10 {0.699999988}

而在 Win11 24H2 VM 上,它们非常大,在数量级上与之前在边界框中观察到的错误值相似。堆栈指针也因为某些原因移动了 4 个字节,但这不太可能相关——并且很可能由 kernel32.dll 内部线程启动样板代码的一些更改引起的:

frontWheelSize 0x01779f18 {7.84421263e+33}
rearWheelSize  0x01779f14 {4.54809690e-38}

这让我感到好奇——0.7 对于将来自堆栈的随机垃圾解释为浮点数的结果来说有点“太好”了;更有可能的是,它是一个实际的浮点数值,恰好位于堆栈上的正确位置。然后我检查了 vehicles.ide 中 TopFun——在 Skimmer 之前定义的车辆。果然,它的车轮比例是 0.7

459, topfun, topfun, car, TOPFUN, TOPFUN, van, ignore, 1, 0, 0, -1, 0.7, 0.7, -1

vehicles.ide 按顺序解析,在类似于此(伪代码)的函数中:

void CFileLoader::LoadObjectTypes(const char* filename)
{
 // Open the file...
 while ((line = fgets(file)) != NULL)
 {
  // Parse the section indicators...
  switch (section)
  {
   // Different sections...
  case SECTION_CARS:
   LoadVehicleObject(line);
   break;
  }
 }
}

似乎代码以某种方式保留了过时的车轮比例值,因此 Skimmer 最终获得了与 Topfun 相同的车轮比例。我设置了另一个实验来验证这一说法:

  1. 再次在 sscanf 之前设置一个断点,但这次是在解析 Topfun 行(车辆 ID 459)之前。
  2. frontWheelScalerearWheelScale 上设置写断点。
  3. 恢复执行,直到游戏开始解析下一个车辆定义。

Windows 10 验证了我的说法——在调用 CFileLoader::LoadVehicleObject 之间,没有任何东西写入这两个堆栈值,因此该函数的有效行为是在连续调用之间保留(尽管是无意中)车轮比例值!

在 Windows 11 24H2 上重复相同的练习会触发写断点!但是,它不在任何安全功能附近:堆栈值被……fgets 内部的 LeaveCriticalSection 覆盖了:

> ntdll.dll!_RtlpAbFindLockEntry@4() Unknown
 ntdll.dll!_RtlAbPostRelease@8() Unknown
 ntdll.dll!_RtlLeaveCriticalSection@4() Unknown
 gta_sa.exe!fgets() Unknown

似乎 Windows 11 24H2 中的一项更改修改了 临界区对象 的内部工作方式,并且解锁临界区的新代码使用的堆栈空间多于旧代码。我运行了另一个实验,比较了 LoadVehicleObject 内部的 sscanf 之后到此函数的下一次调用之间发生的堆栈空间更改。更改的值以红色突出显示:

在 Windows 10 上,调用之间的堆栈值没有太大变化。事实上,你可以看到两个 0x3F449BA6 = 0.768 的值(在屏幕截图中突出显示)。它们对应于 Landstalker 的车轮比例。在 Windows 11 24H2 上,更多堆栈空间被临界区的新实现修改。车轮比例无处可寻,因为它们被覆盖了!

这就是我需要的确切证明——请注意,在 Windows 10 运行中,一些局部变量甚至仍然可以被人眼看到(例如 normal 车辆类别),而在 Windows 11 中,它们完全消失了。还值得指出的是,即使在 Windows 10 中,在车轮比例之后的下一个局部变量也被 LeaveCriticalSection 覆盖了,这意味着游戏早几年就差 4 个字节就遇到了这个完全相同的 Bug!这里显示的运气真是太疯狂了。

Whose Stack Is It Anyway?

为了说明为什么游戏能够长时间逃避这个 Bug,我需要展示堆栈如何在调用之间变化。假设在调用 LoadVehicleObject 之后,堆栈看起来像这样。重点是我们感兴趣的局部变量:

LoadObjectTypes 返回的地址

LoadObjectTypes 的局部变量…
LoadVehicleObject 返回的地址
LoadVehicleObject 的局部变量…
frontWheelScale
rearWheelScale
更多局部变量…

fgets 的调用,以及随后的 LeaveCriticalSection,它遵循对 LoadVehicleObject 的调用,重用了之前由该函数占用的堆栈空间,因为函数堆栈的生存期仅限于函数本身的持续时间,一旦函数完成,此空间就可以再次使用。在 Windows 10 上,一旦 fgetsLeaveCriticalSection 返回,堆栈看起来像这样:

LoadObjectTypes 返回的地址

LoadObjectTypes 的局部变量…
fgets 返回的地址
fgets 的局部变量…
LeaveCriticalSection 返回的地址
LeaveCriticalSection 的局部变量…
frontWheelScale
rearWheelScale
更多局部变量…

突出显示的部分覆盖了曾经是 LoadVehicleObject 的堆栈空间的内容,但请注意,它没有到达车轮比例所在的堆栈区域。在 Windows 11 24H2 中,LeaveCriticalSection 使用了更多堆栈空间,因此堆栈空间看起来更像这样:

LoadObjectTypes 返回的地址

LoadObjectTypes 的局部变量…
fgets 返回的地址
fgets 的局部变量…
LeaveCriticalSection 返回的地址
LeaveCriticalSection 的局部变量…
frontWheelScale 被覆盖!
rearWheelScale 被覆盖!
更多局部变量…

过去完好无损的红色突出显示的堆栈部分现在也被扰乱了;这些部分包括上一次调用 LoadVehicleObject 读取的车轮比例!这反过来又暴露了由这些变量未初始化引起的 Bug,并且由于 sscanf 无法从 Skimmer 的 vehicles.ide 定义中读取这些值,因此它们保持原样并以垃圾形式传播到车辆的数据中。

What are the odds this only broke now? Darn Windows 11!

我想明确指出:所有这些发现都证明该 Bug 不是 Windows 11 24H2 的问题,因为内部 WinAPI 函数使用堆栈的方式等事情不是合同式的,它们可能随时更改,恕不另行通知。这里真正的问题是游戏依赖于未定义的行为(未初始化的局部变量),并且老实说,我很震惊游戏没有在如此多的操作系统版本上遇到这个 Bug,尽管正如我之前指出的,它非常接近。San Andreas 仍然支持 Windows 98,这意味着它在 至少 十几个不同的 Windows 版本以及更多 Wine 版本中逃脱了这个 Bug!

……或者说,它做到了吗?我很难相信游戏永远不会在其发布的许多平台上遇到这个问题,所以我研究了一些其他版本的二进制文件。虽然这个 Bug 没有在官方 1.01 PC 补丁中修复,但它确实在原始 Xbox 版本中修复了,其中将 1.0 的“合理默认值”添加到代码中,就像我的修复一样。然后,此修复被 San Andreas 的许多未来版本“继承”,包括:

但是,与 Rockstar 不同的是,我决定使用 0.7 而不是 1.0 的车轮比例作为默认值,原因有很多:

  1. 这是 Skimmer 在 PC(以及可能在 PS2 上)直到现在的有效车轮比例,因为这是 Topfun 的车轮比例。
  2. 另外两种在水上漂浮的非船只车辆 Sea Sparrow 和 Vortex 都具有 0.7 的车轮比例。
  3. 游戏中的许多汽车都具有 0.7 的车轮比例。

I want this fixed in my game!

此代码修复将包含在 下一个 SilentPatch 热修复程序 中,但现在,你可以通过编辑 vehicles.ide 轻松地自行修复它:

  1. 在你的 San Andreas 目录中,使用 Notepad 打开 data\vehicles.ide
  2. 向下滚动到以 460, skimmer 开头的 Skimmer 行。
  3. 将原始行替换为:
460, skimmer, skimmer, plane, SEAPLANE, SKIMMER, null, ignore, 5, 0, 0, -1, 0.7, 0.7, -1
  1. 保存文件。

Final word

这是我一段时间以来遇到的最有趣的 Bug。最初,我很难相信这样的 Bug 会直接与特定的操作系统版本相关联,但我被证明是完全错误的。归根结底,这是 San Andreas 中的一个简单 Bug,这个函数本就不应该正常工作,但至少在 PC 上,它隐藏了 20 年。

这是一个有趣的兼容性课程:如果应用程序存在 Bug 并且无意中依赖于特定的行为,那么即使对内部实现的堆栈布局的更改也可能具有兼容性含义。这也不是我第一次遇到这样的问题:经常访问的人可能会记得 Bully: Scholarship Edition,它因非常相似的原因而在 Windows 10 上臭名昭著地崩溃。就像在这种情况下一样,Bully 本来就不应该正常工作,但相反,它在多年的时间里逃避了做出错误的假设,直到 Windows 10 中的更改最终让它运气不佳。

再一次,我们被提醒:

最后,GTA 玩家是幸运的:在许多其他游戏中,这样的问题将保持未修复状态,并且它们将成为民间传说。值得庆幸的是,GTA 是可修改的并且被很好地理解,因此我们可以对这样的问题采取行动,并确保游戏在未来许多年内保持正常运行。

  1. 换句话说,由于浮点值的表示方式,从一个巨大的浮点值中减去一个小的浮点值可能根本不会改变结果。