SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
gen_from_paramdex.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Regenerate the DS1 and DS3 id->name tables in db_*/ from soulsmods/Paramdex.
3
4Elden Ring and Sekiro are NOT here: both are read out of the installed game instead,
5which beat this source measurably in both cases. See the notes in PLAN below.
6
7Pinned source: soulsmods/Paramdex @ ff7245e524329bc3eab00036723d2bd53384cedf
8(2026-03-06) -- the commit that carries Elden Ring through Shadow of the Erdtree.
9
10It is idempotent and non-destructive: an existing hand-disambiguated name always wins
11on an id collision, so running it can add rows and can never silently rewrite a
12name a human decided on.
13
14 git clone --depth 1 --filter=blob:none --sparse \
15 https://github.com/soulsmods/Paramdex.git /tmp/Paramdex
16 cd /tmp/Paramdex && git sparse-checkout set DS1 DS1R DS3
17 python3 tools/gen_from_paramdex.py --paramdex /tmp/Paramdex
18
19 --dry-run report the delta, write nothing
20 --only ds1 restrict to one game key (ds1, ds3)
21
22DS2 is deliberately absent: its ids are little-endian save bytes, not param ids,
23and its tables are already complete from the SOTFS Hex Code Compendium. Paramdex
24DS2S cannot be mapped onto them and must not be imported.
25"""
26
27import argparse
28import json
29import os
30import re
31import sys
32
33# --- id-space geometry -------------------------------------------------------
34# A save stores an inventory id as category_prefix | param_id. The prefix is what
35# separates two items that share a param id in different tables.
36W, P, A, G, GEM = 0x00000000, 0x10000000, 0x20000000, 0x40000000, 0x80000000
37
38# Rows that are engine scaffolding, not items a save can hold. Anything matching
39# is dropped before merge. Kept deliberately tight: a bad filter here silently
40# deletes real items, which is worse than carrying a few dev rows.
41JUNK = re.compile(
42 r"^\s*$"
43 r"|\[Unused\]|%null%|^Dummy|^dummy"
44 r"|^test |^Test |test gem|TestData|ID Monitoring|ID monitoring"
45 r"|^Type \d+$|^Unarmed$"
46 r"|^\{|^-$|^NoName|^\‍(dummy",
47)
48# Sekiro's Paramdex rows carry the Japanese dev name after a double dash. Those
49# are dev tables, not localised names, and must never reach a report.
50DEVNAME = re.compile(r" -- ")
51
52
53## Hand corrections applied last, after the merge. Small by design: if this map
54# grows past a dozen entries the upstream table is the thing to fix.
55NAME_FIXUPS = {
56 "Havel's ring +3": "Havel's Ring+3", # lowercase/space drift on one variant
57}
58
59
60def read_names(paramdex, game, stem):
61 """Paramdex Names/*.txt -> {int id: name}. Files are '<id> <name>' per line."""
62 path = os.path.join(paramdex, game, "Names", stem + ".txt")
63 if not os.path.exists(path):
64 return {}
65 out = {}
66 with open(path, encoding="utf-8") as fh:
67 for line in fh:
68 line = line.strip()
69 if not line:
70 continue
71 ident, _, name = line.partition(" ")
72 if not ident.lstrip("-").isdigit():
73 continue
74 name = name.strip()
75 if not name or JUNK.search(name) or DEVNAME.search(name):
76 continue
77 out[int(ident)] = name
78 return out
79
80
81# --- table plan --------------------------------------------------------------
82# (game_key, db_dir, shape, [(out_stem, paramdex_dir, param_stem, prefix, filter)])
83# A stem listed in STRICT treats Paramdex as the closed set: shipped ids Paramdex
84# has never heard of are evicted. Only for tables where cut/foreign rows are known
85# to have crept in — db_ds3/rings.json carried DS1 and DS2 rings.
86#
87# shape "name_str" -> {"Name": "12345"} DS1
88# shape "name_int" -> {"Name": 12345} DS3
89# shape "hex_name" -> {"0000ABCD": "Name"} ER
90# shape "dec_name" -> {"12345": "Name"} Sekiro
91
92
93# --- per-table id gates ------------------------------------------------------
94# Each gate exists because two param tables share one inventory prefix, or
95# because a range is deliberately not reported. Never widen one without saying
96# which collision it was guarding.
97
98
99def ds1_spells(i, _n):
100 """DS1 stores sorceries/miracles/pyromancies as ordinary goods, 3000-8999.
101 9000-9014 is the gesture block, which is a good and stays one — filing a
102 gesture under a "Spells" heading would be a worse lie than no heading."""
103 return 3000 <= i < 9000
104
105
106def ds1_goods(i, _n):
107 return not ds1_spells(i, _n)
108
109
110## @brief DS3 flask ids, in level pairs (full, drained). ds3_resolve_estus computes
111# the level from the id, so a table row would only ever disagree with it about the
112# spelling — and would shadow it, since a direct hit wins.
113DS3_ESTUS = ((150, 171), (190, 211))
114
115
116def ds3_goods(i, _n):
117 """Goods and spells both sit behind 0x40000000 in a DS3 save, so anything at
118 or above the Magic id floor belongs to db_ds3/spells.json, not here. 9000-
119 9099 is the gesture block: gestures are not reported (they scatter off-grid
120 flag hits and cost real items to acquire)."""
121 if any(lo <= i <= hi for lo, hi in DS3_ESTUS):
122 return False
123 return i < 1200000 and not (9000 <= i < 9100)
124
125
126def ds3_weapons(i, _n):
127 """DS3's inventory is found by scanning every byte of the slot for a known id,
128 so a junk row is not free: a dev id matching off the 16-byte record grid splits
129 a run and takes real items with it (a mule lost four items to exactly this).
130 Ammunition sits at 400000-409999 and real armaments at 1000000-29999999;
131 everything Paramdex lists outside those is (Debug)/Test-/Ghost scaffolding, or
132 the sub-10000 thrown-item rows whose small ids match constantly."""
133 return 400000 <= i <= 409999 or 1000000 <= i <= 29999999
134
135
136def ds3_armors(i, _n):
137 """Real DS3 protector ids start at 19000000. Everything below 10^7 in
138 Paramdex is the cut DS1-legacy block (Armor of Favor, Stone Armor) that no
139 DS3 save can hold."""
140 return i >= 10000000
141
142
143def ds3_rings(i, _n):
144 """EquipParamAccessory 10000-19999 is the covenant-badge block, which is
145 rendered from db_ds3/covenants.json. Real rings start at 20000."""
146 return i >= 20000
147
148
149STRICT = {"db_ds3/rings"}
150
151## Tables whose gate also applies to what is ALREADY shipped. Everywhere else a gate
152# filters Paramdex only, because a shipped id is one a real save was seen to hold —
153# db_ds3/weapons carries Torch (90000) and Fists (110000), which the weapon gate
154# rejects and which the equipped-weapon read depends on. Pruning is for the DS1
155# goods/spells split (rows must MOVE, not duplicate) and the ring eviction.
156PRUNE_EXISTING = {"db_ds1/Consumables", "db_ds1/Spells", "db_ds3/rings"}
157
158PLAN = [
159 (
160 "ds1",
161 "db_ds1",
162 "name_str",
163 [
164 # DS1R and DS1 PtDE share an id space; DS1R is the superset. The audit
165 # believed spells were unshipped — they are present, but folded into
166 # Consumables under a "Sorcery:/Pyromancy:/Miracle:" prefix. Splitting
167 # them out is what gives DS1 a spells heading like DS2/DS3 have.
168 # Order matters: Consumables runs first and evicts the spell ids, which
169 # Spells then adopts *with the names already on them* ("Sorcery: Soul
170 # Arrow"), rather than taking Paramdex's bare "Soul Arrow".
171 ("Consumables", "DS1R", "EquipParamGoods", 0, ds1_goods),
172 ("Spells", "DS1R", "EquipParamGoods", 0, ds1_spells),
173 ("MeleeWeapons", "DS1R", "EquipParamWeapon", 0, None),
174 ("Armor", "DS1R", "EquipParamProtector", 0, None),
175 ("Rings", "DS1R", "EquipParamAccessory", 0, None),
176 ],
177 ),
178 (
179 "ds3",
180 "db_ds3",
181 "name_int",
182 [
183 ("goods", "DS3", "EquipParamGoods", G, ds3_goods),
184 ("rings", "DS3", "EquipParamAccessory", A, ds3_rings),
185 ("weapons", "DS3", "EquipParamWeapon", W, ds3_weapons),
186 ("armors", "DS3", "EquipParamProtector", P, ds3_armors),
187 ("spells", "DS3", "Magic", G, None),
188 ],
189 ),
190 # ELDEN RING WAS HERE AND IS DELIBERATELY GONE. db_er/ is generated from the
191 # game's own msg/engus FMGs now, by `tools/gamefiles.py ernames`, and putting
192 # Paramdex back in front of it would undo a measured result with a transcribed
193 # one. The measurement: against the shipped FMGs the Paramdex-derived tables had
194 # 83 names wrong outright and 35 more with the diacritics stripped, and ashes.json
195 # carried 239 rows between 120 distinct names -- sixteen ids all reading "Ash of
196 # War: Lion's Claw" -- because the source forward-filled every row the game leaves
197 # blank. Merging here cannot rewrite an existing name, but it CAN re-add those
198 # blank rows, which is the same damage by a slower route. Same rule as Sekiro
199 # below: one table, one generator.
200 # Sekiro is deliberately not in this plan, and is handled by
201 # tools/gen_sdt_from_regulation.py instead. The reason is the same one that
202 # generator exists for: Paramdex SDT/Names carries machine-translated dev rows
203 # only ("Molotov cocktail -- 火炎瓶"), and Sekiro's shipped English names live in
204 # the game's own msg/engus FMGs, not in Paramdex at all. db_sdt/ was built from a
205 # cleaned pass and is complete for what a report prints; Sekiro's remaining gaps
206 # are save-layout, not names. Do not add SDT here — two generators writing the
207 # same tables from different sources is how the two disagree.
208]
209
210
211def load_existing(path, shape):
212 """Read a shipped table into {int id: name}, whatever shape it is on disk."""
213 if not os.path.exists(path):
214 return {}, None
215 raw = json.load(open(path, encoding="utf-8"))
216 if shape in ("name_str", "name_int"):
217 # A name-keyed value may be a LIST: one name, several ids (see dump).
218 out = {}
219 for name, value in raw.items():
220 for i in value if isinstance(value, list) else [value]:
221 out[int(i)] = name
222 return out, raw
223 if shape == "hex_name":
224 return {int(k, 16): v for k, v in raw.items()}, raw
225 return {int(k): v for k, v in raw.items()}, raw
226
227
228def dump(table, shape):
229 """{int id: name} -> the on-disk shape, id-sorted for a stable diff.
230
231 The name-keyed shapes must hold a name that owns SEVERAL ids — the games ship
232 one, repeatedly (a "Cinders of a Lord" per lord, a DS1 base row beside its
233 alternate-path twin). Writing one key per name drops all but the last, which is
234 how three real DS3 goods and two DS1 weapons went missing on the first import.
235 So a name with several ids gets a list, and the loaders read either form.
236 """
237 items = sorted(table.items())
238 if shape in ("name_str", "name_int"):
239 cast = str if shape == "name_str" else int
240 byname = {}
241 for i, n in items:
242 byname.setdefault(n, []).append(cast(i))
243 return {n: (v[0] if len(v) == 1 else v) for n, v in byname.items()}
244 if shape == "hex_name":
245 return {f"{i:08X}": n for i, n in items}
246 return {str(i): n for i, n in items}
247
248
249def main():
250 ap = argparse.ArgumentParser()
251 ap.add_argument("--paramdex", required=True)
252 ap.add_argument(
253 "--repo", default=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
254 )
255 ap.add_argument("--only")
256 ap.add_argument("--dry-run", action="store_true")
257 args = ap.parse_args()
258
259 # Names a split moves out of one table and into another, keyed by id.
260 carried = {}
261 total_added = 0
262 for key, dbdir, shape, tables in PLAN:
263 if args.only and args.only != key:
264 continue
265 for stem, pgame, pstem, prefix, filt in tables:
266 src = read_names(args.paramdex, pgame, pstem)
267 if not src:
268 print(
269 f" !! no Paramdex rows for {pgame}/{pstem}",
270 file=sys.stderr,
271 )
272 continue
273 if filt:
274 src = {i: n for i, n in src.items() if filt(i, n)}
275 src = {prefix | i: n for i, n in src.items()}
276
277 key_name = dbdir + "/" + stem
278 path = os.path.join(args.repo, dbdir, stem + ".json")
279 have, _raw = load_existing(path, shape)
280 if filt and key_name in PRUNE_EXISTING:
281 # Prune existing rows the gate rejects. This is what makes the
282 # DS1 Consumables/Spells split move rows rather than duplicate
283 # them, and what evicts the cut non-DS3 rings.
284 keep, drop = {}, {}
285 for i, n in have.items():
286 (keep if filt(i & ~prefix, n) else drop)[i] = n
287 have = keep
288 carried.update(drop)
289
290 if key_name in STRICT:
291 evicted = {i: n for i, n in have.items() if i not in src}
292 if evicted:
293 print(
294 " evicted (not in Paramdex): {}".format(
295 ", ".join(sorted(evicted.values()))
296 )
297 )
298 have = {i: n for i, n in have.items() if i in src}
299
300 merged = dict(src)
301 merged.update({i: n for i, n in carried.items() if i in src})
302 merged.update(have) # hand-disambiguated name wins
303 merged = {i: NAME_FIXUPS.get(n, n) for i, n in merged.items()}
304 added = len(merged) - len(have)
305 total_added += added
306 print(
307 f"{dbdir + '/' + stem:<24} {len(have):5d} -> {len(merged):5d} (+{added})"
308 )
309 if not args.dry_run:
310 os.makedirs(os.path.dirname(path), exist_ok=True)
311 with open(path, "w", encoding="utf-8") as fh:
312 json.dump(dump(merged, shape), fh, ensure_ascii=False, indent=1)
313 fh.write("\n")
314 print(
315 f"\ntotal rows added: {total_added}"
316 + (" (dry run, nothing written)" if args.dry_run else "")
317 )
318
319
320if __name__ == "__main__":
321 main()
read_names(paramdex, game, stem)