결정 아홉
2. 결정 1 — 두 모드: LuaJIT 2.1과 순수 Lua 5.3+
결정(개정). 리더는 두 모드를 지원합니다 — LuaJIT 2.1(Lua 5.1 API)과 순수 Lua 5.3 이상. Lua 5.1·5.2의 순수 인터프리터는 지원하지 않습니다.
처음 권고는 「5.3 이상만, LuaJIT 제외」였고, 그 문안 자신이 「소비 프로젝트가 LuaJIT이면 이 결정을 먼저 뒤집고 시작해야 한다」고 적어 두었습니다. 소비 환경 조사가 정확히 그 경우였습니다 — 이전 대규모 코퍼스의 언리얼 클라이언트가 엔진 플러그인으로 LuaJIT 2.1.0-beta3을 소스 임베드하고 있고(FFI 켜짐, 스크립트를 LuaJIT 바이트코드로 사전 컴파일), 게임 로직이 Lua 스크립트 3,508개입니다. 첫 소비자가 LuaJIT이므로 LuaJIT이 범위 밖일 수 없습니다.
버전을 가르는 기준은 그대로 64비트 무손실입니다 — bigint·datetime 틱은 2⁵³을
넘고(현재 날짜의 .NET 틱이 약 6.4×10¹⁷, double의 정수 정밀도는 9×10¹⁵), 같은 double
언어인 TypeScript가 bigint로 낸 선례와 하네스의 출력 계약이 그 선을 요구합니다. 두
모드는 그 선을 서로 다른 도구로 지킵니다.
| 모드 | 64비트 | 바이트 접근 | 판별 |
|---|---|---|---|
| LuaJIT 2.1 | FFI int64_t cdata — 산술·비교가 일반 연산자로 성립하고, 2.1의 bit.*가 64비트 cdata를 받습니다 | ffi.cast한 포인터 — string.unpack보다 빠른 경로입니다 | jit 전역과 ffi의 존재 |
| 순수 5.3+ | 네이티브 64비트 정수 | string.unpack | 기본 |
순수 5.1·5.2를 지원하지 않는 것은 이 표의 왼쪽 열이 전부 없는 조합이기 때문입니다 —
64비트도, FFI도, string.unpack도 없어 세 가지 다 손 구현이 되고, 그 환경의 실제
사용자를 아직 보지 못했습니다.
두 모드의 형태 — 백엔드 분리
5.1 구문에는 //도 비트 연산자도 없으므로, 5.3 연산자 구문으로 쓴 코드는 LuaJIT에서
로드조차 되지 않습니다. 그래서 디코드 로직은 하나이고, 저수준 연산(정수 나눗셈·비트·
64비트 읽기)만 백엔드 모듈 2개(각 100줄 안팎)로 갈립니다. 함수 호출을 거치는 비용은
LuaJIT 쪽에서는 인라인으로 사라지고, 5.3+ 쪽은 Python 리더가 받아들인 것과 같은 종류의
비용입니다.
FFI cdata의 함정 하나가 인덱스의 형태를 정합니다 — cdata는 테이블 키로 값 비교가 아니라
참조 비교라서, int64 키를 해시 테이블에 그대로 넣을 수 없습니다. 그래서 int64 키 인덱스는
두 모드 공통으로 십진 문자열을 키로 씁니다 — findBy…가 tcb.int64String으로 정규화해
넣고 꺼내므로 호출자는 모릅니다. 처음 적었던 안은 정렬 배열 + 이진 탐색이었는데, 문자열 키가
같은 성질(두 모드 공통·무손실)을 더 적은 코드로 주어서 구현에서 이쪽으로 갈아탔습니다.
key-types 게이트가 2⁵³ 너머의 키로 이것을 확인합니다.
LuaJIT이면 순수 Lua 크립토도 감당하지 않느냐는 질문에는 — 콘솔과 iOS에서 JIT이 금지되어 LuaJIT이 인터프리터 모드로 동작하므로, C 모듈 전제(3절)는 그 환경에서도 그대로 유효합니다.
3. 결정 2 — 암호·해시·체크섬은 전부 C 모듈이고, 모듈이 없어도 평문은 읽힙니다
결정. ChaCha20·HMAC-SHA-256(MAC 검증)·MD5(업데이터의 매니페스트 해시)를 전부
네이티브 C 모듈 tabbit.native에 둡니다. 순수 Lua 구현은 만들지 않습니다.
| 구성 | 평 문 파일 | 암호화·MAC 파일 | 업데이터 |
|---|---|---|---|
| 순수 Lua만 | 읽힙니다 | 무엇을 빌드해야 하는지 적힌 오류 | 같은 오류 |
+ tabbit.native | 읽힙니다 | 읽힙니다 | 됩니다 |
두 번째 줄이 아니라 첫 번째 줄이 이 설계의 요점입니다. 리더는 C 모듈을 파일 상단이
아니라 암호 경로에 들어갈 때만 require합니다 — Python 리더가 _chacha20 함수 안에서
cryptography를 import하고, 없으면 pip install cryptography를 함께 출력하고 실패하는 것과
같은 형태입니다. 파일이 암호화되어 있지 않은 프로젝트는 C 컴파일러를 만나지 않습니다.
순수 구현을 만들지 않는 근거
MAC 설계의 실측에서 가장 느린 직접 구현이 TypeScript(Node)의 약 225 MB/s였습니다. Lua 인터프리터의 32비트 산술 루프는 그보다 한 자릿수 아래이고, 8 MB 파일의 MAC 검증에만 수백 ms가 갑니다. 그 돈을 내고 얻는 것이 없으므로 순수 경로 자체를 만들지 않습니다 — 존재하면 누군가 쓰게 되고, 느린 경로는 지원 대상이 됩니다.
4. 결정 3 — C 모듈은 함수 4개, 소스 하나, 새 암호 구현 0줄
결정. tabbit.native가 노출하는 것은 4개뿐입니다.
| 함수 | 무엇 | 어디서 오는가 |
|---|---|---|
open(bytes, key, macKey, verifyMac) | 시그니처·MAC 검증·keyCheck·복호를 한 번에. 평문 형태의 문자열을 돌려주고, 거부는 오류입니다 | C 런타임의 tb_open |
md5hex(bytes) | 32자리 소문자 16진수 | C 업데이터의 tb_md5_hex |
mkdir(path) | 디렉터리 한 단계 생성 — 업데이터용. os에 없는 파일계 연산입니다 | 새로 씁니다(수 줄) |
sleepMs(밀리초) | 업데이터의 재시도 대기 — os에 없고, 바쁜 대기가 대안이라 여기 있습니다 | C 업데이터의 tb_sleep_ms와 같은 형태(수 줄) |
처음 적었던 안은 3개였고 sleepMs는 업데이터를 옮기면서 늘었습니다 — Lua에 sleep이 없다는
사실이 그때 드러났습니다.
암호 코드는 한 줄도 새로 쓰지 않습니다. lib/c/tabbit/tabbit_tcb_reader.h의
2506~2918행이 ChaCha20 + SHA-256 + HMAC + tb_open의 연속 블록이고, 리더 상태(tb_reader·
아레나·커서)에 전혀 닿지 않습니다 — 딸려 와야 하는 것은 4줄짜리 tb_load_fixed32와
snprintf 래퍼 하나뿐입니다. tb_md5_hex도 완전 독립입니다(현재는 업데이터 헤더의 구현부에
있어 libcurl과 같은 번역 단위에 묶여 있으므로, 옮겨 적을 때 그 결합만 풀면 됩니다 —
libcurl은 가져오지 않습니다).
산출물의 tabbit/native/tabbit_native.c는 그 블록의 사본 + Lua 바인딩(약 100줄)을 담은
자족적인 단일 소스입니다. 다른 언어의 리더처럼 임베디드 리소스에서 복사되고,
lua.h와 libc 외에는 아무것도 include하지 않으며, Lua 5.1(LuaJIT)~5.4에서 컴파일됩니다
— 갈리는 API는 등록 함수 정도라 LUA_VERSION_NUM 분기 몇 줄입니다. require용
luaopen_tabbit_native와, 게임 엔진이 정적으로 심을 때 쓰는 등록 함수를 둘 다 냅니다 —
언리얼에 Lua를 임베드한 프로젝트가 이미 갖고 있는 「C++ 유틸을 Lua에 등록하는 파일」 옆에
이 소스 하나를 놓고 등록 한 줄을 부르는 형태가 됩니다.
tb_open을 옮길 때 달라지는 것 하나 — 제자리가 아니라 사본
C의 tb_open은 버퍼를 제자리에서 복호하지만 Lua 문자열은 불변이므로, 바인딩은 같은
길이의 사본을 만들어 복호해 넣고 그 문자열을 돌려줍니다. Java·Kotlin이 byte[] 창의
부재로 이미 같은 형태를 택했고(기록),
할당은 파일당 한 번입니다. 돌려주는 문자열은 헤더가 평문 상태로 되돌려진 형태라, 같은
결과를 다시 열어도 복호가 두 번 돌지 않습니다.
5. 결정 4 — 컬럼 디코드는 순수 Lua입니다
결정. 인코딩 14종의 디코드, varint, presence 비트맵은 순수 Lua로 씁니다. C 모듈이 맡는 것은 3절의 암호·해시까지입니다.
| 근거 | 내용 |
|---|---|
| 전제의 범위가 그렇습니다 | C로 내리기로 한 것은 암호·해시·체크섬 — 파일 전체를 훑는 O(파일 크기) 패스들입니다. 디코드도 같은 자릿수의 일이지만, Python·Ruby·PHP가 순수 디코드로 이미 받아들여진 비용입니다 |
| 5.3의 도구가 그 비용을 줄입니다 | string.unpack이 고정폭 원소를 배치로 읽고, 정수 비트 연산이 varint·zig-zag를 네이티브로 처리합니다. 5.1이었다면 답이 달랐습니다 |
| 물러날 자리가 이미 준비되어 있습니다 | 실측(12절 계측 게이트)에서 로드 시간이 감당이 안 되면, 옮길 곳은 새로 설계할 것 없이 이미 존재하는 C 모듈입니다. 그때 옮기는 것은 컬럼 디코드이고 생성 코드의 형태는 유지됩니다 |