/ blog

#Games15 MIN DE LECTURA

La odisea de migrar código sin actualizarse por más de 16 años

Hace un tiempo, en otro artículo sobre WODBO, mencioné que estaba migrando el servidor desde 8.60 hacia Canary 1525, y que entre una versión y otra había algo así como dieciséis años de diferencia. En ese momento lo escribí bastante por arriba, principalmente porque el artículo trataba de otra cosa, pero también porque todavía no entendía del todo qué significaban esos dieciséis años.

Pensaba, quizás ingenuamente, que el problema iba a estar principalmente en el código. WODBO seguía funcionando sobre un stack viejo, algunas cosas habían quedado limitadas por el cliente 8.60 y hacía tiempo venía queriendo modernizarlo, sobre todo porque el ecosistema de OpenTibia siguió avanzando mientras nosotros seguíamos ahí, utilizando prácticamente la misma base de hace una cantidad considerable de años. El servidor funcionaba, los jugadores podían entrar, estaban sus personajes, casas, guilds, rankings, NPCs, spells y todo lo demás, pero cada vez que quería agregar algo nuevo empezaba a aparecer esa sensación de estar intentando seguir construyendo arriba de algo que ya había cumplido bastante bien su trabajo.

Además, el feedback iba un poco para ese lado. Nadie me decía específicamente “migremos a Canary 1525”, claro, pero sí aparecían pedidos de cosas que en servidores modernos ya eran bastante normales, y que en 8.60 empezaban a necesitar demasiada creatividad para justificar seguir ahí. Así que tomé la decisión de migrar WODBO a Canary junto a OTClient Redemption, habilitar cosas nuevas del cliente moderno, mantener lo que ya funcionaba, y en mi cabeza todo sonaba difícil, pero razonable.

Error.

Lo primero que decidí fue no tocar producción. El servidor 860 siguió corriendo en Hostinger con gente jugando, mientras Canary vivía en CT160 dentro de mi homelab. Esto terminó siendo bastante útil porque, mientras rompía el servidor nuevo de formas que no sabía que eran posibles, siempre tenía al viejo al lado para comparar. Si una spell pegaba raro, probaba en 860. Si un efecto se veía distinto, probaba en 860. Si un monstruo hacía algo que no entendía, nuevamente, 860. En algún momento dejó de ser simplemente el servidor viejo y se convirtió en una especie de oráculo al que volvía cada vez que Canary me hacía dudar de la realidad.

Antes del corte público incluso dejamos el cliente nuevo detrás de una allowlist de mails. El marketplace, la API y el mundo principal seguían trabajando sobre el stack anterior, mientras yo podía hacer pruebas en casa sin que cada crash-loop significara explicar en Discord por qué nadie podía entrar. Eso me dio cierto margen para equivocarme tranquilo, que en un proyecto de este estilo termina siendo bastante más importante de lo que parece.

Mi primera idea era empezar por el mapa, porque bueno, es el mapa. Pensaba que iba a ser uno de esos problemas donde abrís algo viejo, lo convertís, corregís algunos IDs y seguís. Lo que no estaba teniendo demasiado en cuenta es que el mapa de WODBO no era solamente un .otbm. Alrededor tenía spawn.xml, house.xml, DAT, SPR, items.otb, IDs que significaban una cosa para el servidor y otra para el cliente, además de decisiones tomadas hace muchos años que nadie tenía motivo para documentar porque simplemente funcionaban.

Canary representa varias de esas cosas de otra forma. Appearances en protobuf, otro catálogo de sprites, items.xml, monsters, NPCs y zones separados, y sobre todo otra relación entre el ID que existe dentro del mapa y lo que termina viendo el cliente. Ahí empezamos a armar una tabla de remapeo y congelamos una regla que escrita parece trivial: el ID utilizado en el OTBM, el de items.xml y el appearance ID tenían que representar exactamente la misma cosa.

El convertidor terminó procesando más de tres millones ochocientos mil IDs.

Todavía me parece un número ridículo para un juego de Dragon Ball que empecé a revivir por nostalgia.

Después de eso abrí el mapa convertido en el editor de Canary y, obviamente, no estaba todo bien. Había spawns duplicados, NPCs que no cargaban, formatos que el editor no quería saber nada con ellos y miles de invalid item id for border. Estuve un buen rato intentando entender cómo podía tener tantos IDs inválidos en zonas donde ni siquiera veía esos items, hasta descubrir que una parte venía directamente de la paleta stock del editor. Es decir, estaba buscando basura en mi mapa que en realidad ni siquiera estaba en mi mapa.

Por supuesto, durante esa parte también apareció el Soul Wars Coin. En 860 reutilizaba el sprite del gold coin y eso nunca había representado demasiado problema, hasta que en Canary la relación entre el item y su appearance cambió lo suficiente como para que algunos monstruos pudieran empezar a dropear Soul Wars Coins como loot normal. Una decisión que durante años fue básicamente “usemos este sprite y listo” ahora estaba tocando la economía del juego. Terminamos dándole el ID 12573 y dejando el 6526 para el gold coin.

Y este tipo de cosas empezaron a repetirse. No necesariamente grandes problemas de arquitectura, sino decisiones diminutas que funcionaban perfectamente dentro del contexto de 8.60, pero que dieciséis años después significaban otra cosa.

Cuando el mapa empezó a verse razonablemente parecido al original, pasé a los scripts. Ahí tuve otra de mis ideas brillantes: portar una buena cantidad de cosas juntas y resolver la compatibilidad de una sola vez. Canary respondió con crash-loops, que técnicamente también es una forma de feedback.

Instalar toda la capa de compatibilidad durante el boot podía tirar el servidor. Intentar cargar unas doscientas cuarenta y cuatro spells también. Algunos monsters Lua estaban mal generados, varias APIs ya no existían de la misma forma y en uno de esos días terminé debugging archivos que ni siquiera deberían haber estado ahí porque un docker cp había mergeado contenido viejo con el nuevo. No recuerdo exactamente cuántas horas perdí con eso y creo que prefiero mantenerlo así.

Entonces apareció el puente legacy. Wrappers que hacían dofile de scripts viejos, libs separadas, partes de compatibilidad que se cargaban cuando realmente se necesitaban y no al iniciar el servidor. La idea ya no era migrar todo de una, sino lograr que Canary pudiera convivir con cosas del 860 mientras las íbamos reemplazando.

Eso hizo que el servidor prendiera de forma bastante más consistente, aunque “prender” y “funcionar” se convirtieron rápidamente en dos conceptos completamente distintos.

Uno de los primeros ejemplos fueron los efectos de las spells. Algunos beams tenían las partes corridas; un Kamehameha podía mostrar una punta en el medio del haz o utilizar sprites que claramente no correspondían. Yo estaba bastante convencido de que el problema venía de los assets, así que terminé comparando sprites entre los dos clientes, revisando atlas, mirando cada parte por separado y perdiendo una buena cantidad de tiempo antes de encontrar algo muchísimo más tonto.

TFS 0.4 numeraba los magic effects desde cero.

Canary, desde uno.

Un offset.

Los sprites estaban perfectamente bien. El script pedía un efecto y el cliente moderno mostraba el siguiente. Después de entender eso hicimos la corrección, armamos un probe in-game y terminé teniendo incluso un renderer offline del atlas de spells, principalmente porque después de comparar suficientes Kamehamehas manualmente uno empieza a reconsiderar cómo está utilizando su tiempo libre.

Mientras revisábamos los efectos apareció algo bastante peor: el daño tampoco era equivalente.

Un Boo Kamehameha que en 860 podía pegar alrededor de 108.000 terminaba acercándose a 700.000 en Canary. La fórmula se llamaba igual, LEVELMAGIC, por lo cual durante un rato asumí que tendría un comportamiento parecido, hasta mirar lo que hacía cada motor. En el viejo el factor era level dividido entre cinco más magic level; Canary utilizaba level por dos más magic level por tres. El nombre era igual. Nada más.

Probamos primero mediante callbacks, pero Canary no encontraba correctamente el global, dejaba el error en logs y después terminaba utilizando su fórmula de todas formas. La salida fue calcular el rango durante el cast y aplicar el daño directamente, evitando depender de una interpretación distinta del motor.

Creo que por ahí empecé a entender mejor cuál era realmente el problema de la migración. Yo no necesitaba que Canary pudiera ejecutar una spell llamada Kamehameha. Necesitaba que alguien que jugaba WODBO desde antes entrara, utilizara su personaje y sintiera que seguía siendo el mismo Kamehameha. Podía cambiar prácticamente todo lo que había por debajo, pero si el resultado era seis veces más daño, técnicamente había migrado el código y al mismo tiempo había roto el juego.

La capa de compatibilidad tampoco ayudaba demasiado a mi salud mental. Algunos globals viejos terminaban convertidos en punteros crudos dentro de Canary, así que había scripts que pasaban un integer donde el motor nuevo esperaba userdata y el proceso directamente se suicidaba. Incluso el sweep que utilizábamos para ir probando spells podía morir en una, reiniciarse, volver a recorrer todo y morir exactamente en el mismo lugar. Tuvimos que hacer que guardara su avance, pusiera al culpable en una blocklist y continuara con el siguiente archivo. Me causaba bastante gracia que la herramienta diseñada para encontrar crashes necesitara resiliencia frente a sus propios crashes.

A finales de julio llegaron finalmente las primeras spells completamente nativas. Aura, Renzoku Energy Dan y Kamehameha ya no dependían de dofile ni de wrappers del 860. Armamos libs propias para daño, caps y exhaustion, y utilizamos el servidor viejo como fuente para generar perfiles sin copiar manualmente veintinueve configuraciones y unas ochocientas treinta y cinco vocaciones. En algún momento la suite de paridad quedó verde y los errores Lua dentro del LXC pasaron de 105 a cero.

Creo que ese día realmente pensé que ya habíamos pasado la parte fea.

Mientras sacábamos wrappers descubrimos que algunos IDs de spells dependían del orden en que se registraban, así que retirar uno podía renumerar todo lo que venía después y romper hotkeys que los jugadores ya tenían guardadas. Después aparecieron monsters registrados dos veces; Canary avisaba already registered, conservaba la primera definición y descartaba la segunda, siendo casualmente la segunda la que tenía nuestros cambios. El installer tuvo que empezar a apartar el árbol viejo para evitar que convivieran ambas versiones.

Cada vez quedaba más claro que el código legacy no iba a desaparecer un viernes de noche después de un rewrite heroico. Canary podía ser el mundo nuevo mientras todavía existía una cantidad importante de código viejo haciendo de puente, y sinceramente, después de unas semanas, eso ya no me parecía tan grave. Prefería saber exactamente qué parte seguía siendo legacy a reescribir mil scripts juntos y pasar los siguientes meses intentando descubrir cuál de todos había cambiado el comportamiento.

A principios de agosto hicimos el cutover.

Migramos personajes, cambiamos la API y el portal para trabajar con la nueva MariaDB, adaptamos rankings, guilds y online al schema de Canary, publicamos el cliente y dejamos de aceptar conexiones desde 860 al mundo principal. Habíamos pasado semanas probando el servidor nuevo en el homelab y había cierto grupo de usuarios que ya lo venía utilizando, así que llegado ese punto se sentía razonable abrirlo.

Y ahí entraron los jugadores de verdad.

El ETL había migrado casi todos los depots, pero los lockers quedaron anidados bajo cajas con la estructura que utilizaba TFS. Los items estaban, no se había perdido la información, pero cuando alguien abría el depot podía encontrarse con un locker vacío porque las cosas reales seguían guardadas un nivel más abajo. Tuvimos que aplanarlos y, cuando eso estuvo solucionado, descubrimos que la UI de depot de Canary tampoco se llevaba demasiado bien con nuestro OTC. Terminamos espejando el contenido hacia una backpack marcada que el cliente sí entendía.

Una mochila resolvió parte de un problema que venía después de semanas tocando ETLs, bases, protocolos y millones de IDs. No tengo una conclusión inteligente sobre eso, simplemente me pareció gracioso.

Los NPCs también empezaron a mostrar problemas que en el lab no habíamos encontrado. Algunos saludaban perfectamente y después no vendían nada porque había stubs de trader sin shop pisando la configuración correcta. Otros tenían diferencias entre el nombre utilizado en el spawn y el script. El Emporio entregaba cosas mal por cómo Canary interpretaba los chests. En combate aparecieron spells de área que utilizaban setArea en runtime, algo que el motor nuevo solo permite durante load time, así que una buena cantidad de AOEs dejaron de comportarse como esperábamos.

En otro momento, los efectos de algunos beams empezaron a filtrarse hacia el melee. Podías pegar un golpe normal y ver parte de un Kamehameha, lo cual seguramente habría sido una excelente feature si el objetivo fuese hacer todo lo contrario de mantener paridad con 860. Hubo que sacar los effects de Combat, aplicarlos aparte, limpiar los parámetros después y purgar caches de armas.

Y mientras arreglábamos eso aparecía otra cosa. El radar de Dragon Ball no pintaba bien, el online del portal no coincidía, OTServerList respondía raro, el feed de muertes necesitaba otro schema, en algún punto los personajes caminaban demasiado rápido, después demasiado lento, Yellow Ki no se sentía igual, la experiencia no respetaba correctamente los stages del servidor anterior y llegamos a encontrar un clawback que podía resetear niveles después de un reborn.

La diferencia era que ahora todo eso ocurría con gente adentro.

Hasta entonces yo podía pasar horas probando una spell con determinado personaje y convencerme de que estaba bien. Después entraba alguien con otra vocación, otra transformación, un inventario que llevaba años arrastrando, utilizaba una spell que apenas habíamos mirado, iba hasta un NPC al que nadie durante las pruebas había prestado atención y encontraba un bug en cinco minutos.

Incluso apareció un senzu cerca de una zona VIP que podía terminar provocando un crash.

No recuerdo haber agregado “probar senzu cerca del VIP” a ningún test plan, sinceramente.

Ahí el feedback empezó a pesar muchísimo más que antes. Había reportes bien explicados, otros que eran prácticamente “esto no funciona”, gente paciente y obviamente gente bastante menos paciente, pero todos terminaban mostrando partes del servidor que nosotros no habíamos recorrido. El lab había sido excelente para romper cosas sin consecuencias, levantar dos versiones, comparar, automatizar pruebas y revisar logs. Lo que no podía hacer era reproducir años de hábitos acumulados de jugadores reales.

En paralelo, buena parte de todo esto la hice con Cursor. Lo menciono porque esconderlo haría que la historia estuviera bastante incompleta. En el ciclo que revisé mientras preparaba este artículo, entre finales de julio y finales de agosto, el costo de modelos había superado los 700 dólares. Grok se llevó una parte importante, Opus apareció en varios problemas y Auto terminó haciendo bastante del día a día. Agent fue muchísimo más utilizado que el chat y llegué al límite del ciclo.

Ya había escrito antes sobre Jevons y cómo, cuando probar algo cuesta menos, uno simplemente termina haciendo más pruebas. Esta migración fue básicamente la versión práctica de eso. Había cientos de scripts para revisar, conversiones que podía generar, correr y descartar, comparaciones que de otra forma hubiese hecho a mano y tooling que probablemente nunca hubiese escrito si cada intento me llevara una tarde completa.

La IA aceleró una cantidad enorme de cosas.

También me permitió equivocarme más rápido, que no siempre es exactamente lo mismo.

Podía generar un script para migrar lockers, correrlo y revisar el resultado. Podía buscar patrones incompatibles en cientos de spells o generar una base para llevar alguna de ellas hacia código nativo. Lo que no podía hacer era decidir que el resultado estaba correcto simplemente porque el proceso había terminado en exit code cero. Si al día siguiente alguien entraba y no encontraba los items dentro de su depot, el responsable seguía siendo yo, independientemente de quién hubiera escrito el script.

Mientras todo esto pasaba también fui utilizando muchísimo trabajo que no era mío: Canary, OTClient Redemption, Remere y en general buena parte de OpenTibiaBR existen porque hay gente que lleva años manteniendo herramientas públicas para este ecosistema. Una parte de mi intención con documentar esta migración viene también de ahí. Todavía tengo que ser bastante más concreto devolviendo cosas upstream, pero después de pasar tantas horas apoyándome sobre trabajo comunitario, limitarme a utilizarlo y seguir de largo tampoco me termina de cerrar.

Hoy el mundo principal ya corre en Canary 1525.

860 quedó afuera, los personajes están migrados, el portal y la API trabajan contra el schema nuevo y hay jugadores utilizando Canary todos los días. El datapack, sin embargo, todavía conserva partes de ese puente legacy. Hay wrappers que faltan retirar, cosas que todavía no están portadas de forma nativa y seguramente bugs que simplemente no conocemos porque nadie ejecutó todavía la combinación suficientemente extraña de acciones necesaria para encontrarlos.

Y creo que está bien decirlo así.

Cuando empecé pensaba en dieciséis años como una distancia entre dos versiones, como si 8.60 estuviese de un lado y Canary 1525 del otro, con una cantidad considerable de commits en el medio. Después de hacer la migración me parece que los dieciséis años estaban más bien escondidos en otro lugar: en un ID reutilizado porque en aquel momento no importaba, en una fórmula que conservó el nombre pero cambió completamente por dentro, en la forma de guardar un depot, en un NPC, en una hotkey que alguien tenía configurada hace años, en un script que nadie tocaba porque simplemente seguía funcionando.

Era fácil mirar el código viejo y pensar que había que modernizarlo.

Lo difícil terminó siendo descubrir qué cosas no había que cambiar.

WODBO ya está en Canary y todavía queda trabajo. Seguramente bastante. Pero durante semanas tuve ambos mundos corriendo uno al lado del otro, utilizando el 860 para preguntarle constantemente cómo se suponía que debían funcionar las cosas.

Ahora ese mundo viejo ya no está esperando del otro lado para responderme.

Así que supongo que, al menos en algún punto, la migración terminó.

Si llegaste hasta acá y te dio curiosidad ver cómo quedó todo esto del otro lado, WODBO Wars ya está corriendo sobre Canary 1525 y el cliente nuevo está disponible para cualquiera que quiera entrar.

No prometo que no quede algún bug escondido esperando a la combinación correcta de cosas para aparecer, probablemente la haya, pero también hay un mundo bastante más moderno, con años de contenido detrás y una cantidad absurda de trabajo reciente para mantener lo que hacía especial al 860 sin quedarnos atrapados ahí.

Podés crear tu cuenta, descargar el cliente y jugar desde:

https://wodbowars.com/

Y si encontrás algo roto, bueno… después de este artículo ya sabés más o menos qué viene después.

← Artículos