Canarias Go Retro: Castlevania 64 - Creando un nuevo enemigo de cero - por Moisés

lunes, 24 de julio de 2023

Castlevania 64 - Creando un nuevo enemigo de cero - por Moisés

En 1999, se estrenó para Nintendo 64, Castlevania, que supuso el primer salto de la saga al mundo 3D.

A pesar de las críticas, me parece un juego bastante interesante, y desde noviembre del año pasado, he estado investigando el funcionamiento del videojuego usando métodos y programas de ingeniería inversa con el objetivo de generar documentación (ponerle nombre a las funciones / datos, averiguar cómo se estructuran los tipos de datos, etc) que sirviera de ayuda para aquellos que deseen crear ROM hacks y mods complejos para el juego, o bien para que fuese de utilidad en caso de que alguien quisiese decompilar código del juego.

Después de 8 meses, por fin he podido documentar lo suficiente del juego como para poder crear mis propios actores desde cero, inyectarlos en el juego y hacerlos funcionar. Para los que no lo sepan, un actor se corresponde con una entidad que se puede insertar en un mapa y que ejecuta su propio código, independientemente de otras entidades.

Muchos actores se corresponden con enemigos, y eso es justamente lo que he hecho hoy: programar un enemigo completamente nuevo, un Thwomp.

Los Thwomps son unos enemigos de la saga Super Mario con forma de piedra gigantesca. Su comportamiento es muy sencillo: solamente dan un pisotón al suelo cada X tiempo. A lo largo de la saga, este enemigo ha tenido varios diseños. En este artículo, vamos a reutilizar el de Super Mario 64, más que nada porque, gracias al proyecto de decompilación de Super Mario 64, disponemos del código fuente correspondiente al modelo 3D del enemigo. Este código es usado por la GPU de la N64 para dibujar al enemigo en pantalla.

Con todo esto dicho, ¡empecemos!

1. Antes que nada…

  • Castlevania 64 fue programado en C, como la gran mayoría de los juegos para la N64. El compilador utilizado forma parte del toolchain de C IDO (probablemente la versión 7.1), el cual también fue usado por muchos otros juegos de N64. En GitHub podemos encontrar una versión recompilada de este toolchain. Usaremos su compilador de C en nuestro proyecto.
  • Usaremos el linker mips64-ld.
  • También usaremos nOVL para quitar toda la información innecesaria de los ficheros una vez compilados. Esto es necesario para que funcionen correctamente en el juego y por supuesto para que los ficheros finales no sean tan grandes.
  • También utilizaremos ZAPD para poder convertir texturas en formato PNG a datos crudos que serán incluidos al fichero final.
  • Finalmente necesitaremos el software de LZKN64 para comprimir los ficheros finales en el formato de compresión soportado por el juego (llamado Nagano), de manera que los podamos inyectar en la ROM.
  • La inyección de los ficheros y otros parches se realizan mediante un par de scripts de Python, por lo que necesitaremos además Python 3.

Para que el enemigo funcione, debemos de reemplazar un enemigo existente del juego. El enemigo que reemplazaremos será el “Pillar of Bones”. Esto quiere decir que el Thwomp reutilizará la ID del actor Pillar of Bones (0x2081), la ID del fichero del modelo 3D (0x1B) y la ID del fichero de código, osea su overlay (0x82).

2. El struct del Thwomp y sus variables

Todos los actores siguen una estructura de datos específica, que es usada tanto para guardar variables del actor en sí como para ejecutar su código correctamente.

Nuestro Thwomp tiene la siguiente estructura:

typedef struct {

    ModuleHeader header;

    u8 field_0x20[4];

    model_info* model;

    atari_data_work* collider;

    u32 timer;

    f32 fallingSpeed;

    u32 movingTime;

    u32 waitTime;

    u8 field_0x3C[0x70 - 0x3C];

    actor_settings* settings;

} thwomp;

  • header: Todos los actores contienen una estructura más pequeña al principio que indica cosas como su número de identificación (ID), la ID de la función que está ejecutando en este momento, un puntero a su función de destrucción, etc. Básicamente, esta estructura contiene datos relacionados con la ejecución del código del actor.
  • model: Esta estructura controla el modelo 3D del actor y guarda varios parámetros asociados a este (posición, ángulo, tamaño, color, etc).
  • collider: Se trata de un colisionador, una forma geométrica invisible usada para la detección de colisión entre otros colisionadores (por ejemplo, entre el actor y el jugador).
  • timer: Un cronómetro usado para cronometrar los distintos estados en el que se encuentra el Thwomp.
  • fallingSpeed: La velocidad de caída del Thwomp.
  • movingTime: Cuando el Thwomp está subiendo, esta variable contiene el tiempo máximo que deberá estar mientras suba. Cuanto más grande sea este valor, más arriba subirá el Thwomp.
  • waitTime: Cuando el Thwomp está en reposo, esta variable contiene el tiempo máximo que deberá estar mientras esté quieto. Cuanto más grande sea este valor, más tiempo estará el Thwomp en reposo.
  • settings: Esta estructura de datos contiene datos de inicialización asociados a los actores dentro del mapa, tales como su posición inicial, su ID, y sus “variables” (el significado de estas variables depende del actor, véase más abajo).
  • field 0x20 y field 0x3C: Para que nuestro actor funcione de manera correcta, es recomendable que las variables model y settings estén en la posición 0x24 y 0x70 respecto al comienzo de la estructura. Las dos variables “field” sirven como padding (es decir, datos de relleno sin uso), que hacen que model y settings puedan acabar en las posiciones 0x24 y 0x70.

Ahora veamos las funciones asociadas con el Thwomp. Como veremos a continuación, estas funciones son ejecutadas a partir de un array de punteros a funciones:

void (*thwomp_functions[])(thwomp* self) = {

    thwomp_init,

    thwomp_rise_up,

    thwomp_stay_on_top,

    thwomp_fall,

    thwomp_hit_ground,

};

  • thwomp_init: Función de inicialización. Crea y asigna un valor inicial a todos los campos del struct del Thwomp.
  • thwomp_rise_up: Estado 0 -> El Thwomp está elevándose hacia arriba.
  • thwomp_stay_on_top: Estado 1 -> El Thwomp está reposando en el aire.
  • thwomp_fall: Estado 2 -> El Thwomp está cayendo hacia el suelo.
  • thwomp_hit_ground: Estado 3 -> El Thwomp está reposando en el suelo.


Además de estas funciones está la función thwomp_entrypoint. Esta función se ejecuta siempre mientras el Thwomp esté en memoria, y normalmente se encarga de acceder a otras funciones (en el caso del Thwomp, las funciones que vimos arriba).

Finalmente veamos algunas de las variables constantes que usaremos en nuestro código:

#define THWOMP_ASSETS_FILE           0x1B

#define THWOMP_MODEL_SIZE            0.1f

#define THWOMP_COLLIDER_SIZE         400.0f

#define THWOMP_COLLIDER_DAMAGE       16

#define THWOMP_RISE_UP_SPEED         1.0f

#define THWOMP_FALLING_ACCELERATION  -0.4f

#define thwomp_seg5_dl_0500B750 0x06001D98

#define THWOMP_ACTOR_ID 0x2081

THWOMP_ASSETS_FILE: La ID del fichero que contendrá el modelo 3D del Thwomp. Reemplaza al modelo 3D del Pillar of Bones.
THWOMP_MODEL_SIZE: El tamaño del modelo 3D.
THWOMP_COLLIDER_SIZE: El tamaño del colisionador.
THWOMP_COLLIDER_DAMAGE: El daño infligido por el Thwomp en el jugador.
THWOMP_RISE_UP_SPEED: La velocidad de elevación del Thwomp.
THWOMP_FALLING_ACCELERATION: La aceleración del Thwomp cuando cae hacia el suelo.
THWOMP_ACTOR_ID: La ID del actor Thwomp. Como reeemplazaremos al Pillar of Bones, usaremos su ID (0x2081)
thwomp_seg5_dl_0500B750: La “dirección” dentro del fichero del modelo 3D donde empiezan los datos asociados al modelo 3D (en otras palabras, la dirección donde empieza la display list principal del modelo). Pongo “dirección” entre comillas porque realmente no es una dirección real de memoria, sino la dirección dentro del fichero OR’d con 0x06000000 (el OR es necesario para que el juego sepa que la dirección se corresponde con una dirección de fichero, y no una dirección de memoria RAM).

3. El código del Thwomp

Ahora veamos el código del Thwomp. El código será compilado en un fichero de código llamado overlay, el cual se carga bajo demanda (es decir, solamente cuando instanciamos al Thwomp en un mapa).

La función entrypoint se ejecuta constantemente, y se encarga de acceder a las otras funciones del actor (las que vimos en el array de funciones). Como esta función se ejecuta siempre, también incrementaremos el timer dentro de esta función.

void thwomp_entrypoint(thwomp* self) {

    self->timer++;

    self->header.functionInfo_ID++;

    thwomp_functions[self->header.current_function[self->header.functionInfo_ID].function](self);

    self->header.functionInfo_ID--;

}

La función de inicialización inicializa las variables del struct Thwomp.

  • Creamos la estructura que maneja el modelo 3D del Thwomp. Le asignamos la ID del fichero que contiene los datos del modelo y la dirección dentro de dicho fichero donde empiezan esos datos.

// Create the struct that will handle the Thwomp's 3D model

    self->model = ptr_modelInfo_createRootNode(4, ptr_array_8018CDE0[0]);

    self->model->assets_file_ID = THWOMP_ASSETS_FILE;

    self->model->display_list_address = thwomp_seg5_dl_0500B750;

  • La posición del actor será la que le asignaremos en la lista de actores del mapa (véase más abajo)

    self->model->position.x = self->settings->position.x;

    self->model->position.y = self->settings->position.y;

    self->model->position.z = self->settings->position.z;

  • Las variables waitTime y movingTime las copiaremos de la lista de actores del mapa. De esta manera, podremos cambiar el tiempo de reposo y la altura máxima a la que el Thwomp subirá sin cambiar el código.

    self->movingTime   = self->settings->variable_1;

    self->waitTime     = self->settings->variable_2;

  • Asignamos el tamaño del Thwomp. SIZE_MULTIPLIER es la última variable que proviene de la lista de actores. Cuanto mayor sea, más grande será el Thwomp.

    #define SIZE_MULTIPLIER self->settings->variable_3

    self->model->size.x = self->model->size.y = self->model->size.z = THWOMP_MODEL_SIZE * (f32) SIZE_MULTIPLIER;

  • Creamos e inicializamos los colisionadores. Para crear el colisionador, antes debemos de crear una estructura cuyo fin es manejar los colisionadores (atari_base). Una vez hecho esto, creamos el colisionador, lo asignamos al atari_base, y asignamos el tamaño y el daño que inflige.

    atari_base = ptr_atariBaseWork_create(self->model);

    atari_base->field_0x0A |= 0x202;

   

    self->collider = ptr_atariDataWork_create(self->model, 1);

    ptr_atariBaseWork_attachCollider(atari_base, self->collider, 1);

    self->collider->damage = THWOMP_COLLIDER_DAMAGE;

    self->collider->size.x = self->collider->size.y = THWOMP_COLLIDER_SIZE;

  • Con esta línea abandonamos la función de inicialización y vamos al primer estado (thwomp_rise_up)

ptr_goToNextFunc(self->header.current_function, &self->header.functionInfo_ID);

El Thwomp tiene 4 estados asociados, cada una de ellas es manejada por una función. Para transicionar entre cada función, llamamos a la función goToFunc, cuyo último argumento se corresponde con la ID de la función a la que transicionar (dentro del array de funciones).

Además, cada vez que se cambia de estado, el valor de timer se debe de poner a 0 para que se pueda usar en el nuevo estado.

  1. thwomp_rise_up: El Thwomp se eleva hacia arriba hasta que el valor del timer sea mayor que el que le asignamos a movingTime.

    void thwomp_rise_up(thwomp* self) {

        void (*ptr_goToFunc)(u16[], s16*, s32) = goToFunc;

       

        // If we reached the top, stop rising up

        if (self->timer > self->movingTime) {

            self->timer = 0;

            ptr_goToFunc(self->header.current_function, &self->header.functionInfo_ID, THWOMP_STAY_ON_TOP);

        }

        // Else, keep rising up

        else {

            self->model->position.y += THWOMP_RISE_UP_SPEED;

        }

    }

  2. thwomp_stay_on_top: El Thwomp se encuentra en reposo en el aire, hasta que el valor del timer sea mayor que el que le asignamos a movingTime.

    void thwomp_stay_on_top(thwomp* self) {

        void (*ptr_goToFunc)(u16[], s16*, s32) = goToFunc;

        // Wait on top until reaching the time specified in waitTime

        // Then start falling down

        if (self->timer > self->waitTime) {

            self->timer = 0;

            ptr_goToFunc(self->header.current_function, &self->header.functionInfo_ID, THWOMP_FALL);

        }

    }

  3. thwomp_fall: El Thwomp está cayéndose hacia el suelo, cuanto más tiempo está en este estado, más rápido cae. Cuando llega al suelo (es decir, cuando regresa a su posición Y inicial), el Thwomp se para y reproduce un sonido de caída (cuya ID es 0x105) en la posición del actor.

        self->fallingSpeed += THWOMP_FALLING_ACCELERATION;

        self->model->position.y += self->fallingSpeed;

        // When reaching the ground, stop, play a crashing sound, and make the Thwomp's collider damageable

        if (self->model->position.y < self->settings->position.y) {

            self->model->position.y = self->settings->position.y;

            self->fallingSpeed = 0.0f;

            self->timer = 0;

            ptr_play_sound_in_position(0x105, &self->model->position);      

            ptr_goToFunc(self->header.current_function, &self->header.functionInfo_ID, THWOMP_HIT_GROUND);

        }

    }

  4. thwomp_hit_ground: El Thwomp se encuentra en reposo en el suelo, hasta que el valor del timer sea mayor que el que le asignamos a movingTime. Entonces, volvemos al primer estado (subir hacia arriba), repitiendo el ciclo.
  5. void thwomp_hit_ground(thwomp* self) {

        void (*ptr_goToFunc)(u16[], s16*, s32) = goToFunc;

       

        // Wait on the ground until reaching the time specified in waitTime

        // Then start rising up, starting the cycle again

        if (self->timer >= self->waitTime) {

            self->timer = 0;

            ptr_goToFunc(self->header.current_function, &self->header.functionInfo_ID, THWOMP_RISE_UP);

        }

    }

     

     

4. Lista de actores en el mapa

En el repo también he incluido una versión modificada de un mapa de pruebas que se dejaron los desarrolladores dentro del juego. Esta versión modificada contiene una lista de actores que definen tres Thwomps.

Se encuentran colocados en hilera, y cada uno es ligeramente más grande que el otro, y se eleva más alto. Esto es gracias a las 3 variables que comentamos anteriormente.

La lista termina con una entrada cuyo propósito es de notificar al juego el final de la lista.

// Actor list

const actor_settings actor_list[] = {

    {{-100, 0-70},  THWOMP_ACTOR_ID, 4040, 101, 00},

    {{-100, 0, -170},  THWOMP_ACTOR_ID, 8020, 201, 00},

    {{-100, 0, -310},  THWOMP_ACTOR_ID, 120, 10, 301, 00},

    {{   0, 0,    0},   END_ACTOR_LIST,   0000x80, 00}     // "End of actor list" marker

};

Cada entrada de esta lista sigue esta estructura:

typedef struct {

    vec3s position;

    s16 actor_ID;

    u16 variable_1;

    u16 variable_2;

    u16 variable_3;

    u8 difficulty__spawn_setting_1;

    u8 spawn_setting_2;

} actor_settings;

5. Conclusión

Con todo esto dicho, ya solo nos falta compilar cada fichero con el compilador C de IDO, enlazarlo con mips64-ld, y quitar la información innecesaria con nOVL. Además, en cuanto al modelo 3D y el mapa, hay un paso adicional de conversión de texturas de PNG a datos crudos, lo cual se hace con ZAPD.

Una vez tengamos los binarios finales del overlay, el modelo 3D y el mapa, lo inyectamos a la ROM, reemplazando los ficheros asociados al Pillar of Bones, como dijimos anteriormente.

Todo esto ya está especificado dentro del Makefile, con lo que, una vez tengamos los programas instalados y la ROM esté en la raíz del proyecto, basta con escribir make para ejecutar todos estos pasos, y si todo sale bien, la ROM final será parcheada con nuestro nuevo enemigo.

Para acceder al mapa de pruebas, es necesario utilizar el siguiente código de GameShark:

81389EE0 001D

81389EE2 0000

81389EFE 0000

Si todo salió bien, deberíamos de poder ver a los 3 Thwomps:

 

Antes de finalizar… ¿por qué usamos tantos punteros a funciones en el código del Thwomp? 

Pues bien, dado que los overlays de los actores son cargados bajo demanda, estos son cargados en memoria dinámica. Esto significa que la dirección de memoria de la función entrypoint siempre será distinta, pero el juego siempre se espera una dirección única.

Para resolver este problema, los desarrolladores optaron por “mapear” el overlay a la dirección de memoria 0x0F000000, de esta manera que la CPU se piense que el overlay siempre va a estar en esa dirección de memoria, y por lo tanto se pueda ejecutar sin problemas.

Sin embargo, cuando queremos llamar a una función que se encuentre en el rango de memoria de 0x80YYYYYY, la diferencia de salto es tan grande que no cabe en una instrucción de salto ordinaria (jal), por lo que el linker se quejará. Para poder hacer estas llamadas, cada salto se debe realizar a través de una instrucción de ensamblador jr REGISTRO, donde REGISTRO es la dirección completa de la función a llamar. Y para generar esta instrucción, debemos de utilizar punteros a funciones.

6. Enlaces

 

Moisés

1 comentario:

Franck Sauer dijo...

Wow pretty awesome work there. Congrats !