1P by GN⁺ 1일전 | ★ favorite | 댓글 1개
  • Donkey Kong Country 2의 회전하는 배럴 버그는 ZSNES 에뮬레이터에서 발생함
  • ZSNES는 open bus 동작을 제대로 에뮬레이션하지 않아, 배럴이 영구적으로 회전하는 문제 발생함
  • 실제 하드웨어와 달리 ZSNES에서 잘못된 메모리 접근 시 0이 항상 반환되어 버그 유발함
  • 올바른 동작에서는 배럴이 정확한 방향(8방향)에서 회전을 멈추는 로직
  • 이 문제는 코딩상의 사소한 실수(즉, 즉시 주소 지정 대신 절대 주소 지정 사용)에서 비롯된 것으로 추정됨

Donkey Kong Country 2와 ZSNES 에뮬레이터의 배럴 버그

Donkey Kong Country 2는 ZSNES라는 오래된 SNES 에뮬레이터에서 일부 스테이지의 회전하는 배럴(통)이 제대로 작동하지 않는 유명한 버그가 있음

배럴에 들어가면, 원래는 방향키 좌/우를 누르고 있는 동안만 배럴이 회전해야 하지만, ZSNES에서는 좌/우를 짧게 눌러도 배럴이 계속 그 방향으로 영원히 회전하게 됨

이 버그로 인해, 특히 나중 스테이지에서 가시밭이나 장애물 위에 나타나는 회전 배럴 구간이 개발자가 의도한 것보다 훨씬 어렵게 변함

이 문제는 과거 ZSNES 포럼에서 어느 정도 문서화되어 있었으나, 현재 포럼이 사라져 관련 자료를 찾기 어려움

버그의 원인 - Open Bus Emulation

이 버그의 근본 원인은 ZSNES가 open bus 동작을 에뮬레이션하지 않는 데 있음

  • open bus는 SNES와 같은 구형 플랫폼에서 무효 메모리 주소 읽기 시 발생하는 동작임
  • 실제 하드웨어에서는 마지막에 버스에 올린 값이 반환됨
  • SNES의 주요 CPU는 65C816(65816)임
  • 65816은 16비트 버전 6502이며, 24비트 주소 버스를 가지고, 메모리 뱅킹 방식을 사용함

DKC2의 회전 배럴 코드에서, 유효하지 않은 주소(Bank $B3의 $2000, $2001) 접근 시, 하드웨어에서는 open bus로 0x2020 값이 반환됨

ZSNES에서는 이 기능이 없기 때문에 항상 0이 반환되어 버그가 발생함

게임 코드의 동작 방식

회전 배럴과 관련된 게임 루틴은 다음과 같은 동작흐름을 가짐

  • 현재 배럴의 방향과 회전량(속도)을 합산하여 임시 변수에 저장함
  • XOR 연산으로 방향의 변화를 측정하고, 그 결과를 open bus에서 읽은 값과 AND 연산함
  • 그 AND 결과가 0이면 회전을 계속, 0이 아니면 멈추고, 방향을 8방향 중 하나로 반올림하여 정렬함

실제 하드웨어에서는 open bus 값이 0x2020이지만, 만약 0이 반환되면 회전이 무한히 계속됨

이 로직은 원래 AND 연산이 즉시값(address #$2000) 와 해야 하는데, 실수로 절대주소(address $2000) 를 사용했다는 점이 추정됨

하지만 하드웨어의 open bus 특성상, 실제로는 두 방식 모두 정상 동작함

해결 및 결론

Snes9x 같은 다른 SNES 에뮬레이터는 이 버그를 하드코딩 방식으로 픽스했으며, ZSNES는 개발 중단으로 패치되지 않음

해당 루틴에서 AND 명령어의 오퍼코드를 0x2D에서 0x29(AND #$2000)로 바꿀 경우, open bus 동작 없이도 회전 배럴이 정상 작동

이 문제는 실제 하드웨어나 최신 에뮬레이터에서는 발생하지 않음

결국, 이 버그는 open bus 에뮬레이션 미지원코딩 실수가 만나 발생한 예시임


추가 배경: 65816 구조와 SNES 메모리 맵

65816 CPU는 24비트 주소 버스를 가지고 있으나, 주로 8비트 뱅크와 16비트 오프셋 조합을 사용함

  • 프로그램 카운터(PC)는 16비트, 프로그램 뱅크 레지스터(PBR, K)로 전체 주소 구성
  • 데이터 뱅크(DBR, B)는 데이터 연산용 뱅크 선택에 사용됨
  • 하드웨어 스택과 direct page는 항상 $00뱅크에 존재함

SNES 메모리 맵도 65816을 기반으로 설계되어 주소를 8비트 뱅크+16비트 오프셋으로 생각하는 것이 더 효율적임

마무리

이 사례는 레거시 하드웨어의 특성(open bus 등)이 에뮬레이션에서 기대하지 않은 버그로 이어질 수 있음을 보여줌

개발자는 즉시 주소 지정을 사용해야 했지만, 우연히 절대주소도 정상 동작했던 사례임

현대에서는 open bus 동작까지 에뮬레이션하는 것이 구형 소프트웨어의 정확한 재현에 매우 중요함을 시사함

Hacker News 의견
  • 나는 6502 어셈블리 프로그래머로서 # 기호를 빼먹고 즉시값 대신 메모리 액세스를 해버리는 실수로 인해 수없이 많은 시간을 허비한 경험이 있어, 이런 실수는 가끔은 운 좋게 잘 동작하는 경우도 있어 더더욱 골치 아픈 현상임을 체감함. 하지만 예시의 플로팅 버스 문제보다 더 최악인 경우는 초기화되지 않은 RAM에 기대는 코드로, DRAM 마다 초기값이 다르기 때문에 본인 컴퓨터나 에뮬레이터에서는 항상 잘 돌아가지만, 다른 DRAM을 사용하는 다른 컴퓨터에서는 실패하게 됨. 보통 데모파티에서 남의 하드웨어에서 돌려야 할 때 15분도 안남겨 두고 코드가 실행 안될 때 이런 문제를 발견하게 됨

    • 6502 CPU에서 동적 메모리를 사용한 아키텍처가 실제로 있었는지 궁금증이 있음. 내 경험상 해당 플랫폼은 항상 정적 RAM만 사용한 기억

    • 6502가 내 첫 어셈블리 언어였고, "LDA #2"는 “A 레지스터에 2란 숫자를 로드”라고 생각했음. 반면에 LDA 2는 “메모리 위치 2번 값을 로드”란 느낌이라 이 차이로 애초에 실수를 피하려고 노력함

    • 이런 상황에서는 LLM에 코드를 통과시켜 보는 것이 오히려 유용할 수 있음. LLM이 이런 영향이 큰 오타나 실수 포인트를 잘 발견해주는 강점 때문

  • Open Bus라는 단어가 대문자로 써진 걸 보고 무슨 오래된 버스 프로토콜이나 표준인 줄 착각하고 글을 읽음. 알고 보니 단순히 버스가 어떤 곳에도 연결되어 있지 않은 상태를 의미했고, 이는 주소 디코더가 지정한 주소($2000)에서 어떤 메모리 장치도 활성화되지 않았기 때문임을 이해함. 즉시 모드(#)를 빼먹고 인해 메모리에서 아무것도 읽지 못하게 된 현상을 오래된 에뮬레이터가 실제 하드웨어와 다르게 동작함으로써 발견함. 해결책으로 즉시 주소 모드로 지시어를 변경하면, 더 이상 메모리 읽기를 하지 않으므로 약 2us 정도 코드가 빨라짐. 하지만 이 정도 성능 차이는 실제 하드웨어가 아니라면 (특히 타이밍이 완전히 일치하지 않는 에뮬레이터에서는) 큰 의미가 없는 것 같음

    • (몇몇) SNES 에뮬레이터는 현재 거의 시간기반 완벽성을 달성하고 있다는 설명. 단, 2us 차이는 정말 예외적인 경우 아니면 사실상 눈에 띄는 차이를 주지 않는 현상임. 관련 기사: How SNES emulators got a few pixels from complete perfection

    • Rare처럼 출시 후 오랜 시간이 지난 후에야 새로운 아키텍처 덕분에 발견되는 버그가 묻힌 게임을 여럿 출시한 사례가 있음. Donkey Kong 64에서 8~9시간 연속 플레이 후 치명적 메모리 누수가 발생하는데, 에뮬레이터 세이브 기능 덕분에 그 시간이 단숨에 누적되어 버그가 쉽게 노출됨. 참고로 출시 때 동봉된 Memory Pak이 버그 숨기기용이었다는 설이 있지만, 최근 연구에 따르면 Rare도 Nintendo도 그 버그를 당시 인지하지 못했었음

  • SNES Puyo Puyo에서 PPU 오픈 버스 현상을 만난 경험이 있음. RetroArch에서 RunAhead 기능을 작업할 때 저장 상태가 일치하지 않는 이유를 찾는 과정이었고, PPU 오픈 버스에서 읽은 값이 상태 로딩 후 달라져 CPU 실행 트레이스 로그가 일치하지 않았던 특별한 사례

  • 6502 또는 비슷한 코드에서 나는 종종 메모리 주소와 즉시값을 헷갈리는 실수를 함. #$1234 같은 표기법이 실수 유발 요인이라 생각하며 Chuck Peddle 조차 이 문법을 깊이 후회했다는 얘기도 들었음. IDE에서 #을 빨간색으로 강조해 어느 정도 방지할 수 있었음. Rare의 개발자조차 이런 실수를 피하지 못한 사례가 있음

    • 꽤 오래 전 GNU 어셈블러에서 intel_syntax noprefix 모드로 비슷한 문제를 겪었는데, 여기에선 즉시값 이름 상수를 앞에서 참조할 때 메모리 주소나 심볼로 해석될 수 있는 문법적 모호성이 있음. 그 결과 예상과 달리 심볼의 링크 타이밍까지 대기하는 임시 메모리 주소를 만들어 버그 찾기가 정말 고통스러웠던 경험

    • ARM처럼 메모리를 다루는 별도의 지시문이 필요한 명령어셋은 이런 헷갈릴 만한 실수를 근본적으로 막아줌

  • 내가 알기로 오픈 버스 현상은 초기의 간단한 동기 버스 시스템에서만 나타남. 대부분의 다른 시스템은 존재하지 않는 주소에 접근할 때 전체 0 또는 전체 1 같은 일정한 값을 반환하며, 이는 버스 프로토콜에서 응답이 없으면 마스터가 감지할 수 있는 핸드셰이킹(PCI의 master abort)으로 처리함

  • Parallax Propeller 칩을 프로그래밍할 때 비슷한 실수를 반복적으로 경험함. JMP #address와 JMP address의 차이를 자주 헷갈리는데, 이는 6502 어셈블러의 muscle memory 때문임. Propeller의 JMP #address는 지정된 주소로 점프이고, JMP address는 주어진 주소에서 읽은 값으로 점프함. 문제는 이런 버그가 가끔은 잘 돌아가기도 해서, 동작이 멈출 때까지 이유를 찾느라 몇 시간씩 허비하게 됨

  • 오픈 버스란 데이터 버스 라인이 실제로 오픈되어 회로가 열린 상태를 의미함. CPU가 매핑되지 않았거나 쓰기 전용인 주소를 버스에 넣었을 때, 아무 하드웨어도 반응하지 않아 버스 라인이 부동 상태로 남음 — 즉 하드웨어 레벨에서 undefined behavior. 실제로 어떤 일이 벌어지는지 알려면, 데이터 버스의 물리적 구조를 살펴봐야 함. 버스는 마더보드와 카트리지 사이 신호를 전달하는 긴 도체이고, 얇은 절연기판으로 접지면에서 분리되어 있음. 이 구조는 일종의 커패시터 역할을 하므로, 최종적으로는 마지막 신호 전압을 일정 시간 동안 그대로 ‘붙들고’ 있게 됨. 그래서 오픈 버스에서는 마지막으로 전달된 값이 다시 읽히는 효과를 얻게 됨. DKC2 같은 게임은 이 오픈 버스 특성에 무심코 의존하기도 하고, NES의 컨트롤러 시리얼 포트도 저위 비트만 신호를 주고 고위 비트는 오픈 버스가 되기 때문에 특정 게임이 LDA $4016 명령으로 $40이나 $41을 기대함. 오픈 버스 현상은 슈퍼 마리오 월드 크레딧 워프 같은 스피드런 전략(메모리 오염 혹은 임의 코드 실행)에까지 응용됨. 단, 표준이 아닌 카트리지, 풀업/풀다운 저항 사용, 또는 DMA와의 이색 상호작용(Horizontal DMA 등)이 예외적 결과를 만듬. 예를 들어 SNES의 HDMA 전송이 명령어 중간에 발생하면 오픈 버스 읽기 타이밍에 영향을 주어, Super Metroid 스피드런 exploit에서 복제하려는 메모리 블록 사이에 비정상적인 값이 들어가 exploit이 깨지는 경우도 발생함. 이에 따라 원본 하드웨어 또는 아주 정밀한 에뮬레이터를 사용할 경우 크래시가 발생하는 반면, 대부분의 에뮬레이터나 공식 재발매의 경우 이런 니치한 동작을 완벽히 구현하지 않아 전략이 정상 동작함. Super Metroid TAS 세계기록 완주도 이 HDMA 동작에 의존함. 적의 위치를 조작해 CPU 타이밍을 바꿔 HDMA가 오픈 버스에 원하는 값을 올려 최종적으로 컨트롤러 입력을 코드로 실행, 임의 코드 실행까지 가능케 함 Super Mario World credits warp 영상, HDMA 활용 영상, Super Metroid DMA exploit 영상, Super Metroid TAS 기록

    • Ben Eater의 6502 브레드보드 컴퓨터 영상 시리즈 덕분에 이런 하드웨어 동작이 어떻게 동작하는지 이해하는 데 큰 도움이 되었음. 상업용 기기에서 이런 버스 동작이 어떻게 확장되는지를 체감함 Ben Eater 사이트
  • 이런 흥미로운 버그 분석 콘텐츠가 좋아서, 어셈블리 코드는 60% 정도만 겨우 따라가지만 같이 곁들여진 글 설명 덕분에 이해도가 높아짐. 그리고 오랜 기간 아무도 모른 버그가 명작 소프트웨어에서 밝혀지는 이런 스토리가 특히 재미있음

    • 이 시절 시스템은 오늘날 임베디드 시스템 등에선 필수적인 (네트워크 연결 가능성 때문이든 아니든) 대부분의 체크 기능이 존재하지 않아서 더더욱 흥미로움. NES 시대에는 수많은 read/write가 단순히 라인 전압을 토글하는 것에 지나지 않았고, 무슨 일이 일어날지는 실제로 그 시점에만 알 수 있었음. CRT 블랭킹 신호와 정확하게 동기화된 타이밍으로 전압을 토글해 원하는 효과를 얻었고, 슈퍼 마리오 브라더스 3에서는 RAM 멀티플렉서를 토글해 화면 갱신 타이밍마다 스프라이트 뱅크를 바꾸는 등의 장난을 쳤음. 지역별 TV NTSC/PAL 차이에 따른 주사율이 렌더링 논리의 클럭 역할을 했기 때문에, 각각의 TV에 맞는 소프트웨어를 따로 출시해야 했던 진짜 와일드한 시대였음
  • 게임을 에뮬레이터로 플레이하다가 진행이 막히면, ‘혹시 에뮬레이터 버그인가?’라는 의심이 늘 듦. 이 이슈의 경우도 난 그냥 게임 설계가 이렇게 어렵게 만든 줄 알았을 것 같음. 그리고 게임 난이도가 정말 높을 때도 “에뮬레이터 레이턴시 때문인가?”라는 의심을 하곤 했고, 그래서 직접 mister FPGA를 만들어 사용하게 됨

    • Chrono Trigger에서 네 개 키를 동시에 입력해야 하는 구간이 있는데, USB 입력이 한 번에 세 개까지만 전달 가능해 네 번 중 한 번만 등록되는 현상 때문에 매우 어렵고 좌절을 줬던 기억이 있음

    • DKC를 ZSNES로만 플레이했었기에, 기사 읽기 전까지 이게 에뮬레이터 버그라는 걸 전혀 몰랐음. 게임 디자인이 원래 그렇게 난이도를 준 줄로만 알았고, 버그란 걸 알고 나서 정말 충격적이었음

    • Bionic Commando를 어렸을 때 많이 했는데, 에뮬레이터로 다시 하니 훨씬 어렵게 느껴졌음. 나중에 알아보니 에뮬레이터 버그로 적이 사라지지 않아 필요 생명력이 2배 늘더라. 그래도 한 번은 그 방식으로 깬 적 있지만 다시는 못 하겠음

  • DKC 1의 SGI 기반 프리렌더 3D 그래픽은 당대 첨단 기술이었음. Mega Drive의 Vector Man도 비슷한 기법을 썼지만 DKC만큼 주목 받지는 못했음

    • 1995년 DKC의 주대상 연령대(11살)였는데, 이 게임의 그래픽은 정말 충격적이었음. 출시 즈음 홍보용 비디오도 받아 본 적 있는데, 비하인드 씬 영상이 담긴 그 테이프를 여러 번 돌려 봤었음. 나는 직접 게임을 소유하지 못했지만 친구 집에서 할 기회가 있었음

    • 어릴 때 DKC 그래픽이 어쩐지 ‘가짜’라는 감각이 들었음. 당시 잡지들이 SNES가 실시간으로 3D 캐릭터를 렌더링한다는 식으로 작위적인 설명을 하곤 했는데, 사실은 플립북 애니메이션 같은 방식임을 어렴풋이 눈치챘음