ScoreMonitoring in StepMania 5.1
• 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. Upload
s 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.
- Can you assume you’ll stick to one side? There’s an easy solution: introduce a “side” parameter for it.
- 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 Upload
s 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; Upload
s 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 Upload
s 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 nameSteps
, specifically theDifficulty
attributePercentDP
for obvious reasonsDateTime
to determine when you got the scoreStageAward
for full combos, etc (check the source code for valid values)Modifiers
(psst: rate mods)TapNoteScores
for a breakdown of your judgementsGrade
(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 trustDISPLAYBPM
? 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
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:
- 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.
- 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. - 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.
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
, replacinghh: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:
- 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) - 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 ofDate.now() - 6000
if we’re going with our 6 second example) - 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) - 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.
[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.