npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sattori/touhou-replay-parser

v0.3.0

Published

Decoder for Touhou Project replay files (.rpy), covering th06 through th20. Zero runtime dependencies.

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-parser

Requires 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. frameNum resets to ~0 at the start of every stage and the log is terminated by a sentinel frameNum of 9999999. This layout was confirmed exactly (not just empirically) via GensokyoClub/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. frameCount for each stage is the frameNum of the last real record before that stage's terminator. Cross-validated against two real recorded replays (not checked into this repo, see src/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 on STAGE_INPUT_LOG_HEADER_SIZE in src/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-recorder PoC reports: th7_07.rpy recorded at ~840-852s end-to-end; the computed frame count lands within that range) — see the comments on STAGE_CHECKPOINT_HEADER_SIZE in src/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 of StageReplayData (src/ReplayManager.hpp), which also revealed that the field threplay (and this package, until now) labeled Time in splits[].additional is actually pointItemExteds (a point-item-extend counter) — renamed to pointItemExtends here accordingly. Cross-validated against real recorded durations of two replays not checked into this repo (see src/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 on STAGE_CHECKPOINT_HEADER_SIZE in src/games/th08.ts.
  • th10-th18 (all titles sharing the decodeModernBody pipeline: th10, th11, th12, th13, th14, th15, th16, th17, th18): each stage's decompressed header carries an explicit frame-count field, confirmed against Fluorohydride/threp's own C++ implementation (which reads this field to reconstruct input logs for its own purposes) and independently against yiyuezhuo/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 frameCount support; 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/replays directory.

License

MIT (see LICENSE; also see the "Credits" section above for background)