SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
gen_ds3_npcs.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Build `db_ds3/npcs.json` — Dark Souls III NPC deaths, hostility and quest states.
3
4Sources, both in `test/flag_data/files8/dark_souls_3_flags.csv`: Smithbox's committed
5`Documentation/DS3/Info - QWC Flags.txt`, which is FromSoft's own English descriptions,
6and rows extracted from the decompiled `.emevd` (`20006002` awaits a `CharacterDead` then
7sets a flag; `20006000` sets `HostileNPC` then a flag).
8
9THE BASE IS THE INTERESTING PART, and it is derived rather than taken from anywhere. Three
10DS3 common-group bases had been pinned one differential at a time; they are not
11independent. A group holds `n < 1000`, so it occupies 128 bytes, and groups 0..9 are packed
12128 apart inside the `k = 0` category:
13
14 base(g) = 111 + 128 * g # g < 10
15
16`6 -> 879` and `9 -> 1263` fall straight out of that, which is two of the three already
17known. It predicts `1 -> 239`, and group 1 is where most of these flags live. Checked three
18ways: at 239 all 161 group-1 flags are MONOTONE across a 79-save ladder with 43 ever set,
19the only rival candidate is non-monotone eleven times, and -- the one that settles it --
20flag `1218` "character 3000700 killed" first reads set in exactly the 33:31:38 snapshot,
21which is where Ringfinger Leonhard's own Red Eye Orb pickup flag turns on. 3000700 is
22Leonhard. The whole family dates correctly besides: `4500701` (Ariandel) at 45:06,
23`5100810` (Ringed City) at 56:59, `5000705` (Dreg Heap) at 62:49.
24
25TWO WARNINGS THAT MUST SURVIVE INTO THE REPORT.
26
27 * The QWC descriptions are NOT reliable per row. The same ladder that confirmed the base
28 shows `1158` -- which the source calls "after killing Leonhard" -- never setting on a
29 run where Leonhard demonstrably died. The EMEVD-derived row is right and the English
30 label is on the wrong one. Where a flag has both, the English is kept because it is
31 what a reader can use, and the entity id is appended so a wrong label is checkable.
32 * `character 3000700 killed` is an entity id, not a name. Rows with no English label
33 print that, because the alternative is inventing a name.
34
35Only flags in groups 0..9 are emitted. The `70000` and `73xxx`-`74xxx` shop and handover
36groups are two orders of magnitude past the single-digit grid, so this shortcut cannot
37reach them and each needs its own anchor -- a differential either side of handing something
38to the Shrine Handmaid.
39
40Run from repo root: python3 tools/gen_ds3_npcs.py
41"""
42
43import csv
44import json
45import os
46import re
47import sys
48
49BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
50SRC = os.path.join(BASE, "test", "flag_data", "files8", "dark_souls_3_flags.csv")
51OUT = os.path.join(BASE, "db_ds3", "npcs.json")
52
53# Groups 0..9 live in the k=0 category, packed 128 bytes apart, the first at the same 111
54# every other DS3 group is offset by.
55GROUP_STRIDE = 128
56GROUP_ZERO = 111
57
58TITLE = {
59 "npc_death": "NPCs killed",
60 "npc_turned_hostile": "NPCs turned hostile",
61 "npc_quest": "Questline states",
62 "npc_handover": "Items handed over",
63 "area_entered": "Areas entered",
64 "world_state": "World state",
65 "dlc_ownership": "DLC owned",
66}
67
68
69## @brief Flag id -> (distance into the flag region, bit), or None outside groups 0..9.
70def address(fid):
71 group, n = fid // 1000, fid % 1000
72 if group > 9:
73 return None
74 return (
75 GROUP_ZERO + group * GROUP_STRIDE + (n >> 5) * 4 + 3 - ((n & 31) >> 3),
76 7 - (n & 7),
77 )
78
79
80## @brief The best label for a flag, given every row that carries it.
81# @details An English description beats an entity id, because it is the half a reader can
82# act on. The entity ids are appended when there is a description AND ids, so a label the
83# ladder later proves wrong -- and one already is -- can be checked against the entity
84# that actually died rather than quietly believed.
85def label_for(names):
86 described = [n for n in names if not re.match(r"^character \d+ ", n)]
87 entities = sorted(
88 {m.group(1) for n in names for m in [re.match(r"^character (\d+) ", n)] if m}
89 )
90 if described:
91 text = " / ".join(sorted(set(described)))
92 return f"{text} (entity {', '.join(entities)})" if entities else text
93 return " / ".join(sorted(set(names)))
94
95
96def main():
97 rows = {}
98 with open(SRC, encoding="utf-8") as f:
99 for r in csv.DictReader(f):
100 if not r["flag_id"].isdigit():
101 continue
102 addr = address(int(r["flag_id"]))
103 if addr is None:
104 continue
105 # "after defeating <boss>" rows are boss-death flags wearing an NPC family
106 # name. This repo already reports bosses from two flag families and a soul
107 # floor; a third, noisier list would say nothing new and would disagree with
108 # the other two the moment one of them gains a boss.
109 if r["name"].startswith("after defeating "):
110 continue
111 rows.setdefault((r["family"], addr), []).append(r["name"])
112 if not rows:
113 sys.exit("no rows survived -- check the source CSV")
114 table = {}
115 for (family, addr), names in rows.items():
116 title = TITLE.get(family, family.replace("_", " ").capitalize())
117 table.setdefault(title, []).append([addr[0], addr[1], label_for(names)])
118 for rs in table.values():
119 rs.sort()
120 with open(OUT, "w", encoding="utf-8") as f:
121 json.dump(dict(sorted(table.items())), f, ensure_ascii=False, indent=1)
122 f.write("\n")
123 total = sum(len(v) for v in table.values())
124 print(f"wrote {OUT}: {total} flags in {len(table)} families")
125
126
127if __name__ == "__main__":
128 main()
address(fid)
Flag id -> (distance into the flag region, bit), or None outside groups 0..9.
label_for(names)
The best label for a flag, given every row that carries it.