본문으로 건너뛰기

결정 아홉

「Lua 언어 지원」으로 돌아가기


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.1FFI 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_fixed32snprintf 래퍼 하나뿐입니다. 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 분기 몇 줄입니다. requireluaopen_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 모듈입니다. 그때 옮기는 것은 컬럼 디코드이고 생성 코드의 형태는 유지됩니다

6. 결정 5 — 행은 평범한 테이블이되, 없는 키는 오류입니다

결정. 로우와 레코드 원소는 평범한 Lua 테이블입니다. 필드는 camelCase 키이고, 값은 로드 시점에 전부 디코드되어 들어갑니다 — 지연 디코드를 두지 않습니다. 그리고 타입마다 하나씩 공유되는 엄격 메타테이블이 붙습니다 — 없는 키를 읽으면 nil이 아니라 오류입니다.

오타 문제 — 동적 타입 언어 중 Lua만 더 나쁜 자리

row.maxHprow.maxHP로 적으면 Python·Ruby는 그 자리에서 AttributeError로 멈추지만, Lua는 조용히 nil을 돌려주고 그 nil은 산술을 만나는 먼 곳에서 다른 이름의 오류로 나타나거나, or 기본값 뒤에 숨어 영영 나타나지 않습니다. 15번째 언어 중 읽기 오타가 오류조차 아닌 언어는 Lua가 처음이므로, 다른 언어에서는 공짜였던 것을 여기서는 만들어야 합니다. 두 겹으로 막습니다.

무엇잡는 시점
엄격 메타테이블__index가 「Item 로우에 maxHP 필드가 없습니다」로 error합니다. __newindex도 같습니다 — 오타로 새 키를 만드는 것도 막힙니다실행 시, 접근한 그 줄에서
LuaLS 주석생성 파일에 ---@class ItemRow·---@field maxHp integer 주석을 답니다. lua-language-server를 쓰는 편집기와 그 CLI 검사가 오타를 표시합니다편집 시

정상 경로의 비용은 0입니다. __index·__newindex는 키가 테이블에 없을 때만 불리는데, 이 형식의 계약이 「모든 필드는 로드 시점에 언제나 채워져 있다」이므로 올바른 접근은 메타테이블에 닿지 않습니다. 메타테이블은 로우마다가 아니라 생성 타입마다 하나이고, 로우·레코드 원소·enum·상수 세트·액세서가 전부 같은 장치를 씁니다 — Keyword.ENDDlimits.maxSlot 오타로 nil이 산술에 흘러드는 것이 상수 쪽에서 더 아픕니다.

이 장치는 옵셔널 계약의 집행이기도 합니다 — if row.hp == nil처럼 nil로 존재를 확인하는 코드는 이 계약에서 이미 잘못이고, 엄격 메타테이블 아래에서는 그 잘못이 즉시 오류로 나타나 hasHp로 안내됩니다. 의도적인 동적 접근에는 rawget이 그대로 열려 있고, 로우에 자기 데이터를 붙이고 싶은 소비자에게는 rawset이 있습니다 — 둘 다 표준 함수라 문서 한 줄이면 됩니다.

옵셔널 필드는 옵셔널 필드의 규칙을 그대로 따릅니다 — 값 키는 언제나 채워져 있고, 존재 여부는 hasHp 같은 이웃 키가 정합니다. nil로 내지 않습니다. Lua에서 nil 값의 키는 없는 키와 구별되지 않으므로, nil로 내는 순간 「값은 항상 읽힌다, 없음은 따로 확인한다」는 다른 언어와의 계약이 깨지는 것에 더해 pairs 순회에서 필드 자체가 사라집니다.

배열은 1-기반 시퀀스입니다. 시트의 name[0]row.name[1]이 됩니다. 0-기반을 유지하는 테이블(t[0])은 #tipairs가 성립하지 않는, Lua 사용자가 한 번도 본 적 없는 형태입니다. 원소를 무엇에 두는지가 언어마다 그 언어의 관례를 따랐듯이 (기록) 첨자도 언어의 것을 따르고, 한 칸의 이동은 언어 가이드에 적습니다.

고정 길이 레코드 배열을 만드는 자리는 읽기 패스입니다(Go·Rust와 같은 쪽) — 길이는 파일의 컬럼이 정하는 원소 수이고, 첫 멤버 컬럼에 맡기면 그 멤버가 빠진 파일에서 뒤 멤버들이 빈 배열을 인덱싱합니다.

7. 결정 6 — 이름: 전역 0개, 상대 require, 예약어는 이름을 유지

무엇어떻게근거
전역 없음모든 생성 파일과 런타임이 local로만 선언하고 테이블 하나를 return합니다임베디드 Lua에서 전역은 모든 스크립트와 공유되는 이름 공간입니다. 리더의 내부 이름 30여 개가 게임 스크립트에 풀리는 것은 Swift에서 enum Tcb로 막은 것과 같은 사고입니다
상대 require생성 파일이 자기 모듈 이름(...)에서 접두어를 떼어 require(prefix .. "tabbit.tcb_reader") 형태로 이웃을 찾습니다산출물이 소비자의 트리 어디에 놓일지 이 도구는 모릅니다. 접두어를 recipe 옵션으로 받으면 Swift에서 두지 않기로 한 Namespace와 같은, 옮길 때마다 다시 변환해야 하는 옵션이 됩니다
예약어는 이름 유지end·local 같은 필드는 이름을 바꾸지 않고 생성 코드가 대괄호 문자열 키(row["end"])로 냅니다Kotlin·Swift가 백틱으로 이름을 지킨 것과 같은 판단입니다. Lua의 테이블 키는 아무 문자열이나 되므로 바꿀 이유가 없고, 대가는 생성기가 선언 자리(["end"] = 0)와 접근 자리(row["end"])의 두 표기를 갖는 것입니다

액세서의 로드는 디렉터리 경로 또는 읽기 콜백을 받습니다 — load{ directory = ... } 또는 load{ read = function(name) ... end }. 임베디드 환경은 io가 막혀 있거나 파일이 자체 아카이브 안에 있는 경우가 흔하므로, 바이트를 가져오는 수단은 주입 가능해야 합니다. 키는 MAC 설계 4절의 표기 그대로 key·macKey·verifyMac을 같은 옵션 테이블에서 받습니다.

8. 결정 7 — uuid는 정규형 문자열

결정. uuid는 소문자·하이픈의 정규형 문자열입니다. 전용 타입을 만들지 않습니다.

Lua에는 값 타입을 새로 정의할 방법이 문자열·테이블·userdata뿐이고, 그중 키가 될 수 있고 비교가 값 비교인 것은 문자열뿐입니다. uuid 키 테이블의 인덱스, 상수, 하네스 출력이 전부 그 성질을 요구합니다. 대가는 값당 16바이트가 36자 문자열이 되는 것이고, 리더가 .NET Guid 바이트 순서(앞의 세 성분만 리틀엔디언)를 문자열로 푸는 자리에서 지불합니다.

datetime·timespan은 틱 정수입니다 — Kotlin·Java·Swift와 같은 이유로, 왕복이 손실인 날짜 타입 대신 손실 없는 정수를 냅니다.

9. 결정 8 — 산출물의 형태와 recipe 옵션

<Path>/
tabbit/tcb_reader.lua 임베디드 리소스에서 복사
tabbit/tcb_ops_jit.lua 숫자 백엔드 — LuaJIT
tabbit/tcb_ops_53.lua 숫자 백엔드 — 5.3+
tabbit/updater.lua WriteUpdater일 때만
tabbit/native/tabbit_native.c 항상 — 평문만 쓰는 프로젝트는 컴파일하지 않으면 됩니다
tables.lua 액세서 (AccessorName)
enums/enum_<enum>.lua
tables/<table>_table.lua
constants/const_<set>.lua
옵션기본값비고
Path없으면 이 타깃은 아무것도 하지 않습니다
AccessorNametables액세서 모듈 이름이자 파일 이름. Lua 모듈 이름 관례가 소문자입니다
BinaryTableFileExtension.tcb바이너리 익스포터에 적은 것과 같아야 합니다
WriteUpdaterfalsetabbit/updater.lua를 함께 냅니다
Sweeptrue이 실행이 쓰지 않은 생성 파일을 지웁니다
TargetSidecs

Namespace·PackageName은 없습니다. Lua의 모듈 이름은 파일이 선언하는 것이 아니라 package.path에 어떻게 놓이는지가 정합니다 — 7절의 상대 require가 그 자리를 대신합니다. rockspec(WriteManifest)도 없습니다. 처음 안에는 LuaRocks 사용자용으로 있었는데, 첫 소비자가 엔진 임베드 쪽이고 매니페스트는 소스 통합에서 방해만 되므로 범위에서 뺐습니다 — 필요해지는 날 이 절이 다시 정하는 자리입니다.

10. 결정 9 — 업데이터의 HTTP는 주입받습니다

결정. 업데이터의 로직(매니페스트 대조, 바뀐 파일만, 스테이징, MD5 검증)은 C 업데이터의 설계 그대로 Lua로 옮기되, HTTP는 구현하지 않고 fetch 콜백으로 받습니다.

Lua 표준 라이브러리에는 네트워크가 없습니다. 갈림은 LuaSocket 의존이냐 주입이냐인데, 업데이터를 쓸 소비자(게임 클라이언트)는 예외 없이 자기 HTTP 스택을 이미 갖고 있고, LuaSocket을 요구하면 그 스택과 두 번째 HTTP가 됩니다. 재시도·backoff는 우리 로직이므로 콜백 바깥에 남고, 콜백의 계약은 「URL을 받아 본문 또는 nil, 오류」 하나입니다. LuaSocket 사용자를 위한 어댑터 예시는 문서에 적습니다.

MD5·mkdir·재시도 대기(sleepMs)가 C 모듈에서 오므로 업데이터는 C 모듈 없이는 돌지 않습니다 — 리더와 달리 조건 없이 요구하고, 없으면 무엇을 빌드해야 하는지 함께 출력합니다.