¿Cómo se pueden implementar módulos C ++ intercambiables en caliente?

39

Los tiempos de iteración rápidos son clave para desarrollar juegos, mucho más que gráficos y motores sofisticados con un montón de características en mi opinión. No es de extrañar que muchos desarrolladores pequeños elijan lenguajes de script.

La forma Unity 3D de poder pausar un juego y modificar activos Y código, luego continuar y hacer que los cambios surtan efecto de inmediato es absolutamente genial para esto. Mi pregunta es, ¿alguien ha implementado un sistema similar en los motores de juego C ++?

He oído que algunos motores realmente súper de alta gama lo hacen, pero estoy más interesado en descubrir si hay una manera de hacerlo en un motor o juego de cosecha propia.

Obviamente, habría compensaciones, y no puedo imaginar que puedas recompilar tu código mientras el juego está en pausa, volver a cargarlo y trabajar en cualquier circunstancia o plataforma.

Pero tal vez sea posible para la programación de IA y los módulos lógicos de nivel simple. No es que quiera hacer eso en ningún proyecto a corto (o largo plazo), pero tenía curiosidad.

Engel malvado
fuente

Respuestas:

26

Definitivamente es posible hacerlo, aunque no con su código C ++ típico; necesitará crear una biblioteca de estilo C que pueda vincular y recargar dinámicamente en tiempo de ejecución. Para que esto sea posible, la biblioteca debe contener todo el estado dentro de un puntero opaco que se puede proporcionar a la biblioteca al volver a cargar.

Timothy Farrar discute su enfoque:

"Para el desarrollo, el código se compila como una biblioteca, un pequeño programa de carga hace una copia de la biblioteca y carga la copia de la biblioteca para ejecutar el programa. El programa asigna todos los datos al inicio y utiliza un solo puntero para hacer referencia a cualquier dato". No uso nada global excepto datos de solo lectura. Mientras el programa se está ejecutando, la biblioteca original se puede volver a compilar. Luego, al presionar una tecla, el programa vuelve al cargador y devuelve ese puntero de datos único. El cargador hace una copia de la nueva biblioteca, carga la copia y pasa el puntero de datos al nuevo código. Luego el motor continúa donde lo dejó ". ( fuente original , ahora disponible a través del archivo web )

Jason Kozak
fuente
Prefiero esto a la respuesta de Blair, ya que realmente respondes la pregunta.
Jonathan Dickinson el
15

El intercambio en caliente es un problema muy, muy difícil de resolver con código binario. Incluso si su código se coloca en bibliotecas de enlaces dinámicos separadas, su código debe asegurarse de que no haya referencias directas a las funciones en la memoria (ya que la dirección de las funciones puede cambiar con una recompilación). Esto esencialmente significa no usar funciones virtuales, y cualquier cosa que use punteros de función lo hace a través de algún tipo de tabla de despacho.

Cosas peludas y restrictivas. Es mejor usar un lenguaje de script para el código que necesita una iteración rápida.

En el trabajo, nuestra base de código actual es una mezcla de C ++ y Lua. Lua tampoco es una parte pequeña de nuestro proyecto: es casi una división 50/50. Hemos implementado la recarga sobre la marcha de Lua, para que pueda cambiar una línea de código, recargar y continuar. De hecho, puedes hacer esto para corregir errores que ocurren en el código Lua, ¡sin reiniciar el juego!

Blair Holloway
fuente
3
Otro punto importante es que incluso si no tiene la intención de mantener un sistema en script, si espera iterar mucho desde el principio, puede ser completamente razonable comenzar en Lua, luego pasar a C ++ en el futuro cuando necesite el El rendimiento adicional y la tasa de iteración se han ralentizado. El inconveniente obvio aquí es que terminas portando el código, pero muchas veces obtener la lógica correcta es la parte difícil: el código casi se escribe una vez que descubres esa parte.
Logan Kincaid
15

(Es posible que desee saber sobre el término "parche de mono" o "golpe de pato" si no es por otra cosa que la imagen mental humorística).

Aparte de eso: si su objetivo es disminuir el tiempo de iteración para los cambios de "comportamiento", intente algunos enfoques que lo lleven a la mayor parte del camino, y combine bien para permitir más de esto en el futuro.

(¡Esto saldrá por una tangente, pero prometo que volverá!)

  • Comience con datos , y comience con algo pequeño: vuelva a cargar en los límites ("niveles" o similares), luego continúe hasta usar la funcionalidad del sistema operativo para obtener notificaciones de cambio de archivo o simplemente sondear regularmente.
  • (Para puntos de bonificación y tiempos de carga más bajos (nuevamente, disminuyendo el tiempo de iteración), mire la cocción de datos ).
  • Las secuencias de comandos son datos y le permiten iterar el comportamiento. Si usa un lenguaje de secuencias de comandos, ahora tiene las notificaciones / la capacidad de volver a cargar esas secuencias de comandos, interpretadas o compiladas. También puede conectar su intérprete a una consola del juego, una toma de red o similar para una mayor flexibilidad de tiempo de ejecución.
  • El código también puede ser datos : su compilador puede admitir superposiciones , bibliotecas compartidas, archivos DLL o similares. Por lo tanto, ahora puede elegir un momento "seguro" para descargar y volver a cargar una superposición o DLL, ya sea manual o automática. Las otras respuestas entran en detalles aquí. Tenga en cuenta que algunas variantes de esto pueden interferir con la vecificación de firma criptográfica, el bit NX (sin ejecución) o mecanismos de seguridad similares.
  • Considere un sistema profundo de guardado / carga versionado . Si puede guardar y restaurar su estado de manera sólida incluso ante los cambios de código, puede apagar su juego y reiniciarlo con una nueva lógica en el mismo punto exacto. Es más fácil decirlo que hacerlo, pero es factible, y es notablemente más fácil y más portátil que cambiar la memoria para introducir instrucciones.
  • Dependiendo de la estructura y el determinismo de su juego, puede grabar y reproducir . Si esa grabación está por encima de los "comandos del juego" (piense en un juego de cartas, por ejemplo), puede alterar todo el código de representación que desee y reproducir la grabación para ver sus cambios. Para algunos juegos esto es tan "fácil" como registrar algunos parámetros iniciales (por ejemplo, una semilla aleatoria) y luego las acciones del usuario. Para algunos es mucho más complicado.
  • Haga esfuerzos para reducir el tiempo de compilación . En combinación con los sistemas de guardar / cargar o grabar / reproducir mencionados anteriormente, o incluso con superposiciones o archivos DLL, esto puede disminuir su respuesta más que cualquier otra cosa.

Muchos de estos puntos son beneficiosos incluso si no llega a recargar los datos o el código.

Anécdotas de apoyo:

En una gran PC RTS (~ equipo de 120 personas, principalmente C ++), había un sistema de ahorro de estado increíblemente profundo, que se usaba al menos para tres propósitos:

  • Se guardó un salvado "superficial", no en el disco, sino en un motor CRC para garantizar que los juegos multijugador permanecieran en simulación de paso de bloqueo, un CRC cada 10-30 cuadros; esto aseguró que nadie estaba haciendo trampa y atrapó errores de desincronización unos cuadros más tarde
  • Si se producía un error de desincronización en el modo multijugador, se realizaba un guardado extra profundo en cada cuadro, y nuevamente se alimentaba al motor CRC, pero esta vez el motor CRC generaría muchos CRC, cada uno para lotes más pequeños de bytes. De esta manera, podría decirle exactamente qué parte del estado había comenzado a divergir dentro del último cuadro. Detectamos una desagradable diferencia de "modo de punto flotante predeterminado" entre los procesadores AMD e Intel que usan esto.
  • Un ahorro de profundidad normal podría no guardar, por ejemplo, el cuadro exacto de animación que estaba jugando tu unidad, pero obtendría la posición, la salud, etc. de todas tus unidades, permitiéndote guardar y reanudar en cualquier momento durante el juego.

Desde entonces he usado grabación / reproducción determinista en un juego de cartas C ++ y Lua para el DS. Nos conectamos a la API que diseñamos para la IA (en el lado de C ++) y registramos todas las acciones del usuario y de la IA. Utilizamos esta funcionalidad en el juego (para proporcionar una repetición para el jugador), pero también para diagnosticar problemas: cuando hubo un bloqueo o un comportamiento extraño, todo lo que tuvimos que hacer fue obtener el archivo guardado y reproducirlo en una compilación de depuración.

Desde entonces, también he usado superposiciones más de un par de veces, y lo combinamos con nuestro "araña automáticamente este directorio y cargamos nuevo contenido en el sistema portátil". Todo lo que tendríamos que hacer es dejar la escena / nivel / lo que sea y volver, y no solo se cargarían los nuevos datos (sprites, diseño de nivel, etc.) sino también cualquier código nuevo en la superposición. Desafortunadamente, eso se está volviendo mucho más difícil con las computadoras de mano más recientes debido a la protección contra copias y los mecanismos anti-piratería que tratan el código especialmente. Sin embargo, todavía lo hacemos para guiones lua.

Por último, pero no menos importante: puede (y lo he hecho, en varias circunstancias específicas muy pequeñas) hacer un poco de puñetazos al parchear directamente los códigos de operación de instrucciones. Sin embargo, esto funciona mejor si está en una plataforma fija y un compilador, y debido a que es casi imposible de mantener, muy propenso a errores y limitado en lo que puede lograr rápidamente, en su mayoría solo lo uso para redirigir el código durante la depuración. Sin embargo , te enseña muchísimo sobre la arquitectura de tu conjunto de instrucciones.

leander
fuente
6

Puede hacer algo como módulos de intercambio en caliente en tiempo de ejecución implementando los módulos como bibliotecas de enlaces dinámicos (o bibliotecas compartidas en UNIX), y usando dlopen () y dlsym () para cargar funciones dinámicamente desde la biblioteca.

Para Windows, los equivalentes son LoadLibrary y GetProcAddress.

Este es un método de C, y tiene algunas dificultades al usarlo en C ++, puede leer sobre esto aquí .

Matias Valdenegro
fuente
esa guía es la clave para crear bibliotecas intercambiables en caliente, gracias
JqueryToAddNumbers
4

Si está utilizando Visual Studio C ++, puede pausar y recompilar su código en determinadas circunstancias. Visual Studio admite Editar y continuar . Adjunte a su juego en el depurador, haga que se detenga en un punto de interrupción y luego modifique el código después de su punto de interrupción. Si va a guardar y luego continuar, Visual Studio intentará recompilar el código, reinsertarlo en el ejecutable en ejecución y continuar. Si todo va bien, el cambio que realizaste se aplicará al juego en ejecución sin tener que hacer un ciclo completo de compilación-construcción-prueba. Sin embargo, lo siguiente evitará que esto funcione:

  1. Cambiar las funciones de devolución de llamada no funcionará correctamente. E&C funciona agregando un nuevo fragmento de código y cambiando la tabla de llamadas para que apunte al nuevo bloque. Para devoluciones de llamada y cualquier cosa que use punteros de función, seguirá ejecutando las llamadas antiguas no modificadas. Para solucionar esto, puede tener una función de devolución de llamada de envoltura que llame a una función estática
  2. La modificación de los archivos de encabezado casi nunca funcionará. Esto está diseñado para modificar llamadas a funciones reales.
  3. Diversas construcciones del lenguaje harán que falle misteriosamente. Desde mi experiencia personal, pre-declarar cosas como enumeraciones a menudo hará esto.

He usado Editar y continuar para mejorar dramáticamente la velocidad de cosas como la iteración de la interfaz de usuario. Supongamos que tiene una interfaz de usuario funcional construida completamente en código, excepto que cambió accidentalmente el orden de dibujo de dos cuadros para que no pueda ver nada. Al cambiar 2 líneas de código en vivo, puede ahorrarse un ciclo de compilación / construcción / prueba de 20 minutos para verificar una solución trivial de la interfaz de usuario.

Esta NO es una solución completa para un entorno de producción, y he encontrado que la mejor solución es mover la mayor parte posible de su lógica a los archivos de datos y luego hacer que esos archivos de datos sean recargables.

Ben Zeigler
fuente
¿Qué ciclo de compilación dura 20 minutos? prefiero pegarme un tiro
JqueryToAddNumbers
3

Como han dicho otros, es un problema difícil, vinculando dinámicamente C ++. Pero es un problema resuelto: es posible que haya oído hablar de COM o uno de los nombres de marketing que se le han aplicado a lo largo de los años: ActiveX.

COM tiene un poco de mal nombre desde el punto de vista del desarrollador porque puede ser un gran esfuerzo implementar componentes C ++ que expongan su funcionalidad al usarlo (aunque esto se hace más fácil con ATL, la biblioteca de plantillas ActiveX). Desde el punto de vista del consumidor, tiene un mal nombre porque las aplicaciones que lo usan, por ejemplo, para incrustar una hoja de cálculo de Excel en un documento de Word o un diagrama de Visio en una hoja de cálculo de Excel, tienden a fallar bastante en el día. Y eso se reduce a los mismos problemas: incluso con toda la orientación que ofrece Microsoft, COM / ActiveX / OLE fue / es difícil de acertar.

Voy a enfatizar que la tecnología de COM no es inherentemente mala en sí misma. En primer lugar, DirectX usa interfaces COM para exponer su funcionalidad y eso funciona bastante bien, al igual que una multitud de aplicaciones que integran Internet Explorer usando su control ActiveX. En segundo lugar, es una de las formas más simples de vincular dinámicamente el código C ++: una interfaz COM es esencialmente una clase virtual pura. Aunque tiene un IDL como CORBA, no está obligado a usarlo, especialmente si las interfaces que define solo se usan dentro de su proyecto.

Si no está escribiendo para Windows, no piense que no vale la pena considerar COM. Mozilla lo volvió a implementar en su base de código (utilizada en el navegador Firefox) porque necesitaban una forma de componente de su código C ++.

U62
fuente
2

Hay una aplicación de tiempo de ejecución C ++ compilado para el código del juego aquí . Además, sé que hay al menos un motor de juego patentado que hace lo mismo. Es complicado, pero factible.

Laurent Couvidou
fuente