3gamefiles.py — read a FromSoftware install directly: archives, maps, event scripts.
6 Every id table in `db_*/` was transcribed from a community source. That was the only
7 option while the games' own files were sealed, and it costs accuracy: a randomizer's
8 enemy list can be missing five entries and mislabel fourteen more, and nothing in a
9 save can tell you so. This reads the installed game instead — the same four steps a
10 modding tool takes, in one place, with no Windows dependency:
12 unpack open the `.bhd`/`.bdt` archives and extract files by path
13 msb read a map layout: every enemy placement, its entity id and model
14 emevd read the event scripts: instruction by instruction, arguments unpacked
15 roster the payoff — check db_sdt/minibosses.json against the scripts themselves
16 ernames the other payoff — write db_er's item names from Elden Ring's own FMGs
18 It is a READER. It never writes into a game folder, never patches an executable, and
19 the one subcommand that can write a `db_*` table only does so when asked (`--write`).
22 These began as four throwaway scripts, and four scripts is how the BHD5 reader gets
23 copied three times and then fixed once. The formats are shared — a `.msb` and an
24 `.emevd` both come out of a `.bhd`, and both are DCX-wrapped — so the code is too.
26WHAT COMES FROM WHERE — none of the layouts here are invented
27 * Archive container, DCX, MSB and EMEVD layouts: `JKAnderson/SoulsFormats`
28 (`Formats/BHD5.cs`, `Formats/DCX.cs`, `Formats/MSB/MSBS/*`, `Formats/EMEVD/*`).
29 * The RSA step: `Nordgaren/UXM-Selective-Unpack` `CryptographyUtility.DecryptRsa` —
30 raw RSA, no padding, 256-byte blocks in and 255-byte blocks out, left-zero-padded.
31 * The filename hash: `SFUtil.FromPathHash` — lowercase, backslashes to slashes, a
32 leading slash, then `h = h * 37 + c` over a uint32.
33 * Instruction names and argument types: `AinTunez/DarkScript3`'s
34 `sekiro-common.emedf.json`. Only the handful of instructions this file names are
35 hardcoded (@ref LAYOUTS), so no EMEDF file is needed at run time.
36 * Keys and name dictionaries: UXM's `ArchiveKeys.cs` and `res/<game>Dictionary.txt`.
37 NEITHER IS VENDORED HERE — pass them in. Dark Souls II needs neither: it ships its
38 own `*KeyCode.pem` beside each archive.
39 * BND4 and FMG parsing is imported from `gen_sdt_from_regulation.py` rather than
42THE TWO TRAPS, BOTH FOUND THE HARD WAY
43 * **FromSoft's Oodle streams decode ONE 256 KiB CHUNK AT A TIME, each against its own
44 window.** Hand a Kraken decoder the whole stream and chunk 1 comes out perfect and
45 chunk 2 fails — in `powzix/ooz` and in the unrelated Rust `oozextract` alike. See
46 @ref ooz_decompress_chunked.
47 * **A boss/miniboss handler is usually PARAMETERISED.** Its own instruction args are
48 zeroes; the real values arrive through `2000[6] Initialize Common Event`. Scan only
49 the inline calls and Sekiro reports one miniboss instead of thirty-seven.
51ELDEN RING: WHAT WORKS AND WHAT IS NOT WIRED
52 Target is **Shadow of the Erdtree Deluxe, v1.16**. This was written blind from UXM's key
53 list and SoulsFormats' `Game.EldenRing`, and it has now been RUN: the first attempt
54 against a real install indexed **120,332 entries** and pulled `/msg/` without a change
55 to any of it. Both ER-only differences below were right the first time.
57 * `unpack --game er` — works. Archives `Data0..Data3` + `DLC`; keys `EldenRingKeys`;
58 dictionary `EldenRingDictionary.txt` (9 MB, and the RSA header pass over ~14 MB of
59 `.bhd` is the slow step — minutes, not seconds).
60 * `ernames` — works, and `db_er/`'s item-name tables are its output now. It reads the
61 unpacked `msg/engus` rather than params, so `regulation.bin` stays untouched.
62 * **Two ER-only differences are already handled, and both fail SILENTLY if missed.**
63 The filename hash widened to **uint64 with multiplier 0x85** (@ref HASH_PRIME), and
64 the archive entry keeps DS3's 40-byte stride while laying its fields out differently
65 — 64-bit hash, then two 32-bit sizes (@ref parse_bhd5). Either one wrong produces an
66 archive that simply contains nothing you asked for.
67 * `DCX/ZSTD` is handled as well as `KRAK`, because ER's later patches use it.
68 * `regulation.bin` is loose at the install root and is ER-encrypted; nothing here
69 decrypts it, and nothing needs to — `db_er/`'s names come from the `msg/engus`
70 FMGs, which are plain files once the archive is open.
71 * **`msb` will NOT read ER maps.** ER is `MSBE`, whose part struct differs from
72 Sekiro's `MSBS`; the reader asserts rather than guesses (@ref read_msb).
73 * **`roster` is Sekiro-specific and stays that way.** The instruction ids in
74 @ref LAYOUTS are Sekiro's. ER's own convention is already known from the flag
75 research — defeat flag == entity id for 156 of 176 bosses — so the ER equivalent is
76 an ER EMEDF plus its `Handle Boss Defeat` id, not new machinery.
77 * `emevd` may work as-is: ER is expected to be the same version `0xCD` container as
78 Sekiro. If the header check rejects it, the flags it prints are the thing to look at
79 first — do not widen the check without knowing which permutation it is.
81 The real ER blocker is elsewhere and this tool does not touch it: the SAVE-side flag
82 region is still unsolved, so ER ids remain unreadable however many of them get extracted.
85 Sekiro and later compress with Oodle Kraken, which has no pure-Python decoder. Build
86 `powzix/ooz` as a shared object once:
88 git clone --depth 1 https://github.com/powzix/ooz && cd ooz
89 head -4286 kraken.cpp > kraken_lib.cpp
90 printf '\\nextern "C" int ooz_decompress(const byte *s, size_t sl, byte *d, size_t dl)'\\
91 '{ return Kraken_Decompress(s, sl, d, dl); }\\n' >> kraken_lib.cpp
92 # plus a compat/ shim supplying tchar.h, intrin.h and Windows.h on Linux
93 g++ -O2 -DNDEBUG -fPIC -shared -Icompat kraken_lib.cpp bitknit.cpp lzna.cpp \\
96 Point `--ooz` (or `$SL2_OOZ_LIB`) at the result. Games that use Deflate — Dark Souls
97 II, Dark Souls III, Dark Souls Remastered — need none of this.
100 python3 tools/gamefiles.py unpack --game sekiro --game-root ~/Games/Sekiro \\
101 --keys ArchiveKeys.cs --dict SekiroDictionary.txt --out ~/Games/Sekiro-unpacked \\
102 --prefix /event/ --prefix /param/ --prefix /msg/engus/ --prefix /map/mapstudio/
103 python3 tools/gamefiles.py msb <unpacked>/map/mapstudio --entity 1120450
104 python3 tools/gamefiles.py emevd <unpacked>/event --instr 2003:87
105 python3 tools/gamefiles.py roster <unpacked>/event --msg <unpacked>/msg/engus \\
106 [--maps <unpacked>/map/mapstudio] [--paramdex <Paramdex>] [--write db_sdt/minibosses.json]
109 0 ok · 2 bad input/paths · 3 parse failure
112from __future__
import annotations
122from pathlib
import Path
124sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
125import gen_sdt_from_regulation
as fs
127BASE = Path(__file__).resolve().parent.parent
146 "sekiro": {
"archives": [
"Data1",
"Data2",
"Data3",
"Data4",
"Data5"],
147 "keys":
"SekiroKeys",
"bhd5":
"ds3",
"dict":
"SekiroDictionary.txt"},
148 "ds3": {
"archives": [
"Data0",
"Data1",
"Data2",
"Data3",
"Data4",
"Data5",
150 "keys":
"DarkSouls3Keys",
"bhd5":
"ds3",
"dict":
"DarkSouls3Dictionary.txt"},
151 "ds2": {
"archives": [
"GameDataEbl",
"LqChrEbl",
"HqChrEbl",
"LqMapEbl",
"HqMapEbl",
152 "LqObjEbl",
"HqObjEbl",
"LqPartsEbl",
"HqPartsEbl"],
153 "keys":
None,
"bhd5":
"ds2",
"dict":
"ScholarDictionary.txt"},
158 "er": {
"archives": [
"Data0",
"Data1",
"Data2",
"Data3",
"DLC"],
159 "keys":
"EldenRingKeys",
"bhd5":
"er",
"dict":
"EldenRingDictionary.txt"},
169HASH_PRIME = {
"ds2": (37, 32),
"ds3": (37, 32),
"er": (0x85, 64)}
173KEY_HELP =
"""the RSA keys and the name dictionary are not vendored. Fetch them:
175 curl -sSLO https://raw.githubusercontent.com/Nordgaren/UXM-Selective-Unpack/master/UXM/ArchiveKeys.cs
176 curl -sSLO https://raw.githubusercontent.com/Nordgaren/UXM-Selective-Unpack/master/UXM/res/SekiroDictionary.txt
178Dark Souls II needs only the dictionary: its keys ship in the install as *KeyCode.pem.
193def load_keys(path: Path, dict_name: str) -> dict[str, str]:
194 src = Path(path).read_text(encoding=
"utf-8-sig")
196 dict_name +
r"\s*=\s*new\s+Dictionary<string,\s*string>\s*\{(.*?)\n\s*\};",
201 sys.exit(f
"no {dict_name} dictionary in {path}")
203 re.findall(
r'\["([A-Za-z0-9_]+)"\]\s*=\s*@"(.*?)"', body.group(1), re.S)
215 from cryptography.hazmat.primitives
import serialization
217 pub = serialization.load_pem_public_key(pem.encode())
218 n, e = pub.public_numbers().n, pub.public_numbers().e
219 in_size = (n.bit_length() + 7) // 8
220 out_size = (n.bit_length() - 1) // 8
222 for i
in range(0, len(data), in_size):
223 block = data[i : i + in_size]
224 if len(block) < in_size:
226 out += pow(int.from_bytes(block,
"big"), e, n).to_bytes(out_size,
"big")
235 def take(self, n: int) -> bytes:
236 if self.
pos + n > len(self.
buf):
237 sys.exit(f
"header truncated at 0x{self.pos:X} (+{n})")
243 return self.
take(1)[0]
246 return int.from_bytes(self.
take(4),
"little", signed=
True)
248 def u32(self) -> int:
249 return int.from_bytes(self.
take(4),
"little")
252 return int.from_bytes(self.
take(8),
"little", signed=
True)
255 return self.
take(n).decode(
"ascii",
"replace")
258 got = self.
ascii(len(want))
260 sys.exit(f
"expected {want!r} at 0x{self.pos - len(want):X}, got {got!r}")
270def parse_bhd5(buf: bytes, variant: str =
"ds3") -> dict[int, dict]:
274 sys.exit(
"big-endian BHD5; the PC games' are little-endian")
278 sys.exit(
"BHD5 version word is not 1")
280 bucket_count, buckets_offset = r.i32(), r.i32()
290 stride = 32
if variant ==
"ds2" else 40
291 out: dict[int, dict] = {}
292 for b
in range(bucket_count):
293 r.pos = buckets_offset + b * 8
294 count, offset = r.i32(), r.i32()
295 for i
in range(count):
296 e =
Reader(buf, offset + i * stride)
299 "hash": int.from_bytes(e.take(8),
"little"),
305 entry = {
"hash": e.u32(),
"padded": e.i32(),
"offset": e.i64()}
309 entry[
"unpadded"] = e.i64()
310 entry.setdefault(
"unpadded", 0)
311 entry[
"aes"] =
read_aes_key(buf, aes_off)
if aes_off
else None
312 out[entry[
"hash"]] = entry
323 key = r.take(AES_BLOCK)
325 for _
in range(r.i32()):
326 start, end = r.i64(), r.i64()
327 if start != -1
and end != -1
and start != end:
328 ranges.append((start, end))
329 return {
"key": bytes(key),
"ranges": ranges}
338 s = path.lower().replace(
"\\",
"/")
339 if not s.startswith(
"/"):
341 prime, width = HASH_PRIME[variant]
342 mask = (1 << width) - 1
345 h = (h * prime + ord(c)) & mask
356 from cryptography.hazmat.primitives.ciphers
import Cipher, algorithms, modes
358 bdt.seek(entry[
"offset"])
359 data = bytearray(bdt.read(entry[
"padded"]))
362 cipher = Cipher(algorithms.AES(aes[
"key"]), modes.ECB())
363 for start, end
in aes[
"ranges"]:
364 end = min(end, len(data))
365 span = (end - start) // AES_BLOCK * AES_BLOCK
368 dec = cipher.decryptor()
369 data[start : start + span] = (
370 dec.update(bytes(data[start : start + span])) + dec.finalize()
372 if 0 < entry[
"unpadded"] < len(data):
373 del data[entry[
"unpadded"] :]
393 out = [Path(explicit)]
if explicit
else []
394 if os.environ.get(
"SL2_OOZ_LIB"):
395 out.append(Path(os.environ[
"SL2_OOZ_LIB"]))
396 return out + [BASE /
"scratch" /
"libooz.so", BASE /
"libooz.so", Path(
"libooz.so")]
405 _OOZ = ctypes.CDLL(str(cand))
406 _OOZ.ooz_decompress.restype = ctypes.c_int
407 _OOZ.ooz_decompress.argtypes = [
416 "no libooz.so found — build it (see this file's docstring) and pass "
417 "--ooz, or set SL2_OOZ_LIB"
424 dst = ctypes.create_string_buffer(dst_len + OOZ_SLACK)
425 n = lib.ooz_decompress(src, len(src), dst, dst_len)
427 sys.exit(
"ooz_decompress failed")
447 out, pos = bytearray(), 0
448 while len(out) < dst_len:
449 want = min(OODLE_BLOCK, dst_len - len(out))
450 if pos + 2 > len(src):
451 sys.exit(f
"KRAK stream ended early at chunk offset {pos}")
452 b0, b1 = src[pos], src[pos + 1]
453 if b0 & 0x0F != 0x0C:
454 sys.exit(f
"not an Oodle block header at {pos}: 0x{b0:02X}")
459 v = int.from_bytes(src[pos + 2 : pos + 5],
"big")
460 frame += 3 + (3
if b1 & 0x80
else 0)
462 frame += 0
if size == 0x3FFFF
else size + 1
466 sys.exit(f
"KRAK stream has {len(src) - pos} trailing byte(s)")
477 if data[:4] != b
"DCX\0":
479 fmt = data[0x28:0x2C]
480 uncompressed = int.from_bytes(data[0x1C:0x20],
"big")
481 compressed = int.from_bytes(data[0x20:0x24],
"big")
484 dca = data.index(b
"DCA\0")
485 body = data[dca + int.from_bytes(data[dca + 4 : dca + 8],
"big") :][:compressed]
489 out = zlib.decompress(body)
497 sys.exit(
"this file is DCX/ZSTD — pip install zstandard")
498 out = zstandard.ZstdDecompressor().decompress(
499 body, max_output_size=uncompressed
502 sys.exit(f
"unsupported DCX compression {fmt!r}")
503 if len(out) != uncompressed:
504 sys.exit(f
"DCX size mismatch: {len(out)} out, {uncompressed} declared")
513MODEL_SECTION, PARTS_SECTION =
"MODEL_PARAM_ST",
"PARTS_PARAM_ST"
519PART_ENEMY, PART_DUMMY_ENEMY = 2, 10
521PART_NAMES = {0:
"MapPiece", 1:
"Object", 2:
"Enemy", 4:
"Player", 5:
"Collision",
522 9:
"DummyObject", 10:
"DummyEnemy", 11:
"ConnectCollision"}
526P_NAME_OFF, P_TYPE, P_MODEL_INDEX = 0x00, 0x08, 0x10
527P_ENTITY_DATA_OFF, P_TYPE_DATA_OFF = 0x60, 0x68
528E_THINK_PARAM, E_NPC_PARAM, E_CHARA_INIT = 0x08, 0x0C, 0x18
529E_EVENT_FLAG, E_EVENT_FLAG_STATE = 0x40, 0x44
532def _i32(b: bytes, o: int) -> int:
533 return struct.unpack_from(
"<i", b, o)[0]
536def _u32(b: bytes, o: int) -> int:
537 return struct.unpack_from(
"<I", b, o)[0]
540def _i64(b: bytes, o: int) -> int:
541 return struct.unpack_from(
"<q", b, o)[0]
547 while end + 1 < len(b)
and b[end : end + 2] != b
"\0\0":
549 return b[off:end].decode(
"utf-16-le",
"replace")
558 if buf[:4] != b
"MSB ":
559 sys.exit(
"not an MSB")
562 count =
_i32(buf, pos + 4)
563 name_off =
_i64(buf, pos + 8)
564 entries = [
_i64(buf, pos + 16 + i * 8)
for i
in range(count - 1)]
565 nxt =
_i64(buf, pos + 16 + (count - 1) * 8)
566 out[
_utf16(buf, name_off)] = entries
579 count =
_i32(buf, pos + 4)
582 pos =
_i64(buf, pos + 16 + (count - 1) * 8)
583 sys.exit(f
"no {name} section")
588 return [
_utf16(buf, off +
_i64(buf, off + P_NAME_OFF))
for off
in offsets]
596def msb_parts(buf: bytes, offsets: list[int], models: list[str]) -> list[dict]:
599 kind =
_u32(buf, off + P_TYPE)
600 mi =
_i32(buf, off + P_MODEL_INDEX)
602 "name":
_utf16(buf, off +
_i64(buf, off + P_NAME_OFF)),
604 "type_name": PART_NAMES.get(kind, str(kind)),
605 "model": models[mi]
if 0 <= mi < len(models)
else None,
606 "entity_id":
_i32(buf, off +
_i64(buf, off + P_ENTITY_DATA_OFF)),
611 "event_flag_state":
None,
613 if kind
in (PART_ENEMY, PART_DUMMY_ENEMY):
614 t = off +
_i64(buf, off + P_TYPE_DATA_OFF)
616 npc_param=
_i32(buf, t + E_NPC_PARAM),
617 think_param=
_i32(buf, t + E_THINK_PARAM),
618 chara_init=
_i32(buf, t + E_CHARA_INIT),
619 event_flag=
_i32(buf, t + E_EVENT_FLAG),
620 event_flag_state=
_i32(buf, t + E_EVENT_FLAG_STATE),
633def read_msb(path: Path) -> tuple[str, list[dict]] |
None:
634 buf = Path(path).read_bytes()
636 for want
in (MODEL_SECTION, PARTS_SECTION):
638 sys.exit(f
"{path}: no {want} (sections: {', '.join(sec)})")
644 if parts_version
not in MSBS_PARTS_VERSIONS:
647 Path(path).name.replace(
".msb",
""),
653MSBS_PARTS_VERSIONS = {0x21, 0x23}
657def read_msbs(root: str) -> list[tuple[str, list[dict]]]:
659 paths = [p]
if p.is_file()
else sorted(p.glob(
"*.msb"))
665 f
" ! {f.name}: not a Sekiro MSBS part layout (Elden Ring's MSBE is not "
666 f
"implemented), skipped",
680 return f
"m{eid // 100000 % 100:02d}_{eid // 10000 % 10:02d}"
688MINIBOSS_BAR, MINIBOSS_DEFEAT = (2003, 87), (2003, 15)
689BOSS_BAR, BOSS_DEFEAT, BOSS_BANNER = (2003, 11), (2003, 12), (2003, 74)
690AWARD_ITEM_LOT = (2003, 4)
692INIT_COMMON_EVENT = (2000, 6)
696ARG_TYPES = {0: (
"B", 1), 1: (
"H", 2), 2: (
"I", 4), 3: (
"b", 1), 4: (
"h", 2),
697 5: (
"i", 4), 6: (
"f", 4)}
703 MINIBOSS_BAR: [3, 5, 4, 5],
704 BOSS_BAR: [3, 5, 4, 5],
705 MINIBOSS_DEFEAT: [5],
718DS3_DEATH_TEMPLATES = (20005340, 20005341, 20005342, 20000343, 20005416, 20005061, 20005760)
730 fmt, size = ARG_TYPES[t]
731 pos += (-pos) % min(size, 4)
732 if pos + size > len(data):
734 out.append(struct.unpack_from(
"<" + fmt, data, pos)[0])
744 if len(data) < 4
or len(data) % 4:
746 return list(struct.unpack_from(f
"<{len(data) // 4}I", data, 0))
757def read_emevd(path: Path) -> list[tuple[int, int, int, bytes]] |
None:
758 buf = Path(path).read_bytes()
759 if buf[:4] != b
"EVD\0":
760 sys.exit(f
"{path}: not an EMEVD")
765 or buf[7]
not in (0, 0xFF)
766 or _i32(buf, 8) != 0xCD
770 v = [
_i64(buf, 0x10 + i * 8)
for i
in range(16)]
771 event_count, events_off, instrs_off, args_off = v[0], v[1], v[3], v[13]
774 for e
in range(event_count):
775 base = events_off + e * 48
776 eid, n, ioff =
_i64(buf, base),
_i64(buf, base + 8),
_i64(buf, base + 16)
778 ib = instrs_off + ioff + i * 32
779 alen, aoff =
_i64(buf, ib + 8),
_i64(buf, ib + 16)
780 data = buf[args_off + aoff : args_off + aoff + alen]
if alen > 0
else b
""
781 out.append((eid,
_i32(buf, ib),
_i32(buf, ib + 4), data))
788 paths = [p]
if p.is_file()
else sorted(p.glob(
"*.emevd"))
793 print(f
" ! {f.name}: not a DS3/Sekiro EMEVD, skipped", file=sys.stderr)
795 out[f.name.replace(
".emevd",
"")] = got
812 kinds = {MINIBOSS_BAR, BOSS_BAR, MINIBOSS_DEFEAT, BOSS_DEFEAT, BOSS_BANNER}
815 for name, instrs
in scripts.items()
816 if name.startswith(
"common")
817 for eid, b, i, _d
in instrs
822 for mapname, instrs
in scripts.items():
823 for eid, b, i, data
in instrs:
829 if key
in (MINIBOSS_BAR, BOSS_BAR):
830 out.append((mapname, eid, key, vals[1], vals[3]))
832 out.append((mapname, eid, key, vals[0],
None))
833 elif key == INIT_COMMON_EVENT:
835 if not vals
or vals[0]
not in handlers:
842 vals[1]
if len(vals) > 1
else 0,
843 vals[2]
if len(vals) > 2
else None,
855 roster: dict[int, dict] = {}
856 for mapname, eid, key, entity, name_id
in roster_calls(scripts):
859 row = roster.setdefault(
870 row[
"maps"].add(mapname)
871 row[
"events"].add(eid)
872 if key
in (MINIBOSS_BAR, MINIBOSS_DEFEAT):
873 row[
"miniboss"] =
True
876 if name_id
and name_id > 0:
877 row[
"name_ids"].add(name_id)
887 for mapname, instrs
in scripts.items():
888 for _eid, b, i, data
in instrs:
889 if (b, i) != INIT_COMMON_EVENT:
892 if vals
and len(vals) > 1
and vals[0]
in DS3_DEATH_TEMPLATES:
893 out.setdefault(vals[1], (mapname, vals[0]))
908 tables: dict[str, dict[int, str]] = {}
909 for stem
in (
"menu.msgbnd",
"item.msgbnd"):
910 for cand
in (stem, stem +
".dcx"):
911 path = Path(msg_dir) / cand
912 if not path.is_file():
914 for bf
in fs.read_bnd4(path.read_bytes()):
915 key = Path(bf.name.replace(
"\\",
"/")).name.rsplit(
".", 1)[0]
917 tables[key] = fs.read_fmg(bf.data)
918 except Exception
as exc:
919 print(f
" ! {cand}:{key} unreadable ({exc})", file=sys.stderr)
925def pick_table(tables: dict[str, dict[int, str]], ids: set[int]) -> tuple[str, int]:
927 for name, tbl
in tables.items():
928 n = sum(1
for i
in ids
if tbl.get(i))
936 path = Path(paramdex) /
"SDT" /
"Names" /
"NpcParam.txt"
938 if not path.is_file():
940 for line
in path.read_text(encoding=
"utf-8").splitlines():
941 m = re.match(
r"^(\d+)\s+(.*)$", line.strip())
943 out[int(m.group(1))] = m.group(2).split(
" -- ")[0].strip()
956 "m10_00":
"Hirata Estate",
"m11_00":
"Ashina Outskirts",
"m11_01":
"Ashina Castle",
957 "m11_02":
"Ashina Reservoir",
"m13_00":
"Abandoned Dungeon",
958 "m15_00":
"Ashina Depths",
"m17_00":
"Sunken Valley",
959 "m20_00":
"Senpou Temple, Mt. Kongo",
"m25_00":
"Fountainhead Palace",
972SDT_AREA_ORDER = [
"Ashina Outskirts",
"Hirata Estate",
"Ashina Castle",
"Ashina Reservoir",
973 "Abandoned Dungeon",
"Senpou Temple, Mt. Kongo",
"Sunken Valley",
974 "Ashina Depths",
"Fountainhead Palace"]
991def wanted(dict_path: str, prefixes: list[str], paths: list[str]) -> list[str]:
994 for ln
in Path(dict_path).read_text(encoding=
"utf-8-sig").splitlines()
997 if not prefixes
and not paths:
999 keep = [n
for n
in names
if any(n.startswith(p)
for p
in prefixes)]
1001 return keep + [p
for p
in paths
if p
not in known]
1005 spec = GAMES[args.game]
1006 if not Path(args.dict).is_file()
or (
1007 spec[
"keys"]
and not (args.keys
and Path(args.keys).is_file())
1009 print(KEY_HELP, file=sys.stderr)
1011 keys =
load_keys(args.keys, spec[
"keys"])
if spec[
"keys"]
else {}
1016 index: dict[int, tuple[Path, dict]] = {}
1017 for name
in spec[
"archives"]:
1019 Path(args.game_root) / f
"{name}.bhd",
1020 Path(args.game_root) / f
"{name}.bdt",
1022 if not bhd.is_file()
or not bdt.is_file():
1023 print(f
"{name}: absent, skipped")
1025 raw = bhd.read_bytes()
1029 if raw[:4] == b
"BHD5":
1032 pem = keys.get(name)
1034 own = Path(args.game_root) / f
"{name.replace('Ebl', '')}KeyCode.pem"
1036 pem = own.read_text(encoding=
"utf-8")
1040 print(f
"{name}: encrypted with no published key, skipped")
1044 print(f
"{name}: {len(entries)} entries")
1045 for h, e
in entries.items():
1046 index.setdefault(h, (bdt, e))
1048 todo =
wanted(args.dict, args.prefix, args.path)
1049 print(f
"{len(todo)} path(s) requested, {len(index)} entries indexed")
1051 open_bdt: dict[Path, object] = {}
1052 done, missing, failed = 0, [], []
1054 hit = index.get(
path_hash(path, spec[
"bhd5"]))
1056 missing.append(path)
1058 bdt_path, entry = hit
1059 if bdt_path
not in open_bdt:
1060 open_bdt[bdt_path] = open(bdt_path,
"rb")
1062 rel = path.lstrip(
"/")
1063 if not args.keep_dcx:
1070 except SystemExit
as why:
1071 failed.append(f
"{path} ({why})")
1073 if rel.endswith(
".dcx"):
1075 dest = Path(args.out) / rel
1076 dest.parent.mkdir(parents=
True, exist_ok=
True)
1077 dest.write_bytes(data)
1079 for f
in open_bdt.values():
1082 print(f
"wrote {done} file(s) to {args.out}")
1084 print(f
" ! could not decompress {f}")
1087 f
"{len(missing)} dictionary path(s) in no archive (normal — the dictionary "
1088 f
"covers every patch): {', '.join(missing[:5])}"
1089 + (
" ..." if len(missing) > 5
else "")
1096 for name, rows
in maps:
1097 enemies = [r
for r
in rows
if r[
"type"]
in (PART_ENEMY, PART_DUMMY_ENEMY)]
1098 ided = [r
for r
in enemies
if r[
"entity_id"]]
1100 f
"{name}: {len(rows)} parts, {len(enemies)} enemies, "
1101 f
"{len(ided)} with an entity id"
1105 want = set(args.entity)
1106 for name, rows
in maps:
1108 if r[
"entity_id"]
in want:
1109 print(f
"\n{name} entity {r['entity_id']}")
1110 for k, v
in r.items():
1115 for name, rows
in maps:
1117 if r[
"type"]
in (PART_ENEMY, PART_DUMMY_ENEMY)
and r[
"entity_id"]:
1119 f
"{name}\t{r['entity_id']}\t{r['model']}\t{r['npc_param']}"
1120 f
"\t{r['type_name']}\t{r['name']}"
1128 f
"{len(scripts)} script(s), {sum(len(v) for v in scripts.values())} instructions"
1131 for spec
in args.instr:
1132 bank, iid = (int(x)
for x
in spec.split(
":"))
1133 types = LAYOUTS.get((bank, iid))
1134 print(f
"\n=== {bank}[{iid}] ===")
1135 for name, instrs
in scripts.items():
1136 for eid, b, i, data
in instrs:
1137 if (b, i) == (bank, iid):
1138 vals =
unpack_args(data, types)
if types
else data.hex(
" ")
1139 print(f
" {name} event {eid} {vals}")
1143 print(f
"\n=== DS3 one-time-enemy death flags: {len(flags)} ===")
1144 for fid, (mapname, template)
in sorted(flags.items()):
1145 print(f
" {fid} {mapname} template {template}")
1155 "WeaponName": (
"weapons", 0x00000000),
1156 "ProtectorName": (
"armors", 0x10000000),
1157 "AccessoryName": (
"talismans", 0x20000000),
1158 "GoodsName": (
"goods", 0x40000000),
1159 "GemName": (
"ashes", 0x80000000),
1167ER_FMG_PLACEHOLDER =
"[ERROR]"
1180 named: dict[str, dict[int, str]] = {c: {}
for c, _
in ER_NAME_FMGS.values()}
1181 blank: dict[str, set] = {c: set()
for c, _
in ER_NAME_FMGS.values()}
1183 for binder
in (
"item.msgbnd",
"item_dlc01.msgbnd",
"item_dlc02.msgbnd"):
1184 path = Path(msg_dir) / binder
1185 if not path.is_file():
1186 print(f
"{binder}: absent, skipped")
1189 for bf
in fs.read_bnd4(path.read_bytes()):
1190 stem = bf.name.split(
"\\")[-1].removesuffix(
".fmg")
1191 entry = ER_NAME_FMGS.get(stem.split(
"_dlc")[0])
1195 for rid, text
in fs.read_fmg(bf.data).items():
1196 text = (text
or "").strip()
1197 real = text.replace(ER_FMG_PLACEHOLDER,
"").strip()
1199 named[cat][rid] = real
1200 blank[cat].discard(rid)
1201 elif rid
not in named[cat]:
1204 print(f
"no item.msgbnd under {msg_dir}", file=sys.stderr)
1228 if not any(named.values()):
1230 for cat, nib
in sorted({c: n
for c, n
in ER_NAME_FMGS.values()}.items()):
1231 path = Path(args.db) / f
"{cat}.json"
1235 int(k, 16): v
for k, v
in json.loads(path.read_text(
"utf-8")).items()
1237 out = {rid | nib: nm
for rid, nm
in named[cat].items()}
1239 for key, nm
in old.items():
1240 rid = key & 0x0FFFFFFF
1241 if rid
in named[cat]
or rid
in blank[cat]:
1245 dropped = sum(1
for k
in old
if (k & 0x0FFFFFFF)
in blank[cat])
1246 changed = sum(1
for k, v
in out.items()
if k
in old
and old[k] != v)
1248 f
"{cat:<10} {len(out):>5} rows (game {len(named[cat])}, kept {kept} the "
1249 f
"game does not list, dropped {dropped} it lists blank, renamed {changed})"
1254 {f
"{k:X}": out[k]
for k
in sorted(out)},
1258 path.write_text(text +
"\n", encoding=
"utf-8")
1260 print(
"\n(dry run — pass --write to update the tables)")
1273 f
"{len(scripts)} script(s), {sum(len(v) for v in scripts.values())} instructions"
1277 name_ids = {n
for r
in roster.values()
for n
in r[
"name_ids"]
if n > 0}
1278 names: dict[int, str] = {}
1283 f
"name table: {table} ({hits} of {len(name_ids)} name ids resolved, "
1284 f
"{len(tables)} tables probed)"
1286 names = tables.get(table, {})
1290 for mapname, rows
in read_msbs(args.maps):
1292 if r[
"type"]
in (PART_ENEMY, PART_DUMMY_ENEMY)
and r[
"entity_id"]:
1293 placed[r[
"entity_id"]] = dict(r, map=mapname)
1296 mini = sorted(e
for e, r
in roster.items()
if r[
"miniboss"]
and not r[
"boss"])
1297 boss = sorted(e
for e, r
in roster.items()
if r[
"boss"]
and not r[
"miniboss"])
1298 both = sorted(e
for e, r
in roster.items()
if r[
"boss"]
and r[
"miniboss"])
1300 f
"\n{len(mini)} miniboss entities, {len(boss)} boss entities, "
1301 f
"{len(both)} called by both"
1304 def printed_name(e: int) -> str:
1306 {names.get(n, f
"name#{n}")
for n
in roster[e][
"name_ids"]
if n > 0}
1308 return ", ".join(got)
or "(no health bar)"
1310 for label, ids
in ((
"MINIBOSSES", mini), (
"BOSSES", boss), (
"BOTH", both)):
1313 print(f
"\n=== {label} ===")
1317 extra = f
" {placed[e]['model']}"
1318 if placed[e][
"npc_param"]
in dev:
1319 extra += f
" [{dev[placed[e]['npc_param']]}]"
1321 f
" {e} {printed_name(e):38} {'/'.join(sorted(roster[e]['maps']))}{extra}"
1324 shipped_path = BASE /
"db_sdt" /
"minibosses.json"
1325 if shipped_path.is_file():
1326 shipped_raw = json.loads(shipped_path.read_text(encoding=
"utf-8"))
1327 shipped = {eid: n
for rows
in shipped_raw.values()
for eid, n
in rows}
1328 measured = set(mini) | set(both)
1330 f
"\n=== db_sdt/minibosses.json: {len(shipped)} rows vs {len(measured)} "
1333 for eid
in sorted(set(shipped) - measured):
1336 why = f
" — {placed[eid]['model']}"
1337 if placed[eid][
"npc_param"]
in dev:
1338 why += f
", {dev[placed[eid]['npc_param']]}"
1339 print(f
" NOT A MINIBOSS {eid} shipped as '{shipped[eid]}'{why}")
1340 for eid
in sorted(measured - set(shipped)):
1341 print(f
" MISSING {eid} {printed_name(eid)}")
1342 for eid
in sorted(measured & set(shipped)):
1343 got = printed_name(eid)
1344 if got
and shipped[eid]
not in got:
1345 print(f
" RENAMED {eid} '{shipped[eid]}' -> '{got}'")
1348 rows: dict[str, list] = {}
1349 for e
in sorted(set(mini) | set(both)):
1351 rows.setdefault(area, []).append([e, printed_name(e)])
1354 out = {a: rows[a]
for a
in SDT_AREA_ORDER
if a
in rows}
1355 out.update({a: r
for a, r
in rows.items()
if a
not in out})
1356 Path(args.write).write_text(
1357 json.dumps(out, indent=DB_INDENT, ensure_ascii=
False) +
"\n",
1361 f
"\nwrote {args.write}: {sum(len(v) for v in out.values())} rows in "
1366 Path(args.json).write_text(
1371 "maps": sorted(r[
"maps"]),
1372 "miniboss": r[
"miniboss"],
1374 "name_ids": sorted(r[
"name_ids"]),
1376 {names[n]
for n
in r[
"name_ids"]
if n
in names}
1378 "events": sorted(r[
"events"]),
1380 for e, r
in sorted(roster.items())
1388 print(f
"wrote {args.json} ({len(roster)} entities)")
1393 ap = argparse.ArgumentParser(
1394 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
1396 ap.add_argument(
"--ooz", help=
"path to libooz.so (Oodle Kraken; see the docstring)")
1397 sub = ap.add_subparsers(dest=
"cmd", required=
True)
1399 u = sub.add_parser(
"unpack", help=
"extract files from a game's .bhd/.bdt archives")
1400 u.add_argument(
"--game", default=
"sekiro", choices=sorted(GAMES))
1401 u.add_argument(
"--game-root", required=
True)
1402 u.add_argument(
"--keys", help=
"UXM's ArchiveKeys.cs; DS2 ships its own keys")
1403 u.add_argument(
"--dict", required=
True, help=
"UXM's <game>Dictionary.txt")
1404 u.add_argument(
"--out", required=
True)
1409 help=
"extract every dictionary path starting with this; repeatable",
1412 "--path", action=
"append", default=[], help=
"extract one exact path; repeatable"
1416 action=
"store_true",
1417 help=
"write the .dcx as stored instead of decompressing it",
1419 u.set_defaults(func=cmd_unpack)
1421 m = sub.add_parser(
"msb", help=
"read map layouts: enemy placements and entity ids")
1422 m.add_argument(
"root", help=
"mapstudio directory, or one .msb")
1428 help=
"print the placement carrying this entity id; repeatable",
1430 m.add_argument(
"--enemies", action=
"store_true", help=
"list every enemy placement")
1431 m.set_defaults(func=cmd_msb)
1433 e = sub.add_parser(
"emevd", help=
"read event scripts (DS3 and Sekiro)")
1434 e.add_argument(
"root", help=
"event directory, or one .emevd")
1440 help=
"dump every call of this instruction; repeatable",
1444 action=
"store_true",
1445 help=
"list the death flags the DS3 one-time-enemy templates carry",
1447 e.set_defaults(func=cmd_emevd)
1449 r = sub.add_parser(
"roster", help=
"Sekiro's boss/miniboss roster, from the scripts")
1450 r.add_argument(
"root", help=
"event directory")
1451 r.add_argument(
"--msg", help=
"msg/engus directory, to resolve the printed names")
1452 r.add_argument(
"--maps", help=
"mapstudio directory, to add each entity's model")
1453 r.add_argument(
"--paramdex", help=
"Paramdex checkout, for NpcParam dev names")
1454 r.add_argument(
"--json", help=
"write the full roster here")
1455 r.add_argument(
"--write", help=
"regenerate a minibosses.json at this path")
1456 r.set_defaults(func=cmd_roster)
1458 n = sub.add_parser(
"ernames", help=
"regenerate db_er's item names from the game")
1459 n.add_argument(
"--msg", required=
True, help=
"unpacked msg/engus directory")
1460 n.add_argument(
"--db", default=
"db_er", help=
"the db_er directory to update")
1461 n.add_argument(
"--write", action=
"store_true", help=
"write; otherwise dry-run")
1462 n.set_defaults(func=cmd_ernames)
1464 args = ap.parse_args()
1467 return args.func(args)
1470if __name__ ==
"__main__":
A little-endian struct reader that exits rather than reading past its buffer.
None expect(self, str want)
__init__(self, bytes buf, int pos=0)
dict[int, dict] parse_bhd5(bytes buf, str variant="ds3")
Parse a decrypted BHD5 header into {file name hash: entry}.
str _utf16(bytes b, int off)
A NUL-terminated UTF-16LE string at off.
tuple[str, list[dict]]|None read_msb(Path path)
Parse one .msb into (map name, [part rows]).
er_read_names(msg_dir)
Read Elden Ring's own English item names out of an unpacked msg/engus.
dict[str, str] load_keys(Path path, str dict_name)
One game's public keys out of UXM's ArchiveKeys.cs.
list[int]|None common_init_args(bytes data)
Initialize Common Event's variadic arguments: the event id, then its parameters.
dict[str, list[int]] msb_sections(bytes buf)
Every section of an MSB as {name: [entry offsets]}.
int path_hash(str path, str variant="ds3")
SFUtil.FromPathHash: the only way to find a file whose name was thrown away.
dict[int, dict] build_roster(dict[str, list] scripts)
The boss/miniboss roster keyed by entity id.
str entity_map(int eid)
The map file an entity id belongs to, by the same decomposition the flags use.
dict[str, dict[int, str]] load_fmg_tables(str msg_dir)
Every FMG table in the English message binders, keyed by table name.
int cmd_ernames(args)
Regenerate db_er/'s item-name tables from the game's own FMGs.
list[str] wanted(str dict_path, list[str] prefixes, list[str] paths)
Which dictionary paths to extract, from the prefixes and exact paths given.
bytes rsa_decrypt(bytes data, str pem)
Decrypt a .bhd with a PKCS#1 public key.
bytes ooz_decompress(bytes src, int dst_len)
int cmd_roster(args)
Print the roster, compare it with the shipped table, optionally rewrite it.
list[str] msb_models(bytes buf, list[int] offsets)
Model names in index order, so a part's ModelIndex resolves to c1470_0000.
dict[int, tuple[str, int]] ds3_death_flags(dict[str, list] scripts)
Every death flag the DS3 one-time-enemy templates are initialised with.
list[tuple[int, int, int, bytes]]|None read_emevd(Path path)
Every instruction in one .emevd as (event id, bank, instruction id, argdata), or None if the file is ...
dict[int, str] npc_dev_names(str paramdex)
Paramdex's NpcParam annotations as {row id: english text}.
int msb_section_pos(bytes buf, str name)
Byte offset of one section's header, for reading its version word.
bytes read_entry(bdt, dict entry)
Read one entry out of a .bdt, decrypting the ranges its header marks.
list[dict] msb_parts(bytes buf, list[int] offsets, list[str] models)
Every part in one MSB as a dict, enemies carrying their param joins.
list|None unpack_args(bytes data, list[int] types)
Unpack one instruction's argument blob against a type list.
bytes dcx_decompress(bytes data)
Undo a DCX\0 wrapper, KRAK or DFLT.
tuple[str, int] pick_table(dict[str, dict[int, str]] tables, set[int] ids)
The FMG table covering the most of ids, and how many it covered.
list[Path] ooz_paths(str|None explicit)
Where to find the Kraken decoder.
list[tuple[str, list[dict]]] read_msbs(str root)
Every .msb under a directory, parsed, in map order; unknown layouts skipped.
list[tuple] roster_calls(dict[str, list] scripts)
Every boss/miniboss call in the scripts, as (map, event, kind, entity, name id).
ooz_load(str|None explicit=None)
Load libooz.so once, from the first candidate path that exists.
bytes ooz_decompress_chunked(bytes src, int dst_len)
Kraken decode a stream FROM's way: one 256 KiB chunk at a time.
dict[str, list] read_emevds(str root)
Every .emevd under a directory, as {map name: [instructions]}.
dict read_aes_key(bytes buf, int off)
The per-file AES key and the ranges it covers, or None where there is none.