- 📝 Posted:
- 💰 Funded by:
- Blue Bolt, [Anonymous], Yanga, Ember2528
- 🏷️ Tags:
Part 3 of 📝 the 4-post series about the big 2025 PC-98 Touhou portability subproject, and we actually get to move some percentages on the front page with this one! For once, there truly isn't a lot to mention about most of these five disconnected small-feature decompilations, so let's go for more of a touhou-memories style and string together a few shorter bullet points and paragraphs. For even greater brevity, I'll also use the ZUN code issue emoji you might already know from Twitter or Bluesky: 🐞 denotes a bug, 💣 denotes a landmine, and 🎺 denotes a quirk.
- Revising TH02's main menu
- Finishing TH03's High Score menu
- TH04's title screen animation
- TH05's All Cast sequence
- Finishing TH03/TH04/TH05 scorefiles
- A typo in TH04 Reimu A's Good Ending
Revising TH02's main menu
This was one of those old decompilations from 2015 that I really wanted to bring up to current standards before the debloated branch would roll out the new more portable and performant blitting code. Replacing the magic-number coordinates with constants and calculations revealed 📝 the usual off-by-one text positioning bugs in the Option menu, despite ZUN still using monospaced text in this game…
As for more unique and exciting details in this screen: ZUN's defined gaiji strings contain an unused adaptation of TH01's blinking HIT KEY text. On screen, it might have looked something like this:
Finishing TH03's High Score menu
At the end of 2021, 📝 I already decompiled most of this menu, but left two functions in ASM due to push scope constraints. Originally, I thought that this menu would need a few changes to address a certain scorefile inconsistency I'll mention in Part 4, but I ended up finding a better solution. Still, we got one interesting discovery per function out of it:
-
If you've ever entered a score and were too lazy to type a proper name, you know that TH03 just uses the name of the player character in Romaji if you enter either nothing or AAAAAAAA. But did you know that this happens if you enter any letter 8 times?
-
🐞 When sorting a new score into the list, ZUN does not look at the 9th digit, i.e., the number of continues used. If you ever manage to enter a score whose most significant 8 digits match an existing entry in the current difficulty's score list, those two scores are considered equal and the new score always gets inserted below the old one. If you enter more than one such score, the list will therefore maintain the order in which the scores were entered:
In this example, I first entered 800-million scores with 0, 3, and 1 continues in exactly this order, before entering this new 2-continue score.
TH04's title screen animation
This decompilation was necessary because its palette manipulation code did the very dubious thing of accessing the palette in a freed .PI slot. I don't think that the stylish effect of separately whiting in the image's black outlines is appreciated enough. And yes, that formally was the last non-RE'd tiny bit of any OP.EXE binary!
TH05's All Cast sequence
This sequence contained the last not yet decompiled instance of 📝 masked crossfading, which the debloated branch wants to replace with our single optimized implementation.
Most picture and text cues in this sequence are synced to the BGM, using PMD's AH=05h function to retrieve the current measure. And yes, that's measures, which is indeed the only time unit you get from PMD. The cues appear to be timed based on beats rather than measures, but the secret there is that ZUN simply wrote Peaceful Romancer in the internal time signature of 1/4. Just in case anyone tries to mod this BGM and starts wondering why the sequence suddenly progresses more slowly. I'll just use beats
below since it's shorter.
Any cues that don't appear synced only do so because of – you guessed it – weird ZUN code issues.
-
🐞 But first, what happens if you run the game on a system without an FM chip? PMD does remain resident in that case, but enters a reduced-functionality mode that refuses to even process song data, leaving you with no BGM beats to sync to. Due to the various ways of setting the tempo in a .M file, it's impossible to just parse out the tempo without reimplementing the entire format, so it makes sense why ZUN just hardcoded a fixed replacement delay of 44 frames per beat. However, 44 frames translate to (44/56.423) ≈ 780 ms ≈ 76.94 BPM, which is ~1.9× slower than Peaceful Romancer's actual ~145-147 BPM.
Discoveries like these always start out as quirks until I find evidence that would promote them to bugs. And sure enough: ZUN renders this entire sequence at the halved frame rate of 28.212 FPS, that slowdown factor is suspiciously close to 2, and the code actually specifies 22 frames. This looks as if ZUN simply didn't realize that 22 frames would only translate to the slightly more correct 153.88 BPM at the native frame rate of 56.423 FPS.
This bug also applies if you deactivated BGM in the Option menu, since ZUN treats both cases identically. -
🎺 The very first crossfading animation doesn't appear to be synced to any beat, though? It starts close to but not exactly on beat 5:
This one is quickly explained: ZUN does enter the first screen within 2 frames of Peaceful Romancer's first downbeat on "beat" 3, but each screen actually starts with a 34-frame fade-out of the previous screen before crossfading in the new picture. Hence, most of this apparent delay is taken up by a fade-out from black to black.
The remaining 4 frames between the beat and the first visible on-screen pixels can be attributed to double-buffering at the sequence's halved frame rate. -
🎺 Also, why does the crossfading animation only use two of the four mask patterns across its 16 frames? This seems like a typo in the code, but was almost certainly done on purpose to make this sequence feel more languid and relaxed. The dequirked version with all four mask patterns looks almost too hectic, especially compared to the single mask pattern that ZUN used for text.
-
But even after that initial screen, the first two or three text cues on later screens don't appear in sync with the BGM beats either?
As pointed out by the uneven placement of the Reimu and Rika cues.
These are Yuuka's second and third screens; the fact that each character gets its own sequence of pictures is common knowledge by now, right?To understand this, we have to look at how ZUN defines the target BGM beat for each cue in the first place. There's only a single variable that defines the target beat for the BGM-syncing delay, and ZUN simply adds a certain number of beats to this variable before every cue. In the case of these text cues, he adds 2 beats, which matches what we can observe for the correctly synced cues in the video above. The very first text cue, however, is placed two beats after… the beat the fade-out was started on, even though we've just spent at least 56 frames on the two fading effects. This means that BGM playback will not only have already reached this beat, but will even have progressed about half a beat beyond.
Thus, the game just fades in the text immediately…
💣 …except that it doesn't! All of the above was pretty quirky, but then ZUN adds a definite landmine by loading the .PI file with the picture for the next screen right after the fade-in animation. If you just look at the few lines after that load call, this seems like a productive use of an intended 2-beat delay, but we don't actually get that 2-beat delay, as I explained above. Instead, BGM playback gets to progress even further beyond the target beat, by the CPU-specific amount of frames it takes to load that next .PI image on the system the game happens to run on. I've recorded the video above by running the original game on our target Neko Project 66 MHz configuration, and got an additional 17 frames of cue drift, between frames 101 and 118 inclusive. In the end, it takes the first three text cues for the beat target to catch up with the BGM on this system, and we only return to proper syncing with Meira, where the beat target has finally moved ahead of BGM playback.
That .PI load call would have been much more appropriate before the 30-beat delay in front of the fade-out… -
💣 Even worse, ZUN also loads a new image on the last screen, which defines no next image. This causes the game to unconditionally load from a null pointer, resulting in a landmine in 📝 the classic sense of the word: You can completely ignore it on PC-98 because
- Real Mode just lets you read from address
0000:0000without a segmentation fault - The far pointer to the handler for
INT 0is highly unlikely to actually point to the name of an existing file - That file is even less likely to be a valid .PI file
- The game won't display that image anyway, and free its buffer once the sequence ends shortly after
- Real Mode just lets you read from address
Finishing TH03/TH04/TH05 scorefiles
Well, at least as far as decompilation is concerned. Cleaning up all these binary-specific inconsistencies on the debloated branch will be just as annoying as reconstructing them in the first place, and I won't even get it all the way done within these 11 pushes. TH05 made this even worse by continuing its general trend of taking TH04's slightly bloated but overall fine C++ code and needlessly rewriting it in micro-optimized and only semi-decompilable ASM. If you still believe that the master branch is a good foundation for any kind of serious work, this file should convince you otherwise.
Two more discoveries here:
-
If you game over and continue in-game while having a score that would qualify for the current character/difficulty list, the game automatically enters it with a
CONTINUEname while staying withinMAIN.EXE. Of course, this means that both games get yet another dedicated piece of code to mutate the High Score list…
🐞 And so, the TH04 variant of this code also gets its own distinct version of the 📝 C integer promotion issue that limits the technically supported score to 959 million points. In an unexpected twist though, TH05's ASM rewrite actually manages to fix this issue in a surprisinglynatural
way by explicitly performing the necessary calculations on 8-bit registers. On the other hand, fixing it within C++ would have still been totally possible and natural and code-simplifying… The single biggest source of inconsistencies can be found in the code that recreates corrupted scorefiles. During my tests of the cleaned-up and improved rewrite on the
debloatedbranch, I regularly had to corrupt these files on purpose. File contents getting fully or partially overwritten with00bytes is the most common kind of corruption you'd encounter with modern operating systems and SSDs, but hilariously enough, that happens to be the exact kind of corruption these games might even fail to detect. If these00bytes cover an entire character-/difficulty-specific section, all three games consider such a zeroed section as valid, since it passes checksum validation?
The deobfuscation algorithm explains why:// [key1] and [key2] are `uint8_t` as well. decoded_byte[i] = (key1 + (std::rotr<uint8_t>(encoded_byte[i + 1], 3) ^ key2) + encoded_byte[i]);
When saving a section within these files, the games generate new random values for
key1andkey2and store them directly in the file. Without any kind of hardcoded nonce to perturb the input, this obfuscation scheme thus fully relies on the combination of keys and data to generate random-looking output. Set both of them to 0, and deobfuscation turns into a no-op. Then, a buffer of00also sums to 0, which also matches the 0 checksum in the file. In contrast, TH02's obfuscation scheme lacked any source of randomness, but it did cover this exact case…
Here's how such a fully zeroed-out
GENSOU.SCRlooks like in TH04's and TH05's High Score viewer:

If you remember how
GENSOU.SCRsaves scores in 📝 this silly gaiji-offsetted way, these screens almost explain themselves. 0 minus 160 will always be an invalid sprite ID, and since master.lib'ssuper_put()doesn't bounds-check sprite IDs, it blindly accesses invalid sprite data and probably ends up filling every VRAM bitplane with 1 bits. After the game spent way too much time rendering this garbage data, we then only end up seeing the sprites that get rendered after the very last score digit.
The VV characters might look especially weird in place of the usual stage number, but they quickly make sense once you remember that these numbers are gaiji rendered to VRAM. The PC-98's character generator simply can't support a gaiji with an ID of 0, since it would have to be encoded as0x0056, which is indistinguishable from the halfwidth V in ASCII. And since master.lib assumes that all gaiji are fullwidth, we get two of them next to each other.The visual result for a zeroed-out
YUME.NEMin TH03's High Score screen, however, is much more… well-defined:
Since YUME.NEMstores names, scores, and stage numbers as raw sprite IDs, we get sprite #0 fromREGI2.BFTfor all of them.
AAAAAAAA AAAAAAAAAA A
Finally, I stumbled over a script bug in TH04's Good Ending for Reimu A:

This looks unintentional, and the same line in Reimu B's Good Ending confirms that this is indeed a typo:
\p,ed07.pi \=0,4 魔理沙:なんだよ、そりゃ\ga9\s160\c
\p,ed07.pi \==0,4 魔理沙:なんだよ、そりゃ\ga9\s160\c
The 📝 cutscene command reference tells us that the line in the Reimu B variant is preceded by \==, the picture crossfading command, followed by both possible parameters, 0 and 4. Reimu A's script, however, lacks that second = and instead spells out \=, the immediate picture display command, which doesn't take a second parameter. Thus, the command stops reading after the 0 and leaves the trailing ,4 as text to be displayed in the newly started box. The line break is then ignored as usual, causing 魔理沙 to be displayed right next to these two characters.
Whew! Once again, this did turn into more of the typical ReC98 research by the end after all.
And that was just 75% of the pushes assigned to this post, because the rest already went towards the debloating work. Next up: Concluding this series and actually applying all this research to the games.