MCP로 초벌 작업을 하면서 확실히 느낀 게 있다.

고유번호 작성, 대사 스크립트, 타이틀 로고 재배치 같은 단순 작업은 하위 모델에서 스킬과 하네스로 꽉 잡아서 진행하였다.

하나의 매우 작은 agent.md는 공유하지만 개별적인 내용은 그 에이전트 모델만 담당하여 다른 존재를 확인하지 못한다. 나눠진 에이전트 모델들은 연관성이 절대 없는 독립적인 기능만 구현하도록 분업해 놓았기에 별 문제가 없었다.

근데 확실히 AI가 발전할수록 상위 모델에서의 스킬과 하네스도 점점 무의미해지는 것 같다. 오히려 이러한 제한이 기존 모델의 성능, 토큰, 컨텍스트만 잡아먹는 병목이 되는 것 같기도 하다.

상위 모델에서는 입력 시스템 구현, 렌더러 코드 변경, 최적화를 담당했다.

그래도 생각보다 처음에 나온 코드는 문제가 굉장히 심했다. 여전히 아직까지는 과설계, 과검증이 심하다고 느낀다.

몇 가지 이야기하자면, 먼저 사람이 이해하기 힘들게 작성하는 것이다. 한 번만 사용해야 할 값도 따로 라벨링을 거쳐 복잡하게 만든다.

일반적으로 코드 패치를 할 때는 피연산자일 경우 그 값만 “.org”에 워드 단위로 작성하는 게 아니라, 명령어와 함께 한 라인에 적고 간단하게 주석 처리하면 될 것인데, 지침을 줘도 수정이 필요한 값만 워드 단위로 기재해 놓거나 명령어 자체를 헥스 코드를 쓰는 것이였다.

코드 케이브에서는 많은 라벨링을 거쳐서 선형적으로 적어야 할 코드가 조건 분기도 아닌데 JMP를 써서 쓸데없이 코드 계층이 깊어지는 것까지 확인했다.

무엇보다 가장 황당했던 순간은 간단하게 데이터로 있어야 할 것이 런타임에서 그 구조를 만드는 코드를 보았다.

물론 한글화란 게 돌아가면 그만이지만, 쓸데없는 코드로 오버헤드가 커져 성능에 영향을 주는 것들은 확실히 고쳐야 한다고 생각한다. 특히 고전 게임기 같은 경우는 말이다.

만약 이런 부분을 무시하고 그간 작업했다면, 파일 시스템이나 CD-ROM을 사용하는 게임기에서도 로딩 속도를 고려하지 않고

부트 후 초기화 단계에서 빌트인에서 작성한 코드를 메모리에 미리 올리는 대신, 파일 I/O 커맨드나 최적화되지 않은 고전 BIOS의 함수를 남발했을 것이다.

내가 결과적으로 원하는 MCP는 단순히 “해줘”만으로는 아직까진 수많은 문제가 있으며, 프로그래머가 쉽게 확인하고 관리가 가능한 MCP를 만드는 것이 목적이다.

따라서 조금 더 생각하고 업데이트할 필요가 있을 것 같다.


현재까지 확실히 가능한 것들을 말해보자면

그렇게 까다롭지 않는 매퍼는 변환이 가능하다.

그다음은 고유번호와 대사를 뽑고, 이 롬에서 기존 CHR-ROM 방식을 쓸지 CHR-RAM 방식을 쓸지 판단하는 것이다.

예를 들어 대사가 적고 경우의 수가 얼마 없다면, CHR-ROM 방식을 채택해 필요한 CHR 타일 세트를 미리 만든다.

반대로 대사가 많으면 CHR-ROM 타일 세트를 일일이 구성하기 어렵다. 따라서 CHR-RAM으로 변환하고, 필요한 글자를 동적으로 갱신하면서 고정적으로 쓰는 글자는 인덱스를 재사용한다.

일단 롬을 CHR-RAM을 사용하는 구조로 변경하고, 해당 매퍼에서 지원하는 최대 용량까지 확장한다. 기존 CHR-ROM 데이터는 PRG-ROM으로 옮기고, 부트 후 초기화 단계에서 CHR-RAM에 복사한다. 이후 렌더러 코드를 수정해 한글 폰트를 출력한다.


아직 제대로 해결하지 못하는 것들을 난이도 순으로 나열해 보면

  1. CPU 사이클을 계산해 VBlank 안에 몇 타일을 전송해야 최적의 결과가 나오는지 판단하지 못한다. NMI에서 처리하는 다른 작업까지 고려해야 하는데, 전송량을 지나치게 작게 잡으면서 화면을 표시할 때까지 불필요하게 많은 프레임을 기다리게 된다.

  2. 고정적으로 재사용해야 할 글자와 그때그때 갱신해야 할 글자를 구분하지 못한다. 이를 해결하지 못하면 원본 롬보다 표시 속도가 크게 느려진다. 특히 기존 8×8에서 8×16 폰트로 바뀌면 글자당 전송량이 두 배가 되어 문제가 더 심해진다. 숫자처럼 표시할 값이 바뀌더라도 글리프 자체는 같은 경우, 타일을 다시 전송하지 않고 배치할 인덱스만 바꾸면 된다. 이런 부분은 고정 인덱싱을 사용하거나 CHR-RAM에 미리 올려둔 타일을 재사용해야 한다.

  3. 가장 심각한 문제는 현재 매핑된 CHR 뱅크에서 더 이상 사용할 공간이 부족해서 스캔라인 IRQ로 화면 구간에 따라 뱅크를 잠깐 전환해서 출력해야 할 때다. 해당 구간에 필요한 폰트는 알아도 같은 구간에 함께 표시되는 배경이나 창 테두리 등의 그래픽이 어떤 타일을 사용하는지 파악하지 못한다. 폰트만 생각하고 뱅크를 전환하면 그 구간의 다른 그래픽이 깨지는 것이다.

이 부분이 지금 가장 큰 난관이며, 어떻게 해결해야 할지 잘 모르겠다. 무엇보다 시간과 토큰 낭비가 너무 심하다.

아무튼 이런 고생을 하면서 더러운 코드지만 관리가 어느 정도 가능한 패치를 만들었다. 확실히 NES의 6502 코드는 상위 기종에서 컴파일러가 생성한 코드와 달리 사람이 직접 작성한 경우가 많아서, 오히려 작성자의 의도와 코드 흐름이 더 직관적으로 드러나는 것 같다.

추가로 게임 중 스탯을 바로 확인할 수 있도록 루아 스크립트를 IPC 브릿지로 파이썬에서 아군과 적군의 스탯 값을 실시간으로 확인하는 것도 재미 삼아 만들어봤다. 이외에 인터넷에 떠도는 게임지니 코드나 디버그, 숨겨진 요소들도 조금 확인해봤는데, 기존 원본 롬에서도 제대로 적용되지 않는 경우가 있는 것 같아 귀찮아서 더 분석하지 않았다.

매번 구조가 다른 롬을 하나 하나 분석하기보다, 자신만의 표준화된 방식을 정립하고 이를 활용하는 방향으로 개발한다면 더 나은 결과로 이어질 거라 생각한다.

스크린 샷

구현된 방식

  1. nes2.0 변경 및 CHR-ROM → CHR-RAM 변환 후 ROM 레이아웃

     $00~$0F : 원본 PRG 실행 코드/데이터        		
     $10~$1F : 원본 CHR-ROM 그래픽 데이터       		
     $20~$21 : 코드 케이브	
     $22~$26 : 문자열 포인터,문자열 데이터
     $27~$30 : 12x12 한글 폰트 kr_font.bin
     $31     : 초성, 중성, 종성 테이블 hangul_keys.bin
     $32     : 조합값에 대응하는 완성형 한글 고유번호 테이블 hangul_codes.bin
     $33~3D  : 빈 영역
     $3E~$3F : 고정 뱅크  
    
  2. 8x8 -> 8x16 폰트로 변경
  3. 초성 중성 종성 입력 시스템 구현
  4. 좌표 수정, 메뉴창 크기 수정
  5. 동적 갱신 + 고정 타일 인덱싱 조합으로 부분 최적화
  6. 조사 구현

간단한 게임 공략

  1. 50 면을 클리어하면 55까지 맵이 개방되며 모두 클리어해야 엔딩이 나옴
  2. CPU 턴에서 생각을 많이 하는 턴이 있는데 원본 롬도 똑같으며 프리징이 아니므로 배속 기능 활용할 것
  3. 맵에서 사용하는 기술들은 죽여도 경험치를 얻지 못함
  4. 롱 기술을 사용해서 맵에서 체력을 깍아놓고 마무리는 이동력이 좋은 캐릭터 활용하여 막타를 챙겨 성장하는 방식을 추천

캐릭터

아군 캐릭터 HP MP ST AC MD 이동 보유 기술 전체
나디아 (ナディア) C B B B B 주변 1칸 프리즈, 전체 챰
엘렉트라 (エレクトラ) A B C A B 주변 2칸 컨퓨즈, 전체 사일런스, 전체 힐
네모 (ネモ) B B A C B 주변 2칸 프리즈, 롱, 멀티, 블로우
흰고래 (しろくじら) B B C C B 직선 무제한 챰, 전체 힐
남극기지 (なんきょくきち) B B B C B 대각선 무제한 전체 컨퓨즈, 테이크
노틸러스 (ノーチラス) B B A C B 주변 2칸 롱, 멀티
그라탱 (グラタン) B B A B C 주변 1칸
장 (ジャン) B B A B B 주변 1칸 멀티
킹 (キング) A B B A B 주변 1칸
그란디스 (グランディス) A B B B B 주변 1칸 사일런스
적 캐릭터 HP MP ST AC MD 이동 보유 기술 전체
황제 (こうてい) C B B B B 주변 1칸 프리즈, 전체 챰
측근 (そっきん) A B C A B 주변 2칸 컨퓨즈, 전체 사일런스, 전체 힐
가고일 (ガーゴイル) B B A C B 주변 2칸 프리즈, 롱, 멀티, 블로
오르세트라스 (オルセトラス) B B C C B 직선 무제한 챰, 전체 힐
바벨의 탑 (バベルのとう) B B B C B 대각선 무제한 전체 컨퓨즈, 테이크
가피시 (ガーフィッシュ) B B A C B 주변 2칸 롱, 멀티
인간 탱크 (にんげんタンク) B B A B C 주변 1칸
게 로봇 (カニロボット) B B A B B 주변 1칸 멀티
다이소닉스 (ダイソニクス) A B B A B 주변 1칸
대장 (たいちょう) A B B B B 주변 1칸 사일런스

기술 목록

기술 MP 효과
프리즈(フリーズ) 10 적 1명 행동불능
롱(ロング) 8 적 1명 강공격 / 필드 장거리 공격
힐(ヒール) 10 아군 1명 HP 회복
전체 힐(タオヒール) 15 아군 전체 또는 주변 아군 HP 회복
챰(チャム) 5 적 1명 행동불능
전체 챰(タオチャム) 8 적 전체 행동불능
컨퓨즈(コンフ) 6 적 1명 혼란
전체 컨퓨즈(タオコンフ) 10 적 전체 혼란
사일런스(サイレス) 5 적 1명 기술 봉인
전체 사일레스(タオサイレス) 8 적 전체 기술 봉인
테이크(テイク) 2 적 MP 흡수
멀티(マルチ) 15 적 전체 공격
블로우(ブロー) 35 적 1명 다단 강공격

아이템 목록

아이템 효과
일렉테일(エレキテル) 필드상의 모든 적에게 피해
박스 (ボックス) MP 회복
리프 (リーフ) HP 회복
샷건 (ショットガン) 적 전체 공격
부스터 (ブースター) 직접 공격 피해 2배
포드 (ポッド) 전투에서 100% 도주
블루워터 (ブルーウォーター) 전체 챰, 전체 컨퓨즈, 전체 사일런스, 멀티, 전체 힐 중 하나가 랜덤으로 발동

치트

구조체 크기는 0x1C 간격

캐릭터 BASE LEVEL ST AC EXP 현재 HP 현재 MP 최대 HP 최대 MP MD +11~+1B
나디아 6000-6002 6003 6004 6005 6006-6007 6008-6009 600A-600B 600C-600D 600E-600F 6010 6011-601B
엘렉트라 601C-601E 601F 6020 6021 6022-6023 6024-6025 6026-6027 6028-6029 602A-602B 602C 602D-6037
네모 6038-603A 603B 603C 603D 603E-603F 6040-6041 6042-6043 6044-6045 6046-6047 6048 6049-6053
흰고래 6054-6056 6057 6058 6059 605A-605B 605C-605D 605E-605F 6060-6061 6062-6063 6064 6065-606F
남극기지 6070-6072 6073 6074 6075 6076-6077 6078-6079 607A-607B 607C-607D 607E-607F 6080 6081-608B
노틸러스 608C-608E 608F 6090 6091 6092-6093 6094-6095 6096-6097 6098-6099 609A-609B 609C 609D-60A7
그라탱 60A8-60AA 60AB 60AC 60AD 60AE-60AF 60B0-60B1 60B2-60B3 60B4-60B5 60B6-60B7 60B8 60B9-60C3
60C4-60C6 60C7 60C8 60C9 60CA-60CB 60CC-60CD 60CE-60CF 60D0-60D1 60D2-60D3 60D4 60D5-60DF
60E0-60E2 60E3 60E4 60E5 60E6-60E7 60E8-60E9 60EA-60EB 60EC-60ED 60EE-60EF 60F0 60F1-60FB
그란디스 60FC-60FE 60FF 6100 6101 6102-6103 6104-6105 6106-6107 6108-6109 610A-610B 610C 610D-6117
적군 BASE LEVEL ST AC EXP 현재 HP 현재 MP 최대 HP 최대 MP MD +11~+1B
황제 6118-611A 611B 611C 611D 611E-611F 6120-6121 6122-6123 6124-6125 6126-6127 6128 6129-6133
측근 6134-6136 6137 6138 6139 613A-613B 613C-613D 613E-613F 6140-6141 6142-6143 6144 6145-614F
가고일 6150-6152 6153 6154 6155 6156-6157 6158-6159 615A-615B 615C-615D 615E-615F 6160 6161-616B
오르세트라스 616C-616E 616F 6170 6171 6172-6173 6174-6175 6176-6177 6178-6179 617A-617B 617C 617D-6187
바벨의탑 6188-618A 618B 618C 618D 618E-618F 6190-6191 6192-6193 6194-6195 6196-6197 6198 6199-61A3
가피쉬 61A4-61A6 61A7 61A8 61A9 61AA-61AB 61AC-61AD 61AE-61AF 61B0-61B1 61B2-61B3 61B4 61B5-61BF
인간탱크 61C0-61C2 61C3 61C4 61C5 61C6-61C7 61C8-61C9 61CA-61CB 61CC-61CD 61CE-61CF 61D0 61D1-61DB
게로봇 61DC-61DE 61DF 61E0 61E1 61E2-61E3 61E4-61E5 61E6-61E7 61E8-61E9 61EA-61EB 61EC 61ED-61F7
다이소닉스 61F8-61FA 61FB 61FC 61FD 61FE-61FF 6200-6201 6202-6203 6204-6205 6206-6207 6208 6209-6213
대장 6214-6216 6217 6218 6219 621A-621B 621C-621D 621E-621F 6220-6221 6222-6223 6224 6225-622F
local M = emu.memType.nesMemory

local function write8(addr, value)
    emu.write(addr, value, M)
end

local function write16(addr, value)
    write8(addr, value & 0xFF)
    write8(addr + 1, (value >> 8) & 0xFF)
end

local function writeIf(writer, addr, value)
    if value ~= nil then
        writer(addr, value)
    end
end

local function applyCharacter(base, st, ac, exp, curHp, curMp, maxHp, maxMp, md)
    writeIf(write8,  base + 0x04, st)
    writeIf(write8,  base + 0x05, ac)
    writeIf(write16, base + 0x06, exp)
    writeIf(write16, base + 0x08, curHp)
    writeIf(write16, base + 0x0A, curMp)
    writeIf(write16, base + 0x0C, maxHp)
    writeIf(write16, base + 0x0E, maxMp)
    writeIf(write8,  base + 0x10, md)
end

local function cheats()

    -- 아군
    applyCharacter(0x6000, nil, nil, 9999, nil, nil, nil, nil, nil) -- 나디아
    applyCharacter(0x601C, nil, nil, 9999, nil, nil, nil, nil, nil) -- 엘렉트라
    applyCharacter(0x6038, nil, nil, 9999, nil, nil, nil, nil, nil) -- 네모
    applyCharacter(0x6054, nil, nil, 9999, nil, nil, nil, nil, nil) -- 흰고래
    applyCharacter(0x6070, nil, nil, 9999, nil, nil, nil, nil, nil) -- 남극기지
    applyCharacter(0x608C, nil, nil, 9999, nil, nil, nil, nil, nil) -- 노틸러스
    applyCharacter(0x60A8, nil, nil, 9999, nil, nil, nil, nil, nil) -- 그라탱
    applyCharacter(0x60C4, nil, nil, 9999, nil, nil, nil, nil, nil) -- 장
    applyCharacter(0x60E0, nil, nil, 9999, nil, nil, nil, nil, nil) -- 킹
    applyCharacter(0x60FC, nil, nil, 9999, nil, nil, nil, nil, nil) -- 그란디스

    -- 적군
    applyCharacter(0x6118, 0x01, 0x01, 0x01, 1, 1, 1, 1, 0x01) -- 황제
    applyCharacter(0x6134, 0x01, 0x01, 0x01, 1, 1, 1, 1, 0x01) -- 측근
    applyCharacter(0x6150, 0x01, 0x01, 0x01, 1, 1, 1, 1, 0x01) -- 가고일
    applyCharacter(0x616C, 0x01, 0x01, 0x01, 1, 1, 1, 1, 0x01) -- 오르세트라스
    applyCharacter(0x6188, 0x01, 0x01, 0x01, 1, 1, 1, 1, 0x01) -- 바벨의탑
    applyCharacter(0x61A4, 0x01, 0x01, 0x01, 1, 1, 1, 1, 0x01) -- 가피쉬
    applyCharacter(0x61C0, 0x01, 0x01, 0x01, 1, 1, 1, 1, 0x01) -- 인간탱크
    applyCharacter(0x61DC, 0x01, 0x01, 0x01, 1, 1, 1, 1, 0x01) -- 게로봇
    applyCharacter(0x61F8, 0x01, 0x01, 0x01, 1, 1, 1, 1, 0x01) -- 다이소닉스
    applyCharacter(0x6214, 0x01, 0x01, 0x01, 1, 1, 1, 1, 0x01) -- 대장
end

emu.addEventCallback(cheats, emu.eventType.endFrame)

IPC 브릿지를 이용한 스탯 모니터링


local socket = require("socket")

local M = emu.memType.nesMemory

local HOST = "127.0.0.1"
local PORT = 5005

local udp = socket.udp()
udp:setpeername(HOST, PORT)
udp:settimeout(0)

local allies = {
    { name = "나디아",   base = 0x6000 },
    { name = "엘렉트라", base = 0x601C },
    { name = "네모",     base = 0x6038 },
    { name = "흰고래",   base = 0x6054 },
    { name = "남극기지", base = 0x6070 },
    { name = "노틸러스", base = 0x608C },
    { name = "그라탱",   base = 0x60A8 },
    { name = "장",       base = 0x60C4 },
    { name = "킹",       base = 0x60E0 },
    { name = "그란디스", base = 0x60FC },
}

local enemies = {
    { name = "황제",       base = 0x6118 },
    { name = "측근",       base = 0x6134 },
    { name = "가고일",     base = 0x6150 },
    { name = "오르세트라스", base = 0x616C },
    { name = "바벨의탑",   base = 0x6188 },
    { name = "가피쉬",     base = 0x61A4 },
    { name = "인간탱크",   base = 0x61C0 },
    { name = "게로봇",     base = 0x61DC },
    { name = "다이소닉스", base = 0x61F8 },
    { name = "대장",       base = 0x6214 },
}

local function read16(addr)
    local lo = emu.read(addr, M)
    local hi = emu.read(addr + 1, M)
    return lo + hi * 256
end

local function read8(addr)
    return emu.read(addr, M)
end

local function buildLine(unit)
    local lv    = read8(unit.base + 0x03)
    local exp   = read16(unit.base + 0x06)
    local hp    = read16(unit.base + 0x08)
    local mp    = read16(unit.base + 0x0A)
    local maxHp = read16(unit.base + 0x0C)
    local maxMp = read16(unit.base + 0x0E)

    return string.format(
        "%s|%d|%d|%d|%d|%d|%d",
        unit.name,
        lv,
        exp,
        hp,
        maxHp,
        mp,
        maxMp
    )
end

local frame = 0

local function sendStatus()
    frame = frame + 1

    -- 약 10Hz
    if frame % 6 ~= 0 then
        return
    end

    local lines = {}

    table.insert(lines, "[ALLY]")

    for _, c in ipairs(allies) do
        table.insert(lines, buildLine(c))
    end

    table.insert(lines, "[ENEMY]")

    for _, e in ipairs(enemies) do
        table.insert(lines, buildLine(e))
    end

    local packet = table.concat(lines, "\n")

    udp:send(packet)
end

emu.addEventCallback(
    sendStatus,
    emu.eventType.endFrame
)

emu.log("Nadia UDP bridge started")
import socket
import sys
import os
import unicodedata

HOST = "127.0.0.1"
PORT = 5005

if os.name == "nt":
    os.system("")

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((HOST, PORT))


def display_width(text):
    width = 0

    for ch in text:
        if unicodedata.east_asian_width(ch) in ("W", "F"):
            width += 2
        else:
            width += 1

    return width


def pad_right(text, width):
    current = display_width(text)
    return text + (" " * max(0, width - current))


def pad_left(text, width):
    current = display_width(text)
    return (" " * max(0, width - current)) + text


def parse_packet(text):
    allies = []
    enemies = []

    target = None

    for raw in text.splitlines():
        line = raw.strip()

        if not line:
            continue

        if line == "[ALLY]":
            target = allies
            continue

        if line == "[ENEMY]":
            target = enemies
            continue

        if target is None:
            continue

        parts = line.split("|")

        if len(parts) != 7:
            continue

        name, lv, exp, hp, max_hp, mp, max_mp = parts

        try:
            target.append({
                "name": name,
                "lv": int(lv),
                "exp": int(exp),
                "hp": int(hp),
                "max_hp": int(max_hp),
                "mp": int(mp),
                "max_mp": int(max_mp),
            })

        except ValueError:
            continue

    return allies, enemies


NAME_W = 16
LV_W = 6
EXP_W = 10
HP_W = 16
MP_W = 16


def build_units(units):
    lines = []

    for u in units:
        lv = f"Lv{u['lv']}"
        exp = f"EXP {u['exp']}"
        hp = f"{u['hp']}/{u['max_hp']}"
        mp = f"{u['mp']}/{u['max_mp']}"

        line = (
            pad_right(u["name"], NAME_W)
            + pad_left(lv, LV_W)
            + pad_left(exp, EXP_W)
            + pad_left(hp, HP_W)
            + pad_left(mp, MP_W)
        )

        lines.append(line)

    return lines


sys.stdout.write("\x1b[2J")
sys.stdout.write("\x1b[?25l")
sys.stdout.flush()

last_screen = ""

try:
    while True:
        data, _ = sock.recvfrom(65535)

        try:
            text = data.decode("utf-8")
        except UnicodeDecodeError:
            continue

        allies, enemies = parse_packet(text)

        lines = []

        lines.extend(build_units(allies))

        lines.append("")

        lines.extend(build_units(enemies))

        screen = "\n".join(lines)

        if screen != last_screen:
            sys.stdout.write("\x1b[H")
            sys.stdout.write(screen)
            sys.stdout.write("\x1b[J")
            sys.stdout.flush()

            last_screen = screen

except KeyboardInterrupt:
    pass

finally:
    sys.stdout.write("\x1b[?25h")
    sys.stdout.write("\n")
    sys.stdout.flush()
    sock.close()

소스 코드

https://github.com/sunlightface/Fushigi-no-Umi-no-Nadia-KR-

패치

MD5 값을 추출해 검색해도 관련 정보가 잘 나오지 않으므로, 일본판 중 롬 파일명 끝에 (Japan) 또는 (J)만 붙은 파일에 패치를 적용

MD5: 6dc8b6972f6193f450b3a92f7df5e37c (J)패치 파일

MD5: 5607cf2af4d0327bf39adaa664436937 (Japan) 패치 파일