- ใน Windows 11 24H2 พบปัญหาที่ทำซ้ำได้คือ เครื่องบินน้ำ Skimmer หายไป หรือผู้เล่นถูกดีดขึ้นไปบนท้องฟ้าสูงผิดปกติทันทีหลังสปอว์น โดยสาเหตุไม่ใช่ OS แต่เป็นบั๊กเก่าในวิธีประมวลผลข้อมูลภายในเกม
- แถวของ Skimmer ใน
vehicles.ide ขาดค่า wheel scale 2 ค่า ที่จำเป็นสำหรับเครื่องบิน แต่ CFileLoader::LoadVehicleObject ไม่ได้ตรวจสอบค่าที่ sscanf ส่งกลับ จึงนำ local variable ที่ยังไม่ได้ initialize มาใช้ต่อโดยตรง
- ในสภาพแวดล้อม Windows รุ่นเก่า ค่า wheel scale
0.7 ของรถ TopFun คันก่อนหน้าบังเอิญค้างอยู่ใน stack ทำให้ Skimmer ดูเหมือนทำงานปกติ แต่ใน Windows 11 24H2 ปริมาณการใช้ stack ของ LeaveCriticalSection เปลี่ยนไป ความบังเอิญนั้นจึงหายไป
- wheel scale ที่ผิดพลาดทำให้การคำนวณ suspension และพิกัด Z ของ collision box ปนเปื้อน แล้วแพร่ต่อไปถึงการคำนวณความสูงตอนสร้างและความเร็วใบพัด ส่งผลให้ตำแหน่งกล้องผิดปกติ เกิดเอฟเฟกต์ burn-in และในสภาพแวดล้อมที่ใช้ SilentPatch เกมค้างในลูป
- วิธีแก้คือเพิ่ม
-1, 0.7, 0.7, -1 ในแถว Skimmer ของ vehicles.ide หรือใช้ hotfix ถัดไปของ SilentPatch และกรณีนี้แสดงให้เห็นว่า การตรวจสอบข้อมูลอินพุต กับการจัดการ compile warning ส่งผลโดยตรงต่อความเข้ากันได้ระยะยาว
อาการของ Skimmer ที่ถูกเปิดเผยใน Windows 11 24H2
- มีรายงานใน issue tracker ของ SilentPatch ว่าหลังอัปเดตเป็น Windows 11 24H2 เครื่องบิน Skimmer หายไปจากเกมโดยสิ้นเชิง
- ใช้ trainer ก็สปอว์นไม่ได้ และไม่พบที่จุดสปอว์นเดิม
- ทำซ้ำได้ทั้งในเกมที่มี mod และสำเนา vanilla ที่ติดตั้งแค่ SilentPatch
- ใน GTAForums ก็มีรายงานปัญหาเดียวกันตั้งแต่เดือนพฤศจิกายน 2024 และแม้ผู้ใช้บางคนสงสัย SilentPatch แต่เกมที่ไม่มี mod ใด ๆ เลยก็เกิดอาการเดียวกัน
- บน Windows 10 22H2 และ Windows 11 23H2 Skimmer สปอว์นได้ตามปกติ ส่วนผู้ใช้ Windows 11 24H2 พบ bug เดียวกัน
- จากการ remote debugging ใน virtual machine 24H2 พบว่าเครื่องบินและเรือลำอื่นทำงานปกติ และมีเพียง Skimmer เท่านั้นที่หายไป
ความสูงผิดปกติและลูปใบพัดที่ไม่สิ้นสุด
- เมื่อใช้ script บังคับสร้าง Skimmer แล้วให้ CJ ขึ้นไปนั่ง ผู้เล่นถูกดีดขึ้นไปที่ความสูง
1.0287648030984853e+0031m หรือประมาณ 10.3 nonillion เมตร
- หากติดตั้ง SilentPatch เกมจะเข้าสู่ลูปและค้างทันทีหลังโยนผู้เล่นขึ้นด้านบน
- หากไม่มี SilentPatch เกมไม่ค้าง แต่จะเกิดเอฟเฟกต์ burn-in อันเป็นที่รู้จักกันดี ซึ่งเกิดเมื่อกล้องเคลื่อนไปยังตำแหน่งใกล้อนันต์
- จุดที่ค้างคือ loop สำหรับ normalize มุมใบพัด rotor ใน
CPlane::PreRender
- ค่า
m_fBladeSpeed เพิ่มขึ้นถึง 3.73340132e+29
- แม้จะลบ
6.2831855 ซ้ำ ๆ ค่าไม่เปลี่ยนในเชิงการแทนค่าด้วย floating point ทำให้ลูปไม่จบ
- ความเร็วใบพัดได้มาจากค่าที่แปรผันตามความสูงของเครื่องบิน จึงเป็นเบาะแสว่า Skimmer ถูกสร้างที่ตำแหน่งสูงผิดปกติตั้งแต่ต้น
การคำนวณ suspension ที่ทำให้ collision box ปนเปื้อน
- ฟังก์ชันสร้างด้วย script
CCarCtrl::CreateCarForScript จะบวกผลลัพธ์ของ GetDistanceFromCentreOfMassToBaseOfModel เข้ากับพิกัด Z ที่รับมา
- เมื่อตรวจสอบ collision box ของ Skimmer พบว่า
bbox.sup.z ปนเปื้อนด้วยค่าที่ไม่มีเหตุผล เช่น -4.30747210e+33
- จากการติดตามด้วย data breakpoint พบว่าค่า collision box ตอนโหลดครั้งแรกยังปกติ
- ค่าเริ่มต้นของ
bbox.sup.z คือ -2.21952772
- หลังจากนั้นเมื่อรถถูกสปอว์นครั้งแรก
SetupSuspensionLines จะนำความสูง suspension มาสะท้อนและอัปเดตพิกัด Z ของ collision box
- ปัญหาอยู่ที่หนึ่งในค่าอินพุตที่ใช้ในการคำนวณ suspension line
- การคำนวณใช้ขอบเขตบน/ล่างของ suspension จาก
handling.cfg และ wheel scale จาก vehicles.ide
- ค่า
handling.cfg ของ Skimmer ไม่ได้ต่างจากเครื่องบินลำอื่นมากนัก
แถว vehicles.ide ของ Skimmer ที่สั้นเกินไป
- นิยาม Skimmer ใน
vehicles.ide สั้นกว่าเครื่องบินลำอื่น และขาด parameter 4 ตัวท้าย
- ในค่าที่หายไป มี 2 ค่าเป็น wheel scale หน้าและหลัง
- สำหรับเรือ การไม่มีค่าเหล่านี้ไม่ก่อปัญหา แต่ Skimmer เป็นเครื่องบินเพียงลำเดียวที่ละเว้น parameter ดังกล่าว
- ดูเหมือนว่า Skimmer เคยถูกนิยามเป็น เรือ ใน Vice City แล้วเปลี่ยนเป็น เครื่องบิน ใน San Andreas โดยไม่ได้เพิ่ม parameter ใหม่ที่จำเป็นเข้ามา
- เมื่อใส่ parameter ที่หายไปกลับเข้าไป Skimmer จะทำงานปกติ
Loader ที่ไม่ตรวจสอบค่าที่ sscanf ส่งกลับ
CFileLoader::LoadVehicleObject parse แต่ละบรรทัดของ vehicles.ide ด้วย sscanf โดยสมมติว่า parameter ทั้งหมดมีอยู่เสมอ
- ฟังก์ชันนี้ไม่ตรวจสอบค่าที่
sscanf ส่งกลับ และไม่ได้ใส่ค่า default ให้ parameter ส่วนใหญ่ท้ายบรรทัด
wheelModelID ไม่ได้ถูก initialize
frontWheelScale, rearWheelScale ก็ไม่ได้ถูก initialize
- มีเพียง
wheelUpgradeClass ที่ initialize เป็น -1
- ในแถวที่ค่าหายไปอย่าง Skimmer ตัวแปร wheel scale จึงคงอยู่ในสถานะ ยังไม่ได้ initialize และค่านั้นถูกส่งต่อไปยังข้อมูลรถ
- การแก้ไขของ SilentPatch คือครอบการเรียก
sscanf และกำหนดค่า default ให้ 4 ค่าท้าย
wheelModelID = -1
frontWheelSize = 0.7f
rearWheelSize = 0.7f
wheelUpgradeClass = -1
- commit แก้ไขถูกนำเข้าใน repository ของ SilentPatch
เหตุผลที่ซ่อนอยู่ได้นาน 20 ปี
- San Andreas ใช้ CRT ที่ compile แบบ static ดังนั้น hotfix ระดับ CRT ของ Windows ไม่ได้เปลี่ยนพฤติกรรมของ
sscanf
- บน Windows 10 มีค่า
0.7 ค้างอยู่ในตำแหน่ง local variable ก่อน parse Skimmer
- ค่านี้ตรงกับ wheel scale ของ TopFun ที่นิยามอยู่ก่อน Skimmer ทันที
- แถว TopFun มี
-1, 0.7, 0.7, -1
vehicles.ide ถูกอ่านตามลำดับ และเรียก LoadVehicleObject ในแต่ละแถว
- บน Windows 10 ตำแหน่ง stack ดังกล่าวไม่ถูกเขียนทับระหว่างการเรียก
LoadVehicleObject ทำให้ Skimmer บังเอิญรับ wheel scale ของ TopFun ต่อมา
- บน Windows 11 24H2 ในกระบวนการอ่านแถวถัดไป
LeaveCriticalSection ภายใน fgets ใช้พื้นที่ stack มากขึ้น ส่งผลให้ค่าที่ค้างอยู่ถูกเขียนทับ
Windows 11 24H2 เป็นเพียงตัวกระตุ้น
- วิธีที่ฟังก์ชัน WinAPI ภายในใช้ stack ไม่ใช่พฤติกรรมที่มีสัญญารับประกัน และอาจเปลี่ยนได้โดยไม่ต้องแจ้งล่วงหน้า
- Windows 11 24H2 เพียงแค่ลบค่าค้างใน stack ที่เกมบังเอิญพึ่งพา สาเหตุจริงคือ undefined behavior ของเกม
- แม้บน Windows 10 local variable ถัดจาก wheel scale ก็ถูก
LeaveCriticalSection เขียนทับอยู่แล้ว และเกมอยู่ในสภาพที่อาจเจอ bug นี้ได้ตั้งแต่หลายปีก่อน
- เนื่องจาก San Andreas รองรับ Windows 98 ด้วย bug นี้จึงบังเอิญไม่ปรากฏอย่างน้อยตลอด Windows กว่าสิบเวอร์ชันและ Wine หลาย release
- patch PC อย่างเป็นทางการ 1.01 ไม่ได้แก้ bug นี้ แต่ release Xbox ต้นฉบับมีการแก้ไขโดยใส่ค่า default
1.0
- Steam 3.0, newsteam, RGL อิงจาก branch โค้ด Xbox จึงได้รับการแก้นี้ต่อมา
- release Android, X360, PS3 ของ War Drum Studios และ Definitive Edition ก็ได้รับผลกระทบเช่นกัน
เหตุผลที่ SilentPatch เลือก 0.7 เป็นค่า default
- SilentPatch ใช้
0.7 เป็น wheel scale เริ่มต้น แทนที่จะเป็น 1.0 เหมือนการแก้ไขของ Rockstar บน Xbox
- เหตุผลมีสามข้อ
- ในเวอร์ชัน PC Skimmer ทำงานด้วย wheel scale
0.7 ของ TopFun มาโดยพฤตินัยจนถึงตอนนี้
- Sea Sparrow และ Vortex ซึ่งเป็นยานพาหนะไม่ใช่เรือที่ลอยบนผิวน้ำได้ ก็มี wheel scale
0.7
- รถยนต์จำนวนมากในเกมก็ใช้ wheel scale
0.7
วิธีแก้ด้วยตัวเอง
- การแก้ในโค้ดจะรวมอยู่ใน hotfix ถัดไปของ SilentPatch
- หากต้องการแก้ทันที ให้เปิด
data\vehicles.ide ในไดเรกทอรี San Andreas ด้วย Notepad แล้วแทนที่แถวที่ขึ้นต้นด้วย 460, skimmer
- แถวที่ใช้แทนคือดังนี้
460, skimmer, skimmer, plane, SEAPLANE, SKIMMER, null, ignore, 5, 0, 0, -1, 0.7, 0.7, -1
บทเรียนที่ความเข้ากันได้ของเกมเก่าทิ้งไว้
- ปัญหานี้เป็น bug ธรรมดาของ San Andreas และฟังก์ชันดังกล่าวเป็นโค้ดที่ไม่อาจทำงานได้ถูกต้องมาตั้งแต่แรก
- แม้แต่การเปลี่ยนแปลง stack layout ของ implementation ภายใน ก็อาจกลายเป็น ปัญหาความเข้ากันได้ หากแอปพลิเคชันที่มี bug บังเอิญพึ่งพาพฤติกรรมบางอย่าง
- ตัวอย่างคล้ายกันคือ Bully: Scholarship Edition ที่เคยพังบน Windows 10 เพราะพึ่งพาสมมติฐานที่ผิด และปัญหาถูกเปิดเผยเมื่อ OS เปลี่ยน
- ปัญหาพื้นฐานของ San Andreas คือ การขาดการตรวจสอบข้อมูลอินพุต ที่ไม่สามารถคัดกรองแถว config ที่ไม่สมบูรณ์ได้
- โค้ดนี้มีความเป็นไปได้สูงที่จะเคยทำให้เกิด compile warning และหากละเลยหรือปิด warning ไว้ bug ที่ซ่อนตัวมานานอาจกลายเป็นปัญหาจริงสำหรับผู้ใช้ได้
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
บทความแบบนี้เป็นระดับที่คาดหวังได้จาก Raymond Chen เท่านั้น และนั่นเป็นคำชมอย่างยิ่ง
ดีใจที่ผู้เขียนขุดลึกลงไปจนระบุได้ว่าเป็นเพราะอะไรกันแน่
โดยส่วนตัวคิดว่า ถ้าเป็น พฤติกรรมที่ไม่ได้อยู่ในสัญญา ก็ควรถูกสุ่ม
เช่น ถ้าภาษาไม่ได้รับประกันลำดับการวนผ่านแมป ก็ควรจงใจสุ่มลำดับไปเลย
ไม่อย่างนั้นจะเกิดโค้ดเปราะบางแบบ “ใช้ได้ดีจนกระทั่งวันหนึ่งมันพัง”
-ftrivial-auto-var-initที่เริ่มต้นค่าตัวแปรที่ยังไม่ได้ initialize ให้เป็นค่าหนึ่งหรือค่าสุ่มแต่ถ้าสุ่มหรือเติมศูนย์ให้กับ เนื้อหาทั้งหมดของสแตก ทุกครั้งที่เรียกฟังก์ชัน ประสิทธิภาพจะตกอย่างหนัก จึงมักไม่ทำกัน
มีเครื่องมือที่ทำแบบนี้เพื่อการดีบัก แต่ในโหมดนั้นโปรแกรมจะทำงานช้าลงมาก
บางทีนี่อาจเป็นเหตุผลที่ผู้ดูแลเคอร์เนล Linux ยืนกรานว่าอย่าทำให้ user space พังเด็ดขาด
เมื่อมีผู้ใช้ API มากพอ ไม่ว่าสัญญาจะสัญญาอะไรไว้ก็ไม่สำคัญ เพราะจะมีใครสักคนพึ่งพาพฤติกรรมทุกอย่างของระบบที่สังเกตได้
ถ้าสัญญาว่าจะสุ่ม ก็จะมีใครสักคนพึ่งพาการสุ่มนั้นด้วย
แล้วสุดท้ายก็จะเอามันออกไม่ได้ตลอดไป
ไม่ต้องถูกบังคับให้จ่าย โอเวอร์เฮดที่ไม่จำเป็น อย่างการ initialize ตัวแปรที่ไม่ได้ใช้
ตรงส่วนที่ว่า “อย่าเพิกเฉยต่อคำเตือนของคอมไพเลอร์” ผมไม่แน่ใจว่าคาดหวังให้คอมไพเลอร์แจ้งข้อผิดพลาดอะไรในกรณีนี้
อาจเป็นแค่การไม่ตรวจสอบว่าค่าที่
scanfคืนมาตรงกับจำนวนอาร์กิวเมนต์หรือเปล่า? นอกนั้นดูเหมือนเป็น ข้อผิดพลาดในไฟล์ข้อมูล ที่คอมไพเลอร์รู้ไม่ได้sscanfคืนมา ก็ไม่มีคำเตือนตามค่าเริ่มต้นในตัวอย่างเล็ก ๆ แม้ใส่
g++ -Wall -Wextra -Wunused-resultก็ยังไม่มีคำเตือนแต่เพราะพาร์สทั้งบรรทัดด้วยการเรียก
sscanfครั้งเดียว การวิเคราะห์แบบ static ของคอมไพเลอร์จึงจำเป็นต้องถือว่าค่าต่าง ๆ ถูก initialize แล้วดูเหมือนจะไม่มีวิธีวิเคราะห์แบบ static ทั่วไปที่จะจับบั๊กนี้ได้
แต่ก็น่าจะสร้างคำเตือนเฉพาะสำหรับ
scanfเพื่อบังคับให้ส่งค่าที่ initialize ไว้ล่วงหน้า หรือให้ตรวจสอบค่าที่คืนมาได้การได้อ่าน บทวิเคราะห์เชิงเทคนิคลึก ๆ แบบนี้สนุกเสมอ
สงสัยว่าในยุค AI บทความแบบนี้จะหายากขึ้นหรือไม่
AI จะไม่มาแทนที่พวกเขา และนวัตกรรมการพัฒนาซอฟต์แวร์ตลอดกว่า 50 ปีที่ผ่านมาก็ยังทำไม่ได้
นักพัฒนาภาษาโปรแกรมระดับสูงหลายล้านคน หรืออาจถึงหลายสิบล้านคน รู้ความต่างระหว่างสแตกกับฮีปแค่เป็นทฤษฎีเลือน ๆ ที่เคยเรียนในโรงเรียน และในงานประจำวันก็ไม่จำเป็นต้องสนใจ จึงไม่ได้ใส่ใจ
อยากรู้มากกว่าว่าใน Windows เวอร์ชันนี้ การ implement การล็อก/ปลดล็อก critical section เปลี่ยนอะไรไป
มีแค่ผมหรือเปล่าที่รู้สึกขัดใจกับโค้ดนี้?
while (this->m_fBladeAngle > 6.2831855) { this->m_fBladeAngle = this->m_fBladeAngle - 6.2831855; }ให้ความรู้สึกว่าไม่อยากหาร เลยใช้ลูป
whileที่อาจกลายเป็นลูปไม่รู้จบแทนแต่พอเห็นว่าพวกเขาเคยพาร์ส JSON ด้วย
sscanfจนทำให้เวลาโหลด GTA5 เพิ่มขึ้น 5 นาที ก็ไม่ได้คาดหวังมากนักคอมไพเลอร์ก็อาจมีเทคนิคในการปรับให้เหมาะสมกว่านี้ได้
แทบไม่มีทางที่มันจะกลายเป็นลูปไม่รู้จบจริง ๆ underflow เป็นไปได้ แต่ถ้าเป็นอย่างนั้นมุมก็ต้องน้อยกว่า
2*piอยู่แล้ว จึงออกจากลูปfmodเลยใครมีปัญหาในการเข้าถึง ใช้ลิงก์นี้ได้
https://web.archive.org/web/20250423144746/https://cookieplm...
เพราะรู้ C/C++ เลยเดาได้ตั้งแต่ช่วงต้นของบล็อกคร่าว ๆ ว่าเกิดอะไรขึ้น นั่นคือปัญหา ตัวแปรที่ยังไม่ได้ initialize
น่าทึ่งที่ยังมีภาษาที่ยอมให้ปล่อยตัวแปรไว้โดยไม่ initialize ได้ เรื่องนี้สร้างบั๊กนับไม่ถ้วน รวมถึงบั๊กในระบบ production ที่ผมเคยเห็นเอง และมักต้องพึ่งพา flag ของคอมไพเลอร์เพิ่มเติม เครื่องมือวิเคราะห์แบบ static หรือ Valgrind ฯลฯ เพื่อจับมัน
ทั้งที่ภาษาสมัยใหม่กว่าเลือกทางออกอื่น เช่น ใช้ค่าเริ่มต้นเป็น 0 หรือบังคับให้ initialize ก่อนใช้งาน แต่ผู้คนก็ยังกลับไปใช้ C/C++ อยู่เรื่อย ๆ
ส่วนที่ว่า “การค้นพบทั้งหมดนี้พิสูจน์ว่าบั๊กไม่ใช่ปัญหาของ Windows 11 24H2 สิ่งอย่างวิธีที่ฟังก์ชัน WinAPI ภายในใช้สแตกไม่ใช่สัญญา และสามารถเปลี่ยนได้ทุกเมื่อโดยไม่ต้องแจ้งล่วงหน้า” ทำให้นึกถึงบทความยอดเยี่ยมที่เคยอ่าน
ใจความคือ สำหรับ API ที่ประสบความสำเร็จมากพอ จะไม่มีสิ่งที่เรียกว่า API ส่วนตัว อยู่จริง