Building a Local-First Gacha Tracker: Reverse-Engineering Wuthering Waves' Convene Log
Why grep finds nothing in Wuthering Waves' Client.log, how to decode it in one line, and the archive bug that nearly ate six months of history.
Building a Local-First Gacha Tracker#
Every gacha tracker asks you to paste a URL into someone else’s website. That URL contains your account identifier and a signed token. The site fetches your pull history, stores it, and shows you a dashboard.
Nothing about that pipeline requires a third party. The game already writes everything needed onto your own disk. This post is the record of removing the middleman, including the two places where my first assumption was wrong, and the bug that would have silently destroyed the very data the tool exists to preserve.
Note
All account identifiers and tokens in this post are redacted. The record_id in
particular is a short-lived credential, treat it like a password.
The premise#
The hypothesis was simple:
- The game writes a gacha-history URL somewhere local.
- Tracker sites just read that URL and call the game publisher’s own API.
- Therefore the whole loop can run locally.
All three turned out to be true. Getting to them took longer than expected.
Method: three dead ends#
Dead end 1, grep finds nothing#
The community scripts all describe the same approach: search Client.log for a
URL matching aki-gm-resources.../aki/gacha/index.html#/record. So I searched
every log in the game’s log directory.
Zero matches. Not even the bare substring gacha.
The file was clearly alive, modified seconds earlier, 3.5 MB. So the data was there and my reader was wrong. Dumping the first bytes explained why:
EF BB BF B4 97 95 97 93 8B 95 9D 8B 97 DE C2 DE DA ...plaintextA UTF-8 BOM (EF BB BF), then bytes that are not valid UTF-8. The log is
obfuscated. A rotated backup, closed, not being written, showed the same shape,
so this wasn’t an artifact of reading a file mid-write.
Dead end 2, the webview’s storage#
The convene history screen is a webview, so its storage seemed promising.
Client/Saved/LocalStorage/LocalStorage.db is a plain SQLite file:
CREATE TABLE LocalStorage(key text primary key not null, value text not null);sqlIt holds ~250 keys, graphics settings, red-dot state, RecentlyLoginUID, and
teasingly GachaPoolOpenRecord_<uid>. But that last one is just a list of banner
IDs you’ve viewed. No URL, no token.
Dead end 3, the SDK’s debug log#
Older scripts mention a second path:
Client/Binaries/Win64/ThirdParty/KrPcSdk_Global/KRSDKRes/KRSDKWebView/debug.logplaintextThis one is plaintext, a Chromium debug log. It still exists, but contains only
GPU errors and spdy_session warnings. Whatever used to log the record URL there
no longer does.
Important
Three plausible sources, all dry. At this point the only remaining lead was the obfuscated log itself.
Finding: the cipher is one line#
A single-byte XOR brute force scored no key above ~0.83 printable ratio, so not a trivial cipher. But there was a much better lever available than brute force: known plaintext.
UE4 logs open with their own timestamp, and the rotated backup file was named
Client-backup-2026.08.21-15.21.53-.... So the plaintext almost certainly began:
[2026.08.21-15.21.53:...plaintextAligning that against the cipher bytes:
| Position | Cipher | Plain | cipher ^ plain |
|---|---|---|---|
| 0 | B4 | [ (5B) | EF |
| 1 | 97 | 2 (32) | A5 |
| 2 | 95 | 0 (30) | A5 |
| 3 | 97 | 2 (32) | A5 |
| 4 | 93 | 6 (36) | A5 |
| 5 | 8B | . (2E) | A5 |
| 10 | DE | 1 (31) | EF |
| 11 | C2 | - (2D) | EF |
Two keys, 0xA5 and 0xEF, but alternating on no positional schedule. Sorting by
which key applied reveals the rule instantly:
- Key
0xA5applies to:32 30 36 2E 38 3A, all even - Key
0xEFapplies to:5B 31 2D 35 33 5D, all odd
The key is selected by the plaintext byte’s parity. That sounds circular, you need the plaintext to pick the key, except both keys have bit 0 set, so XOR always flips the low bit. The cipher byte’s own low bit tells you which key was used.
def decode(raw: bytes) -> str:
body = raw[3:] if raw[:3] == b"\xef\xbb\xbf" else raw # BOM is not obfuscated
return bytes((b ^ 0xA5) if (b & 1) else (b ^ 0xEF)
for b in body).decode("utf-8")pythonResult: valid UTF-8, 84,002 lines, readable UE4 log, including Chinese-language entries, which is why the naive “printable ASCII ratio” heuristic capped at 0.86 and made the decode look wrong at first glance.
Tip
When a brute force stalls, look for known plaintext. A log file that embeds its own timestamp in its filename is a gift.
And there it was:
https://aki-gm-resources-oversea.aki-game.net/aki/gacha/index.html#/record
?player_id=<REDACTED>
&record_id=<REDACTED>
&svr_id=<REDACTED>
&resources_id=<REDACTED>
&gacha_type=1&lang=en&platform=PCplaintextThe API#
Those four parameters are everything the record endpoint needs:
POST https://gmserver-api.aki-game2.net/gacha/record/query
{
"playerId": "<player_id>",
"cardPoolId": "<resources_id>",
"cardPoolType": 1, # 1..8, the actual banner selector
"languageCode": "en",
"recordId": "<record_id>",
"serverId": "<svr_id>"
}pythonProbing all eight pool types showed cardPoolType is the real selector,
cardPoolId doesn’t need to vary with it. Each call returns that banner’s
complete window, newest first:
{
"cardPoolType": "Resonators Accurate Modulation",
"resourceId": 21030013,
"qualityLevel": 3,
"resourceType": "Weapon",
"name": "Pistols of Night",
"count": 1,
"time": "2026-08-20 10:23:17"
}jsonThe one irreducible constraint#
record_id is minted when you open Convene History in-game, and expires in roughly
a day. No tool avoids this, not this one, not the hosted trackers. It is a
property of the API, not of running locally.
sequenceDiagram
participant P as Player
participant G as Game client
participant L as Client.log
participant T as Tracker (local)
participant K as Kuro API
P->>G: Open Convene History
G->>L: write signed URL (obfuscated)
P->>T: run `sync`
T->>L: read + XOR-decode
L-->>T: player_id, record_id, svr_id
loop pool types 1..8
T->>K: POST /gacha/record/query
K-->>T: full window for that banner
end
T->>T: archive into SQLite
Architecture#
No server, no client, no backend. One CLI that produces a report.
flowchart LR
A[Client.log] -->|XOR decode| B[convene URL]
B -->|parse params| C[Kuro record API]
C -->|~6 month window| D[(pulls.db<br/>append-only)]
D --> E[report.html]
style A fill:#4a3aa7,color:#fff
style D fill:#eda100,color:#000
style E fill:#1baf7a,color:#fff
A static-file pipeline beats a local web server here: nothing to keep running, nothing listening on a port, and the output is a file you can archive or email.
The bug that mattered#
Kuro’s API serves a rolling window of roughly six months. Older pulls stop coming back. That single fact is the tool’s entire reason to exist, and it broke my first schema.
The API gives no pull ID. So identity has to be derived. My first attempt keyed each row on its position from the oldest record:
pull_index = total - i # WRONGpythonThe reasoning: history only grows at the newest end, so position-from-oldest is stable. That reasoning holds only while the window doesn’t slide.
When the oldest records age out, every index shifts by the number dropped. The upsert then writes new content onto old keys. Replaying that rule against a simulated slide on a real 550-row banner:
v1 keys written on full window : 550
v1 keys written on slid window : 350
keys whose CONTENT changed : 350 <-- silent overwrites
e.g. pull_index 350: was 'Originite: Type I' @ 2026-06-13
now 'Pistols of Night' @ 2026-08-20text350 of 550 rows silently corrupted, precisely the old history the archive exists to protect. No error, no constraint violation. Just wrong data.
The fix: content-addressed identity#
Anchor identity to absolute time, never to position:
PRIMARY KEY (player_id, pool_type, time, seq)pythonseq is needed because timestamps alone don’t identify a pull:
- A 10-pull shares one timestamp across all ten records.
- The same
resource_idcan appear three times within that single second.
So within each timestamp, pulls are numbered 1..n oldest-first. That key survives
any window movement, because a timestamp never changes.
stateDiagram-v2
[*] --> Fetched: API returns window
Fetched --> Existing: key already in archive
Fetched --> New: key unseen
Existing --> Kept: refresh last_seen only
New --> Stored: insert with first_seen
Kept --> [*]
Stored --> [*]
note right of Kept
Rows the API stopped
returning are never
touched. That is the
archive.
end note
Verified against the real database:
| Scenario | Expected | Result |
|---|---|---|
| Drop oldest 200 from response | archive unchanged | 550 rows, byte-identical |
| Slid window + 3 new pulls | old kept, new added | 553 rows, old intact |
| Re-sync identical data | no-op | 0 new rows |
Warning
If you build one of these: never key gacha rows by position in the API response. It works perfectly until the day it destroys your history, and it fails silently when it does.
Findings from 755 pulls#
With the archive in place, the data is worth a look. Across three banners:
| Banner | Pulls | 5★ | Rate | Avg pity |
|---|---|---|---|---|
| Featured Resonator | 550 | 10 | 1.82% | 54.1 |
| Featured Weapon | 84 | 1 | 1.19% | 63.0 |
| Standard | 121 | 2 | 1.65% | 45.5 |
| All | 755 | 13 | 1.72% | 53.5 |
The observed rate sits close to the advertised 0.8% base rate once pity is accounted for. The Wilson 95% interval for this sample is wide:
which gives roughly , with 13 successes, this sample cannot distinguish much. Thirteen five-stars is an anecdote, not a measurement.
The pity distribution is more interesting than the rate:
| Pity bin | 11–20 | 31–40 | 51–60 | 61–70 | 71–80 |
|---|---|---|---|---|---|
| Count | 3 | 1 | 1 | 5 | 3 |
Eight of thirteen landed at pity 61 or higher, and the 21–30, 41–50 bins are empty. That bimodal shape, a few early, a cluster late, a gap between, is the visible fingerprint of a soft pity mechanic: the rate stays low, then ramps sharply somewhere in the 60s. You can see the mechanic in the shape even when the sample is far too small to pin down the rate.
What it looks like#
The output is a single self-contained report.html, no CDN, no server, no
network. Per banner: current pity against the 80-pull hard cap, 5★ rate, average
pity, and every 5★ as a bar whose length is the pity it landed at. Plus an
all-banners summary with monthly volume and the histogram above.
Dark mode is a selected palette rather than an inverted one, and the two data colors (gold for 5★, violet for 4★) were validated for colorblind separation rather than eyeballed, worst-pair ΔE 41.0 in light, 27.3 in dark.
Takeaways#
- Obfuscation is not encryption. A parity-selected two-key XOR is one line to undo once you have known plaintext. The filename was the known plaintext.
- Prefer the boring architecture. A CLI that writes a static HTML file needs no server, no port, no process manager, and no deploy.
- Interrogate your primary key against time. “History only grows at the end” was true on the day I wrote it and false six months later. Any key derived from position in a paginated response is a bug waiting for a rolling window.
- Local-first means the archive outlives the API. The upstream keeps six months. The SQLite file keeps everything.
The whole thing is ~600 lines of Python with no dependencies outside the standard library. The interesting part was never the code, it was the twenty minutes of staring at hex.