O TEMA DO FÓRUM ESTÁ EM MANUTENÇÃO. FEEDBACKS AQUI: ACESSAR

[RGSS3 + DLLs] Callback Ruby para funções C

Iniciado por Brandt, 12/02/2020 às 21:24

12/02/2020 às 21:24 Última edição: 12/02/2020 às 21:31 por Brandt
Este tópico vai ser mais curtinho que o normal, queria mais deixar registrado aqui um negócio que descobri enquanto fuçava em coisas para colocar no script que estou fazendo pro Colosseum.
O da vez é uma classe para criar callbacks escritos em Ruby para funções C.


O problema

Algumas funções nativas (da Win32 API, por exemplo) recebem ponteiros de função como parâmetro. Esses ponteiros costumam ser usados como forma de callback, isto é, uma função chamada quando outra termina ou chega em determinado ponto em sua execução que pode ser interessante de fora dela. Exemplos de funções com callbacks são a EnumWindows ou o loop de eventos das janelas, que chama um WindowProc definido pelo usuário quando recebe um evento.

O problema é que esses ponteiros de função devem ser ponteiros de funções nativas. Sendo assim, não podemos fazer essas funções só com scripts: acaba sendo necessário criar uma DLL ou um programa com a função e pegar o ponteiro dela de lá. Isso não é muito flexível, e é meio chatinho de fazer (ter que compilar uma DLL pra mudar o script, eca); além disso, não é possível chamar Ruby de dentro dessas funções nativas, porque o RPG Maker não expõe a VM do ruby.


A solução

De forma simplificada, gerar funções nativas usando Ruby.
O processo é mais ou menos assim:
Feito isso, o ponteiro para a nossa função é o ponteiro para a região de memória alocada, e podemos passar ela para funções nativas normalmente. Claro, não é tão simples quanto fica parecendo só falando assim: precisamos fazer alguma coisa útil com essa função, e é difícil fazer isso usando linguagem de montagem.

Mais difícil ainda, eu descobri, é converter as instruções que você escreve do Assembly em um programa de fato. A codificação dos comandos é bem complicada, e é fácil de se perder nas instruções, suas variações e seus parâmetros. Por sorte, existem assemblers online que fazem isso pra gente. Claro, poderíamos fazer isso com um compilador normal, depois abrir num editor hexadecimal ou mesmo um bom debugger e ver o código que foi gerado, mas dá bem mais trabalho.

Pra facilitar ainda mais, eu busquei deixar a função em assembly bem enxuta e passar o comando pro RGSS assim que possível. Para isso, a função assembly só manda os parâmetros que recebe para locais de memória (que o RGSS consegue acessar, usando a classe DL::CPtr) e depois chama a função RGSSEval com uma chamada especial. Além disso, pra permitir valores de retorno nas funções de callback (algumas funções precisam), usei um ponteiro para guardar o valor de retorno da função Ruby, que o assembly pega e coloca no registrador de retorno depois; dessa forma, é como se a função retornasse o que o callback ruby retornou para a função nativa.

Enfim, sem mais enrolar, o código da função assembly ficou mais ou menos assim:

Código: ASMx86
# Guarda o valor de EBP e substitui por ESP
push ebp
mov ebp, esp

# Transfere os parâmetros recebidos da pilha para os ponteiros intermediários
mov eax, [ebp+8]
mov [<args[0]>], eax

mov eax, [ebp+12]
mov [<args[1]>], eax

....

mov eax, [ebp+8+4n]
mov [<args[n]>], eax

# Recupera o antigo valor de EBP
pop ebp

# Adiciona a string a ser executado pelo RGSSEval à pilha, como argumento
mov eax, <ruby_eval_string>
push eax

# Chama a função RGSSEval
mov ecx, <RGSS301.dll:RGSSEval>
call ecx

# Consome um valor da pilha. É curioso que sem isso ele quebra, mas não deveria.
# Isso indica que a função RGSSEval não está consumindo toda a pilha, ou colocando
# algo que não deveria nela. Ou eu que estou viajando.
add esp, 4

# Salva em EAX (o registrador de retorno) o valor retornado pela função ruby
# A chama à função RGSSEval vai colocar o valor apropriado nesse endereço de memória
mov eax, [ruby_return_value]

# Retorna e limpa a pilha
ret 4n   # n = número de argumentos da função


Tem algumas partes de sintaxe que eu inventei aí, mas é pra ficar mais fácil de entender o que acontece de fato na função. Essa função não está escrita toda no script desse jeito, ela é meio "compilada" em tempo real.
Agora, sim, o script que permite fazer essas coisas:

Código: Ruby
#==============================================================================
# ** Ini
#------------------------------------------------------------------------------
#  Este módulo implementa lógica de leitura de arquivos .ini.
#==============================================================================

module Ini
  #--------------------------------------------------------------------------
  # * Funções da Win32 API
  #--------------------------------------------------------------------------
  GetPrivateProfileString = Win32API.new('kernel32',
                                          'GetPrivateProfileStringA',
                                          'pppplp', 'l')
  #--------------------------------------------------------------------------
  # * Obtém um valor do arquivo
  #     file    : Nome do arquivo
  #     section : Seção do valor no arquivo
  #     key     : Chave do valor na seção
  #     default : Valor padrão retornado caso a chave não exista (opcional)
  #--------------------------------------------------------------------------
  def self.get(file, section, key, default = nil)
    unless FileTest.file?(file)
      raise "`#{file}' does not exist or is not a file" 
    end
    buffer = Array.new(256, 0).pack('C*')
    length = Ini::GetPrivateProfileString.(
      section,
      key,
      default || 0,
      buffer,
      256,
      file)
    return buffer[0, length]
  end
end

#==============================================================================
# ** CFunction
#------------------------------------------------------------------------------
#  Classe para uma função nativa com callback para Ruby.
#==============================================================================

class CFunction
  #--------------------------------------------------------------------------
  # * Constantes
  #--------------------------------------------------------------------------
  MEM_COMMIT = 0x00001000
  PAGE_READWRITE = 0x00000004
  PAGE_EXECUTE_READ = 0x00000020
  RGSSEval = DL.dlopen(Ini.get('./Game.ini', 'Game', 'Library'))['RGSSEval']
  #--------------------------------------------------------------------------
  # * Funções da Win32 API
  #--------------------------------------------------------------------------
  VirtualAlloc = Win32API.new('kernel32', 'VirtualAlloc', 'plll', 'l')
  CopyMemory = Win32API.new('kernel32', 'RtlMoveMemory', 'lpl', 'v')
  VirtualProtect = Win32API.new('kernel32', 'VirtualProtect', 'pllp', 'i')
  VirtualFree = Win32API.new('kernel32', 'VirtualFree', 'pll', 'i')
  #--------------------------------------------------------------------------
  # * Inicialização do objeto
  #--------------------------------------------------------------------------
  def initialize(args, &block)
    @args = args
    @arg_buffers = Array.new(args.size) do
      VirtualAlloc.(0, 4, MEM_COMMIT, PAGE_READWRITE)
    end
    @return_buffer = VirtualAlloc.(0, 4, MEM_COMMIT, PAGE_READWRITE)
    @callback = block
    compile
  end
  #--------------------------------------------------------------------------
  # * Compila a função
  #--------------------------------------------------------------------------
  def compile
    @callback_script = "ObjectSpace._id2ref(#{self.object_id}).call"
    tmp = [0x55].pack('C*')
    tmp << [0x89, 0xE5].pack('C*')
    @args.chars.each_with_index do |t, i|
      tmp << [0x8B, 0x45, 4 * (i + 2)].pack('C*')
      tmp << [0xA3, @arg_buffers[i]].pack('CL')
    end
    tmp << [0x5D].pack('C*')
    tmp << [0xB8, DL::CPtr[@callback_script].to_i].pack('CL')
    tmp << [0x50].pack('C*')
    tmp << [0xBA, RGSSEval].pack('CL')
    tmp << [0xFF, 0xD2].pack('C*')
    tmp << [0x83, 0xC4, 0x04].pack('C*')
    tmp << [0xA1, @return_buffer].pack('CL')
    tmp << [0xC2, @args.size * 4].pack('CS')
    @pointer = VirtualAlloc.(0, tmp.size, MEM_COMMIT, PAGE_READWRITE)
    CopyMemory.(@pointer, tmp, tmp.size)
    dummy = [0].pack('L')
    VirtualProtect.(@pointer, tmp.size, PAGE_EXECUTE_READ, dummy)
  end
  #--------------------------------------------------------------------------
  # * Chama a função
  #--------------------------------------------------------------------------
  def call
    ret = @callback.call(*@arg_buffers.each_with_index.map do |pointer, i|
      type = @args[i]
      case type.upcase
      when 'L', 'I', 'N', 'S', 'C'
        DL::CPtr.new(pointer).to_s(4).unpack(type).first
      when 'P'
        DL::CPtr.new(pointer).to_s
      end
    end)
    ret = 1 if ret.is_a?(TrueClass)
    ret = 0 if ret.is_a?(FalseClass)
    ret_size = ret.is_a?(String) ? ret.size : 4
    ret = [ret].pack('L') if ret.is_a?(Integer)
    CopyMemory.(@return_buffer, ret, ret_size)
  end
  #--------------------------------------------------------------------------
  # * Ponteiro da função
  #--------------------------------------------------------------------------
  def pointer
    DL::CPtr[@pointer].to_i
  end
end



Exemplos

Essa é a parte legal. Será que funciona? Veremos.
Primeiro, um exemplo bem bobinho, somando dois números:

Código: Ruby
sum = CFunction.new('ll') do |a, b|
  a + b
end

puts DL::CFunc.new(sum.pointer, DL::TYPE_LONG).([2, 3])


Explicando:

  • Criamos uma CFunction (a nossa classe de função Ruby que funciona como função nativa) que recebe dois argumentos inteiros (por isso o 'll'; as letras têm o mesmo significado que têm no método String#unpack). A declaração do corpo da função se dá por meio de um block passado no construtor, e que recebe dois argumentos (assim como especificado na função)
  • Então, criamos um objeto DL::CFunc, que chama funções nativas a partir de um ponteiro (é a mesma classe usada internamente pelo módulo Win32API). Tirando a parte de sintaxe que é meio estranha, acho que fica claro o que fazemos com ele então.
E, como esperado, vemos o resultado no console (dá 5, pra quem não tinha certeza).

Um exemplo um pouco mais elaborado:

Código: Ruby
EnumWindows = Win32API.new('user32', 'EnumWindows', 'll', 'i')
GetWindowText = Win32API.new('user32', 'GetWindowTextA', 'lpi', 'i')

enum_windows_proc = CFunction.new('LL') do |hwnd, lparam|
  buffer = Array.new(0, 256).pack('C*')
  n = GetWindowText.(hwnd, buffer, 256)
  next true if n.zero?
  msgbox buffer
  true
end

EnumWindows.(enum_windows_proc.pointer, 0)


Executando o jogo com esse script, você deve ver o nome de cada janela no seu desktop (algumas, inclusive, que são invisíveis). Louco né?


Alguns problemas

Como nem tudo são flores, essa solução não dá conta de tudo. Um exemplo que testei e vi que não dá certo, por exemplo, são funções assíncronas. Não dá pra criar threads com isso, infelizmente.
Na verdade, possível até é: só não tem como chamar a função do Ruby. Colocar o argumentos da função nos buffers intermediários, por exemplo, pode ser feito de forma assíncrona, embora eu acredite que provavelmente também não de forma segura. Isso provavelmente acontece porque o RGSSEval não tem os mecanismos de sincronização que seriam necessários para que isso desse certo. Vou estudar um pouco mais o assunto e tentar achar solução pra isso, mas é possível que não seja viável mesmo.

Outro problema nessa implementação é que não é possível criar funções com argumentos variádicos. Esse problema é mais por conta da forma como o assembly foi escrito, e deve dar pra resolver, só preciso estudar mais, de novo xd.




Bom, era isso que eu queria apresentar hoje. Fiz isso aí meio que na expectativa de usar em algum ponto do script no colosseum, mas acabei não precisando pro que achei que precisaria. Como é um negócio que acho que alguns já passaram por dificuldade para fazer e que pode ser bem útil em alguns casos, imaginei que seria legal compartilhar.

Obrigado pela atenção \o
~ Masked