RPG Maker VX Ace에서 C 언어로 확장을 할 수 있다는 건 아는 사람들은 다 아는 사실일 것이다. 필자도 유용하게 쓰고 있는데, 바로 Win32API로 DLL을 로드할 수 있는 기능이다. 하지만 테스트를 해보면 알겠지만 이 기능에도 문제가 있다. 바로 float나 double 같은 부동 소수점을 사용하면 오류가 난다는 점이다. 그 오류는 실수 형 인자 값이 4개 있는 함수이지만, 0개 있다고 판단하는 오류이다. 

 

필자는 해결 방법을 찾아보던 도중에 흥미로운 포스트를 발견했다. 알만툴 계에서도 이런 작업을 즐기는 사람들이 꽤 있기 때문에 구글링하면 다 나온다. RGSS Player와 RGSS Bitmap 구조를 리버싱으로 모두 풀어버린 중국이 레전드 반열에 올라있고, 독일 커뮤니티도 2K 쪽으로는 레전드 반열에 올라와 있고 정보량이 압도적이지만, 중국어나 독일어를 모르는 관계로 찾아볼 수가 없다. 하지만 영어로 찾아봐도 많이 검색된다.

 

https://forums.rpgmakerweb.com/index.php?threads/combine-rgss-with-ruby-c-dll-and-speed-up-the-game-like-a-boss.107006/

 

Combine RGSS with ruby-c dll and speed up the game like a boss

WARNING: This post is not suitable for newbies, perhaps also not applied to the game that doesn't have a performance issue. It's suggested to know the...

forums.rpgmakerweb.com

 

RGSS301.DLL 파일에 매핑되어있는 루비 C 함수들에 직접 접근하여 성능을 극적으로 올린다는 글인데, 루비 객체의 오브젝트 ID를 이용하여 VALUE로 변환하고, C 함수를 매핑하는 게 주된 포스트 내용인데 이건 사실 가뭄에 단비 같은 존재의 글이다. 이를 이용하면 왠지 해결할 수 있을 거라는 생각이 들지 않는가? 필자는 이걸 보고 확신했다. 이 포스트에서 루비의 부동 소수점으로 변환할 수 있는 방법에 대한 힌트를 얻었기 때문이다. 실수를 사용하는 방법에 대한 다른 글도 있었다. float* (포인터)를 전달하자는 생각이였는데 별로 매력적이진 않았다.

 

하지만 먼저, 루비의 VALUE 타입에서 double로 어떻게 변환하고, 또 double에서 VALUE로 어떻게 바꾸는 지 소스 코드 상에서 찾아야 했다. 루비 깃허브에서 RPG Maker VX Ace 버전과 같은 1.9.2 버전 브랜치를 찾고, 루비처럼 강타입 언어가 아닌 경우에는 보통 형식에 관계가 없이 하나의 객체로 통합되어있으므로 Numeric을 보는 식으로 진행하면 된다.

 

https://github.com/ruby/ruby/blob/ruby_1_9_2/numeric.c#L543

 

ruby/ruby

The Ruby Programming Language [mirror]. Contribute to ruby/ruby development by creating an account on GitHub.

github.com

 

찾아보면 다음과 같이 두개의 매크로가 나온다. RFLOAT_VALUE(x)의 경우, 헤더 파일에 C++ 인라인과 같기 때문에 ruby.h나 그 하위 헤더가 있으면 사용할 수 있다. 하지만 DBL2NUM(x)의 경우, 함수를 호출하기 때문에 함수의 주소가 없으면 곤란해진다. 해당 함수도 단순 캐스팅에 불과한데, 구조체 패킹이라던지 정보가 정확하지 않으면 오류가 나므로 함수의 고정 주소 값을 RGSS301.dll에서 찾아봐야 했다.

 

VALUE -> double : RFLOAT_VALUE(x)
double -> VALUE : DBL2NUM(x) -> rb_float_new(x)

 

필자는 치트 엔진을 사용하여 DBL2NUM(x) 매크로가 자주 호출되는 구간을 알아냈다. 어셈블리 코드를 보고 찾아야 하지만 공개된 루비의 소스 코드와 바이너리 코드는 언어만 다를 뿐 정확히 일치하기 때문에 차례대로 비교하면서 보면 된다. 누군가 알아낸 고정 오프셋을 베이스 기지 삼아, 그 기준으로부터 위 아래로 훑어나가는 식으로 찾아봤다. 마침내 DBL2NUM(x)으로 의심되는 함수 호출 부분을 찾아냈다. 이를 확인해보니 numeric.c 파일의 rb_float_new 함수였다.

 

VALUE
rb_float_new(double d)
{
    NEWOBJ(flt, struct RFloat);
    OBJSETUP(flt, rb_cFloat, T_FLOAT);

    flt->float_value = d;
    return (VALUE)flt;
}

 

필자는 상단에 링크한 포스트에서 공개한 방법처럼 함수 포인터를 이용하여 해당 함수의 고정 주소 값을 바인딩했다. 여기에서 char*가 맞는지 PBYTE가 맞는 지 고민이 되었지만 상단에서 공개된 소스와 같이 char*로 형 변환을 했다. 확인해보니 문제가 없었다.

 

rgss_rb_float_new = (rgss_rb_float_new_proto)((char*)RGSSDLL + 0x5A250);

 

필자는 이를 이용하여 Rovert Penner의 Easing Equrations의 C 소스를 RGSS 호환 DLL로 빌드해보았다. 이건 사실 며칠 작업한 부분의 일부 핵심 소스 코드이다. 나머지 소스 코드는 하단에 링크되어있는 깃허브에 공개되어있다.

 

#define CONVERT_VALUE(A, B, C, D) \
	double t = RFLOAT_VALUE(A); \
	double b = RFLOAT_VALUE(B); \
	double c = RFLOAT_VALUE(C); \
	double d = RFLOAT_VALUE(D); 

#define RET(x) \
	double ret = x; \
	return rgss_rb_float_new(ret);

RSDLL VALUE BackEaseIn(VALUE tt, VALUE bb, VALUE cc, VALUE dd)
{
	CONVERT_VALUE(tt, bb, cc, dd);

	double s = 1.70158;
	double postFix = t /= d;
	
	RET(c*(postFix)*t*((s + 1)*t - s) + b);
}

 

상단에 공개된 소스 코드 상에 따르면 루비 상에서는 다음과 같이 작업해야 한다. 함수의 주소는 프로세스 상에 전개된 DLL의 ImageBase에 따라 달라지기 때문에 DLL이 매핑된 주소를 기준으로 상대적으로 오프셋을 더해야 한다. 예를 들어, 두 개의 게임이 실행되고 있으면 서로 다른 곳에 매핑되기 때문이다.

 

LoadLibrary = Win32API.new('kernel32.dll', 'LoadLibrary', 'p','l')
hmodule = LoadLibrary.call('System/RGSS301.dll')
Init = Win32API.new('Easing.dll', 'Initialize', 'l', 'v')
Init.call(hmodule)

 

이렇게 하면 RGSS301.dll의 핸들 값이 커스텀 DLL 내에 전달되고, 특정 루비 함수의 고정 주소 값을 구하는데 핸들 값을 활용할 수 있다.

 

BackEaseIn = Win32API.new('Easing.dll', 'BackEaseIn', 'llll', 'l')

 

핵심 부분은 오브젝트 ID를 이용하여 VALUE 타입으로 바꾸는 부분이다. VALUE는 오브젝트 ID와 어느 정도 비례한다. 정확히 2배를 하면 된다. * 2도 적절하지만, 쉬프트 연산자 << 1 로도 바꿔 쓸 수 있다. 이런 스타일을 즐기는 건 아닌데 상단에 공개된 포스트의 원 작성자가 이렇게 해놨기 때문에 이 스타일을 유지했다.

 

class Object
  def ptr
    object_id << 1
  end
end

 

이렇게 하면 DLL 파일 내에서 부동 소수점을 처리하고, 루비 값으로 내보낼 수 있게 된다.

 

module SCG

  # 생략...

  def self.VALUE2obj(cvalue)
    return ObjectSpace._id2ref(cvalue >> 1);
  end  
  
  def self.back_ease_in(t, b, c, d)
    ret = BackEaseIn.call(t.ptr, b.ptr, c.ptr, d.ptr)
    VALUE2obj(ret)
  end
end

 

제대로 동작하는 지는 에코 메시지로 다시 실수 값을 받아보고 값이 정확한 지 확인하면 될 것이다. 에코 메시지 구현은 다음과 같이 할 수 있다. 로직을 보면 알겠지만, 오버헤드가 있을 것 같다는 느낌이 든다. 즉, 순수 루비로 작성하는 것보다 불리할 듯 하다.

 

RSDLL VALUE EchoMessage(VALUE d)
{
	double t = RFLOAT_VALUE(d);
	return rgss_rb_float_new(t);
}

 

테스트를 위해 스크립트 에디터에선 다음과 같이 작성해보자. 이제 콘솔에 찍히는 값이 0.5인지 확인해보면 될 것이다.

 

$imported = {} if $imported.nil?
$imported["RS_SCG_MessageSystem"] = true

class Object
  def ptr; object_id << 1; end
end

module Easing

  GetTickCount = Win32API.new('kernel32.dll', 'GetTickCount', 'v', 'l')
  LoadLibrary = Win32API.new('kernel32.dll', 'LoadLibrary', 'p', 'l')
  Init = Win32API.new('Easing.dll', 'Initialize', 'l', 'v')
  EchoMessage = Win32API.new('Easing.dll', 'EchoMessage', 'l', 'l')

  @@handle = LoadLibrary.call('System/RGSS301.dll')

  Init.call(@@handle)
  
  EASE = {}

  METHODS = [
    "BackEaseIn", "BackEaseOut", "BackEaseInOut",
    "BounceEaseIn", "BounceEaseOut", "BounceEaseInOut",
    "CircEaseIn", "CircEaseOut", "CircEaseInOut",
    "CubicEaseIn", "CubicEaseOut", "CubicEaseInOut",
    "ElasticEaseIn", "ElasticEaseOut", "ElasticEaseInOut",
    "ExpoEaseIn", "ExpoEaseOut", "ExpoEaseInOut",
    "LinearEaseNone", "LinearEaseIn", "LinearEaseOut", "LinearEaseInOut",
    "QuadEaseIn", "QuadEaseOut", "QuadEaseInOut",
    "QuartEaseIn", "QuartEaseOut", "QuartEaseInOut",
    "QuintEaseIn", "QuintEaseOut", "QuintEaseInOut",
    "SineEaseIn", "SineEaseOut", "SineEaseInOut",
  ]

  def self.set(name)
    EASE[name] = Win32API.new('Easing.dll', name, 'llll', 'l')
  end

  METHODS.each {|func_name| Easing.set(func_name)}  

  def self.VALUE2obj(cvalue)
    return ObjectSpace._id2ref(cvalue >> 1)
  end    

  def self.echo(x)
    ret = EchoMessage.call(x.ptr)
    VALUE2obj(ret)
  end  

  def self.t
    GetTickCount.call
  end  
  
  module Base
    def base(*args)
      name = self.name.to_s.gsub("Easing::", "") 
      f = EASE[name + args[0]]
      args.slice!(0)
      data = args.collect {|a| a.ptr }
      ret = f.call( *data )
      Easing.VALUE2obj(ret)          
    end
    def ease_in(*args)
      base("EaseIn", *args[0..3])
    end   
    def ease_out(*args)
      base("EaseOut", *args[0..3])
    end        
    def ease_in_out(*args)
      base("EaseInOut", *args[0..3])
    end        
  end  
  
  module Back
  end
  
  module Bounce
  end
  
  module Circ
  end
  
  module Cubic
  end
  
  module Elastic
  end
  
  module Expo
  end
  
  module Linear
  end
  
  module Quad
  end
  
  module Quart
  end
  
  module Quint
  end
  
  module Sine
  end
    
  [Back, Bounce, Circ, Cubic, Elastic, Expo, Linear, Quad, Quart, 
  Quint, Sine].each do |i| 
    i.module_exec {
      class << self
        include Base 
      end
    }
  end
  
  module Linear
    def self.ease_none(*args)
      self.base("EaseNone", *args[0..3])
    end        
  end  
    
end

p Easing.echo(0.5)
p Easing::Back.ease_in(0.2, 1.0, 0.8, 1.0)
p Easing::Back.ease_out(0.2, 1.0, 0.8, 1.0)
p Easing::Back.ease_in_out(0.2, 1.0, 0.8, 1.0)
p Easing::Expo.ease_in(0.2, 1.0, 0.8, 1.0)
p Easing::Linear.ease_none(0.2, 1.0, 0.8, 1.0)

 

최근에 개발 중인 스크립트의 일부를 그대로 붙여놓은 거라 좀 복잡해보이는데, 에코는 정상 작동 확인 용에 불과하다. 확인해보면 에코 메세지 출력 결과 정상적으로 0.5가 출력된다는 것을 알 수 있다. 저 스크립트는 한글 메시지 시스템의 애드온 격으로 만들고 있지만 사실 완성은 기대하지 않는 것이 좋다. 스크립트를 새로 만드는 것은 작심삼일이라 3일이 지나봐야 알 수 있기 때문이다. 이틀이 지난 지금 흥미가 꽤 떨어지고 있어서 완성은 되지 않을 지도 모른다. 특히 요즘은 밤에 컴퓨터 대신 운동을 하고 있어서 (걷기 운동이라도 해야 한다고 한다) 컴퓨터를 오래 하기가 힘기 때문에 작업이 더딘 상황이다.

 

 

이처럼 Win32API의 버그로 인해 실수를 처리하는 것이 간단하지 않다는 것을 알 수 있다. 이렇게 DLL 만능 주의가 되어버리면 네코 플레이어나 다른 플랫폼으로 이식할 때 문제가 생길 수 있다. 또한 DLL 파일을 백신이 바이러스나 악성 코드로 오진하는 경우도 흔하게 있다. DLL 파일을 인증서로 인증하는 방법도 있지만 찾아보니 인증서 발급과 갱신에 1년 단위로 정기적으로 돈이 많이 들기 때문에 이렇게까지 하는 사람들은 없다. 하지만 이렇게 메모리를 이용한 작업은 MV보다도 꽤 흥미롭고 작업 자체가 즐겁다.

 


https://github.com/biud436/RGSS3/tree/master/Easing/src

 

biud436/RGSS3

RPG Maker VX Ace Scripts. Contribute to biud436/RGSS3 development by creating an account on GitHub.

github.com