Donkey Kong Country 2 và Open Bus
(jsgroth.dev)- Lỗi thùng quay của Donkey Kong Country 2 xảy ra trên trình giả lập ZSNES
- ZSNES không giả lập đúng hành vi open bus, khiến thùng quay vĩnh viễn
- Không giống phần cứng thật, khi truy cập bộ nhớ sai trên ZSNES thì luôn trả về 0, từ đó gây ra lỗi
- Ở hành vi đúng, đây là logic dừng quay khi thùng ở đúng hướng (8 hướng)
- Vấn đề này được cho là bắt nguồn từ một lỗi mã hóa nhỏ (tức là dùng địa chỉ tuyệt đối thay vì địa chỉ tức thời)
Donkey Kong Country 2 và lỗi thùng quay trên trình giả lập ZSNES
Donkey Kong Country 2 có một lỗi nổi tiếng khiến các thùng quay ở một số màn chơi không hoạt động đúng trên ZSNES, một trình giả lập SNES đã cũ.
Khi vào trong thùng, bình thường thùng chỉ nên quay trong lúc người chơi giữ phím trái/phải, nhưng trên ZSNES thì chỉ cần bấm trái/phải trong chốc lát là thùng sẽ tiếp tục quay mãi mãi theo hướng đó.
Vì lỗi này, đặc biệt ở các màn sau, những đoạn thùng quay xuất hiện phía trên bụi gai hoặc chướng ngại vật trở nên khó hơn rất nhiều so với ý đồ ban đầu của nhà phát triển.
Vấn đề này từng được ghi chép phần nào trên diễn đàn ZSNES ngày trước, nhưng hiện diễn đàn đã biến mất nên rất khó tìm lại tài liệu liên quan.
Nguyên nhân của lỗi - Giả lập Open Bus
Nguyên nhân cốt lõi của lỗi này là ZSNES không giả lập hành vi open bus.
- open bus là hành vi xảy ra trên các nền tảng cũ như SNES khi đọc từ địa chỉ bộ nhớ không hợp lệ
- Trên phần cứng thật, giá trị cuối cùng được đặt lên bus sẽ được trả về
- CPU chính của SNES là 65C816 (65816)
- 65816 là phiên bản 16-bit của 6502, có bus địa chỉ 24-bit và sử dụng cơ chế phân trang bộ nhớ
Trong đoạn mã thùng quay của DKC2, khi truy cập các địa chỉ không hợp lệ (Bank $B3 tại $2000, $2001), phần cứng sẽ trả về giá trị 0x2020 thông qua open bus.
Vì ZSNES không có tính năng này nên luôn trả về 0, từ đó sinh ra lỗi.
Cách mã game hoạt động
Routine trong game liên quan đến thùng quay hoạt động theo luồng như sau:
- Cộng hướng hiện tại của thùng với lượng quay (tốc độ) rồi lưu vào biến tạm
- Dùng phép XOR để đo sự thay đổi hướng, sau đó thực hiện phép AND giữa kết quả đó với giá trị đọc từ open bus
- Nếu kết quả AND bằng 0 thì tiếp tục quay, nếu khác 0 thì dừng lại và căn chỉnh bằng cách làm tròn hướng về một trong 8 hướng
Trên phần cứng thật, giá trị open bus là 0x2020, nhưng nếu trả về 0 thì việc quay sẽ tiếp diễn vô hạn.
Người ta cho rằng logic này vốn phải thực hiện phép AND với giá trị tức thời (address #$2000), nhưng do nhầm lẫn lại dùng địa chỉ tuyệt đối (address $2000).
Tuy vậy, nhờ đặc tính open bus của phần cứng nên trên thực tế cả hai cách đều hoạt động bình thường.
Cách khắc phục và kết luận
Các trình giả lập SNES khác như Snes9x đã sửa lỗi này bằng cách hardcode, còn ZSNES thì do đã ngừng phát triển nên không được vá.
Nếu đổi opcode của lệnh AND trong routine đó từ 0x2D sang 0x29 (AND #$2000), thì thùng quay sẽ hoạt động bình thường ngay cả khi không có hành vi open bus.
Vấn đề này không xảy ra trên phần cứng thật hoặc các trình giả lập hiện đại.
Cuối cùng, đây là một ví dụ cho thấy lỗi có thể phát sinh khi thiếu hỗ trợ giả lập open bus kết hợp với một sai sót trong mã nguồn.
Bối cảnh bổ sung: cấu trúc 65816 và bản đồ bộ nhớ SNES
CPU 65816 có bus địa chỉ 24-bit, nhưng chủ yếu dùng tổ hợp ngân hàng 8-bit và offset 16-bit.
- Bộ đếm chương trình (PC) là 16-bit, kết hợp với thanh ghi ngân hàng chương trình (PBR, K) để tạo thành địa chỉ đầy đủ
- Ngân hàng dữ liệu (DBR, B) được dùng để chọn ngân hàng cho các phép toán dữ liệu
- Stack phần cứng và direct page luôn nằm trong bank $00
Bản đồ bộ nhớ của SNES cũng được thiết kế dựa trên 65816, nên việc xem địa chỉ dưới dạng ngân hàng 8-bit + offset 16-bit sẽ hiệu quả hơn.
Kết
Trường hợp này cho thấy đặc tính của phần cứng legacy (như open bus) có thể dẫn đến những lỗi không ngờ trong quá trình giả lập.
Nhà phát triển lẽ ra nên dùng địa chỉ tức thời, nhưng tình cờ địa chỉ tuyệt đối vẫn hoạt động bình thường.
Điều này cũng gợi ý rằng trong thời hiện đại, việc giả lập chính xác cả hành vi open bus là rất quan trọng để tái hiện trung thực phần mềm cũ.
1 bình luận
Ý kiến trên Hacker News
Là một lập trình viên assembly 6502, tôi từng lãng phí vô số thời gian vì những lỗi như quên ký hiệu
#và vô tình truy cập bộ nhớ thay vì dùng giá trị tức thời; những lỗi kiểu này đôi khi còn tình cờ chạy được nên lại càng đau đầu. Nhưng trường hợp còn tệ hơn cả ví dụ về floating bus là code dựa vào RAM chưa được khởi tạo; do mỗi DRAM có giá trị khởi tạo khác nhau nên trên máy hay trình giả lập của mình thì lúc nào cũng chạy ổn, nhưng trên máy khác dùng DRAM khác thì lại hỏng. Thường chỉ phát hiện ra kiểu lỗi này khi còn chưa tới 15 phút trước lúc phải chạy trên phần cứng của người khác ở demoparty mà code thì không chạyTôi cũng tò mò không biết đã từng có kiến trúc nào dùng bộ nhớ động với CPU 6502 hay chưa. Theo kinh nghiệm của tôi thì nền tảng đó lúc nào cũng dùng SRAM
6502 là ngôn ngữ assembly đầu tiên của tôi, và tôi nghĩ
LDA #2là “nạp số 2 vào thanh ghi A”. CònLDA 2thì cho cảm giác là “nạp giá trị ở ô nhớ số 2”, nên tôi cố phân biệt như vậy để tránh mắc lỗi ngay từ đầuTrong tình huống như thế này, cho code đi qua LLM đôi khi lại hữu ích. LLM khá giỏi trong việc phát hiện các lỗi gõ nhầm hay điểm dễ sai nhưng ảnh hưởng lớn như vậy
Thấy cụm từ Open Bus được viết hoa, tôi tưởng đó là một giao thức hay tiêu chuẩn bus cũ nào đó rồi mới đọc bài. Hóa ra nó chỉ đơn giản có nghĩa là bus không được nối tới đâu cả, vì ở địa chỉ do bộ giải mã địa chỉ chỉ định (
$2000) không có thiết bị nhớ nào được kích hoạt. Trình giả lập đời cũ hoạt động khác phần cứng thật nên mới lộ ra hiện tượng không đọc được gì từ bộ nhớ do quên chế độ immediate (#). Cách sửa là đổi chỉ thị sang chế độ địa chỉ immediate, khi đó sẽ không còn đọc bộ nhớ nữa nên code nhanh hơn khoảng 2us. Nhưng mức chênh lệch hiệu năng này có lẽ không quá ý nghĩa nếu không phải trên phần cứng thật, đặc biệt là với các trình giả lập không khớp timing hoàn toànCó giải thích rằng (một số) trình giả lập SNES hiện nay gần như đã đạt tới độ hoàn hảo theo thời gian thực. Tuy vậy, chênh lệch 2us thực sự chỉ tạo khác biệt đáng chú ý trong những trường hợp cực kỳ ngoại lệ. Bài liên quan: How SNES emulators got a few pixels from complete perfection
Đã có nhiều trường hợp như Rare phát hành game chứa bug bị chôn giấu, chỉ nhiều năm sau mới bị phát hiện nhờ kiến trúc mới. Ở Donkey Kong 64 có lỗi rò rỉ bộ nhớ nghiêm trọng xảy ra sau 8–9 tiếng chơi liên tục, nhưng nhờ tính năng save state của trình giả lập mà khoảng thời gian đó có thể bị cộng dồn ngay lập tức nên bug dễ lộ hơn. Có giả thuyết rằng Memory Pak đi kèm khi phát hành là để che bug này, nhưng nghiên cứu gần đây cho thấy cả Rare lẫn Nintendo khi đó đều không hề biết về lỗi này
Tôi từng gặp hiện tượng PPU open bus trong SNES Puyo Puyo. Khi làm tính năng RunAhead cho RetroArch, tôi đi tìm lý do tại sao save state không khớp, và phát hiện ra đây là một trường hợp đặc biệt khi giá trị đọc từ PPU open bus thay đổi sau khi nạp trạng thái, khiến log trace thực thi CPU không còn trùng nhau
Với 6502 hoặc mã tương tự, tôi khá hay nhầm giữa địa chỉ bộ nhớ và giá trị tức thời. Tôi nghĩ ký pháp như
#$1234rất dễ gây lỗi, thậm chí còn nghe nói chính Chuck Peddle cũng rất hối hận về cú pháp này. Tôi từng giảm bớt lỗi bằng cách cho IDE tô#màu đỏ. Ngay cả các lập trình viên của Rare cũng không tránh được kiểu sai sót nàyKhá lâu trước đây tôi từng gặp vấn đề tương tự với GNU assembler ở chế độ
intel_syntax noprefix; ở đó có sự mơ hồ cú pháp khi tham chiếu hằng số tên của giá trị tức thời ở phía trước, có thể bị diễn giải thành địa chỉ bộ nhớ hoặc symbol. Kết quả là nó tạo ra một địa chỉ bộ nhớ tạm phải chờ tới lúc link symbol, khiến việc tìm bug đau khổ ngoài sức tưởng tượngNhững instruction set như ARM, nơi phải có chỉ thị riêng để thao tác với bộ nhớ, giúp ngăn tận gốc loại nhầm lẫn này
Theo tôi biết thì hiện tượng open bus chỉ xuất hiện ở các hệ thống bus đồng bộ đơn giản thời kỳ đầu. Phần lớn 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ị cố định như toàn 0 hoặc toàn 1, và bus protocol sẽ xử lý việc không có phản hồi bằng cơ chế handshaking để master có thể phát hiện, chẳng hạn
master abortcủa PCIKhi lập trình chip Parallax Propeller, tôi cũng lặp đi lặp lại lỗi tương tự. Tôi rất hay nhầm giữa
JMP #addressvàJMP address, có lẽ do muscle memory từ assembler 6502. Trong Propeller,JMP #addresslà nhảy tới đúng địa chỉ được chỉ định, cònJMP addresslà nhảy tới giá trị đọc được từ địa chỉ đó. Vấn đề là kiểu bug này đôi khi vẫn chạy được, nên có lúc tôi mất hàng tiếng chỉ để tìm ra vì sao chương trình bị treoOpen bus nghĩa là các đường bus dữ liệu thực sự đang ở trạng thái mở. Khi CPU đưa lên bus một địa chỉ chưa được map hoặc chỉ ghi, sẽ không có phần cứng nào phản hồi, nên các đường bus sẽ ở trạng thái floating — tức là undefined behavior ở mức phần cứng. Muốn hiểu chuyện gì thật sự xảy ra thì phải nhìn vào cấu trúc vật lý của bus. Bus là các dây dẫn dài truyền tín hiệu giữa 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. Cấu trúc này hoạt động như một tụ điện, vì vậy cuối cùng nó sẽ “giữ” điện áp của tín hiệu gần nhất trong một khoảng thời gian. Do đó ở trạng thái open bus, bạn sẽ đọc lại giá trị vừa được truyền gần nhất. Những game như DKC2 vô tình phụ thuộc vào đặc tính open bus này, và cổng serial của tay cầm NES cũng vậy: chỉ các bit thấp có tín hiệu còn các bit cao là open bus, nên một số game chờ
LDA $4016trả về$40hoặc$41. Hiện tượng open bus còn được tận dụng trong các chiến thuật speedrun như credits warp của Super Mario World, tức làm bẩn bộ nhớ hoặc thực thi mã tùy ý. Tuy nhiên, cartridge không chuẩn, điện trở pull-up/pull-down, hoặc những tương tác lạ với DMA như Horizontal DMA có thể tạo ra kết quả ngoại lệ. Ví dụ, nếu quá trình truyền HDMA của SNES xảy ra giữa chừng một lệnh, nó có thể ảnh hưởng tới thời điểm đọc open bus, khiến xuất hiện giá trị bất thường giữa các khối bộ nhớ đang được sao chép trong exploit speedrun của Super Metroid và làm hỏng exploit. Vì vậy trên phần cứng gốc hoặc trình giả lập cực kỳ chính xác thì có thể bị crash, trong khi phần lớn trình giả lập hay bản tái phát hành chính thức lại không mô phỏng hoàn toàn hành vi ngách này nên chiến thuật vẫn hoạt động bình thường. Kỷ lục TAS any% của Super Metroid cũng phụ thuộc vào hành vi HDMA này. Bằng cách điều khiển vị trí kẻ địch để đổi timing của CPU, người ta khiến HDMA đặt giá trị mong muốn lên open bus và cuối cùng thực thi input tay cầm như mã lệnh, dẫn tới thực thi mã tùy ý. Video credits warp của Super Mario World, Video tận dụng HDMA, Video exploit DMA của Super Metroid, Kỷ lục TAS của Super MetroidTôi rất thích kiểu nội dung phân tích bug thú vị như thế này; dù chỉ theo được khoảng 60% phần assembly, phần giải thích bằng văn bản đi kèm giúp tôi hiểu hơn nhiều. Và những câu chuyện về các bug ẩn trong phần mềm kinh điển suốt thời gian dài rồi mới bị phát hiện thì đặc biệt hấp dẫn
Mỗi khi chơi game bằng trình giả lập mà bị kẹt tiến độ, tôi luôn nghi ngờ “hay là bug của trình giả lập?”. Với trường hợp này thì có lẽ tôi chỉ nghĩ game được thiết kế khó như vậy thôi. Và khi game quá khó, tôi cũng từng tự hỏi “có phải do độ trễ của trình giả lập không?”, nên cuối cùng đã tự dựng hẳn một mister FPGA để dùng
Tôi nhớ trong Chrono Trigger có đoạn phải nhấn đồng thời bốn phím, nhưng đầu vào USB chỉ truyền được tối đa ba phím một lúc nên bốn lần thử chỉ có một lần được nhận, khiến đoạn đó cực kỳ khó chịu và gây nản
Tôi chỉ từng chơi DKC bằng ZSNES, nên trước khi đọc bài này tôi hoàn toàn không biết đây là bug của trình giả lập. Tôi cứ tưởng game được thiết kế khó như thế, nên khi biết đó là bug thì thật sự rất sốc
Hồi nhỏ tôi chơi Bionic Commando rất nhiều, nhưng khi chơi lại trên trình giả lập thì thấy khó hơn hẳn. Sau này mới biết là do bug của trình giả lập khiến kẻ địch không biến mất, nên lượng mạng cần thiết tăng gấp đôi. Dù vậy tôi cũng từng phá đảo một lần theo kiểu đó, nhưng chắc chắn không muốn làm lại
Đồ họa 3D prerender dựa trên SGI của DKC 1 từng là công nghệ tối tân của thời đó. Vector Man trên Mega Drive cũng dùng kỹ thuật tương tự, nhưng không được chú ý bằng DKC
Năm 1995 tôi đúng độ tuổi mục tiêu của DKC, khoảng 11 tuổi, và đồ họa của game này thực sự gây sốc. Tôi còn từng nhận được băng video quảng bá vào khoảng thời gian phát hành; cuốn băng có cả cảnh hậu trường đó tôi đã xem đi xem lại rất nhiều lần. Dù không sở hữu game, tôi vẫn có dịp chơi ở nhà bạn bè
Hồi nhỏ tôi luôn có cảm giác đồ họa DKC có gì đó “giả giả”. Tạp chí thời đó thường giải thích khá gượng rằng SNES đang render nhân vật 3D theo thời gian thực, nhưng tôi lờ mờ nhận ra thực chất nó giống kiểu hoạt họa lật trang hơn