1 Punkte von GN⁺ 2025-04-24 | 1 Kommentare | Auf WhatsApp teilen
  • Unter Windows 11 24H2 ließ sich ein Problem reproduzieren, bei dem das Skimmer-Wasserflugzeug verschwindet oder den Spieler direkt nach dem Spawn ungewöhnlich hoch in den Himmel katapultiert. Die Ursache war kein OS-Fehler, sondern ein alter Bug in der internen Datenverarbeitung des Spiels.
  • In der vehicles.ide-Zeile des Skimmer fehlten zwei für Flugzeuge nötige Werte für die Radskalierung, aber CFileLoader::LoadVehicleObject prüfte den Rückgabewert von sscanf nicht und verwendete dadurch unveränderte, nicht initialisierte lokale Variablen.
  • In früheren Windows-Umgebungen blieb zufällig der Radskalierungswert 0.7 des unmittelbar vorherigen Fahrzeugs TopFun auf dem Stack liegen, sodass der Skimmer normal wirkte. Unter Windows 11 24H2 änderte sich jedoch die Stack-Nutzung von LeaveCriticalSection, wodurch dieser Zufall wegfiel.
  • Die falsche Radskalierung verunreinigte die Federungsberechnung und die Z-Koordinate der Collision Box und pflanzte sich bis in die Berechnung von Spawn-Höhe und Rotorblattgeschwindigkeit fort. Das führte zu fehlerhaften Kamerapositionen, dem Burn-in-Effekt und in Umgebungen mit SilentPatch zu einem hängenden Loop.
  • Die Lösung besteht darin, der Skimmer-Zeile in vehicles.ide -1, 0.7, 0.7, -1 hinzuzufügen oder den nächsten SilentPatch-Hotfix anzuwenden. Validierung von Eingabedaten und der Umgang mit Compiler-Warnungen wirken sich direkt auf langfristige Kompatibilität aus.

Unter Windows 11 24H2 sichtbar gewordene Skimmer-Symptome

  • Im Issue-Tracker von SilentPatch wurde nach dem Update auf Windows 11 24H2 gemeldet, dass das Skimmer-Flugzeug vollständig aus dem Spiel verschwunden sei
    • Es ließ sich auch per Trainer nicht spawnen und war auch an seinen ursprünglichen Spawnpunkten nicht zu finden
    • Reproduzierbar war es sowohl in einem Spiel mit Mods als auch in einer Vanilla-Kopie, auf die nur SilentPatch angewendet war
  • Auch in den GTAForums wurde dasselbe Problem seit November 2024 gemeldet; einige Nutzer verdächtigten SilentPatch, doch das Phänomen trat auch in einem völlig ungemoddeten Spiel auf
  • Unter Windows 10 22H2 und Windows 11 23H2 spawnte der Skimmer normal, während Nutzer von Windows 11 24H2 denselben Bug erlebten
  • Remote-Debugging in einer 24H2-VM zeigte, dass andere Flugzeuge und Boote normal waren und nur der Skimmer verschwand

Anormale Höhe und eine endlose Rotorblatt-Schleife

  • Wenn der Skimmer per Skript erzwungen erzeugt und CJ hineingesetzt wird, wird der Spieler auf 1.0287648030984853e+0031 m katapultiert, also rund 10,3 Nonillionen Meter Höhe
  • Ist SilentPatch installiert, gerät das Spiel direkt nachdem der Spieler nach oben geschleudert wurde in eine Schleife und friert ein
  • Ohne SilentPatch friert das Spiel nicht ein, aber der bekannte Burn-in-Effekt tritt auf, der entsteht, wenn die Kamera an eine Position nahe unendlich bewegt wird
  • Die Stelle des Hängers war die Schleife zur Normalisierung des Rotorblattwinkels in CPlane::PreRender
    • Der Wert von m_fBladeSpeed wuchs auf 3.73340132e+29
    • Selbst durch wiederholtes Subtrahieren von 6.2831855 änderte sich der Wert aufgrund der Gleitkommadarstellung nicht, sodass die Schleife nie endete
  • Da die Rotorblattgeschwindigkeit aus einem Wert abgeleitet wird, der proportional zur Flughöhe ist, war dies ein Hinweis darauf, dass der Skimmer von Anfang an an einer anormal hohen Position erzeugt wurde

Federungsberechnung verunreinigte die Collision Box

  • Die Skript-Erzeugungsfunktion CCarCtrl::CreateCarForScript addiert zum übergebenen Z-Wert das Ergebnis von GetDistanceFromCentreOfMassToBaseOfModel
  • Bei der Prüfung der Collision Box des Skimmer war bbox.sup.z mit einem unsinnigen Wert wie -4.30747210e+33 verunreinigt
  • Die Verfolgung per Data Breakpoint ergab, dass der Wert der Collision Box zum Zeitpunkt des initialen Ladens normal war
    • Der anfängliche Wert von bbox.sup.z betrug -2.21952772
    • Später, wenn das Fahrzeug erstmals spawnt, aktualisiert SetupSuspensionLines die Z-Koordinate der Collision Box unter Berücksichtigung der Federungshöhe
  • Das Problem lag in einem der Eingabewerte für die Berechnung der Federungslinien
    • In die Berechnung fließen die oberen und unteren Federungsgrenzen aus handling.cfg sowie die Radskalierung aus vehicles.ide ein
    • Die Werte des Skimmer in handling.cfg unterschieden sich nicht stark von denen anderer Flugzeuge

Die kurze vehicles.ide-Zeile des Skimmer

  • Die vehicles.ide-Definition des Skimmer ist kürzer als die anderer Flugzeuge; die letzten vier Parameter fehlen
  • Zwei der fehlenden Werte sind die Skalierung der Vorder- und Hinterräder
  • Bei Booten ist es kein Problem, wenn diese Werte fehlen, doch unter den Flugzeugen ist der Skimmer das einzige, das diese Parameter auslässt
  • Der Skimmer war in Vice City als Boot definiert und wurde in San Andreas zu einem Flugzeug geändert; dabei wurden offenbar die neu nötigen Parameter nicht ergänzt
  • Wenn die fehlenden Parameter wieder eingefügt werden, funktioniert der Skimmer normal

Loader prüfte den Rückgabewert von sscanf nicht

  • CFileLoader::LoadVehicleObject parst eine Zeile aus vehicles.ide mit sscanf und geht dabei davon aus, dass alle Parameter immer vorhanden sind
  • Die Funktion prüft den Rückgabewert von sscanf nicht und setzt für die meisten der letzten Parameter auch keine Standardwerte
    • wheelModelID wird nicht initialisiert
    • Auch frontWheelScale und rearWheelScale werden nicht initialisiert
    • Nur wheelUpgradeClass wird mit -1 initialisiert
  • Bei Zeilen mit fehlenden Werten wie beim Skimmer bleiben die Variablen für die Radskalierung nicht initialisiert, und diese Werte werden in die Fahrzeugdaten übernommen
  • Der SilentPatch-Fix besteht darin, den sscanf-Aufruf zu kapseln und Standardwerte für die letzten vier Werte bereitzustellen
    • wheelModelID = -1
    • frontWheelSize = 0.7f
    • rearWheelSize = 0.7f
    • wheelUpgradeClass = -1
  • Der Fix-Commit wurde im SilentPatch-Repository übernommen

Warum der Bug 20 Jahre lang verborgen blieb

  • San Andreas verwendet eine statisch kompilierte CRT; ein Hotfix auf CRT-Ebene in Windows hat also nicht das Verhalten von sscanf verändert
  • Unter Windows 10 blieb unmittelbar vor dem Parsen des Skimmer an der Position der lokalen Variablen der Wert 0.7 liegen
    • Dieser Wert entspricht der Radskalierung von TopFun, das direkt vor dem Skimmer definiert ist
    • Die TopFun-Zeile enthält -1, 0.7, 0.7, -1
  • vehicles.ide wird der Reihe nach gelesen, und für jede Zeile wird LoadVehicleObject aufgerufen
  • Unter Windows 10 wurde diese Stack-Position zwischen den Aufrufen von LoadVehicleObject nicht überschrieben, sodass der Skimmer zufällig die Radskalierung von TopFun erbte
  • Unter Windows 11 24H2 verwendete LeaveCriticalSection innerhalb von fgets beim Lesen der nächsten Zeile mehr Stack-Speicher; dadurch wurde der zurückgebliebene Wert überschrieben

Windows 11 24H2 war nur der Auslöser

  • Die Art, wie interne WinAPI-Funktionen den Stack nutzen, ist kein vertraglich garantiertes Verhalten und kann sich ohne Vorankündigung ändern
  • Windows 11 24H2 beseitigte lediglich den zufälligen Restwert auf dem Stack, auf den sich das Spiel verließ; die eigentliche Ursache ist undefiniertes Verhalten im Spiel
  • Auch unter Windows 10 wurde die lokale Variable direkt nach der Radskalierung bereits von LeaveCriticalSection überschrieben, und das Spiel hätte schon vor Jahren auf diesen Bug treffen können
  • Da San Andreas auch Windows 98 unterstützte, blieb dieser Bug offenbar über mindestens ein gutes Dutzend Windows-Versionen und mehrere Wine-Releases hinweg zufällig verborgen
  • Im offiziellen PC-Patch 1.01 wurde dieser Bug nicht behoben, aber die ursprüngliche Xbox-Version enthielt einen Fix, der den Standardwert 1.0 setzt
    • Steam 3.0, newsteam und RGL basieren auf dem Xbox-Codezweig und haben diesen Fix daher geerbt
    • Auch die Android-, X360- und PS3-Releases von War Drum Studios sowie die Definitive Edition sind betroffen

Warum SilentPatch 0.7 als Standardwert wählte

  • SilentPatch verwendet als Standard-Radskalierung nicht wie Rockstars Xbox-Fix 1.0, sondern 0.7
  • Die Entscheidung beruht auf drei Gründen
    • In der PC-Version hat der Skimmer bis heute faktisch mit der Radskalierung 0.7 von TopFun funktioniert
    • Auch Sea Sparrow und Vortex, andere nicht als Boote klassifizierte Fahrzeuge, die auf dem Wasser treiben, haben eine Radskalierung von 0.7
    • Viele Autos im Spiel haben ebenfalls eine Radskalierung von 0.7

So behebt man es selbst

  • Die Code-Änderung soll im nächsten SilentPatch-Hotfix enthalten sein
  • Wer es sofort reparieren möchte, öffnet im San-Andreas-Verzeichnis data\vehicles.ide mit dem Editor und ersetzt die Zeile, die mit 460, skimmer beginnt
  • Die zu ersetzende Zeile lautet:
460, 	skimmer,	skimmer, 	plane,		SEAPLANE,	SKIMMER,	null,	ignore,		5,	0,	0,		-1, 0.7, 0.7,		-1

Lehren aus der Kompatibilität alter Spiele

  • Dieses Problem war ein einfacher Bug in San Andreas, und die betreffende Funktion war von Anfang an Code, der nicht korrekt funktionieren konnte
  • Auch Änderungen am Stack-Layout interner Implementierungen können zu einem Kompatibilitätsproblem werden, wenn eine fehlerhafte Anwendung sich zufällig auf ein bestimmtes Verhalten verlässt
  • Ein ähnlicher Fall war Bully: Scholarship Edition, das unter Windows 10 kaputtging, weil es ebenfalls auf falschen Annahmen beruhte und der Fehler durch eine OS-Änderung sichtbar wurde
  • Das Grundproblem in San Andreas war das Fehlen von Validierung der Eingabedaten, wodurch eine unvollständige Konfigurationszeile nicht abgefangen wurde
  • Dieser Code hat ursprünglich wahrscheinlich Compiler-Warnungen erzeugt; wenn Warnungen ignoriert oder deaktiviert werden, können lange verborgene Bugs zu echten Problemen für Nutzer werden

1 Kommentare

 
GN⁺ 2025-04-24
Meinungen auf Hacker News
  • Das ist ein Niveau, das man sonst von Raymond Chen erwarten würde, und das ist ein riesiges Kompliment.
    Schön, dass der Autor noch tiefer nachgegraben und genau herausgefunden hat, warum das passiert.

  • Persönlich finde ich: Wenn ein Verhalten nicht Teil des Vertrags ist, sollte es randomisiert werden.
    Wenn eine Sprache zum Beispiel keine Reihenfolge beim Iterieren über Maps garantiert, sollte sie die Reihenfolge absichtlich randomisieren.
    Sonst entsteht fragiler Code, der „funktioniert, bis er eines Tages kaputtgeht“.

    • Es gibt diverse Compiler-Optionen wie -ftrivial-auto-var-init, die nicht initialisierte Variablen mit einem festen oder zufälligen Wert initialisieren.
      Aber wenn man bei jedem Funktionsaufruf den gesamten Stack-Inhalt randomisieren oder mit Nullen füllen würde, wäre der Performance-Einbruch furchtbar, deshalb macht man das normalerweise nicht.
    • Randomisierung auf diesem Niveau ist zu teuer.
      Es gibt Tools, die so etwas zu Debugging-Zwecken tun, aber in diesem Modus läuft das Programm deutlich langsamer.
    • Aus Vertragssicht gibt es auch diese Lektion aus dem Originaltext: „Das ist eine interessante Lektion in Sachen Kompatibilität. Wenn eine Anwendung einen Bug hat und sich unbeabsichtigt auf ein bestimmtes Verhalten verlässt, kann schon eine Änderung des Stack-Layouts einer internen Implementierung Auswirkungen auf die Kompatibilität haben.“
      Wahrscheinlich ist das auch der Grund, warum die Maintainer des Linux-Kernels so sehr darauf bestehen, den Userspace niemals zu brechen.
    • Nein. Man sollte sich an https://www.hyrumslaw.com/ erinnern.
      Wenn eine API genügend Nutzer hat, ist es egal, was im Vertrag versprochen wurde: Irgendjemand wird sich auf jedes beobachtbare Verhalten des Systems verlassen.
      Wenn man Randomisierung verspricht, wird sich jemand auch auf diese Randomisierung verlassen.
      Dann kann man auch sie nie wieder entfernen.
    • Einer der Vorteile von Sprachen wie C ist, dass man nur für die Features bezahlt, die man auswählt.
      Man wird nicht gezwungen, für unnötigen Overhead wie die Initialisierung ungenutzter Variablen zu zahlen.
  • Bei dem Teil „Compiler-Warnungen nicht ignorieren“ weiß ich nicht, welchen Compiler-Fehler man hier erwarten könnte.
    Vielleicht, dass nicht geprüft wurde, ob der Rückgabewert von scanf zur Anzahl der Argumente passt? Ansonsten wirkt es eher wie ein Fehler in der Datendatei, den der Compiler nicht kennen kann.

    • Mit g++ 11.4 gibt es standardmäßig keine Warnung, selbst wenn man den Rückgabewert von sscanf nicht prüft.
      In einem kleinen Beispiel kommt selbst mit g++ -Wall -Wextra -Wunused-result keine Warnung.
    • Der Zugriff auf nicht initialisierten Speicher ist undefiniertes Verhalten, also hätte ein Sanitizer das erwischt.
    • Guter Punkt. Beim Lesen dachte ich vage, eine Warnung wegen „Verwendung nicht initialisierten Speichers“ würde das finden.
      Da aber die gesamte Zeile mit einem einzigen sscanf-Aufruf geparst wird, muss die statische Analyse des Compilers annehmen, dass die Werte nun initialisiert sind.
      Es scheint keine allgemeine statische Analyse zu geben, die diesen Bug erwischen würde.
      Man könnte allerdings eine scanf-spezifische Warnung bauen, die erzwingt, dass vorinitialisierte Werte übergeben oder der Rückgabewert geprüft wird.
  • Solche tiefgehenden technischen Analysen zu lesen, macht immer Freude.
    Ich frage mich, ob solche Texte im KI-Zeitalter seltener werden oder nicht.

    • Ich glaube nicht, dass sie seltener werden. Es wird immer Top-Ingenieure geben, die tief graben.
      KI wird sie nicht ersetzen, und die Softwareentwicklungs-Innovationen der letzten über 50 Jahre haben das auch nicht geschafft.
      Millionen, vielleicht zig Millionen Entwickler in höheren Programmiersprachen kennen den Unterschied zwischen Stack und Heap nur als vage Theorie aus der Schule, und im Arbeitsalltag müssen sie sich nicht darum kümmern, also interessiert es sie auch nicht.
    • Der durchschnittliche Software Engineer mag sich vom Handwerker eher in Richtung technischer Fachkraft bewegen, aber solche Texte scheinen aus einem handwerklichen Stil heraus zu entstehen.
  • Mich würde eher interessieren, was sich in dieser Windows-Version an der Implementierung des Sperrens/Entsperrens kritischer Abschnitte geändert hat.

    • Es sieht so aus, als sei die verwendete Stack-Größe oder der Stack-Guard-Bereich größer geworden.
  • Bin ich der Einzige, den dieser Code stört?
    while (this->m_fBladeAngle > 6.2831855) { this->m_fBladeAngle = this->m_fBladeAngle - 6.2831855; }
    Das wirkt, als hätte jemand keine Lust auf Division gehabt und stattdessen eine while-Schleife geschrieben, die eventuell unendlich laufen kann.

    • Ich würde gern glauben, dass die GTA-Entwickler diesen Hack gemacht haben, weil er auf Umgebungen wie der PlayStation 2 schneller war als Floating-Point-Division.
      Aber wenn man bedenkt, dass das Parsen von JSON mit sscanf die Ladezeit von GTA5 um fünf Minuten verlängern konnte, sind meine Erwartungen nicht allzu hoch.
    • Ich halte es für sehr wahrscheinlich, dass es aus Performance-Gründen so gemacht wurde. Subtraktion ist billiger als Floating-Point-Division.
      Der Compiler könnte außerdem Wege haben, das weiter zu optimieren.
      Es gibt praktisch keine Möglichkeit, dass daraus eine Endlosschleife wird. Underflow ist möglich, aber dafür müsste der Winkel bereits kleiner als 2*pi sein, wodurch die Schleife verlassen würde.
    • Unwahrscheinlich, aber wenn der Wert klein ist, könnte diese Schleife tatsächlich schneller sein als eine Division.
    • Stimmt wirklich. Der Autor kannte fmod offenbar überhaupt nicht.
  • Wer Probleme mit dem Zugriff hat, kann diesen Link verwenden:
    https://web.archive.org/web/20250423144746/https://cookieplm...

  • Weil ich C/C++ kenne, hatte ich schon früh im Blog ungefähr eine Ahnung, was passiert, nämlich ein Problem mit einer nicht initialisierten Variablen.
    Es ist erstaunlich, dass eine Sprache erlaubt, Variablen uninitialisiert zu lassen. Dadurch sind unzählige Bugs entstanden, darunter Produktionsbugs, die ich selbst gesehen habe, und oft muss man sich zum Aufspüren auf zusätzliche Compiler-Flags, statische Analysewerkzeuge, Valgrind usw. verlassen.
    Neuere Sprachen wählen andere Lösungen, etwa Standard-Nullwerte oder erzwungene Initialisierung vor der Verwendung, und trotzdem kehren die Leute immer wieder zu C/C++ zurück.

  • Der Abschnitt „All diese Erkenntnisse beweisen, dass der Bug kein Problem von Windows 11 24H2 ist. Dinge wie die Art, wie eine interne WinAPI-Funktion den Stack verwendet, sind kein Vertrag und können sich jederzeit ohne Vorankündigung ändern“ erinnert mich an einen hervorragenden Artikel, den ich früher einmal gelesen habe.
    Die Kernaussage war, dass es bei einer hinreichend erfolgreichen API so etwas wie eine private API nicht gibt.

    • Es wäre schön, wenn du den Artikel finden und verlinken könntest. Mich interessiert die Argumentation.
    • Ich glaube, dazu gibt es einen passenden XKCD-Comic.