nnnotes 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.
- nnnotes/__init__.py +7 -0
- nnnotes/__main__.py +4 -0
- nnnotes/acb.py +190 -0
- nnnotes/addressables.py +413 -0
- nnnotes/adv.py +243 -0
- nnnotes/advcommand.py +29 -0
- nnnotes/advmedia.py +178 -0
- nnnotes/advscene.py +113 -0
- nnnotes/advui.py +1154 -0
- nnnotes/advvideo.py +684 -0
- nnnotes/atoms/__init__.py +151 -0
- nnnotes/atoms/astc.py +52 -0
- nnnotes/atoms/gltf.py +290 -0
- nnnotes/atoms/mesh.py +289 -0
- nnnotes/atoms/png.py +28 -0
- nnnotes/atoms/reader.py +269 -0
- nnnotes/atoms/sniff.py +198 -0
- nnnotes/atoms/sprite.py +236 -0
- nnnotes/atoms/texture.py +80 -0
- nnnotes/cache.py +289 -0
- nnnotes/catalog.py +373 -0
- nnnotes/catalogdb.py +457 -0
- nnnotes/census.py +276 -0
- nnnotes/cli.py +744 -0
- nnnotes/cli_assets.py +1539 -0
- nnnotes/config.py +238 -0
- nnnotes/contract.py +497 -0
- nnnotes/cri.py +371 -0
- nnnotes/crikey.py +71 -0
- nnnotes/crilips.py +265 -0
- nnnotes/cristages.py +558 -0
- nnnotes/deckdata.py +462 -0
- nnnotes/export.py +1462 -0
- nnnotes/gameapi.py +318 -0
- nnnotes/jsonio.py +98 -0
- nnnotes/languages.py +44 -0
- nnnotes/layout.py +596 -0
- nnnotes/link.py +418 -0
- nnnotes/live.py +115 -0
- nnnotes/live2d.py +235 -0
- nnnotes/liveaudio.py +331 -0
- nnnotes/livenotes.py +291 -0
- nnnotes/liveoptions.py +250 -0
- nnnotes/livescene.py +450 -0
- nnnotes/liveui.py +328 -0
- nnnotes/master.py +251 -0
- nnnotes/modelstages.py +424 -0
- nnnotes/motion.py +131 -0
- nnnotes/objexport.py +1564 -0
- nnnotes/orchestrate.py +630 -0
- nnnotes/plan.py +218 -0
- nnnotes/player.py +392 -0
- nnnotes/room.py +391 -0
- nnnotes/score.py +1139 -0
- nnnotes/shader.py +217 -0
- nnnotes/spot.py +201 -0
- nnnotes/stages.py +285 -0
- nnnotes/store.py +518 -0
- nnnotes/story.py +142 -0
- nnnotes/storyfonts.py +1258 -0
- nnnotes/storyhost.py +1190 -0
- nnnotes/storysite.py +662 -0
- nnnotes/storyvoices.py +102 -0
- nnnotes/textstyle.py +355 -0
- nnnotes/tmpfont.py +636 -0
- nnnotes/typetrees/6000.3.12f1.json +19 -0
- nnnotes/unity.py +368 -0
- nnnotes/viewrules.json +441 -0
- nnnotes/views.py +966 -0
- nnnotes/voices.py +1363 -0
- nnnotes/web.py +1624 -0
- nnnotes/webaudio.py +14 -0
- nnnotes/webmodel.py +397 -0
- nnnotes-0.1.0.dist-info/METADATA +200 -0
- nnnotes-0.1.0.dist-info/RECORD +79 -0
- nnnotes-0.1.0.dist-info/WHEEL +5 -0
- nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
- nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
- nnnotes-0.1.0.dist-info/top_level.txt +1 -0
nnnotes/gameapi.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Anonymous calls to the game's API: the master data version a region serves, and the server list.
|
|
2
|
+
|
|
3
|
+
The game's API is gRPC over HTTP/2 with TLS. Two unary methods can be called anonymously; both take an empty request
|
|
4
|
+
message:
|
|
5
|
+
|
|
6
|
+
`app.masterdata.MasterdataService/Version`, on a region's API root
|
|
7
|
+
response `string version = 1` (the master data version: the `<version>` of `<cdn>/master/<version>/`) and
|
|
8
|
+
`string resource_version = 2`.
|
|
9
|
+
`GetServerList`, on the bootstrap API root
|
|
10
|
+
response `repeated ServerInfo servers = 1`; of ServerInfo only `string name = 1`, `string cdnRoot = 2`,
|
|
11
|
+
`string apiServerRoot = 3`, `string displayName = 7` and `string areaID = 8` are read. A root field can hold
|
|
12
|
+
several alternative roots separated by `|`.
|
|
13
|
+
|
|
14
|
+
Every call sends the metadata the game client sends with each call: `x-request-id` (a random UUID), `x-platform`
|
|
15
|
+
(`android`) and `x-client-version` (the client version). Requests and responses are raw bytes; the few fields above
|
|
16
|
+
are read with a small protobuf wire-format decoder, without generated code. A call has a deadline and is retried when
|
|
17
|
+
the server is unreachable, misses the deadline or answers with an empty message. Error messages name the method and
|
|
18
|
+
the setting, never the address.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import time
|
|
23
|
+
import uuid
|
|
24
|
+
import zipfile
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from urllib.parse import urlsplit
|
|
28
|
+
|
|
29
|
+
import grpc
|
|
30
|
+
|
|
31
|
+
from .config import Config, ConfigError, describe
|
|
32
|
+
|
|
33
|
+
VERSION_METHOD = "/app.masterdata.MasterdataService/Version"
|
|
34
|
+
SERVER_LIST_METHOD = "/app.playerlogin.PlayerLoginService/GetServerList"
|
|
35
|
+
PLATFORM = "android"
|
|
36
|
+
TIMEOUT = 10.0 # seconds, per attempt
|
|
37
|
+
ATTEMPTS = 3
|
|
38
|
+
RETRY_STATUS = (grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.DEADLINE_EXCEEDED)
|
|
39
|
+
ERROR_CODE_TRAILER = "x-sirius-error-code" # the game's error code (with status UNKNOWN / PERMISSION_DENIED)
|
|
40
|
+
STATUS_HINT = {
|
|
41
|
+
grpc.StatusCode.UNAVAILABLE: "server unreachable",
|
|
42
|
+
grpc.StatusCode.DEADLINE_EXCEEDED: "no answer before the deadline",
|
|
43
|
+
grpc.StatusCode.UNIMPLEMENTED: "no such method there: is the setting the API root?",
|
|
44
|
+
}
|
|
45
|
+
GAME_ERROR_HINT = {
|
|
46
|
+
"CLIENT_UPDATE_REQUIRED": "the client version is older than the server accepts",
|
|
47
|
+
"UNDER_MAINTENANCE": "the server is under maintenance",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class GameApiError(RuntimeError):
|
|
52
|
+
"""A game API call failed (the message names the method and the setting, never the address)."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------- protobuf wire format
|
|
56
|
+
def _varint(buf: bytes, i: int) -> tuple[int, int]:
|
|
57
|
+
val = shift = 0
|
|
58
|
+
while True:
|
|
59
|
+
if i >= len(buf):
|
|
60
|
+
raise ValueError("truncated varint")
|
|
61
|
+
b = buf[i]
|
|
62
|
+
i += 1
|
|
63
|
+
val |= (b & 0x7F) << shift
|
|
64
|
+
if not b & 0x80:
|
|
65
|
+
return val, i
|
|
66
|
+
shift += 7
|
|
67
|
+
if shift >= 64:
|
|
68
|
+
raise ValueError("varint longer than 10 bytes")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def fields(buf: bytes) -> list[tuple[int, int | bytes]]:
|
|
72
|
+
"""(field number, value) of every field of a protobuf message, in wire order. Varints are ints; 64-bit, 32-bit
|
|
73
|
+
and length-delimited fields are their raw bytes. Raises ValueError on malformed data."""
|
|
74
|
+
out: list[tuple[int, int | bytes]] = []
|
|
75
|
+
i, n = 0, len(buf)
|
|
76
|
+
while i < n:
|
|
77
|
+
key, i = _varint(buf, i)
|
|
78
|
+
num, wire = key >> 3, key & 7
|
|
79
|
+
if num == 0:
|
|
80
|
+
raise ValueError("field number 0")
|
|
81
|
+
if wire == 0:
|
|
82
|
+
v, i = _varint(buf, i)
|
|
83
|
+
elif wire in (1, 2, 5):
|
|
84
|
+
if wire == 2:
|
|
85
|
+
size, i = _varint(buf, i)
|
|
86
|
+
else:
|
|
87
|
+
size = 8 if wire == 1 else 4
|
|
88
|
+
if i + size > n:
|
|
89
|
+
raise ValueError(f"field {num} runs past the end of the message")
|
|
90
|
+
v, i = bytes(buf[i:i + size]), i + size
|
|
91
|
+
else:
|
|
92
|
+
raise ValueError(f"field {num}: unsupported wire type {wire}")
|
|
93
|
+
out.append((num, v))
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def strings(buf: bytes, names: dict[int, str]) -> dict[str, str]:
|
|
98
|
+
"""The string fields `names` ({field number: name}) of a message. An absent field is "" (the proto3 default);
|
|
99
|
+
of repeated occurrences the last one counts."""
|
|
100
|
+
out = dict.fromkeys(names.values(), "")
|
|
101
|
+
for num, v in fields(buf):
|
|
102
|
+
if num in names:
|
|
103
|
+
if not isinstance(v, bytes):
|
|
104
|
+
raise ValueError(f"field {num} is not a string")
|
|
105
|
+
out[names[num]] = v.decode("utf-8")
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------- messages
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class MasterVersion:
|
|
112
|
+
version: str # master data version
|
|
113
|
+
resource_version: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class Server:
|
|
118
|
+
"""An entry of the server list. `cdn` / `api`: the alternative roots in the server's order (not in the repr)."""
|
|
119
|
+
name: str
|
|
120
|
+
display_name: str
|
|
121
|
+
area_id: str
|
|
122
|
+
cdn: tuple[str, ...] = field(repr=False)
|
|
123
|
+
api: tuple[str, ...] = field(repr=False)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
SERVER_INFO = {1: "name", 2: "cdn", 3: "api", 7: "display_name", 8: "area_id"}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def split_roots(value: str) -> tuple[str, ...]:
|
|
130
|
+
"""`a|b|c` -> ("a", "b", "c"): blanks dropped, trailing `/` removed."""
|
|
131
|
+
return tuple(p.strip().rstrip("/") for p in value.split("|") if p.strip())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse_version(buf: bytes) -> MasterVersion:
|
|
135
|
+
d = strings(buf, {1: "version", 2: "resource_version"})
|
|
136
|
+
return MasterVersion(d["version"], d["resource_version"])
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def parse_server_list(buf: bytes) -> list[Server]:
|
|
140
|
+
out = []
|
|
141
|
+
for num, v in fields(buf):
|
|
142
|
+
if num != 1:
|
|
143
|
+
continue
|
|
144
|
+
if not isinstance(v, bytes):
|
|
145
|
+
raise ValueError("field 1 is not a message")
|
|
146
|
+
d = strings(v, SERVER_INFO)
|
|
147
|
+
out.append(Server(d["name"], d["display_name"], d["area_id"], split_roots(d["cdn"]), split_roots(d["api"])))
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------- calls
|
|
152
|
+
def metadata(client_version: str, request_id: str | None = None) -> list[tuple[str, str]]:
|
|
153
|
+
"""The metadata of a call: a fresh `x-request-id` unless one is given."""
|
|
154
|
+
return [("x-request-id", request_id or str(uuid.uuid4())), ("x-platform", PLATFORM),
|
|
155
|
+
("x-client-version", client_version)]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def channel_target(root: str) -> tuple[str, bool]:
|
|
159
|
+
"""(gRPC target `host:port`, TLS) of an API root: `https://host[:port]` or `host[:port]` (TLS, port 443 by
|
|
160
|
+
default), or `http://host[:port]` (plain text, port 80 by default; for local servers). A trailing `/` is
|
|
161
|
+
allowed, any other path is not. Raises ValueError with a message that does not repeat the value."""
|
|
162
|
+
s = root.strip()
|
|
163
|
+
if "://" not in s:
|
|
164
|
+
s = "https://" + s
|
|
165
|
+
u = urlsplit(s)
|
|
166
|
+
if u.scheme not in ("https", "http"):
|
|
167
|
+
raise ValueError("must be https://host[:port], http://host[:port] or host[:port]")
|
|
168
|
+
if u.path not in ("", "/") or u.query or u.fragment:
|
|
169
|
+
raise ValueError("must be the API root (scheme, host and port only), not a URL with a path")
|
|
170
|
+
try:
|
|
171
|
+
host, port = u.hostname, u.port
|
|
172
|
+
except ValueError:
|
|
173
|
+
raise ValueError("has an invalid port") from None
|
|
174
|
+
if not host:
|
|
175
|
+
raise ValueError("has no host")
|
|
176
|
+
if ":" in host: # IPv6 literal
|
|
177
|
+
host = f"[{host}]"
|
|
178
|
+
tls = u.scheme == "https"
|
|
179
|
+
return f"{host}:{port or (443 if tls else 80)}", tls
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def call(root: str, method: str, request: bytes, client_version: str, *, timeout: float = TIMEOUT,
|
|
183
|
+
attempts: int = ATTEMPTS, setting: str = "the API root") -> bytes:
|
|
184
|
+
"""One unary call with raw bytes; returns the response message's bytes. `setting` names the root in errors.
|
|
185
|
+
Raises GameApiError."""
|
|
186
|
+
target, tls = channel_target(root)
|
|
187
|
+
name = method.rsplit("/", 1)[-1] # e.g. Version
|
|
188
|
+
channel = grpc.secure_channel(target, grpc.ssl_channel_credentials()) if tls else grpc.insecure_channel(target)
|
|
189
|
+
try:
|
|
190
|
+
stub = channel.unary_unary(method) # no (de)serializers: bytes in, bytes out
|
|
191
|
+
attempts = max(1, attempts)
|
|
192
|
+
for i in range(attempts):
|
|
193
|
+
last = i + 1 == attempts
|
|
194
|
+
try:
|
|
195
|
+
body = stub(request, timeout=timeout, metadata=metadata(client_version))
|
|
196
|
+
except grpc.RpcError as e:
|
|
197
|
+
code = e.code()
|
|
198
|
+
if code in RETRY_STATUS and not last:
|
|
199
|
+
time.sleep(0.5 * (i + 1))
|
|
200
|
+
continue
|
|
201
|
+
raise GameApiError(failure(name, setting, code, e.trailing_metadata())) from None
|
|
202
|
+
if body or last:
|
|
203
|
+
return body
|
|
204
|
+
time.sleep(0.3 * (i + 1)) # OK with an empty message: transient, ask again
|
|
205
|
+
finally:
|
|
206
|
+
channel.close()
|
|
207
|
+
raise AssertionError("unreachable")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def failure(name: str, setting: str, code, trailers) -> str:
|
|
211
|
+
"""The one-line error of a failed call: status, a hint, and the game's error code when the server sent one."""
|
|
212
|
+
status = code.name if code is not None else "no status"
|
|
213
|
+
hint = STATUS_HINT.get(code)
|
|
214
|
+
game = next((v for k, v in (trailers or ()) if k == ERROR_CODE_TRAILER), None)
|
|
215
|
+
extra = ", ".join(x for x in (hint, f"game error code {game}" if game else None, GAME_ERROR_HINT.get(game))
|
|
216
|
+
if x)
|
|
217
|
+
return f"game API call {name} to {setting} failed: {status}" + (f" ({extra})" if extra else "")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def fetch_master_version(root: str, client_version: str, *, timeout: float = TIMEOUT,
|
|
221
|
+
setting: str = "the API root") -> MasterVersion:
|
|
222
|
+
body = call(root, VERSION_METHOD, b"", client_version, timeout=timeout, setting=setting)
|
|
223
|
+
try:
|
|
224
|
+
v = parse_version(body)
|
|
225
|
+
except ValueError as e:
|
|
226
|
+
raise GameApiError(f"game API call Version to {setting}: malformed response ({e})") from None
|
|
227
|
+
if not v.version:
|
|
228
|
+
raise GameApiError(f"game API call Version to {setting}: no master data version in the response")
|
|
229
|
+
return v
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def fetch_server_list(root: str, client_version: str, *, timeout: float = TIMEOUT,
|
|
233
|
+
setting: str = "the bootstrap API root") -> list[Server]:
|
|
234
|
+
body = call(root, SERVER_LIST_METHOD, b"", client_version, timeout=timeout, setting=setting)
|
|
235
|
+
try:
|
|
236
|
+
servers = parse_server_list(body)
|
|
237
|
+
except ValueError as e:
|
|
238
|
+
raise GameApiError(f"game API call GetServerList to {setting}: malformed response ({e})") from None
|
|
239
|
+
if not servers:
|
|
240
|
+
raise GameApiError(f"game API call GetServerList to {setting}: empty server list")
|
|
241
|
+
return servers
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------- settings
|
|
245
|
+
def api_root(cfg: Config, section: str) -> str:
|
|
246
|
+
"""`[<section>] api`, checked (a ConfigError names the setting, not the value)."""
|
|
247
|
+
v = cfg.require(section, "api")
|
|
248
|
+
try:
|
|
249
|
+
channel_target(v)
|
|
250
|
+
except ValueError as e:
|
|
251
|
+
raise ConfigError(f"setting {section}.api: {e}") from None
|
|
252
|
+
return v
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def apk_version_name(apk: Path) -> str | None:
|
|
256
|
+
"""`versionName` of the APK's AndroidManifest.xml (None when the APK has none)."""
|
|
257
|
+
from .player import MANIFEST_IN_APK, manifest_version_name
|
|
258
|
+
try:
|
|
259
|
+
with zipfile.ZipFile(apk) as z:
|
|
260
|
+
data = z.read(MANIFEST_IN_APK) if MANIFEST_IN_APK in z.namelist() else None
|
|
261
|
+
except (zipfile.BadZipFile, OSError):
|
|
262
|
+
raise ConfigError(f"setting paths.apk: {apk} is not a readable APK") from None
|
|
263
|
+
return manifest_version_name(data) if data is not None else None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def client_version(cfg: Config) -> str:
|
|
267
|
+
"""The `x-client-version` of calls: `[client] version`, else the versionName of `[paths] apk`."""
|
|
268
|
+
v = cfg.get("client", "version")
|
|
269
|
+
what = "setting client.version"
|
|
270
|
+
if v is None:
|
|
271
|
+
apk = cfg.path("paths", "apk")
|
|
272
|
+
if apk is None:
|
|
273
|
+
raise ConfigError(f"the game API needs the client version: give it as {describe('client', 'version')}, "
|
|
274
|
+
f"or give base.apk as {describe('paths', 'apk', '--apk')} to use its versionName")
|
|
275
|
+
if not apk.is_file():
|
|
276
|
+
raise ConfigError(f"setting paths.apk: file {apk} not found")
|
|
277
|
+
v = apk_version_name(apk)
|
|
278
|
+
if not v:
|
|
279
|
+
raise ConfigError("the APK's AndroidManifest.xml has no versionName: give the client version as "
|
|
280
|
+
+ describe("client", "version"))
|
|
281
|
+
what = "the APK's versionName"
|
|
282
|
+
v = v.strip()
|
|
283
|
+
if not v or not v.isascii() or not v.isprintable():
|
|
284
|
+
raise ConfigError(f"{what}: must be printable ASCII")
|
|
285
|
+
return v
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def master_version(cfg: Config, region: str, *, timeout: float = TIMEOUT) -> MasterVersion:
|
|
289
|
+
"""The master data version region `region` serves now (its `[servers.<region>] api`)."""
|
|
290
|
+
section = f"servers.{region}"
|
|
291
|
+
root = api_root(cfg, section)
|
|
292
|
+
return fetch_master_version(root, client_version(cfg), timeout=timeout, setting=f"[{section}] api")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def server_list(cfg: Config, *, timeout: float = TIMEOUT) -> list[Server]:
|
|
296
|
+
"""The server list of `[bootstrap] api`."""
|
|
297
|
+
root = api_root(cfg, "bootstrap")
|
|
298
|
+
return fetch_server_list(root, client_version(cfg), timeout=timeout, setting="[bootstrap] api")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def configured_roots(cfg: Config) -> dict[str, set[str]]:
|
|
302
|
+
"""{region: its `cdn` and `api` settings, trailing `/` removed} of the configured regions."""
|
|
303
|
+
out = {}
|
|
304
|
+
for r in cfg.regions():
|
|
305
|
+
roots = {cfg.get(f"servers.{r}", k) for k in ("cdn", "api")}
|
|
306
|
+
out[r] = {s.strip().rstrip("/") for s in roots if s}
|
|
307
|
+
return out
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def server_summary(s: Server, configured: dict[str, set[str]], hosts: bool = False) -> dict:
|
|
311
|
+
"""A server list entry for printing: the configured region with one of its roots, root counts, and the roots
|
|
312
|
+
themselves only when `hosts`."""
|
|
313
|
+
region = next((r for r, roots in configured.items() if roots & {*s.cdn, *s.api}), None)
|
|
314
|
+
d = {"name": s.name, "displayName": s.display_name, "areaId": s.area_id, "region": region,
|
|
315
|
+
"cdnRoots": len(s.cdn), "apiRoots": len(s.api)}
|
|
316
|
+
if hosts:
|
|
317
|
+
d.update(cdn=list(s.cdn), api=list(s.api))
|
|
318
|
+
return d
|
nnnotes/jsonio.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""The one JSON writer of the extractors.
|
|
2
|
+
|
|
3
|
+
Every JSON file the extractors write goes through `write_json` (or `dumps` for JSON embedded
|
|
4
|
+
elsewhere): UTF-8, LF line endings, and non-finite floats as the out-of-range literals
|
|
5
|
+
`1e999` / `-1e999`, which are valid JSON and read back as +/-Infinity by JSON.parse and
|
|
6
|
+
Python's json.loads (the bare `Infinity` token Python writes by default is not JSON).
|
|
7
|
+
NaN has no such literal and raises, naming where it occurs.
|
|
8
|
+
|
|
9
|
+
Most documents have no non-finite float: they are encoded by json.dumps directly, and only a document that
|
|
10
|
+
json.dumps refuses (or whose text holds a placeholder) takes the walk that finds and replaces them.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
_POS, _NEG = "\x00nnnotes:+inf", "\x00nnnotes:-inf" # placeholders, replaced after encoding
|
|
19
|
+
_TOKENS = ((json.dumps(_POS), "1e999"), (json.dumps(_NEG), "-1e999"))
|
|
20
|
+
_MARK = json.dumps(_POS)[1:-len("+inf") - 1] # the placeholders' common prefix as JSON text
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _NaN(ValueError):
|
|
24
|
+
def __init__(self):
|
|
25
|
+
super().__init__()
|
|
26
|
+
self.path: list[str] = []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _finite(v, found: list):
|
|
30
|
+
"""`v` with every non-finite float replaced by a placeholder string; containers are copied only
|
|
31
|
+
where something changed, so the caller's document is never modified."""
|
|
32
|
+
if isinstance(v, float):
|
|
33
|
+
if math.isfinite(v):
|
|
34
|
+
return v
|
|
35
|
+
if v != v:
|
|
36
|
+
raise _NaN()
|
|
37
|
+
found.append(True)
|
|
38
|
+
return _POS if v > 0 else _NEG
|
|
39
|
+
if isinstance(v, dict):
|
|
40
|
+
out = None
|
|
41
|
+
for k, x in v.items():
|
|
42
|
+
if k == _POS or k == _NEG:
|
|
43
|
+
raise ValueError(f"key {k!r} collides with the non-finite placeholder")
|
|
44
|
+
try:
|
|
45
|
+
y = _finite(x, found)
|
|
46
|
+
except _NaN as e:
|
|
47
|
+
e.path.append(f"/{k}")
|
|
48
|
+
raise
|
|
49
|
+
if y is not x:
|
|
50
|
+
if out is None:
|
|
51
|
+
out = dict(v)
|
|
52
|
+
out[k] = y
|
|
53
|
+
return v if out is None else out
|
|
54
|
+
if isinstance(v, (list, tuple)):
|
|
55
|
+
out = None
|
|
56
|
+
for i, x in enumerate(v):
|
|
57
|
+
try:
|
|
58
|
+
y = _finite(x, found)
|
|
59
|
+
except _NaN as e:
|
|
60
|
+
e.path.append(f"[{i}]")
|
|
61
|
+
raise
|
|
62
|
+
if y is not x:
|
|
63
|
+
if out is None:
|
|
64
|
+
out = list(v)
|
|
65
|
+
out[i] = y
|
|
66
|
+
return v if out is None else out
|
|
67
|
+
if isinstance(v, str) and (v == _POS or v == _NEG):
|
|
68
|
+
raise ValueError(f"string {v!r} collides with the non-finite placeholder")
|
|
69
|
+
return v
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def dumps(obj, **kw) -> str:
|
|
73
|
+
"""json.dumps with non-finite floats as 1e999 / -1e999; raises ValueError on NaN (with its path).
|
|
74
|
+
Keyword arguments are json.dumps' (indent, ensure_ascii, separators, default, ...)."""
|
|
75
|
+
try:
|
|
76
|
+
s = json.dumps(obj, allow_nan=False, **kw)
|
|
77
|
+
except ValueError:
|
|
78
|
+
pass # a non-finite float (or NaN): the walk below
|
|
79
|
+
else:
|
|
80
|
+
if _MARK not in s:
|
|
81
|
+
return s # (a placeholder string in the data: the walk rejects it)
|
|
82
|
+
found: list = []
|
|
83
|
+
try:
|
|
84
|
+
clean = _finite(obj, found)
|
|
85
|
+
except _NaN as e:
|
|
86
|
+
raise ValueError(f"NaN at {''.join(reversed(e.path)) or '/'} (no JSON representation)") from None
|
|
87
|
+
s = json.dumps(clean, allow_nan=False, **kw)
|
|
88
|
+
if found:
|
|
89
|
+
for token, literal in _TOKENS:
|
|
90
|
+
s = s.replace(token, literal)
|
|
91
|
+
return s
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write_json(path, obj, *, indent: int | None = 1, ensure_ascii: bool = False, **kw) -> Path:
|
|
95
|
+
"""Write `obj` as JSON to `path` (UTF-8, LF); see `dumps`. Returns the path."""
|
|
96
|
+
path = Path(path)
|
|
97
|
+
path.write_text(dumps(obj, indent=indent, ensure_ascii=ensure_ascii, **kw), encoding="utf-8", newline="\n")
|
|
98
|
+
return path
|
nnnotes/languages.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The game's text languages: catalog language codes, Fwk.Localization.LanguageMode values and MasterText columns.
|
|
2
|
+
|
|
3
|
+
A catalog is `catalog_main_<code>.bin`; the client's LanguageMode picks the column of the text tables
|
|
4
|
+
(MasterLoader.GetLocalizedText) and the fonts of LocalizeManager. Every region serves every language.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .config import Config, ConfigError, active
|
|
9
|
+
|
|
10
|
+
# code -> (Fwk.Localization.LanguageMode, text table column)
|
|
11
|
+
LANGUAGES = {
|
|
12
|
+
"ja": (0, "_japanese"),
|
|
13
|
+
"en": (1, "_english"),
|
|
14
|
+
"zh-Hant": (2, "_traditionalChinese"),
|
|
15
|
+
"zh-Hans": (3, "_simplifiedChinese"),
|
|
16
|
+
"ko": (4, "_korean"),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def check(code: str) -> str:
|
|
21
|
+
"""`code` when it is one of LANGUAGES, else a ConfigError naming the setting (not the value)."""
|
|
22
|
+
if code not in LANGUAGES:
|
|
23
|
+
raise ConfigError(f"setting catalog.language: not one of the game's languages ({', '.join(LANGUAGES)})")
|
|
24
|
+
return code
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def configured() -> str:
|
|
28
|
+
"""`[catalog] language` of the process's settings (config.use; without them, of the environment), checked."""
|
|
29
|
+
return check((active() or Config()).require("catalog", "language"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def mode(code: str) -> int:
|
|
33
|
+
"""The LanguageMode of a language code."""
|
|
34
|
+
return LANGUAGES[check(code)][0]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def column(code: str) -> str:
|
|
38
|
+
"""The text table column of a language code (`_traditionalChinese` for zh-Hant)."""
|
|
39
|
+
return LANGUAGES[check(code)][1]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def texts(row: dict | None) -> dict:
|
|
43
|
+
"""{code: text} of a text table row in every language (None for a column the row lacks)."""
|
|
44
|
+
return {code: (row or {}).get(col) for code, (_, col) in LANGUAGES.items()}
|