ScoreMonitoring in StepMania 5.1

✔️
Needs fact checking:
• Is it guaranteed that P1’s score will come first before P2 in an Upload score file? (Empirically confirmed, but not 100% sure)

I’ve heard a fair bit of interest from ITG friends regarding the live score monitoring feature that’s in my streams (and Dingoshi’s before his layout change for SRPG 6).

This makes use of a feature that’s apparently of so little use, a developer of StepMania was questioning why it was there in the first place (I don’t have a quote ready for this; all I remember was that it was a comment in the source code somewhere).

Backend: The Upload Folder

After every song or marathon that’s been played (specifically, when we enter ScreenEvaluation), StepMania saves your score in its own XML file inside the Save/Upload folder (either at the program’s root for portable mode or in your user folder if not, etc) titled yyyy-mm-dd nnnnn: dated and suffixed with a five-digit zero-indexed incrementing value representing the score played for that day.

The HighScore format is exactly the same as what you’d see in Stats.xml, it’s just housed in a different parent structure. Their XPath:

  • Stats.xml: /Stats/SongScores/Song[]/Steps/HighScoreList/HighScore[]
  • Upload: Stats/RecentSongScores/HighScoreForASongAndSteps[]/HighScore

As the Upload XML only contains one score, you can assume there will only be one HighScore item. Actually, this isn’t the case: if two (or perhaps more) players play the same stage, an Upload can contain multiple HighScoreForASongAndSteps.

After analysing a few scores, I can say with high certainty that the first score in such an Upload file will always be P1, and the second score P2. Enough certainty that you can quote me on this, but not enough to be held responsible for any consequences that may occur (say, in a competition). Of course, by all means feel free to check the source code, as the answer will be there.

Due to how the end solution is supposed to be a web source that you add to OBS, you most likely want this data to be presented as an API; in other words, you access a location (say, http://localhost:42069) and your app that scans the Upload folder will respond with a JSON object that you use to show the data.

…That’s it, really. Sorry if that was a bit anti-climactic. However, this Upload folder is crucial to our setup.

Stats vs. Uploads with two players

When two players play, you need to be able to distinguish which one is you. How you implement that in your streaming solution matters.

  1. Can you assume you’ll stick to one side? There’s an easy solution: introduce a “side” parameter for it.
  2. Are you going to determine it by a player tag? What assumptions can you make, given that scores may or may not immediately have a name associated with it?

Let’s talk about #1. The good thing about Uploads are that it’s easy to scan and access, the relevant score is in an individual single file, and that they (seem to) always have P1’s score come first, then P2’s second. Getting the right one is simple.

#2 is a bit more tricky. Back then, Stats.xml and an Upload had an important distinction: Stats was saved after every credit; Uploads were saved after every stage. This made Stats unsuitable for stream use since we didn’t want the viewer to wait for a whole credit to finish – which, if Event Mode is enabled, is never, until the player exits the session.

There was a reason for this. It’s only after when players enter their names in does the game have “full information” on what scores are to be written to Stats: score names, and removing old entries where necessary (max machine scores, max player scores, etc). Notice how relying on this file causes a side effect: if the current score wasn’t good enough to make it to Stats, it doesn’t exist.

It should also be known that when an Upload is written, it is deemed final. Therefore, all Uploads actually don’t have a name associated with a score, even if you do use a profile (you may decide to use a different name, for instance).

Now, with the current version of StepMania, Stats updates after every stage, adding scores with a blank name – unless you used a profile of some sort, whether that be from a USB or on the machine. Then, at the end of the credit, it updates/culls scores again given the “full information”. Upload behaviour remains the same.

So, unless you always remember to use a profile, relying on names isn’t viable. If you’re willing to stick to one side, the first option is definitely an easier alternative.

Of course, these two strategies are just examples. You might come up with other better solutions – but just keep in mind of the limitations and circumstances, if any, that make it work or break.

Backend: Processing the Files

Given that we have a folder full of scores that writes itself the moment we hit the evaluation screen, we can simply enumerate through every file in the Upload folder.

Upload XML

Useful data here might include:

  • PlayerGuid if you’re playing with a profile (otherwise this is empty)
  • SongDir for the song name
  • Steps, specifically the Difficulty attribute
  • PercentDP for obvious reasons
  • DateTime to determine when you got the score
  • StageAward for full combos, etc (check the source code for valid values)
  • Modifiers (psst: rate mods)
  • TapNoteScores for a breakdown of your judgements
  • Grade (psst: it’s to determine whether you failed)
  • Disqualified maybe

You can choose to traverse the model tree, but I took the long route and bound my variables.

One thing you’ll have noticed is that SongDir gives you the directory of the song, which may not necessarily be the actual song’s name. This means we should be putting in a bit more effort and also accessing the simfile at its location:

Simfile Data

The simfile will give you details about the song, which could include:

  • TITLE (psst: filter out [lvl] and/or [bpm] ECS/SRPG/ITL standardised labelling)
  • SUBTITLE
  • ARTIST
  • Transliterations for those tags, if you’d rather not deal with foreign language characters/fonts
  • BPMS, perhaps finding a min/max if necessary, multiplying it by the music rate, and then displaying that range (would you ever trust DISPLAYBPM? Hmm, London Evolved Type B?)
  • NOTES, to which you’ll need to keep searching (as each difficulty has its own tag) until you hit the right one

Backend: Filtering the List

Now that we’ve got sufficient data to display for a score, the next thing to do is to figure out a way to only get the most recent one: if we leave our function as it is now, our final output will dump every single score in that Upload folder – which, after possibly years of gameplay, would equate to a damn lot of scores it’s returning.

The solution to this is a no-brainer: restrict it based on time (by, say, passing the function a time value). We already have something we can use that doesn’t require reading the contents of the XML – its last written date.

In practice, what you would do is request the function to grab everything within a certain period of time from the present and keep doing this on a frequent basis, where “frequent” is enough times to make updating look as if it was real-time without going too crazy. A couple to several seconds should be the right balance.

The more “correct” answer is to instead create a file watcher and send out a message via a webhook, although I personally think that requires a lot more preparation overhead than what is worth the effort. (Disclaimer: I’ve actually never tried; I’m currently working on another project that does utilise a watcher, though, so this opinion might change.)

Frontend: Design

Designing mockups of the score animations for my snooker themed stream layout, and putting it in action on my laptop via CSS.
it’s the same kind of deal when it comes to layouts, too: start with a sketch. excuse the typo of the song name too btw, it’s actually だ (is) + もん (sentence end)

I usually have already envisioned, down to small details, what I want the presentation and animation of the scores to look like when they’re being displayed. You might’ve noticed that those pool balls are actually correctly coloured and striped for all values 1 to 15, and this repeats itself for every multiple onwards.

I can’t really offer much advice on how to do this (design and animation wise) other than just having a creative mind. Some of my inspiration and ideas come from watching other streams and games.[1] Consuming content like this is totally for R&D purposes, I swear.

Frontend: Polling

Once you know how your scores will animate on and off with manually triggered scores, it’s time to replace it with actual data.

Earlier we talked about how the frontend continuously asks for scores every p seconds to give it the appearance of having a real-time feed. For this explanation, let’s make p = 3.

Now, what we want to do is look back, every p seconds, the previous 2p seconds (i.e. 6 seconds). The rationale behind doing this versus simply checking for the past p seconds every p seconds are twofold (no pun intended), plus an inherent property:

  1. Of course, this won’t matter if your monitoring is occurring on the same machine as the game (like, a home PC setup), but if it’s separate (like, a laptop and a cabinet), there is no guarantee the monitor and the cabinet have their clocks exactly synced. If your laptop is even 0.5s too late/early from the cabinet, that’s a possible gap where your score could be written to/read from that isn’t picked up by the monitor.
  2. Even if your clocks are perfectly synced, there’s the rare (?) chance that there’s a delay between the moment when you obtained your score and when the Upload file is written.
  3. In these above cases, the missed score occurred because we only checked once for every possible period in the past. If we know that some external problem may create gaps in our checks, we really should be looking over everything twice, which is what this double duration strategy covers.
A timeline of various strategies for polling. Each polling duration is 3 seconds, but as demonstrated, we want the length of time to look in the past to be twice as long as the frequency to ensure we always have two checks for scores at any time (any more wouldn’t be necessary).
the first value is the time to look back, the second the frequency. the key word here is contingency

Having a backup second check means the monitor will potentially pick up the same score that occur between two polls, causing a duplicate. This can be trivially solved by comparing against the previous score’s time, and not including it if it’s the same.[2] (I actually compare by percentage score, which obviously won’t work if you’ve got enough consistency to quad stuff – or have the luck to repeat a score twice.)

You may notice there are still gaps in this strategy (what happens if a score doesn’t save within 2p seconds, etc) and you could improve it in other ways. But I think this is pretty good for an 80:20 – after all, is it really worth the extra effort dealing with circumstances like those that are extremely unlikely to occur?

Example of Time Desync

To demonstrate a real life example of clocks desyncing, take my cab at Koko for instance. Normally, it doesn’t have internet connectivity for security reasons. So when I need to sync the time, I:

  • SSH into the cab and run the one-liner sudo date +%T -s "hh:mm:ss" && sudo hwclock --systohc, replacing hh:mm:ss with a time about 20 or so seconds in the future
  • Execute the command and type the sudo password in
  • Open the clock modal on my taskbar (the window that shows the calendar and time when you click on it) to get a rhythm going in my head
  • Then switch back to the terminal (which closes that modal) and hit enter in the terminal as close to what I think the time I typed in is

This will sync the time up to within about a second, well inside the bounds of 2p. But it’s definitely no zero.

That’s the Package!

And with that, you should have a complete system. The process may look something like this:

  1. Start the backend, which has an API endpoint (say, http://localhost:5000. Or, if you’re running the app behind a reverse proxy, whatever that address is)
  2. Your frontend has a script that polls the backend every p seconds to look for scores on or after the current date minus 2p seconds (say, http://localhost:5000?after={time} where {time} is a time value of your choice, typically something along the lines of Date.now() - 6000 if we’re going with our 6 second example)
  3. The backend looks for scores in the Uploads folder with the timestamp filter and maybe returns a score (or scores, if you’re handling two player, or polling went funny and you got back two scores, etc)
  4. Your frontend processes this (deduplication, song name cleanup, etc) and animates a new score if there’s a score to be shown

The backend is usually an executable program you manually double click on and leave in the background. The frontend is usually a browser source in OBS. Of course, whilst you’re in the process of developing it, do it in your actual browser.

It goes without saying, if you’re running a similar wireless setup like my cab, you also need to keep in mind about opening the appropriate ports (ufw perhaps) and fidgeting with CORS.

Man, CORS...


[1] Specifically, I got inspiration for design aesthetic, animations, and counter-examples of bad layout decisions from watching VTuber streams such as hololive. (Diamond masked chat log... seriously?) The card expansion animation, or more just the easing curve, was heavily influenced from Genshin Impact’s map reveal animation when you discovered a new area.

[2] I guarantee you, under virtually all circumstances, you’re not going to attempt to go out of your way to play a two-second chart from Crapyard Scent twice within 2p seconds and obtain the same 100% score. But don’t let me stop you.