1 คะแนน โดย GN⁺ 2025-07-02 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • บั๊กถังหมุนของ Donkey Kong Country 2 เกิดขึ้นในอีมูเลเตอร์ ZSNES
  • ZSNES จำลอง พฤติกรรม open bus ได้ไม่ถูกต้อง ทำให้เกิดปัญหาที่ถังหมุนค้างตลอดเวลา
  • ต่างจากฮาร์ดแวร์จริง เมื่อมีการเข้าถึงหน่วยความจำผิดพลาดใน ZSNES จะ คืนค่า 0 เสมอ จนทำให้เกิดบั๊ก
  • ในการทำงานที่ถูกต้อง ถังจะมีลอจิก หยุดหมุน ที่ทิศทางที่แม่นยำ (8 ทิศทาง)
  • สันนิษฐานว่าปัญหานี้เกิดจากความผิดพลาดเล็กน้อยในการเขียนโค้ด (คือใช้ absolute addressing แทน immediate addressing)

บั๊กถังของ Donkey Kong Country 2 และอีมูเลเตอร์ ZSNES

Donkey Kong Country 2 มี บั๊กที่เป็นที่รู้จักกันดี ซึ่งทำให้ถังหมุนในบางด่านทำงานไม่ถูกต้องบนอีมูเลเตอร์ SNES รุ่นเก่าอย่าง ZSNES

เมื่อเข้าไปในถัง ตามปกติแล้วถังควรหมุนเฉพาะตอนที่กดปุ่มทิศทางซ้าย/ขวาค้างไว้เท่านั้น แต่ใน ZSNES แม้จะแตะซ้าย/ขวาเพียงสั้น ๆ ถังก็จะ หมุนไปในทิศทางนั้นตลอดไป

บั๊กนี้ทำให้ช่วงถังหมุนที่ปรากฏเหนือ พงหนามหรือสิ่งกีดขวาง โดยเฉพาะในด่านช่วงหลัง ๆ ยากกว่าที่ผู้พัฒนาตั้งใจไว้อย่างมาก

ปัญหานี้เคยมีการบันทึกไว้ระดับหนึ่งในฟอรัมของ ZSNES ในอดีต แต่ปัจจุบันฟอรัมดังกล่าวหายไปแล้ว จึงหาเอกสารที่เกี่ยวข้องได้ยาก

สาเหตุของบั๊ก - Open Bus Emulation

สาเหตุหลักของบั๊กนี้คือ ZSNES ไม่ได้จำลองพฤติกรรม open bus

  • open bus คือพฤติกรรมที่เกิดขึ้นบนแพลตฟอร์มรุ่นเก่าอย่าง SNES เมื่อมีการอ่านจากที่อยู่หน่วยความจำที่ใช้ไม่ได้
  • บนฮาร์ดแวร์จริง ระบบจะคืนค่าล่าสุดที่ถูกวางไว้บนบัส
  • CPU หลักของ SNES คือ 65C816 (65816)
  • 65816 เป็น 6502 เวอร์ชัน 16 บิต มีบัสแอดเดรส 24 บิต และใช้รูปแบบ memory banking

ในโค้ดถังหมุนของ DKC2 เมื่อมีการเข้าถึงที่อยู่ที่ไม่ถูกต้อง (Bank $B3 ที่ $2000, $2001) บนฮาร์ดแวร์จะคืนค่า 0x2020 ผ่าน open bus

แต่ใน ZSNES ไม่มีความสามารถนี้ จึง คืนค่า 0 ตลอด และทำให้เกิดบั๊ก

วิธีการทำงานของโค้ดในเกม

รูทีนของเกมที่เกี่ยวข้องกับถังหมุนมีลำดับการทำงานดังนี้

  • นำทิศทางปัจจุบันของถังมาบวกกับปริมาณการหมุน (ความเร็ว) แล้วเก็บไว้ในตัวแปรชั่วคราว
  • ใช้การคำนวณ XOR เพื่อตรวจจับการเปลี่ยนแปลงของทิศทาง แล้วนำผลนั้นไปทำ AND กับค่าที่อ่านจาก open bus
  • ถ้าผลลัพธ์ของ AND เป็น 0 ก็หมุนต่อไป ถ้าไม่เป็น 0 ก็หยุดหมุน แล้วจัดแนวทิศทางด้วยการ ปัดให้ตรง กับหนึ่งใน 8 ทิศทาง

บนฮาร์ดแวร์จริง ค่า open bus คือ 0x2020 แต่ถ้าคืนค่าเป็น 0 จะทำให้ หมุนไม่สิ้นสุด

สันนิษฐานว่าลอจิกนี้เดิมทีควรทำ AND กับ ค่าทันที (address #$2000) แต่เกิดเขียนผิดเป็น absolute address (address $2000)

อย่างไรก็ตาม ด้วยคุณสมบัติ open bus ของฮาร์ดแวร์จริง ทั้งสองแบบจึงทำงานได้ถูกต้องเหมือนกัน

การแก้ไขและข้อสรุป

อีมูเลเตอร์ SNES อื่นอย่าง Snes9x ได้แก้บั๊กนี้แบบฮาร์ดโค้ดไว้แล้ว ส่วน ZSNES ไม่มีการแพตช์เพราะหยุดพัฒนาไปแล้ว

หากเปลี่ยน opcode ของคำสั่ง AND ในรูทีนดังกล่าวจาก 0x2D เป็น 0x29 (AND #$2000) ถังหมุนก็จะ กลับมาทำงานได้ปกติ โดยไม่ต้องพึ่งพาพฤติกรรม open bus

ปัญหานี้ไม่เกิดบนฮาร์ดแวร์จริงหรืออีมูเลเตอร์รุ่นใหม่

ท้ายที่สุด บั๊กนี้เป็นตัวอย่างของการที่ การไม่รองรับ open bus emulation และ ความผิดพลาดในการเขียนโค้ด มารวมกันจนก่อให้เกิดปัญหา


พื้นหลังเพิ่มเติม: โครงสร้าง 65816 และ memory map ของ SNES

CPU 65816 มีบัสแอดเดรส 24 บิต แต่โดยทั่วไปจะใช้การผสมกันของแบงก์ 8 บิตและออฟเซ็ต 16 บิต

  • Program Counter (PC) เป็น 16 บิต และใช้ Program Bank Register (PBR, K) เพื่อประกอบเป็นแอดเดรสเต็ม
  • Data Bank (DBR, B) ใช้เลือกแบงก์สำหรับการทำงานกับข้อมูล
  • hardware stack และ direct page จะอยู่ในแบงก์ $00 เสมอ

memory map ของ SNES ก็ถูกออกแบบบนพื้นฐานของ 65816 เช่นกัน ทำให้การมองแอดเดรสเป็นแบงก์ 8 บิต + ออฟเซ็ต 16 บิตมีประสิทธิภาพมากกว่า

สรุปท้ายเรื่อง

กรณีนี้แสดงให้เห็นว่า คุณลักษณะของฮาร์ดแวร์ยุคเก่า (เช่น open bus) สามารถนำไปสู่บั๊กที่ไม่คาดคิดในการจำลองระบบได้

ผู้พัฒนาควรใช้ immediate addressing ตั้งแต่แรก แต่กรณีนี้กลับบังเอิญที่ absolute address ก็ยังทำงานได้ตามปกติ

สิ่งนี้ชี้ให้เห็นว่า ในยุคปัจจุบัน การจำลองแม้กระทั่งพฤติกรรมแบบ open bus ก็ สำคัญอย่างมากต่อการถ่ายทอดการทำงานของซอฟต์แวร์เก่าให้แม่นยำ

1 ความคิดเห็น

 
GN⁺ 2025-07-02
ความคิดเห็นบน Hacker News
  • ในฐานะคนเขียนโปรแกรมแอสเซมบลี 6502 ฉันเคยเสียเวลาไปนับไม่ถ้วนเพราะลืมใส่เครื่องหมาย # แล้วเผลอทำ memory access แทน immediate value และก็สัมผัสได้ว่าความผิดพลาดแบบนี้ยิ่งน่าปวดหัวเพราะบางครั้งมันก็ “บังเอิญ” ทำงานได้ดีเสียด้วย แต่กรณีที่เลวร้ายยิ่งกว่าปัญหา floating bus ในตัวอย่างนี้คือโค้ดที่พึ่งพา RAM ที่ยังไม่ได้ initialize เพราะค่าเริ่มต้นของแต่ละ DRAM ไม่เหมือนกัน ทำให้บนเครื่องหรือ emulator ของตัวเองอาจรันได้ตลอด แต่พอไปเครื่องอื่นที่ใช้ DRAM คนละตัวกลับพัง ปกติจะไปเจอปัญหาแบบนี้ตอนงาน demoparty เวลาต้องเอาไปรันบนฮาร์ดแวร์ของคนอื่น ทั้งที่เหลือเวลาไม่ถึง 15 นาทีแล้วแต่โค้ดยังไม่ยอมทำงาน

    • สงสัยว่ามีสถาปัตยกรรมใดที่ใช้ 6502 ร่วมกับหน่วยความจำแบบ dynamic จริงหรือไม่ เพราะจากประสบการณ์ของฉัน แพลตฟอร์มพวกนี้เหมือนจะใช้แต่ static RAM เสมอ

    • 6502 คือภาษาแอสเซมบลีตัวแรกของฉัน และฉันมองว่า LDA #2 หมายถึง “โหลดเลข 2 เข้ารีจิสเตอร์ A” ขณะที่ LDA 2 ให้ความรู้สึกว่า “โหลดค่าจากตำแหน่งหน่วยความจำหมายเลข 2” เลยพยายามหลีกเลี่ยงความผิดพลาดนี้ตั้งแต่แรก

    • ในสถานการณ์แบบนี้ การเอาโค้ดให้ LLM ช่วยดูอาจมีประโยชน์จริง ๆ เพราะ LLM มักเก่งในการจับ typo หรือจุดผิดพลาดเล็ก ๆ ที่ส่งผลกระทบใหญ่แบบนี้

  • ตอนเห็นคำว่า Open Bus เขียนตัวใหญ่ ฉันดันเข้าใจว่าเป็น protocol หรือมาตรฐานบัสโบราณอะไรสักอย่าง แล้วค่อยอ่านไปถึงได้รู้ว่ามันแค่หมายถึงสถานะที่บัสไม่ได้เชื่อมกับอะไรเลย ซึ่งเกิดขึ้นเพราะไม่มีอุปกรณ์หน่วยความจำตัวใดถูก activate ที่แอดเดรสซึ่ง address decoder ระบุไว้ ($2000) พอเผลอลืม immediate mode (#) จึงกลายเป็นอ่านจากหน่วยความจำไม่ได้อะไรเลย และเรื่องนี้ถูกค้นพบเพราะ emulator รุ่นเก่าทำงานต่างจากฮาร์ดแวร์จริง วิธีแก้คือเปลี่ยน directive ให้เป็น immediate addressing mode ซึ่งจะไม่ต้องอ่านหน่วยความจำอีก ทำให้โค้ดเร็วขึ้นประมาณ 2us แต่ความต่างระดับนี้ดูแทบไม่มีความหมายมากนักถ้าไม่ใช่บนฮาร์ดแวร์จริง โดยเฉพาะใน emulator ที่ timing ไม่ตรงเป๊ะ

    • มีคำอธิบายว่า emulator ของ SNES (บางตัว) ตอนนี้เกือบจะทำความแม่นยำด้านเวลาได้สมบูรณ์แล้ว อย่างไรก็ตาม ความต่าง 2us แบบนี้แทบไม่ก่อให้เกิดผลที่สังเกตได้ เว้นแต่จะเป็นกรณีพิเศษจริง ๆ บทความที่เกี่ยวข้อง: How SNES emulators got a few pixels from complete perfection

    • มีหลายกรณีของเกมที่มีบั๊กซ่อนอยู่และเพิ่งถูกค้นพบหลังวางขายไปนานมาก เพราะสถาปัตยกรรมใหม่ ๆ อย่าง Rare เองก็เคยออกเกมลักษณะนี้หลายเกม เช่นใน Donkey Kong 64 มี memory leak ร้ายแรงที่จะเกิดหลังเล่นต่อเนื่อง 8–9 ชั่วโมง แต่ด้วยฟังก์ชัน save state ของ emulator เวลานั้นจึงสะสมได้รวดเดียวและทำให้บั๊กโผล่ออกมาง่าย มีทฤษฎีหนึ่งว่าที่แถม Memory Pak มาตอนวางขายก็เพื่อซ่อนบั๊กนี้ แต่จากการวิจัยล่าสุดดูเหมือนทั้ง Rare และ Nintendo เองก็ไม่รู้เรื่องบั๊กนี้ในตอนนั้น

  • ฉันเคยเจอปรากฏการณ์ PPU open bus ใน SNES Puyo Puyo ตอนกำลังทำฟีเจอร์ RunAhead ให้ RetroArch และกำลังตามหาว่าทำไม save state ถึงไม่ตรงกัน เป็นกรณีพิเศษที่ค่าซึ่งอ่านจาก PPU open bus เปลี่ยนไปหลังโหลดสถานะ ทำให้ CPU execution trace log ไม่ตรงกัน

  • เวลาเขียน 6502 หรือโค้ดคล้าย ๆ กัน ฉันมักสับสนระหว่าง memory address กับ immediate value อยู่บ่อย ๆ คิดว่าสัญลักษณ์อย่าง #$1234 เป็นตัวกระตุ้นให้พลาดได้ง่าย และเคยได้ยินมาด้วยว่าแม้แต่ Chuck Peddle เองก็เสียใจกับไวยากรณ์นี้มาก การไฮไลต์ # เป็นสีแดงใน IDE ช่วยป้องกันได้บ้าง และแม้แต่นักพัฒนาของ Rare เองก็ยังพลาดเรื่องนี้ได้

    • นานมากแล้วฉันเคยเจอปัญหาคล้ายกันใน GNU assembler โหมด intel_syntax noprefix ซึ่งมีความกำกวมทางไวยากรณ์เวลาจะอ้างถึง immediate named constant จากด้านหน้า เพราะมันอาจถูกตีความเป็น memory address หรือ symbol ได้ ผลคือแทนที่จะได้อย่างที่คิด มันกลับสร้าง temporary memory address ที่ต้องรอถึงช่วง link symbol ทำให้การไล่บั๊กทรมานมาก

    • ชุดคำสั่งแบบ ARM ที่ต้องใช้คำสั่งแยกต่างหากในการจัดการหน่วยความจำ ช่วยป้องกันความสับสนประเภทนี้ได้ตั้งแต่ต้น

  • เท่าที่ฉันรู้ ปรากฏการณ์ open bus มีเฉพาะในระบบบัส synchronous แบบง่ายยุคแรก ๆ เท่านั้น ระบบอื่นส่วนใหญ่จะคืนค่าแบบคงที่ เช่น 0 ทั้งหมดหรือ 1 ทั้งหมด เมื่อเข้าถึงแอดเดรสที่ไม่มีอยู่ โดยจัดการผ่าน handshaking ที่ master สามารถตรวจจับได้หากไม่มีการตอบสนองจาก bus protocol (เช่น master abort ของ PCI)

  • ตอนเขียนโปรแกรมให้ชิป Parallax Propeller ฉันก็เจอความผิดพลาดแบบเดียวกันซ้ำ ๆ มักสับสนระหว่าง JMP #address กับ JMP address เพราะ muscle memory จากแอสเซมบลี 6502 ใน Propeller นั้น JMP #address คือกระโดดไปยังแอดเดรสที่ระบุ ส่วน JMP address คือกระโดดไปยังค่าที่อ่านได้จากแอดเดรสนั้น ปัญหาคือบั๊กแบบนี้บางครั้งก็ดันทำงานได้ เลยเสียเวลาเป็นชั่วโมงกว่าจะหาสาเหตุว่าทำไมมันถึงหยุดทำงาน

  • open bus หมายถึงเส้น data bus อยู่ในสภาพเปิดจริง ๆ ที่วงจรไม่ถูกขับค่า เมื่อ CPU วางแอดเดรสที่ไม่ได้แมปหรือเป็นแบบ write-only ลงบนบัส จะไม่มีฮาร์ดแวร์ตัวใดตอบสนอง ทำให้เส้นบัสลอยอยู่ — พูดอีกแบบคือ undefined behavior ในระดับฮาร์ดแวร์ ถ้าอยากรู้ว่าเกิดอะไรขึ้นจริง ต้องดูโครงสร้างทางกายภาพของ data bus บัสก็คือตัวนำยาว ๆ ที่ส่งสัญญาณระหว่างเมนบอร์ดกับคาร์ทริดจ์ และถูกแยกจาก ground plane ด้วยแผ่นฉนวนบาง ๆ โครงสร้างนี้ทำตัวเหมือน capacitor ชนิดหนึ่ง จึงสามารถ “กัก” แรงดันจากสัญญาณล่าสุดไว้ได้ช่วงหนึ่ง ดังนั้นในสภาพ open bus จึงเหมือนอ่านค่าล่าสุดที่เคยส่งผ่านบัสกลับมาอีกครั้ง เกมอย่าง DKC2 ก็เผลอพึ่งพาคุณสมบัติ open bus นี้อยู่บ้าง และ serial port ของคอนโทรลเลอร์ NES ก็ส่งสัญญาณเฉพาะบิตล่าง ส่วนบิตสูงเป็น open bus ทำให้บางเกมคาดหวังว่า LDA $4016 จะได้ $40 หรือ $41 ปรากฏการณ์ open bus ยังถูกนำไปใช้กับกลยุทธ์ speedrun อย่าง Super Mario World credits warp (memory corruption หรือ arbitrary code execution) ได้ด้วย อย่างไรก็ตาม คาร์ทริดจ์ที่ไม่เป็นมาตรฐาน การใช้ตัวต้านทาน pull-up/pull-down หรือปฏิสัมพันธ์แปลก ๆ กับ DMA (เช่น Horizontal DMA) อาจทำให้ได้ผลลัพธ์ผิดปกติ เช่น ถ้า HDMA transfer ของ SNES เกิดขึ้นกลางคำสั่ง ก็อาจส่งผลต่อจังหวะของการอ่าน open bus และทำให้มีค่าผิดปกติเข้าไประหว่างบล็อกหน่วยความจำที่พยายามคัดลอกใน exploit ของ Super Metroid จน exploit ใช้ไม่ได้ เพราะแบบนี้ ถ้าใช้ฮาร์ดแวร์จริงหรือ emulator ที่แม่นยำมากอาจเกิดแครชได้ แต่ emulator ส่วนใหญ่หรือฉบับ rerelease ทางการกลับไม่ได้จำลองพฤติกรรมเฉพาะทางนี้ครบถ้วน กลยุทธ์จึงยังทำงานได้ การจบเกมทำลายสถิติโลกแบบ TAS ของ Super Metroid ก็อาศัยพฤติกรรม HDMA นี้เช่นกัน โดยควบคุมตำแหน่งศัตรูเพื่อเปลี่ยน timing ของ CPU ให้ HDMA วางค่าที่ต้องการไว้บน open bus และสุดท้ายทำให้ input จากคอนโทรลเลอร์ถูกประมวลผลเป็นโค้ด จนไปถึง arbitrary code execution ได้ วิดีโอ Super Mario World credits warp, วิดีโอการใช้ HDMA, วิดีโอ Super Metroid DMA exploit, สถิติ TAS ของ Super Metroid

    • ซีรีส์วิดีโอคอมพิวเตอร์ 6502 บน breadboard ของ Ben Eater ช่วยให้ฉันเข้าใจมากขึ้นว่าพฤติกรรมฮาร์ดแวร์แบบนี้ทำงานอย่างไร และเห็นภาพว่าพฤติกรรมของบัสเชิงพาณิชย์ขยายต่อจากหลักการนี้อย่างไร เว็บไซต์ Ben Eater
  • ฉันชอบคอนเทนต์วิเคราะห์บั๊กลักษณะนี้มาก ถึงจะตามโค้ดแอสเซมบลีได้แค่ราว 60% แต่คำอธิบายประกอบในบทความช่วยให้เข้าใจขึ้นมาก และเรื่องที่บั๊กในซอฟต์แวร์ระดับตำนานถูกเปิดเผยหลังผ่านไปนานโดยไม่มีใครรู้มาก่อนก็สนุกเป็นพิเศษ

    • ระบบยุคนั้นน่าสนใจยิ่งขึ้นไปอีกเพราะไม่มีฟีเจอร์ตรวจสอบสารพัดแบบที่ปัจจุบันถือเป็นสิ่งจำเป็นในระบบ embedded ไม่ว่าจะเพราะเชื่อมเครือข่ายได้หรือไม่ก็ตาม ในยุค NES การ read/write จำนวนมากก็แค่เป็นการ toggle แรงดันบนเส้นสัญญาณ และจะเกิดอะไรขึ้นต้องดู ณ เวลาจริงเท่านั้น พวกเขา toggle แรงดันด้วย timing ที่ซิงก์กับสัญญาณ CRT blanking อย่างแม่นยำเพื่อให้ได้เอฟเฟกต์ที่ต้องการ และใน Super Mario Bros. 3 ก็ยังเล่นกลด้วยการ toggle RAM multiplexer เพื่อสลับ sprite bank ทุกครั้งที่ถึงจังหวะรีเฟรชหน้าจอด้วย ความต่างของทีวีแต่ละภูมิภาคระหว่าง NTSC/PAL ทำให้อัตรารีเฟรชกลายเป็น clock ของ logic การเรนเดอร์ จนต้องออกซอฟต์แวร์แยกให้เหมาะกับทีวีแต่ละแบบ เป็นยุคที่ wild มากจริง ๆ
  • เวลาฉันเล่นเกมบน emulator แล้วติดจนไปต่อไม่ได้ ก็มักสงสัยเสมอว่า “หรือจะเป็นบั๊กของ emulator?” สำหรับกรณีนี้ ฉันคงคิดไปเองว่าเกมมันออกแบบมาให้ยากแบบนี้ และเวลาเกมยากมาก ๆ ก็มักสงสัยอีกว่า “เป็นเพราะ latency ของ emulator หรือเปล่า?” สุดท้ายเลยประกอบ mister FPGA ใช้เอง

    • ใน Chrono Trigger มีช่วงหนึ่งที่ต้องกดปุ่มพร้อมกันสี่ปุ่ม แต่ USB ส่ง input ได้พร้อมกันแค่สามปุ่ม ทำให้ในสี่ครั้งจะมีเพียงครั้งเดียวที่ลงทะเบียนได้ เป็นประสบการณ์ที่ยากและน่าหงุดหงิดมาก

    • ฉันเล่น DKC ด้วย ZSNES อย่างเดียวมาตลอด เลยไม่เคยรู้มาก่อนว่าเรื่องนี้เป็นบั๊กของ emulator จนได้อ่านบทความ ฉันคิดมาตลอดว่าเกมตั้งใจออกแบบให้ยากแบบนั้น พอรู้ว่าเป็นบั๊กก็ตกใจมาก

    • ตอนเด็ก ๆ ฉันเล่น Bionic Commando เยอะมาก แต่พอกลับมาเล่นใหม่บน emulator กลับรู้สึกว่ายากขึ้นมาก มารู้ทีหลังว่าเป็นบั๊กของ emulator ที่ทำให้ศัตรูไม่หายไป เลยต้องใช้พลังชีวิตเป็นสองเท่า แม้จะเคยผ่านได้ครั้งหนึ่งด้วยสภาพแบบนั้น แต่คงไม่ทำอีกแล้ว

  • กราฟิก 3D prerendered ที่ทำบน SGI ใน DKC 1 ถือเป็นเทคโนโลยีล้ำสมัยมากในยุคนั้น Vector Man บน Mega Drive ก็ใช้เทคนิคคล้ายกัน แต่ไม่ได้รับความสนใจเท่า DKC

    • ปี 1995 ฉันอยู่ในกลุ่มอายุเป้าหมายหลักของ DKC (11 ขวบ) และกราฟิกของเกมนี้ช็อกมากจริง ๆ ฉันยังเคยได้รับวิดีโอโปรโมตช่วงใกล้วางขายด้วย ซึ่งในเทปมีฟุตเทจเบื้องหลังการสร้าง ฉันเปิดดูซ้ำอยู่หลายรอบ แม้จะไม่ได้เป็นเจ้าของเกมเอง แต่ก็มีโอกาสได้เล่นที่บ้านเพื่อน

    • ตอนเด็ก ๆ ฉันรู้สึกว่ากราฟิกของ DKC มัน “ปลอม” อยู่แปลก ๆ ตอนนั้นนิตยสารหลายเล่มชอบอธิบายเกินจริงประมาณว่า SNES กำลังเรนเดอร์ตัวละคร 3D แบบเรียลไทม์ แต่ฉันพอจับได้ลาง ๆ ว่าจริง ๆ แล้วมันเหมือนแอนิเมชันแบบ flipbook มากกว่า