/ blog
The odyssey of migrating code that had not been updated in more than 16 years

A while ago, in another article about WODBO, I mentioned I was migrating the server from 8.60 to Canary 1525, and that between one version and the other there was something like sixteen years of difference. At the time I wrote it fairly quickly, mainly because the article was about something else, but also because I still did not fully understand what those sixteen years meant.
I thought, maybe naively, that the problem was going to be mostly in the code. WODBO was still running on an old stack, some things had been limited by the 8.60 client, and I had wanted to modernize it for a while, especially because the OpenTibia ecosystem kept moving while we stayed there, using pretty much the same base from a considerable number of years ago. The server worked, players could log in, their characters, houses, guilds, rankings, NPCs, spells and everything else were there, but every time I wanted to add something new that feeling started to show up: trying to keep building on top of something that had already done its job quite well.

Besides, the feedback was pointing a bit that way. Nobody was specifically telling me “let’s migrate to Canary 1525”, of course, but requests did show up for things that were already fairly normal on modern servers, and that on 8.60 started to need too much creativity to justify staying there. So I decided to migrate WODBO to Canary together with OTClient Redemption, enable new things from the modern client, keep what already worked, and in my head it all sounded hard, but reasonable.
Wrong.
The first thing I decided was not to touch production. The 860 server kept running on Hostinger with people playing, while Canary lived on CT160 inside my homelab. That ended up being fairly useful because, while I was breaking the new server in ways I did not know were possible, I always had the old one next to it to compare. If a spell hit weird, I tested on 860. If an effect looked different, I tested on 860. If a monster did something I did not understand, again, 860. At some point it stopped being simply the old server and became a kind of oracle I went back to every time Canary made me doubt reality.
Before the public cut we even left the new client behind a mail allowlist. The marketplace, the API and the main world kept working on the previous stack, while I could run tests at home without every crash-loop meaning I had to explain on Discord why nobody could log in. That gave me some room to be wrong in peace, which in a project like this ends up being quite a bit more important than it looks.
My first idea was to start with the map, because, well, it is the map. I thought it was going to be one of those problems where you open something old, convert it, fix a few IDs and move on. What I was not really taking into account is that WODBO’s map was not just an .otbm. Around it there was spawn.xml, house.xml, DAT, SPR, items.otb, IDs that meant one thing to the server and another to the client, plus decisions made many years ago that nobody had a reason to document because they simply worked.
Canary represents several of those things in another way. Appearances in protobuf, another sprite catalog, items.xml, monsters, NPCs and zones split apart, and above all another relationship between the ID that exists inside the map and what the client ends up seeing. That is where we started building a remap table and froze a rule that, written down, looks trivial: the ID used in the OTBM, the one in items.xml and the appearance ID had to represent exactly the same thing.

The converter ended up processing more than three million eight hundred thousand IDs.
It still feels like a ridiculous number for a Dragon Ball game I started bringing back out of nostalgia.
After that I opened the converted map in Canary’s editor and, obviously, not everything was fine. There were duplicated spawns, NPCs that would not load, formats the editor wanted nothing to do with, and thousands of invalid item id for border. I spent a good while trying to understand how I could have so many invalid IDs in areas where I could not even see those items, until I found out that part of it came straight from the editor’s stock palette. In other words, I was hunting garbage in my map that was not even in my map.
Of course, during that part the Soul Wars Coin also showed up. On 860 it reused the gold coin sprite and that had never been much of a problem, until on Canary the relationship between the item and its appearance changed enough that some monsters could start dropping Soul Wars Coins as normal loot. A decision that for years was basically “let’s use this sprite and done” was now touching the game economy. We ended up giving it ID 12573 and leaving 6526 for the gold coin.
And this kind of thing started repeating. Not necessarily big architecture problems, but tiny decisions that worked perfectly inside the 8.60 context, and that sixteen years later meant something else.

When the map started looking reasonably close to the original, I moved on to the scripts. That is where I had another one of my brilliant ideas: port a good amount of things together and solve compatibility in one go. Canary answered with crash-loops, which technically is also a form of feedback.
Installing the entire compatibility layer during boot could take the server down. Trying to load about two hundred forty-four spells could too. Some Lua monsters were badly generated, several APIs no longer existed in the same way, and on one of those days I ended up debugging files that should not even have been there because a docker cp had merged old content with the new. I do not remember exactly how many hours I lost on that and I think I prefer to keep it that way.
Then the legacy bridge showed up. Wrappers that dofile’d old scripts, separate libs, compatibility pieces that loaded when they were actually needed and not at server start. The idea was no longer to migrate everything at once, but to get Canary to live with 860 things while we replaced them.
That made the server boot a lot more consistently, although “boot” and “work” quickly became two completely different concepts.
One of the first examples was spell effects. Some beams had their parts shifted; a Kamehameha could show a tip in the middle of the beam or use sprites that clearly did not belong. I was fairly convinced the problem came from the assets, so I ended up comparing sprites between the two clients, checking atlases, looking at each part separately and wasting a good amount of time before finding something much dumber.

TFS 0.4 numbered magic effects from zero.
Canary, from one.
An offset.
The sprites were perfectly fine. The script asked for an effect and the modern client showed the next one. After understanding that we made the correction, built an in-game probe and I even ended up with an offline renderer for the spell atlas, mainly because after comparing enough Kamehamehas by hand you start reconsidering how you are using your free time.
While we were reviewing the effects, something quite a bit worse showed up: the damage was not equivalent either.
A Boo Kamehameha that on 860 could hit around 108,000 ended up getting close to 700,000 on Canary. The formula had the same name, LEVELMAGIC, so for a while I assumed it would behave similarly, until I looked at what each engine actually did. On the old one the factor was level divided by five plus magic level; Canary used level times two plus magic level times three. The name was the same. Nothing else.
We tried through callbacks first, but Canary could not find the global correctly, left the error in the logs and then ended up using its formula anyway. The way out was to compute the range during the cast and apply the damage directly, avoiding a different interpretation from the engine.
I think that is around where I started to understand better what the migration problem really was. I did not need Canary to be able to run a spell called Kamehameha. I needed someone who had been playing WODBO from before to log in, use their character and feel that it was still the same Kamehameha. I could change pretty much everything underneath, but if the result was six times more damage, I had technically migrated the code and at the same time broken the game.
The compatibility layer was not doing my mental health many favors either. Some old globals ended up turned into raw pointers inside Canary, so there were scripts that passed an integer where the new engine expected userdata and the process simply killed itself. Even the sweep we used to test spells could die on one, restart, walk through everything again and die in exactly the same place. We had to make it save its progress, put the culprit on a blocklist and continue with the next file. I found it fairly funny that the tool designed to find crashes needed resilience against its own crashes.
At the end of July the first fully native spells finally arrived. Aura, Renzoku Energy Dan and Kamehameha no longer depended on dofile or on 860 wrappers. We built our own libs for damage, caps and exhaustion, and used the old server as a source to generate profiles without copying twenty-nine configurations and about eight hundred thirty-five vocations by hand. At some point the parity suite went green and Lua errors inside the LXC went from 105 to zero.
I think that day I really believed we had already gotten past the ugly part.
While we were pulling wrappers out we discovered that some spell IDs depended on the order they were registered, so removing one could renumber everything that came after and break hotkeys players already had saved. Then monsters registered twice showed up; Canary warned already registered, kept the first definition and discarded the second, which happened to be the one with our changes. The installer had to start setting the old tree aside so both versions would not live together.
It became clearer and clearer that the legacy code was not going to disappear on a Friday night after a heroic rewrite. Canary could be the new world while a considerable amount of old code was still acting as a bridge, and honestly, after a few weeks, that no longer seemed so bad to me. I preferred knowing exactly which part was still legacy over rewriting a thousand scripts together and spending the following months trying to figure out which one had changed the behavior.
At the beginning of August we did the cutover.
We migrated characters, changed the API and the portal to work with the new MariaDB, adapted rankings, guilds and online to Canary’s schema, published the client and stopped accepting 860 connections to the main world. We had spent weeks testing the new server in the homelab and there was a group of users already using it, so at that point it felt reasonable to open it.
And that is when the real players came in.
The ETL had migrated almost all depots, but the lockers stayed nested under boxes with the structure TFS used. The items were there, the information had not been lost, but when someone opened the depot they could find an empty locker because the real things were still stored one level down. We had to flatten them and, once that was solved, we discovered that Canary’s depot UI also did not get along too well with our OTC. We ended up mirroring the content into a marked backpack that the client did understand.
A backpack solved part of a problem that came after weeks of touching ETLs, databases, protocols and millions of IDs. I do not have a smart conclusion about that, I just thought it was funny.
NPCs also started showing problems we had not found in the lab. Some greeted perfectly and then sold nothing because trader stubs without a shop were overriding the correct configuration. Others had differences between the name used in the spawn and the script. The Emporium delivered things wrong because of how Canary interpreted chests. In combat, area spells showed up that used setArea at runtime, something the new engine only allows at load time, so a good number of AOEs stopped behaving as we expected.
At another point, some beam effects started leaking into melee. You could land a normal hit and see part of a Kamehameha, which would surely have been an excellent feature if the goal had been to do the exact opposite of keeping parity with 860. We had to take the effects out of Combat, apply them separately, clean the parameters afterwards and purge weapon caches.
And while we were fixing that, something else showed up. The Dragon Ball radar was not painting correctly, the portal online count did not match, OTServerList answered strangely, the death feed needed another schema, at some point characters walked too fast, then too slow, Yellow Ki did not feel the same, experience was not respecting the previous server’s stages correctly and we even found a clawback that could reset levels after a reborn.
The difference was that now all of that was happening with people inside.
Until then I could spend hours testing a spell with a given character and convince myself it was fine. Then someone logged in with another vocation, another transformation, an inventory they had been dragging around for years, used a spell we had barely looked at, walked up to an NPC nobody had paid attention to during testing and found a bug in five minutes.
A senzu even showed up near a VIP zone that could end up causing a crash.
I do not remember adding “test senzu near VIP” to any test plan, honestly.
That is when feedback started to weigh much more than before. There were well explained reports, others that were basically “this does not work”, patient people and obviously people who were quite a bit less patient, but all of them ended up showing parts of the server we had not walked through. The lab had been excellent for breaking things without consequences, spinning up two versions, comparing, automating tests and reviewing logs. What it could not do was reproduce years of accumulated habits from real players.
In parallel, a large part of all this I did with Cursor. I mention it because hiding it would leave the story fairly incomplete. In the cycle I reviewed while preparing this article, between late July and late August, model cost had gone past 700 dollars. Grok took an important share, Opus showed up in several problems and Auto ended up doing a good amount of the day to day. Agent was used far more than chat and I hit the cycle limit.
I had already written before about Jevons and how, when trying something costs less, you simply end up doing more tests. This migration was basically the practical version of that. There were hundreds of scripts to review, conversions I could generate, run and discard, comparisons I would otherwise have done by hand, and tooling I probably never would have written if each attempt took a full afternoon.
AI sped up an enormous amount of things.
It also let me be wrong faster, which is not always exactly the same thing.
I could generate a script to migrate lockers, run it and review the result. I could search for incompatible patterns across hundreds of spells or generate a base to take one of them toward native code. What I could not do was decide the result was correct simply because the process had ended with exit code zero. If the next day someone logged in and could not find the items in their depot, I was still the one responsible, regardless of who had written the script.
While all this was happening I was also using a lot of work that was not mine: Canary, OTClient Redemption, Remere and in general a large part of OpenTibiaBR exist because there are people who have spent years maintaining public tools for this ecosystem. Part of my intention in documenting this migration also comes from there. I still have to be quite a bit more concrete about giving things back upstream, but after spending so many hours leaning on community work, just using it and moving on does not sit right with me either.
Today the main world already runs on Canary 1525.

860 is out, the characters are migrated, the portal and the API work against the new schema and there are players using Canary every day. The datapack, however, still keeps parts of that legacy bridge. There are wrappers left to remove, things that are still not ported natively, and surely bugs we simply do not know about because nobody has executed the sufficiently strange combination of actions needed to find them yet.
And I think it is fine to say it that way.
When I started I thought of sixteen years as a distance between two versions, as if 8.60 were on one side and Canary 1525 on the other, with a considerable number of commits in between. After doing the migration it seems to me that the sixteen years were hidden somewhere else: in an ID reused because at the time it did not matter, in a formula that kept the name but changed completely on the inside, in the way a depot was stored, in an NPC, in a hotkey someone had configured years ago, in a script nobody touched because it simply kept working.

It was easy to look at the old code and think it had to be modernized.
The hard part ended up being discovering what not to change.
WODBO is already on Canary and there is still work left. Quite a bit, surely. But for weeks I had both worlds running side by side, using 860 to constantly ask it how things were supposed to work.
Now that old world is no longer waiting on the other side to answer me.
So I guess, at least at some point, the migration ended.
If you made it this far and got curious to see how all of this looks on the other side, WODBO Wars is already running on Canary 1525 and the new client is available for anyone who wants to join.
I am not promising there is not some hidden bug waiting for the right combination of things to show up, there probably is, but there is also a much more modern world, with years of content behind it and an absurd amount of recent work to keep what made 860 special without staying trapped there.
You can create your account, download the client and play from:
And if you find something broken, well… after this article you already have a pretty good idea of what comes next.