Donkey Kong Country 2 và Open Bus
(jsgroth.dev)- Một số đoạn thùng xoay trong Donkey Kong Country 2 có lỗi trên trình giả lập SNES cũ ZSNES: thùng vẫn tiếp tục quay ngay cả khi đã nhả phím điều hướng, nguyên nhân là do chưa mô phỏng hành vi open bus
- Trên SNES thật, khi đọc từ địa chỉ chưa được ánh xạ, hệ thống sẽ đọc lại giá trị cuối cùng còn trên bus dữ liệu, và DKC2 phụ thuộc vào đặc tính này khi đọc $2000/$2001 ở bank $B3
- Routine gây ra vấn đề XOR hướng trước đó của thùng với hướng mới rồi thực hiện
and $2000; trên phần cứng thật, phép đọc open bus 16-bit này trả về 0x2020, dùng để xác định việc đi qua ranh giới hướng - Nếu phép đọc open bus trả về 0 như trên ZSNES, kết quả AND sẽ luôn là 0 nên nhánh kết thúc xoay không bao giờ chạy, khiến thùng tiếp tục quay cho tới khi người chơi nhấn hướng ngược lại
- Lệnh này nhiều khả năng lẽ ra phải là
and #$2000; trong một bản revision ROM, nếu đổi opcode tại $33EDAC từ0x2Dsang0x29thì game sẽ hoạt động đúng ngay cả khi không có open bus
Hiện tượng thùng xoay không dừng trong ZSNES
- Donkey Kong Country 2 có một lỗi cũ khiến thùng xoay ở một số màn chơi không hoạt động đúng trên ZSNES
- Ở hành vi bình thường, người chơi chỉ có thể điều khiển việc xoay khi đang giữ phím trái hoặc phải lúc ở trong thùng
- Trên ZSNES, chỉ cần nhấn trái hoặc phải một lần là thùng sẽ tiếp tục quay theo hướng đó; nhấn hướng ngược lại thì nó lại quay liên tục theo chiều ngược lại
- Ở các đoạn phía sau, thùng được đặt phía trên gai hoặc các chướng ngại nguy hiểm, nên lỗi này làm độ khó của màn tăng cao hơn nhiều so với ý đồ của nhà phát triển
- Có vẻ Anomie đã phát hiện cùng vấn đề này khoảng 20 năm trước và sửa trong Snes9x; khi đó bản sửa không phải là mô phỏng đầy đủ open bus mà là hard-code giá trị tại địa chỉ cụ thể mà game phụ thuộc vào
- Trong ZSNES, lỗi này cuối cùng vẫn không được sửa, và bản phát hành cuối cùng của dự án là 2007
Open bus của SNES và cơ chế định địa chỉ 65816
- Trên SNES, ngay cả khi đọc một địa chỉ bộ nhớ sai thì chương trình thường cũng không bị crash
- Khi đọc từ địa chỉ chưa được ánh xạ, CPU sẽ đọc lại giá trị cuối cùng từng xuất hiện trên bus dữ liệu; đây là hành vi open bus
- CPU chính của SNES là 65C816, tức 65816, nằm trong gói Ricoh 5A22 S-CPU
- 65816 là bản mở rộng 16-bit của 6502; trên SNES nó dùng bus địa chỉ 24-bit nhưng phần lớn địa chỉ được tạo thành từ bank 8-bit và offset 16-bit
- Nhiều lệnh nội bộ dùng địa chỉ 16-bit; instruction fetch liên quan đến program bank register, còn truy cập dữ liệu liên quan đến data bank register
- Ở trạng thái thùng xoay của DKC2, game đọc $2000 và $2001 trong bank $B3; các địa chỉ này không được ánh xạ ở đâu trong bank $B3 nên trở thành phép đọc open bus
Truy cập bộ nhớ trong routine gây lỗi
- Khi nhả phím trái hoặc phải trong lúc ở trong thùng xoay, routine chạy mỗi frame sẽ thực hiện phép đọc open bus
- Trạng thái ban đầu là data bank register bằng
$B3, direct page bằng$0000, và cờ M/X đều clear nên thanh ghi và truy cập bộ nhớ đều ở chế độ 16-bit - Routine này dùng nhiều địa chỉ trong phạm vi
$0000-$2001$0EE6: hướng hiện tại của thùng$0E0A: lượng xoay mỗi frame$0032: vị trí có vẻ là biến tạm
- Trong các bank $00-$3F và $80-$BF, vùng
$0000-$1FFFđược ánh xạ tới 8KB đầu tiên của 128KB WRAM của máy $2000-$20FFlà vùng chưa được ánh xạ, nên lệnhand $2000sẽ trở thành phép đọc open bus
Cách 0x2020 được dùng để xác định thời điểm dừng xoay
- Routine cộng lượng xoay vào hướng hiện tại để tạo ra hướng mới rồi lưu nó vào biến tạm
- Sau đó nó XOR hướng cũ với hướng mới, áp dụng
and $2000lên kết quả, rồi kiểm tra xem kết quả có bằng 0 hay không - Trên phần cứng SNES thật, phép đọc open bus 16-bit của
and $2000luôn trả về 0x2020- Mã máy của
and $2000là2D 00 20 - 65816 dùng bus dữ liệu 8-bit nên thực hiện phép đọc 16-bit bằng hai lần đọc 8-bit
- Trong trường hợp này, byte được đọc cuối cùng là high byte của địa chỉ, tức
0x20, nên cả hai lần đều trả về0x20
- Mã máy của
- Vì vậy, về mặt chức năng hành vi thực tế tương đương với
and #$2020 - Nếu kết quả AND bằng 0, game sẽ lưu nguyên hướng mới và tiếp tục xoay ở frame tiếp theo
- Nếu khác 0, game sẽ đặt lượng xoay về 0, cộng
0x1000vào hướng mới, rồi mask bằng0xE000để căn nó về hướng gần nhất là bội số của0x2000
Vì sao trên ZSNES nó quay mãi
- Giá trị hướng của thùng có vẻ dùng thang đo trong đó
0x0000là hướng xuống dưới và0x4000là hướng sang trái - Thùng được phân tích có lượng xoay theo chiều kim đồng hồ là
0x0300, còn ngược chiều kim đồng hồ là0xFD00, và cần hơn 85 frame một chút để quay đủ 360 độ - Với 60fps, thời gian này ngắn hơn 1,5 giây một chút
- Trong phép AND với
0x2020, thay đổi thực sự có ý nghĩa là bit 13, tức 0x2000 - Trong giá trị hướng, thay đổi ở
0x2000tương ứng với một bước chuyển giữa các hướng cơ bản hoặc chéo - Trên phần cứng bình thường, khi nhả phím điều hướng, thùng sẽ tiếp tục quay tới hướng cơ bản hoặc chéo kế tiếp rồi dừng, đồng thời căn chính xác vào hướng đó
- Nếu phép đọc open bus luôn trả về 0 thì kết quả AND cũng luôn là 0, khiến đoạn mã kết thúc xoay không bao giờ được thực thi
Khả năng là lỗi gõ và kết quả vá ROM
and $2000là lệnh dùng absolute addressing, và về mặt logic nhiều khả năng phải làand #$2000với immediate addressing- Trên phần cứng thật, do open bus trả về 0x2020 nên
and $2000tình cờ vẫn hoạt động đúng như mong muốn - Sự trùng hợp này phụ thuộc vào điều kiện là 6 bit thấp của lượng xoay mỗi frame luôn bằng 0, khiến
0x2020về mặt chức năng tương đương với0x2000 - Opcode sai được thực thi tại offset
$EDACcủa bank$B3; trong bản revision được phân tích, nó được ánh xạ tới$33EDACtrong ROM 4MB của game - Nếu đổi byte này từ
0x2Dsang0x29thì thùng xoay sẽ hoạt động bình thường ngay cả khi phép đọc open bus luôn trả về 0 - Vị trí chính xác trong ROM có thể khác ở các revision khác của game
- Trên gần như mọi trình giả lập SNES ngoài ZSNES đời cũ, game đều hoạt động bình thường, nên trường hợp này gần hơn với một phân tích truy vết hành vi phụ thuộc phần cứng hơn là một bản vá mang tính thực dụng
1 bình luận
Các ý kiến trên Hacker News
Tôi đã lãng phí rất nhiều thời gian trong đời vì những lỗi kiểu như khi viết hợp ngữ 6502 lại quên dấu
#trước giá trị tức thời, khiến nó truy cập bộ nhớNhư trường hợp này, cũng có nhiều khi tình cờ nó vẫn chạy trong một số tình huống. Tệ hơn nữa là phụ thuộc vào RAM chưa được khởi tạo thay vì floating bus; do đặc tính của DRAM, trên máy của tôi hay trong emulator thì lúc nào cũng chạy tốt, nhưng trên máy của người khác dùng chip DRAM khác thì có thể hỏng. Thường thì bạn sẽ phát hiện ra chuyện đó trên máy của sự kiện, 15 phút trước khi trình diễn ở demoparty, khi nó không chạy
LDA #2là “nạp số 2 vào A”, cònLDA 2là “nạp giá trị ở vị trí bộ nhớ 2 vào A”Vì Open Bus trong tiêu đề được viết hoa, ban đầu tôi bắt đầu đọc vì tưởng đó là tên riêng của một giao thức bus hay tiêu chuẩn cũ nào đó mà mình chưa từng nghe
Đọc rồi mới thấy nghĩa là bộ giải mã đường địa chỉ không kích hoạt bất kỳ thiết bị bộ nhớ nào tại địa chỉ được chỉ định
$2000, nên bus ở trạng thái “mở”, không nối với thứ gì. Khá buồn cười là lỗi quên#của chế độ địa chỉ tức thời chỉ lộ ra khi emulator cũ không xử lý việc đọc bộ nhớ giống phần cứng thật. Nếu sửa bằng cách dùng địa chỉ tức thời thay vì địa chỉ tuyệt đối thì sẽ không đọc bộ nhớ nữa, nên thời gian thực thi cũng sẽ nhanh hơn. Trong khối mã đó có thể nhanh hơn khoảng 2µs, nhưng có lẽ chỉ có ý nghĩa trên bare metal, còn emulator thì nhiều khả năng dù sao cũng không đạt độ chính xác timing hoàn toànTôi biết Donkey Kong 64 có rò rỉ bộ nhớ, khiến game chết sau 8–9 giờ chơi liên tục, một khoảng thời gian phi thực tế theo tiêu chuẩn lúc đó. Trong quá trình phát triển không bắt được, nhưng nếu dùng save state của emulator và tiếp tục chơi thay vì dùng chức năng lưu trong game thì thời gian đó tích lũy khá nhanh. Tuy nhiên lịch sử chuyện này khá mơ hồ. Một số tài liệu cho rằng việc bán kèm Memory Pak là biện pháp phút chót để che bug, bằng cách đẩy thời gian crash từ 8–9 giờ lên 13–20 giờ, nhưng nghiên cứu gần đây có vẻ nghiêng về khả năng đó chỉ là trùng hợp và Rare hay Nintendo không phát hành game khi đã biết bug đó
[0] https://arstechnica.com/gaming/2021/06/how-snes-emulators-go...
Khi làm tính năng RunAhead của RetroArch và kiểm tra thời điểm save state không khớp, tôi từng thấy SNES Puyo Puyo dùng PPU open bus
Sau khi load state, giá trị đọc từ PPU Open Bus thay đổi, nên log trace thực thi CPU không khớp
Không phải lúc nào tôi cũng mắc lỗi kiểu họ 6502, nhưng khi mắc thì thường là lỗi dùng địa chỉ bộ nhớ thay vì giá trị tức thời
Đây là lỗi rất phổ biến và dễ mắc, và tôi tin rằng chính Chuck Peddle cũng đã rất hối hận về cú pháp thêm
#cho giá trị tức thời như#$1234. Tôi đặt cho#hiển thị màu đỏ tươi trong IDE, và điều đó cũng giúp được phần nào. Ngay cả các vị thần hợp ngữ của Rare cũng dính vấn đề nàyintel_syntax noprefixcủa GNU assemblerVới các lệnh có thể nhận cả giá trị tức thời lẫn địa chỉ bộ nhớ, có một mơ hồ cú pháp khiến một hằng số tức thời có tên được tham chiếu phía trước có thể bị diễn giải thành tham chiếu symbol chưa biết. Kết quả là nó được assemble thành một lệnh mang địa chỉ bộ nhớ placeholder, nơi sẽ được điền bằng địa chỉ relocation của symbol khi link, chứ không phải giá trị tức thời như mong đợi. Debug rất khổ
Tôi thích những bài kiểu này. Với mã hợp ngữ, tôi luôn có cảm giác mình chỉ theo được khoảng 60%, nên phần giải thích bằng văn xuôi bên cạnh thật sự rất hữu ích
Nghe chuyện về các bug trong phần mềm cổ điển mà có thể chẳng ai hiểu, hoặc thậm chí đến giờ vẫn chưa ai nhận ra, cũng rất thú vị
Ý tôi là những cơ chế vốn là bắt buộc với hệ thống có thể kết nối mạng, và nay đã rẻ đến mức cũng có thể đưa vào cả các kiến trúc nhúng hoàn toàn biệt lập. Trên NES gốc, nhiều thao tác đọc và ghi thực chất chỉ là toggle điện áp trên một đường dây nào đó, rồi mọi thứ tiếp theo cứ xảy ra theo cách của nó. Hiệu ứng mong muốn đạt được bằng cách toggle điện áp theo cách rất có kiểm soát, khớp chính xác với tín hiệu biểu thị khoảng blanking của CRT. Một số animation trong Super Mario Bros 3 đã toggle bộ multiplex RAM để chọn một trong nhiều bank dữ liệu sprite, khiến phần cứng đồ họa khi lấy sprite sẽ đọc từ một con chip hoàn toàn khác có ngoại hình hơi khác. Vì timing của TV rất quan trọng, phần mềm cho các vùng TV NTSC và PAL cũng phải phát hành riêng; hai hệ này có tần số quét khác nhau, và tần số quét đó chính là clock điều khiển logic render. Đúng là một thời kỳ hoang dã
Theo tôi biết, open bus chỉ thấy ở các hệ thống thời kỳ đầu dùng bus đồng bộ đơn giản
Tôi hiểu rằng hầu hết các hệ thống khác, khi truy cập địa chỉ không tồn tại, sẽ trả về một giá trị hằng toàn 0 hoặc toàn 1. Đó là vì giao thức bus có handshake để master biết rằng không có phản hồi; theo thuật ngữ PCI thì tương ứng với “master abort”
Open bus đúng như tên gọi, nghĩa là các đường bus dữ liệu là mạch hở
CPU đưa lên bus địa chỉ một địa chỉ chưa được ánh xạ hoặc chỉ cho ghi, và vì không có phần cứng nào trên bus phản hồi nên các đường bus không được điều khiển và rơi vào trạng thái lơ lửng. Về danh nghĩa, đây là hành vi không xác định ở mức phần cứng
Để hiểu thực tế chuyện gì xảy ra, cần nhìn kỹ hơn một chút vào cấu trúc vật lý của bus dữ liệu. Có những dây dẫn dài mang tín hiệu quanh bo mạch chủ và cartridge, được tách khỏi mặt phẳng mass bởi một lớp nền cách điện mỏng. Nó trông giống như một tụ điện, và trên thực tế các kỹ sư mô tả cũng như mô hình hóa nó là điện dung ký sinh. Hiệu ứng này giới hạn tốc độ truyền dữ liệu tối đa của bus nên người ta cố giảm thiểu nó. Nhưng chính vì hiệu ứng này, khi bus không được điều khiển, nó có xu hướng giữ lại mức điện áp được điều khiển gần nhất. Nó hoạt động giống một ô DRAM nhỏ, tạo ra hiệu ứng mà bài viết nói là “đọc open bus trả về giá trị cuối cùng đi qua bus”
Việc game vô tình phụ thuộc vào hiệu ứng open bus như DKC2 không phải là hiếm. Trên NES, thanh ghi cổng nối tiếp dùng để kết nối tay cầm chỉ điều khiển các bit thấp, còn các bit cao là open bus. Cũng có vài game đọc input tay cầm bằng lệnh
LDA $4016và kỳ vọng giá trị$40hoặc$41. Ở đây số 4 là giá trị còn sót lại do open busCũng có các chiến thuật speedrun dựa vào hành vi open bus như một phần của khai thác làm hỏng bộ nhớ hoặc thực thi mã tùy ý. Ví dụ, credit warp trong Super Mario World đưa program counter vào vùng bộ nhớ chưa ánh xạ để nó chạy lang thang một lúc rồi cuối cùng đến RAM, sau đó thực thi payload được tạo bằng cách thao tác vị trí kẻ địch thật tinh vi [1]
Tuy vậy, ngay cả với hành vi open bus thường có thể dự đoán được cũng có ngoại lệ. Cartridge không chuẩn có thể trả về giá trị mặc định cho bộ nhớ chưa ánh xạ, hoặc có thể chứa điện trở kéo lên/kéo xuống làm ảnh hưởng đến hành vi open bus. Cũng có tương tác thú vị với DMA. SNES hỗ trợ một tính năng gọi là HDMA, cho phép ứng dụng đặt lịch truyền dữ liệu từ CPU sang phần cứng đồ họa với thời điểm chính xác để upload dữ liệu hoặc thay đổi thiết lập giữa khung hình [2]. Việc truyền DMA này tạm dừng CPU để dùng bus cho quá trình truyền, và nếu truyền DMA chen vào giữa lúc thực thi lệnh, tức là sau khi đã đọc địa chỉ đích nhưng trước lần đọc open bus thực sự, nó có thể làm thay đổi hành vi đọc open bus
Trường hợp biên cực kỳ đặc thù này có ảnh hưởng lớn đến exploit speedrun của Super Metroid [3]. Exploit này gây ra một
memcpyvượt phạm vi, nhằm chuyển một khối dữ liệu lớn từ open bus vào RAM. Việc đọc open bus gần như luôn trả về 0, vì byte cuối của lệnh load liên quan là 0. Nhưng trong một số phòng nhất định có nhiều hiệu ứng đồ họa HDMA, có khả năng khá cao là một lần truyền DMA sẽ ảnh hưởng đến một trong các lần đọc đó; khi ấy một byte khác 0 sẽ chen vào vị trí quan trọng, khiến exploit không chạy đúng và bị crash. Vì vậy đã có một cuộc tranh luận nhẹ trong cộng đồng. Một số route và chiến thuật chỉ ổn định trên emulator và firmware không chuẩn. Người chơi dùng phần cứng gốc hoặc emulator rất chính xác có khả năng cao gặp crash, nhưng phần lớn emulator, bao gồm tất cả các bản tái phát hành chính thức của Nintendo, không giả lập trường hợp biên đặc thù trong đó truyền HDMA giữa chừng một lệnh làm thay đổi giá trị đọc open busNgoài ra, lượt hoàn thành TAS Super Metroid nhanh nhất hiện nay [4] cũng dựa vào tương tác HDMA này. Người ta tìm thấy một tình huống cố thực thi open bus dẫn đến crash, nhưng thông thường không thể điều khiển nó theo cách hữu ích. Bằng cách thao tác kẻ địch trong phòng để ảnh hưởng đến timing của CPU, họ có thể dùng HDMA đưa một lệnh hữu ích lên bus đúng thời điểm. Cuối cùng, họ khiến console thực thi input tay cầm như mã, đạt được thực thi mã tùy ý hoàn toàn
[1]: https://youtu.be/vAHXK2wut_I
[2]: https://youtu.be/K7gWmdgXPgk
[3]: https://youtu.be/CnThmKhtfOs
[4]: https://tasvideos.org/8214S
Nhờ loạt video của anh ấy về việc làm một máy tính breadboard với 6502, tôi mới thực sự hiểu được nội dung bài viết và phần giải thích vấn đề phần cứng. Tất nhiên, đó là mở rộng ví dụ bus cơ bản của anh ấy sang một máy thương mại. Nếu không thì có lẽ tôi gần như chẳng biết gì
https://eater.net
Khi lập trình chip Parallax Propeller cũng có vấn đề phần nào tương tự
Đáng ra phải dùng
JMP #addressđể nhảy đến vị trí bộ nhớ được chỉ định, nhưng tôi cứ dùngJMP address, tức là nhảy đến địa chỉ đọc được từ vị trí bộ nhớ được chỉ định. Có lẽ vì assembler 6502 vẫn còn nằm trong trí nhớ cơ bắpPropeller:
JMP #address6502:
JMP addressPropeller:
JMP address6502:
JMP (address)Tệ nhất là, giống như bài này, code Propeller có bug đôi khi vẫn chạy. Rồi đến một lúc nào đó nó dừng lại, và bạn mất hàng giờ để tìm hiểu vì sao
Đồ họa 3D render sẵn bằng SGI của DKC 1 là công nghệ tối tân vào thời đó. Vector Man trên Genesis cũng làm điều tương tự nhưng ít được chú ý hơn
Tôi không thể tin vào thứ mình đang nhìn thấy. Gần thời điểm phát hành có một cuộn băng video giới thiệu game và cho xem cả hậu trường phát triển; tôi nhớ có lẽ đó là tài liệu quảng bá nhận được sau khi đăng ký từ đâu đó như hộp ngũ cốc. Tôi đã xem cuộn băng đó rất nhiều lần. Tôi không sở hữu DKC, nhưng có thể chơi ở nhà bạn
Các tạp chí thời đó nói khá mơ hồ về chủ đề này, thường ngụ ý như thể sức mạnh của SNES đang render nhân vật và những thứ tương tự theo thời gian thực. Trong khi thực tế về bản chất nó gần với hoạt hình flipbook hơn
Khi chơi game bằng trình giả lập mà bị kẹt, cuối cùng người ta thường tự hỏi liệu đó có phải là lỗi trình giả lập không
Nếu là vấn đề cụ thể này thì hẳn tôi đã nghĩ rằng trò chơi vốn được thiết kế như vậy và đơn giản là khó. Không hẳn liên quan trực tiếp, nhưng khi một trò chơi cảm thấy thật sự khó, tôi cũng nghĩ tương tự: “Có phải do độ trễ giả lập không?” Đào sâu vào vấn đề này, cuối cùng tôi đã tự làm một MiSTer FPGA
Tôi nhớ có một đoạn sau khi bắt con chuột, phải nhập đồng thời bốn phím. Nhưng tín hiệu nhập qua USB chỉ truyền được 3 phím cùng lúc, nên để vượt qua thì phải bấm loạn bốn phím để rốt cuộc tất cả đều được ghi nhận trong một khoảng thời gian rất ngắn. Phải thử nhiều lần và cực kỳ bực bội
Như đã nói, tôi cứ nghĩ việc căn thời điểm bắn ra từ thùng để tạo đúng góc là thiết kế có chủ đích của game. Biết rằng đó là lỗi khiến tôi thật sự ngạc nhiên
Đến đầu những năm 2000, khi chạy lại bằng trình giả lập, nó khó hơn ký ức của tôi rất nhiều. Sau này mới biết có một lỗi giả lập khiến kẻ địch không biến mất dù đã cho nổ tung căn cứ, và Ladd vẫn bị đóng băng. Vì vậy để qua màn thì cần thêm khoảng 2 vạch máu nữa. Tôi từng phá đảo như vậy một lần để xem có làm được không, nhưng không bao giờ làm lại nữa