@sattori/touhou-replay-parser 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +166 -0
  3. package/dist/byte-reader.d.ts +48 -0
  4. package/dist/byte-reader.d.ts.map +1 -0
  5. package/dist/byte-reader.js +129 -0
  6. package/dist/byte-reader.js.map +1 -0
  7. package/dist/errors.d.ts +10 -0
  8. package/dist/errors.d.ts.map +1 -0
  9. package/dist/errors.js +13 -0
  10. package/dist/errors.js.map +1 -0
  11. package/dist/game-ids.d.ts +13 -0
  12. package/dist/game-ids.d.ts.map +1 -0
  13. package/dist/game-ids.js +56 -0
  14. package/dist/game-ids.js.map +1 -0
  15. package/dist/games/modern-body.d.ts +16 -0
  16. package/dist/games/modern-body.d.ts.map +1 -0
  17. package/dist/games/modern-body.js +21 -0
  18. package/dist/games/modern-body.js.map +1 -0
  19. package/dist/games/th06.d.ts +8 -0
  20. package/dist/games/th06.d.ts.map +1 -0
  21. package/dist/games/th06.js +85 -0
  22. package/dist/games/th06.js.map +1 -0
  23. package/dist/games/th07.d.ts +12 -0
  24. package/dist/games/th07.d.ts.map +1 -0
  25. package/dist/games/th07.js +138 -0
  26. package/dist/games/th07.js.map +1 -0
  27. package/dist/games/th08.d.ts +6 -0
  28. package/dist/games/th08.d.ts.map +1 -0
  29. package/dist/games/th08.js +118 -0
  30. package/dist/games/th08.js.map +1 -0
  31. package/dist/games/th09.d.ts +10 -0
  32. package/dist/games/th09.d.ts.map +1 -0
  33. package/dist/games/th09.js +110 -0
  34. package/dist/games/th09.js.map +1 -0
  35. package/dist/games/th095.d.ts +7 -0
  36. package/dist/games/th095.d.ts.map +1 -0
  37. package/dist/games/th095.js +39 -0
  38. package/dist/games/th095.js.map +1 -0
  39. package/dist/games/th10.d.ts +4 -0
  40. package/dist/games/th10.d.ts.map +1 -0
  41. package/dist/games/th10.js +50 -0
  42. package/dist/games/th10.js.map +1 -0
  43. package/dist/games/th11.d.ts +4 -0
  44. package/dist/games/th11.d.ts.map +1 -0
  45. package/dist/games/th11.js +49 -0
  46. package/dist/games/th11.js.map +1 -0
  47. package/dist/games/th12.d.ts +4 -0
  48. package/dist/games/th12.d.ts.map +1 -0
  49. package/dist/games/th12.js +58 -0
  50. package/dist/games/th12.js.map +1 -0
  51. package/dist/games/th125.d.ts +6 -0
  52. package/dist/games/th125.d.ts.map +1 -0
  53. package/dist/games/th125.js +39 -0
  54. package/dist/games/th125.js.map +1 -0
  55. package/dist/games/th128.d.ts +9 -0
  56. package/dist/games/th128.d.ts.map +1 -0
  57. package/dist/games/th128.js +79 -0
  58. package/dist/games/th128.js.map +1 -0
  59. package/dist/games/th13-14.d.ts +17 -0
  60. package/dist/games/th13-14.d.ts.map +1 -0
  61. package/dist/games/th13-14.js +110 -0
  62. package/dist/games/th13-14.js.map +1 -0
  63. package/dist/games/th143.d.ts +7 -0
  64. package/dist/games/th143.d.ts.map +1 -0
  65. package/dist/games/th143.js +39 -0
  66. package/dist/games/th143.js.map +1 -0
  67. package/dist/games/th15.d.ts +4 -0
  68. package/dist/games/th15.d.ts.map +1 -0
  69. package/dist/games/th15.js +49 -0
  70. package/dist/games/th15.js.map +1 -0
  71. package/dist/games/th16.d.ts +4 -0
  72. package/dist/games/th16.d.ts.map +1 -0
  73. package/dist/games/th16.js +51 -0
  74. package/dist/games/th16.js.map +1 -0
  75. package/dist/games/th17.d.ts +4 -0
  76. package/dist/games/th17.d.ts.map +1 -0
  77. package/dist/games/th17.js +49 -0
  78. package/dist/games/th17.js.map +1 -0
  79. package/dist/games/th18.d.ts +4 -0
  80. package/dist/games/th18.d.ts.map +1 -0
  81. package/dist/games/th18.js +58 -0
  82. package/dist/games/th18.js.map +1 -0
  83. package/dist/games/th20.d.ts +22 -0
  84. package/dist/games/th20.d.ts.map +1 -0
  85. package/dist/games/th20.js +41 -0
  86. package/dist/games/th20.js.map +1 -0
  87. package/dist/index.d.ts +6 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +70 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/lzss.d.ts +16 -0
  92. package/dist/lzss.d.ts.map +1 -0
  93. package/dist/lzss.js +147 -0
  94. package/dist/lzss.js.map +1 -0
  95. package/dist/types.d.ts +125 -0
  96. package/dist/types.d.ts.map +1 -0
  97. package/dist/types.js +22 -0
  98. package/dist/types.js.map +1 -0
  99. package/dist/userdata.d.ts +24 -0
  100. package/dist/userdata.d.ts.map +1 -0
  101. package/dist/userdata.js +52 -0
  102. package/dist/userdata.js.map +1 -0
  103. package/package.json +41 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Koki Takahashi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,166 @@
1
+ # @sattori/touhou-replay-parser
2
+
3
+ A zero-dependency TypeScript library for decoding Touhou Project main-series
4
+ replay files (`.rpy`). Written for
5
+ [Sattori](https://github.com/hakatashi/sattori) (a Touhou replay recording web
6
+ service), but designed with no dependency on Sattori-specific types, so it can
7
+ be used standalone.
8
+
9
+ Based on a TypeScript port of `ReplayDecoder.cs` (the C# implementation) from
10
+ [raviddog/threplay](https://github.com/raviddog/threplay), with additions for
11
+ correct decoding of Shift_JIS player names, safe error handling for corrupted
12
+ files, and support for titles that threplay did not cover.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @sattori/touhou-replay-parser
18
+ ```
19
+
20
+ Requires Node.js >= 14 (or a modern browser). Player names and dates are
21
+ decoded as Shift_JIS via the global `TextDecoder`, which needs a full-ICU
22
+ build — the default for official Node.js binaries since Node.js 13. On a
23
+ runtime without Shift_JIS support, decoding silently falls back to Latin1
24
+ (mojibake for Japanese text, not an error) rather than failing outright.
25
+
26
+ ## Usage
27
+
28
+ ```ts
29
+ import { parseReplay } from "@sattori/touhou-replay-parser";
30
+ import { readFile } from "node:fs/promises";
31
+
32
+ const data = new Uint8Array(await readFile("th7_01.rpy"));
33
+ const result = parseReplay(data);
34
+
35
+ if (result.ok) {
36
+ const { game, player, character, difficulty, score, cleared, splits } = result.replay;
37
+ console.log(`${game}: ${player} / ${character} / ${difficulty} / ${score}`);
38
+ } else {
39
+ // parseReplay never throws. Invalid, unsupported, or corrupted files are
40
+ // returned as a discriminated error code instead.
41
+ console.error(result.error.code, result.error.message);
42
+ }
43
+ ```
44
+
45
+ `parseReplay` never throws. Internally detected corruption is caught as a
46
+ `ReplayCorruptError` and converted to `{ ok: false, error: { code: "corrupt", ... } }`.
47
+
48
+ ## Supported titles
49
+
50
+ Titles are identified by the 4-byte magic at the start of the file. th13
51
+ (東方神霊廟, TD) and th14 (東方輝針城, DDC) share the same magic `t13r`, so
52
+ they are distinguished by a version byte in the header.
53
+
54
+ | Game ID | Title | Verification status |
55
+ | --- | --- | --- |
56
+ | `th06` | 東方紅魔郷 (EoSD) | Verified with checked-in replays + in-game screenshots in `test-fixtures/` |
57
+ | `th07` | 東方妖々夢 (PCB) | Same as above |
58
+ | `th08` | 東方永夜抄 (IN) | Same as above (includes Shift_JIS character names) |
59
+ | `th09` | 東方花映塚 (PoFV) | Verified with real replays (samples obtained from [Silent Selene](https://www.silentselene.net/)) |
60
+ | `th095` | 東方文花帖 (StB) | Same as above |
61
+ | `th10` | 東方風神録 (MoF) | Verified with real replays + screenshots/samples |
62
+ | `th11` | 東方地霊殿 (SA) | Verified with `test-fixtures/` + screenshots |
63
+ | `th12` | 東方星蓮船 (UFO) | Verified with Silent Selene samples |
64
+ | `th125` | ダブルスポイラー (DS) | Verified with checked-in replays in `test-fixtures/` |
65
+ | `th128` | 妖精大戦争 (GFW) | Verified with Silent Selene samples |
66
+ | `th13` | 東方神霊廟 (TD) | Verified with `test-fixtures/` + screenshots |
67
+ | `th14` | 東方輝針城 (DDC) | Same as above |
68
+ | `th143` | 弾幕アマノジャク (ISC) | Verified with checked-in replays in `test-fixtures/` |
69
+ | `th15` | 東方紺珠伝 (LoLK) | Verified with `test-fixtures/` + screenshots |
70
+ | `th16` | 東方天空璋 (HSiFS) | Verified with Silent Selene samples |
71
+ | `th165` | 秘封ナイトメアダイアリー (VD) | **Unverified** (ported from threplay only; no test data obtained yet) |
72
+ | `th17` | 東方鬼形獣 (WBaWC) | Verified with Silent Selene samples |
73
+ | `th18` | 東方虹龍洞 (UM) | Same as above |
74
+ | `th20` | 東方錦上京 (FW) | Player name/date/character/difficulty/stage/score verified with `test-fixtures/` + screenshots.<br>**Per-stage breakdown (splits) is not supported** (see below) |
75
+
76
+ th19 (東方獣王園, UDoALG) is excluded because the game itself has no
77
+ replay-saving feature.
78
+
79
+ ### Notes on th20 (東方錦上京, FW)
80
+
81
+ threplay only supports up to th18; th20 is implemented based on this
82
+ package's own investigation. The USER section (player name, date, character,
83
+ difficulty, stage, score) has been confirmed to use the same layout as
84
+ th10-th18, but the "per-stage breakdown via header XOR decoding + LZSS
85
+ decompression" present in th10-th18 appears, on the samples at hand, to
86
+ always decompress to a constant size regardless of progress — suggesting the
87
+ format has likely changed. Since this has not been analyzed, `splits` always
88
+ returns an empty array.
89
+
90
+ ## Output data
91
+
92
+ `ParsedReplay` (`result.replay` when `result.ok === true`) carries richer
93
+ information than `ReplayInfo`, the type used by Sattori itself (player name,
94
+ date, character, difficulty, stage, score, clear status). In particular,
95
+ `splits` (a per-stage breakdown of score, power, lives, bombs, graze, etc.)
96
+ and `formatVersion` (the raw version/format byte embedded in the header,
97
+ whose meaning differs per game and which this package does not attempt to
98
+ interpret) are not part of `ReplayInfo`.
99
+
100
+ Conversion to `ReplayInfo` for Sattori itself is handled by `fromParsedReplay()`
101
+ in `packages/shared` (this package deliberately does not include that
102
+ conversion logic, so as to avoid depending on Sattori-specific types).
103
+
104
+ `splits[].lives` / `splits[].bombs` are not strings but a structured
105
+ `ReplayResourceCount` type (`{ count, pieces, maxPieces }`). For games with a
106
+ fragment system (fragments toward the next unit), `pieces`/`maxPieces` are
107
+ populated; for games without one, they are `null` (th128 is the sole
108
+ exception, where `count` holds a percentage and `maxPieces` is always 100 —
109
+ see the comments in `src/games/th128.ts` for details). Likewise,
110
+ `splits[].additional` returns game-specific extra info (UFO color, trance,
111
+ season, spell cards, etc.) as an object with typed properties rather than
112
+ strings (e.g. `{ ufoColors: ["Red", "None", "None"] }`).
113
+
114
+ ### `frameCount`
115
+
116
+ `frameCount` is the total number of in-game frames the replay plays back.
117
+ The main-series games run gameplay logic at a fixed 60 frames/sec, so
118
+ `frameCount / 60` gives the playback duration in seconds — useful for
119
+ estimating recording time before actually running the replay. It does not
120
+ include any recording-pipeline overhead (menu automation, end-of-replay
121
+ detection lag, etc.) layered on top by a consumer such as Sattori's worker.
122
+
123
+ Currently populated for:
124
+
125
+ - **th07**: derived by reverse-engineering the per-checkpoint input log
126
+ layout (not documented by threplay/threp, which don't parse this data at
127
+ all). Cross-validated against real recorded durations of a checked-in
128
+ fixture (`touhou-recorder` PoC reports: `th7_07.rpy` recorded at ~840-852s
129
+ end-to-end; the computed frame count lands within that range) — see the
130
+ comments on `STAGE_CHECKPOINT_HEADER_SIZE` in `src/games/th07.ts`.
131
+ - **th10-th18** (all titles sharing the `decodeModernBody` pipeline: th10,
132
+ th11, th12, th13, th14, th15, th16, th17, th18): each stage's decompressed
133
+ header carries an explicit frame-count field, confirmed against
134
+ `Fluorohydride/threp`'s own C++ implementation (which reads this field to
135
+ reconstruct input logs for its own purposes) and independently against
136
+ `yiyuezhuo/touhou-replay-decoder`.
137
+
138
+ `null` for every other supported title (th06, th08, th09, th095, th125,
139
+ th128, th143/th165, th20) — the per-frame input log location for those has
140
+ not been reverse-engineered yet.
141
+
142
+ `splits[].frameCount` breaks the same total down per stage/segment (frames
143
+ played from that checkpoint up to the next one, or to the end of the replay
144
+ for the last split) for the same set of titles; `ParsedReplay.frameCount`
145
+ is simply the sum of every `splits[].frameCount`. It is `null` per-split
146
+ wherever the top-level `frameCount` is also `null`.
147
+
148
+ ## Credits
149
+
150
+ Most of the decoding logic was independently written from scratch in
151
+ TypeScript, based on `ReplayDecoder.cs` from
152
+ [raviddog/threplay](https://github.com/raviddog/threplay). The core LZSS
153
+ decompression and XOR block decoding algorithms originate from `common.cpp`
154
+ in [Fluorohydride/threp](https://github.com/Fluorohydride/threp), which that
155
+ repository references.
156
+
157
+ Neither repository carries an explicit OSS license
158
+ (threplay's `LICENCES.txt` only lists licenses for third-party dependencies
159
+ such as UI components, not for `ReplayDecoder.cs` itself). This package is
160
+ published under the MIT license as an independent implementation built from
161
+ factual information (byte offsets, XOR keys, etc.), but please make your own
162
+ judgment call about usage with this background in mind.
163
+
164
+ ## License
165
+
166
+ MIT (see [LICENSE](./LICENSE); also see the "Credits" section above for background)
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A low-level cursor for reading through .rpy binary data.
3
+ * Out-of-range access does not throw a generic exception but a
4
+ * ReplayCorruptError (caught at the parseReplay() boundary and
5
+ * converted to a discriminated error value).
6
+ */
7
+ export declare class ByteReader {
8
+ private readonly bytes;
9
+ private readonly view;
10
+ pos: number;
11
+ constructor(data: Uint8Array);
12
+ get length(): number;
13
+ private ensure;
14
+ readUint8(): number;
15
+ readUint16LE(): number;
16
+ readUint32LE(): number;
17
+ readFloat32LE(): number;
18
+ readBytes(n: number): Uint8Array;
19
+ /** Jumps to an absolute position. */
20
+ seek(offset: number): void;
21
+ /** Moves relative to the current position. */
22
+ skip(delta: number): void;
23
+ slice(start: number, end: number): Uint8Array;
24
+ remaining(): Uint8Array;
25
+ all(): Uint8Array;
26
+ /**
27
+ * Equivalent to ReadStringANSI in raviddog/threplay. Reads until a
28
+ * CR(0x0D)+LF(0x0A) sequence is found as the string terminator, leaving the
29
+ * cursor just before the terminator (right before the 0x0A), so that
30
+ * subsequent fields' `skip()` offsets match the original implementation
31
+ * (this behavior is faithfully reproduced byte-for-byte).
32
+ *
33
+ * Returns the raw byte sequence. Character encoding conversion is done by
34
+ * the caller (decodeAnsiText).
35
+ */
36
+ readAnsiBytes(): Uint8Array;
37
+ readAnsiString(): string;
38
+ }
39
+ /**
40
+ * Decodes ANSI/Shift_JIS strings. The Japanese version of Touhou replays
41
+ * often stores player names and dates as Shift_JIS byte sequences.
42
+ * raviddog/threplay's ReadStringANSI has a known limitation of turning each
43
+ * byte directly into a char (equivalent to Latin1), which mangles Japanese
44
+ * text (see the ReadStringWide comment in that same implementation). Here we
45
+ * try Shift_JIS first and only fall back to Latin1 if that fails.
46
+ */
47
+ export declare function decodeAnsiText(bytes: Uint8Array): string;
48
+ //# sourceMappingURL=byte-reader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"byte-reader.d.ts","sourceRoot":"","sources":["../src/byte-reader.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAa;IACnC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAW;IAChC,GAAG,SAAK;IAER,YAAY,IAAI,EAAE,UAAU,EAG3B;IAED,IAAI,MAAM,IAAI,MAAM,CAEnB;IAED,OAAO,CAAC,MAAM;IAQd,SAAS,IAAI,MAAM,CAKlB;IAED,YAAY,IAAI,MAAM,CAKrB;IAED,YAAY,IAAI,MAAM,CAKrB;IAED,aAAa,IAAI,MAAM,CAKtB;IAED,SAAS,CAAC,CAAC,EAAE,MAAM,GAAG,UAAU,CAK/B;IAED,qCAAqC;IACrC,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAKzB;IAED,8CAA8C;IAC9C,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAExB;IAED,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,UAAU,CAK5C;IAED,SAAS,IAAI,UAAU,CAEtB;IAED,GAAG,IAAI,UAAU,CAEhB;IAED;;;;;;;;;OASG;IACH,aAAa,IAAI,UAAU,CAgB1B;IAED,cAAc,IAAI,MAAM,CAEvB;CACF;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAUxD"}
@@ -0,0 +1,129 @@
1
+ import { ReplayCorruptError } from "./errors.js";
2
+ /**
3
+ * A low-level cursor for reading through .rpy binary data.
4
+ * Out-of-range access does not throw a generic exception but a
5
+ * ReplayCorruptError (caught at the parseReplay() boundary and
6
+ * converted to a discriminated error value).
7
+ */
8
+ export class ByteReader {
9
+ bytes;
10
+ view;
11
+ pos = 0;
12
+ constructor(data) {
13
+ this.bytes = data;
14
+ this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);
15
+ }
16
+ get length() {
17
+ return this.bytes.length;
18
+ }
19
+ ensure(n) {
20
+ if (this.pos < 0 || this.pos + n > this.bytes.length) {
21
+ throw new ReplayCorruptError(`unexpected end of file at offset ${this.pos} (need ${n} more byte(s), have ${this.bytes.length})`);
22
+ }
23
+ }
24
+ readUint8() {
25
+ this.ensure(1);
26
+ const value = this.bytes[this.pos];
27
+ this.pos += 1;
28
+ return value;
29
+ }
30
+ readUint16LE() {
31
+ this.ensure(2);
32
+ const value = this.view.getUint16(this.pos, true);
33
+ this.pos += 2;
34
+ return value;
35
+ }
36
+ readUint32LE() {
37
+ this.ensure(4);
38
+ const value = this.view.getUint32(this.pos, true);
39
+ this.pos += 4;
40
+ return value;
41
+ }
42
+ readFloat32LE() {
43
+ this.ensure(4);
44
+ const value = this.view.getFloat32(this.pos, true);
45
+ this.pos += 4;
46
+ return value;
47
+ }
48
+ readBytes(n) {
49
+ this.ensure(n);
50
+ const value = this.bytes.subarray(this.pos, this.pos + n);
51
+ this.pos += n;
52
+ return value;
53
+ }
54
+ /** Jumps to an absolute position. */
55
+ seek(offset) {
56
+ if (offset < 0 || offset > this.bytes.length) {
57
+ throw new ReplayCorruptError(`seek out of range: ${offset} (length ${this.bytes.length})`);
58
+ }
59
+ this.pos = offset;
60
+ }
61
+ /** Moves relative to the current position. */
62
+ skip(delta) {
63
+ this.seek(this.pos + delta);
64
+ }
65
+ slice(start, end) {
66
+ if (start < 0 || end > this.bytes.length || start > end) {
67
+ throw new ReplayCorruptError(`slice out of range: [${start}, ${end}) (length ${this.bytes.length})`);
68
+ }
69
+ return this.bytes.subarray(start, end);
70
+ }
71
+ remaining() {
72
+ return this.bytes.subarray(this.pos);
73
+ }
74
+ all() {
75
+ return this.bytes;
76
+ }
77
+ /**
78
+ * Equivalent to ReadStringANSI in raviddog/threplay. Reads until a
79
+ * CR(0x0D)+LF(0x0A) sequence is found as the string terminator, leaving the
80
+ * cursor just before the terminator (right before the 0x0A), so that
81
+ * subsequent fields' `skip()` offsets match the original implementation
82
+ * (this behavior is faithfully reproduced byte-for-byte).
83
+ *
84
+ * Returns the raw byte sequence. Character encoding conversion is done by
85
+ * the caller (decodeAnsiText).
86
+ */
87
+ readAnsiBytes() {
88
+ const out = [];
89
+ let b0 = this.readUint8();
90
+ let b1 = this.readUint8();
91
+ if (!(b0 === 13 && b1 === 10)) {
92
+ let b2 = this.readUint8();
93
+ do {
94
+ out.push(b0);
95
+ b0 = b1;
96
+ b1 = b2;
97
+ b2 = this.readUint8();
98
+ } while (b0 !== 13 && b1 !== 10);
99
+ }
100
+ // Equivalent to `file.Seek(-1, SeekOrigin.Current)` in the original implementation.
101
+ this.pos -= 1;
102
+ return Uint8Array.from(out);
103
+ }
104
+ readAnsiString() {
105
+ return decodeAnsiText(this.readAnsiBytes());
106
+ }
107
+ }
108
+ /**
109
+ * Decodes ANSI/Shift_JIS strings. The Japanese version of Touhou replays
110
+ * often stores player names and dates as Shift_JIS byte sequences.
111
+ * raviddog/threplay's ReadStringANSI has a known limitation of turning each
112
+ * byte directly into a char (equivalent to Latin1), which mangles Japanese
113
+ * text (see the ReadStringWide comment in that same implementation). Here we
114
+ * try Shift_JIS first and only fall back to Latin1 if that fails.
115
+ */
116
+ export function decodeAnsiText(bytes) {
117
+ if (bytes.length === 0)
118
+ return "";
119
+ try {
120
+ const decoded = new TextDecoder("shift_jis", { fatal: true }).decode(bytes);
121
+ return decoded;
122
+ }
123
+ catch {
124
+ // If invalid as Shift_JIS, or the runtime lacks shift_jis support,
125
+ // treat it as Latin1 (1 byte = 1 char), same fallback as the original implementation.
126
+ return Array.from(bytes, (b) => String.fromCharCode(b)).join("");
127
+ }
128
+ }
129
+ //# sourceMappingURL=byte-reader.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"byte-reader.js","sourceRoot":"","sources":["../src/byte-reader.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * An unrecoverable problem encountered while reading a replay (corrupted file,
3
+ * invalid offset, etc.). Thrown only inside decoders and always caught at the
4
+ * parseReplay() boundary, where it is converted to a ReplayParseError. This
5
+ * exception never escapes to callers.
6
+ */
7
+ export declare class ReplayCorruptError extends Error {
8
+ constructor(message: string);
9
+ }
10
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF"}
package/dist/errors.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * An unrecoverable problem encountered while reading a replay (corrupted file,
3
+ * invalid offset, etc.). Thrown only inside decoders and always caught at the
4
+ * parseReplay() boundary, where it is converted to a ReplayParseError. This
5
+ * exception never escapes to callers.
6
+ */
7
+ export class ReplayCorruptError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = "ReplayCorruptError";
11
+ }
12
+ }
13
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAC3C,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;IACnC,CAAC;CACF"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Title identifiers for Touhou Project main games recognized by this package.
3
+ * Independent of `GameId` in `packages/shared` (this package aims to be
4
+ * publishable standalone as OSS, so it does not depend on Sattori-specific types).
5
+ *
6
+ * th13 (東方神霊廟, TD) and th14 (東方輝針城, DDC) share the same
7
+ * magic bytes `t13r` and are distinguished by a version byte in the header
8
+ * (inherited from threplay; see the in-code comments).
9
+ */
10
+ export declare const REPLAY_GAME_IDS: readonly ["th06", "th07", "th08", "th09", "th095", "th10", "th11", "th12", "th125", "th128", "th13", "th14", "th143", "th15", "th16", "th165", "th17", "th18", "th20"];
11
+ export type ReplayGameId = (typeof REPLAY_GAME_IDS)[number];
12
+ export declare const REPLAY_GAME_TITLES: Record<ReplayGameId, string>;
13
+ //# sourceMappingURL=game-ids.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"game-ids.d.ts","sourceRoot":"","sources":["../src/game-ids.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,YAC1B,MAAM,EACN,MAAM,EACN,MAAM,EACN,MAAM,EACN,OAAO,EACP,MAAM,EACN,MAAM,EACN,MAAM,EACN,OAAO,EACP,OAAO,EACP,MAAM,EACN,MAAM,EACN,OAAO,EACP,MAAM,EACN,MAAM,EACN,OAAO,EACP,MAAM,EACN,MAAM,EAGN,MAAM,CACE,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAE5D,eAAO,MAAM,kBAAkB,EAAE,MAAM,CAAC,YAAY,EAAE,MAAM,CAsB3D,CAAC"}
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Title identifiers for Touhou Project main games recognized by this package.
3
+ * Independent of `GameId` in `packages/shared` (this package aims to be
4
+ * publishable standalone as OSS, so it does not depend on Sattori-specific types).
5
+ *
6
+ * th13 (東方神霊廟, TD) and th14 (東方輝針城, DDC) share the same
7
+ * magic bytes `t13r` and are distinguished by a version byte in the header
8
+ * (inherited from threplay; see the in-code comments).
9
+ */
10
+ export const REPLAY_GAME_IDS = [
11
+ "th06",
12
+ "th07",
13
+ "th08",
14
+ "th09",
15
+ "th095",
16
+ "th10",
17
+ "th11",
18
+ "th12",
19
+ "th125",
20
+ "th128",
21
+ "th13",
22
+ "th14",
23
+ "th143",
24
+ "th15",
25
+ "th16",
26
+ "th165",
27
+ "th17",
28
+ "th18",
29
+ // th19 (東方獣王園, UDoALG) is excluded because it has no replay-saving feature
30
+ // "th19",
31
+ "th20",
32
+ ];
33
+ export const REPLAY_GAME_TITLES = {
34
+ th06: "東方紅魔郷 ~ the Embodiment of Scarlet Devil.",
35
+ th07: "東方妖々夢 ~ Perfect Cherry Blossom.",
36
+ th08: "東方永夜抄 ~ Imperishable Night.",
37
+ th09: "東方花映塚 ~ Phantasmagoria of Flower View.",
38
+ th095: "東方文花帖 ~ Shoot the Bullet.",
39
+ th10: "東方風神録 ~ Mountain of Faith.",
40
+ th11: "東方地霊殿 ~ Subterranean Animism.",
41
+ th12: "東方星蓮船 ~ Undefined Fantastic Object.",
42
+ th125: "ダブルスポイラー ~ 東方文花帖",
43
+ th128: "妖精大戦争 ~ 東方三月精",
44
+ th13: "東方神霊廟 ~ Ten Desires.",
45
+ th14: "東方輝針城 ~ Double Dealing Character.",
46
+ th143: "弾幕アマノジャク ~ Impossible Spell Card.",
47
+ th15: "東方紺珠伝 ~ Legacy of Lunatic Kingdom.",
48
+ th16: "東方天空璋 ~ Hidden Star in Four Seasons.",
49
+ th165: "秘封ナイトメアダイアリー ~ Violet Detector.",
50
+ th17: "東方鬼形獣 ~ Wily Beast and Weakest Creature.",
51
+ th18: "東方虹龍洞 ~ Unconnected Marketeers.",
52
+ // th19 (東方獣王園, UDoALG) is excluded because it has no replay-saving feature
53
+ // th19: "東方獣王園 〜 Unfinished Dream of All Living Ghost.",
54
+ th20: "東方錦上京 ~ Fossilized Wonders.",
55
+ };
56
+ //# sourceMappingURL=game-ids.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"game-ids.js","sourceRoot":"","sources":["../src/game-ids.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,MAAM;IACN,MAAM;IACN,MAAM;IACN,MAAM;IACN,OAAO;IACP,MAAM;IACN,MAAM;IACN,MAAM;IACN,OAAO;IACP,OAAO;IACP,MAAM;IACN,MAAM;IACN,OAAO;IACP,MAAM;IACN,MAAM;IACN,OAAO;IACP,MAAM;IACN,MAAM;IACN,2EAA2E;IAC3E,UAAU;IACV,MAAM;CACE,CAAC;AAIX,MAAM,CAAC,MAAM,kBAAkB,GAAiC;IAC9D,IAAI,EAAE,0CAA0C;IAChD,IAAI,EAAE,iCAAiC;IACvC,IAAI,EAAE,6BAA6B;IACnC,IAAI,EAAE,wCAAwC;IAC9C,KAAK,EAAE,2BAA2B;IAClC,IAAI,EAAE,4BAA4B;IAClC,IAAI,EAAE,+BAA+B;IACrC,IAAI,EAAE,qCAAqC;IAC3C,KAAK,EAAE,kBAAkB;IACzB,KAAK,EAAE,eAAe;IACtB,IAAI,EAAE,sBAAsB;IAC5B,IAAI,EAAE,mCAAmC;IACzC,KAAK,EAAE,mCAAmC;IAC1C,IAAI,EAAE,oCAAoC;IAC1C,IAAI,EAAE,sCAAsC;IAC5C,KAAK,EAAE,iCAAiC;IACxC,IAAI,EAAE,0CAA0C;IAChD,IAAI,EAAE,iCAAiC;IACvC,2EAA2E;IAC3E,yDAAyD;IACzD,IAAI,EAAE,6BAA6B;CACpC,CAAC"}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The "body" decoding pipeline shared from th10 (東方風神録, MoF) onward.
3
+ * After stripping the 36-byte header, this runs two passes of XOR block
4
+ * decoding followed by LZSS decompression (consolidating the processing
5
+ * common to the start of each Read_tNNr in threplay).
6
+ */
7
+ export declare function decodeModernBody(original: Uint8Array, pass1: {
8
+ blockSize: number;
9
+ base: number;
10
+ add: number;
11
+ }, pass2: {
12
+ blockSize: number;
13
+ base: number;
14
+ add: number;
15
+ }): Uint8Array;
16
+ //# sourceMappingURL=modern-body.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"modern-body.d.ts","sourceRoot":"","sources":["../../src/games/modern-body.ts"],"names":[],"mappings":"AAKA;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,UAAU,EACpB,KAAK,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,EACvD,KAAK,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACtD,UAAU,CAUZ"}
@@ -0,0 +1,21 @@
1
+ import { ReplayCorruptError } from "../errors.js";
2
+ import { decompress, readBufferedUint32LE, xorBlockDecode } from "../lzss.js";
3
+ const MODERN_HEADER_SIZE = 36;
4
+ /**
5
+ * The "body" decoding pipeline shared from th10 (東方風神録, MoF) onward.
6
+ * After stripping the 36-byte header, this runs two passes of XOR block
7
+ * decoding followed by LZSS decompression (consolidating the processing
8
+ * common to the start of each Read_tNNr in threplay).
9
+ */
10
+ export function decodeModernBody(original, pass1, pass2) {
11
+ if (original.length < MODERN_HEADER_SIZE + 4 + 4) {
12
+ throw new ReplayCorruptError("file too short for modern-era header");
13
+ }
14
+ const length = readBufferedUint32LE(original, 28);
15
+ const dlength = readBufferedUint32LE(original, 32);
16
+ const workBuffer = original.slice(MODERN_HEADER_SIZE);
17
+ xorBlockDecode(workBuffer, length, pass1.blockSize, pass1.base, pass1.add);
18
+ xorBlockDecode(workBuffer, length, pass2.blockSize, pass2.base, pass2.add);
19
+ return decompress(workBuffer, length, dlength);
20
+ }
21
+ //# sourceMappingURL=modern-body.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"modern-body.js","sourceRoot":"","sources":["../../src/games/modern-body.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,EAAE,UAAU,EAAE,oBAAoB,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAE9E,MAAM,kBAAkB,GAAG,EAAE,CAAC;AAE9B;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB,CAC9B,QAAoB,EACpB,KAAuD,EACvD,KAAuD;IAEvD,IAAI,QAAQ,CAAC,MAAM,GAAG,kBAAkB,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;QACjD,MAAM,IAAI,kBAAkB,CAAC,sCAAsC,CAAC,CAAC;IACvE,CAAC;IACD,MAAM,MAAM,GAAG,oBAAoB,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;IAClD,MAAM,OAAO,GAAG,oBAAoB,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;IACnD,MAAM,UAAU,GAAG,QAAQ,CAAC,KAAK,CAAC,kBAAkB,CAAC,CAAC;IACtD,cAAc,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,CAAC,SAAS,EAAE,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;IAC3E,cAAc,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,CAAC,SAAS,EAAE,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;IAC3E,OAAO,UAAU,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC;AACjD,CAAC"}
@@ -0,0 +1,8 @@
1
+ import { type ParsedReplay } from "../types.js";
2
+ /**
3
+ * T6RP (東方紅魔郷, EoSD) decoder. Ported from Read_T6RP in threplay.
4
+ * The header is decoded with a simple additive-key scheme (subtracting a key
5
+ * that is updated by +7 each step).
6
+ */
7
+ export declare function parseTh06(original: Uint8Array): ParsedReplay;
8
+ //# sourceMappingURL=th06.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"th06.d.ts","sourceRoot":"","sources":["../../src/games/th06.ts"],"names":[],"mappings":"AAGA,OAAO,EAA4C,KAAK,YAAY,EAAyB,MAAM,aAAa,CAAC;AAkBjH;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,QAAQ,EAAE,UAAU,GAAG,YAAY,CAqD5D"}
@@ -0,0 +1,85 @@
1
+ import { decodeAnsiText } from "../byte-reader.js";
2
+ import { ReplayCorruptError } from "../errors.js";
3
+ import { additiveKeyDecode, readBufferedUint32LE } from "../lzss.js";
4
+ import { emptySplit, normalizeText, resourceCount } from "../types.js";
5
+ import { REPLAY_GAME_TITLES } from "../game-ids.js";
6
+ const CHARACTERS = ["ReimuA", "ReimuB", "MarisaA", "MarisaB"];
7
+ const DIFFICULTIES = ["Easy", "Normal", "Hard", "Lunatic", "Extra"];
8
+ function readNullTerminatedAnsi(buffer, start) {
9
+ let end = start;
10
+ while (true) {
11
+ if (end >= buffer.length) {
12
+ throw new ReplayCorruptError(`unterminated string starting at ${start}`);
13
+ }
14
+ if (buffer[end] === 0x00)
15
+ break;
16
+ end++;
17
+ }
18
+ return { text: decodeAnsiText(buffer.subarray(start, end)), end };
19
+ }
20
+ /**
21
+ * T6RP (東方紅魔郷, EoSD) decoder. Ported from Read_T6RP in threplay.
22
+ * The header is decoded with a simple additive-key scheme (subtracting a key
23
+ * that is updated by +7 each step).
24
+ */
25
+ export function parseTh06(original) {
26
+ if (original.length < 0x38) {
27
+ throw new ReplayCorruptError("file too short for T6RP header");
28
+ }
29
+ const buffer = original.slice();
30
+ const characterIndex = buffer[0x06];
31
+ const difficultyIndex = buffer[0x07];
32
+ const character = CHARACTERS[characterIndex] ?? null;
33
+ const difficulty = DIFFICULTIES[difficultyIndex] ?? null;
34
+ additiveKeyDecode(buffer, 0x0f, buffer[0x0e], 7);
35
+ const { text: date, end: afterDate } = readNullTerminatedAnsi(buffer, 0x10);
36
+ const { text: name } = readNullTerminatedAnsi(buffer, Math.max(afterDate + 1, 0x19));
37
+ const score = readBufferedUint32LE(buffer, 0x24);
38
+ const scoreOffsets = [];
39
+ let maxStage = 0;
40
+ for (let i = 0; i < 7; i++) {
41
+ const offset = readBufferedUint32LE(buffer, 0x34 + 4 * i);
42
+ scoreOffsets.push(offset);
43
+ if (offset !== 0)
44
+ maxStage = i;
45
+ }
46
+ const splits = [];
47
+ if (maxStage === 6) {
48
+ const offset = scoreOffsets[6];
49
+ const split = readStageSplit(buffer, offset, 7);
50
+ splits.push(split);
51
+ }
52
+ else {
53
+ for (let i = 0; i <= maxStage; i++) {
54
+ const offset = scoreOffsets[i];
55
+ if (offset === 0)
56
+ continue;
57
+ splits.push(readStageSplit(buffer, offset, i + 1));
58
+ }
59
+ }
60
+ return {
61
+ game: "th06",
62
+ gameTitle: REPLAY_GAME_TITLES.th06,
63
+ formatVersion: null,
64
+ player: normalizeText(name),
65
+ date: normalizeText(date),
66
+ character,
67
+ difficulty,
68
+ stage: null,
69
+ score,
70
+ cleared: maxStage === 6,
71
+ splits,
72
+ frameCount: null,
73
+ };
74
+ }
75
+ function readStageSplit(buffer, offset, stage) {
76
+ const split = emptySplit();
77
+ split.stage = stage;
78
+ split.score = readBufferedUint32LE(buffer, offset);
79
+ split.power = String(buffer[offset + 0x8] ?? 0);
80
+ split.lives = resourceCount(buffer[offset + 0x9] ?? 0);
81
+ split.bombs = resourceCount(buffer[offset + 0xa] ?? 0);
82
+ split.additional = { rank: buffer[offset + 0xb] ?? 0 };
83
+ return split;
84
+ }
85
+ //# sourceMappingURL=th06.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"th06.js","sourceRoot":"","sources":["../../src/games/th06.ts"],"names":[],"mappings":"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"}