3gen_sdt_from_regulation.py — extract Sekiro (SDT) param values, event flag ids and
4shipped English display names from a local Sekiro install into db_sdt/ tables.
6NAME CORRECTION — READ FIRST
7 This file is called gen_sdt_from_REGULATION, and Sekiro has no regulation.bin.
8 Not loose and not inside the archives: that is a DS3/Elden Ring file. Sekiro's
9 params are /param/gameparam/gameparam.parambnd.dcx. The name is kept only because
10 renaming it would break the /sekiro-data command that calls it; read every mention
11 of "regulation.bin" below as that path instead. See CLAUDE.md and the memory
12 sekiro-regulation-unpack for the unpack route (UXM-Selective-Unpack has the Sekiro
13 RSA keys; BinderTool ships DS3's and will NOT open these archives unmodified).
16 Paramdex ships PARAMDEFs (field layouts) and Names (id -> annotation) but NOT
17 param VALUES. Every event flag id the analyzer needs lives in the game's own
18 param binder. Paramdex SDT/Names are also machine-translated Japanese dev
19 strings ("Rough temple", "Protagonist_arm_prosthesis") — the shipped English
20 names live in msg/engus/*.msgbnd.dcx as FMG entries. So: defs from Paramdex,
21 values from the param binder, display names from FMG.
23FORMAT NOTES (reconciled against JKAnderson/SoulsFormats)
24 - Sekiro's regulation.bin is NOT encrypted. SoulsFormats has
25 DecryptDS3Regulation / DecryptERRegulation / DecryptAC6Regulation and no
26 Sekiro equivalent; SDT regulation is a plain DCX-wrapped BND4.
27 - PARAM header: Format2D lives at 0x2C (legacy name, real offset 0x2C).
28 LongDataOffset (0x04) -> 24-byte row entries; OffsetParamType (0x80) ->
29 param type is a string at an offset rather than inline.
30 - FMG: Sekiro uses the DarkSouls3 ("wide") variant — 64-bit string offsets.
33 python3 tools/gen_sdt_from_regulation.py \
34 --game-root "/path/to/Sekiro" \
35 --paramdex "/path/to/Paramdex" \
37 --report "db_sdt/_extract_report.md" \
41 0 ok · 2 bad input/paths · 3 parse failure · 4 acceptance gate failed (--strict)
44from __future__
import annotations
50import xml.etree.ElementTree
as ET
52from dataclasses
import dataclass
53from dataclasses
import field
as dc_field
54from pathlib
import Path
64TARGETS: dict[str, dict] = {
78 "name_fmg": (
"menuTextId",
"menu"),
79 "purpose":
"Sculptor's Idol discovery flags",
91 + [f
"EventFlagId{i}" for i
in range(1, 11)]
92 + [f
"EventFlagManByte{i}" for i
in range(1, 11)]
95 "purpose":
"boss/miniboss roster + gating flags",
100 "out":
"objact_flags.tsv",
101 "fields": [
"spQualifiedPassEventFlag"],
103 "purpose":
"door / shortcut activation flags",
108 "fields": [
"unlockEventFlag",
"virtualWeaponId",
"acquireWeaponId"],
110 "purpose":
"skill / combat-art unlock flags",
114 "out":
"item_flags.tsv",
115 "fields": [f
"lotItemId{i:02d}" for i
in range(1, 9)]
116 + [f
"getItemFlagId{i:02d}" for i
in range(1, 9)]
117 + [
"getItemFlagId",
"cumulateNumFlagId"],
119 "purpose":
"item pickup flags",
123 "out":
"shop_flags.tsv",
124 "fields": [
"equipId",
"mtrlId",
"eventFlag",
"flagId_forRelease"],
126 "purpose":
"shop unlock / sold-out flags",
132 "GameProgressParam": {
133 "out":
"progress.tsv",
134 "fields": [
"eventFlagId",
"progressValue"],
136 "purpose":
"main-progression milestone flags (names untrusted)",
140 "out":
"boss_areas.tsv",
143 "purpose":
"boss entity ids (join key for RematchWarpParam)",
148 "fields": [
"goodsType",
"goodsUseAnim",
"maxNum"],
149 "name_fmg": (
"__row_id__",
"item"),
150 "purpose":
"consumables / key items / materials",
152 "EquipParamWeapon": {
153 "out":
"weapons.tsv",
155 "name_fmg": (
"__row_id__",
"item"),
156 "purpose":
"weapons + prosthetic tools",
165 "item": [
"item.msgbnd.dcx",
"item_dlc1.msgbnd.dcx",
"item_dlc2.msgbnd.dcx"],
166 "menu": [
"menu.msgbnd.dcx",
"menu_dlc1.msgbnd.dcx",
"menu_dlc2.msgbnd.dcx"],
170 r"(^\s*$|^\[?(DUMMY|dummy|ダミー|test|テスト)|^%null%|"
171 r"Protagonist[_ ]|主人公|Original Memory|不明|未使用|使用しない|"
182def dcx_decompress(data: bytes) -> bytes:
183 """Unwrap a DCX container. Sekiro uses DFLT (raw zlib)."""
184 if data[:4] != b
"DCX\0":
186 dca = data.find(b
"DCA\0")
188 raise ValueError(
"DCX: no DCA block")
189 (dca_size,) = struct.unpack_from(
">i", data, dca + 4)
190 payload = data[dca + dca_size :]
192 return zlib.decompress(payload)
196 for i
in range(len(payload) - 2):
197 if payload[i] == 0x78
and payload[i + 1]
in (0x01, 0x9C, 0xDA, 0x5E):
199 return zlib.decompress(payload[i:])
202 raise ValueError(
"DCX: could not inflate (Oodle/KRAK payload?)")
from None
217def read_bnd4(data: bytes) -> list[BinderFile]:
219 Minimal BND4 reader — enough for regulation.bin and *.msgbnd.dcx.
221 If sl2_to_md.py already exposes a BND4 reader (the .sl2 saves are BND4),
222 prefer importing it and delete this. Kept standalone so the tool can run
223 before that refactor lands.
225 data = dcx_decompress(data)
226 if data[:4] != b
"BND4":
227 raise ValueError(f
"not a BND4 (magic {data[:4]!r})")
229 big = data[0x09] != 0
230 e =
">" if big
else "<"
231 (file_count,) = struct.unpack_from(e +
"i", data, 0x0C)
232 (header_size,) = struct.unpack_from(e +
"q", data, 0x10)
233 (file_header_size,) = struct.unpack_from(e +
"q", data, 0x20)
234 unicode_names = data[0x30] != 0
236 _extended = data[0x32]
238 out: list[BinderFile] = []
240 for _
in range(file_count):
243 (compressed_size,) = struct.unpack_from(e +
"q", data, p + 0x08)
244 (uncompressed_size,) = (
245 struct.unpack_from(e +
"q", data, p + 0x10)
246 if (fmt & 0b0010_0000)
247 else (compressed_size,)
249 off_field = 0x18
if (fmt & 0b0010_0000)
else 0x10
250 (data_offset,) = struct.unpack_from(e +
"I", data, p + off_field)
251 (file_id,) = struct.unpack_from(e +
"i", data, p + off_field + 4)
252 (name_offset,) = struct.unpack_from(e +
"i", data, p + off_field + 8)
257 end = data.find(b
"\x00\x00", name_offset)
258 while (end - name_offset) % 2:
259 end = data.find(b
"\x00\x00", end + 1)
260 name = data[name_offset:end].decode(
"utf-16-le",
"replace")
262 end = data.find(b
"\x00", name_offset)
263 name = data[name_offset:end].decode(
"shift_jis",
"replace")
265 blob = data[data_offset : data_offset + compressed_size]
266 if blob[:4] == b
"DCX\0":
267 blob = dcx_decompress(blob)
268 out.append(BinderFile(file_id, name, blob))
269 pos += file_header_size
272 raise ValueError(
"BND4 parsed but contained no files — layout mismatch")
308 r"^\s*(?P<type>\w+)\s+(?P<name>\w+)"
312 r"(?:\s*\[\s*(?P<arr>[^\]]*?)\s*\])?"
313 r"(?:\s*:\s*(?P<bits>\d+))?"
314 r"(?:\s*=\s*(?P<default>.+))?\s*$"
332 fields: list[DefField] = dc_field(default_factory=list)
334 def by_name(self, n: str) -> DefField |
None:
335 for f
in self.fields:
341def load_paramdef(path: Path) -> ParamDef:
342 root = ET.parse(path).getroot()
343 param_type = (root.findtext(
"ParamType")
or "").strip()
345 fields: list[DefField] = []
346 for node
in root.findall(
"./Fields/Field"):
347 m = _DEF_RE.match(node.get(
"Def",
""))
349 raise ValueError(f
"{path.name}: unparsable Def {node.get('Def')!r}")
352 type=m.group(
"type"),
353 name=m.group(
"name"),
354 array=int(m.group(
"arr"))
if (m.group(
"arr")
or "").isdigit()
else 1,
355 bits=int(m.group(
"bits")
or -1),
365 if f.type
not in _SIZES:
366 raise ValueError(f
"{path.name}: unknown type {f.type!r}")
367 size = _SIZES[f.type]
370 off += _SIZES[bit_type]
371 bit_off, bit_type = -1,
None
373 off += size * f.array
376 if bit_off == -1
or bit_type != f.type
or bit_off + f.bits > limit:
378 off += _SIZES[bit_type]
379 bit_off, bit_type = 0, f.type
380 f.offset, f.bit_offset = off, bit_off
383 off += _SIZES[bit_type]
385 return ParamDef(param_type=param_type, row_size=off, fields=fields)
392FLAG_INT_DATA_OFFSET = 0b0000_0010
393FLAG_LONG_DATA_OFFSET = 0b0000_0100
394FLAG_OFFSET_PARAM_TYPE = 0b1000_0000
395FLAG2_UNICODE_ROW_NAMES = 0b0000_0001
409 detected_row_size: int
412def read_param(blob: bytes) -> Param:
413 big = blob[0x2C] == 0xFF
414 e =
">" if big
else "<"
415 f2d, f2e = blob[0x2D], blob[0x2E]
417 (strings_offset,) = struct.unpack_from(e +
"I", blob, 0x00)
418 (row_count,) = struct.unpack_from(e +
"H", blob, 0x0A)
420 if f2d & FLAG_OFFSET_PARAM_TYPE:
421 (pt_off,) = struct.unpack_from(e +
"q", blob, 0x10)
422 end = blob.find(b
"\x00", pt_off)
423 param_type = blob[pt_off:end].decode(
"ascii",
"replace")
426 param_type = blob[0x0C:0x2C].split(b
"\x00")[0].decode(
"ascii",
"replace")
428 if f2d & FLAG_LONG_DATA_OFFSET:
431 long_rows = bool(f2d & FLAG_LONG_DATA_OFFSET)
432 stride = 24
if long_rows
else 12
433 unicode_names = bool(f2e & FLAG2_UNICODE_ROW_NAMES)
437 for _
in range(row_count):
439 (rid,) = struct.unpack_from(e +
"i", blob, p)
440 (data_off,) = struct.unpack_from(e +
"q", blob, p + 8)
441 (name_off,) = struct.unpack_from(e +
"q", blob, p + 16)
443 rid, data_off, name_off = struct.unpack_from(e +
"iII", blob, p)
444 entries.append((rid, data_off, name_off))
451 detected = entries[1][1] - entries[0][1]
453 detected = (strings_offset
or len(blob)) - entries[0][1]
458 for rid, data_off, name_off
in entries:
462 end = blob.find(b
"\x00\x00", name_off)
463 while (end - name_off) % 2:
464 end = blob.find(b
"\x00\x00", end + 1)
465 name = blob[name_off:end].decode(
"utf-16-le",
"replace")
467 end = blob.find(b
"\x00", name_off)
468 name = blob[name_off:end].decode(
"shift_jis",
"replace")
469 rows.append(ParamRow(rid, name, blob[data_off : data_off + max(detected, 0)]))
471 return Param(param_type, rows, detected)
474def read_cell(row: ParamRow, f: DefField, big: bool =
False):
475 e =
">" if big
else "<"
476 if f.type
in (
"dummy8",
"fixstr",
"fixstrW"):
478 fmt = _STRUCT[f.type]
479 (raw,) = struct.unpack_from(e + fmt, row.data, f.offset)
481 return (raw >> f.bit_offset) & ((1 << f.bits) - 1)
490def read_fmg(blob: bytes) -> dict[int, str]:
492 e =
">" if big
else "<"
496 (group_count,) = struct.unpack_from(e +
"i", blob, 0x0C)
498 (str_off_off,) = struct.unpack_from(e +
"q", blob, 0x18)
499 groups_at, group_stride = 0x28, 16
501 (str_off_off,) = struct.unpack_from(e +
"i", blob, 0x14)
502 groups_at, group_stride = 0x1C, 12
504 out: dict[int, str] = {}
506 for _
in range(group_count):
507 idx, first_id, last_id = struct.unpack_from(e +
"iii", blob, p)
509 for j
in range(last_id - first_id + 1):
510 o = str_off_off + (idx + j) * (8
if wide
else 4)
512 (so,) = struct.unpack_from(e +
"q", blob, o)
514 (so,) = struct.unpack_from(e +
"i", blob, o)
516 end = blob.find(b
"\x00\x00", so)
517 while (end - so) % 2:
518 end = blob.find(b
"\x00\x00", end + 1)
519 out[first_id + j] = blob[so:end].decode(
"utf-16-le",
"replace")
523def load_fmg_bundle(msg_dir: Path, bundle: str) -> dict[str, dict[int, str]]:
524 """Return {fmg_name: {id: text}} for every FMG in the named msgbnd set."""
525 tables: dict[str, dict[int, str]] = {}
526 for fn
in FMG_BUNDLES[bundle]:
532 p = msg_dir / fn.removesuffix(
".dcx")
535 for bf
in read_bnd4(p.read_bytes()):
536 key = Path(bf.name.replace(
"\\",
"/")).stem
or f
"fmg_{bf.id}"
538 tables.setdefault(key, {}).update(read_fmg(bf.data))
539 except Exception
as exc:
540 print(f
" ! {fn}:{key} unreadable ({exc})", file=sys.stderr)
545 tables: dict[str, dict[int, str]], ids: set[int]
546) -> tuple[str, dict[int, str]]:
547 """Pick the FMG whose ids best cover the param's row ids. Reported, not assumed."""
548 best, best_hit =
"", -1
549 for name, tbl
in tables.items():
550 if "name" not in name.lower():
552 hit = len(ids & tbl.keys())
554 best, best_hit = name, hit
555 return best, tables.get(best, {})
563def load_paramdex_names(path: Path) -> dict[int, str]:
564 out: dict[int, str] = {}
565 if not path.exists():
567 for line
in path.read_text(encoding=
"utf-8", errors=
"replace").splitlines():
568 parts = line.split(
" ", 1)
569 if len(parts) != 2
or not parts[0].lstrip(
"-").isdigit():
571 name = parts[1].split(
" -- ")[0].strip()
572 if JUNK.search(name):
574 out[int(parts[0])] = name
583def find_regulation(game_root: Path) -> Path:
584 """Locate the param binder.
586 Sekiro's is `param/gameparam/gameparam.parambnd`, and it is looked for FIRST:
587 `regulation.bin` is a DS3/Elden Ring name that no Sekiro tree contains, so
588 searching for it alone made this function fail on every real input. Both the
589 packed `.dcx` name and the already-decompressed one are candidates, because
590 `gamefiles.py unpack` writes the tree decompressed (its DCX is Oodle KRAK,
591 which nothing in this file can inflate — and does not need to).
594 "param/gameparam/gameparam.parambnd",
595 "param/gameparam/gameparam.parambnd.dcx",
597 "Game/regulation.bin",
598 "sekiro/regulation.bin",
603 for pattern
in (
"gameparam.parambnd*",
"regulation.bin"):
604 hits = list(game_root.rglob(pattern))
607 raise FileNotFoundError(
608 f
"no gameparam.parambnd or regulation.bin under {game_root}"
612def find_msg_dir(game_root: Path, lang: str) -> Path |
None:
613 for p
in game_root.rglob(f
"msg/{lang}"):
620 ap = argparse.ArgumentParser()
621 ap.add_argument(
"--game-root", required=
True, type=Path)
626 help=
"Paramdex checkout root (SDT/ must exist under it)",
628 ap.add_argument(
"--out", default=
"db_sdt", type=Path)
629 ap.add_argument(
"--report", default=
None, type=Path)
630 ap.add_argument(
"--lang", default=
"engus")
632 "--strict", action=
"store_true", help=
"exit 4 if any acceptance gate fails"
634 args = ap.parse_args()
636 sdt = args.paramdex /
"SDT"
637 if not (sdt /
"Defs").is_dir():
638 print(f
"error: {sdt}/Defs missing", file=sys.stderr)
641 reg_path = find_regulation(args.game_root)
642 print(f
"regulation: {reg_path}")
645 binder = read_bnd4(reg_path.read_bytes())
646 except Exception
as exc:
647 print(f
"error: regulation unreadable: {exc}", file=sys.stderr)
649 " Sekiro's regulation.bin should be a plain DCX-wrapped BND4 — "
650 "if this fails, check whether the repack shipped a modified file.",
655 params: dict[str, bytes] = {}
657 stem = Path(bf.name.replace(
"\\",
"/")).stem
658 params[stem] = bf.data
659 print(f
"regulation contains {len(params)} params")
661 args.out.mkdir(parents=
True, exist_ok=
True)
662 report: list[str] = [
"# SDT extraction report",
""]
663 report.append(f
"- regulation: `{reg_path}`")
664 report.append(f
"- params in regulation: {len(params)}")
667 msg_dir = find_msg_dir(args.game_root, args.lang)
668 fmg_cache: dict[str, dict[str, dict[int, str]]] = {}
670 print(f
"msg dir: {msg_dir}")
671 report.append(f
"- msg dir: `{msg_dir}`")
674 f
"warning: no msg/{args.lang} found — falling back to Paramdex names",
677 report.append(f
"- msg dir: **not found** (`msg/{args.lang}`)")
680 "| param | rows | def size | detected size | named ids | resolved | gate |"
682 report.append(
"|---|---|---|---|---|---|---|")
686 for pname, spec
in TARGETS.items():
687 if pname
not in params:
688 print(f
" ! {pname}: not present in regulation", file=sys.stderr)
689 report.append(f
"| {pname} | — | — | — | — | — | **MISSING** |")
693 def_path = sdt /
"Defs" / f
"{pname}.xml"
694 if not def_path.exists():
695 report.append(f
"| {pname} | — | — | — | — | — | **NO DEF** |")
699 pdef = load_paramdef(def_path)
700 param = read_param(params[pname])
705 param.detected_row_size == pdef.row_size
706 )
or param.detected_row_size < 0
709 names = load_paramdex_names(sdt /
"Names" / f
"{pname}.txt")
710 ids = {r.id
for r
in param.rows}
711 missing = set(names) - ids
712 names_ok =
not missing
715 want = [f
for f
in spec[
"fields"]
if f !=
"__row_id__"]
716 absent = [f
for f
in want
if pdef.by_name(f)
is None]
717 fields_ok =
not absent
719 print(f
" ! {pname}: def lacks {absent}", file=sys.stderr)
721 gate =
"ok" if (size_ok
and names_ok
and fields_ok)
else "FAIL"
726 fmg_name, fmg_tbl, fmg_src =
"", {},
"paramdex"
727 if msg_dir
and spec[
"name_fmg"]:
728 key, bundle = spec[
"name_fmg"]
729 if bundle
not in fmg_cache:
730 fmg_cache[bundle] = load_fmg_bundle(msg_dir, bundle)
731 lookup_ids = ids
if key ==
"__row_id__" else set()
732 if key !=
"__row_id__":
733 fd = pdef.by_name(key)
735 lookup_ids = {read_cell(r, fd)
for r
in param.rows}
736 lookup_ids.discard(
None)
737 lookup_ids.discard(-1)
738 fmg_name, fmg_tbl = pick_name_table(fmg_cache[bundle], lookup_ids)
740 fmg_src = f
"fmg:{fmg_name}"
742 cols = [
"id",
"name",
"name_source"] + want
743 lines = [
"\t".join(cols)]
748 key, _ = spec[
"name_fmg"]
749 if key ==
"__row_id__":
750 disp = fmg_tbl.get(r.id,
"")
752 fd = pdef.by_name(key)
754 disp = fmg_tbl.get(read_cell(r, fd),
"")
755 src = fmg_src
if disp
else ""
757 disp = names.get(r.id,
"")
758 src =
"paramdex" if disp
else "unnamed"
759 if disp
and not JUNK.search(disp):
765 fd = pdef.by_name(fn)
766 v = read_cell(r, fd)
if fd
else None
767 vals.append(
"" if v
is None else str(v))
768 lines.append(
"\t".join([str(r.id), disp, src] + vals))
770 (args.out / spec[
"out"]).write_text(
"\n".join(lines) +
"\n", encoding=
"utf-8")
772 f
" {pname:22s} -> {spec['out']:16s} "
773 f
"{len(param.rows):5d} rows {resolved:5d} named [{gate}]"
777 f
"| {pname} | {len(param.rows)} | {pdef.row_size} | "
778 f
"{param.detected_row_size} | {len(names)} | {resolved} | {gate} |"
782 f
"| ↳ ids in Paramdex but not in regulation: "
783 f
"{sorted(missing)[:12]}{'…' if len(missing) > 12 else ''} ||||||"
787 report.append(f
"**{failures} gate failure(s).**")
789 args.report.parent.mkdir(parents=
True, exist_ok=
True)
790 args.report.write_text(
"\n".join(report) +
"\n", encoding=
"utf-8")
791 print(f
"report: {args.report}")
793 if failures
and args.strict:
798if __name__ ==
"__main__":