SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
gen_sdt_from_regulation.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
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.
5
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).
14
15WHY THIS EXISTS
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.
22
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.
31
32USAGE
33 python3 tools/gen_sdt_from_regulation.py \
34 --game-root "/path/to/Sekiro" \
35 --paramdex "/path/to/Paramdex" \
36 --out "db_sdt" \
37 --report "db_sdt/_extract_report.md" \
38 [--strict]
39
40EXIT CODES
41 0 ok · 2 bad input/paths · 3 parse failure · 4 acceptance gate failed (--strict)
42"""
43
44from __future__ import annotations
45
46import argparse
47import re
48import struct
49import sys
50import xml.etree.ElementTree as ET
51import zlib
52from dataclasses import dataclass
53from dataclasses import field as dc_field
54from pathlib import Path
55
56# --------------------------------------------------------------------------
57# What we actually want out of regulation.bin.
58#
59# Field names below are verbatim from Paramdex SDT/Defs (commit ff7245e) and were
60# confirmed present. If a future Paramdex bump renames one, this table is the
61# single place to fix it — the extractor asserts every field exists before use.
62# --------------------------------------------------------------------------
63
64TARGETS: dict[str, dict] = {
65 # Sculptor's Idols. The bonfire analogue: discovery flags.
66 # NOTE: 58 rows, but Sekiro ships ~38 reachable idols — several rows are
67 # per-map-state duplicates of the same physical idol (Dilapidated Temple is
68 # both 100 and 1100). Dedupe on bonfireEntityId, never on row id.
69 "BonfireWarpParam": {
70 "out": "idols.tsv",
71 "fields": [
72 "eventflagId",
73 "bonfireEntityId",
74 "grayoutEventflagId",
75 "msgId",
76 "menuTextId",
77 ],
78 "name_fmg": ("menuTextId", "menu"),
79 "purpose": "Sculptor's Idol discovery flags",
80 },
81 # Reflection of Strength roster = the canonical boss + miniboss list, each
82 # row carrying up to 10 gating flags (defeat flags, availability flags).
83 # Paramdex has NO Names file for this param — row ids need hand-mapping.
84 "RematchWarpParam": {
85 "out": "bosses.tsv",
86 # EventFlagManByte<i> carries the required state for EventFlagId<i>
87 # (i.e. flag polarity). Without it a "defeated" flag and a "not yet
88 # available" flag look identical.
89 "fields": (
90 ["WarpPointId"]
91 + [f"EventFlagId{i}" for i in range(1, 11)]
92 + [f"EventFlagManByte{i}" for i in range(1, 11)]
93 ),
94 "name_fmg": None,
95 "purpose": "boss/miniboss roster + gating flags",
96 },
97 # Door / shortcut / lever activations — the Sekiro analogue of DS3 shortcut
98 # flags, and the cheapest source of "has this area been opened up" evidence.
99 "ObjActParam": {
100 "out": "objact_flags.tsv",
101 "fields": ["spQualifiedPassEventFlag"],
102 "name_fmg": None,
103 "purpose": "door / shortcut activation flags",
104 },
105 # Skill tree unlocks — one flag per node, 89 annotated rows.
106 "SkillParam": {
107 "out": "skills.tsv",
108 "fields": ["unlockEventFlag", "virtualWeaponId", "acquireWeaponId"],
109 "name_fmg": None,
110 "purpose": "skill / combat-art unlock flags",
111 },
112 # Pickup flags. The DS3-analogue for item completion tracking.
113 "ItemLotParam": {
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"],
118 "name_fmg": None,
119 "purpose": "item pickup flags",
120 },
121 # Merchant unlocks — proves NPC availability without touching NPC flags.
122 "ShopLineupParam": {
123 "out": "shop_flags.tsv",
124 "fields": ["equipId", "mtrlId", "eventFlag", "flagId_forRelease"],
125 "name_fmg": None,
126 "purpose": "shop unlock / sold-out flags",
127 },
128 # 13 rows driving the completion percentage.
129 # CAUTION: Paramdex SDT Names for this param are DS3 leftovers ("Abyss
130 # Watchers", "Aldrich", "Twin Princes"). Treat the annotation as junk and
131 # judge purely on the eventFlagId values read here.
132 "GameProgressParam": {
133 "out": "progress.tsv",
134 "fields": ["eventFlagId", "progressValue"],
135 "name_fmg": None,
136 "purpose": "main-progression milestone flags (names untrusted)",
137 },
138 # Boss health-bar rows: 18 real entries, ids match MSB entity ids.
139 "GameAreaParam": {
140 "out": "boss_areas.tsv",
141 "fields": [],
142 "name_fmg": None,
143 "purpose": "boss entity ids (join key for RematchWarpParam)",
144 },
145 # Item tables, for the name database rather than flags.
146 "EquipParamGoods": {
147 "out": "goods.tsv",
148 "fields": ["goodsType", "goodsUseAnim", "maxNum"],
149 "name_fmg": ("__row_id__", "item"),
150 "purpose": "consumables / key items / materials",
151 },
152 "EquipParamWeapon": {
153 "out": "weapons.tsv",
154 "fields": [],
155 "name_fmg": ("__row_id__", "item"),
156 "purpose": "weapons + prosthetic tools",
157 },
158}
159
160# FMG ids differ per game; these are probed rather than assumed. The extractor
161# reads every FMG in the msgbnd and picks the one whose id coverage best matches
162# the param it is naming, then records the winning id in the report so it can be
163# pinned later.
164FMG_BUNDLES = {
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"],
167}
168
169JUNK = re.compile(
170 r"(^\s*$|^\[?(DUMMY|dummy|ダミー|test|テスト)|^%null%|"
171 r"Protagonist[_ ]|主人公|Original Memory|不明|未使用|使用しない|"
172 r"^\d+$|^-+$)",
173 re.IGNORECASE,
174)
175
176
177# ==========================================================================
178# DCX
179# ==========================================================================
180
181
182def dcx_decompress(data: bytes) -> bytes:
183 """Unwrap a DCX container. Sekiro uses DFLT (raw zlib)."""
184 if data[:4] != b"DCX\0":
185 return data
186 dca = data.find(b"DCA\0")
187 if 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 :]
191 try:
192 return zlib.decompress(payload)
193 except zlib.error:
194 # Fallback: scan forward for a zlib header. Cheap insurance against a
195 # DCA header size we mis-read rather than a genuinely different codec.
196 for i in range(len(payload) - 2):
197 if payload[i] == 0x78 and payload[i + 1] in (0x01, 0x9C, 0xDA, 0x5E):
198 try:
199 return zlib.decompress(payload[i:])
200 except zlib.error:
201 continue
202 raise ValueError("DCX: could not inflate (Oodle/KRAK payload?)") from None
203
204
205# ==========================================================================
206# BND4
207# ==========================================================================
208
209
210@dataclass
211class BinderFile:
212 id: int
213 name: str
214 data: bytes
215
216
217def read_bnd4(data: bytes) -> list[BinderFile]:
218 """
219 Minimal BND4 reader — enough for regulation.bin and *.msgbnd.dcx.
220
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.
224 """
225 data = dcx_decompress(data)
226 if data[:4] != b"BND4":
227 raise ValueError(f"not a BND4 (magic {data[:4]!r})")
228
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
235 fmt = data[0x31]
236 _extended = data[0x32] # layout note: kept for the record, unused
237
238 out: list[BinderFile] = []
239 pos = header_size
240 for _ in range(file_count):
241 p = pos
242 _flags = data[p] # layout note: kept for the record, unused
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,)
248 )
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)
253
254 name = ""
255 if name_offset:
256 if unicode_names:
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")
261 else:
262 end = data.find(b"\x00", name_offset)
263 name = data[name_offset:end].decode("shift_jis", "replace")
264
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
270
271 if not out:
272 raise ValueError("BND4 parsed but contained no files — layout mismatch")
273 return out
274
275
276# ==========================================================================
277# PARAMDEF (Paramdex XML)
278# ==========================================================================
279
280_SIZES = {
281 "s8": 1,
282 "u8": 1,
283 "s16": 2,
284 "u16": 2,
285 "s32": 4,
286 "u32": 4,
287 "b32": 4,
288 "f32": 4,
289 "angle32": 4,
290 "f64": 8,
291 "dummy8": 1,
292 "fixstr": 1,
293 "fixstrW": 2,
294}
295_STRUCT = {
296 "s8": "b",
297 "u8": "B",
298 "s16": "h",
299 "u16": "H",
300 "s32": "i",
301 "u32": "I",
302 "b32": "i",
303 "f32": "f",
304 "angle32": "f",
305 "f64": "d",
306}
307_DEF_RE = re.compile(
308 r"^\s*(?P<type>\w+)\s+(?P<name>\w+)"
309 # Brackets are usually an array length, but Paramdex also uses them for
310 # value-range comments (e.g. "u8 GroundMaterialType [0,1,2,3]"). Anything
311 # that isn't a bare integer is treated as a comment, not an array.
312 r"(?:\s*\[\s*(?P<arr>[^\]]*?)\s*\])?"
313 r"(?:\s*:\s*(?P<bits>\d+))?"
314 r"(?:\s*=\s*(?P<default>.+))?\s*$"
315)
316
317
318@dataclass
319class DefField:
320 type: str
321 name: str
322 array: int = 1
323 bits: int = -1
324 offset: int = -1
325 bit_offset: int = -1
326
327
328@dataclass
329class ParamDef:
330 param_type: str
331 row_size: int
332 fields: list[DefField] = dc_field(default_factory=list)
333
334 def by_name(self, n: str) -> DefField | None:
335 for f in self.fields:
336 if f.name == n:
337 return f
338 return None
339
340
341def load_paramdef(path: Path) -> ParamDef:
342 root = ET.parse(path).getroot()
343 param_type = (root.findtext("ParamType") or "").strip()
344
345 fields: list[DefField] = []
346 for node in root.findall("./Fields/Field"):
347 m = _DEF_RE.match(node.get("Def", ""))
348 if not m:
349 raise ValueError(f"{path.name}: unparsable Def {node.get('Def')!r}")
350 fields.append(
351 DefField(
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),
356 )
357 )
358
359 # Offset pass, mirroring SoulsFormats' bit packing: consecutive bitfields
360 # of the same DefType share one storage unit; anything else flushes it.
361 off = 0
362 bit_off = -1
363 bit_type = None
364 for f in fields:
365 if f.type not in _SIZES:
366 raise ValueError(f"{path.name}: unknown type {f.type!r}")
367 size = _SIZES[f.type]
368 if f.bits == -1:
369 if bit_off != -1:
370 off += _SIZES[bit_type]
371 bit_off, bit_type = -1, None
372 f.offset = off
373 off += size * f.array
374 else:
375 limit = size * 8
376 if bit_off == -1 or bit_type != f.type or bit_off + f.bits > limit:
377 if bit_off != -1:
378 off += _SIZES[bit_type]
379 bit_off, bit_type = 0, f.type
380 f.offset, f.bit_offset = off, bit_off
381 bit_off += f.bits
382 if bit_off != -1:
383 off += _SIZES[bit_type]
384
385 return ParamDef(param_type=param_type, row_size=off, fields=fields)
386
387
388# ==========================================================================
389# PARAM
390# ==========================================================================
391
392FLAG_INT_DATA_OFFSET = 0b0000_0010
393FLAG_LONG_DATA_OFFSET = 0b0000_0100
394FLAG_OFFSET_PARAM_TYPE = 0b1000_0000
395FLAG2_UNICODE_ROW_NAMES = 0b0000_0001
396
397
398@dataclass
399class ParamRow:
400 id: int
401 name: str
402 data: bytes
403
404
405@dataclass
406class Param:
407 param_type: str
408 rows: list[ParamRow]
409 detected_row_size: int
410
411
412def read_param(blob: bytes) -> Param:
413 big = blob[0x2C] == 0xFF
414 e = ">" if big else "<"
415 f2d, f2e = blob[0x2D], blob[0x2E]
416
417 (strings_offset,) = struct.unpack_from(e + "I", blob, 0x00)
418 (row_count,) = struct.unpack_from(e + "H", blob, 0x0A)
419
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")
424 rows_at = 0x40
425 else:
426 param_type = blob[0x0C:0x2C].split(b"\x00")[0].decode("ascii", "replace")
427 rows_at = 0x30
428 if f2d & FLAG_LONG_DATA_OFFSET:
429 rows_at = 0x40
430
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)
434
435 entries = []
436 p = rows_at
437 for _ in range(row_count):
438 if long_rows:
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)
442 else:
443 rid, data_off, name_off = struct.unpack_from(e + "iII", blob, p)
444 entries.append((rid, data_off, name_off))
445 p += stride
446
447 # Row size is inferred from the gap between consecutive rows, exactly as
448 # SoulsFormats does — never from the paramdef, so a def/regulation version
449 # mismatch shows up as a mismatch instead of silently shifting every field.
450 if len(entries) > 1:
451 detected = entries[1][1] - entries[0][1]
452 elif entries:
453 detected = (strings_offset or len(blob)) - entries[0][1]
454 else:
455 detected = -1
456
457 rows = []
458 for rid, data_off, name_off in entries:
459 name = ""
460 if name_off:
461 if unicode_names:
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")
466 else:
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)]))
470
471 return Param(param_type, rows, detected)
472
473
474def read_cell(row: ParamRow, f: DefField, big: bool = False):
475 e = ">" if big else "<"
476 if f.type in ("dummy8", "fixstr", "fixstrW"):
477 return None
478 fmt = _STRUCT[f.type]
479 (raw,) = struct.unpack_from(e + fmt, row.data, f.offset)
480 if f.bits != -1:
481 return (raw >> f.bit_offset) & ((1 << f.bits) - 1)
482 return raw
483
484
485# ==========================================================================
486# FMG
487# ==========================================================================
488
489
490def read_fmg(blob: bytes) -> dict[int, str]:
491 big = blob[1] != 0
492 e = ">" if big else "<"
493 version = blob[2]
494 wide = version == 2 # DarkSouls3 variant; Sekiro uses it
495
496 (group_count,) = struct.unpack_from(e + "i", blob, 0x0C)
497 if wide:
498 (str_off_off,) = struct.unpack_from(e + "q", blob, 0x18)
499 groups_at, group_stride = 0x28, 16
500 else:
501 (str_off_off,) = struct.unpack_from(e + "i", blob, 0x14)
502 groups_at, group_stride = 0x1C, 12
503
504 out: dict[int, str] = {}
505 p = groups_at
506 for _ in range(group_count):
507 idx, first_id, last_id = struct.unpack_from(e + "iii", blob, p)
508 p += group_stride
509 for j in range(last_id - first_id + 1):
510 o = str_off_off + (idx + j) * (8 if wide else 4)
511 if wide:
512 (so,) = struct.unpack_from(e + "q", blob, o)
513 else:
514 (so,) = struct.unpack_from(e + "i", blob, o)
515 if so:
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")
520 return out
521
522
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]:
527 # An unpacked tree carries these decompressed and so WITHOUT the .dcx suffix;
528 # a tree taken straight out of the archives keeps it. Try both rather than
529 # making the caller rename files.
530 p = msg_dir / fn
531 if not p.exists():
532 p = msg_dir / fn.removesuffix(".dcx")
533 if not p.exists():
534 continue
535 for bf in read_bnd4(p.read_bytes()):
536 key = Path(bf.name.replace("\\", "/")).stem or f"fmg_{bf.id}"
537 try:
538 tables.setdefault(key, {}).update(read_fmg(bf.data))
539 except Exception as exc: # noqa: BLE001
540 print(f" ! {fn}:{key} unreadable ({exc})", file=sys.stderr)
541 return tables
542
543
544def pick_name_table(
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():
551 continue
552 hit = len(ids & tbl.keys())
553 if hit > best_hit:
554 best, best_hit = name, hit
555 return best, tables.get(best, {})
556
557
558# ==========================================================================
559# Paramdex Names (for the acceptance gate)
560# ==========================================================================
561
562
563def load_paramdex_names(path: Path) -> dict[int, str]:
564 out: dict[int, str] = {}
565 if not path.exists():
566 return out
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():
570 continue
571 name = parts[1].split(" -- ")[0].strip()
572 if JUNK.search(name):
573 continue
574 out[int(parts[0])] = name
575 return out
576
577
578# ==========================================================================
579# Driver
580# ==========================================================================
581
582
583def find_regulation(game_root: Path) -> Path:
584 """Locate the param binder.
585
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).
592 """
593 for cand in (
594 "param/gameparam/gameparam.parambnd",
595 "param/gameparam/gameparam.parambnd.dcx",
596 "regulation.bin",
597 "Game/regulation.bin",
598 "sekiro/regulation.bin",
599 ):
600 p = game_root / cand
601 if p.exists():
602 return p
603 for pattern in ("gameparam.parambnd*", "regulation.bin"):
604 hits = list(game_root.rglob(pattern))
605 if hits:
606 return hits[0]
607 raise FileNotFoundError(
608 f"no gameparam.parambnd or regulation.bin under {game_root}"
609 )
610
611
612def find_msg_dir(game_root: Path, lang: str) -> Path | None:
613 for p in game_root.rglob(f"msg/{lang}"):
614 if p.is_dir():
615 return p
616 return None
617
618
619def main() -> int:
620 ap = argparse.ArgumentParser()
621 ap.add_argument("--game-root", required=True, type=Path)
622 ap.add_argument(
623 "--paramdex",
624 required=True,
625 type=Path,
626 help="Paramdex checkout root (SDT/ must exist under it)",
627 )
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")
631 ap.add_argument(
632 "--strict", action="store_true", help="exit 4 if any acceptance gate fails"
633 )
634 args = ap.parse_args()
635
636 sdt = args.paramdex / "SDT"
637 if not (sdt / "Defs").is_dir():
638 print(f"error: {sdt}/Defs missing", file=sys.stderr)
639 return 2
640
641 reg_path = find_regulation(args.game_root)
642 print(f"regulation: {reg_path}")
643
644 try:
645 binder = read_bnd4(reg_path.read_bytes())
646 except Exception as exc: # noqa: BLE001
647 print(f"error: regulation unreadable: {exc}", file=sys.stderr)
648 print(
649 " Sekiro's regulation.bin should be a plain DCX-wrapped BND4 — "
650 "if this fails, check whether the repack shipped a modified file.",
651 file=sys.stderr,
652 )
653 return 3
654
655 params: dict[str, bytes] = {}
656 for bf in binder:
657 stem = Path(bf.name.replace("\\", "/")).stem
658 params[stem] = bf.data
659 print(f"regulation contains {len(params)} params")
660
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)}")
665 report.append("")
666
667 msg_dir = find_msg_dir(args.game_root, args.lang)
668 fmg_cache: dict[str, dict[str, dict[int, str]]] = {}
669 if msg_dir:
670 print(f"msg dir: {msg_dir}")
671 report.append(f"- msg dir: `{msg_dir}`")
672 else:
673 print(
674 f"warning: no msg/{args.lang} found — falling back to Paramdex names",
675 file=sys.stderr,
676 )
677 report.append(f"- msg dir: **not found** (`msg/{args.lang}`)")
678 report.append("")
679 report.append(
680 "| param | rows | def size | detected size | named ids | resolved | gate |"
681 )
682 report.append("|---|---|---|---|---|---|---|")
683
684 failures = 0
685
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** |")
690 failures += 1
691 continue
692
693 def_path = sdt / "Defs" / f"{pname}.xml"
694 if not def_path.exists():
695 report.append(f"| {pname} | — | — | — | — | — | **NO DEF** |")
696 failures += 1
697 continue
698
699 pdef = load_paramdef(def_path)
700 param = read_param(params[pname])
701
702 # Gate 1 — the paramdef must describe the same row size the regulation
703 # actually uses. A mismatch means every field offset is wrong.
704 size_ok = (
705 param.detected_row_size == pdef.row_size
706 ) or param.detected_row_size < 0
707
708 # Gate 2 — every id Paramdex annotates must exist in the regulation.
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
713
714 # Gate 3 — every field we intend to read must exist in the def.
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
718 if absent:
719 print(f" ! {pname}: def lacks {absent}", file=sys.stderr)
720
721 gate = "ok" if (size_ok and names_ok and fields_ok) else "FAIL"
722 if gate == "FAIL":
723 failures += 1
724
725 # Display names: shipped English first, Paramdex annotation as fallback.
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)
734 if fd:
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)
739 if fmg_tbl:
740 fmg_src = f"fmg:{fmg_name}"
741
742 cols = ["id", "name", "name_source"] + want
743 lines = ["\t".join(cols)]
744 resolved = 0
745 for r in param.rows:
746 disp, src = "", ""
747 if spec["name_fmg"]:
748 key, _ = spec["name_fmg"]
749 if key == "__row_id__":
750 disp = fmg_tbl.get(r.id, "")
751 else:
752 fd = pdef.by_name(key)
753 if fd:
754 disp = fmg_tbl.get(read_cell(r, fd), "")
755 src = fmg_src if disp else ""
756 if not disp:
757 disp = names.get(r.id, "")
758 src = "paramdex" if disp else "unnamed"
759 if disp and not JUNK.search(disp):
760 resolved += 1
761 else:
762 disp = disp or ""
763 vals = []
764 for fn in want:
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))
769
770 (args.out / spec["out"]).write_text("\n".join(lines) + "\n", encoding="utf-8")
771 print(
772 f" {pname:22s} -> {spec['out']:16s} "
773 f"{len(param.rows):5d} rows {resolved:5d} named [{gate}]"
774 )
775
776 report.append(
777 f"| {pname} | {len(param.rows)} | {pdef.row_size} | "
778 f"{param.detected_row_size} | {len(names)} | {resolved} | {gate} |"
779 )
780 if missing:
781 report.append(
782 f"| ↳ ids in Paramdex but not in regulation: "
783 f"{sorted(missing)[:12]}{'…' if len(missing) > 12 else ''} ||||||"
784 )
785
786 report.append("")
787 report.append(f"**{failures} gate failure(s).**")
788 if args.report:
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}")
792
793 if failures and args.strict:
794 return 4
795 return 0
796
797
798if __name__ == "__main__":
799 sys.exit(main())