gdformat 0.1.0__tar.gz

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 (45) hide show
  1. gdformat-0.1.0/LICENSE +21 -0
  2. gdformat-0.1.0/PKG-INFO +62 -0
  3. gdformat-0.1.0/README.md +37 -0
  4. gdformat-0.1.0/gdformat/__init__.py +12 -0
  5. gdformat-0.1.0/gdformat/_common.py +24 -0
  6. gdformat-0.1.0/gdformat/_wire.py +190 -0
  7. gdformat-0.1.0/gdformat/codes.py +59 -0
  8. gdformat-0.1.0/gdformat/crypto.py +296 -0
  9. gdformat-0.1.0/gdformat/encoding.py +122 -0
  10. gdformat-0.1.0/gdformat/enums.py +336 -0
  11. gdformat-0.1.0/gdformat/objects/__init__.py +78 -0
  12. gdformat-0.1.0/gdformat/objects/_common.py +58 -0
  13. gdformat-0.1.0/gdformat/objects/comment.py +145 -0
  14. gdformat-0.1.0/gdformat/objects/level.py +263 -0
  15. gdformat-0.1.0/gdformat/objects/level_list.py +77 -0
  16. gdformat-0.1.0/gdformat/objects/pack.py +86 -0
  17. gdformat-0.1.0/gdformat/objects/reward.py +106 -0
  18. gdformat-0.1.0/gdformat/objects/score.py +47 -0
  19. gdformat-0.1.0/gdformat/objects/social.py +190 -0
  20. gdformat-0.1.0/gdformat/objects/song.py +162 -0
  21. gdformat-0.1.0/gdformat/objects/user.py +531 -0
  22. gdformat-0.1.0/gdformat/py.typed +0 -0
  23. gdformat-0.1.0/gdformat/requests/__init__.py +111 -0
  24. gdformat-0.1.0/gdformat/requests/_common.py +49 -0
  25. gdformat-0.1.0/gdformat/requests/accounts.py +132 -0
  26. gdformat-0.1.0/gdformat/requests/comments.py +215 -0
  27. gdformat-0.1.0/gdformat/requests/levels.py +511 -0
  28. gdformat-0.1.0/gdformat/requests/lists.py +117 -0
  29. gdformat-0.1.0/gdformat/requests/misc.py +96 -0
  30. gdformat-0.1.0/gdformat/requests/rewards.py +103 -0
  31. gdformat-0.1.0/gdformat/requests/socials.py +263 -0
  32. gdformat-0.1.0/gdformat/requests/songs.py +49 -0
  33. gdformat-0.1.0/gdformat/requests/users.py +233 -0
  34. gdformat-0.1.0/gdformat/responses/__init__.py +30 -0
  35. gdformat-0.1.0/gdformat/responses/accounts.py +43 -0
  36. gdformat-0.1.0/gdformat/responses/comments.py +27 -0
  37. gdformat-0.1.0/gdformat/responses/levels.py +128 -0
  38. gdformat-0.1.0/gdformat/responses/lists.py +21 -0
  39. gdformat-0.1.0/gdformat/responses/packs.py +27 -0
  40. gdformat-0.1.0/gdformat/responses/rewards.py +89 -0
  41. gdformat-0.1.0/gdformat/responses/socials.py +28 -0
  42. gdformat-0.1.0/gdformat/responses/songs.py +19 -0
  43. gdformat-0.1.0/gdformat/responses/users.py +27 -0
  44. gdformat-0.1.0/pyproject.toml +89 -0
  45. gdformat-0.1.0/pyproject.toml.orig +87 -0
gdformat-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RealistikDash
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.
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: gdformat
3
+ Version: 0.1.0
4
+ Summary: Parser and builder for the Geometry Dash 2.2 server protocol.
5
+ Keywords: geometry-dash,gdps,gd,protocol,parser,serialisation
6
+ Author: RealistikDash
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Games/Entertainment
16
+ Classifier: Topic :: Internet :: WWW/HTTP
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Typing :: Typed
19
+ Maintainer: RealistikDash
20
+ Requires-Python: >=3.14
21
+ Project-URL: Homepage, https://github.com/RealistikGDPS/gdformat
22
+ Project-URL: Repository, https://github.com/RealistikGDPS/gdformat
23
+ Project-URL: Issues, https://github.com/RealistikGDPS/gdformat/issues
24
+ Description-Content-Type: text/markdown
25
+
26
+ # gdformat
27
+
28
+ Parser and builder for the Geometry Dash 2.2 server protocol. It covers the
29
+ wire objects, the full endpoint response envelopes and typed parsing of every
30
+ endpoint's form parameters, so a server never handles the raw format itself.
31
+
32
+ Only the latest game version is supported. Python 3.14, managed with uv, no
33
+ runtime dependencies.
34
+
35
+ ## Layout
36
+
37
+ | Module | Contents |
38
+ |--------|----------|
39
+ | `gdformat.objects` | Frozen dataclasses for each wire object (`Level`, `User`, `Comment`, `Song`, ...) with `serialise_*` / `parse_*` functions. |
40
+ | `gdformat.responses` | One `serialise_*` per endpoint response, producing the exact string to send. |
41
+ | `gdformat.requests` | One `*Request` dataclass and `parse_*` per endpoint, taking the POST form. `verify_*` helpers check the client integrity values. |
42
+ | `gdformat.crypto` | XOR keys and salts, `gjp2`, `chk` generation, response hashes, level passwords, leaderboard seeds. |
43
+ | `gdformat.encoding` | Base64, XOR, level string compression, URL quoting, relative age strings. |
44
+ | `gdformat.enums` | Every discriminant the protocol uses. |
45
+ | `gdformat.codes` | Documented numeric response codes. |
46
+
47
+ ## Usage
48
+
49
+ ```python
50
+ from gdformat import is_error, objects, requests, responses
51
+
52
+ def download_level(form: dict[str, str]) -> str:
53
+ request = requests.parse_download_level(form)
54
+ if is_error(request):
55
+ return "-1"
56
+
57
+ level = objects.Level(...) # Built from your storage.
58
+ return responses.serialise_level_download(level, songs)
59
+ ```
60
+
61
+ Failures are values: every parser returns `T | ParseError`, narrowed with
62
+ `is_error` / `is_success`. Nothing in the library raises.
@@ -0,0 +1,37 @@
1
+ # gdformat
2
+
3
+ Parser and builder for the Geometry Dash 2.2 server protocol. It covers the
4
+ wire objects, the full endpoint response envelopes and typed parsing of every
5
+ endpoint's form parameters, so a server never handles the raw format itself.
6
+
7
+ Only the latest game version is supported. Python 3.14, managed with uv, no
8
+ runtime dependencies.
9
+
10
+ ## Layout
11
+
12
+ | Module | Contents |
13
+ |--------|----------|
14
+ | `gdformat.objects` | Frozen dataclasses for each wire object (`Level`, `User`, `Comment`, `Song`, ...) with `serialise_*` / `parse_*` functions. |
15
+ | `gdformat.responses` | One `serialise_*` per endpoint response, producing the exact string to send. |
16
+ | `gdformat.requests` | One `*Request` dataclass and `parse_*` per endpoint, taking the POST form. `verify_*` helpers check the client integrity values. |
17
+ | `gdformat.crypto` | XOR keys and salts, `gjp2`, `chk` generation, response hashes, level passwords, leaderboard seeds. |
18
+ | `gdformat.encoding` | Base64, XOR, level string compression, URL quoting, relative age strings. |
19
+ | `gdformat.enums` | Every discriminant the protocol uses. |
20
+ | `gdformat.codes` | Documented numeric response codes. |
21
+
22
+ ## Usage
23
+
24
+ ```python
25
+ from gdformat import is_error, objects, requests, responses
26
+
27
+ def download_level(form: dict[str, str]) -> str:
28
+ request = requests.parse_download_level(form)
29
+ if is_error(request):
30
+ return "-1"
31
+
32
+ level = objects.Level(...) # Built from your storage.
33
+ return responses.serialise_level_download(level, songs)
34
+ ```
35
+
36
+ Failures are values: every parser returns `T | ParseError`, narrowed with
37
+ `is_error` / `is_success`. Nothing in the library raises.
@@ -0,0 +1,12 @@
1
+ from . import codes
2
+ from . import crypto
3
+ from . import encoding
4
+ from . import enums
5
+ from . import objects
6
+ from . import requests
7
+ from . import responses
8
+ from ._common import Form
9
+ from ._common import ParseError
10
+ from ._common import ParseResult
11
+ from ._common import is_error
12
+ from ._common import is_success
@@ -0,0 +1,24 @@
1
+ from collections.abc import Mapping
2
+ from enum import StrEnum
3
+ from typing import TypeIs
4
+
5
+ type Form = Mapping[str, str]
6
+
7
+
8
+ class ParseError(StrEnum):
9
+ MISSING = "missing"
10
+ INVALID_INTEGER = "invalid_integer"
11
+ INVALID_VALUE = "invalid_value"
12
+ INVALID_BASE64 = "invalid_base64"
13
+ INVALID_LAYOUT = "invalid_layout"
14
+
15
+
16
+ type ParseResult[T] = T | ParseError
17
+
18
+
19
+ def is_error[T](value: ParseResult[T]) -> TypeIs[ParseError]:
20
+ return isinstance(value, ParseError)
21
+
22
+
23
+ def is_success[T](value: ParseResult[T]) -> TypeIs[T]:
24
+ return not isinstance(value, ParseError)
@@ -0,0 +1,190 @@
1
+ from collections.abc import Mapping
2
+ from enum import IntEnum
3
+ from enum import StrEnum
4
+
5
+ from gdformat._common import ParseError
6
+ from gdformat._common import ParseResult
7
+ from gdformat.encoding import decode_text
8
+
9
+
10
+ def pairs(text: str, separator: str) -> dict[str, str] | None:
11
+ parts = text.split(separator)
12
+
13
+ if len(parts) % 2:
14
+ return None
15
+
16
+ return dict(zip(parts[::2], parts[1::2], strict=True))
17
+
18
+
19
+ def split_nonempty(text: str, separator: str) -> list[str]:
20
+ return [part for part in text.split(separator) if part]
21
+
22
+
23
+ class Reader:
24
+ """Reads typed values out of a string mapping, remembering the first failure.
25
+ Failed reads return a placeholder so callers can build the whole model in one
26
+ expression and then check `done`."""
27
+
28
+ __slots__ = ("_values", "error") # One instance per parsed request or object.
29
+
30
+ def __init__(self, values: Mapping[str, str]) -> None:
31
+ self._values = values
32
+ self.error: ParseError | None = None
33
+
34
+ def _fail(self, error: ParseError) -> None:
35
+ if self.error is None:
36
+ self.error = error
37
+
38
+ def _to_int(self, value: str) -> int:
39
+ try:
40
+ return int(value)
41
+ except ValueError:
42
+ self._fail(ParseError.INVALID_INTEGER)
43
+
44
+ return 0
45
+
46
+ def has(self, key: str) -> bool:
47
+ return bool(self._values.get(key))
48
+
49
+ def string(self, key: str, *, default: str | None = None) -> str:
50
+ value = self._values.get(key)
51
+
52
+ if value is not None:
53
+ return value
54
+
55
+ if default is None:
56
+ self._fail(ParseError.MISSING)
57
+
58
+ return ""
59
+
60
+ return default
61
+
62
+ def integer(self, key: str, *, default: int | None = None) -> int:
63
+ value = self._values.get(key)
64
+
65
+ if not value:
66
+ if default is None:
67
+ self._fail(ParseError.MISSING)
68
+ return 0
69
+
70
+ return default
71
+
72
+ return self._to_int(value)
73
+
74
+ def optional_integer(self, key: str) -> int | None:
75
+ value = self._values.get(key)
76
+
77
+ if not value:
78
+ return None
79
+
80
+ return self._to_int(value)
81
+
82
+ def boolean(self, key: str, *, default: bool = False) -> bool:
83
+ value = self._values.get(key)
84
+
85
+ if not value:
86
+ return default
87
+
88
+ return value == "1"
89
+
90
+ def member[E: IntEnum](
91
+ self, key: str, kind: type[E], *, default: E | None = None
92
+ ) -> E:
93
+ value = self._values.get(key)
94
+
95
+ if not value:
96
+ if default is None:
97
+ self._fail(ParseError.MISSING)
98
+ return next(iter(kind))
99
+
100
+ return default
101
+
102
+ return self._to_member(value, kind)
103
+
104
+ def optional_member[E: IntEnum](self, key: str, kind: type[E]) -> E | None:
105
+ value = self._values.get(key)
106
+
107
+ if not value:
108
+ return None
109
+
110
+ return self._to_member(value, kind)
111
+
112
+ def _to_member[E: IntEnum](self, value: str, kind: type[E]) -> E:
113
+ number = self._to_int(value)
114
+
115
+ try:
116
+ return kind(number)
117
+ except ValueError:
118
+ self._fail(ParseError.INVALID_VALUE)
119
+
120
+ return next(iter(kind))
121
+
122
+ def choice[E: StrEnum](
123
+ self, key: str, kind: type[E], *, default: E | None = None
124
+ ) -> E:
125
+ value = self._values.get(key)
126
+
127
+ if not value:
128
+ if default is None:
129
+ self._fail(ParseError.MISSING)
130
+ return next(iter(kind))
131
+
132
+ return default
133
+
134
+ try:
135
+ return kind(value)
136
+ except ValueError:
137
+ self._fail(ParseError.INVALID_VALUE)
138
+
139
+ return next(iter(kind))
140
+
141
+ def text(self, key: str, *, default: str | None = None) -> str:
142
+ value = self._values.get(key)
143
+
144
+ if not value:
145
+ if default is None:
146
+ self._fail(ParseError.MISSING)
147
+ return ""
148
+
149
+ return default
150
+
151
+ decoded = decode_text(value)
152
+
153
+ if decoded is None:
154
+ self._fail(ParseError.INVALID_BASE64)
155
+
156
+ return ""
157
+
158
+ return decoded
159
+
160
+ def integers(self, key: str, *, separator: str = ",") -> tuple[int, ...]:
161
+ value = self._values.get(key)
162
+
163
+ if not value or value == "-":
164
+ return ()
165
+
166
+ return tuple(
167
+ self._to_int(part) for part in value.strip("()").split(separator) if part
168
+ )
169
+
170
+ def members[E: IntEnum](
171
+ self,
172
+ key: str,
173
+ kind: type[E],
174
+ *,
175
+ separator: str = ",",
176
+ ) -> tuple[E, ...]:
177
+ value = self._values.get(key)
178
+
179
+ if not value or value == "-":
180
+ return ()
181
+
182
+ return tuple(
183
+ self._to_member(part, kind) for part in value.split(separator) if part
184
+ )
185
+
186
+ def done[T](self, value: T) -> ParseResult[T]:
187
+ if self.error is not None:
188
+ return self.error
189
+
190
+ return value
@@ -0,0 +1,59 @@
1
+ from enum import IntEnum
2
+
3
+ SUCCESS = "1"
4
+ FAILURE = "-1"
5
+
6
+
7
+ class LoginError(IntEnum):
8
+ GENERIC = -1
9
+ PASSWORD_TOO_SHORT = -8
10
+ NAME_TOO_SHORT = -9
11
+ LINKED_TO_OTHER_ACCOUNT = -10
12
+ WRONG_CREDENTIALS = -11
13
+ ACCOUNT_DISABLED = -12
14
+ LINKED_TO_OTHER_STEAM = -13
15
+
16
+
17
+ class RegisterError(IntEnum):
18
+ GENERIC = -1
19
+ NAME_TAKEN = -2
20
+ EMAIL_TAKEN = -3
21
+ NAME_INVALID = -4
22
+ PASSWORD_INVALID = -5
23
+ EMAIL_INVALID = -6
24
+ PASSWORD_TOO_SHORT = -8
25
+ NAME_TOO_SHORT = -9
26
+
27
+
28
+ class SaveError(IntEnum):
29
+ GENERIC = -1
30
+ LOGIN_FAILED = -2
31
+ GENERIC_VISIBLE = -3
32
+ TOO_LARGE = -4
33
+ BAD_LOGIN = -5
34
+ SERVER_ERROR = -6
35
+
36
+
37
+ class SongError(IntEnum):
38
+ NOT_FOUND = -1
39
+ NOT_ALLOWED = -2
40
+
41
+
42
+ class CommentError(IntEnum):
43
+ REJECTED = -1
44
+ NONE_FOUND = -2
45
+ PERMANENT_BAN = -10
46
+
47
+
48
+ class ListUploadError(IntEnum):
49
+ REJECTED = -1
50
+ BAD_SEED = -10
51
+
52
+
53
+ class ModeratorError(IntEnum):
54
+ REJECTED = -1
55
+ NOT_MODERATOR = -2
56
+
57
+
58
+ def serialise_code(code: int) -> str:
59
+ return str(int(code))
@@ -0,0 +1,296 @@
1
+ import hashlib
2
+ from collections.abc import Iterable
3
+
4
+ from gdformat.encoding import cyclic_xor
5
+ from gdformat.encoding import decode_base64
6
+ from gdformat.encoding import encode_base64
7
+ from gdformat.encoding import encode_xor_text
8
+
9
+ KEY_MESSAGE = b"14251"
10
+ KEY_VAULT = b"19283"
11
+ KEY_CHALLENGES = b"19847"
12
+ KEY_LEVEL_PASSWORD = b"26364"
13
+ KEY_COMMENT = b"29481"
14
+ KEY_ACCOUNT_PASSWORD = b"37526"
15
+ KEY_LEVEL_LEADERBOARD = b"39673"
16
+ KEY_LEVEL = b"41274"
17
+ KEY_LOAD = b"48291"
18
+ KEY_LIBRARY = b"57709"
19
+ KEY_RATING = b"58281"
20
+ KEY_CHESTS = b"59182"
21
+ KEY_STATS = b"85271"
22
+
23
+ SALT_LEVEL = "xI25fpAapCQg"
24
+ SALT_COMMENT = "xPT6iUrtws0J"
25
+ SALT_LIKE = "ysg6pUrtjn0J"
26
+ SALT_PROFILE = "xI35fsAapCRg"
27
+ SALT_LEVEL_LEADERBOARD = "yPg6pUrtWn0J"
28
+ SALT_VAULT = "ask2fpcaqCQ2"
29
+ SALT_CHALLENGES = "oC36fpYaPtdg"
30
+ SALT_REWARDS = "pC26fpYaQCtg"
31
+ SALT_GJP2 = "mI29fmAnxgTs"
32
+
33
+ _SEED_SAMPLE_SIZE = 50
34
+ _DOWNLOAD_SAMPLE_SIZE = 40
35
+
36
+
37
+ def sha1_hex(text: str) -> str:
38
+ return hashlib.sha1(text.encode()).hexdigest()
39
+
40
+
41
+ def gjp2(password: str) -> str:
42
+ return sha1_hex(password + SALT_GJP2)
43
+
44
+
45
+ def chk(values: Iterable[object], key: bytes, salt: str) -> str:
46
+ joined = "".join(map(str, values)) + salt
47
+
48
+ return encode_base64(cyclic_xor(sha1_hex(joined).encode(), key))
49
+
50
+
51
+ def sample(data: str, count: int) -> str:
52
+ if len(data) < count:
53
+ return data
54
+
55
+ return data[:: len(data) // count][:count]
56
+
57
+
58
+ def comment_chk(
59
+ username: str,
60
+ content: str,
61
+ level_id: int,
62
+ percent: int,
63
+ comment_type: int,
64
+ ) -> str:
65
+ """`content` is the URL-safe base64 form of the comment, as sent on the wire."""
66
+
67
+ return chk(
68
+ (username, content, level_id, percent, comment_type), KEY_COMMENT, SALT_COMMENT
69
+ )
70
+
71
+
72
+ def like_chk(
73
+ special: int,
74
+ item_id: int,
75
+ like: bool,
76
+ like_type: int,
77
+ rs: str,
78
+ account_id: int,
79
+ udid: str,
80
+ user_id: int,
81
+ ) -> str:
82
+ return chk(
83
+ (special, item_id, int(like), like_type, rs, account_id, udid, user_id),
84
+ KEY_RATING,
85
+ SALT_LIKE,
86
+ )
87
+
88
+
89
+ def rate_chk(
90
+ level_id: int,
91
+ stars: int,
92
+ rs: str,
93
+ account_id: int,
94
+ udid: str,
95
+ user_id: int,
96
+ ) -> str:
97
+ return chk((level_id, stars, rs, account_id, udid, user_id), KEY_RATING, SALT_LIKE)
98
+
99
+
100
+ def download_chk(
101
+ level_id: int,
102
+ increment: bool,
103
+ rs: str,
104
+ account_id: int,
105
+ udid: str,
106
+ user_id: int,
107
+ ) -> str:
108
+ # NOTE: The documentation lists no salt for this value; this follows it as written.
109
+ return chk((level_id, int(increment), rs, account_id, udid, user_id), KEY_LEVEL, "")
110
+
111
+
112
+ def profile_chk(values: Iterable[object]) -> str:
113
+ return chk(values, KEY_STATS, SALT_PROFILE)
114
+
115
+
116
+ def level_leaderboard_chk(values: Iterable[object], rs: str) -> str:
117
+ return chk((*values, SALT_LEVEL_LEADERBOARD, rs), KEY_LEVEL_LEADERBOARD, "")
118
+
119
+
120
+ def level_seed(level_string: str) -> str:
121
+ """The `seed2` parameter of a level upload."""
122
+
123
+ sampled = sample(level_string, _SEED_SAMPLE_SIZE)
124
+
125
+ return encode_base64(cyclic_xor(sha1_hex(sampled + SALT_LEVEL).encode(), KEY_LEVEL))
126
+
127
+
128
+ def list_seed(level_ids: str, account_id: int, seed2: str) -> str:
129
+ """The `seed` parameter of a list upload; `seed2` is the random key it was
130
+ encrypted with."""
131
+
132
+ sampled = sample(level_ids, _SEED_SAMPLE_SIZE)
133
+ digest = sha1_hex(f"{sampled}{account_id}")
134
+
135
+ return encode_base64(cyclic_xor(digest.encode(), seed2.encode()))
136
+
137
+
138
+ def level_download_hash(level_string: str) -> str:
139
+ if len(level_string) <= _DOWNLOAD_SAMPLE_SIZE:
140
+ return sha1_hex(level_string + SALT_LEVEL)
141
+
142
+ step = len(level_string) // _DOWNLOAD_SAMPLE_SIZE
143
+
144
+ sampled = "".join(
145
+ level_string[index * step] for index in range(_DOWNLOAD_SAMPLE_SIZE)
146
+ )
147
+
148
+ return sha1_hex(sampled + SALT_LEVEL)
149
+
150
+
151
+ def level_metadata_hash(
152
+ creator_id: int,
153
+ stars: int,
154
+ is_demon: bool,
155
+ level_id: int,
156
+ verified_coins: bool,
157
+ feature_score: int,
158
+ password: int,
159
+ timely_id: int,
160
+ ) -> str:
161
+ joined = (
162
+ f"{creator_id},{stars},{is_demon:d},{level_id},{verified_coins:d},"
163
+ f"{feature_score},{password},{timely_id}"
164
+ )
165
+
166
+ return sha1_hex(joined + SALT_LEVEL)
167
+
168
+
169
+ def level_search_hash(levels: Iterable[tuple[int, int, bool]]) -> str:
170
+ """`levels` yields `(level_id, stars, verified_coins)` per level, in order."""
171
+
172
+ parts = []
173
+
174
+ for level_id, stars, verified_coins in levels:
175
+ digits = str(level_id)
176
+ parts.append(f"{digits[0]}{digits[-1]}{stars}{verified_coins:d}")
177
+
178
+ return sha1_hex("".join(parts) + SALT_LEVEL)
179
+
180
+
181
+ def map_pack_hash(packs: Iterable[tuple[int, int, int]]) -> str:
182
+ """`packs` yields `(pack_id, stars, coins)` per pack, in order."""
183
+
184
+ parts = []
185
+
186
+ for pack_id, stars, coins in packs:
187
+ digits = str(pack_id)
188
+ parts.append(f"{digits[0]}{digits[-1]}{stars}{coins}")
189
+
190
+ return sha1_hex("".join(parts) + SALT_LEVEL)
191
+
192
+
193
+ def gauntlet_hash(gauntlets: Iterable[tuple[int, str]]) -> str:
194
+ """`gauntlets` yields `(gauntlet_id, comma_separated_level_ids)`, in order."""
195
+
196
+ joined = "".join(
197
+ f"{gauntlet_id}{level_ids}" for gauntlet_id, level_ids in gauntlets
198
+ )
199
+
200
+ return sha1_hex(joined + SALT_LEVEL)
201
+
202
+
203
+ def challenges_hash(blob: str) -> str:
204
+ return sha1_hex(blob + SALT_CHALLENGES)
205
+
206
+
207
+ def rewards_hash(blob: str) -> str:
208
+ return sha1_hex(blob + SALT_REWARDS)
209
+
210
+
211
+ LEVEL_LIST_HASH = sha1_hex(SALT_LEVEL)
212
+
213
+
214
+ def level_password_wire(password: str | None) -> str:
215
+ """The plain-text form of a copy password: `0` no copy, `1` free copy, else
216
+ `1` followed by the digits."""
217
+
218
+ if password is None:
219
+ return "0"
220
+
221
+ return f"1{password}"
222
+
223
+
224
+ def level_password_from_wire(wire: str) -> str | None:
225
+ if wire in ("", "0"):
226
+ return None
227
+
228
+ return wire[1:]
229
+
230
+
231
+ def level_password_number(password: str | None) -> int:
232
+ """The numeric form used by the download hash: the plain wire value as an
233
+ integer. Verified against the official server for 4 and 6 digit passwords;
234
+ the documented 1,000,000 normalisation is not applied there."""
235
+
236
+ return int(level_password_wire(password))
237
+
238
+
239
+ def encode_level_password(password: str | None) -> str:
240
+ if password is None:
241
+ return "0"
242
+
243
+ return encode_xor_text(level_password_wire(password), KEY_LEVEL_PASSWORD)
244
+
245
+
246
+ def decode_level_password(encoded: str) -> str | None:
247
+ if encoded in ("", "0"):
248
+ return None
249
+
250
+ raw = decode_base64(encoded)
251
+
252
+ if raw is None:
253
+ return None
254
+
255
+ return level_password_from_wire(
256
+ cyclic_xor(raw, KEY_LEVEL_PASSWORD).decode("ascii", "replace")
257
+ )
258
+
259
+
260
+ def decode_reward_chk(value: str, key: bytes) -> int | None:
261
+ """Recovers the client's challenge number from a rewards `chk` parameter."""
262
+
263
+ raw = decode_base64(value[5:])
264
+
265
+ if raw is None:
266
+ return None
267
+
268
+ number = cyclic_xor(raw, key).decode("ascii", "replace")
269
+
270
+ if not number.isdecimal():
271
+ return None
272
+
273
+ return int(number)
274
+
275
+
276
+ def encode_reward_blob(plaintext: str, key: bytes) -> str:
277
+ return encode_base64(cyclic_xor(plaintext.encode(), key))
278
+
279
+
280
+ def classic_leaderboard_seed(clicks: int, percentage: int, seconds: int) -> int:
281
+ return (
282
+ 1482 * 2
283
+ + (clicks + 3991) * (percentage + 8354)
284
+ + (seconds + 4085) ** 2
285
+ - 50028039
286
+ )
287
+
288
+
289
+ def platformer_leaderboard_hash(best_time: int, best_points: int) -> int:
290
+ number = (
291
+ ((best_time + 7890) % 34567) * 601
292
+ + ((abs(best_points) + 3456) % 78901) * 967
293
+ + 94819
294
+ ) % 94433
295
+
296
+ return ((number ^ (number >> 16)) * 829) % 77849