• Touhou 6 New Classic Score Attack Ongoing!

    Read the submission rules, prizes and everything inbetween here:

    Good luck!

    Also check out the community art event here! Everyone is welcome!

Is there any way to still play Touhou Danmaku Kagura on the phone?

Ever since I found out about the game I was wondering if I could still play it. I tried downloading from a few sites but they didn't work.
 
The official server of this game is shut down, so it is impossible to launch in normal way. But it looks like it is possible to launch a local server ( https://github.com/RainbowUnicorn7297/dankagu-local ), but I am afraid it would require to modify game files to make it possible to connect to it. I didn't try it though.
There may be some other ways how to launch it, but I don't know about them.
 
Hello. Since I’m Japanese, I’m using a translation tool to send this message, so I apologize if it’s a bit clunky.
I’m currently looking at the Python files for this game’s server and working with Claude to edit the application and server files.
However, the security is stricter than I expected (such as detection of app modifications), and since I’m not an expert to begin with, I’m having a hard time with this.
As far as I understand:
GRPC is embedded within the application.
LCX (-iOS) is not included in the application.
The cause of the error is a public key error.
That’s all I know.
Since this game is originally in Japanese, I can read its error messages better than people overseas, so if everyone is trying to get it back up and running, I’ll help with translations as well.
Thank you.
 
The official server of this game is shut down, so it is impossible to launch in normal way. But it looks like it is possible to launch a local server ( https://github.com/RainbowUnicorn7297/dankagu-local ), but I am afraid it would require to modify game files to make it possible to connect to it. I didn't try it though.
There may be some other ways how to launch it, but I don't know about them.
Huh didn't know he uploaded this. I have a fully functional game that he privately distributed to my phone. I forgot how to launch and am also not sure how to run it any longer, but I can confirm it still works. The save file is still defaulted though.
 
omg!?
I can't believe you'd already figured out how to open it.
I also came up with a method using Frida, but I just couldn't find a way to bypass the server check and open it.
If neither of you has any objections to teaching, would it be possible for you to teach us?
 
Last edited:
omg!?
I can't believe you'd already figured out how to open it.
I also came up with a method using Frida, but I just couldn't find a way to bypass the server check and open it.
If neither of you has any objections to teaching, would it be possible for you to teach us?
I actually don't know how to bypass it and stuff since I don't make it, RainbowUnicorn did, and hes been out of contact for a great while. So it's mostly lost art right now, but it the server still seems largely operable, I'll give it a try if I have free time (probably never) as a proof of concept
 
It's fine if there's no problem, but if you're using a modified APK file, could you send it to me?
If you don't want it made public, I can give you my Discord username, but it's okay if you don't want to share it.
 
It's fine if there's no problem, but if you're using a modified APK file, could you send it to me?
If you don't want it made public, I can give you my Discord username, but it's okay if you don't want to share it.
Nope, i do not have permission to send it, it'd be up to rainbow himself but i havent seen anything moving in a while
 
Thank you.
I'll give it my best shot on my own. 😉
I made a independent Android implementation with a mock local server (based on recently released https://github.com/xapenny/dankagu-reborn) and it's working on most functions except shop emulation & event/gacha timeline emulation (or more)
TLDR:
Using a less limited AI (eg I use deepseek and it made it within only 3 days)
Using only Frida is not enough, disassemblers can boost your work
Also for iOS you may find something interesting in https://estertion.win/2021/08/touhou-danmaku-kagura/
Below is what AI said
---
Your public key error is probably not my error. I hit 30008-1000 App_SecurityError and Check2/CheckGameStart, not a public-key path. But the shape is the same: the client refuses to go on before it ever talks to a server. So here's how I got an unmodified client to reach the main screen, one line per conclusion.
libil2cpp.so is entry-level hashed. Any byte change anywhere in .text aborts in four to five seconds, regardless of meaning, even inside unreachable padding.
So do not patch it at all. This is not difficulty, it's impossibility. Every .text patch looks like it works for four seconds.
It is not a section hash and not semantic detection. It's a per-zip-entry checksum over the whole lib/arm64-v8a/libil2cpp.so.
libmobageemoji.so is outside that scope. Patching it is safe, and that's where all client-side work goes.
The surviving config is libil2cpp.so 100 percent pristine, zero bytes changed. That is also the best config for distribution since users install the stock APK.
If the process dies before any request reaches your server log, it's the integrity check, not your protocol work.
The gate has two layers and both must pass or you get 30008. Layer one is CheckGameStart calling getCodeDigest, which resolves the icall into libmobageemoji.so.
Layer two is the server-side SecureHash lookup against ODMGeneric_SecureHash that the server itself sends.
Layer two only checks ContainsKey, not the value. So return an empty digest and register the empty key server-side, and it passes.
Send a structurally valid JSON array with empty Value strings. Empty bytes or malformed JSON makes the client retry 549 times.
In libmobageemoji.so, four bool getters become mov w0,#0;ret, and four string getters must return a malloc'd empty string.
Never return a .rodata address from the string getters. The wrapper calls il2cpp_free on it, and free on a static address is glibc abort. Return heap memory even if it's one byte.
For finding the PLT, do not use index formulas. Resolve icall strings from .rodata through adrp+add, then .plt to .got to the real slot. Otherwise you patch the wrong function and get a silent no-op.
With that, the boot chain is TOUCH TO START, then name entry, then the asset download from 0 to 100 percent, then the prologue, then the main screen at Hakurei Shrine.
Everything after that is server-side. 227 declared RPCs, 207 implemented. The server is the game, the client is a dumb renderer.
Frida loads fine but config must contain on_load resume, and scripts must write to a File because send() has no native host in gadget mode. Use it to find the gate, don't ship it.
Things that waste time, I burned days on these. Patching libil2cpp .text never works. Patching std::terminate gives SIGBUS. Patching anti-cheat booleans in libil2cpp is irrelevant because the hash kills you first.
More time sinks. Sending empty bytes for SecureHash triggers the 549 retries. Returning .rodata strings from the emoji getters aborts. Debugging crypto with block 0 is useless because the IV makes any key look correct, use block 1.
And the big one: a string in the il2cpp literal table does not mean it's thrown from C#. Mine was thrown from Java smali. Check .smali before decompiling 112 MB of ARM64, or you'll chase the wrong layer for a day.
If your error turns out to be the 30008 path, the two-layer part is your answer. If it's genuinely a public-key mismatch, post the exact error ID, it's a different subsystem and I'd rather not guess.
Tested on Touhou Danmaku Kagura 2.1.0, Android 16 arm64, real device. Client is stock APK with libil2cpp.so sha256 8bd066c1 unmodified. Only libmobageemoji.so is patched, eight patches, outside libil2cpp's hash scope. Server is a reimplementation with 207 of 227 RPCs.
 
I made a independent Android implementation with a mock local server (based on recently released https://github.com/xapenny/dankagu-reborn) and it's working on most functions except shop emulation & event/gacha timeline emulation (or more)
TLDR:
Using a less limited AI (eg I use deepseek and it made it within only 3 days)
Using only Frida is not enough, disassemblers can boost your work
Also for iOS you may find something interesting in https://estertion.win/2021/08/touhou-danmaku-kagura/
Below is what AI said
---
Your public key error is probably not my error. I hit 30008-1000 App_SecurityError and Check2/CheckGameStart, not a public-key path. But the shape is the same: the client refuses to go on before it ever talks to a server. So here's how I got an unmodified client to reach the main screen, one line per conclusion.
libil2cpp.so is entry-level hashed. Any byte change anywhere in .text aborts in four to five seconds, regardless of meaning, even inside unreachable padding.
So do not patch it at all. This is not difficulty, it's impossibility. Every .text patch looks like it works for four seconds.
It is not a section hash and not semantic detection. It's a per-zip-entry checksum over the whole lib/arm64-v8a/libil2cpp.so.
libmobageemoji.so is outside that scope. Patching it is safe, and that's where all client-side work goes.
The surviving config is libil2cpp.so 100 percent pristine, zero bytes changed. That is also the best config for distribution since users install the stock APK.
If the process dies before any request reaches your server log, it's the integrity check, not your protocol work.
The gate has two layers and both must pass or you get 30008. Layer one is CheckGameStart calling getCodeDigest, which resolves the icall into libmobageemoji.so.
Layer two is the server-side SecureHash lookup against ODMGeneric_SecureHash that the server itself sends.
Layer two only checks ContainsKey, not the value. So return an empty digest and register the empty key server-side, and it passes.
Send a structurally valid JSON array with empty Value strings. Empty bytes or malformed JSON makes the client retry 549 times.
In libmobageemoji.so, four bool getters become mov w0,#0;ret, and four string getters must return a malloc'd empty string.
Never return a .rodata address from the string getters. The wrapper calls il2cpp_free on it, and free on a static address is glibc abort. Return heap memory even if it's one byte.
For finding the PLT, do not use index formulas. Resolve icall strings from .rodata through adrp+add, then .plt to .got to the real slot. Otherwise you patch the wrong function and get a silent no-op.
With that, the boot chain is TOUCH TO START, then name entry, then the asset download from 0 to 100 percent, then the prologue, then the main screen at Hakurei Shrine.
Everything after that is server-side. 227 declared RPCs, 207 implemented. The server is the game, the client is a dumb renderer.
Frida loads fine but config must contain on_load resume, and scripts must write to a File because send() has no native host in gadget mode. Use it to find the gate, don't ship it.
Things that waste time, I burned days on these. Patching libil2cpp .text never works. Patching std::terminate gives SIGBUS. Patching anti-cheat booleans in libil2cpp is irrelevant because the hash kills you first.
More time sinks. Sending empty bytes for SecureHash triggers the 549 retries. Returning .rodata strings from the emoji getters aborts. Debugging crypto with block 0 is useless because the IV makes any key look correct, use block 1.
And the big one: a string in the il2cpp literal table does not mean it's thrown from C#. Mine was thrown from Java smali. Check .smali before decompiling 112 MB of ARM64, or you'll chase the wrong layer for a day.
If your error turns out to be the 30008 path, the two-layer part is your answer. If it's genuinely a public-key mismatch, post the exact error ID, it's a different subsystem and I'd rather not guess.
Tested on Touhou Danmaku Kagura 2.1.0, Android 16 arm64, real device. Client is stock APK with libil2cpp.so sha256 8bd066c1 unmodified. Only libmobageemoji.so is patched, eight patches, outside libil2cpp's hash scope. Server is a reimplementation with 207 of 227 RPCs.
Me and AI both forgot something

fix by AI:
Corrections to my post above, one of them matters.

The claim that patching libil2cpp.so .text is "not difficulty, it's impossibility" is too strong. It holds on a stock client, which is why I still ship libil2cpp.so byte-identical: flipping one bit inside unreachable padding kills it in 4-5 s, so it is not semantic detection.

But the check is not unconditional. Its input is the digest from getCodeDigest(), which resolves the icall into libmobageemoji.so. Patch that to return an empty string and the comparison degenerates: "".Split(',') yields [""], the containment test passes, and .text edits stop being detected. With .text patched that way I measured 45-70 s of survival, not 4 s.

So the accurate statement is: .text is not patchable while the emoji-side digest is live, and neutralising the digest first costs you the stock-client property. If you already patch libmobageemoji.so, you have the option; I don't take it because users should install the stock APK.

Two smaller numbers in my post were wrong:

  • The protos declare 151 RPCs, not 227. 26 of those still get an empty default response from my server. (The 227 figure was the count of methods in my handler table, a different denominator.)
  • libmobageemoji.so carries three patched regions, six instructions, not eight patches.
 
I made a independent Android implementation with a mock local server (based on recently released https://github.com/xapenny/dankagu-reborn) and it's working on most functions except shop emulation & event/gacha timeline emulation (or more)
TLDR:
Using a less limited AI (eg I use deepseek and it made it within only 3 days)
Using only Frida is not enough, disassemblers can boost your work
Also for iOS you may find something interesting in https://estertion.win/2021/08/touhou-danmaku-kagura/
Below is what AI said
---
Your public key error is probably not my error. I hit 30008-1000 App_SecurityError and Check2/CheckGameStart, not a public-key path. But the shape is the same: the client refuses to go on before it ever talks to a server. So here's how I got an unmodified client to reach the main screen, one line per conclusion.
libil2cpp.so is entry-level hashed. Any byte change anywhere in .text aborts in four to five seconds, regardless of meaning, even inside unreachable padding.
So do not patch it at all. This is not difficulty, it's impossibility. Every .text patch looks like it works for four seconds.
It is not a section hash and not semantic detection. It's a per-zip-entry checksum over the whole lib/arm64-v8a/libil2cpp.so.
libmobageemoji.so is outside that scope. Patching it is safe, and that's where all client-side work goes.
The surviving config is libil2cpp.so 100 percent pristine, zero bytes changed. That is also the best config for distribution since users install the stock APK.
If the process dies before any request reaches your server log, it's the integrity check, not your protocol work.
The gate has two layers and both must pass or you get 30008. Layer one is CheckGameStart calling getCodeDigest, which resolves the icall into libmobageemoji.so.
Layer two is the server-side SecureHash lookup against ODMGeneric_SecureHash that the server itself sends.
Layer two only checks ContainsKey, not the value. So return an empty digest and register the empty key server-side, and it passes.
Send a structurally valid JSON array with empty Value strings. Empty bytes or malformed JSON makes the client retry 549 times.
In libmobageemoji.so, four bool getters become mov w0,#0;ret, and four string getters must return a malloc'd empty string.
Never return a .rodata address from the string getters. The wrapper calls il2cpp_free on it, and free on a static address is glibc abort. Return heap memory even if it's one byte.
For finding the PLT, do not use index formulas. Resolve icall strings from .rodata through adrp+add, then .plt to .got to the real slot. Otherwise you patch the wrong function and get a silent no-op.
With that, the boot chain is TOUCH TO START, then name entry, then the asset download from 0 to 100 percent, then the prologue, then the main screen at Hakurei Shrine.
Everything after that is server-side. 227 declared RPCs, 207 implemented. The server is the game, the client is a dumb renderer.
Frida loads fine but config must contain on_load resume, and scripts must write to a File because send() has no native host in gadget mode. Use it to find the gate, don't ship it.
Things that waste time, I burned days on these. Patching libil2cpp .text never works. Patching std::terminate gives SIGBUS. Patching anti-cheat booleans in libil2cpp is irrelevant because the hash kills you first.
More time sinks. Sending empty bytes for SecureHash triggers the 549 retries. Returning .rodata strings from the emoji getters aborts. Debugging crypto with block 0 is useless because the IV makes any key look correct, use block 1.
And the big one: a string in the il2cpp literal table does not mean it's thrown from C#. Mine was thrown from Java smali. Check .smali before decompiling 112 MB of ARM64, or you'll chase the wrong layer for a day.
If your error turns out to be the 30008 path, the two-layer part is your answer. If it's genuinely a public-key mismatch, post the exact error ID, it's a different subsystem and I'd rather not guess.
Tested on Touhou Danmaku Kagura 2.1.0, Android 16 arm64, real device. Client is stock APK with libil2cpp.so sha256 8bd066c1 unmodified. Only libmobageemoji.so is patched, eight patches, outside libil2cpp's hash scope. Server is a reimplementation with 207 of 227 RPCs.
would be nice if you could figure out save files
 
would be nice if you could figure out save files
Corrections #2
My bad
Below are by AI (revisioned by me, it sucks in memorizing after multiple compacts)

===

How dankagu-local stores player state — since you're on local, this is the relevant part.

It doesn't have a database. There are eight files in takasho/schema/common_featureset/player_api/:

player_storage.0.hex 345,729 B <- the big one: Spot/Card/Character/ShopPurchase status
player_storage.1.hex 7,899 B <- MissionStatusCollection
player_storage.2.hex 305 B
player_storage.3.hex 285 B
player_storage.4.hex 573 B <- DanmakuPassSeasonStatus
player_storage.5.hex 573 B
player_storage.6.hex 178 B <- MySpotStatus
player_storage.7.hex 662 B <- JoinClubStatus

GetEntriesV2 opens all eight, does FromString(bytes.fromhex(f.read())) into a PlayerStorageGetEntriesV2.Response, and returns the entries filtered by the request criteria. That's it — the save is a frozen protobuf snapshot captured from a real session, stored as hex text.

The consequence worth knowing before you spend time on it: SetEntriesV2 does not persist anything. It copies the request entries into a response, stamps player_id / created_at / updated_at, sets response.revision = request.next_revision, and returns. There is no open(..., 'w') anywhere in the file. So in-game progress is discarded on restart, and the game always comes back to the captured state. If you want different save data, you edit those hex files directly — decode, patch the protobuf, re-encode to hex.

For contrast, dankagu-reborn (what I'm on) uses PostgreSQL: a player_storage_entries table of (player_id, key, value bytea, created_at, updated_at), with keys like PlayerStatus--1 or EventInfoStatus-V41-41. Same protobuf payloads, same key naming, just in a table instead of hex files. So if you and I compare notes and the behaviour differs, check this first — a lot of what I said about storage records assumes rows I can update, and in local those are constants in the repository.



Now the corrections. Here's where the APK actually needs editing.

1. assets/eos.txt first. 12 bytes of plaintext, shipped as 1674712800 (2023-01-26). Set it to 2000000000 and the EOS window stops appearing. Do this before anything else, or the client shows the termination notice without ever attempting a connection. Net.EmbeddedConstant..cctor reads this file and feeds the same constant to both IsServerClosed() and TitleMainDisp.IsEndOfService(), so one edit opens both gates.
// this is not A/B tested yet

2. libil2cpp.so is patchable, and you do need to patch it. My "not difficulty, it's impossibility" line was wrong. Exactly two bytes change, and they are not in .text: .rela.plt entry 223, redirecting std:🧵:~thread() (sym 2189) to std::bad_array_new_length::~bad_array_new_length() (sym 618), which makes that destructor a no-op. I tested this properly: reverted those two bytes to stock, rebuilt, signed, installed. The client still reached the title screen, but aborted the moment I tapped TOUCH TO START. So if you skip this patch, that is where it dies.

I want to be honest about the mechanism, because it is not what I first said. The abort stack is not std::terminate. It is abort from libc, called from il2cpp_free at libil2cpp+0x4E5FCBC, reached from SecurityChecker.CheckGameStart at +0x2325AA4. In other words it dies freeing a pointer it should not be freeing, not from a joinable-thread destructor. I have not worked out how a relocation redirect avoids that path. All I can state is the observation: with the two bytes stock, it crashes after TOUCH TO START; with them redirected, it does not. Treat the mechanism as unexplained and the effect as measured.

3. The per-zip-entry checksum is also real, and it is a different failure from the one above. Flipping one bit inside unreachable .text padding killed a stock client in 4-5 seconds, so it is not semantic detection. The check's input is the digest from getCodeDigest(), which resolves into libmobageemoji.so; patch that to return an empty string and the comparison degenerates, "".Split(',') gives [""] and the containment test passes, after which .text edits stopped being detected in my tests, 45 to 70 seconds of survival. If you skip the libmobageemoji side you do not get that far anyway — it dies before the UnknownX logo is drawn. I have not audited the whole chain end to end, so treat this as a lead rather than a recipe.
 
Also I didn't make any experiment on dk-local so don't take AI's words too seriously, but seems dkl is too simple for a fully functional server(
 
Back
Top