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.
Files changed (79) hide show
  1. nnnotes/__init__.py +7 -0
  2. nnnotes/__main__.py +4 -0
  3. nnnotes/acb.py +190 -0
  4. nnnotes/addressables.py +413 -0
  5. nnnotes/adv.py +243 -0
  6. nnnotes/advcommand.py +29 -0
  7. nnnotes/advmedia.py +178 -0
  8. nnnotes/advscene.py +113 -0
  9. nnnotes/advui.py +1154 -0
  10. nnnotes/advvideo.py +684 -0
  11. nnnotes/atoms/__init__.py +151 -0
  12. nnnotes/atoms/astc.py +52 -0
  13. nnnotes/atoms/gltf.py +290 -0
  14. nnnotes/atoms/mesh.py +289 -0
  15. nnnotes/atoms/png.py +28 -0
  16. nnnotes/atoms/reader.py +269 -0
  17. nnnotes/atoms/sniff.py +198 -0
  18. nnnotes/atoms/sprite.py +236 -0
  19. nnnotes/atoms/texture.py +80 -0
  20. nnnotes/cache.py +289 -0
  21. nnnotes/catalog.py +373 -0
  22. nnnotes/catalogdb.py +457 -0
  23. nnnotes/census.py +276 -0
  24. nnnotes/cli.py +744 -0
  25. nnnotes/cli_assets.py +1539 -0
  26. nnnotes/config.py +238 -0
  27. nnnotes/contract.py +497 -0
  28. nnnotes/cri.py +371 -0
  29. nnnotes/crikey.py +71 -0
  30. nnnotes/crilips.py +265 -0
  31. nnnotes/cristages.py +558 -0
  32. nnnotes/deckdata.py +462 -0
  33. nnnotes/export.py +1462 -0
  34. nnnotes/gameapi.py +318 -0
  35. nnnotes/jsonio.py +98 -0
  36. nnnotes/languages.py +44 -0
  37. nnnotes/layout.py +596 -0
  38. nnnotes/link.py +418 -0
  39. nnnotes/live.py +115 -0
  40. nnnotes/live2d.py +235 -0
  41. nnnotes/liveaudio.py +331 -0
  42. nnnotes/livenotes.py +291 -0
  43. nnnotes/liveoptions.py +250 -0
  44. nnnotes/livescene.py +450 -0
  45. nnnotes/liveui.py +328 -0
  46. nnnotes/master.py +251 -0
  47. nnnotes/modelstages.py +424 -0
  48. nnnotes/motion.py +131 -0
  49. nnnotes/objexport.py +1564 -0
  50. nnnotes/orchestrate.py +630 -0
  51. nnnotes/plan.py +218 -0
  52. nnnotes/player.py +392 -0
  53. nnnotes/room.py +391 -0
  54. nnnotes/score.py +1139 -0
  55. nnnotes/shader.py +217 -0
  56. nnnotes/spot.py +201 -0
  57. nnnotes/stages.py +285 -0
  58. nnnotes/store.py +518 -0
  59. nnnotes/story.py +142 -0
  60. nnnotes/storyfonts.py +1258 -0
  61. nnnotes/storyhost.py +1190 -0
  62. nnnotes/storysite.py +662 -0
  63. nnnotes/storyvoices.py +102 -0
  64. nnnotes/textstyle.py +355 -0
  65. nnnotes/tmpfont.py +636 -0
  66. nnnotes/typetrees/6000.3.12f1.json +19 -0
  67. nnnotes/unity.py +368 -0
  68. nnnotes/viewrules.json +441 -0
  69. nnnotes/views.py +966 -0
  70. nnnotes/voices.py +1363 -0
  71. nnnotes/web.py +1624 -0
  72. nnnotes/webaudio.py +14 -0
  73. nnnotes/webmodel.py +397 -0
  74. nnnotes-0.1.0.dist-info/METADATA +200 -0
  75. nnnotes-0.1.0.dist-info/RECORD +79 -0
  76. nnnotes-0.1.0.dist-info/WHEEL +5 -0
  77. nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
  78. nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
  79. 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()}