《GTA San Andreas》20 年 Bug 在 Windows 11 24H2 中浮出水面
《GTA San Andreas》20 年 Bug 在 Windows 11 24H2 中浮出水面
2025年4月23日 阅读时长:14 分钟
- Introduction
- Investigating the bug
- Here be dragons – the true root cause
- I want this fixed in my game!
- Final word
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,并把 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_fBladeSpeed
是 3.73340132e+29
。这个值显然非常大,大到足以使通过 6.2831855
递减该值完全无效,因为这两个值的浮点指数存在差异。1 但是为什么叶片速度如此之高呢?叶片速度源自以下公式:
this->m_fBladeSpeed = (v34 - this->m_fBladeSpeed) * CTimer::ms_fTimeStep / 100.0 + this->m_fBladeSpeed;
其中 v34
与飞机的海拔高度成正比。这与最初的发现相符——如前所述,“烧屏效应”通常发生在相机非常远离地图中心或处于很高的高度时。
是什么导致飞机射得如此之高?有两种可能性:
- 飞机一开始就生成在高空中。
- 飞机在地面上生成,然后在下一帧中射向空中。
至于这个测试,我自己使用测试脚本生成 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
已损坏,它既不是边界框中的第一个也不是最后一个字段。再一次,我脑海中浮现出两种可能性:
- 碰撞文件读取不正确,某些字段保持未初始化状态,或者读取不相关的数据而不是边界框?鉴于此问题可能是一个操作系统 Bug,这种可能性很小,但并非不可能。
- 边界框读取正确,但随后使用一个非常不正确的值对其进行更新。
在 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->fSuspensionLowerLimit
、pHandling->fSuspensionUpperLimit
或 wheelScale
是伪造的。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 相同的车轮比例。我设置了另一个实验来验证这一说法:
- 再次在
sscanf
之前设置一个断点,但这次是在解析 Topfun 行(车辆 ID 459)之前。 - 在
frontWheelScale
和rearWheelScale
上设置写断点。 - 恢复执行,直到游戏开始解析下一个车辆定义。
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 上,一旦 fgets
和 LeaveCriticalSection
返回,堆栈看起来像这样:
从 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 的许多未来版本“继承”,包括:
- Steam 3.0、newsteam 和 RGL,因为它们都基于 Xbox 代码分支。
- 来自 War Drum Studios 的任何版本,包括 Android、X360 和 PS3。
- Definitive Edition。
但是,与 Rockstar 不同的是,我决定使用 0.7
而不是 1.0
的车轮比例作为默认值,原因有很多:
- 这是 Skimmer 在 PC(以及可能在 PS2 上)直到现在的有效车轮比例,因为这是 Topfun 的车轮比例。
- 另外两种在水上漂浮的非船只车辆 Sea Sparrow 和 Vortex 都具有
0.7
的车轮比例。 - 游戏中的许多汽车都具有
0.7
的车轮比例。
I want this fixed in my game!
此代码修复将包含在 下一个 SilentPatch 热修复程序 中,但现在,你可以通过编辑 vehicles.ide
轻松地自行修复它:
- 在你的 San Andreas 目录中,使用 Notepad 打开
data\vehicles.ide
。 - 向下滚动到以
460, skimmer
开头的 Skimmer 行。 - 将原始行替换为:
460, skimmer, skimmer, plane, SEAPLANE, SKIMMER, null, ignore, 5, 0, 0, -1, 0.7, 0.7, -1
- 保存文件。
Final word
这是我一段时间以来遇到的最有趣的 Bug。最初,我很难相信这样的 Bug 会直接与特定的操作系统版本相关联,但我被证明是完全错误的。归根结底,这是 San Andreas 中的一个简单 Bug,这个函数本就不应该正常工作,但至少在 PC 上,它隐藏了 20 年。
这是一个有趣的兼容性课程:如果应用程序存在 Bug 并且无意中依赖于特定的行为,那么即使对内部实现的堆栈布局的更改也可能具有兼容性含义。这也不是我第一次遇到这样的问题:经常访问的人可能会记得 Bully: Scholarship Edition,它因非常相似的原因而在 Windows 10 上臭名昭著地崩溃。就像在这种情况下一样,Bully 本来就不应该正常工作,但相反,它在多年的时间里逃避了做出错误的假设,直到 Windows 10 中的更改最终让它运气不佳。
再一次,我们被提醒:
- 验证你的输入数据——San Andreas 在这方面声名狼藉,而这最终是未完成的配置文件行未被注意到的主要原因。
- 不要忽略编译警告——此代码很可能在原始代码中抛出了一个警告,该警告被忽略或禁用了!
最后,GTA 玩家是幸运的:在许多其他游戏中,这样的问题将保持未修复状态,并且它们将成为民间传说。值得庆幸的是,GTA 是可修改的并且被很好地理解,因此我们可以对这样的问题采取行动,并确保游戏在未来许多年内保持正常运行。
- 换句话说,由于浮点值的表示方式,从一个巨大的浮点值中减去一个小的浮点值可能根本不会改变结果。↩