1 คะแนน โดย GN⁺ 2025-04-24 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • ใน 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 ความคิดเห็น

 
GN⁺ 2025-04-24
ความคิดเห็นบน Hacker News
  • บทความแบบนี้เป็นระดับที่คาดหวังได้จาก Raymond Chen เท่านั้น และนั่นเป็นคำชมอย่างยิ่ง
    ดีใจที่ผู้เขียนขุดลึกลงไปจนระบุได้ว่าเป็นเพราะอะไรกันแน่

  • โดยส่วนตัวคิดว่า ถ้าเป็น พฤติกรรมที่ไม่ได้อยู่ในสัญญา ก็ควรถูกสุ่ม
    เช่น ถ้าภาษาไม่ได้รับประกันลำดับการวนผ่านแมป ก็ควรจงใจสุ่มลำดับไปเลย
    ไม่อย่างนั้นจะเกิดโค้ดเปราะบางแบบ “ใช้ได้ดีจนกระทั่งวันหนึ่งมันพัง”

    • มีตัวเลือกคอมไพเลอร์หลายอย่าง เช่น -ftrivial-auto-var-init ที่เริ่มต้นค่าตัวแปรที่ยังไม่ได้ initialize ให้เป็นค่าหนึ่งหรือค่าสุ่ม
      แต่ถ้าสุ่มหรือเติมศูนย์ให้กับ เนื้อหาทั้งหมดของสแตก ทุกครั้งที่เรียกฟังก์ชัน ประสิทธิภาพจะตกอย่างหนัก จึงมักไม่ทำกัน
    • การสุ่ม ในระดับนี้มีต้นทุนสูงเกินไป
      มีเครื่องมือที่ทำแบบนี้เพื่อการดีบัก แต่ในโหมดนั้นโปรแกรมจะทำงานช้าลงมาก
    • ถ้ามองในแง่สัญญา ก็มีบทเรียนนี้จากบทความต้นฉบับด้วย: “นี่เป็นบทเรียนที่น่าสนใจเรื่องความเข้ากันได้ หากแอปพลิเคชันมีบั๊กและไปพึ่งพาพฤติกรรมบางอย่างโดยไม่ตั้งใจ แม้แต่การเปลี่ยน การจัดวางสแตก ของการ implement ภายในก็อาจส่งผลต่อความเข้ากันได้ได้”
      บางทีนี่อาจเป็นเหตุผลที่ผู้ดูแลเคอร์เนล Linux ยืนกรานว่าอย่าทำให้ user space พังเด็ดขาด
    • ไม่ใช่ ต้องจำ https://www.hyrumslaw.com/ ไว้
      เมื่อมีผู้ใช้ API มากพอ ไม่ว่าสัญญาจะสัญญาอะไรไว้ก็ไม่สำคัญ เพราะจะมีใครสักคนพึ่งพาพฤติกรรมทุกอย่างของระบบที่สังเกตได้
      ถ้าสัญญาว่าจะสุ่ม ก็จะมีใครสักคนพึ่งพาการสุ่มนั้นด้วย
      แล้วสุดท้ายก็จะเอามันออกไม่ได้ตลอดไป
    • อาจมองได้ว่าหนึ่งในข้อดีของภาษาอย่าง C คือจ่ายต้นทุนเฉพาะฟีเจอร์ที่เลือกใช้เท่านั้น
      ไม่ต้องถูกบังคับให้จ่าย โอเวอร์เฮดที่ไม่จำเป็น อย่างการ initialize ตัวแปรที่ไม่ได้ใช้
  • ตรงส่วนที่ว่า “อย่าเพิกเฉยต่อคำเตือนของคอมไพเลอร์” ผมไม่แน่ใจว่าคาดหวังให้คอมไพเลอร์แจ้งข้อผิดพลาดอะไรในกรณีนี้
    อาจเป็นแค่การไม่ตรวจสอบว่าค่าที่ scanf คืนมาตรงกับจำนวนอาร์กิวเมนต์หรือเปล่า? นอกนั้นดูเหมือนเป็น ข้อผิดพลาดในไฟล์ข้อมูล ที่คอมไพเลอร์รู้ไม่ได้

    • ลองด้วย g++ 11.4 แล้ว ต่อให้ไม่ตรวจสอบค่าที่ sscanf คืนมา ก็ไม่มีคำเตือนตามค่าเริ่มต้น
      ในตัวอย่างเล็ก ๆ แม้ใส่ g++ -Wall -Wextra -Wunused-result ก็ยังไม่มีคำเตือน
    • การเข้าถึงหน่วยความจำที่ยังไม่ได้ initialize เป็น พฤติกรรมที่ไม่ได้นิยาม ดังนั้น sanitizer น่าจะจับได้
    • ประเด็นดี ตอนอ่านก็คิดกว้าง ๆ ว่าคำเตือน “ใช้หน่วยความจำที่ยังไม่ได้ initialize” น่าจะจับกรณีนี้ได้
      แต่เพราะพาร์สทั้งบรรทัดด้วยการเรียก 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 ที่อาจกลายเป็นลูปไม่รู้จบแทน

    • อยากเชื่อว่านักพัฒนา GTA ทำแฮ็กแบบนี้เพราะมันเร็วกว่า การหารเลขทศนิยมลอยตัว บนสภาพแวดล้อมอย่าง PlayStation 2
      แต่พอเห็นว่าพวกเขาเคยพาร์ส JSON ด้วย sscanf จนทำให้เวลาโหลด GTA5 เพิ่มขึ้น 5 นาที ก็ไม่ได้คาดหวังมากนัก
    • ผมว่ามีโอกาสสูงว่าเป็นเพราะประสิทธิภาพ การลบถูกกว่าการหารเลขทศนิยมลอยตัว
      คอมไพเลอร์ก็อาจมีเทคนิคในการปรับให้เหมาะสมกว่านี้ได้
      แทบไม่มีทางที่มันจะกลายเป็นลูปไม่รู้จบจริง ๆ underflow เป็นไปได้ แต่ถ้าเป็นอย่างนั้นมุมก็ต้องน้อยกว่า 2*pi อยู่แล้ว จึงออกจากลูป
    • ถึงโอกาสจะต่ำ แต่ถ้าค่ามีขนาดเล็ก ลูปนี้อาจเร็วกว่า division ก็ได้
    • ใช่จริง ๆ ดูเหมือนผู้เขียนไม่รู้จัก 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 ส่วนตัว อยู่จริง

    • ถ้าหาบทความนั้นแล้วส่งลิงก์ให้ได้ก็ดี อยากรู้ตรรกะของมัน
    • ผมจำได้ว่ามีการ์ตูน XKCD ที่เกี่ยวกับเรื่องนี้