SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
jsonout.py
Go to the documentation of this file.
1"""JSON output: the same parsed save the Markdown writer gets, as a machine-readable
2document against a published schema.
3
4The Markdown is written for a person (or an LLM) to read; this is written for a
5program. Both start from `parse_save`, so a field can never say one thing in one
6format and something else in the other.
7
8Two rules shape the document. Nothing is invented to fill a hole — a field the save
9does not carry is simply absent, exactly as in the Markdown, so a consumer can tell
10"not in this game" from "zero". And nothing about the machine that produced it is
11guessed: the environment block holds only what the caller passed on the command line.
12"""
13
14import json
15import os
16from collections import OrderedDict
17from datetime import datetime, timezone
18
19from validators import run_validation
20from validators.file_rules import run_file_validation
21
22from .bnd4 import checksum_ok, parse_bnd4
23from .convert import REPO_URL
24
25## @brief Where the schema is published. Static file at the site root, so a consumer
26# can resolve it without cloning anything.
27SCHEMA_URL = "https://sl2-analyzer.darthdemono.com/schema.json"
28
29## @brief Schema version, semver. MINOR for a new optional field, MAJOR for anything
30# that would break a reader which trusted the previous shape.
31SCHEMA_VERSION = "1.3.0"
32
33## @brief Environment keys the schema names explicitly. Any other key is still
34# accepted and written through — this list is what gets documented and type-checked,
35# not a whitelist. The point of the block is that a save alone cannot tell you which
36# store sold the game, which patch it ran, or what it ran under.
37KNOWN_META = (
38 "source",
39 "version",
40 "dlc",
41 "os",
42 "launcher",
43 "proton",
44 "gamemode",
45 "mangohud",
46 "notes",
47)
48
49
50##
51# @brief Normalise a CLI metadata key: lowercase, spaces and dashes to underscores.
52# @details "Proton version" and "proton-version" are the same key. The value is left
53# exactly as typed — only the key is canonicalised, because the key is what a
54# consumer looks up.
55# @param key Raw key as typed. @return The canonical form.
56def meta_key(key):
57 return key.strip().lower().replace(" ", "_").replace("-", "_")
58
59
60##
61# @brief Build the environment block from repeated `--meta key=value` arguments and
62# an optional JSON file.
63# @details A key given more than once becomes a LIST, in the order given — that is how
64# `--meta dlc=X --meta dlc=Y` says two DLCs, with no comma-splitting guesswork (item
65# and boss names are full of commas, so splitting on one would be a bug waiting).
66# The JSON file is merged first so an explicit `--meta` on the command line wins.
67# @param pairs List of "key=value" strings, or None.
68# @param path Path to a JSON object to merge underneath, or None.
69# @return An OrderedDict, empty when nothing was passed.
70# @throws ValueError on an argument with no "=", or a JSON file that is not an object.
71def parse_meta(pairs, path=None):
72 meta = OrderedDict()
73 if path:
74 with open(path, encoding="utf-8") as f:
75 loaded = json.load(f)
76 if not isinstance(loaded, dict):
77 raise ValueError(f"{path}: expected a JSON object at the top level")
78 for k, v in loaded.items():
79 meta[meta_key(k)] = v
80 for pair in pairs or []:
81 if "=" not in pair:
82 raise ValueError(f"--meta expects key=value, got {pair!r}")
83 key, value = pair.split("=", 1)
84 key, value = meta_key(key), value.strip()
85 if key in meta:
86 # Repeat means "and also", so the first repeat turns the value into a list.
87 meta[key] = (meta[key] if isinstance(meta[key], list) else [meta[key]]) + [
88 value
89 ]
90 else:
91 meta[key] = value
92 return meta
93
94
95##
96# @brief Make a parsed value safe for json.dump, without changing what it says.
97# @details The parser uses sets for boss evidence and tuples for fixed-shape rows;
98# both become lists, and a set is sorted so two runs of the same save produce the
99# same bytes. Everything else passes through untouched.
100def jsonable(value):
101 if isinstance(value, dict):
102 return OrderedDict((k, jsonable(v)) for k, v in value.items())
103 if isinstance(value, set):
104 return sorted(value)
105 if isinstance(value, (list, tuple)):
106 return [jsonable(v) for v in value]
107 return value
108
109
110##
111# @brief One character as a JSON object: its slot number, then every field the parse
112# actually set.
113# @details Absent stays absent. The keys are the parser's own, which is deliberate —
114# the Markdown, the web app and this document all name a field the same thing.
115# @param slot_no 1-based slot number. @param ch The character dict.
116# @param report A validation @ref validators.models.Report, or None when the caller
117# did not ask for one. It is emitted whole — findings, the rule count and the
118# unimplemented list — because a consumer reading "no findings" is entitled to
119# the same caveat the Markdown prints.
120def character_json(slot_no, ch, report=None):
121 out = OrderedDict([("slot", slot_no)])
122 for key, value in ch.items():
123 if value is None or value == [] or value == {}:
124 continue
125 out[key] = jsonable(value)
126 if report is not None:
127 out["validation"] = report.as_dict()
128 return out
129
130
131##
132# @brief Build the whole JSON document for a parsed save.
133# @param save A @ref sl2.convert.SaveData.
134# @param filename The source filename, recorded so an export can be traced back.
135# @param meta The environment block from @ref parse_meta, or None.
136# @param validate Attach each character's validation report (off by default).
137# @param data The file bytes, when the caller has them: the file-level rules read
138# the container rather than a character.
139# @return A dict ready for json.dump.
140def build_json(save, filename, meta=None, validate=False, data=None):
141 cfg = save.cfg
142 source = OrderedDict(
143 [
144 ("filename", os.path.basename(filename)),
145 ("game", save.game),
146 ("game_title", cfg["title"]),
147 ("support_tier", cfg["tier"]),
148 ]
149 )
150 # What the tier does NOT cover, where the game has a gap a reader would otherwise
151 # not guess from the word "full". Only Sekiro has one today.
152 if cfg.get("coverage"):
153 source["support_tier_coverage"] = cfg["coverage"]
154 # Both are properties of the FILE, not of any character, and both are frequently
155 # absent — DS2 has no version word, and only ER carries a regulation version.
156 if save.version is not None:
157 source["save_format_version"] = save.version
158 if save.patch is not None:
159 source["game_patch"] = save.patch
160 # The owning Steam account, where the game records one. The account id is a plain
161 # uint32, but the SteamID64 is emitted as TEXT on purpose: it is larger than the
162 # integer a JSON parser is obliged to represent exactly, and a consumer reading it
163 # into a double would silently lose the last digits.
164 if save.owner is not None:
165 source["steam_account_id"] = save.owner[0]
166 source["steam_id64"] = save.owner[1]
167 if save.folder is not None:
168 source["save_folder"] = save.folder
169
170 doc = OrderedDict(
171 [
172 ("$schema", SCHEMA_URL),
173 ("schema_version", SCHEMA_VERSION),
174 ("generated", datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")),
175 ("tool", OrderedDict([("name", "sl2-analyzer"), ("url", REPO_URL)])),
176 ("source", source),
177 ]
178 )
179 if meta:
180 doc["environment"] = OrderedDict(meta)
181 doc["characters"] = [
183 i - cfg["slots"].start + 1,
184 ch,
185 run_validation(ch, save.game) if validate else None,
186 )
187 for i, ch in save.characters
188 ]
189 if validate and data is not None:
190 entries = parse_bnd4(data) or []
191 doc["file_validation"] = run_file_validation(
192 save.game, entries, data, checksum_ok
193 ).as_dict()
194 return doc
character_json(slot_no, ch, report=None)
One character as a JSON object: its slot number, then every field the parse actually set.
Definition jsonout.py:120
jsonable(value)
Make a parsed value safe for json.dump, without changing what it says.
Definition jsonout.py:100
meta_key(key)
Normalise a CLI metadata key: lowercase, spaces and dashes to underscores.
Definition jsonout.py:56