2"""Regenerate the DS1 and DS3 id->name tables in db_*/ from soulsmods/Paramdex.
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.
7Pinned source: soulsmods/Paramdex @ ff7245e524329bc3eab00036723d2bd53384cedf
8(2026-03-06) -- the commit that carries Elden Ring through Shadow of the Erdtree.
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.
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
19 --dry-run report the delta, write nothing
20 --only ds1 restrict to one game key (ds1, ds3)
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.
36W, P, A, G, GEM = 0x00000000, 0x10000000, 0x20000000, 0x40000000, 0x80000000
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",
50DEVNAME = re.compile(
r" -- ")
56 "Havel's ring +3":
"Havel's Ring+3",
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):
66 with open(path, encoding=
"utf-8")
as fh:
71 ident, _, name = line.partition(
" ")
72 if not ident.lstrip(
"-").isdigit():
75 if not name
or JUNK.search(name)
or DEVNAME.search(name):
77 out[int(ident)] = name
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
113DS3_ESTUS = ((150, 171), (190, 211))
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):
123 return i < 1200000
and not (9000 <= i < 9100)
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
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."""
144 """EquipParamAccessory 10000-19999 is the covenant-badge block, which is
145 rendered from db_ds3/covenants.json. Real rings start at 20000."""
149STRICT = {
"db_ds3/rings"}
156PRUNE_EXISTING = {
"db_ds1/Consumables",
"db_ds1/Spells",
"db_ds3/rings"}
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),
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),
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):
215 raw = json.load(open(path, encoding=
"utf-8"))
216 if shape
in (
"name_str",
"name_int"):
219 for name, value
in raw.items():
220 for i
in value
if isinstance(value, list)
else [value]:
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
228def dump(table, shape):
229 """{int id: name} -> the on-disk shape, id-sorted for a stable diff.
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.
237 items = sorted(table.items())
238 if shape
in (
"name_str",
"name_int"):
239 cast = str
if shape ==
"name_str" else int
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}
250 ap = argparse.ArgumentParser()
251 ap.add_argument(
"--paramdex", required=
True)
253 "--repo", default=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
255 ap.add_argument(
"--only")
256 ap.add_argument(
"--dry-run", action=
"store_true")
257 args = ap.parse_args()
262 for key, dbdir, shape, tables
in PLAN:
263 if args.only
and args.only != key:
265 for stem, pgame, pstem, prefix, filt
in tables:
269 f
" !! no Paramdex rows for {pgame}/{pstem}",
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()}
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:
285 for i, n
in have.items():
286 (keep
if filt(i & ~prefix, n)
else drop)[i] = n
290 if key_name
in STRICT:
291 evicted = {i: n
for i, n
in have.items()
if i
not in src}
294 " evicted (not in Paramdex): {}".format(
295 ", ".join(sorted(evicted.values()))
298 have = {i: n
for i, n
in have.items()
if i
in src}
301 merged.update({i: n
for i, n
in carried.items()
if i
in src})
303 merged = {i: NAME_FIXUPS.get(n, n)
for i, n
in merged.items()}
304 added = len(merged) - len(have)
307 f
"{dbdir + '/' + stem:<24} {len(have):5d} -> {len(merged):5d} (+{added})"
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)
315 f
"\ntotal rows added: {total_added}"
316 + (
" (dry run, nothing written)" if args.dry_run
else "")
320if __name__ ==
"__main__":
read_names(paramdex, game, stem)