자바스크립트를 활성화 해주세요

NES CPU(6502) 에뮬레이션 - 4 (마무리)

 ·   ·  ☕ 7 min read  ·  ✍️ Yogo

테스트 롬파일

CPU 에뮬레이션이 어느정도 완료가 되었으면 실제 코드를 구동 시켜서 정상적으로 동작 하는지 검증이 필요합니다. 검증을 위해서 6502에 맞게 컴파일된 바이너리가 필요한데 NES ROM 파일에는 당연히 게임 실행을 위한 바이너리 코드가 포함되어 있으므로 이를 활용하면 됩니다.

하지만 단순히 ROM 파일이 있어도 연산이 정상 동작을 하는지 여부를 알기는 어렵습니다. 연산을 수행한 결과와 레지스터 변화를 예측할 수가 없고, 구현된 명령어가 모두 활용되는 지 알 수 없기 때문입니다.

다행히도 Emulator tests1나 Collection of test ROMs for testing a NES emulator2 페이지를 참고하면 에뮬레이션 테스트만을 위한 ROM 파일이 공유가 되고 있는 것을 알수 있습니다.

여기서는 NES CPU 에뮬레이션 쪽에서는 골드 스탠다드?로 불리우는 The Ultimate NES CPU test ROM3을 이용하여 테스트를 진행하였습니다. 이 ROM 파일은 연산 과정에 따른 레지스터 변화에 대한 로그(log)가 함께 공개되어 있으므로 해당 로그와 유사한 포맷으로 로그를 기록하면 상호 비교하여 구현이 잘 되었는지 확인 할 수 있습니다.

관련 파일이나 정보는 Emulator tests 페이지의 CPU Tests 테이블 밑에서 두번 째 항목의 링크를 참조하거나 아래 직접 링크를 통해 확인이 가능합니다.

파일: http://nickmass.com/images/nestest.nes
문서: https://www.qmtpro.com/~nes/misc/nestest.txt
로그: https://www.qmtpro.com/~nes/misc/nestest.log

iNES 롬 구조(iNES ROM Structure)

롬 파일은 카트리지의 메모리를 덤프 한 것 입니다. Raw 데이터만을 그대로 옮겨 놓은 것들도 있지만 게임 정보나 에뮬레이션에 필요한 정보들이 함께 포함되어 있는 형태도 있습니다.

그렇기 때문에 롬 파일도 하나의 포맷이 아니라 여러 포맷으로 공유가 되며 NES의 경우 보편적으로 초창기 NES 에뮬레이터를 개발한 Marat Fayzullin의 iNES 포맷이 많이 사용되고 있습니다.

테스트 롬도 iNES 포맷으로 되어있으므로 짧막하게 관련 내용을 살펴보겠습니다.

헤더 포맷 (Header Format)4

iNES는 버전에 따라 대략 3 ~ 4개의 섹션으로 이루어져 있습니다. 가장 첫 16바이트는 헤더 영역으로 PGR, CHR 영역 사이즈, Mapper 타입, RAM 크기 등 정보를 담고 있습니다.

offset을 고려하여 아래와 같이 코드로 표현 할 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
internal unsafe struct INesHeader
{
    public fixed byte magic[4];
    public byte numPRG;
    public byte numCHR;
    public byte control1;
    public byte control2;
    public byte numRAM;
    public byte flags9;
    public byte flags10;
    public fixed byte reserved[5];
}

magic은 ‘NES’ 문자열을 담고 있는 매직 코드 입니다. NES 파일인지 여부를 확인하는 용도로 사용합니다.
numPGR은 16kB 단위 크기의 PGR 뱅크 갯수를 numCHR은 8kB 단위 크기의 CHR 뱅크 갯수를 의미합니다.

control1, control2는 데이터에 대한 부가정보를 담고 있습니다. 아래 NesDev 위키 사이트의 INES 페이지5 설명을 일부 첨부 합니다.

Control 1
76543210
||||||||
|||||||+- Mirroring: 0: horizontal (vertical arrangement) (CIRAM A10 = PPU A11)
|||||||              1: vertical (horizontal arrangement) (CIRAM A10 = PPU A10)
||||||+-- 1: Cartridge contains battery-backed PRG RAM ($6000-7FFF) or other persistent memory
|||||+--- 1: 512-byte trainer at $7000-$71FF (stored before PRG data)
||||+---- 1: Ignore mirroring control or above mirroring bit; instead provide four-screen VRAM
++++----- Lower nybble of mapper number

Control 2
76543210
||||||||
|||||||+- VS Unisystem
||||||+-- PlayChoice-10 (8KB of Hint Screen data stored after CHR data)
||||++--- If equal to 2, flags 8-15 are in NES 2.0 format
++++----- Upper nybble of mapper number

이외에 나머지 바이트는 거의 사용되지 않는 항목이거나 예비 영역입니다.

파일 읽기

CPU 테스트를 위해서는 PGR 영역만 가져오면 되므로 매직 코드로 iNES 포맷인지 확인 후 PGR 뱅크 개수 * 16kB 만큼 데이터를 읽어서 배열에 저장하여 이 정보를 가지고 테스트를 합니다. 참고로 NES의 경우 오래된 콘솔이고 게임 롬 사이즈가 수 백 킬로바이트 정도 수준이므로 모든 정보를 변수에 저장해서 사용해도 크게 무리가 없습니다.

저장한 배열을 PC에 맞추어 순차대로 읽으면서 연산을 수행 할 것 입니다. 실제 게임 구동을 위해서라면 mirroring, mapper 정보에 대한 이해가 필요하지만 여기서는 CPU 테스트만 할 것이므로 신경쓰지 않아도 됩니다.

아래는 iNES 파일 구조를 읽어서 카트리지 인스턴스를 생성하여 반환하는 예제 입니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
class INes
{
    public unsafe Cartridge LoadFile(string path) 
    {
        FileStream file = null;
        try
        {
            file = File.OpenRead(path);
        } catch (Exception e)
        {
            if (file != null)
            {
                file.Close();
            }
            throw;
        }

        BinaryReader binary = new BinaryReader(file);
        byte[] headerBytes = binary.ReadBytes(16);
        INesHeader header = Utils.BytesToStructure<INesHeader>(headerBytes);

        // check file format
        if (Convert.ToChar(header.magic[0]) != 'N' ||
            Convert.ToChar(header.magic[1]) != 'E' ||
            Convert.ToChar(header.magic[2]) != 'S')
        {
            throw new Exception("Not INES Format");
        }

        bool battery = ((header.control1 >> 1) & 0x01) != 0;

        // mapper
        byte mapper = (byte)((header.control2 & 0xF0) | ((header.control1 & 0xF0) >> 4));

        Debug.WriteLine("Control1: {0:X2}, Control2: {1:X2}", header.control1, header.control2);

        // format version
        int version = ((header.control2 >> 2) & 0x03);
        if (version != 0)
        {
            throw new Exception("Can not support INES 2.0 Format");
        }

        // screen mirroring
        bool fourScreen = (header.control1 & 0x80) != 0;
        bool vertical = (header.control1 & 0x01) != 0;
        Debug.WriteLine("four: {0}, vert: {1}", fourScreen, vertical);

        byte mirroring = (byte)(((header.control1 >> 2) & 0x02) | (header.control1 & 0x01));

        // has trainer?
        if (((header.control1 >> 2) & 0x1) != 0)
        {
            // unused
            binary.ReadBytes(512);
        }
                    
        // read PRG-ROM
        byte[] prg = binary.ReadBytes(header.numPRG * 0x4000); // 16384

        // read CHR-ROM
        byte[] chr;

        if (header.numCHR == 0)
        {
            chr = new byte[8192];
        } 
        else
        {
            chr = binary.ReadBytes(header.numCHR * 0x2000); // 8192
        }

        Debug.WriteLine("{0}{1}{2}, {3:X}, {4:X}, {5}, {6}, mirror: {7}", 
            Convert.ToChar(header.magic[0]), 
            Convert.ToChar(header.magic[1]), 
            Convert.ToChar(header.magic[2]), prg[0], prg[16383], mapper, header.numPRG, mirroring);

        binary.Close();
        file.Close();

        return new Cartridge(prg, chr, mapper, mirroring, battery);
    }
}

테스트

CPU가 명령어 사이클(nstruction Cycles)을 수행 할 수 있고 롬파일에서 PGR 영역을 가져올 수 있으면 테스트 롬 파일을 읽어서 연산이 제대로 수행하는지 확인을 합니다.

로그 출력

실제 연산이 잘 이루어졌는지는 확인을 위해서 로그를 출력해야 합니다. 위에 테스트 롬의 로그 파일을 다운 받아서 어떤 포맷으로 출력 하는지 확인 하도록 합니다.

C000  4C F5 C5  JMP $C5F5                       A:00 X:00 Y:00 P:24 SP:FD PPU:  0, 21 CYC:7
C5F5  A2 00     LDX #$00                        A:00 X:00 Y:00 P:24 SP:FD PPU:  0, 30 CYC:10
C5F7  86 00     STX $00 = 00                    A:00 X:00 Y:00 P:26 SP:FD PPU:  0, 36 CYC:12
C5F9  86 10     STX $10 = 00                    A:00 X:00 Y:00 P:26 SP:FD PPU:  0, 45 CYC:15
C5FB  86 11     STX $11 = 00                    A:00 X:00 Y:00 P:26 SP:FD PPU:  0, 54 CYC:18
C5FD  20 2D C7  JSR $C72D                       A:00 X:00 Y:00 P:26 SP:FD PPU:  0, 63 CYC:21
C72D  EA        NOP                             A:00 X:00 Y:00 P:26 SP:FB PPU:  0, 81 CYC:27
...

로그를 라인 단위로 확인하면 다음과 같은 순서대로 출력 된 것을 확인 할 수 있습니다.

  • PC, Opcode, Operand, Instruction Addressing, A(ccumulator), X, Y, P(Status), SP(Stack Pointer), PPU cycles, CPU cycles(누적)

해당 정보들을 Opcode를 실행 하는 과정에서 문자열로 만들어 출력 할 수 있도록 합니다.

1
2
3
4
5
6
7
8
9
private string WriteLog(byte opcode)
{
    ...
    return String.Format("{0:X4}  {1:X2} {2} {3}\t{4} {5,-26} A:{6:X2} X:{7:X2}" +  
                "Y:{8:X2} P:{9:X2} SP:{10:X2} PPU:{11} CYC:{12}",
                registers.PC, opcode, w1, w2, name, addrssing,
                registers.ACC, registers.X, registers.Y, 
                registers.SR.GetByte(), registers.SP, ppuCycles, cpuCycles);
}

실제 구현에서는 포맷이 완전하게 동일하지는 않고 어드레싱 포맷이 반영된 operand 주소는 단순화 시켜서 출력 하도록 했습니다. 대신 그 외 정보는 모두 동일하도록 하여 적어도 검증에 문제가 생기지 않도록 하였습니다.

아래 그림과 같이 텍스트 박스에 로그를 출력하고 출력이 완료되면 원본 로그와 차이가 있는지 비교를 하였습니다.

CPU TEST

아래는 로그의 처음 3줄과 마지막 3줄 예시 입니다.

// 원본 로그
C000  4C F5 C5  JMP $C5F5                       A:00 X:00 Y:00 P:24 SP:FD PPU:  0, 21 CYC:7
C5F5  A2 00     LDX #$00                        A:00 X:00 Y:00 P:24 SP:FD PPU:  0, 30 CYC:10
C5F7  86 00     STX $00 = 00                    A:00 X:00 Y:00 P:26 SP:FD PPU:  0, 36 CYC:12
...
C69F  8D 07 40  STA $4007 = FF                  A:00 X:FF Y:15 P:27 SP:FB PPU:233,179 CYC:26544
C6A2  60        RTS                             A:00 X:FF Y:15 P:27 SP:FB PPU:233,191 CYC:26548
C66E  60        RTS                             A:00 X:FF Y:15 P:27 SP:FD PPU:233,209 CYC:26554


// 출력 로그
C000  4C F5 C5	JMP $C5F5                      A:00 X:00 Y:00 P:24 SP:FD PPU:  0, 21 CYC:7
C5F5  A2 00   	LDX $C5F6                      A:00 X:00 Y:00 P:24 SP:FD PPU:  0, 30 CYC:10
C5F7  86 00   	STX $00                        A:00 X:00 Y:00 P:26 SP:FD PPU:  0, 36 CYC:12
...
C69F  8D 07 40	STA $4007                      A:00 X:FF Y:15 P:27 SP:FB PPU:233,179 CYC:26544
C6A2  60      	RTS                            A:00 X:FF Y:15 P:27 SP:FB PPU:233,191 CYC:26548
C66E  60      	RTS                            A:00 X:FF Y:15 P:27 SP:FD PPU:233,209 CYC:26554

지금은 검증이 완료된 상태이므로 출력이 동일하게 이루어진 것을 확인 할 수 있습니다.

개발 중에는 당연히 실수나 또는 잘못된 구현으로 로그가 불일치가 발생하였고, 문제가 발생한 로그를 참조하여 해당 명령어나 어드레싱 모드의 문제를 수정하고 반복 테스트하는 과정을 거쳤습니다.

유의 및 고려사항

테스트 로그와 동일한 PC, Opcode, 각종 레지스터와 사이클이 모두 일치하는 것을 확인으로 CPU 에뮬레이션을 1차료 마무리 하였습니다.

로그 결과만 본다면 문제없이 구현된 것이 맞지만 확인이 필요한 사항이 있습니다.

먼저 원본 로그의 CPU 사이클이 처음부터 7으로 되어 있습니다. 로그에서 사이클은 연산 완료 후 사이클이 아니라 명령 실행 전 사이클 입니다. (연산 후 사이클이라고 하기에는 JMP 에서 소요하는 사이클과 일치하지 않습니다.)

6502에서는 인터럽트 발생 시 약 7사이클이 소요되는데, 초기 PC는 Reset Interrupt Vector($FFFC)에 있는 값으로 결정되기 때문에 Reset으로 인터럽트가 발생하여 PC를 새로 초기화 한 것을 기준으로한 것이 아닐까 하는 생각이 들었습니다.

일단은 테스트 시 CPU 사이클 초기 값을 7이라고 지정하여 테스트를 진행 하였습니다.

그리고 로그의 처음 PC는 C000으로 리셋 인터럽트 벡터의 값이 $C000 이어야 할 것 같은데, 테스트 롬파일을 확인해보면 실제로는 $C004가 기록되어 있었습니다. 해당 값의 불일치 원인이 명확하지 않아서 PC 값을 $C000로 기본 지정하여 테스트를 수행했습니다.

현재는 CPU 기능상의 테스트만 고려하였기 떄문에 임의로 수정을 하여 테스트를 완료 하였지만 차후 완성된 에뮬레이션을 위해서는 해당 부분에 대한 명확한 원인 파악과 동작 정의가 필요할 것으로 생각됩니다.

마무리 및 계획

어렵게나마 NES CPU 에뮬레이션이 부분적으로 완료되었습니다. 부분적이라고 한 것은 아직 다른 테스트 롬이나 실제 게임이 구동한 상태인지 추가 검증이 완료되지 않았기 때문입니다.

그래도 어느정도 가능성을 확인 하였기 때문에 일단 구현 된 것을 완성도를 높이기 위해서 리팩토링과 추가 검증을 수행하고 PPU 개발을 진행해야 합니다. PPU에 대해서는 사실 일부 스터디와 구현이 진행중이었으나 개념이 복잡하고 어려운 부분이 있어서 이해를 하고 진행하려다보니 현재는 중단상태입니다. 짧은 시간내에 바짝 하고 싶었지만 결과보다는 과정이 중요하니 느긋하게 진행 될 것 같습니다.

이 작업의 결과물도 공개를 하려고 하고 있는데 지금은 어렵고 PPU 완료한 이후 통합적으로 게임 구동이 가능한 상태 이후가 되지 않을까 합니다.

그럼 CPU 에뮬레이션 포스팅은 여기서 마무리하고 나중에 PPU 구현이 완료될 때 PPU 관련 포스트를 남기겠습니다.