영찬 블로그
About

Ascii Fluids Deobfuscation
Yusuke Endoh의 Ascii Fluids 코드 분석
2018-08-06

프로그래밍의 흑마법, 코드골프 등에 관심이 있는 사람들이라면 한번쯤은 IOCCC에 대해 들어봤을 것이다. 아니면 IOCCC을 들어본 적은 없더라도 ASCII fluid dynamics 라는 영상은 봤을 지도 모른다.

그리고 이 프로그램의 소스 코드는 다음과 같다.

#  include<stdio.h>//  .IOCCC                                         Fluid-  #
#  include <unistd.h>  //2012                                         _Sim!_  #
#  include<complex.h>  //||||                     ,____.              IOCCC-  #
#  define              h for(                     x=011;              2012/*  #
#  */-1>x              ++;)b[                     x]//-'              winner  #
#  define              f(p,e)                                         for(/*  #
#  */p=a;              e,p<r;                                        p+=5)//  #
#  define              z(e,i)                                        f(p,p/*  #
## */[i]=e)f(q,w=cabs  (d=*p-  *q)/2-     1)if(0  <(x=1-      w))p[i]+=w*/// ##
   double complex a [  97687]  ,*p,*q     ,*r=a,  w=0,d;    int x,y;char b/* ##
## */[6856]="\x1b[2J"  "\x1b"  "[1;1H     ", *o=  b, *t;   int main   (){/** ##
## */for(              ;0<(x=  getc (     stdin)  );)w=x  >10?32<     x?4[/* ##
## */*r++              =w,r]=  w+1,*r     =r[5]=  x==35,  r+=9:0      ,w-I/* ##
## */:(x=              w+2);;  for(;;     puts(o  ),o=b+  4){z(p      [1]*/* ##
## */9,2)              w;z(G,  3)(d*(     3-p[2]  -q[2])  *P+p[4      ]*V-/* ##
## */q[4]              *V)/p[  2];h=0     ;f(p,(  t=b+10  +(x=*p      *I)+/* ##
## */80*(              y=*p/2  ),*p+=p    [4]+=p  [3]/10  *!p[1])     )x=0/* ##
## */ <=x              &&x<79   &&0<=y&&y<23?1[1  [*t|=8   ,t]|=4,t+=80]=1/* ##
## */, *t              |=2:0;    h=" '`-.|//,\\"  "|\\_"    "\\/\x23\n"[x/** ##
## */%80-              9?x[b]      :16];;usleep(  12321)      ;}return 0;}/* ##
####                                                                       ####
###############################################################################
**###########################################################################*/

소스와 문제에 대한 설명은 ioccc 페이지 에서 찾을 수 있다. SPH 알고리즘을 사용하여 만든 액체 시뮬레이션이지만, 저 짧고 괴랄한 코드로 어떻게 이게 가능한지 궁금하여 코드를 나름 분석해보았고, 이게 굉장히 재밌어서 공유해본다.

복호화

일단 소스 코드에서 주석을 지우고 인덴트만 맞춰보자. 그러면 다음과 같이 된다.

#  include <stdio.h>
#  include <unistd.h>
#  include <complex.h>
#  define h for(x=011;2012-1>x++;)b[x]
#  define f(p,e) for(p=a;e,p<r;p+=5)
#  define z(e,i) f(p,p[i]=e)f(q,w=cabs(d=*p-*q)/2-1)if(0<(x=1-w))p[i]+=w*
double complex a [97687],*p,*q,*r=a,w=0,d;
int x,y;char b[6856]="\x1b[2J"  "\x1b"  "[1;1H     ",*o=b,*t;
int main(){ 
    for(;0<(x=getc(stdin));)
        w=x>10?32<x?4[*r++=w,r]=w+1,*r=r[5]=x==35,r+=9:0,w-I:(x=w+2);;
    for(;;puts(o),o=b+4) {
        z(p[1]*9,2)w;
        z(G,3)(d*(3-p[2]-q[2])*P+p[4]*V-q[4]*V)/p[2];
        h=0;
        f(p,(t=b+10+(x=*p*I)+80*(y=*p/2),*p+=p[4]+=p[3]/10*!p[1]))
        x=0<=x&&x<79&&0<=y&&y<23?1[1[*t|=8,t]|=4,t+=80]=1, *t|=2:0;
        h=" '`-.|//,\\"  "|\\_"    "\\/\x23\n"[x%80-9?x[b]:16];
        usleep(12321);
    }
    return 0;
}

여기서 매크로까지 전부 치환시켜보자.

#  include <stdio.h>
#  include <unistd.h>
#  include <complex.h>

double complex a [97687],*p,*q,*r=a,w=0,d;
int x,y;char b[6856]="\x1b[2J"  "\x1b"  "[1;1H     ",*o=b,*t;
int main() {
    for(;0<(x=getc(stdin));)
        w=x>10?32<x?4[*r++=w,r]=w+1,*r=r[5]=x==35,r+=9:0,w-I:(x=w+2);;
    for(;;puts(o),o=b+4) {
        for(p=a;p[2]=p[1]*9,p<r;p+=5)
            for(q=a;w=cabs(d=*p-*q)/2-1,q<r;q+=5)
                if(0<(x=1-w))p[2]+=w*w;
        for(p=a;p[3]=G,p<r;p+=5)
            for(q=a;w=cabs(d=*p-*q)/2-1,q<r;q+=5)
                if(0<(x=1-w))p[3]+=w*(d*(3-p[2]-q[2])*P+p[4]*V-q[4]*V)/p[2];
        for(x=011;2012-1>x++;)
            b[x]=0;
        for(p=a;(t=b+10+(x=*p*I)+80*(y=*p/2),*p+=p[4]+=p[3]/10*!p[1]),p<r;p+=5)
            x=0<=x&&x<79&&0<=y&&y<23?1[1[*t|=8,t]|=4,t+=80]=1, *t|=2:0;
        for(x=011;2012-1>x++;)
            b[x]=" '`-.|//,\\"  "|\\_"    "\\/\x23\n"[x%80-9?x[b]:16];
        usleep(12321);
    }
    return 0;
}

코드에서 9번째 라인의 정체불명의 w=x>10? ... 부분을 분석해보자. 삼항연산자와 콤마연산자로 복잡하게 연결되있는 코드이다. 일단 삼항 연산자부터 분리해보자. 중요한건 콤마연산자의 우선순위이다.

if (x > 10)
    w = 32<x?4[*r++=w,r]=w+1,*r=r[5]=x==35,r+=9:0,w-I;
else
    w = x = w + 2;

한단계 더 분리할 수 있다.

if (x > 10) {
    if (32 < x)
        w = 4[*r++=w,r]=w+1,*r=r[5]=x==35,r+=9,w-I;
    else
        w = 0, w-I
}
else
    w = x = w + 2;

여기서 콤마 연산자도 늘어놓을 수 있다.

if (x > 10) {
    if (32 < x)
        4[*r++=w,r]=w+1;
        *r=r[5]=x==35;
        r+=9;
        w = w-I;
    else
        w = 0, w-I
}
else
    w = x = w + 2;

충분히 읽을 수 있는 코드가 되었다. 4[*r++=w,r]=w+1 는 다시 다음의 두 문장으로 분리 가능하다.

*r++=w;
4[r]=w+1;

4[r]r[4]와 같으므로 (C언어의 배열 신태틱 슈거 참고) 바꿔 쓸 수 있다.

그리고 xstdin으로부터 한글자씩 가져오는 변수이므로 10보다 작은 문자중 출력가능한 '\n' 만이 첫번째 분기문에서 else문으로 빠질 수 있다. 그렇다면 코드는 다음과 같이 조금 더 보기 좋게 바꿀 수 있다.

while ((x=getc(stdin)) != EOF) {
    if (x != '\n') {
        if (x > 32) {
            *(r++) = w;
            r[4] = w + 1;
            if (x == '#')
                r[0] = r[5] = 1;
            else
                r[0] = r[5] = 0;
            r += 9;
        }
        w = w - I;
    }
    else {
        w = x = w + 2;
    }
}

흥미로운 코드는 첫 분기문에서 else문으로 빠진 부분의 코드이다. w = x = w + 2는 그냥 w += 2 코드로 치환할 수 있어 보이지만, 그렇지 않다. 변수 w 의 타입은 double complex 이고 x의 타입은 int 이다. 즉 x = w + 2 의 값은 w + 2의 실수부가 되며, 그래서 w에는 w의 실수부에 2를 더한 값이 들어가게 된다. 즉 w = x = w + 2w = 2 + creal(2) 로 치환가능하다.

하지만 여전히 이해하기는 쉽지 않은 코드이다. stdin으로부터 입력을 받아와 배열 a를 초기화하는 듯 보인다. 내부에서 x'#' 임을 비교하는 코드는 아마 사용되는 맵에서 '벽' 임을 확인하는 코드일테다. 그리고 복소수인 w는 개행문자를 만나면 (w + 2, 0i) 의 복소수로 이동하고 그렇지 않으면 i 를 하나씩 감소함으로 보아 현재 좌표를 복소수로 저장하는 변수임을 알 수 있다. (x, y)y - xi 꼴로 저장이 된다. 이렇게 이해가 갔다면 좀더 readable 하게 코드를 바꿔보자.

while ((x=getc(stdin)) != EOF) {
    if (x == '\n') {
        // w 에 좌표 (0, y + 2) 넣어서 개행
        w = creal(w + 2);
        continue;
    }
    
    if (x > ' ') {        
        r[0] = w;
        r[5] = w + 1;
        if (x == '#')
            r[1] = r[6] = 1;
        else
            r[1] = r[6] = 0;
        r += 10;
    }

    w -= I; // w의 x좌표 증가
}

인덱스 변수처럼 사용되는 포인터 변수 r을 사용한 배열 a 의 초기화는 조금 있다 살펴보자. 이제 for(;;puts(o),o=b+4) { 으로 시작하는 실제 알고리즘 코드를 살펴볼 차례다.

첫번째 큰 루프는 일단 스킵하고 두개의 중첩된 반복문을 살펴보자.

for(p=a;p[2]=p[1]*9,p<r;p+=5)
    for(q=a;w=cabs(d=*p-*q)/2-1,q<r;q+=5)
        if(0<(x=1-w))p[2]+=w*w;
for(p=a;p[3]=G,p<r;p+=5)
    for(q=a;w=cabs(d=*p-*q)/2-1,q<r;q+=5)
        if(0<(x=1-w))p[3]+=w*(d*(3-p[2]-q[2])*P+p[4]*V-q[4]*V)/p[2];

비슷한 방법으로 난독화된 이런 코드는 콤마 오퍼레이터만 늘어놓기만 해도 코드가 훨씬 이해하기 쉬워진다.

for (p=a; p<r; p+=5) { 
    p[2] = p[1] * 9;
    for (q=a; q<r; q+=5) {
        w = cabs(d=p[0]-q[0])/2-1;
        if(0<(x=1-w))
            p[2] += w * w;
    }
}

for(p=a; p<r; p+=5) {
    p[3] = G;
    for(q=a;q<r;q+=5) {
        w = cabs(d=p[0]-q[0])/2-1;
        if(0<(x=1-w))
            p[3]+=w*(d*(3-p[2]-q[2])*P+p[4]*V-q[4]*V)/p[2];
    }
}

두 반복문에서 똑같이 보이는 w 를 사용한 if(0<(x=1-w)) 코드는 아까 말했던 것처럼 정수 변수를 사용하여 복소수의 실수부를 가져오는 코드이다. 그래서 일반 double 로 다음과 같이 바꿔줘도 문제 없다. 그리고 귀찮은 선언문도 앞으로 빼주자.

for (p=a; p<r; p+=5) { 
    p[2] = p[1] * 9; 
    for (q=a; q<r; q+=5) {
        double i = cabs(p[0]-q[0])/2-1;
        if(0<(int)(1-i))
            p[2] += i * i;
    }
}

for(p=a; p<r; p+=5) {
    p[3] = G;
    for(q=a;q<r;q+=5) {
        d=p[0]-q[0];
        double i = cabs(d)/2-1;
        if(0<(int)(1-i))
            p[3]+=i*(d*(3-p[2]-q[2])*P+p[4]*V-q[4]*V)/p[2];
    }
}

여기서 알 수 있는 중요한 것은 배열 a에서 5씩 증가시켜서 값을 계산한다는 것이다. 초기화 코드를 보면 10을 주기로 초기화를 하지만 r[1] = r[6] = 1 같은 코드는 다음 인덱스에 대해서 초기화를 동시에 해주는 것으로 보인다. 배열 a는 여기서 사용된 알고리즘의 핵심인 SPH 파티클들의 배열이란것을 눈칠챌 수 있다. 다시 초기화 코드로 넘어가서 다음 코드만 보면 된다.

r[0] = w;
r[5] = w + 1;
if (x == '#')
    r[1] = r[6] = 1;

5개의 복소수 값을 가지는 구조체 정도로 생각을 해주면, 0번째 복소수는 좌표를 담고 1번째 복소수는 현재 좌표가 벽인지 확인하는 0혹은 1의 플래그 값임을 알 수 있다. 그리고 위 두 반복문을 보면 p[2] 는 처음 벽의 유무에 따라 0 혹은 9 가 되고 그 다음 모든 원소에 대해 거리의 제곱에 비례하는 값이 더해진다. 바로 현재 파티클이 얼마나 다른 원소들로 밀집되어 있는지 확인하는 밀도이다. 그리고 p[3] 는 처음은 중력을 의미하는 매크로 G로 초기화 되고 복잡한 값들로 초기화가 되는데 여기서는 크게 신경쓰지 않아도 된다.

마지막 남은 코드들을 보자.

for(x=011;2012-1>x++;)
    b[x]=0;
for(p=a;(t=b+10+(x=*p*I)+80*(y=*p/2),*p+=p[4]+=p[3]/10*!p[1]),p<r;p+=5)
    x=0<=x&&x<79&&0<=y&&y<23?1[1[*t|=8,t]|=4,t+=80]=1, *t|=2:0;
for(x=011;2012-1>x++;)
    b[x]=" '`-.|//,\\"  "|\\_"    "\\/\x23\n"[x%80-9?x[b]:16];
usleep(12321);

역시 같은 방법으로 적당히 보기 쉽게 바꿔주자. 다만 첫 반복문의 011은 8진법이므로 실제 값은 9임을 헷갈리지 말아야 한다.

for(x=10; x < 2011; x++)
    b[x]=0;

for(p=a; p<r; p+=5) {
    if (p[1] == 0) {
        p[4] += p[3] / 10;
        p[0] += p[4];
    }
    x = p[0] * I;
    y = p[0] / 2;
    t = b + 10 + x + 80 * y;

    if (0<=x && x<79 && 0<=y && y<23) {
        t[0] |= 8;
        t[1] |= 4;
        
        t[80] |= 2;
        t[81] = 1;
    }
}

for(x=10; x < 2011; x++) {
    if (x % 80 == 9)
        b[x] = '\n';
    else
        b[x]=" '`-.|//,\\|\\_\\/\x23"[b[x]];
}

첫 반복문은 문자열의 뒷부분을 0으로 초기화하는 코드이다. 그리도 두번째 반복문의 첫 분기문을 보자.

if (p[1] == 0) {
    p[4] += p[3] / 10;
    p[0] += p[4];
}

p[1]이 0이면, 즉 벽이 아니면 p[4]p[3]/10을 더하고 그리고 좌표에 해당하는 p[0]p[4]를 더해준다. 물리엔진, 미적분을 조금 접해본 사람들은 바로 눈치챘을 코드인데, p[3] 는 속도이고 p[4]는 힘에 해당한다. 그렇다면 파티클의 배열인 배열 a는 0번째 인덱스부터 차례대로, 좌표, 벽 플래그, 밀도, 속도, 힘을 나타내는 변수들로 이루어진 구조체의 배열로 볼 수 있다.

이 다음 코드는 보면 괴상하지만, 다음 반복문과 함께 보면 그렇게 어렵지 않다. 일단 다음 반복문부터 보도록 하자. 10부터 2011까지, b[x] 값을 지정한 문자열로 치환한다. 그리고, 전체 코드를 슬쩍 보고 오면 알 수 있듯이 이 큰 반복문은 한번 돌았을 때 puts(o), o+=4 코드를 실행하고 ob의 일부임으로 문자열 b는 출력 버퍼이다. 그리고 이 출력 버퍼에 t라는 인덱스 포인터를 사용하여 모든 배열 a의 원소들을 80*24의 콘솔창 크기에 맞게 출력 버퍼에 씌우는 코드가 t[0] |= 8; .. 이다.

여기서 or 연산자를 사용하여 버퍼의 값을 변경해주는 것은 자연스러운 액체의 모양을 만들기 위함이다. 간단한 예를 들어 생각해보자. 비어있는 버퍼에 대해, x, y에 액체가 있어서 출력을 해야 한다. 그렇다면 버퍼는 다음과 같은 값이 저장된다.

8  4
2  1

이는 다음과 같이 치환될 것이다.

,.
`'

그리고 x + 1, y에도 액체가 있어서 출력을 해야 한다면 버퍼는 다음 모양이 된다.

8  12 4
2  3  2

이는 다음과 같이 치환될 것이다. 여기서 3과 12 둘 다 가로선임을 봤을 때 정말 단순하지만 잘 작동하는 메카니즘이란걸 알 수 있다.

,_.
`-`

그리고 화면을 잠시 멈추는 usleep과 메인 반복문의 증가문에 해당하는 코드까지 적절하게 정리해주면 다음과 같은 코드를 볼 수 있다.

#  include <stdio.h>
#  include <unistd.h>
#  include <complex.h>

double complex a[97687],*p,*q,*r=a,w=0,d;
int x,y;
char b[6856]="\x1b[2J"  "\x1b"  "[1;1H     ",*o=b,*t;
int main() {
    while ((x=getc(stdin)) != EOF) {
        if (x == '\n') {
            // w 에 좌표 (0, y + 2) 넣어서 개행
            w = creal(w + 2);
            continue;
        }
        
        if (x > ' ') {        
            r[0] = w;
            r[5] = w + 1;
            if (x == '#')
                r[1] = r[6] = 1;
            else
                r[1] = r[6] = 0;
            r += 10;
        }

        w -= I; // w의 x좌표 증가
    }

    while (1) {
        for (p=a; p<r; p+=5) { 
            p[2] = p[1] == 1 ? 9 : 0;       
            for (q=a; q<r; q+=5) {
                double i = cabs(p[0]-q[0])/2-1;
                if(0<(int)(1-i))
                    p[2] += i * i;
            }
        }

        for(p=a; p<r; p+=5) {
            p[3] = G;
            for(q=a;q<r;q+=5) {
                d=p[0]-q[0];
                double i = cabs(d)/2-1;
                if(0<(int)(1-i))
                    p[3]+=i*(d*(3-p[2]-q[2])*P+p[4]*V-q[4]*V)/p[2];
            }
        }
        for(x=10; x < 2011; x++)
            b[x]=0;

        for(p=a; p<r; p+=5) {
            if (p[1] == 0) {
                p[4] += p[3] / 10;
                p[0] += p[4];
            }
            x = p[0] * I;
            y = p[0] / 2;
            t = b + 10 + x + 80 * y;

            if (0<=x && x<79 && 0<=y && y<23) {
                t[0] |= 8;
                t[1] |= 4;
                
                t[80] |= 2;
                t[81] = 1;
            }
        }

        for(x=10; x < 2011; x++) {
            if (x % 80 == 9)
                b[x] = '\n';
            else
                b[x]=" '`-.|//,\\|\\_\\/\x23"[b[x]];
        }
        usleep(12321);
        puts(o);
        o=b+4;
    }
    return 0;
}

마지막의 puts(0); o = b + 4 는 반복문의 처음 실행시에만 "\x1b[2J" 을 출력하고 그 다음부터는 이 다음인 "\x1b[1;1H" 부터 출력을 하는 것인데 각각 화면을 지우고, 커서를 맨왼쪽상단으로 옮기는 터미널 이스케이프 코드이다.

결론

너무 어려웠지만 나름 며칠간 분석해보니 재밌었다. 나도 언젠가 이런 코드를 작성하는 멋진 코드 골퍼가 되고 싶다.

©20chan 2024, Built with Gatsby