@sattori/touhou-replay-parser
v0.3.0
Published
Decoder for Touhou Project replay files (.rpy), covering th06 through th20. Zero runtime dependencies.
Maintainers
Readme
@sattori/touhou-replay-parser
A zero-dependency TypeScript library for decoding Touhou Project main-series
replay files (.rpy). Written for
Sattori (a Touhou replay recording web
service), but designed with no dependency on Sattori-specific types, so it can
be used standalone.
Based on a TypeScript port of ReplayDecoder.cs (the C# implementation) from
raviddog/threplay, with additions for
correct decoding of Shift_JIS player names, safe error handling for corrupted
files, and support for titles that threplay did not cover.
Installation
npm install @sattori/touhou-replay-parserRequires Node.js >= 14 (or a modern browser). Player names and dates are
decoded as Shift_JIS via the global TextDecoder, which needs a full-ICU
build — the default for official Node.js binaries since Node.js 13. On a
runtime without Shift_JIS support, decoding silently falls back to Latin1
(mojibake for Japanese text, not an error) rather than failing outright.
Usage
import { parseReplay } from "@sattori/touhou-replay-parser";
import { readFile } from "node:fs/promises";
const data = new Uint8Array(await readFile("th7_01.rpy"));
const result = parseReplay(data);
if (result.ok) {
const { game, player, character, difficulty, score, cleared, splits } = result.replay;
console.log(`${game}: ${player} / ${character} / ${difficulty} / ${score}`);
} else {
// parseReplay never throws. Invalid, unsupported, or corrupted files are
// returned as a discriminated error code instead.
console.error(result.error.code, result.error.message);
}parseReplay never throws. Internally detected corruption is caught as a
ReplayCorruptError and converted to { ok: false, error: { code: "corrupt", ... } }.
Supported titles
Titles are identified by the 4-byte magic at the start of the file. th13
(東方神霊廟, TD) and th14 (東方輝針城, DDC) share the same magic t13r, so
they are distinguished by a version byte in the header.
| Game ID | Title | Verification status |
| --- | --- | --- |
| th06 | 東方紅魔郷 (EoSD) | Verified with checked-in replays + in-game screenshots in test-fixtures/ |
| th07 | 東方妖々夢 (PCB) | Same as above |
| th08 | 東方永夜抄 (IN) | Same as above (includes Shift_JIS character names) |
| th09 | 東方花映塚 (PoFV) | Verified with real replays (samples obtained from Silent Selene) |
| th095 | 東方文花帖 (StB) | Same as above |
| th10 | 東方風神録 (MoF) | Verified with real replays + screenshots/samples |
| th11 | 東方地霊殿 (SA) | Verified with test-fixtures/ + screenshots |
| th12 | 東方星蓮船 (UFO) | Verified with Silent Selene samples |
| th125 | ダブルスポイラー (DS) | Verified with checked-in replays in test-fixtures/ |
| th128 | 妖精大戦争 (GFW) | Verified with Silent Selene samples |
| th13 | 東方神霊廟 (TD) | Verified with test-fixtures/ + screenshots |
| th14 | 東方輝針城 (DDC) | Same as above |
| th143 | 弾幕アマノジャク (ISC) | Verified with checked-in replays in test-fixtures/ |
| th15 | 東方紺珠伝 (LoLK) | Verified with test-fixtures/ + screenshots |
| th16 | 東方天空璋 (HSiFS) | Verified with Silent Selene samples |
| th165 | 秘封ナイトメアダイアリー (VD) | Unverified (ported from threplay only; no test data obtained yet) |
| th17 | 東方鬼形獣 (WBaWC) | Verified with Silent Selene samples |
| th18 | 東方虹龍洞 (UM) | Same as above |
| th20 | 東方錦上京 (FW) | Player name/date/difficulty/stage/score verified with test-fixtures/ + screenshots; character verified against 16/16 distinct shot values from Silent Selene samples (see below).Per-stage breakdown (splits) is not supported (see below) |
th19 (東方獣王園, UDoALG) is excluded because the game itself has no replay-saving feature.
Notes on th20 (東方錦上京, FW)
threplay only supports up to th18; th20 is implemented based on this package's own investigation. The USER section (player name, date, difficulty, stage, score) has been confirmed to use the same layout as th10-th18.
character, however, is deliberately not read from that USER section's
"Chara" field, unlike every other title sharing readModernUserdata — real
replays were found where that field contains outright garbage (e.g. "test",
or a difficulty/stage word like "Hard", apparently belonging to a different
field), while the surrounding fields were consistently fine. Instead,
character is derived from the numeric shot/stones fields in the
decompressed per-stage-header body, the same approach
n-rook/thscoreboard's own th20
parser uses (see "Related work" below) — this requires th20's own header size
before that decompression (48 bytes, wider than th10-th18's shared 36-byte
layout, matching thscoreboard's own separate th20 kaitai header definition).
An earlier version of this file used the wrong (36-byte) header size, which
made the length/decompressed-size fields read from the wrong offset and
consistently produced a garbage, constant-size decompression output — at the
time misdiagnosed as "th20 moved the per-stage breakdown data elsewhere"
rather than corrupt input from a wrong header size. splits still returns an
empty array, since thscoreboard's own th20 kaitai struct only defines this
header, not a per-stage layout — meaning that part hasn't been
reverse-engineered upstream either.
Output data
ParsedReplay (result.replay when result.ok === true) carries richer
information than ReplayInfo, the type used by Sattori itself (player name,
date, character, difficulty, stage, score, clear status). In particular,
splits (a per-stage breakdown of score, power, lives, bombs, graze, etc.)
and formatVersion (the raw version/format byte embedded in the header,
whose meaning differs per game and which this package does not attempt to
interpret) are not part of ReplayInfo.
Conversion to ReplayInfo for Sattori itself is handled by fromParsedReplay()
in packages/shared (this package deliberately does not include that
conversion logic, so as to avoid depending on Sattori-specific types).
characterNameJa / characterNameEn
character is otherwise used verbatim as it appears in the source data (a raw
shot-id string like "ReimuA" for most titles, or, for th08, a Japanese
display-name string read directly from the file). characterNameJa /
characterNameEn provide a localized display name for character (e.g.
"ReimuA" → characterNameJa: "霊符" / characterNameEn: "Reimu A" for th06),
looked up via localizeCharacterName() (src/character-names.ts). Both are
null when character is null or doesn't match any known raw form for that
game.
The lookup tables are sourced from
n-rook/thscoreboard (the software
behind Silent Selene, see "Related work"
below) — specifically GetShotName/GetCharacterName in
replays/game_ids.py and its ja/en_US gettext catalogs — cross-checked
against real replays fetched from Silent Selene's API rather than taken on
faith (see character-names.ts for details, including th08's Japanese raw
strings and th17's inconsistent internal spacing, both confirmed genuine this
way rather than assumed).
splits[].lives / splits[].bombs are not strings but a structured
ReplayResourceCount type ({ count, pieces, maxPieces }). For games with a
fragment system (fragments toward the next unit), pieces/maxPieces are
populated; for games without one, they are null (th128 is the sole
exception, where count holds a percentage and maxPieces is always 100 —
see the comments in src/games/th128.ts for details). Likewise,
splits[].additional returns game-specific extra info (UFO color, trance,
season, spell cards, etc.) as an object with typed properties rather than
strings (e.g. { ufoColors: ["Red", "None", "None"] }).
frameCount
frameCount is the total number of in-game frames the replay plays back.
The main-series games run gameplay logic at a fixed 60 frames/sec, so
frameCount / 60 gives the playback duration in seconds — useful for
estimating recording time before actually running the replay. It does not
include any recording-pipeline overhead (menu automation, end-of-replay
detection lag, etc.) layered on top by a consumer such as Sattori's worker.
Currently populated for:
- th06: unlike th07/th08 (see below), th06's replay body is
uncompressed and stores a sparse input-change-event log rather than one
fixed-width record per frame:
ReplayDataInput { frameNum: i32; inputKey: u16; padding: u16 }(8 bytes), one record only when the held key combination actually changes.frameNumresets to ~0 at the start of every stage and the log is terminated by a sentinelframeNumof9999999. This layout was confirmed exactly (not just empirically) viaGensokyoClub/th06's decompilation of the game (src/ReplayData.hpp):ZUN_ASSERT_SIZE(StageReplayData, 0x69780)matches this package's 16-byte header +53998 * 8-byte input array exactly, and the header's known fields (score/power/lives/bombs/rank) line up with the same offsets this package already read before frameCount support was added.frameCountfor each stage is theframeNumof the last real record before that stage's terminator. Cross-validated against two real recorded replays (not checked into this repo, seesrc/games/th06.ts): a single-stage clear and a 6-stage clear both landed within a few percent of their independently known recorded durations. See the comments onSTAGE_INPUT_LOG_HEADER_SIZEinsrc/games/th06.ts. - th07: derived by reverse-engineering the per-checkpoint input log
layout (not documented by threplay/threp, which don't parse this data at
all). Cross-validated against real recorded durations of a checked-in
fixture (
touhou-recorderPoC reports:th7_07.rpyrecorded at ~840-852s end-to-end; the computed frame count lands within that range) — see the comments onSTAGE_CHECKPOINT_HEADER_SIZEinsrc/games/th07.ts. - th08: same reverse-engineering approach as th07 (a fixed-size
per-checkpoint header followed by one fixed-width record per frame), but
independently derived and with different constants (a 2-byte-per-frame
record instead of th07's 4). The header layout was cross-referenced
against
GensokyoClub/th08's decompilation ofStageReplayData(src/ReplayManager.hpp), which also revealed that the field threplay (and this package, until now) labeledTimeinsplits[].additionalis actuallypointItemExteds(a point-item-extend counter) — renamed topointItemExtendshere accordingly. Cross-validated against real recorded durations of two replays not checked into this repo (seesrc/games/th08.ts): a single-segment Extra-stage clear and a short spell-practice replay both landed within a few percent of their independently known recorded durations. See the comments onSTAGE_CHECKPOINT_HEADER_SIZEinsrc/games/th08.ts. - th10-th18 (all titles sharing the
decodeModernBodypipeline: th10, th11, th12, th13, th14, th15, th16, th17, th18): each stage's decompressed header carries an explicit frame-count field, confirmed againstFluorohydride/threp's own C++ implementation (which reads this field to reconstruct input logs for its own purposes) and independently againstyiyuezhuo/touhou-replay-decoder.
null for every other supported title (th09, th095, th125, th128,
th143/th165, th20) — the per-frame input log location for those has not
been reverse-engineered yet.
splits[].frameCount breaks the same total down per stage/segment (frames
played from that checkpoint up to the next one, or to the end of the replay
for the last split) for the same set of titles; ParsedReplay.frameCount
is simply the sum of every splits[].frameCount. It is null per-split
wherever the top-level frameCount is also null.
Credits
Most of the decoding logic was independently written from scratch in
TypeScript, based on ReplayDecoder.cs from
raviddog/threplay. The core LZSS
decompression and XOR block decoding algorithms originate from common.cpp
in Fluorohydride/threp, which that
repository references.
th06 and th08's frameCount support (see above) was additionally
cross-referenced against the reverse-engineered struct layouts in
GensokyoClub/th06 (CC0-1.0) and
GensokyoClub/th08 (MIT), two
community decompilation projects — used here only as factual confirmation
of byte offsets/struct sizes already derived independently, not as a source
of copied code.
Neither repository carries an explicit OSS license
(threplay's LICENCES.txt only lists licenses for third-party dependencies
such as UI components, not for ReplayDecoder.cs itself). This package is
published under the MIT license as an independent implementation built from
factual information (byte offsets, XOR keys, etc.), but please make your own
judgment call about usage with this background in mind.
Related work
Other open-source projects for parsing or reverse-engineering Touhou replay files:
- raviddog/threplay (C#) — the base this package's decoding logic was ported from; see "Credits" above.
- Fluorohydride/threp (C++) — source of the LZSS decompression / XOR block decoding algorithms; see "Credits" above.
- GensokyoClub/th06 and
GensokyoClub/th08 — decompilation
projects used to cross-reference struct layouts for th06/th08
frameCountsupport; see "Credits" above. - yiyuezhuo/touhou-replay-decoder —
used to independently cross-validate the th10-th18 frame-count field (see
the "
frameCount" section above). - hoangcaominh/thrpy-parser (Python) — listed here as prior art; its code has not been consulted or referenced during the development of this package.
- n-rook/thscoreboard
(Python) — web application containing replay parsing implementations in its
project/thscoreboard/replaysdirectory.
License
MIT (see LICENSE; also see the "Credits" section above for background)
