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.
- gdformat-0.1.0/LICENSE +21 -0
- gdformat-0.1.0/PKG-INFO +62 -0
- gdformat-0.1.0/README.md +37 -0
- gdformat-0.1.0/gdformat/__init__.py +12 -0
- gdformat-0.1.0/gdformat/_common.py +24 -0
- gdformat-0.1.0/gdformat/_wire.py +190 -0
- gdformat-0.1.0/gdformat/codes.py +59 -0
- gdformat-0.1.0/gdformat/crypto.py +296 -0
- gdformat-0.1.0/gdformat/encoding.py +122 -0
- gdformat-0.1.0/gdformat/enums.py +336 -0
- gdformat-0.1.0/gdformat/objects/__init__.py +78 -0
- gdformat-0.1.0/gdformat/objects/_common.py +58 -0
- gdformat-0.1.0/gdformat/objects/comment.py +145 -0
- gdformat-0.1.0/gdformat/objects/level.py +263 -0
- gdformat-0.1.0/gdformat/objects/level_list.py +77 -0
- gdformat-0.1.0/gdformat/objects/pack.py +86 -0
- gdformat-0.1.0/gdformat/objects/reward.py +106 -0
- gdformat-0.1.0/gdformat/objects/score.py +47 -0
- gdformat-0.1.0/gdformat/objects/social.py +190 -0
- gdformat-0.1.0/gdformat/objects/song.py +162 -0
- gdformat-0.1.0/gdformat/objects/user.py +531 -0
- gdformat-0.1.0/gdformat/py.typed +0 -0
- gdformat-0.1.0/gdformat/requests/__init__.py +111 -0
- gdformat-0.1.0/gdformat/requests/_common.py +49 -0
- gdformat-0.1.0/gdformat/requests/accounts.py +132 -0
- gdformat-0.1.0/gdformat/requests/comments.py +215 -0
- gdformat-0.1.0/gdformat/requests/levels.py +511 -0
- gdformat-0.1.0/gdformat/requests/lists.py +117 -0
- gdformat-0.1.0/gdformat/requests/misc.py +96 -0
- gdformat-0.1.0/gdformat/requests/rewards.py +103 -0
- gdformat-0.1.0/gdformat/requests/socials.py +263 -0
- gdformat-0.1.0/gdformat/requests/songs.py +49 -0
- gdformat-0.1.0/gdformat/requests/users.py +233 -0
- gdformat-0.1.0/gdformat/responses/__init__.py +30 -0
- gdformat-0.1.0/gdformat/responses/accounts.py +43 -0
- gdformat-0.1.0/gdformat/responses/comments.py +27 -0
- gdformat-0.1.0/gdformat/responses/levels.py +128 -0
- gdformat-0.1.0/gdformat/responses/lists.py +21 -0
- gdformat-0.1.0/gdformat/responses/packs.py +27 -0
- gdformat-0.1.0/gdformat/responses/rewards.py +89 -0
- gdformat-0.1.0/gdformat/responses/socials.py +28 -0
- gdformat-0.1.0/gdformat/responses/songs.py +19 -0
- gdformat-0.1.0/gdformat/responses/users.py +27 -0
- gdformat-0.1.0/pyproject.toml +89 -0
- 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.
|
gdformat-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
gdformat-0.1.0/README.md
ADDED
|
@@ -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
|