1 điểm bởi GN⁺ 2025-04-24 | 1 bình luận | Chia sẻ qua WhatsApp
  • Trên Windows 11 24H2, đã tái hiện được hiện tượng thủy phi cơ Skimmer biến mất hoặc người chơi bị bắn vọt lên bầu trời ở độ cao bất thường ngay sau khi spawn; nguyên nhân không phải do hệ điều hành mà là một lỗi xử lý dữ liệu cũ bên trong trò chơi
  • Dòng Skimmer trong vehicles.ide bị thiếu 2 giá trị tỷ lệ bánh xe cần cho máy bay, nhưng CFileLoader::LoadVehicleObject không kiểm tra giá trị trả về của sscanf, nên đã dùng nguyên các biến cục bộ chưa được khởi tạo
  • Trên các môi trường Windows cũ, giá trị tỷ lệ bánh xe 0.7 của xe TopFun ngay trước đó tình cờ còn sót lại trên stack nên Skimmer trông có vẻ hoạt động bình thường, nhưng trên Windows 11 24H2, mức sử dụng stack của LeaveCriticalSection thay đổi đã phá vỡ sự ngẫu nhiên đó
  • Tỷ lệ bánh xe sai đã làm nhiễm bẩn phép tính hệ treo và tọa độ Z của hộp va chạm, rồi lan sang cả độ cao khi tạo spawn và phép tính tốc độ cánh quạt, dẫn đến vị trí camera bất thường, hiệu ứng burn-in, và vòng lặp treo trong môi trường SilentPatch
  • Cách khắc phục là thêm -1, 0.7, 0.7, -1 vào dòng Skimmer trong vehicles.ide hoặc áp dụng hotfix SilentPatch tiếp theo; kiểm tra dữ liệu đầu vào và quản lý cảnh báo biên dịch ảnh hưởng trực tiếp tới khả năng tương thích lâu dài

Triệu chứng của Skimmer lộ ra trên Windows 11 24H2

  • Trên issue tracker của SilentPatch đã có báo cáo rằng sau khi cập nhật lên Windows 11 24H2, máy bay Skimmer biến mất hoàn toàn khỏi game
    • Không thể spawn bằng trainer và cũng không tìm thấy ở vị trí spawn gốc
    • Hiện tượng tái hiện được cả trên bản game có mod lẫn bản sao vanilla chỉ cài SilentPatch
  • Trên GTAForums cũng đã có báo cáo về cùng vấn đề này từ tháng 11/2024, và một số người dùng nghi ngờ SilentPatch, nhưng hiện tượng tương tự cũng xuất hiện trên game hoàn toàn không mod
  • Trên Windows 10 22H2 và Windows 11 23H2, Skimmer spawn bình thường, còn người dùng Windows 11 24H2 thì gặp cùng lỗi này
  • Kết quả debug từ xa trên máy ảo 24H2 cho thấy các máy bay và thuyền khác đều bình thường, chỉ riêng Skimmer là biến mất

Độ cao bất thường và vòng lặp cánh quạt không bao giờ kết thúc

  • Khi dùng script để ép tạo Skimmer và cho CJ lên ngồi, người chơi bị bắn lên độ cao 1.0287648030984853e+0031m, tức khoảng 10.3 nonillion mét
  • Nếu cài SilentPatch, game sẽ rơi vào vòng lặp và treo ngay sau khi hất người chơi lên cao
  • Nếu không có SilentPatch, game không treo nhưng xuất hiện hiệu ứng burn-in nổi tiếng khi camera di chuyển tới vị trí gần như vô cực
  • Điểm treo nằm ở vòng lặp chuẩn hóa góc cánh quạt trong CPlane::PreRender
    • Giá trị m_fBladeSpeed tăng tới 3.73340132e+29
    • Dù liên tục trừ 6.2831855, giá trị vẫn không đổi theo biểu diễn số thực dấu phẩy động nên vòng lặp không thể kết thúc
  • Vì tốc độ cánh quạt được suy ra từ một giá trị tỷ lệ với độ cao máy bay, đây là manh mối cho thấy Skimmer ngay từ đầu đã được tạo ở vị trí cao bất thường

Phép tính hệ treo làm nhiễm bẩn hộp va chạm

  • Hàm tạo bằng script CCarCtrl::CreateCarForScript cộng kết quả của GetDistanceFromCentreOfMassToBaseOfModel vào tọa độ Z được truyền vào
  • Khi kiểm tra hộp va chạm của Skimmer, bbox.sup.z đã bị nhiễm bẩn thành một giá trị vô lý như -4.30747210e+33
  • Theo dõi bằng data breakpoint cho thấy giá trị hộp va chạm ở thời điểm load ban đầu là bình thường
    • bbox.sup.z ban đầu là -2.21952772
    • Sau đó, khi xe được spawn lần đầu, SetupSuspensionLines cập nhật tọa độ Z của hộp va chạm để phản ánh chiều cao hệ treo
  • Vấn đề nằm ở một trong các giá trị đầu vào dùng cho phép tính đường hệ treo
    • Phép tính sử dụng giới hạn trên/dưới của hệ treo trong handling.cfg và tỷ lệ bánh xe trong vehicles.ide
    • Các giá trị của Skimmer trong handling.cfg không khác đáng kể so với các máy bay khác

Dòng vehicles.ide quá ngắn của Skimmer

  • Định nghĩa Skimmer trong vehicles.ide ngắn hơn các máy bay khác và bị thiếu 4 tham số cuối
  • Trong các giá trị bị thiếu có 2 giá trị là tỷ lệ bánh trước và bánh sau
  • Với thuyền thì không có các giá trị này cũng không sao, nhưng Skimmer là chiếc máy bay duy nhất bỏ qua các tham số đó
  • Có vẻ Skimmer ban đầu được định nghĩa là thuyền trong Vice City rồi được đổi thành máy bay trong San Andreas, nhưng các tham số mới cần thiết đã không được thêm vào
  • Nếu chèn lại các tham số bị thiếu, Skimmer sẽ hoạt động bình thường

Loader không kiểm tra giá trị trả về của sscanf

  • CFileLoader::LoadVehicleObject phân tích một dòng trong vehicles.ide bằng sscanf và giả định rằng mọi tham số luôn tồn tại
  • Hàm này không kiểm tra giá trị trả về của sscanf, và cũng không gán giá trị mặc định cho hầu hết các tham số cuối
    • wheelModelID không được khởi tạo
    • frontWheelScale, rearWheelScale cũng không được khởi tạo
    • Chỉ có wheelUpgradeClass được khởi tạo thành -1
  • Với những dòng thiếu giá trị như Skimmer, các biến tỷ lệ bánh xe sẽ giữ nguyên trạng thái chưa khởi tạo và giá trị đó lan sang dữ liệu phương tiện
  • Bản sửa của SilentPatch bọc lời gọi sscanf và cung cấp giá trị mặc định cho 4 giá trị cuối
    • wheelModelID = -1
    • frontWheelSize = 0.7f
    • rearWheelSize = 0.7f
    • wheelUpgradeClass = -1
  • Commit sửa lỗi đã được đưa vào kho lưu trữ SilentPatch

Vì sao lỗi này ẩn suốt 20 năm

  • San Andreas sử dụng CRT được biên dịch tĩnh, nên không phải hotfix ở mức CRT của Windows đã làm thay đổi hành vi của sscanf
  • Trên Windows 10, tại vị trí biến cục bộ ngay trước lúc parse Skimmer vẫn còn sót lại giá trị 0.7
    • Giá trị này trùng với tỷ lệ bánh xe của TopFun được định nghĩa ngay trước Skimmer
    • Dòng TopFun chứa -1, 0.7, 0.7, -1
  • vehicles.ide được đọc theo thứ tự và mỗi dòng sẽ gọi LoadVehicleObject
  • Trên Windows 10, vị trí stack đó không bị ghi đè giữa các lần gọi LoadVehicleObject, nên Skimmer đã tình cờ thừa hưởng tỷ lệ bánh xe của TopFun
  • Trên Windows 11 24H2, trong quá trình đọc dòng kế tiếp, LeaveCriticalSection bên trong fgets dùng nhiều không gian stack hơn, khiến giá trị còn sót lại bị ghi đè

Windows 11 24H2 chỉ là tác nhân kích hoạt

  • Cách các hàm WinAPI nội bộ sử dụng stack không phải là hành vi được cam kết và có thể thay đổi mà không cần báo trước
  • Windows 11 24H2 chỉ đơn giản là xóa đi giá trị stack còn sót lại mà game đã vô tình dựa vào; nguyên nhân thực sự là hành vi không xác định của chính game
  • Ngay cả trên Windows 10, biến cục bộ ngay sau tỷ lệ bánh xe đã bị LeaveCriticalSection ghi đè, và game thực ra đã ở trong trạng thái có thể gặp lỗi này từ nhiều năm trước
  • Vì San Andreas còn hỗ trợ cả Windows 98, lỗi này đã vô tình không lộ ra trên ít nhất hơn 10 phiên bản Windows và nhiều bản phát hành Wine khác nhau
  • Bản vá PC chính thức 1.01 không sửa lỗi này, nhưng bản phát hành Xbox gốc đã có chỉnh sửa gán giá trị mặc định 1.0
    • Steam 3.0, newsteam, RGL đều dựa trên nhánh mã Xbox nên đã thừa hưởng bản sửa này
    • Các bản Android của War Drum Studios, X360, PS3 và Definitive Edition cũng không bị ảnh hưởng

Vì sao SilentPatch chọn 0.7 làm giá trị mặc định

  • SilentPatch dùng tỷ lệ bánh xe mặc định 0.7 thay vì 1.0 như bản sửa Xbox của Rockstar
  • Có ba lý do cho lựa chọn này
    • Trên bản PC, từ trước đến nay Skimmer thực tế vẫn luôn hoạt động với tỷ lệ bánh xe 0.7 của TopFun
    • Sea Sparrow và Vortex, hai phương tiện không phải thuyền nhưng nổi trên mặt nước, cũng có tỷ lệ bánh xe 0.7
    • Nhiều ô tô trong game cũng dùng tỷ lệ bánh xe 0.7

Cách tự sửa

  • Bản sửa mã sẽ được đưa vào hotfix SilentPatch tiếp theo
  • Nếu muốn sửa ngay, hãy mở data\vehicles.ide trong thư mục San Andreas bằng Notepad và thay thế dòng bắt đầu bằng 460, skimmer
  • Dòng cần thay thế là như sau
460, 	skimmer,	skimmer, 	plane,		SEAPLANE,	SKIMMER,	null,	ignore,		5,	0,	0,		-1, 0.7, 0.7,		-1

Bài học từ khả năng tương thích của game cũ

  • Đây là một lỗi đơn giản của San Andreas, và hàm liên quan vốn dĩ là đoạn mã không thể hoạt động đúng theo thiết kế ban đầu
  • Ngay cả thay đổi về bố cục stack trong triển khai nội bộ cũng có thể dẫn đến vấn đề tương thích nếu ứng dụng có lỗi vô tình phụ thuộc vào một hành vi cụ thể
  • Một ví dụ tương tự là Bully: Scholarship Edition từng bị lỗi trên Windows 10 vì dựa vào các giả định sai và chỉ lộ vấn đề khi hệ điều hành thay đổi
  • Vấn đề gốc của San Andreas là thiếu kiểm tra dữ liệu đầu vào, nên không thể loại bỏ các dòng cấu hình không đầy đủ
  • Đoạn mã này nhiều khả năng vốn đã phát sinh cảnh báo biên dịch, và việc bỏ qua hoặc tắt cảnh báo có thể khiến những lỗi ẩn suốt thời gian dài trở thành vấn đề thực tế với người dùng

1 bình luận

 
GN⁺ 2025-04-24
Ý kiến trên Hacker News
  • Bài viết kiểu này đạt mức mà người ta chỉ kỳ vọng ở Raymond Chen, và đó là một lời khen rất lớn.
    Thật vui khi tác giả còn đào sâu để chỉ ra chính xác vì sao lại như vậy.

  • Cá nhân tôi nghĩ rằng nếu đó là hành vi không nằm trong hợp đồng thì nên ngẫu nhiên hóa nó.
    Ví dụ, nếu một ngôn ngữ không bảo đảm thứ tự duyệt map, thì nên cố ý ngẫu nhiên hóa thứ tự đó.
    Nếu không, sẽ sinh ra loại mã mong manh “chạy ổn cho đến một ngày nào đó thì vỡ”.

    • Có nhiều tùy chọn trình biên dịch, như -ftrivial-auto-var-init, để khởi tạo biến chưa được khởi tạo bằng một giá trị cụ thể hoặc giá trị ngẫu nhiên.
      Nhưng nếu ngẫu nhiên hóa hoặc điền 0 vào toàn bộ nội dung stack ở mỗi lần gọi hàm thì hiệu năng sẽ tụt thảm hại, nên thường không làm vậy.
    • Ngẫu nhiên hóa ở mức này quá tốn kém.
      Có những công cụ làm việc này cho mục đích debug, nhưng ở chế độ đó chương trình chạy chậm hơn rất nhiều.
    • Nhìn từ góc độ hợp đồng, trong bài gốc cũng có bài học này: “Đây là một bài học thú vị về tính tương thích. Nếu ứng dụng có bug và vô tình phụ thuộc vào một hành vi cụ thể, thì ngay cả việc thay đổi bố cục stack của phần triển khai nội bộ cũng có thể tạo ra ảnh hưởng về tương thích.”
      Có lẽ đây cũng là lý do các maintainer kernel Linux luôn khăng khăng không bao giờ được phá vỡ user space.
    • Không phải vậy. Cần nhớ https://www.hyrumslaw.com/.
      Khi có đủ nhiều người dùng API, việc hợp đồng hứa hẹn điều gì không còn quan trọng; sẽ có ai đó phụ thuộc vào mọi hành vi quan sát được của hệ thống.
      Nếu bạn hứa ngẫu nhiên hóa, sẽ có người phụ thuộc cả vào sự ngẫu nhiên hóa đó.
      Khi ấy bạn cũng sẽ không thể loại bỏ nó mãi mãi.
    • Có thể xem một trong những ưu điểm của các ngôn ngữ như C là bạn chỉ trả chi phí cho những tính năng mình chọn dùng.
      Bạn không bị buộc phải trả overhead không cần thiết như khởi tạo những biến không dùng đến.
  • Ở phần “đừng bỏ qua cảnh báo biên dịch”, tôi không rõ ở đây có thể kỳ vọng lỗi biên dịch nào.
    Có phải chỉ là việc không kiểm tra xem giá trị trả về của scanf có khớp với số lượng đối số hay không? Ngoài ra thì trông giống lỗi file dữ liệu mà trình biên dịch không thể biết được.

    • Thử với g++ 11.4 thì không có cảnh báo mặc định nào dù không kiểm tra giá trị trả về của sscanf.
      Trong ví dụ nhỏ, thêm g++ -Wall -Wextra -Wunused-result cũng không thấy cảnh báo.
    • Truy cập bộ nhớ chưa được khởi tạo là hành vi không xác định, nên sanitizer lẽ ra đã bắt được.
    • Ý hay. Khi đọc, tôi cũng mơ hồ nghĩ rằng cảnh báo “dùng bộ nhớ chưa được khởi tạo” sẽ bắt được lỗi này.
      Nhưng vì cả dòng được parse bằng một lần gọi sscanf duy nhất, phân tích tĩnh của trình biên dịch buộc phải giả định rằng các giá trị giờ đã được khởi tạo.
      Có vẻ không có phương pháp phân tích tĩnh tổng quát nào để bắt bug này.
      Tuy vậy, có thể tạo cảnh báo riêng cho scanf, buộc phải truyền vào giá trị đã được khởi tạo trước hoặc phải kiểm tra giá trị trả về.
  • Đọc những bài phân tích kỹ thuật sâu như thế này lúc nào cũng thú vị.
    Tôi tò mò không biết trong thời đại AI, những bài như vậy sẽ trở nên hiếm hơn hay không.

    • Tôi không nghĩ chúng sẽ hiếm hơn. Sẽ luôn có những kỹ sư hàng đầu thích đào sâu.
      AI sẽ không thay thế họ, cũng như hơn 50 năm đổi mới trong phát triển phần mềm vừa qua đã không làm được điều đó.
      Hàng triệu, có lẽ hàng chục triệu lập trình viên ngôn ngữ bậc cao chỉ biết khác biệt giữa stack và heap như một lý thuyết mơ hồ từng học ở trường, và vì công việc hằng ngày không cần bận tâm nên họ cũng không quan tâm.
    • Kỹ sư phần mềm thông thường có thể dịch chuyển từ kiểu nghệ nhân sang gần với nghề kỹ thuật hơn, nhưng những bài như thế này dường như tự thân xuất phát từ phong cách nghệ nhân.
  • Tôi tò mò hơn là trong phiên bản Windows này, cách triển khai khóa/mở khóa critical section đã thay đổi điều gì.

    • Có vẻ kích thước stack được dùng hoặc vùng bảo vệ stack đã tăng lên.
  • Có phải chỉ mình tôi thấy đoạn mã này khó chịu không?
    while (this->m_fBladeAngle > 6.2831855) { this->m_fBladeAngle = this->m_fBladeAngle - 6.2831855; }
    Cảm giác như họ dùng vòng lặp while có thể thành vòng lặp vô hạn chỉ vì lười làm phép chia.

    • Tôi muốn tin rằng các lập trình viên GTA dùng mẹo này vì trong môi trường như PlayStation 2 nó nhanh hơn phép chia số thực dấu phẩy động.
      Nhưng xét việc họ từng parse JSON bằng sscanf khiến thời gian tải GTA5 tăng thêm 5 phút, tôi không kỳ vọng nhiều.
    • Tôi nghĩ khả năng cao là vì hiệu năng. Phép trừ rẻ hơn phép chia số thực dấu phẩy động.
      Trình biên dịch cũng có thể có kỹ thuật tối ưu hóa việc này tốt hơn.
      Thực tế gần như không có cách nào để nó thành vòng lặp vô hạn. Underflow thì có thể, nhưng để vậy thì góc vốn đã phải nhỏ hơn 2*pi, nên vòng lặp sẽ thoát.
    • Khả năng thấp, nhưng nếu giá trị nhỏ thì vòng lặp này cũng có thể nhanh hơn phép chia.
    • Đúng là vậy. Có vẻ tác giả hoàn toàn không biết đến fmod.
  • Ai gặp vấn đề truy cập thì có thể dùng liên kết này.
    https://web.archive.org/web/20250423144746/https://cookieplm...

  • Vì biết C/C++, ngay từ đầu blog tôi đã đoán đại khái chuyện gì đang xảy ra, tức là vấn đề biến chưa được khởi tạo.
    Thật đáng kinh ngạc khi có một ngôn ngữ cho phép để biến ở trạng thái chưa khởi tạo. Điều này đã gây ra vô số bug, gồm cả những bug production tôi từng trực tiếp thấy, và để bắt chúng thường phải dựa vào cờ biên dịch bổ sung, công cụ phân tích tĩnh, Valgrind, v.v.
    Dù các ngôn ngữ hiện đại hơn chọn các cách giải khác, như dùng giá trị 0 mặc định hoặc bắt buộc khởi tạo trước khi dùng, người ta vẫn tiếp tục quay lại C/C++.

  • Đoạn “Tất cả những phát hiện này chứng minh bug không phải là vấn đề của Windows 11 24H2. Những thứ như cách các hàm WinAPI nội bộ sử dụng stack không phải là hợp đồng và có thể thay đổi bất cứ lúc nào mà không cần báo trước” làm tôi nhớ đến một bài viết tuyệt vời từng đọc.
    Đại ý là với một API đủ thành công thì không tồn tại thứ gọi là API không công khai.

    • Nếu bạn tìm được bài đó và gửi link thì tốt quá. Tôi tò mò về lập luận của nó.
    • Tôi nhớ là có một truyện tranh XKCD liên quan đến chuyện này.