gdformat 0.1.0__py3-none-any.whl
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/__init__.py +12 -0
- gdformat/_common.py +24 -0
- gdformat/_wire.py +190 -0
- gdformat/codes.py +59 -0
- gdformat/crypto.py +296 -0
- gdformat/encoding.py +122 -0
- gdformat/enums.py +336 -0
- gdformat/objects/__init__.py +78 -0
- gdformat/objects/_common.py +58 -0
- gdformat/objects/comment.py +145 -0
- gdformat/objects/level.py +263 -0
- gdformat/objects/level_list.py +77 -0
- gdformat/objects/pack.py +86 -0
- gdformat/objects/reward.py +106 -0
- gdformat/objects/score.py +47 -0
- gdformat/objects/social.py +190 -0
- gdformat/objects/song.py +162 -0
- gdformat/objects/user.py +531 -0
- gdformat/py.typed +0 -0
- gdformat/requests/__init__.py +111 -0
- gdformat/requests/_common.py +49 -0
- gdformat/requests/accounts.py +132 -0
- gdformat/requests/comments.py +215 -0
- gdformat/requests/levels.py +511 -0
- gdformat/requests/lists.py +117 -0
- gdformat/requests/misc.py +96 -0
- gdformat/requests/rewards.py +103 -0
- gdformat/requests/socials.py +263 -0
- gdformat/requests/songs.py +49 -0
- gdformat/requests/users.py +233 -0
- gdformat/responses/__init__.py +30 -0
- gdformat/responses/accounts.py +43 -0
- gdformat/responses/comments.py +27 -0
- gdformat/responses/levels.py +128 -0
- gdformat/responses/lists.py +21 -0
- gdformat/responses/packs.py +27 -0
- gdformat/responses/rewards.py +89 -0
- gdformat/responses/socials.py +28 -0
- gdformat/responses/songs.py +19 -0
- gdformat/responses/users.py +27 -0
- gdformat-0.1.0.dist-info/METADATA +62 -0
- gdformat-0.1.0.dist-info/RECORD +44 -0
- gdformat-0.1.0.dist-info/WHEEL +4 -0
- gdformat-0.1.0.dist-info/licenses/LICENSE +21 -0
gdformat/__init__.py
ADDED
|
@@ -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
|
gdformat/_common.py
ADDED
|
@@ -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)
|
gdformat/_wire.py
ADDED
|
@@ -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
|
gdformat/codes.py
ADDED
|
@@ -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))
|
gdformat/crypto.py
ADDED
|
@@ -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
|
gdformat/encoding.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import binascii
|
|
3
|
+
import gzip
|
|
4
|
+
import random
|
|
5
|
+
import string
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import zlib
|
|
8
|
+
|
|
9
|
+
_ALPHABET = string.ascii_letters + string.digits
|
|
10
|
+
_SEPARATOR_TABLE = str.maketrans("", "", ":|#~")
|
|
11
|
+
_AGE_UNITS = (
|
|
12
|
+
(31_536_000, "year"),
|
|
13
|
+
(2_592_000, "month"),
|
|
14
|
+
(604_800, "week"),
|
|
15
|
+
(86_400, "day"),
|
|
16
|
+
(3_600, "hour"),
|
|
17
|
+
(60, "minute"),
|
|
18
|
+
(1, "second"),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cyclic_xor(data: bytes, key: bytes) -> bytes:
|
|
23
|
+
length = len(data)
|
|
24
|
+
|
|
25
|
+
if length == 0:
|
|
26
|
+
return b""
|
|
27
|
+
|
|
28
|
+
repeated = (key * (length // len(key) + 1))[:length]
|
|
29
|
+
mixed = int.from_bytes(data, "big") ^ int.from_bytes(repeated, "big")
|
|
30
|
+
|
|
31
|
+
return mixed.to_bytes(length, "big")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def encode_base64(data: bytes) -> str:
|
|
35
|
+
return base64.urlsafe_b64encode(data).decode("ascii")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def decode_base64(text: str) -> bytes | None:
|
|
39
|
+
padded = text + "=" * (-len(text) % 4)
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
return base64.urlsafe_b64decode(padded)
|
|
43
|
+
except binascii.Error:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def encode_text(text: str) -> str:
|
|
48
|
+
return encode_base64(text.encode())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def decode_text(text: str) -> str | None:
|
|
52
|
+
raw = decode_base64(text)
|
|
53
|
+
|
|
54
|
+
if raw is None:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
return raw.decode("utf-8", "replace")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def encode_xor_text(text: str, key: bytes) -> str:
|
|
61
|
+
return encode_base64(cyclic_xor(text.encode(), key))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def decode_xor_text(text: str, key: bytes) -> str | None:
|
|
65
|
+
raw = decode_base64(text)
|
|
66
|
+
|
|
67
|
+
if raw is None:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
return cyclic_xor(raw, key).decode("utf-8", "replace")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def compress_level(level: str) -> str:
|
|
74
|
+
return encode_base64(gzip.compress(level.encode(), mtime=0))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def decompress_level(data: str) -> str | None:
|
|
78
|
+
raw = decode_base64(data)
|
|
79
|
+
|
|
80
|
+
if raw is None:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
return zlib.decompress(raw, 15 | 32).decode("utf-8", "replace")
|
|
85
|
+
except zlib.error:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def deflate_base64(text: str) -> str:
|
|
90
|
+
return encode_base64(zlib.compress(text.encode()))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def quote_url(url: str) -> str:
|
|
94
|
+
return urllib.parse.quote(url, safe="")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def unquote_url(url: str) -> str:
|
|
98
|
+
return urllib.parse.unquote(url)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def random_string(length: int) -> str:
|
|
102
|
+
return "".join(random.choices(_ALPHABET, k=length))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def strip_separators(text: str) -> str:
|
|
106
|
+
return text.translate(_SEPARATOR_TABLE)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def describe_age(seconds: int) -> str:
|
|
110
|
+
"""Formats a duration the way the official servers do: `3 months`, `1 second`."""
|
|
111
|
+
|
|
112
|
+
elapsed = max(seconds, 1)
|
|
113
|
+
|
|
114
|
+
for unit, name in _AGE_UNITS:
|
|
115
|
+
if elapsed < unit:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
count = elapsed // unit
|
|
119
|
+
|
|
120
|
+
return f"{count} {name}" if count == 1 else f"{count} {name}s"
|
|
121
|
+
|
|
122
|
+
return "1 second"
|