Adding GITADORA on the Network

luna2026-09-27

Adding a new game to our server, and figuring out that correct isn't always right, and that konami is.. well, they're konami

As far as my new server was concerned, GITADORA had never existed. No controller, no model, no service, and no intention to serve it. Since migrating to C# i lost most of the work i had done in python. Maybe that was for the better, since that server was eventually bound to fail. Along the stuff i lost, M32 was one of the titles that i never bothered to add support to, until i tried it again. All i had at this point was old notes, a folder full of game data, and the new server already running IIDX, SDVX, DDR and DANCERUSH. The job was to cram one more game in there.

That being said, this is the descent into madness. First the static teardown in IDA that got me a working scaffold and nightmares. Then the debugging until 4 in the morning that made me figure out that caffeine and dreams won’t make the game read an half-assed broken response.

Orienting: what am I even looking at

EA3 config told me the shape of the thing. I managed to get M32 and 2025122301 , the alleged datecode and model, from the config.xml file. The modules folder had the usual AVS platform libraries (avs2-core.dll, avs2-ea3.dll) and one 10 mb non-AVS DLL that had to be the game itself: gdxg.dll.

Now, as much as i do trust the source i got the games from, i don’t always trust those things, and besides these games always have funky things for their network protocol, so i ran strings on the binary and started grepping for get, a generally utilized thing in Bemani titles

galaxywave_gameinfo.get
galaxywave_gametop.get
galaxywave_playablemusic.get
galaxywave_cardutil.check
galaxywave_cardutil.regist
galaxywave_gameend.regist
galaxywave_shopinfo.regist

And just like that, there’s the entire protocol surface, and the first real lesson. The module prefix is galaxywave_, not gitadora_ or m32_. It tracks the version name, GALAXY WAVE, so it changes with every new version. A change of pace compared to how other games do it (i.e, IIDX titles have their version id in their protocol things, so they end up as IIDX33PC.get or similar)

Most of the “strings” the game’s DLL are, unsurprisingly, asset filenames. Thousands of .ifs, .png, .psd, and the protocol strings drown in all of it. What surfaced them was filtering the file extensions out first, then keeping only things shaped like word.word.

If it walks like an AVS call and it’s shaped like an AVS call, it must be an AVS call!

Or it’s an obfuscated function for building the AVS element tree

e-amusement games, as we’ll probably explore in a further article, don’t send raw xml, rather, they build a typed property tree with the AVS API and serialise it to kbin, a compact binary XML.

The problem is that avs2-core.dll exports everything under obfuscated names like XCgsqzn00000af, and gdxg imports most of them by ordinal only. The names are useless. So I identified each function by how it gets called, from the signature and the argument pattern.

I managed to find the keys to the city in a small function within an AVS library, sub_180149680, that loads and de-obfuscates /dev/raw/exclb.bin. It reads a handful of typed fields into stack buffers, so I could read the types straight off the sizes:

v13 = avs2_core_176(prop, node, "/str", 10, buf, 4097);  // type 10 -> string-ish
v14 = avs2_core_176(prop, node, "/len",  6, &len, 4);    // type 6, 4 bytes -> s32

The types are even clearer one level down, in the disassembly. Watch r9d, the fourth argument, which is the AVS type, and the stack slot that holds the buffer size:

1497DD  mov     r9d, 0Ah              ; arg4 type = 10  (bin/str)
1497CE  mov     [rsp+var_40], 1001h   ; arg6 size = 4097 bytes
149809  call    cs:avs2_core_176      ; the typed read

149821  mov     r9d, 6                ; arg4 type = 6  (s32)
14980F  mov     [rsp+var_40], 4       ; arg6 size = 4 -> one s32
149837  call    cs:avs2_core_176

Type 0Ah into a 4097-byte buffer is a string. Type 6 into 4 bytes is a single s32. Every type constant came from reading exactly this: the constant in r9d, the size on the stack, divided out.

ordinal what it is
avs2_core_163 property_node_create
avs2_core_162 property_search
avs2_core_176 typed read (node_refer)
avs2_core_245 strtol, base 10

Every method logs a marker string to a helper, sub_180473660("gametop.get.receive", prop). So the parser for any method is just “the function that references <method>.receive”.

The strictest parser i’ve ever seen: all-or-nothing for 264 nodes

The profile load is galaxywave_gametop.get, parsed by sub_180477AB0. It’s 264 node reads, and the last line leaves the game in a make it or break it situation for me:

if ( avs2_core_176(prop, node, "finish", 52, buf, 1) < 0 )
  --v3;
return v3 == 0;   // every single read must succeed

v3 is a failure counter that starts at 264 and gets knocked down by one on every read that comes back negative, broken or mismatched, and the function returns v3 == 0. Very cool, Konami. Very cool.

First contact: getting the game online

No dynamic analysis rig for GITADORA yet, so launching the game was the test harness itself, and it earned its keep immediately. The first log line was damning:

[WRN] Card inquire requested for non-PostgreSQL game M32
      out of scope, no profile store available

Oops. i forgot to add M32 to the allowlist for card lookups. Small fix, reboot the game and.

crap.

A boot error: “unable to load data”, flashed and still dropped to attract, with no 500s in the log. This is where I got a friend to send me a network capture from a real cab,. The tell was in the call order: When working properly, the game goes gameinfo then playablemusic then into the game, while mine called gameinfo, retried it, and never advanced. My songs carried data_ver values up to 999, which was what i found in the dll, while the good network’s data_ver_limit was 315, exactly its highest real song version. Apparently sending songs above the declared limit made the cab treat all the data as inconsistent.

Hitting the wall, creating a new profile

Boot fixed, the cab dropped me at card-in instead. The full flow ran and then bounced me straight back to attract. I diffed my gametop response against the capture, node for node: zero missing, zero extra, zero type mismatches. Structurally identical to a response the game accepts.

The capture I had was from an established player. My failing case was a brand-new card. The parser treats both the same. Nothing in gdxg.dll tells a valid empty profile from an invalid one, because that distinction isn’t in the parser at all. It lives in the game logic that runs after the parse, on the assembled values. At this point i couldn’t slack off anymore and i had to resort to dynamic analysis

Dynamic analysis: the actual answer

At this point i got to the crisp hour of 2:20 in the morning. the game was happier, but still booting me off when trying to card in, and i just couldn’t figure out why for the life of me. So i whipped out wine’s debug tools, and it hit me. Both issues came down to the same thing: I was sending too much, at the wrong time.

My cardutil.check always built a full player block by calling EnsureProfileAsync, which creates a profile if none exists. So a brand-new card got back a populated scaffold describing an empty profile. The game read that as “existing player”, went down the returning-player path, found the data hollow, and dropped the session. The good network’s answer for a new card is almost nothing: <player no="1" state="0"/>, attributes only, no children.

The other half: gameinfo shipped empty demomusic and friends. Structurally legal, since the parser guards each container, but the game needs real demo music to settle boot instead of limping through a retry.

And just like that, after a couple days of work, i managed to get the game running, figured out why the hell it wasn’t running as i wanted to, and managed to save my first score on this unknown game for our server. Definitely a fun exercise for a full analysis after a while of mindlessly working through updates