Sharing scores is a big part of the rhythm game community. For me, it's been a great way to keep track of my progress and meet similarly skilled players to compete with. On the other hand, taking pictures of the screen with my phone each time got old fast. The process needed some streamlining and I had a plan.
It's been almost two years since my last game hacking post, so how about a new game? Enter beatmania IIDX INFINITAS, the first in the long running series to be released on PC, and a game that quickly became a personal favourite of mine. The gameplay is simple - hit the falling notes as they reach the bottom of the lane. Of course, as you ascend to higher levels it can get quite difficult.
At the end of each chart, the result screen is displayed. This contains a bunch of information such as the music title, difficulty rating, whether you failed or cleared, a letter grade, your final score, how you fared against an optional target score and how well timed each note hit was. You'll see a lot of these being posted around in any rhythm game community chatroom.
As far as overall quality goes, this is definitely one of the better ones. It's still mostly readable without clicking to view the full size image, it's not too blurry and, despite being partially cropped out, the song title is still legible. The opposite side of the screen is usually populated with the rival ranking leaderboard but some people prefer to play without it, leaving a lot of wasted space.
Now compare that with an official e-amusement mobile scorecard. Sure, it's missing a few things, most notably the graph, but it's compact, consistent and there's very little wasted space. Unfortunately, it's also exclusive to the arcade releases, but hey, that's no reason to give up. If they didn't want to make one for INFINITAS then I'll just have to make one myself.
Today's toolkit consists of Cheat Engine - an excellent memory scanner and my usual starting point with any unfamiliar game, ReClass.NET - an indispensable tool for dissecting structures in memory, and last but not least, IDA Pro for static analysis.
Here's a quick breakdown of all the data I'll need for my replica scorecard. If the picture above just looks like a bunch of numbers, don't worry, I'll try my best to explain all the non-obvious stuff as it comes up.
Play style. As seen in the top left corner, just under the large grade letters. Can either be Single Play (SP) which uses 7 keys and a turntable, or Double Play (DP) which uses both sides of the deck, totaling 14 keys and 2 turntables.
Difficulty. With a few exceptions, every song in the game has up to three distinct note charts (per play style) which vary in difficulty. From easiest to hardest, these are NORMAL, HYPER and ANOTHER. Each of these has their own level rating from 1 to 12 which gives a general indication of difficulty. (in this case the difficulty is NORMAL and the rating is 7.)
Clear type. Typically, either NO PLAY, FAILED or CLEAR. Completing a chart with certain modifiers can change this to an ASSIST, EASY, HARD or EX HARD clear. Completing a chart without breaking combo will award a FULL COMBO clear.
EX score. The definitive scoring metric used in online rankings. Calculated by multiplying each flashing GREAT (hereinafter referred to as PGREAT, perfect great) by 2, then adding the amount of regular GREATs. The highest EX score for a chart (commonly referred to as MAX) is equivalent to the amount of notes in the chart multiplied by 2.
DJ level. A letter grade based on your EX score. From worst to best: F, E, D, C, B, A, double A, and finally, triple A. For more details, including the percentages for each, check out this explanation over at RemyWiki.
Miss count. The combined total of hits judged to be BAD or POOR. This differs from COMBO BREAK as hitting non-existent notes will register as a miss but will not 'break' (reset to zero) the current combo counter.
Pacemaker. An optional target score which can be specified before starting a chart. A non-exhaustive list includes: a specific letter grade, a user-defined percentage, a score from a specific rival and the current world record.
Judgement. The five numbers below the pacemaker, representing how accurately each note was timed. The combo break number below that indicates how many times a note was missed entirely, and finally, below that, the amount of notes that landed on the early or late sides of the timing window.
Music info. The title displayed at the bottom. The official mobile scorecards also display the artist name, but it's nothing to worry about just yet. These kind of things usually aren't too far away from each other in memory.
DJ name. Another exclusive to the mobile scorecard but should be trivial to find with a memory scan.
Timestamp. Self-explanatory, and easy enough to generate myself.
With that out of the way, it's time to start up Cheat Engine and attach to the game process: bm2dx.exe.
Pro tip: INFINITAS runs in fullscreen, but you can get it to work in a window with DxWnd and these settings.
How about starting with those five judgement numbers? Considering how they're displayed on the result screen, I'm thinking they might be structured in memory as an array of contiguous 32-bit integers. If this is true, converting each number to hexadecimal, stringing them together and performing an Array of Byte scan in Cheat Engine should give me something to work with.
Off to a good start. As indicated by the green colored text on the left, both of these results are static addresses. This means I could set a new score or completely restart the game and I'd still be able to get to the judgement data using the same address.
The above screenshot doesn't tell the whole story though. I wouldn't reference this in my code using the address 0x34C82D4. When static results are added to Cheat Engine's address list they become relative to the module base. In this case, since the module is bm2dx.exe, which has a base address of 0x1390000, the final address would become bm2dx.exe+0x21382D4 instead.
There's no need for two addresses that point to the same data though, so which one should I use? In these situations, I try to filter the list down to addresses that are reliable, then check each to see if they're close to anything else. Keeping the amount of addresses low helps reduce the amount of maintenance required when an update inevitably breaks something.
Looks like 0x34C82D4 also has the COMBO BREAK, FAST and SLOW values nearby, making it the clear winner for now. I suspect those zeroes in-between might be used if I were playing on the P2 side, but that's something to confirm later.
Since that first scan worked out so well, why not apply the same technique to the next one?
Once again, checking the memory region around this address reveals a few extra values of interest, specifically the best and current miss counts. Seems like a good find, but this isn't a static address. This won't necessarily be here again by the time I set another score, and it'll certainly be somewhere completely different if I were to restart the game now.
Fortunately, Cheat Engine can do a bit more than just scan memory. Right-clicking the address from the results window and selecting "Find out what accesses this address" pointed me in the right direction.
(In case you're following along, attempting this will almost certainly trigger anti-debugging protection and crash the game. You'll need to change the debugger method in the Cheat Engine settings dialog to "Use VEH Debugger" first.)
Seems that four instructions are reading the value at this address. Just a guess, but perhaps these are rendering related instructions being called once per frame. In any case, these will make for good starting points when moving over to IDA.
Looking at the disassembly, it appears that the address in the ebx register, 0x216E6F4C, should denote the start of the class.
Okay, so here's ReClass.NET. I've attached to bm2dx.exe, set the class address to 0x216E6F4C and named everything known.
Looking back at the result screen image, I can make some assumptions about those first four variables. Assuming that data is always in a pair of best and current values, and that the order is the same as how it appears on the result screen, it's likely that those first four nodes correspond to numeric representations of the clear type and DJ level.
I suppose setting a few different scores with various grades and clear types would confirm this, but there's a better way.
That first instruction was traced to 0x14AFEB4. After subtracting the module base address, 0x1390000, then adding the IDA image base, 0x400000, I finally get to an address that's usable in IDA: 0x51FEB4, which is inside function sub_51FE90.
I won't bother including a screenshot of this function as there were only a few EX score related strings and calls to what appear to be drawing related functions. Repeating the same trace on the miss count ended up at a similar function, sub_5200B0.
Neither as interesting as what they had in common - the single cross-reference to sub_51FC00.
I've left comments on the two functions I found from the previous Cheat Engine traces.
There's not much to go through here so why not take a look around, perhaps at sub_51FDC0? It's the function above the one that draws the EX score, so if my order theory applies, it might be a DJ level rendering function.
That confirms at least two of the unknown fields in the class were indexes to a string table, specifically this one at off_B6B910.
.data:00B6B910 ; "level_s_aaa" .data:00B6B914 dd offset aLevelSAa ; "level_s_aa" .data:00B6B918 dd offset aLevelSA ; "level_s_a" .data:00B6B91C dd offset aLevelSB ; "level_s_b" .data:00B6B920 dd offset aLevelSC ; "level_s_c" .data:00B6B924 dd offset aLevelSD ; "level_s_d" .data:00B6B928 dd offset aLevelSE ; "level_s_e" .data:00B6B92C dd offset aLevelSF ; "level_s_f"
Looking back to the class, the best DJ level has an index of 2, level_s_a, while the current DJ level index is 1, level_s_aa, so that's good. I went back and checked the function above this one, sub_51FCD0. As expected, the clear type string table was there and the indexes lined up once again. The best clear type index being 4, clear_clear, and the current being 5, clear_hard.
.data:00B6B950 ; "clear_noplay" .data:00B6B954 dd offset aClearFailed ; "clear_failed" .data:00B6B958 dd offset aClearAssist ; "clear_assist" .data:00B6B95C dd offset aClearEasy ; "clear_easy" .data:00B6B960 dd offset aClearClear ; "clear_clear" .data:00B6B964 dd offset aClearHard ; "clear_hard" .data:00B6B968 dd offset aClearEx ; "clear_ex" .data:00B6B96C dd offset aClearFullcombo ; "clear_fullcombo"
Finally, I ran Class Informer, which revealed the true class name to be StageResultDrawFrame. Thinking about it now, I probably should've done that a bit earlier. With the new findings included, the class now looked something like this.
Now all that's left is the pacemaker, music information and a couple of things I'll probably need to blindly scan for.
Nothing around the memory region of StageResultDrawFrame resembled anything pacemaker related, but there were a few functions still left in sub_51FC00. Seemed like a safe bet to check the function directly under the miss count one, sub_5202B0.
A target_name string here and a score_no there indicated that this was indeed, a pacemaker related function. A quick glance at the pseudo-code was enough to find the source of the pacemaker data, a certain sub_520A40.
In here, there was a switch with 20 cases. Most of these were identical, but one in particular caught my attention.
Remember how I mentioned earlier that one of the pacemaker types was a user-defined percentage? If my eyes aren't deceiving me, that looks like a printf format string - and that would make those two percentage symbols an escape sequence.
To confirm my suspicions, I set a breakpoint just after that call to XCgsqzn0000101, then set a new score.
Sure enough, the breakpoint fired and there was an address in the esi register. Accounting for that +2 from the pseudo-code, I subtracted 8 bytes from the address, resulting in 0x3DFF208, which I then opened up in ReClass for a better look.
That first integer matches the one from the case, so that's surely the pacemaker type - most likely an index to another string table. Following that, the target EX score, and finally, the name string - only used when targeting a specific rival or custom percentage.
Fortunately, finding the relevant string table didn't take too long.
.data:00B637D8 ; "sg_fno" .data:00B637DC dd offset aSgMonly ; "sg_monly" .data:00B637E0 dd offset aSgRiva1 ; "sg_riva1" .data:00B637E4 dd offset aSgRiva2 ; "sg_riva2" .data:00B637E8 dd offset aSgRiva3 ; "sg_riva3" .data:00B637EC dd offset aSgRiva4 ; "sg_riva4" .data:00B637F0 dd offset aSgRiva5 ; "sg_riva5" .data:00B637F4 dd offset aSgRivaTop ; "sg_riva_top" .data:00B637F8 dd offset aSgRivaAve ; "sg_riva_ave" .data:00B637FC dd offset aSgAltop ; "sg_altop" .data:00B63800 dd offset aSgAlave ; "sg_alave" .data:00B63804 dd offset aSgLotop ; "sg_lotop" .data:00B63808 dd offset aSgLoave ; "sg_loave" .data:00B6380C dd offset aSgDantp ; "sg_dantp" .data:00B63810 dd offset aSgDanav ; "sg_danav" .data:00B63814 dd offset aSgGhost ; "sg_ghost" .data:00B63818 dd offset aSgPacemakerAaa ; "sg_pacemaker_aaa" .data:00B6381C dd offset aSgPacemakerAa ; "sg_pacemaker_aa" .data:00B63820 dd offset aSgPacemakerA ; "sg_pacemaker_a" .data:00B63824 dd offset aSgPacemaker ; "sg_pacemaker"
As luck would have it, getting to this data programmatically would be easy. I would only need to allocate enough memory to store all the above, meaning 264 bytes, then call sub_520A40 with a pointer to said memory.
With the pacemaker data found, it was time to bid farewell to sub_51FC00. There were a few other cool functions in there related to the graph and background art, but I'll leave that for another time since they weren't particularly useful for the scorecard.
Fortunately, that previous run of Class Informer provided no shortage of avenues to explore next.
I'm still missing all the music related information so StageResultDrawMusicInfo seems like the next logical step. Excluding the destructor, only two entries in the virtual function table were unique. May as well start with the first one, sub_523350.
I'm surprised it took this long to find, but sub_544A90 looks like a way to determine whether the current player is on the 1P or 2P play side. Unlike the arcade releases which can have up to two people playing at the same time, INFINITAS is limited to one.
That's all for this function. Nothing resembling music data just yet, but there's still one more to look at.
I've highlighted some of the immediately interesting stuff, starting with the values returned by sub_546EE0 and sub_4A8560.
Judging by the strings in the two calls to sub_4A8E60, I'm guessing these two contain the current difficulty and play style respectively. The argument in sub_546EE0 most likely being the active player. The string tables all but confirm this.
.data:00B5FF3C 1C D8 B3 00 off_B5FF3C dd offset aDiffSp ; DATA XREF: sub_5233B0+34↑r .data:00B5FF3C ; "diff_sp" .data:00B5FF40 14 D8 B3 00 dd offset aDiffDp ; "diff_dp"
.data:00B5FECC 08 D8 B3 00 off_B5FECC dd offset aDiffNormal ; DATA XREF: sub_5233B0+47↑r .data:00B5FECC ; "diff_normal" .data:00B5FED0 FC D7 B3 00 dd offset aDiffHyper ; "diff_hyper" .data:00B5FED4 EC D7 B3 00 dd offset aDiffAnother ; "diff_another"
I'll forgo including screenshots of those two functions as they both simply returned fixed addresses. Now for that last highlighted function, sub_545340 - this one led straight to the jackpot, returning a pointer to what appeared to be the current music entry.
ReClass to the rescue once again. The first 256 bytes of this class contained the music title (twice?), the artist and the genre. To figure out the size of this structure, I simply added bytes until I saw what appeared to be another title. Very scientific.
After consulting RemyWiki, I was able to distinguish a few more values, including the note count and rating for each difficulty (sometimes easy to miss those 1 byte values when displayed as 4), and a few values that were seemingly related to the BPM.
The song in my test score has a constant BPM throughout, but there are plenty of others in the game that vary. The best example I could think of was ワルツ第17番 ト短調"大犬のワルツ", a rare one where the BPM differs for each chart.
I found it by searching for the artist "virkato" in Cheat Engine and subtracting 192 bytes from the result address.
Oh, that's new. Looks like Japanese titles don't fare too well. On the other hand, that title2 looks pretty good. After checking RemyWiki again, it turns out that this is what would be displayed on the 16-segment LED ticker.
When ワルツ第17番 ト短調"大犬のワルツ" is played on beatmania IIDX, the arcade LED ticker displays the title as "VALSE DU GRANDE CHIEN".
The per-chart BPM values were also listed on RemyWiki, demystifying even more of the class.
Okay, getting sidetracked a bit here. Nothing after the first 256 bytes is actually used for the scorecard.
Anyway, the final piece of data, the DJ name. Official scorecards show this above a customisable player avatar, known as a Qpro. That's yet another arcade exclusive feature, so I'll only be looking for the name. Nothing fancy, just a simple string search.
That's everything I needed, so it's time to start writing some code now, right?
As much as I'd love to, this is a good point to stop at for now. There's still a lot left to do, so stay tuned for the next post where I'll be implementing all these discoveries into an internal library and looking for some good areas to place some hooks.