SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
gen_ds3_enemies.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Build `db_ds3/enemies.json` — the one-time enemies whose death Dark Souls III flags.
3
4Source: `test/flag_data/files7/ds3_onetime_enemy_flags.csv`, itself extracted from the
5`.emevd` committed in `thefifthmatt/SoulsRandomizers` `dist/Base/`. The rows are calls to
6`common_func` templates that take a death flag and an entity id -- `20005340`, `20005341`,
7`20005342`, `20000343` (the mimics), `20005416`, `20005061`, `20005760`, plus map-local
8events that gate on a flag at start and set it after a death check -- those are deduped by
9entity id, for the reason given at the dedupe.
10
11WHY THIS IS TRUSTED, since a table of ids is worth nothing on its own. Run against a
1279-save ladder in play-time order the count climbs 3 -> 98 with ZERO regressions, and the
13per-area breakdown lands where the player actually was: at 12:17:13 it reads Cathedral 9,
14Road of Sacrifices 7, High Wall 3, Undead Settlement 3, and nothing at all in Irithyll,
15Archdragon Peak, the Grand Archives or either DLC. A wrong base reads noise, and noise
16does not do that. `scratch/ds3_enemy_flags.py` is the check; re-run it if this table moves.
17
18The flags do NOT reset on a new journey -- the NG+2 ending save still reads 98, which is
19`63xx` boss-victory behaviour rather than per-map `13xxxx8xx` behaviour.
20
21Five more rows are dropped: their groups (`8`, `9`, `13105`) have no base in the CE
22table, and an unmapped group is absent rather than guessed, exactly like the three
23world-pickup groups. `X8` of the mimic template is NOT read -- the packet calls it a drop
24flag, but all six Irithyll Dungeon copies turn on in one snapshot while their death flags
25turn on hours apart, which is what an area-enable flag looks like.
26
27Run from repo root: python3 tools/gen_ds3_enemies.py
28"""
29
30import csv
31import json
32import os
33import re
34import sys
35
36BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
37SRC = os.path.join(BASE, "test", "flag_data", "files7", "ds3_onetime_enemy_flags.csv")
38BASES = os.path.join(BASE, "scratch", "ds3flags", "ds3_flag_group_bases.csv")
39OUT = os.path.join(BASE, "db_ds3", "enemies.json")
40
41# The save sits this far past the Cheat Engine table's memory offset, verified across all
42# sixteen map groups when the bonfires were derived.
43SAVE_DELTA = 111
44
45
46## @brief Flag id -> (distance into the flag region, bit), or None for an unmapped group.
47# @details The byte counts DOWN inside each uint32 word. Same expression as every other
48# DS3 table here, and it reproduces all 77 shipped bonfires exactly.
49def address(fid, ks):
50 k = ks.get(fid // 1000)
51 if k is None:
52 return None
53 n = fid % 1000
54 return k * 0x500 + (n >> 5) * 4 + 3 - ((n & 31) >> 3) + SAVE_DELTA, 7 - (n & 7)
55
56
57## @brief "Crystal Lizard [entity 3200259]" -> "Crystal Lizard".
58# @details The entity id is how the extractor kept rows apart; it is not a name, and
59# 47 lines reading "Crystal Lizard [entity ...]" say less than 47 reading the type and a
60# count. Duplicates ACROSS rows are real and are collapsed at render time by
61# `count_dupes`, the same way seven separate mimics and four Shura Samurai already are —
62# but a duplicate INSIDE one row is one flag covering two enemies, so it collapses here.
63# Rows the source could not name keep their entity id, because "Unnamed enemy" three
64# times would merge three different enemies into one line.
65def clean(name):
66 name = re.sub(r"\s*\[entity [\d/]+\]", "", name).strip()
67 parts, seen = [], set()
68 for part in re.split(r"\s*(?:\|\||\+)\s*", name):
69 part = part.strip()
70 part = re.sub(r"^entity (\d+)$", r"Unnamed enemy (\1)", part)
71 if part and part not in seen:
72 seen.add(part)
73 parts.append(part)
74 return " + ".join(parts)
75
76
77## @brief "Lothric Castle (m30_01_00_00)" -> "Lothric Castle".
78# @details The map id is what the EMEVD file was called. A reader wants the area, and the
79# spelling has to match nothing else here -- this is its own section, not a join.
80def area_of(text):
81 return re.sub(r"\s*\‍(m\d\d_\d\d_\d\d_\d\d\‍)", "", text).strip()
82
83
84def main():
85 with open(BASES) as f:
86 ks = {
87 int(r["flag_group (id//1000)"]): int(r["k (base/0x500)"])
88 for r in csv.DictReader(f)
89 }
90 table, dropped, local, seen_entities = {}, [], 0, set()
91 with open(SRC) as f:
92 for r in csv.DictReader(f):
93 fid = int(r["flag_id"])
94 # MAP-LOCAL rows are kept, but DEDUPED BY ENTITY ID, and the Nameless King is
95 # why: three separate map-local flags (13200850/855/856) all carry his one
96 # entity id, so listing each would report him killed three times. One entity
97 # is one enemy, so the first flag wins and the rest are dropped. The
98 # `common_func` rows are template invocations with one flag per entity and do
99 # not have the problem. These rows are still the softer half of the table --
100 # a flag set after a death check can also be a phase or music flag, and the
101 # source's own filter (gated at event start AND set after the death check) is
102 # what stands between them and a false kill.
103 entity = re.search(r"\[entity ([\d/]+)\]", r["enemy_name"])
104 if "Map-local event" in r["notes"]:
105 key = entity.group(1) if entity else r["enemy_name"]
106 if key in seen_entities:
107 local += 1
108 continue
109 seen_entities.add(key)
110 addr = address(fid, ks)
111 if addr is None:
112 dropped.append(fid)
113 continue
114 table.setdefault(area_of(r["area_or_map"]), []).append(
115 [addr[0], addr[1], clean(r["enemy_name"])]
116 )
117 if not table:
118 sys.exit("no rows survived -- check the source CSV")
119 for rows in table.values():
120 rows.sort(key=lambda t: (t[2], t[0], t[1]))
121 with open(OUT, "w", encoding="utf-8") as f:
122 json.dump(dict(sorted(table.items())), f, ensure_ascii=False, indent=1)
123 f.write("\n")
124 total = sum(len(v) for v in table.values())
125 print(f"wrote {OUT}: {total} enemies in {len(table)} areas")
126 print(
127 f"dropped {len(dropped)} in groups with no base: {sorted({d // 1000 for d in dropped})}"
128 )
129 print(f"dropped {local} duplicate map-local rows (same entity, another flag)")
130
131
132if __name__ == "__main__":
133 main()
area_of(text)
"Lothric Castle (m30_01_00_00)" -> "Lothric Castle".
address(fid, ks)
Flag id -> (distance into the flag region, bit), or None for an unmapped group.
clean(name)
"Crystal Lizard [entity 3200259]" -> "Crystal Lizard".