실행 파일 정적 분석
← 조사 기록 · 원본: docs/reverse-engineering.md
실행 파일 정적 분석 — 어디까지 되고 어디서 막히나
계산식(최종 피해 합성 순서, 스킬 랭크당 계수, 티어별 아이템 스탯 범위)을 얻으려고 게임 실행 파일을 정적으로 분석한 기록입니다. 결론부터: 절반만 됩니다.
여기 적힌 것은 전부 정적 분석 결과라 검증되지 않았습니다. 시뮬레이터 계산에는
반영하지 않았고, rules.json의 값은 여전히 게임 텍스트에 적힌 것만 씁니다.
왜 디컴파일이 안 되나
이 게임은 YYC(YoYo Compiler) 빌드입니다. GML이 GameMaker VM 바이트코드가 아니라 네이티브 기계어로 컴파일돼 실행 파일에 들어갑니다.
$ UndertaleModCli info game.unx
Is YYC - True
Unknown amount of Code entries
그래서 UndertaleModTool 같은 도구는 꺼낼 코드가 없다며 종료합니다. 데이터 파일에는 스프라이트·룸·문자열만 남습니다.
실행 파일 쪽도 만만치 않습니다.
$ nm Hero_Siege → no symbols (스트립됨)
.text 크기 → 161MB
gml_Script_* 문자열 → 4,497개 (문자열일 뿐 심볼 아님)
뚫은 곳: 이름 → 함수 주소
YYC는 script_execute 같은 동적 호출을 위해 등록 테이블을 남깁니다.
[이름 포인터][함수 포인터] 24바이트 항목이 늘어선 구조입니다. 이걸 찾으면 스트립된
바이너리에서도 이름으로 함수 위치를 알 수 있습니다.
python3 scripts/re_function_map.py # 2026-08-25 빌드 → 함수 20,847개
현재 빌드에서 CalculateEndDamage는 0x90cf60이고 다음 등록 함수까지 60,928바이트입니다.
“161MB 건초더미에서 바늘 찾기” 문제는 해결된 셈입니다.
시즌 이전 빌드에서는 주소가 0x7b2a10, 크기가 24,634바이트였습니다. 주소뿐 아니라
크기도 패치마다 바뀌므로 re_trace_damage.py는 하드코딩한 크기 대신 함수 맵의 다음
등록 주소로 경계를 잡습니다. 이 경계에도 미등록 함수나 패딩이 섞일 수 있어 정확한
함수 크기로 확정하지는 않습니다.
얻은 것: 피해 계산의 호출 순서
CalculateEndDamage가 부르는 스크립트를 등장 순서대로 나열하면 이렇습니다.
GPV → ReturnSpecificStat ×6 (플레이어 스탯 수집)
EnemyGetBuff
LoadMonsterBreaks (브레이크 상태)
LoadDamageMultipliers ← 여기서 유일한 인라인 덧셈 2회
LoadMonsterTalentModifiers
LoadMonsterExtraDamageAilments
ReturnSpecificStat ×5
LoadMonsterBreaks ×2
LoadMonsterResistance (저항 적용)
EnemyGetBuff
LoadMonsterElementalWeakness (원소 약점)
LoadMonsterTopDamageModifiers
ReturnSpecificStat ×2
읽어지는 그림: 플레이어 스탯을 모아 배수를 곱한 뒤, 몬스터 쪽 수정치 → 저항 → 원소 약점 순으로 적용합니다. 저항이 배수보다 뒤에 온다는 것이 확인됩니다.
다만 이건 정적인 등장 순서라 분기·반복을 감안하면 실행 순서와 다를 수 있습니다.
함수 안의 부동소수 상수는 0.0, 0.01, 1.0 셋뿐이었습니다. 0.01은 퍼센트를
소수로 바꾸는 값이고 1.0은 1 + x 형태의 밑입니다. 즉 계수를 코드에 박아두지 않고
데이터로 들고 다니며 퍼센트 곱연산으로 합성합니다.
막힌 곳: 실제 산술
시즌 이전 24,634바이트 함수 전체에 부동소수 연산 명령이 2개뿐이었습니다.
연산 분포: Counter({'addsd': 2})
산술이 인라인으로 되어 있지 않다는 뜻입니다. YYC는 모든 값을 RValue 태그드 유니온으로
감싸고 사칙연산을 런타임 헬퍼 함수 호출로 처리합니다. 그래서 objdump로 보면 이름 없는
sub_xxxxxxx 호출만 줄줄이 나오고, 어떤 연산이 어떤 순서로 적용되는지는 보이지 않습니다.
계수 쪽도 같은 벽입니다. LoadTalentDamageBase는 808바이트에 상수가 하나도 없는 얇은
래퍼라 전역 배열을 읽을 뿐이고, 그 배열을 채우는 DefineItemNormal* 계열은 함수 하나가
95KB에 정수 즉시값이 1,692개입니다. 그중 무엇이 아이템 수치이고 무엇이 스택 오프셋·타입
태그인지는 디컴파일러 없이 가릴 수 없습니다.
우회로: 연산자 헬퍼를 식별해서 흐름 읽기
산술이 헬퍼 호출로 처리된다면, 그 헬퍼가 무슨 연산인지 알아내면 호출 나열을 연산 기호로 바꿔 읽을 수 있습니다. 헬퍼 본문에 어떤 SSE 명령이 들어 있는지로 가려냅니다.
0x669720 116회 addsd만 → 덧셈 계열
0x669930 26회 mulsd 포함 → 곱셈 계열
0x66a420 6회 subsd만 → 뺄셈
0x9b0da50 46회 ucomisd → 비교
이걸로 CalculateEndDamage 의 흐름을 다시 뽑으면 이렇게 읽힙니다.
▶ LoadDamageMultipliers
+ * + + + * + + + + clamp × 6 ...
▶ LoadMonsterResistance
* cmp cmp cmp cmp cmp cmp * - cmp cmp * * + ...
▶ LoadMonsterElementalWeakness
저항 직후에 비교(상한 처리)들 뒤에 곱셈과 뺄셈이 오는 모양이라, 피해 × (1 − 저항)
형태로 보입니다. 최대 저항 상한을 먼저 걸고 적용하는 것과도 맞아떨어집니다.
python3 scripts/re_trace_damage.py CalculateEndDamage
python3 scripts/re_trace_damage.py ReturnTalentValue 8000
한계를 분명히 해둡니다. 여기 찍히는 + 는 상당수가 배열 인덱스 계산이나 RValue
복사입니다. 의미 있는 덧셈만 걸러낸 게 아닙니다. 곱셈·뺄셈이 어디에 몰리는지를 보는
용도로만 쓸 수 있고, 이 흐름은 어디까지나 가설입니다.
검증 방법은 verification-checklist.md 4번에 적어 뒀습니다.
주는 피해와 받는 피해는 다른 함수입니다
함수 맵에서 “Resist” 가 붙은 스크립트는 LoadMonsterResistance 하나뿐이고, 그건
CalculateEndDamage 에서만 불립니다. 즉 CalculateEndDamage 는 플레이어 → 몬스터
경로이고 거기서 깎이는 것은 몬스터의 저항입니다.
내가 받는 피해는 PlayerTakeDamage 라는 별도 함수가 처리합니다. 흐름이 눈에 띄게
다릅니다.
▶ IsMyClient
cmp + / / + cmp + cmp * + / / + + + + / / + + + + + / / + + + + + / / + + + * -
+ + cmp cmp cmp cmp cmp
나눗셈 쌍이 정확히 다섯 번 반복되고, 그 뒤에 곱셈·뺄셈과 비교 다섯 번이 옵니다.
다섯은 원소 5종(불·냉기·번개·독·아케인)에, 뒤의 비교 다섯은 최대 저항 상한
(max_*_resist_percent)에 대응하는 것으로 읽힙니다.
주의: 이 함수는 경계를 정확히 못 잡아 뒷부분에는 다른 코드가 섞여 있을 수 있습니다. 위 판단은 앞부분만 근거로 합니다.
스킬 계수도 데이터였습니다
ReturnTalentValue 를 같은 방법으로 뜨면 GetTalentInfo 를 반복해서 부르고 그 사이에
array_contains 로 조건을 검사합니다. 랭크당 계수를 코드에 박아둔 게 아니라 배열에서
꺼내 쓴다는 뜻이라, 여기서도 숫자는 안 나옵니다.
클래스 기본 생명력·마나는 바이너리에 있습니다
함수 맵에 load_class_base_stats 가 있습니다. 5,440바이트로 작고, 안에 실수 상수가
실제로 들어 있습니다.
65.0 50.0 45.0 8.0 10.0 4.0 3.5 0.75 0.25 …
다만 어느 숫자가 생명력이고 어느 게 마나인지, 클래스마다 다른지는 알 수 없습니다. 변수 이름이 없고, 22개 클래스만큼 블록이 반복되지도 않습니다(클래스별 값이 다른 배열을 인덱싱하는 구조일 수 있습니다).
그래서 이 값은 게임에서 스탯 창을 한 번 보는 쪽이 확실하고 빠릅니다.
저항 상한·하한은 못 찾았습니다
LoadMonsterResistance(7,200B), LoadMonsterBreaks, LoadMonsterElementalWeakness
어디에도 실수 상수가 없습니다. 저항 상한(기본 60%로 알려진 값)이나 음수 하한이
코드에 박혀 있지 않고 데이터로 오거나, CalculateEndDamage 의 비교(cmp) 뭉치에서
다른 값과 견주는 형태로 보입니다. 난이도별 저항 페널티도 ReturnSubDifficultyStat
(11,888B)에 실수 상수가 없어 확인하지 못했습니다.
다음 단계의 비용
여기서 더 가려면 Ghidra 같은 디컴파일러가 필요합니다. 161MB 바이너리 분석에 시간과 메모리가 상당히 들고, YYC의 RValue 코드는 디컴파일해도 읽기 까다롭습니다.
그리고 어느 쪽이든 검증 문제가 남습니다. 잘못 읽은 계산식은 없느니만 못합니다. 확인하려면 게임을 돌려 측정해야 하는데, 디버거나 메모리 리더를 붙이는 방식은 EasyAntiCheat와 부딪혀 계정 위험이 있습니다. 정적 분석은 EAC와 무관하지만, 실행 중인 게임을 건드리는 것은 다릅니다.
그래서 실제로 더 싼 길은 게임 안에서 직접 측정하는 것입니다 — 스탯 하나만 바꿔가며
숫자 변화를 기록하는 방식. wiki.gg Mechanics의 계수도 커뮤니티가 그렇게 뽑았습니다.