vermilion10

Back

vermilion10/wuwa-pulltrack Loading repository info… ★ –⑂ –

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:

  1. The game writes a gacha-history URL somewhere local.
  2. Tracker sites just read that URL and call the game publisher’s own API.
  3. 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 ...
plaintext

A 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);
sql

It 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.log
plaintext

This 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:...
plaintext

Aligning that against the cipher bytes:

PositionCipherPlaincipher ^ plain
0B4[ (5B)EF
1972 (32)A5
2950 (30)A5
3972 (32)A5
4936 (36)A5
58B. (2E)A5
10DE1 (31)EF
11C2- (2D)EF

Two keys, 0xA5 and 0xEF, but alternating on no positional schedule. Sorting by which key applied reveals the rule instantly:

  • Key 0xA5 applies to: 32 30 36 2E 38 3A, all even
  • Key 0xEF applies 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")
python

Result: 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=PC
plaintext

The 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>"
}
python

Probing 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"
}
json

The 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   # WRONG
python

The 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-20
text

350 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)
python

seq is needed because timestamps alone don’t identify a pull:

  • A 10-pull shares one timestamp across all ten records.
  • The same resource_id can 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:

ScenarioExpectedResult
Drop oldest 200 from responsearchive unchanged550 rows, byte-identical
Slid window + 3 new pullsold kept, new added553 rows, old intact
Re-sync identical datano-op0 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:

BannerPulls5★RateAvg pity
Featured Resonator550101.82%54.1
Featured Weapon8411.19%63.0
Standard12121.65%45.5
All755131.72%53.5

The observed rate p^=13/755=1.72%\hat{p} = 13/755 = 1.72\% sits close to the advertised 0.8% base rate once pity is accounted for. The Wilson 95% interval for this sample is wide:

p^±zp^(1−p^)n=0.0172±1.960.0172×0.9828755\hat{p} \pm z\sqrt{\frac{\hat{p}(1-\hat{p})}{n}} = 0.0172 \pm 1.96\sqrt{\frac{0.0172 \times 0.9828}{755}}

which gives roughly [0.86%, 2.65%][0.86\%,\ 2.65\%], 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 bin11–2031–4051–6061–7071–80
Count31153

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#

  1. 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.
  2. Prefer the boring architecture. A CLI that writes a static HTML file needs no server, no port, no process manager, and no deploy.
  3. 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.
  4. 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.

Building a Local-First Gacha Tracker: Reverse-Engineering Wuthering Waves' Convene Log
https://pure.vermilion10.dev/blog/building-a-local-first-gacha-tracker
Author vermilion10
Published at August 23, 2026