SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
gamefiles.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3gamefiles.py — read a FromSoftware install directly: archives, maps, event scripts.
4
5WHAT THIS IS FOR
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:
11
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
17
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`).
20
21WHY ONE FILE
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.
25
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
40 written twice.
41
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.
50
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.
56
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.
80
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.
83
84THE OODLE DEPENDENCY
85 Sekiro and later compress with Oodle Kraken, which has no pure-Python decoder. Build
86 `powzix/ooz` as a shared object once:
87
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 \\
94 -o libooz.so
95
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.
98
99USAGE
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]
107
108EXIT CODES
109 0 ok · 2 bad input/paths · 3 parse failure
110"""
111
112from __future__ import annotations
113
114import argparse
115import ctypes
116import json
117import os
118import re
119import struct
120import sys
121import zlib
122from pathlib import Path
123
124sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
125import gen_sdt_from_regulation as fs # noqa: E402 (BND4 + FMG readers, written once)
126
127BASE = Path(__file__).resolve().parent.parent
128
129# ==========================================================================
130# Archives
131# ==========================================================================
132
133##
134# @brief Per-game archive layout: which pairs exist, whose keys open them, which BHD5 shape.
135# @details `keys` names the dictionary inside UXM's `ArchiveKeys.cs`, or None for a game that
136# ships its own key file — DS2 puts `GameDataKeyCode.pem` next to `GameDataEbl.bhd`, so
137# nothing external is needed there. `bhd5` picks the entry stride. Sekiro having no `Data0`
138# is a fact about Sekiro, not a gap in this table.
139#
140# `fmt: off` here and on the other hand-laid-out tables in this file: ruff formats a dict
141# literal one key per line, which is right for code and wrong for a table — this one goes
142# from four rows you can compare at a glance to thirty-five you cannot. The code between
143# the tables is formatted normally.
144# fmt: off
145GAMES = {
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",
149 "DLC1", "DLC2"],
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"},
154 # Elden Ring, including Shadow of the Erdtree: `DLC.bhd/.bdt` is the DLC pair and UXM
155 # publishes its key alongside the four base ones. `sd/` (sound) is deliberately absent —
156 # nothing here wants it and no key for it is published. UNTESTED: written from UXM's
157 # key list and SoulsFormats' `Game.EldenRing`, with no install on this machine yet.
158 "er": {"archives": ["Data0", "Data1", "Data2", "Data3", "DLC"],
159 "keys": "EldenRingKeys", "bhd5": "er", "dict": "EldenRingDictionary.txt"},
160}
161# fmt: on
162
163##
164# @brief The filename hash, per era. Same rolling shape, different width and multiplier.
165# @details Through Sekiro it is `SFUtil.FromPathHash`: uint32, multiplier 37. **Elden Ring
166# widened it to uint64 with multiplier 0x85 (133)** — UXM's `ArchiveDictionary.ComputeHash`
167# switches on `game >= EldenRing`. Using the old one against an ER archive finds nothing at
168# all, which reads as a bad dictionary rather than as a wrong hash.
169HASH_PRIME = {"ds2": (37, 32), "ds3": (37, 32), "er": (0x85, 64)}
170## @brief AES block size, and so the granularity an encrypted range can be decrypted at.
171AES_BLOCK = 16
172
173KEY_HELP = """the RSA keys and the name dictionary are not vendored. Fetch them:
174
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
177
178Dark Souls II needs only the dictionary: its keys ship in the install as *KeyCode.pem.
179"""
180
181
182##
183# @brief One game's public keys out of UXM's `ArchiveKeys.cs`.
184# @details A C# source file rather than anything structured, so this is a text scrape: the
185# named dictionary, then each `["DataN"] = @"...PEM..."` inside it. Scoping to one
186# dictionary body matters — the same file carries DS3's, Sekiro's, Elden Ring's and AC6's,
187# and the wrong game's key fails as plausible garbage rather than as an error.
188#
189# No completeness check on purpose: UXM publishes no key for DS3's `Data0` because that
190# header is not encrypted, so an archive missing here is a fact, not a failure.
191# @param path `ArchiveKeys.cs`. @param dict_name e.g. `SekiroKeys`.
192# @return @c {archive name: PEM text}.
193def load_keys(path: Path, dict_name: str) -> dict[str, str]:
194 src = Path(path).read_text(encoding="utf-8-sig")
195 body = re.search(
196 dict_name + r"\s*=\s*new\s+Dictionary<string,\s*string>\s*\{(.*?)\n\s*\};",
197 src,
198 re.S,
199 )
200 if not body:
201 sys.exit(f"no {dict_name} dictionary in {path}")
202 return dict(
203 re.findall(r'\["([A-Za-z0-9_]+)"\]\s*=\s*@"(.*?)"', body.group(1), re.S)
204 )
205
206
207##
208# @brief Decrypt a `.bhd` with a PKCS#1 public key.
209# @details Raw RSA, deliberately: UXM runs BouncyCastle's `RsaEngine` with no padding
210# scheme, so every 256-byte ciphertext block becomes a 255-byte plaintext block — the
211# engine's own input and output block sizes for a 2048-bit modulus. The left-zero pad is
212# not cosmetic: a block whose plaintext happens to start with a zero byte would otherwise
213# shift the whole rest of the header.
214def rsa_decrypt(data: bytes, pem: str) -> bytes:
215 from cryptography.hazmat.primitives import serialization
216
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
221 out = bytearray()
222 for i in range(0, len(data), in_size):
223 block = data[i : i + in_size]
224 if len(block) < in_size:
225 break
226 out += pow(int.from_bytes(block, "big"), e, n).to_bytes(out_size, "big")
227 return bytes(out)
228
229
230## @brief A little-endian struct reader that exits rather than reading past its buffer.
231class Reader:
232 def __init__(self, buf: bytes, pos: int = 0):
233 self.buf, self.pos = buf, pos
234
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})")
238 v = self.buf[self.pos : self.pos + n]
239 self.pos += n
240 return v
241
242 def u8(self) -> int:
243 return self.take(1)[0]
244
245 def i32(self) -> int:
246 return int.from_bytes(self.take(4), "little", signed=True)
247
248 def u32(self) -> int:
249 return int.from_bytes(self.take(4), "little")
250
251 def i64(self) -> int:
252 return int.from_bytes(self.take(8), "little", signed=True)
253
254 def ascii(self, n: int) -> str:
255 return self.take(n).decode("ascii", "replace")
256
257 def expect(self, want: str) -> None:
258 got = self.ascii(len(want))
259 if got != want:
260 sys.exit(f"expected {want!r} at 0x{self.pos - len(want):X}, got {got!r}")
261
262
263##
264# @brief Parse a decrypted BHD5 header into @c {file name hash: entry}.
265# @details `Game.DarkSouls3` shape is a 32-bit name hash, a padded size, a 64-bit offset,
266# the SHA and AES side-table offsets, then the unpadded size. **DS2 stops before that last
267# field**, so its entries are 32 bytes and not 40 — read a DS2 header on the DS3 stride and
268# it does not error, it walks off by eight bytes per entry and reports an empty archive,
269# which is exactly what a wrong key looks like.
270def parse_bhd5(buf: bytes, variant: str = "ds3") -> dict[int, dict]:
271 r = Reader(buf)
272 r.expect("BHD5")
273 if r.u8() == 0:
274 sys.exit("big-endian BHD5; the PC games' are little-endian")
275 r.u8() # Unk05, "crypto allowed?" per SoulsFormats
276 r.u8(), r.u8()
277 if r.i32() != 1:
278 sys.exit("BHD5 version word is not 1")
279 r.i32() # file size, unused: the buffer is the size
280 bucket_count, buckets_offset = r.i32(), r.i32()
281 r.ascii(r.i32()) # salt; only the SHA side-table needs it
282
283 # Three entry shapes, all 32 or 40 bytes, and the difference is not cosmetic:
284 # ds2 hash(u32) padded(i32) offset(i64) sha(i64) aes(i64) = 32
285 # ds3 the same, then unpadded(i64) = 40
286 # er hash(U64) padded(i32) UNPADDED(i32) offset(i64) sha(i64) aes(i64) = 40
287 # Elden Ring widened the hash to 64 bits and shrank both sizes to 32, so it is the same
288 # length as DS3's and lays out differently — read one as the other and every offset is
289 # garbage while nothing errors.
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)
297 if variant == "er":
298 entry = {
299 "hash": int.from_bytes(e.take(8), "little"),
300 "padded": e.i32(),
301 "unpadded": e.i32(),
302 "offset": e.i64(),
303 }
304 else:
305 entry = {"hash": e.u32(), "padded": e.i32(), "offset": e.i64()}
306 e.i64() # SHA side table, not verified here
307 aes_off = e.i64()
308 if variant == "ds3":
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
313 return out
314
315
316##
317# @brief The per-file AES key and the ranges it covers, or None where there is none.
318# @details Most entries carry no key at all, and the ones that do encrypt only slices — so
319# the ranges have to be honoured rather than decrypting the whole file. A `-1..-1` range is
320# the unused-slot marker SoulsFormats filters out.
321def read_aes_key(buf: bytes, off: int) -> dict:
322 r = Reader(buf, off)
323 key = r.take(AES_BLOCK)
324 ranges = []
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}
330
331
332##
333# @brief `SFUtil.FromPathHash`: the only way to find a file whose name was thrown away.
334# @details The archives store no names. Knowing the path is enough — but only exactly: case
335# and the leading slash both feed the hash, so `/Event/x`, `/event/x` and `event/x` are
336# three different numbers and two of them are in no archive.
337def path_hash(path: str, variant: str = "ds3") -> int:
338 s = path.lower().replace("\\", "/")
339 if not s.startswith("/"):
340 s = "/" + s
341 prime, width = HASH_PRIME[variant]
342 mask = (1 << width) - 1
343 h = 0
344 for c in s:
345 h = (h * prime + ord(c)) & mask
346 return h
347
348
349##
350# @brief Read one entry out of a `.bdt`, decrypting the ranges its header marks.
351# @details The read is padded-size, and the entry's own unpadded size is only trusted when
352# POSITIVE: Sekiro writes 0 there on every entry checked, so honouring it blindly truncates
353# every file to nothing. The real length comes from the DCX header, which is verified
354# anyway. Ranges are clamped to whole AES blocks for the same reason the read is padded.
355def read_entry(bdt, entry: dict) -> bytes:
356 from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
357
358 bdt.seek(entry["offset"])
359 data = bytearray(bdt.read(entry["padded"]))
360 aes = entry["aes"]
361 if aes:
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
366 if span <= 0:
367 continue
368 dec = cipher.decryptor()
369 data[start : start + span] = (
370 dec.update(bytes(data[start : start + span])) + dec.finalize()
371 )
372 if 0 < entry["unpadded"] < len(data):
373 del data[entry["unpadded"] :]
374 return bytes(data)
375
376
377# ==========================================================================
378# DCX, and Oodle
379# ==========================================================================
380
381## @brief Oodle's block length. Every chunk past the first starts a fresh match window.
382OODLE_BLOCK = 0x40000
383##
384# @brief Slack on the Kraken output buffer.
385# @details ooz's decoder writes in quantums and can overshoot the exact declared size while
386# finishing the last one. Sizing to the declared length alone corrupts the heap.
387OOZ_SLACK = 0x10000
388_OOZ = None
389
390
391## @brief Where to find the Kraken decoder. See the module docstring for the build recipe.
392def ooz_paths(explicit: str | None) -> list[Path]:
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")]
397
398
399## @brief Load `libooz.so` once, from the first candidate path that exists.
400def ooz_load(explicit: str | None = None):
401 global _OOZ
402 if _OOZ is None:
403 for cand in ooz_paths(explicit):
404 if cand.is_file():
405 _OOZ = ctypes.CDLL(str(cand))
406 _OOZ.ooz_decompress.restype = ctypes.c_int
407 _OOZ.ooz_decompress.argtypes = [
408 ctypes.c_char_p,
409 ctypes.c_size_t,
410 ctypes.c_char_p,
411 ctypes.c_size_t,
412 ]
413 break
414 else:
415 sys.exit(
416 "no libooz.so found — build it (see this file's docstring) and pass "
417 "--ooz, or set SL2_OOZ_LIB"
418 )
419 return _OOZ
420
421
422def ooz_decompress(src: bytes, dst_len: int) -> bytes:
423 lib = ooz_load()
424 dst = ctypes.create_string_buffer(dst_len + OOZ_SLACK)
425 n = lib.ooz_decompress(src, len(src), dst, dst_len)
426 if n < 0:
427 sys.exit("ooz_decompress failed")
428 return dst.raw[:n]
429
430
431##
432# @brief Kraken decode a stream FROM's way: one 256 KiB chunk at a time.
433# @details THE SINGLE MOST EXPENSIVE THING IN THIS FILE TO LEARN. Handing the whole stream
434# to a Kraken decoder decodes chunk 1 and then fails on chunk 2, in `powzix/ooz` AND in the
435# unrelated Rust `oozextract` — two independent implementations, the same failure, which is
436# what ruled out the decoders; `libooz` decoding ooz's own 21-chunk `xml.kraken` vector
437# cleanly ruled out the build. The difference is the WINDOW: FromSoft compresses each chunk
438# against its own base, and a whole-stream decode hands chunk 2 the buffer's start instead.
439# Decode chunk by chunk and every file decodes.
440#
441# The framing has to be walked to do that, since a chunk's compressed size is only in its
442# own header: two bytes of block header (magic nibble `0xC`, then decoder type and a
443# checksum flag), a three-byte quantum header holding `size - 1` in its low 18 bits, then
444# three more bytes of checksum if the flag is set. An `uncompressed` block carries raw bytes
445# instead, and a zero compressed size is a memset/whole-match chunk with no payload at all.
446def ooz_decompress_chunked(src: bytes, dst_len: int) -> bytes:
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}")
455 frame = 2
456 if b0 & 0x40: # uncompressed block
457 frame += want
458 else:
459 v = int.from_bytes(src[pos + 2 : pos + 5], "big")
460 frame += 3 + (3 if b1 & 0x80 else 0)
461 size = v & 0x3FFFF
462 frame += 0 if size == 0x3FFFF else size + 1
463 out += ooz_decompress(src[pos : pos + frame], want)
464 pos += frame
465 if pos != len(src):
466 sys.exit(f"KRAK stream has {len(src) - pos} trailing byte(s)")
467 return bytes(out)
468
469
470##
471# @brief Undo a `DCX\0` wrapper, KRAK or DFLT. Returns the input unchanged if it is not one.
472# @details The header is BIG-endian inside a format whose every other number is little,
473# which is the one thing here that produces nonsense rather than an error. The declared
474# uncompressed size is checked against what came out, so a wrong decoder fails loudly
475# instead of writing a plausible short file.
476def dcx_decompress(data: bytes) -> bytes:
477 if data[:4] != b"DCX\0":
478 return data
479 fmt = data[0x28:0x2C]
480 uncompressed = int.from_bytes(data[0x1C:0x20], "big")
481 compressed = int.from_bytes(data[0x20:0x24], "big")
482 # DCA\0 then its own length: the payload starts after that, and the length is read
483 # rather than assumed because the DCX permutations differ in header size.
484 dca = data.index(b"DCA\0")
485 body = data[dca + int.from_bytes(data[dca + 4 : dca + 8], "big") :][:compressed]
486 if fmt == b"KRAK":
487 out = ooz_decompress_chunked(body, uncompressed)
488 elif fmt == b"DFLT":
489 out = zlib.decompress(body)
490 elif fmt == b"ZSTD":
491 # Elden Ring's later patches use Zstandard for some files, so it is handled here
492 # rather than discovered at the worst moment. Plain frame, no chunking: this one is
493 # nothing like KRAK.
494 try:
495 import zstandard
496 except ImportError:
497 sys.exit("this file is DCX/ZSTD — pip install zstandard")
498 out = zstandard.ZstdDecompressor().decompress(
499 body, max_output_size=uncompressed
500 )
501 else:
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")
505 return out
506
507
508# ==========================================================================
509# MSB — map layouts
510# ==========================================================================
511
512## @brief Section names, from `MSBS.cs`. Only the two a placement needs are named.
513MODEL_SECTION, PARTS_SECTION = "MODEL_PARAM_ST", "PARTS_PARAM_ST"
514
515##
516# @brief Part type ids from `PartsParam.PartType`.
517# @details Both enemy types matter: `DummyEnemy` placements are cutscene or unused copies
518# and they still carry entity ids, so filtering on "has an id" does not separate them.
519PART_ENEMY, PART_DUMMY_ENEMY = 2, 10
520# fmt: off
521PART_NAMES = {0: "MapPiece", 1: "Object", 2: "Enemy", 4: "Player", 5: "Collision",
522 9: "DummyObject", 10: "DummyEnemy", 11: "ConnectCollision"}
523# fmt: on
524
525## @brief Offsets inside a Part's base struct, and inside an enemy's type data.
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
530
531
532def _i32(b: bytes, o: int) -> int:
533 return struct.unpack_from("<i", b, o)[0]
534
535
536def _u32(b: bytes, o: int) -> int:
537 return struct.unpack_from("<I", b, o)[0]
538
539
540def _i64(b: bytes, o: int) -> int:
541 return struct.unpack_from("<q", b, o)[0]
542
543
544## @brief A NUL-terminated UTF-16LE string at @p off.
545def _utf16(b: bytes, off: int) -> str:
546 end = off
547 while end + 1 < len(b) and b[end : end + 2] != b"\0\0":
548 end += 2
549 return b[off:end].decode("utf-16-le", "replace")
550
551
552##
553# @brief Every section of an MSB as @c {name: [entry offsets]}.
554# @details Walks the chain by each section's own `nextParamOffset`, which is what makes this
555# robust: the section ORDER is a fact about the game version, the chain is a fact about the
556# file.
557def msb_sections(buf: bytes) -> dict[str, list[int]]:
558 if buf[:4] != b"MSB ":
559 sys.exit("not an MSB")
560 out, pos = {}, 0x10
561 while pos:
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
567 pos = nxt
568 return out
569
570
571##
572# @brief Byte offset of one section's header, for reading its version word.
573# @details Walks the same chain `msb_sections` does. Kept separate rather than folded into
574# that function's return shape, because every other caller wants the entry list and nothing
575# else, and the version is only consulted to refuse a layout this reader does not know.
576def msb_section_pos(buf: bytes, name: str) -> int:
577 pos = 0x10
578 while pos:
579 count = _i32(buf, pos + 4)
580 if _utf16(buf, _i64(buf, pos + 8)) == name:
581 return pos
582 pos = _i64(buf, pos + 16 + (count - 1) * 8)
583 sys.exit(f"no {name} section")
584
585
586## @brief Model names in index order, so a part's `ModelIndex` resolves to `c1470_0000`.
587def msb_models(buf: bytes, offsets: list[int]) -> list[str]:
588 return [_utf16(buf, off + _i64(buf, off + P_NAME_OFF)) for off in offsets]
589
590
591##
592# @brief Every part in one MSB as a dict, enemies carrying their param joins.
593# @details The entity id is read for every part type, because objects carry them too. The
594# NPC and think ids exist only on the enemy types and are None elsewhere, rather than a
595# misread of some other struct's bytes.
596def msb_parts(buf: bytes, offsets: list[int], models: list[str]) -> list[dict]:
597 out = []
598 for off in offsets:
599 kind = _u32(buf, off + P_TYPE)
600 mi = _i32(buf, off + P_MODEL_INDEX)
601 row = {
602 "name": _utf16(buf, off + _i64(buf, off + P_NAME_OFF)),
603 "type": kind,
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)),
607 "npc_param": None,
608 "think_param": None,
609 "chara_init": None,
610 "event_flag": None,
611 "event_flag_state": None,
612 }
613 if kind in (PART_ENEMY, PART_DUMMY_ENEMY):
614 t = off + _i64(buf, off + P_TYPE_DATA_OFF)
615 row.update(
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),
621 )
622 out.append(row)
623 return out
624
625
626##
627# @brief Parse one `.msb` into @c (map name, [part rows]). SEKIRO's `MSBS` layout only.
628# @details Elden Ring's `MSBE` shares the section names and the chain, and its Part base
629# struct is NOT the same — extra offsets before the type data. So an ER map would walk this
630# reader without erroring and report entity ids read out of the wrong words. The version
631# word is what separates them, and it is asserted rather than assumed: a file this reader
632# does not know is refused, because a wrong entity id here becomes a wrong flag downstream.
633def read_msb(path: Path) -> tuple[str, list[dict]] | None:
634 buf = Path(path).read_bytes()
635 sec = msb_sections(buf)
636 for want in (MODEL_SECTION, PARTS_SECTION):
637 if want not in sec:
638 sys.exit(f"{path}: no {want} (sections: {', '.join(sec)})")
639 # The section's own version word, not the file header — the header is identical across
640 # MSBS and MSBE. Sekiro's real maps read 0x23 and its tiny `m89` test map reads 0x21;
641 # Elden Ring's is higher, and this reader refuses it rather than reporting entity ids
642 # taken from the wrong words.
643 parts_version = _i32(buf, msb_section_pos(buf, PARTS_SECTION))
644 if parts_version not in MSBS_PARTS_VERSIONS:
645 return None
646 return (
647 Path(path).name.replace(".msb", ""),
648 msb_parts(buf, sec[PARTS_SECTION], msb_models(buf, sec[MODEL_SECTION])),
649 )
650
651
652## @brief `PARTS_PARAM_ST` versions this reader's part struct is known to match (Sekiro).
653MSBS_PARTS_VERSIONS = {0x21, 0x23}
654
655
656## @brief Every `.msb` under a directory, parsed, in map order; unknown layouts skipped.
657def read_msbs(root: str) -> list[tuple[str, list[dict]]]:
658 p = Path(root)
659 paths = [p] if p.is_file() else sorted(p.glob("*.msb"))
660 out = []
661 for f in paths:
662 got = read_msb(f)
663 if got is None:
664 print(
665 f" ! {f.name}: not a Sekiro MSBS part layout (Elden Ring's MSBE is not "
666 f"implemented), skipped",
667 file=sys.stderr,
668 )
669 continue
670 out.append(got)
671 return out
672
673
674##
675# @brief The map file an entity id belongs to, by the same decomposition the flags use.
676# @details A Sekiro entity id is `AAB nnnn` — area, sub-area, placement — so `1120450` is
677# `m11_02`. That identical decomposition is the whole reason a defeat flag and an entity id
678# can be the same number.
679def entity_map(eid: int) -> str:
680 return f"m{eid // 100000 % 100:02d}_{eid // 10000 % 10:02d}"
681
682
683# ==========================================================================
684# EMEVD — event scripts
685# ==========================================================================
686
687## @brief The instructions that define a roster, and what each contributes.
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)
691## @brief `Initialize Common Event` — how a parameterised handler gets its real arguments.
692INIT_COMMON_EVENT = (2000, 6)
693
694## @brief EMEDF numeric type code -> (struct format, size).
695# fmt: off
696ARG_TYPES = {0: ("B", 1), 1: ("H", 2), 2: ("I", 4), 3: ("b", 1), 4: ("h", 2),
697 5: ("i", 4), 6: ("f", 4)}
698# fmt: on
699
700## @brief Argument layouts for the instructions this file reads, from the EMEDF.
701# fmt: off
702LAYOUTS = {
703 MINIBOSS_BAR: [3, 5, 4, 5], # enabled, entity, slot, name id
704 BOSS_BAR: [3, 5, 4, 5],
705 MINIBOSS_DEFEAT: [5], # entity
706 BOSS_DEFEAT: [5],
707 BOSS_BANNER: [5, 0], # entity, banner type
708 AWARD_ITEM_LOT: [5], # item lot id
709}
710# fmt: on
711
712##
713# @brief The Dark Souls III `common_func` templates that flag a one-time enemy death.
714# @details Each takes a death flag and an entity id (some take more). Reading these out of
715# an install is how `db_ds3/enemies.json` was confirmed: 125 of the 130 flags they carry
716# land on a byte and bit that table already has, and none contradict it.
717# fmt: off
718DS3_DEATH_TEMPLATES = (20005340, 20005341, 20005342, 20000343, 20005416, 20005061, 20005760)
719# fmt: on
720
721
722##
723# @brief Unpack one instruction's argument blob against a type list.
724# @details Each field is aligned to `min(its size, 4)` before it is read — FromSoft's own
725# packing, and the thing that silently shifts every later argument if you skip it. A blob
726# shorter than the layout demands returns None rather than a wrong number.
727def unpack_args(data: bytes, types: list[int]) -> list | None:
728 out, pos = [], 0
729 for t in types:
730 fmt, size = ARG_TYPES[t]
731 pos += (-pos) % min(size, 4)
732 if pos + size > len(data):
733 return None
734 out.append(struct.unpack_from("<" + fmt, data, pos)[0])
735 pos += size
736 return out
737
738
739##
740# @brief `Initialize Common Event`'s variadic arguments: the event id, then its parameters.
741# @details The EMEDF entry is `[Event ID, Parameters]` with `Parameters` repeating, so the
742# blob is a plain run of u32 with no alignment to honour.
743def common_init_args(data: bytes) -> list[int] | None:
744 if len(data) < 4 or len(data) % 4:
745 return None
746 return list(struct.unpack_from(f"<{len(data) // 4}I", data, 0))
747
748
749##
750# @brief Every instruction in one `.emevd` as @c (event id, bank, instruction id, argdata),
751# or None if the file is not the DS3/Sekiro shape.
752# @details Sekiro sets `unk07` and DS3 does not; both are version `0xCD` with the same
753# tables, so both are read. An older permutation is SKIPPED rather than guessed at — DS3
754# ships eight stray `m20_*`/`m21_00`/`m29_*` scripts in the Bloodborne shape (version
755# `0xCC`, no unicode flag) whose varint width differs, and parsing those on this layout
756# would invent events.
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")
761 if (
762 buf[4] != 0
763 or buf[5] != 0xFF
764 or buf[6] != 1
765 or buf[7] not in (0, 0xFF)
766 or _i32(buf, 8) != 0xCD
767 ):
768 return None
769
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]
772
773 out = []
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)
777 for i in range(n):
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))
782 return out
783
784
785## @brief Every `.emevd` under a directory, as @c {map name: [instructions]}.
786def read_emevds(root: str) -> dict[str, list]:
787 p = Path(root)
788 paths = [p] if p.is_file() else sorted(p.glob("*.emevd"))
789 out = {}
790 for f in paths:
791 got = read_emevd(f)
792 if got is None:
793 print(f" ! {f.name}: not a DS3/Sekiro EMEVD, skipped", file=sys.stderr)
794 continue
795 out[f.name.replace(".emevd", "")] = got
796 return out
797
798
799##
800# @brief Every boss/miniboss call in the scripts, as @c (map, event, kind, entity, name id).
801# @details TWO MECHANISMS, and missing the second is why a first pass finds ONE miniboss in
802# the whole of Sekiro. Bosses are handled inline, so their entity id is a literal in the
803# instruction. Minibosses go through a single PARAMETERISED event in `common_func` whose own
804# copy of `Display Miniboss Health Bar` has zeroes where the entity and name belong; the
805# real values arrive per placement through `Initialize Common Event`.
806#
807# The handler set is DISCOVERED, not hardcoded — any common event carrying one of the five
808# instructions counts — so a patch adding a second handler cannot silently halve the roster.
809# In Sekiro 1.06 there is exactly one, `20005330`, called 41 times with
810# `(entity, name id, 0, kind)`.
811def roster_calls(scripts: dict[str, list]) -> list[tuple]:
812 kinds = {MINIBOSS_BAR, BOSS_BAR, MINIBOSS_DEFEAT, BOSS_DEFEAT, BOSS_BANNER}
813 handlers = {
814 eid: (b, i)
815 for name, instrs in scripts.items()
816 if name.startswith("common")
817 for eid, b, i, _d in instrs
818 if (b, i) in kinds
819 }
820
821 out = []
822 for mapname, instrs in scripts.items():
823 for eid, b, i, data in instrs:
824 key = (b, i)
825 if key in kinds:
826 vals = unpack_args(data, LAYOUTS[key])
827 if not vals:
828 continue
829 if key in (MINIBOSS_BAR, BOSS_BAR):
830 out.append((mapname, eid, key, vals[1], vals[3]))
831 else:
832 out.append((mapname, eid, key, vals[0], None))
833 elif key == INIT_COMMON_EVENT:
834 vals = common_init_args(data)
835 if not vals or vals[0] not in handlers:
836 continue
837 out.append(
838 (
839 mapname,
840 vals[0],
841 handlers[vals[0]],
842 vals[1] if len(vals) > 1 else 0,
843 vals[2] if len(vals) > 2 else None,
844 )
845 )
846 return out
847
848
849##
850# @brief The boss/miniboss roster keyed by entity id.
851# @details An entity can be named by its health bar in one event and defeated in another, so
852# the two halves meet on the entity. `miniboss` and `boss` are not exclusive by construction
853# — a placement called by both is reported as such rather than silently filed under one.
854def build_roster(scripts: dict[str, list]) -> dict[int, dict]:
855 roster: dict[int, dict] = {}
856 for mapname, eid, key, entity, name_id in roster_calls(scripts):
857 if entity <= 0:
858 continue
859 row = roster.setdefault(
860 entity,
861 {
862 "entity": entity,
863 "maps": set(),
864 "name_ids": set(),
865 "miniboss": False,
866 "boss": False,
867 "events": set(),
868 },
869 )
870 row["maps"].add(mapname)
871 row["events"].add(eid)
872 if key in (MINIBOSS_BAR, MINIBOSS_DEFEAT):
873 row["miniboss"] = True
874 else:
875 row["boss"] = True
876 if name_id and name_id > 0:
877 row["name_ids"].add(name_id)
878 return roster
879
880
881##
882# @brief Every death flag the DS3 one-time-enemy templates are initialised with.
883# @details The first template argument is the flag; the rest are entity ids and extras that
884# differ per template. Returns @c {flag: (map, template)}.
885def ds3_death_flags(scripts: dict[str, list]) -> dict[int, tuple[str, int]]:
886 out = {}
887 for mapname, instrs in scripts.items():
888 for _eid, b, i, data in instrs:
889 if (b, i) != INIT_COMMON_EVENT:
890 continue
891 vals = common_init_args(data)
892 if vals and len(vals) > 1 and vals[0] in DS3_DEATH_TEMPLATES:
893 out.setdefault(vals[1], (mapname, vals[0]))
894 return out
895
896
897# ==========================================================================
898# Names
899# ==========================================================================
900
901
902##
903# @brief Every FMG table in the English message binders, keyed by table name.
904# @details Which table holds an NPC name is not assumed: every table is loaded and the
905# caller picks by coverage of the ids it is resolving. Same probe-then-report discipline the
906# param extractor uses. An unpacked tree has no `.dcx` suffix; a raw one does; both work.
907def load_fmg_tables(msg_dir: str) -> dict[str, dict[int, str]]:
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():
913 continue
914 for bf in fs.read_bnd4(path.read_bytes()):
915 key = Path(bf.name.replace("\\", "/")).name.rsplit(".", 1)[0]
916 try:
917 tables[key] = fs.read_fmg(bf.data)
918 except Exception as exc: # noqa: BLE001
919 print(f" ! {cand}:{key} unreadable ({exc})", file=sys.stderr)
920 break
921 return tables
922
923
924## @brief The FMG table covering the most of @p ids, and how many it covered.
925def pick_table(tables: dict[str, dict[int, str]], ids: set[int]) -> tuple[str, int]:
926 best, hits = "", -1
927 for name, tbl in tables.items():
928 n = sum(1 for i in ids if tbl.get(i))
929 if n > hits:
930 best, hits = name, n
931 return best, hits
932
933
934## @brief Paramdex's `NpcParam` annotations as @c {row id: english text}.
935def npc_dev_names(paramdex: str) -> dict[int, str]:
936 path = Path(paramdex) / "SDT" / "Names" / "NpcParam.txt"
937 out = {}
938 if not path.is_file():
939 return out
940 for line in path.read_text(encoding="utf-8").splitlines():
941 m = re.match(r"^(\d+)\s+(.*)$", line.strip())
942 if m:
943 out[int(m.group(1))] = m.group(2).split(" -- ")[0].strip()
944 return out
945
946
947##
948# @brief The travel-menu area a Sekiro entity id belongs to, for the shipped table's keys.
949# @details `db_sdt/minibosses.json` is keyed by the game's TRAVEL AREAS and the maps are the
950# game's FILES — two different partitions, the same split the idol table lives with. This
951# mapping is the one the shipped table already uses, so a regenerated table keeps its shape;
952# the two Ashina Castle map files both fall under one menu area, and `m11_02` is the
953# Reservoir, which the menu files under its own name.
954# fmt: off
955SDT_AREA_BY_MAP = {
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",
960}
961# fmt: on
962
963##
964# @brief The order areas are printed in: the game's own travel-menu order.
965# @details `db_sdt/idols.json` uses exactly this sequence, so the two Sekiro progress
966# sections read in the same order, and a regenerated table does not reshuffle the output.
967# Sorting by map id instead would put Hirata before the Outskirts, which is neither the
968# menu's order nor the order the player sees them in. The Reservoir has no key of its own
969# in `idols.json` — its idol is filed under Ashina Castle — but it does have minibosses,
970# so it is inserted where the menu puts it rather than appended.
971# fmt: off
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"]
975# fmt: on
976
977##
978# @brief Indent for a generated `db_*` table. ONE space, matching every other table there.
979# @details `.prettierignore` excludes `db_*/` on purpose — these are data, not code — so
980# nothing reformats them afterwards and the house style has to be produced here. Writing
981# prettier's two-space default instead reflows the whole file and buries the real diff.
982DB_INDENT = 1
983
984
985# ==========================================================================
986# Subcommands
987# ==========================================================================
988
989
990## @brief Which dictionary paths to extract, from the prefixes and exact paths given.
991def wanted(dict_path: str, prefixes: list[str], paths: list[str]) -> list[str]:
992 names = [
993 ln.strip()
994 for ln in Path(dict_path).read_text(encoding="utf-8-sig").splitlines()
995 if ln.strip()
996 ]
997 if not prefixes and not paths:
998 return names
999 keep = [n for n in names if any(n.startswith(p) for p in prefixes)]
1000 known = set(keep)
1001 return keep + [p for p in paths if p not in known]
1002
1003
1004def cmd_unpack(args) -> int:
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())
1008 ):
1009 print(KEY_HELP, file=sys.stderr)
1010 return 2
1011 keys = load_keys(args.keys, spec["keys"]) if spec["keys"] else {}
1012
1013 # One index over every archive, so a lookup does not care which one a file landed in —
1014 # the split is a packaging detail, not a namespace. A missing archive is skipped, not
1015 # fatal: one install's DLC layout should not stop the run.
1016 index: dict[int, tuple[Path, dict]] = {}
1017 for name in spec["archives"]:
1018 bhd, bdt = (
1019 Path(args.game_root) / f"{name}.bhd",
1020 Path(args.game_root) / f"{name}.bdt",
1021 )
1022 if not bhd.is_file() or not bdt.is_file():
1023 print(f"{name}: absent, skipped")
1024 continue
1025 raw = bhd.read_bytes()
1026 # NOT EVERY HEADER IS ENCRYPTED — DS3's `Data0` is a plain BHD5, which is why UXM
1027 # publishes no key for it — so the magic decides and a key is only demanded when the
1028 # bytes are not already a header.
1029 if raw[:4] == b"BHD5":
1030 header = raw
1031 else:
1032 pem = keys.get(name)
1033 if pem is None:
1034 own = Path(args.game_root) / f"{name.replace('Ebl', '')}KeyCode.pem"
1035 if own.is_file():
1036 pem = own.read_text(encoding="utf-8")
1037 if pem is None:
1038 # DS3's `Data0` is encrypted some other way again and nobody publishes a key
1039 # for it. It holds `regulation.bin`, which Paramdex covers anyway.
1040 print(f"{name}: encrypted with no published key, skipped")
1041 continue
1042 header = rsa_decrypt(raw, pem)
1043 entries = parse_bhd5(header, spec["bhd5"])
1044 print(f"{name}: {len(entries)} entries")
1045 for h, e in entries.items():
1046 index.setdefault(h, (bdt, e))
1047
1048 todo = wanted(args.dict, args.prefix, args.path)
1049 print(f"{len(todo)} path(s) requested, {len(index)} entries indexed")
1050
1051 open_bdt: dict[Path, object] = {}
1052 done, missing, failed = 0, [], []
1053 for path in todo:
1054 hit = index.get(path_hash(path, spec["bhd5"]))
1055 if hit is None:
1056 missing.append(path)
1057 continue
1058 bdt_path, entry = hit
1059 if bdt_path not in open_bdt:
1060 open_bdt[bdt_path] = open(bdt_path, "rb")
1061 data = read_entry(open_bdt[bdt_path], entry)
1062 rel = path.lstrip("/")
1063 if not args.keep_dcx:
1064 # A file this decoder cannot open is REPORTED AND SKIPPED, not fatal: a few
1065 # `drawparam/*.gparam.dcx` use Oodle decoder type 2 (LZNIB), which `ooz` does
1066 # not implement, and aborting over a lighting table would take the params and
1067 # the event scripts down with it.
1068 try:
1069 data = dcx_decompress(data)
1070 except SystemExit as why:
1071 failed.append(f"{path} ({why})")
1072 continue
1073 if rel.endswith(".dcx"):
1074 rel = rel[:-4]
1075 dest = Path(args.out) / rel
1076 dest.parent.mkdir(parents=True, exist_ok=True)
1077 dest.write_bytes(data)
1078 done += 1
1079 for f in open_bdt.values():
1080 f.close()
1081
1082 print(f"wrote {done} file(s) to {args.out}")
1083 for f in failed:
1084 print(f" ! could not decompress {f}")
1085 if missing:
1086 print(
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 "")
1090 )
1091 return 0
1092
1093
1094def cmd_msb(args) -> int:
1095 maps = read_msbs(args.root)
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"]]
1099 print(
1100 f"{name}: {len(rows)} parts, {len(enemies)} enemies, "
1101 f"{len(ided)} with an entity id"
1102 )
1103
1104 if args.entity:
1105 want = set(args.entity)
1106 for name, rows in maps:
1107 for r in rows:
1108 if r["entity_id"] in want:
1109 print(f"\n{name} entity {r['entity_id']}")
1110 for k, v in r.items():
1111 if v is not None:
1112 print(f" {k}: {v}")
1113
1114 if args.enemies:
1115 for name, rows in maps:
1116 for r in rows:
1117 if r["type"] in (PART_ENEMY, PART_DUMMY_ENEMY) and r["entity_id"]:
1118 print(
1119 f"{name}\t{r['entity_id']}\t{r['model']}\t{r['npc_param']}"
1120 f"\t{r['type_name']}\t{r['name']}"
1121 )
1122 return 0
1123
1124
1125def cmd_emevd(args) -> int:
1126 scripts = read_emevds(args.root)
1127 print(
1128 f"{len(scripts)} script(s), {sum(len(v) for v in scripts.values())} instructions"
1129 )
1130
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}")
1140
1141 if args.ds3_deaths:
1142 flags = ds3_death_flags(scripts)
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}")
1146 return 0
1147
1148
1149## @brief The five `item.msgbnd` name tables that back a `db_er/` file, and the category
1150# nibble Elden Ring's save ids carry on top of the bare param row id.
1151# @details `db_er/` is keyed by the id as the SAVE stores it, so the nibble goes back on
1152# before writing. Weapons are nibble 0, which is why they are the one category whose db
1153# keys already match the game's row ids one for one.
1154ER_NAME_FMGS = {
1155 "WeaponName": ("weapons", 0x00000000),
1156 "ProtectorName": ("armors", 0x10000000),
1157 "AccessoryName": ("talismans", 0x20000000),
1158 "GoodsName": ("goods", 0x40000000),
1159 "GemName": ("ashes", 0x80000000),
1160}
1161
1162
1163## @brief FromSoft's marker for a row that exists but carries no player-facing name.
1164# @details It appears either alone or in front of a real string. A bare one means the
1165# row is not an item the player ever sees named; a prefixed one is a real name with a
1166# development marker still on it, so the marker comes off and the name stays.
1167ER_FMG_PLACEHOLDER = "[ERROR]"
1168
1169
1170##
1171# @brief Read Elden Ring's own English item names out of an unpacked `msg/engus`.
1172# @details Three binders are merged in patch order — base, then each DLC layer — and a
1173# real name never loses to a placeholder or a blank, whichever binder it came from. The
1174# result is split into `named` (what the game calls the row) and `blank` (rows the game
1175# lists and deliberately leaves nameless), because the difference between those two is
1176# what decides whether a `db_er` row that the game does not name is worth keeping.
1177# @param msg_dir The unpacked `msg/engus` directory.
1178# @return `(named, blank)`, both keyed by category then bare row id.
1179def er_read_names(msg_dir):
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()}
1182 seen = 0
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")
1187 continue
1188 seen += 1
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])
1192 if entry is None:
1193 continue
1194 cat = entry[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()
1198 if real:
1199 named[cat][rid] = real
1200 blank[cat].discard(rid)
1201 elif rid not in named[cat]:
1202 blank[cat].add(rid)
1203 if not seen:
1204 print(f"no item.msgbnd under {msg_dir}", file=sys.stderr)
1205 return named, blank
1206
1207
1208##
1209# @brief Regenerate `db_er/`'s item-name tables from the game's own FMGs.
1210# @details The tables were transcribed from a community id list, and this is what checks
1211# them against the game that owns them. The merge rule turns on a distinction the FMGs
1212# make and a flat id list cannot:
1213#
1214# * the game names the row → the game wins, always;
1215# * the game lists it, nameless → DROP it, whatever the old table said. This is what
1216# clears `db_er/ashes.json`'s forward-filled junk: 123 rows the game leaves blank,
1217# which the old table had filled by repeating the previous name — sixteen separate
1218# ids all reading "Ash of War: Lion's Claw", and seven reading "Ash of War:";
1219# * the game has no such row → KEEP the old name. These are the `[NPC]`-prefixed
1220# weapons and the NPC flask variants, real rows the shipped FMGs simply do not name.
1221#
1222# The split is clean rather than convenient: across `db_er` every db-only ash falls in
1223# the second case and every db-only weapon and good in the third, with nothing straddling.
1224# @param args Parsed CLI arguments.
1225# @return Process exit status.
1226def cmd_ernames(args) -> int:
1227 named, blank = er_read_names(args.msg)
1228 if not any(named.values()):
1229 return 2
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"
1232 old = {}
1233 if path.is_file():
1234 old = {
1235 int(k, 16): v for k, v in json.loads(path.read_text("utf-8")).items()
1236 }
1237 out = {rid | nib: nm for rid, nm in named[cat].items()}
1238 kept = 0
1239 for key, nm in old.items():
1240 rid = key & 0x0FFFFFFF
1241 if rid in named[cat] or rid in blank[cat]:
1242 continue
1243 out[key] = nm
1244 kept += 1
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)
1247 print(
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})"
1250 )
1251 if not args.write:
1252 continue
1253 text = json.dumps(
1254 {f"{k:X}": out[k] for k in sorted(out)},
1255 indent=DB_INDENT,
1256 ensure_ascii=False,
1257 )
1258 path.write_text(text + "\n", encoding="utf-8")
1259 if not args.write:
1260 print("\n(dry run — pass --write to update the tables)")
1261 return 0
1262
1263
1264##
1265# @brief Print the roster, compare it with the shipped table, optionally rewrite it.
1266# @details The comparison is the point. It reports three kinds of disagreement separately,
1267# because they need different judgements: an id the scripts do not call a miniboss (a row
1268# that should go), a miniboss the table lacks (a row that should arrive), and a row whose id
1269# is right but whose name is a model family rather than a character.
1270def cmd_roster(args) -> int:
1271 scripts = read_emevds(args.root)
1272 print(
1273 f"{len(scripts)} script(s), {sum(len(v) for v in scripts.values())} instructions"
1274 )
1275 roster = build_roster(scripts)
1276
1277 name_ids = {n for r in roster.values() for n in r["name_ids"] if n > 0}
1278 names: dict[int, str] = {}
1279 if args.msg:
1280 tables = load_fmg_tables(args.msg)
1281 table, hits = pick_table(tables, name_ids)
1282 print(
1283 f"name table: {table} ({hits} of {len(name_ids)} name ids resolved, "
1284 f"{len(tables)} tables probed)"
1285 )
1286 names = tables.get(table, {})
1287
1288 placed = {}
1289 if args.maps:
1290 for mapname, rows in read_msbs(args.maps):
1291 for r in rows:
1292 if r["type"] in (PART_ENEMY, PART_DUMMY_ENEMY) and r["entity_id"]:
1293 placed[r["entity_id"]] = dict(r, map=mapname)
1294 dev = npc_dev_names(args.paramdex) if args.paramdex else {}
1295
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"])
1299 print(
1300 f"\n{len(mini)} miniboss entities, {len(boss)} boss entities, "
1301 f"{len(both)} called by both"
1302 )
1303
1304 def printed_name(e: int) -> str:
1305 got = sorted(
1306 {names.get(n, f"name#{n}") for n in roster[e]["name_ids"] if n > 0}
1307 )
1308 return ", ".join(got) or "(no health bar)"
1309
1310 for label, ids in (("MINIBOSSES", mini), ("BOSSES", boss), ("BOTH", both)):
1311 if not ids:
1312 continue
1313 print(f"\n=== {label} ===")
1314 for e in ids:
1315 extra = ""
1316 if e in placed:
1317 extra = f" {placed[e]['model']}"
1318 if placed[e]["npc_param"] in dev:
1319 extra += f" [{dev[placed[e]['npc_param']]}]"
1320 print(
1321 f" {e} {printed_name(e):38} {'/'.join(sorted(roster[e]['maps']))}{extra}"
1322 )
1323
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)
1329 print(
1330 f"\n=== db_sdt/minibosses.json: {len(shipped)} rows vs {len(measured)} "
1331 f"measured ==="
1332 )
1333 for eid in sorted(set(shipped) - measured):
1334 why = ""
1335 if eid in placed:
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}'")
1346
1347 if args.write:
1348 rows: dict[str, list] = {}
1349 for e in sorted(set(mini) | set(both)):
1350 area = SDT_AREA_BY_MAP.get(entity_map(e), entity_map(e))
1351 rows.setdefault(area, []).append([e, printed_name(e)])
1352 # Travel-menu order, then anything this table has never seen, so a new area shows up
1353 # at the end rather than silently sorting itself into the middle.
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",
1358 encoding="utf-8",
1359 )
1360 print(
1361 f"\nwrote {args.write}: {sum(len(v) for v in out.values())} rows in "
1362 f"{len(out)} areas"
1363 )
1364
1365 if args.json:
1366 Path(args.json).write_text(
1367 json.dumps(
1368 {
1369 str(e): {
1370 "entity": e,
1371 "maps": sorted(r["maps"]),
1372 "miniboss": r["miniboss"],
1373 "boss": r["boss"],
1374 "name_ids": sorted(r["name_ids"]),
1375 "names": sorted(
1376 {names[n] for n in r["name_ids"] if n in names}
1377 ),
1378 "events": sorted(r["events"]),
1379 }
1380 for e, r in sorted(roster.items())
1381 },
1382 indent=2,
1383 ensure_ascii=False,
1384 )
1385 + "\n",
1386 encoding="utf-8",
1387 )
1388 print(f"wrote {args.json} ({len(roster)} entities)")
1389 return 0
1390
1391
1392def main() -> int:
1393 ap = argparse.ArgumentParser(
1394 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
1395 )
1396 ap.add_argument("--ooz", help="path to libooz.so (Oodle Kraken; see the docstring)")
1397 sub = ap.add_subparsers(dest="cmd", required=True)
1398
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)
1405 u.add_argument(
1406 "--prefix",
1407 action="append",
1408 default=[],
1409 help="extract every dictionary path starting with this; repeatable",
1410 )
1411 u.add_argument(
1412 "--path", action="append", default=[], help="extract one exact path; repeatable"
1413 )
1414 u.add_argument(
1415 "--keep-dcx",
1416 action="store_true",
1417 help="write the .dcx as stored instead of decompressing it",
1418 )
1419 u.set_defaults(func=cmd_unpack)
1420
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")
1423 m.add_argument(
1424 "--entity",
1425 type=int,
1426 action="append",
1427 default=[],
1428 help="print the placement carrying this entity id; repeatable",
1429 )
1430 m.add_argument("--enemies", action="store_true", help="list every enemy placement")
1431 m.set_defaults(func=cmd_msb)
1432
1433 e = sub.add_parser("emevd", help="read event scripts (DS3 and Sekiro)")
1434 e.add_argument("root", help="event directory, or one .emevd")
1435 e.add_argument(
1436 "--instr",
1437 action="append",
1438 default=[],
1439 metavar="BANK:ID",
1440 help="dump every call of this instruction; repeatable",
1441 )
1442 e.add_argument(
1443 "--ds3-deaths",
1444 action="store_true",
1445 help="list the death flags the DS3 one-time-enemy templates carry",
1446 )
1447 e.set_defaults(func=cmd_emevd)
1448
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)
1457
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)
1463
1464 args = ap.parse_args()
1465 if args.ooz:
1466 ooz_load(args.ooz)
1467 return args.func(args)
1468
1469
1470if __name__ == "__main__":
1471 sys.exit(main())
A little-endian struct reader that exits rather than reading past its buffer.
Definition gamefiles.py:231
None expect(self, str want)
Definition gamefiles.py:257
bytes take(self, int n)
Definition gamefiles.py:235
int i32(self)
Definition gamefiles.py:245
str ascii(self, int n)
Definition gamefiles.py:254
__init__(self, bytes buf, int pos=0)
Definition gamefiles.py:232
int i64(self)
Definition gamefiles.py:251
dict[int, dict] parse_bhd5(bytes buf, str variant="ds3")
Parse a decrypted BHD5 header into {file name hash: entry}.
Definition gamefiles.py:270
str _utf16(bytes b, int off)
A NUL-terminated UTF-16LE string at off.
Definition gamefiles.py:545
tuple[str, list[dict]]|None read_msb(Path path)
Parse one .msb into (map name, [part rows]).
Definition gamefiles.py:633
er_read_names(msg_dir)
Read Elden Ring's own English item names out of an unpacked msg/engus.
int cmd_msb(args)
dict[str, str] load_keys(Path path, str dict_name)
One game's public keys out of UXM's ArchiveKeys.cs.
Definition gamefiles.py:193
list[int]|None common_init_args(bytes data)
Initialize Common Event's variadic arguments: the event id, then its parameters.
Definition gamefiles.py:743
dict[str, list[int]] msb_sections(bytes buf)
Every section of an MSB as {name: [entry offsets]}.
Definition gamefiles.py:557
int path_hash(str path, str variant="ds3")
SFUtil.FromPathHash: the only way to find a file whose name was thrown away.
Definition gamefiles.py:337
dict[int, dict] build_roster(dict[str, list] scripts)
The boss/miniboss roster keyed by entity id.
Definition gamefiles.py:854
str entity_map(int eid)
The map file an entity id belongs to, by the same decomposition the flags use.
Definition gamefiles.py:679
dict[str, dict[int, str]] load_fmg_tables(str msg_dir)
Every FMG table in the English message binders, keyed by table name.
Definition gamefiles.py:907
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.
Definition gamefiles.py:991
bytes rsa_decrypt(bytes data, str pem)
Decrypt a .bhd with a PKCS#1 public key.
Definition gamefiles.py:214
bytes ooz_decompress(bytes src, int dst_len)
Definition gamefiles.py:422
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.
Definition gamefiles.py:587
dict[int, tuple[str, int]] ds3_death_flags(dict[str, list] scripts)
Every death flag the DS3 one-time-enemy templates are initialised with.
Definition gamefiles.py:885
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 ...
Definition gamefiles.py:757
int _u32(bytes b, int o)
Definition gamefiles.py:536
dict[int, str] npc_dev_names(str paramdex)
Paramdex's NpcParam annotations as {row id: english text}.
Definition gamefiles.py:935
int msb_section_pos(bytes buf, str name)
Byte offset of one section's header, for reading its version word.
Definition gamefiles.py:576
bytes read_entry(bdt, dict entry)
Read one entry out of a .bdt, decrypting the ranges its header marks.
Definition gamefiles.py:355
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.
Definition gamefiles.py:596
int main()
list|None unpack_args(bytes data, list[int] types)
Unpack one instruction's argument blob against a type list.
Definition gamefiles.py:727
int cmd_emevd(args)
bytes dcx_decompress(bytes data)
Undo a DCX\0 wrapper, KRAK or DFLT.
Definition gamefiles.py:476
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.
Definition gamefiles.py:925
list[Path] ooz_paths(str|None explicit)
Where to find the Kraken decoder.
Definition gamefiles.py:392
list[tuple[str, list[dict]]] read_msbs(str root)
Every .msb under a directory, parsed, in map order; unknown layouts skipped.
Definition gamefiles.py:657
int _i32(bytes b, int o)
Definition gamefiles.py:532
int cmd_unpack(args)
list[tuple] roster_calls(dict[str, list] scripts)
Every boss/miniboss call in the scripts, as (map, event, kind, entity, name id).
Definition gamefiles.py:811
int _i64(bytes b, int o)
Definition gamefiles.py:540
ooz_load(str|None explicit=None)
Load libooz.so once, from the first candidate path that exists.
Definition gamefiles.py:400
bytes ooz_decompress_chunked(bytes src, int dst_len)
Kraken decode a stream FROM's way: one 256 KiB chunk at a time.
Definition gamefiles.py:446
dict[str, list] read_emevds(str root)
Every .emevd under a directory, as {map name: [instructions]}.
Definition gamefiles.py:786
dict read_aes_key(bytes buf, int off)
The per-file AES key and the ranges it covers, or None where there is none.
Definition gamefiles.py:321