이 글의 목적은 기존 ChibiAkumas의 튜토리얼을 참고하여 정상적인 NDS ROM 구조 + armips용 + 백 버퍼(Back Buffer) 개선을 구현하는 데 있다.

NDS VRAM 직접 출력 모드에서 스프라이트 이동을 구현하기에 앞서 디스플레이 모드에 대해 간단히 설명할 필요가 있다.

NDS 디스플레이 모드

NDS의 디스플레이 모드는 DISPCNT 레지스터(0x04000000)의 bit 16~17을 통해 설정할 수 있다.

바이너리 모드 설명
0 0b00 화면 꺼짐 디스플레이 비활성화
1 0b01 일반 2D 렌더링 BG/OBJ/팔레트/타일 엔진 사용
2 0b10 VRAM 직접 출력 프레임 버퍼 방식 — (이번 글에서 사용)
3 0b11 메인 메모리 직접 출력 메인 RAM에서 직접 화면 출력

이 중 Mode 2 (VRAM 직접 출력)는 타일 엔진, OAM, 팔레트 등을 일절 사용하지 않고, VRAM Bank A(0x06800000)의 256×192×16bpp 데이터를 LCD에 그대로 출력한다. 가장 원시적이고 직관적인 방식이므로 픽셀 단위의 제어 원리를 학습하는 데 적합하다.

정상적인 NDS ROM을 만들기 위한 조건

먼저 왜 헤더와 닌텐도 로고가 필요한지부터 생각해보자. NDS 펌웨어(BIOS)는 ROM을 부팅하기 전에 다음 세 가지를 엄격하게 검증한다.

  • 헤더 오프셋 검증: 0x0B2 위치에 고정값 0x96이 존재하는지
  • 닌텐도 로고 데이터 검증: 0x0C0~0x15B (156바이트) 데이터가 정확한지 (로고 CRC-16 값이 0xCF56이어야 함)
  • 헤더 체크섬 검증: 0x000~0x15D 영역의 CRC-16 값이 0x15E 위치의 값과 일치하는지

이 중 하나라도 틀리면 ROM이 부팅되지 않는다. 닌텐도 로고 데이터는 본래 상표권 보호 목적으로 포함된 것이며, 에뮬레이터에서도 이 검증을 수행하는 경우가 많기 때문이다. 따라서 빌드 후 체크섬을 자동으로 맞춰주는 아래와 같은 파이썬 패치 스크립트가 필요하다.

CRC 패치 스크립트 (Python)

import struct

def crc16(data):
    crc = 0xFFFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            if crc & 1:
                crc = (crc >> 1) ^ 0xA001
            else:
                crc >>= 1
    return crc & 0xFFFF

with open("sprite_anim.nds", "r+b") as f:
    header = bytearray(f.read(0x200))

    logo_crc = crc16(header[0xC0:0x15C])
    print(f"Logo CRC16:   0x{logo_crc:04X} (expected 0xCF56)")

    header_crc = crc16(header[0x000:0x15E])
    print(f"Header CRC16: 0x{header_crc:04X}")

    struct.pack_into('<H', header, 0x15E, header_crc)

    f.seek(0)
    f.write(header)
    print("Checksum patched!")

첫 번째 방식: XOR 기법으로 스프라이트 이동

가장 먼저 시도해 볼 수 있는 방법은 XOR 연산으로 화면(VRAM)에 직접 스프라이트를 그리고 지우는 기법이다. 화면 깜빡임은 발생하지만, 직접적인 연산을 통해 스프라이트를 제어할 수 있다.

A ⊕ B = C    (그리기: 현재 화면 ⊕ 스프라이트 데이터)
C ⊕ B = A    (지우기: 그려진 화면에 다시 스프라이트를 XOR하면 원래 화면으로 복원됨)

armips 구현 코드

.nds
.arm
.create "sprite_anim.nds", 0x02000000 - 0x8000

.org 0x02000000 - 0x8000
HeaderStart:
    .ascii "LEARNASM.NET"
    .ascii "0000"
    .ascii "00"
    .byte 0
    .byte 0
    .byte 0
    .fill 7
    .byte 0
    .byte 0
    .byte 0
    .byte 4
    .word Arm9_Start - HeaderStart
    .word 0x02000000
    .word 0x02000000
    .word Arm9_End - Arm9_Start
    .word Arm7_Start - HeaderStart
    .word 0x03800000
    .word 0x03800000
    .word Arm7_End - Arm7_Start
    .word 0
    .word 0
    .word 0
    .word 0
    .word 0
    .word 0
    .word 0
    .word 0
    .word 0x00586000
    .word 0x001808F8
    .word 0
    .halfword 0
    .halfword 0
    .word 0
    .word 0
    .fill 8
    .word 0
    .word 0x4000

    .fill 0x2A
    .byte 0x96
    .fill 0x0D

    .byte 0x24,0xFF,0xAE,0x51,0x69,0x9A,0xA2,0x21,0x3D,0x84,0x82,0x0A,0x84,0xE4,0x09,0xAD
    .byte 0x11,0x24,0x8B,0x98,0xC0,0x81,0x7F,0x21,0xA3,0x52,0xBE,0x19,0x93,0x09,0xCE,0x20
    .byte 0x10,0x46,0x4A,0x4A,0xF8,0x27,0x31,0xEC,0x58,0xC7,0xE8,0x33,0x82,0xE3,0xCE,0xBF
    .byte 0x85,0xF4,0xDF,0x94,0xCE,0x4B,0x09,0xC1,0x94,0x56,0x8A,0xC0,0x13,0x72,0xA7,0xFC
    .byte 0x9F,0x84,0x4D,0x73,0xA3,0xCA,0x9A,0x61,0x58,0x97,0xA3,0x27,0xFC,0x03,0x98,0x76
    .byte 0x23,0x1D,0xC7,0x61,0x03,0x04,0xAE,0x56,0xBF,0x38,0x84,0x00,0x40,0xA7,0x0E,0xFD
    .byte 0xFF,0x52,0xFE,0x03,0x6F,0x95,0x30,0xF1,0x97,0xFB,0xC0,0x85,0x60,0xD6,0x80,0x25
    .byte 0xA9,0x63,0xBE,0x03,0x01,0x4E,0x38,0xE2,0xF9,0xA2,0x34,0xFF,0xBB,0x3E,0x03,0x44
    .byte 0x78,0x00,0x90,0xCB,0x88,0x11,0x3A,0x94,0x65,0xC0,0x7C,0x63,0x87,0xF0,0x3C,0xAF
    .byte 0xD6,0x25,0xE4,0x8B,0x38,0x0A,0xAC,0x72,0x21,0xD4,0xF8,0x07

    .halfword 0xCF56
    .halfword 0x0000
    .word 0
    .word 0
    .word 0
    .word 0
    .fill 0x90
    .fill 0x7E00 - 4

Arm7_Start:
    b Arm7_Start
Arm7_End:

.org 0x02000000
Arm9_Start:
    ldr r0, =0x04000304
    ldr r1, =0x820F
    str r1, [r0]

    ldr r0, =0x04000240
    mov r1, 0x80
    strb r1, [r0]

    ldr r0, =0x04000000
    ldr r1, =0x00020000
    str r1, [r0]

    mov r8, 10
    mov r9, 10
    bl ShowSprite

InfLoop:
    ldr r3, =0x04000130
    ldrh r0, [r3]

    and r0, r0, 0b0000000011110000
    cmp r0, 0b0000000011110000
    beq InfLoop

    bl ShowSprite

    tst r0, 0b0000000001000000
    bne JoyNotUp
    cmp r9, 0
    beq JoyNotUp
    sub r9, r9, 1
JoyNotUp:

    tst r0, 0b0000000010000000
    bne JoyNotDown
    cmp r9, 192-8
    beq JoyNotDown
    add r9, r9, 1
JoyNotDown:

    tst r0, 0b0000000000100000
    bne JoyNotLeft
    cmp r8, 0
    beq JoyNotLeft
    sub r8, r8, 1
JoyNotLeft:

    tst r0, 0b0000000000010000
    bne JoyNotRight
    cmp r8, 256-8
    beq JoyNotRight
    add r8, r8, 1
JoyNotRight:

    bl ShowSprite

    ldr r0, =0x10000
Delay:
    subs r0, r0, 1
    bne Delay
    b InfLoop

ShowSprite:
    ldr r10, =0x06800000

    mov r1, 2
    mul r2, r1, r8
    add r10, r10, r2

    mov r1, 256*2
    mul r2, r1, r9
    add r10, r10, r2

    ldr r1, =SpriteTest
    mov r6, 8
Sprite_NextLine:
    mov r5, 8

    STMFD sp!, {r10}
Sprite_NextByte:
    ldrh r3, [r1], 2
    ldrh r2, [r10]
    eor r3, r3, r2
    strh r3, [r10], 2

    subs r5, r5, 1
    bne Sprite_NextByte

    LDMFD sp!, {r10}
    add r10, r10, 256*2
    subs r6, r6, 1
    bne Sprite_NextLine
    mov pc, lr

.pool

SpriteTest:
    .halfword 0x8000, 0x8000, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x8000, 0x8000
    .halfword 0x8000, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x8000
    .halfword 0x83FF, 0x83FF, 0x801F, 0x83FF, 0x83FF, 0x801F, 0x83FF, 0x83FF
    .halfword 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF
    .halfword 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF
    .halfword 0x83FF, 0x83FF, 0xFFE0, 0x83FF, 0x83FF, 0xFFE0, 0x83FF, 0x83FF
    .halfword 0x8000, 0x83FF, 0x83FF, 0xFFE0, 0xFFE0, 0x83FF, 0x83FF, 0x8000
    .halfword 0x8000, 0x8000, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x8000, 0x8000

Arm9_End:
.close

XOR 방식의 핵심 문제는 화면에 출력 중인 VRAM을 직접 수정한다는 점이다. 이로 인해 다음과 같은 한계가 발생한다.

  • 깜빡임(Flicker): ShowSprite로 스프라이트를 지우고 새 위치에 다시 그리는 사이에 LCD가 화면을 갱신하면, 스프라이트가 사라진 프레임이 눈에 보인다.
  • VBlank 동기화 없음: Delay 루프로 타이밍을 잡고 있어서 LCD 갱신 주기와 무관하게 VRAM을 수정하게 된다.
  • 스프라이트 겹침 불가: 두 스프라이트가 겹치면 XOR 연산끼리 간섭하여 색이 깨질 우려가 있어 확장성에 좋지 않다.
  • 배경 제한: 검정(0x0000) 배경에서만 정상 동작한다.

개선된 방식: 백 버퍼 + VBlank + DMA 전송

이는 정석적인 개발 방식으로, 화면에 보이는 VRAM은 절대 직접 건드리지 않고 보이지 않는 백 버퍼(메인 RAM)에서 프레임을 완성한 뒤 VBlank 때 DMA로 한 번에 복사한다. 사용자 눈에는 항상 완성된 프레임만 보이므로 깜빡임이 사라지는 것을 확인할 수 있다.

정석적인 프레임 루프 구조

Active Display 기간 (라인 0~191)
→ LCD가 VRAM을 읽어 화면에 출력하는 중
→ 이 동안 CPU는 게임 로직 처리 및 백 버퍼에 렌더링 완료

VBlank 기간 (라인 192~262)
→ LCD가 화면을 갱신하지 않는 구간
→ 이미 완성된 백 버퍼를 DMA로 VRAM에 전송

armips 구현 코드

.nds
.arm
.create "sprite_anim.nds", 0x02000000 - 0x8000

.org 0x02000000 - 0x8000
HeaderStart:
    .ascii "LEARNASM.NET"
    .ascii "0000"
    .ascii "00"
    .byte 0
    .byte 0
    .byte 0
    .fill 7
    .byte 0
    .byte 0
    .byte 0
    .byte 4
    .word Arm9_Start - HeaderStart
    .word 0x02000000
    .word 0x02000000
    .word Arm9_End - Arm9_Start
    .word Arm7_Start - HeaderStart
    .word 0x03800000
    .word 0x03800000
    .word Arm7_End - Arm7_Start
    .word 0
    .word 0
    .word 0
    .word 0
    .word 0
    .word 0
    .word 0
    .word 0
    .word 0x00586000
    .word 0x001808F8
    .word 0
    .halfword 0
    .halfword 0
    .word 0
    .word 0
    .fill 8
    .word 0
    .word 0x4000

    .fill 0x2A
    .byte 0x96
    .fill 0x0D

    .byte 0x24,0xFF,0xAE,0x51,0x69,0x9A,0xA2,0x21,0x3D,0x84,0x82,0x0A,0x84,0xE4,0x09,0xAD
    .byte 0x11,0x24,0x8B,0x98,0xC0,0x81,0x7F,0x21,0xA3,0x52,0xBE,0x19,0x93,0x09,0xCE,0x20
    .byte 0x10,0x46,0x4A,0x4A,0xF8,0x27,0x31,0xEC,0x58,0xC7,0xE8,0x33,0x82,0xE3,0xCE,0xBF
    .byte 0x85,0xF4,0xDF,0x94,0xCE,0x4B,0x09,0xC1,0x94,0x56,0x8A,0xC0,0x13,0x72,0xA7,0xFC
    .byte 0x9F,0x84,0x4D,0x73,0xA3,0xCA,0x9A,0x61,0x58,0x97,0xA3,0x27,0xFC,0x03,0x98,0x76
    .byte 0x23,0x1D,0xC7,0x61,0x03,0x04,0xAE,0x56,0xBF,0x38,0x84,0x00,0x40,0xA7,0x0E,0xFD
    .byte 0xFF,0x52,0xFE,0x03,0x6F,0x95,0x30,0xF1,0x97,0xFB,0xC0,0x85,0x60,0xD6,0x80,0x25
    .byte 0xA9,0x63,0xBE,0x03,0x01,0x4E,0x38,0xE2,0xF9,0xA2,0x34,0xFF,0xBB,0x3E,0x03,0x44
    .byte 0x78,0x00,0x90,0xCB,0x88,0x11,0x3A,0x94,0x65,0xC0,0x7C,0x63,0x87,0xF0,0x3C,0xAF
    .byte 0xD6,0x25,0xE4,0x8B,0x38,0x0A,0xAC,0x72,0x21,0xD4,0xF8,0x07

    .halfword 0xCF56
    .halfword 0x0000
    .word 0
    .word 0
    .word 0
    .word 0
    .fill 0x90
    .fill 0x7E00 - 4

Arm7_Start:
    b Arm7_Start
Arm7_End:

.org 0x02000000
Arm9_Start:
    ldr sp, =0x02400000

    ldr r0, =0x04000304
    ldr r1, =0x820F
    str r1, [r0]

    ldr r0, =0x04000240
    mov r1, #0x80
    strb r1, [r0]

    ldr r0, =0x04000000
    ldr r1, =0x00020000
    str r1, [r0]

    mov r8, #10
    mov r9, #10

    bl ClearBackBuffer

FrameLoop:
    ldr r3, =0x04000130
    ldrh r0, [r3]
    and r0, r0, #0xF0

    tst r0, #0x40
    bne JoyNotUp
    cmp r9, #0
    beq JoyNotUp
    sub r9, r9, #1
JoyNotUp:

    tst r0, #0x80
    bne JoyNotDown
    cmp r9, #(192-8)
    beq JoyNotDown
    add r9, r9, #1
JoyNotDown:

    tst r0, #0x20
    bne JoyNotLeft
    cmp r8, #0
    beq JoyNotLeft
    sub r8, r8, #1
JoyNotLeft:

    tst r0, #0x10
    bne JoyNotRight
    cmp r8, #(256-8)
    beq JoyNotRight
    add r8, r8, #1
JoyNotRight:

    bl ClearBackBuffer
    bl DrawSprite
    bl WaitVBlank
    bl DMATransfer

    b FrameLoop

ClearBackBuffer:
    stmfd sp!, {r0-r3, lr}
    ldr r0, =BackBuffer
    mov r1, #0
    ldr r2, =24576
ClearLoop:
    str r1, [r0], #4
    subs r2, r2, #1
    bne ClearLoop
    ldmfd sp!, {r0-r3, pc}

DrawSprite:
    stmfd sp!, {r0-r7, r10-r11, lr}
    ldr r10, =BackBuffer

    mov r1, #2
    mul r2, r8, r1
    add r10, r10, r2

    ldr r1, =512
    mul r2, r9, r1
    add r10, r10, r2

    ldr r11, =SpriteData
    mov r6, #8

DrawSprite_Row:
    mov r5, #8
    stmfd sp!, {r10}

DrawSprite_Pixel:
    ldrh r3, [r11], #2
    tst r3, #0x8000
    beq DrawSprite_Skip
    strh r3, [r10]

DrawSprite_Skip:
    add r10, r10, #2
    subs r5, r5, #1
    bne DrawSprite_Pixel

    ldmfd sp!, {r10}
    add r10, r10, #512
    subs r6, r6, #1
    bne DrawSprite_Row
    ldmfd sp!, {r0-r7, r10-r11, pc}

WaitVBlank:
    ldr r0, =0x04000006
WaitVBlank_NotYet:
    ldrh r1, [r0]
    cmp r1, #192
    bne WaitVBlank_NotYet
    mov pc, lr

DMATransfer:
    stmfd sp!, {r0-r2, lr}
    ldr r0, =0x040000D4
    ldr r1, =BackBuffer
    str r1, [r0]
    ldr r1, =0x06800000
    str r1, [r0, #4]
    ldr r1, =0x84006000
    str r1, [r0, #8]
    ldmfd sp!, {r0-r2, pc}

.pool

SpriteData:
    .halfword 0x0000, 0x0000, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x0000, 0x0000
    .halfword 0x0000, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x0000
    .halfword 0x83FF, 0x83FF, 0x801F, 0x83FF, 0x83FF, 0x801F, 0x83FF, 0x83FF
    .halfword 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF
    .halfword 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x83FF
    .halfword 0x83FF, 0x83FF, 0xFFE0, 0x83FF, 0x83FF, 0xFFE0, 0x83FF, 0x83FF
    .halfword 0x0000, 0x83FF, 0x83FF, 0xFFE0, 0xFFE0, 0x83FF, 0x83FF, 0x0000
    .halfword 0x0000, 0x0000, 0x83FF, 0x83FF, 0x83FF, 0x83FF, 0x0000, 0x0000

.align 4
BackBuffer:
    .fill 256 * 192 * 2

Arm9_End:
.close

두 방식을 비교하여 요약하면 다음과 같다.

항목 XOR 방식 백 버퍼 + DMA 방식
깜빡임 있음 없음
VRAM 직접 수정 함 (프론트 버퍼) 안 함 (백 버퍼에만 그림)
VBlank 동기화 없음 (Delay 루프) 있음 (VCOUNT 대기)
VBlank에서 하는 일 DMA 전송만
투명 처리 불가 bit15로 투명/불투명 구분
스프라이트 겹침 XOR 간섭으로 깨짐 정상 동작
배경 검정색만 가능 어떤 배경이든 가능
확장성 스프라이트가 커지면 한계 도달 렌더링은 Active 기간에 하므로 여유 있음

결과: 백 버퍼와 DMA를 적용한 결과, 화면 깜빡임 없이 부드럽게 이동하는 것을 확인할 수 있다.

melon ds에서 실행한 결과