SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
gen_ds1_known_flags.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Build `db_ds1/known_flags.json` — Dark Souls 1 world state, by event flag.
3
4WHAT THIS UNLOCKS
5 The repo has read DS1's event-flag region since the boss-flag work
6 (`DS1_FLAG_BASE`, found by search because DS1's published group bases are MEMORY
7 offsets). It has only ever read twelve boss flags out of it. There are fifty-two
8 more named flags sitting in `test/flag_data/files2/ds1_known_event_flags.csv` — the
9 Bells of Awakening, the Lordvessel, the shortcut doors and levers, the non-boss fog
10 gates, NPC states, covenants joined — and every one is addressable with machinery
11 that already exists. This turns them into a readable section instead of a file
12 nobody opened.
13
14THE ADDRESSING, AND WHY IT IS TRUSTWORTHY HERE
15 An 8-digit DS1 flag is `G AAA S NNN`: a group digit, a three-digit area, a section
16 digit and the flag number. From `ds1_flag_addressing.csv`:
17
18 offset = group_base[G] + area_index[AAA]*0x500 + S*128 + (NNN - NNN%32)/8
19 mask = 0x80000000 >> (NNN % 32)
20
21 That is not taken on faith. Run over the twelve boss ids this repo already ships as
22 hand-checked `(offset, mask)` pairs in `db_ds1/boss_flags.json`, it reproduces all
23 twelve exactly. A formula that regenerates an independently-derived table is a
24 formula worth pointing at new ids.
25
26WHAT IS DELIBERATELY DROPPED
27 A flag whose group or area is not in the addressing table is skipped rather than
28 guessed — the same rule the DS3 pickup groups follow. Anything not 8 digits is an
29 enum index rather than an event flag (the boss CSV is full of them) and is skipped
30 too. The counts printed at the end say how many of each, so a shrinking table is
31 visible rather than silent.
32
33Run from the repo root:
34
35 python3 tools/gen_ds1_known_flags.py
36"""
37
38import argparse
39import collections
40import csv
41import json
42import os
43import sys
44
45BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
46ADDRESSING = os.path.join(
47 BASE, "test", "flag_data", "files2", "ds1_flag_addressing.csv"
48)
49KNOWN = os.path.join(BASE, "test", "flag_data", "files2", "ds1_known_event_flags.csv")
50OUT = os.path.join(BASE, "db_ds1", "known_flags.json")
51
52## @brief The order categories are printed in — roughly the order they matter to a
53# reader, rather than alphabetical. Anything not listed sorts to the end by name.
54CATEGORY_ORDER = [
55 "Bells of Awakening",
56 "Lordvessel",
57 "Non-Boss Fog Gates",
58 "Doors",
59 "Levers",
60 "Elevators",
61 "Join Covenants",
62 "NPC",
63 "Other",
64]
65
66
67##
68# @brief Categories dropped on evidence, not taste.
69# @details "Boss Fight" is eight rows of "Boss Arena Entered" and "Cutscene Skipped",
70# and every one reads CLEAR on the NG+2 all-bonfires mule that has 23 bosses dead. A
71# flag that must be true there and is not is transient — set while you are in the arena,
72# not after you win — so printing "Boss Fight 0 of 8" on a finished run would tell a
73# reader those bosses were never fought. The kills are already reported properly from
74# `db_ds1/boss_flags.json` and the soul floor.
75EXCLUDE_CATEGORIES = {"Boss Fight"}
76
77
78## @brief Load the group-base and area-index tables out of the addressing CSV.
80 groups, areas = {}, {}
81 with open(ADDRESSING, encoding="utf-8-sig") as f:
82 for row in csv.DictReader(f):
83 table = (row.get("table") or "").strip()
84 key, value = (
85 (row.get("key") or "").strip(),
86 (row.get("value") or "").strip(),
87 )
88 if table.startswith("group"):
89 groups[int(key)] = int(value, 16)
90 elif table.startswith("area"):
91 areas[key] = int(value)
92 return groups, areas
93
94
95##
96# @brief Byte offset of a flag's uint32 and the bit mask inside it, or None.
97# @details MSB-first within the word, which is the same rule DS3 and Sekiro use — DS1
98# just expresses it as a whole-word mask rather than a byte and a bit.
99# @param fid The 8-digit event flag id.
100def address(fid, groups, areas):
101 # Short ids are common-group flags written without their leading zeros — `851` is
102 # `00000851`, area 000, section 0. They are NOT enum indices; padding is the
103 # documented reading of the same G-AAA-S-NNN shape.
104 text = str(fid).zfill(8)
105 if len(text) != 8:
106 return None
107 group, area, section, number = int(text[0]), text[1:4], int(text[4]), int(text[5:8])
108 if group not in groups or area not in areas:
109 return None
110 off = (
111 groups[group]
112 + areas[area] * 0x500
113 + section * 128
114 + (number - number % 32) // 8
115 )
116 return off, 0x80000000 >> (number % 32)
117
118
119##
120# @brief Leading text that only repeats the category it is printed under.
121# @details The source writes "Covenant Joined, Chaos Servant" and files it under
122# "Join Covenants". Printed as a bullet beneath that heading the lead is pure noise, so
123# it comes off. Area leads ("Sen's Fortress, Fog Gate 1") are NOT stripped — those say
124# where, which is the useful half.
125PREFIX_STRIP = {
126 "Bells of Awakening": "Bell of Awakening,",
127 "Join Covenants": "Covenant Joined,",
128 "Lordvessel": "Lordvessel,",
129 "NPC": "NPC,",
130}
131
132
133##
134# @brief Tidy a display name: drop the trailing id and the category echo.
135# @details The CSV writes "Bell of Awakening, Undead Parish 11010700". The id is already
136# the key, so repeating it in the printed name is noise.
137def clean(name, fid, category):
138 name = name.strip()
139 if name.endswith(str(fid)):
140 name = name[: -len(str(fid))].strip()
141 lead = PREFIX_STRIP.get(category)
142 if lead and name.startswith(lead):
143 name = name[len(lead) :].strip()
144 return name or "(unnamed)"
145
146
147def main():
148 ap = argparse.ArgumentParser(
149 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
150 )
151 ap.add_argument("-o", "--out", default=OUT)
152 args = ap.parse_args()
153
154 groups, areas = addressing()
155 if not groups or not areas:
156 sys.exit(f"no addressing tables in {ADDRESSING}")
157
158 table = collections.defaultdict(list)
159 skipped_index = skipped_area = excluded = 0
160 with open(KNOWN, encoding="utf-8-sig") as f:
161 for row in csv.DictReader(f):
162 raw = (row.get("flag_id") or "").strip()
163 if not raw.isdigit():
164 continue
165 fid = int(raw)
166 if len(raw) > 8:
167 skipped_index += 1
168 continue
169 at = address(fid, groups, areas)
170 if at is None:
171 skipped_area += 1
172 continue
173 cat = (row.get("category") or "Other").strip() or "Other"
174 if cat in EXCLUDE_CATEGORIES:
175 excluded += 1
176 continue
177 table[cat].append([at[0], at[1], clean(row.get("name") or "", fid, cat)])
178
179 order = {c: i for i, c in enumerate(CATEGORY_ORDER)}
180 out = collections.OrderedDict()
181 for cat in sorted(table, key=lambda c: (order.get(c, len(order)), c)):
182 out[cat] = sorted(table[cat], key=lambda r: (r[0], -r[1]))
183
184 with open(args.out, "w", encoding="utf-8") as f:
185 json.dump(out, f, ensure_ascii=False, indent=1)
186 f.write("\n")
187 total = sum(len(v) for v in out.values())
188 print(f"wrote {args.out}: {total} flags in {len(out)} categories")
189 for cat, rows in out.items():
190 print(f" {len(rows):3} {cat}")
191 if excluded:
192 print(
193 f" dropped {excluded} in {', '.join(sorted(EXCLUDE_CATEGORIES))} "
194 f"(transient — see EXCLUDE_CATEGORIES)"
195 )
196 if skipped_index:
197 print(f" skipped {skipped_index} enum indices (not event flags)")
198 if skipped_area:
199 print(f" skipped {skipped_area} with an unmapped group/area")
200
201
202if __name__ == "__main__":
203 main()
clean(name, fid, category)
Tidy a display name: drop the trailing id and the category echo.
address(fid, groups, areas)
Byte offset of a flag's uint32 and the bit mask inside it, or None.
addressing()
Load the group-base and area-index tables out of the addressing CSV.