SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
ds2.py
Go to the documentation of this file.
1"""Dark Souls II, vanilla and Scholar of the First Sin."""
2
3import json
4import os
5from collections import OrderedDict, defaultdict
6
7from .crypto import decrypt_ds2
8from .itemdb import merge_qty
9from .reader import is_valid_name, read_utf16, u8, u16, u32
10
11## @brief The two DS2 releases. Vanilla (the DX9 original) and Scholar of the First
12# Sin share the ENTIRE save layout — only the AES key differs. Verified field by
13# field on a real DARKSII0000.sl2 against a real DS2SOFS0000.sl2: identical BND4
14# entry count and sizes (bar one non-character block), the name at DS2_NAME_OFF, and
15# DS2's own level identity (sum of the nine attributes minus level == 53) holding on
16# both. So every DS2 table below is shared; anything keyed by game id resolves
17# vanilla through DS2_FAMILY rather than carrying a duplicate entry.
18DS2_GAMES = ("ds2sotfs", "ds2vanilla")
19
20
21## @brief Game id to the id whose lookup tables it uses (attribute reference, derived
22# stats, themes). DS1's two releases already collapse this way; DS2's now do too.
23DS2_FAMILY = {"dsr": "ds1", "ptde": "ds1", "ds2vanilla": "ds2sotfs"}
24
25
26## @brief DS2 character-slot offsets (absolute, into decrypted game data).
27DS2_NAME_OFF, DS2_SOULS_OFF, DS2_SOULMEM_OFF, DS2_HP_OFF, DS2_NG_OFF = (
28 960,
29 60,
30 64,
31 72,
32 1028,
33)
34
35
36## @brief DS2 header (BND4 entry 0) title-list layout: each menu slot's name sits at
37# DS2_TITLE_NAME_OFF + DS2_TITLE_STRIDE * title_index. Block entry i maps to title
38# index (i - slots.start). Used to tell active characters from deleted ghosts.
39DS2_TITLE_NAME_OFF, DS2_TITLE_STRIDE = 1286, 496
40
41
42## @brief Play time (u32 seconds) inside a header title record: name is at record
43# base +0, the play-time counter at +66. Pinned by a real ~1-minute differential
44# pair (40:10:25 → 40:11:27, the u32 rose by exactly 62). Per-character, since each
45# title record is one slot. This is DS2's play time, which no editor exposed and an
46# earlier differential missed; it lives in the header, not the character block.
47DS2_TITLE_PLAYTIME_OFF = 66
48
49
50## @brief Starting class (byte) and current covenant (byte) offsets in the slot
51# block. Pinned by differential saves: class read 2 (Knight) on one character and
52# 8 (Explorer) on another at +1024; covenant read 3 (Brotherhood of Blood) then 0
53# (None) after leaving the covenant at +189, cross-checked against a third char.
54DS2_CLASS_OFF, DS2_COVENANT_OFF = 1024, 189
55
56
57## @brief DS2 starting-class and covenant id→name (from the SOTFS Cheat Engine table
58# dropdowns). Id 0 / unknown is absent, so `.get` yields None and the field is
59# omitted rather than shown wrong. Covenant 0 = not in a covenant (omitted).
60DS2_CLASS = {
61 1: "Warrior",
62 2: "Knight",
63 4: "Bandit",
64 6: "Cleric",
65 7: "Sorcerer",
66 8: "Explorer",
67 9: "Swordsman",
68 10: "Deprived",
69}
70
71
72DS2_COVENANT = {
73 1: "Heirs of the Sun",
74 2: "Blue Sentinels",
75 3: "Brotherhood of Blood",
76 4: "Way of Blue",
77 5: "Rat King",
78 6: "Bell Keepers",
79 7: "Dragon Remnants",
80 8: "Company of Champions",
81 9: "Pilgrims of Dark",
82}
83
84
85## @brief Per-covenant discovered flag and rank, as two dense byte runs in covenant-id
86# order just past the current-covenant byte. DS2S-META puts CurrentCovenant at 0x1AD
87# with Discovered at 0x1AF.. and Rank at 0x1B9.., so relative to our own (differential-
88# verified) DS2_COVENANT_OFF the runs start +2 and +12 — the absolute bases differ
89# between that editor and this tool, but the layout inside the struct does not.
90# Checked across Joy's ladder: the flags read a clean 0/1, rank is never nonzero for
91# an undiscovered covenant, and the only two she ranked to 3 are Way of Blue and
92# Pilgrims of Dark — the two cheapest ladders in the game (1/5/10 and 1/2/3).
93DS2_COV_DISC_D, DS2_COV_RANK_D, DS2_COV_MAX_RANK = 2, 12, 3
94
95
96## @brief Gender (u8) and hollowing level (u8) offsets in the slot block. From the
97# Jappi88 DS2 save editor: its player block reads Gender then HollowLv at block[0]
98# 0x15A/0x15B, and that block starts at slot flat +32 (Level/Souls/Soul-Memory/Health
99# line up), so Gender is 0x15A+32 = 378 and HollowLv 0x15B+32 = 379. HollowLv verified
100# on a 30h character (Hollow Lv 1). Gender polarity verified by a real F→M differential
101# save pair (the byte flipped 1→0), so 1 = Female, 0 = Male.
102DS2_GENDER_OFF, DS2_HOLLOW_OFF = 378, 379
103
104
105## @brief Total deaths (u32) in the slot block. Pinned by a real 201→202 death
106# differential: the u32 rose by exactly 1, and it climbs monotonically with play time
107# across the whole backup set (181 at 37h → 202 at 40.4h), reaching the labelled death
108# counts. DS2 mirrors it at three offsets (+104, +184, +7272) that always agree; +104
109# is used. This is the deaths counter no editor exposed and an earlier differential
110# could not find in the player region.
111DS2_DEATHS_OFF = 104
112
113
114## @brief DS2 gender enum. Female = 1, Male = 0 (see DS2_GENDER_OFF). Any other value
115# yields None via `.get` and the field is omitted rather than shown wrong.
116DS2_GENDER = {0: "Male", 1: "Female"}
117
118
119## @brief Bonfire (rest-point) progression lives in a separate WORLD block, not the
120# character-status block. In the SOTFS `.sl2` the world block for status entry i is
121# entry i + DS2_WORLD_ENTRY_DELTA. Inside it (per the Jappi88 editor's MapData: ids
122# at block 0x1598, unlock flags at 0x1798) a contiguous u16 array of bonfire ids is
123# followed DS2_BONFIRE_FLAG_DELTA bytes later by one unlock byte each. The array's
124# slot offset is not fixed across saves, so it is found by content (a long run of
125# known bonfire ids). Verified: a fresh mule shows 1 bonfire (the start), a 30h save
126# shows 49 across the whole game.
127DS2_WORLD_ENTRY_DELTA, DS2_BONFIRE_FLAG_DELTA, DS2_BONFIRE_MIN_RUN = 10, 0x200, 16
128
129
130## @brief DS2 attribute offsets (uint16 each), in display order; Level last.
131# Adaptability, Intelligence and Faith are NOT stored in display order: memory
132# keeps Intelligence @44, Faith @46, Adaptability @48 (verified against a known
133# SL88 character whose real ADP/INT/FTH were 15/3/6 but read out as 3/6/15 under
134# the naive contiguous mapping). The dict below lists them in display order with
135# their true offsets, so the table reads ADP, INT, FTH while pointing at 48/44/46.
136DS2_STAT_OFF = OrderedDict(
137 [
138 ("Vigor", 32),
139 ("Endurance", 34),
140 ("Vitality", 36),
141 ("Attunement", 38),
142 ("Strength", 40),
143 ("Dexterity", 42),
144 ("Adaptability", 48),
145 ("Intelligence", 44),
146 ("Faith", 46),
147 ("Level", 0x38),
148 ]
149)
150
151
152## @brief DS2 derived-stat bases (values BEFORE rings/equipment). Each derived stat is
153# a pure function of one/two attributes, verified byte-exact against a real save's
154# in-game Level-Up screen (Lv155 char: END 31 -> 131 stamina, VIT 30 -> 83.0 equip
155# load, ADP 20 / ATN 4 -> 96 agility / 11 roll i-frames). Unlike HP — which carries a
156# class/base offset the flat table misses (so HP is read from the save, not computed;
157# see STAT_CAPS note) — these three start from a universal base with no class variance,
158# so the formula reproduces the game exactly. Sources: fextralife Endurance /
159# Equipment Load / Agility pages.
160DS2_STAMINA_BASE, DS2_EQUIP_BASE, DS2_AGL_BASE = 80, 38.5, 80
161
162
163## @brief Roll i-frames by Agility value (fextralife/community breakpoints). Look up the
164# highest key <= AGL; below 85 the count is undocumented, so i-frames are omitted there.
165DS2_IFRAMES = OrderedDict(
166 [
167 (85, 5),
168 (86, 8),
169 (88, 9),
170 (92, 10),
171 (96, 11),
172 (99, 12),
173 (105, 13),
174 (111, 14),
175 (114, 15),
176 (116, 16),
177 ]
178)
179
180
181## @brief Attunement values at which a spell slot is unlocked (fextralife Attunement).
182# Slot count = how many of these are <= ATN. ATN 4 -> 0 slots (first slot at 10).
183DS2_SLOT_BREAKS = (10, 13, 16, 20, 25, 30, 40, 50, 60, 75, 94)
184
185
186## @brief Physical attack bonus (ATK: Str / ATK: Dex) by stat value — decade
187# breakpoints of the weapon-independent curve (the weapon then applies its own scaling
188# on top). Base 50 at 0, soft caps 40/50/80. From the DS2 wikidot/fextralife scaling
189# table; verified STR 50 -> 155 and DEX 16 -> 70 (interpolated) against a real save.
190# ATK: Str and ATK: Dex share this identical curve.
191DS2_PHYS_ATK_BP = OrderedDict(
192 [
193 (0, 50),
194 (10, 57),
195 (20, 80),
196 (30, 102),
197 (40, 140),
198 (50, 155),
199 (60, 162),
200 (70, 170),
201 (80, 185),
202 (90, 192),
203 (99, 200),
204 ]
205)
206
207
208## @brief Shared elemental-defence curve breakpoint rates (per stat point): +6 (1-10),
209# +8 (11-20), +1 (21-60), +0.5 (61-99); base 0. Magic DEF uses INT, Lightning DEF FTH,
210# Dark DEF min(INT,FTH), Fire DEF the floor-average of INT & FTH ("scales with both").
211# Verified: INT 3 -> Magic DEF 18, FTH 10 -> Lightning DEF 60, min 3 -> Dark DEF 18,
212# avg 6 -> Fire DEF 36. (fextralife Magic/defence pages.)
213## @brief DS2 inventory regions (start, end); 16-byte slots throughout.
214DS2_INV_RANGE, DS2_KEY_RANGE = (0x1E2C, 0x10E1C), (0x10E30, 0x11DF0)
215
216
217## @brief DS2 categories whose slot +8 field is a real count (float durability
218# elsewhere). Weapons/armour/rings/emotes are one instance per slot.
219DS2_STACKABLE = {
220 "consumables",
221 "online",
222 "bolts",
223 "spells",
224 "upgrade",
225 "keys",
226 "bosssouls",
227}
228
229
230## @brief Categories whose slot +12 field carries a reinforcement level. Only
231# weapons and armour reinforce in DS2; other categories keep other state there.
232DS2_UPGRADEABLE = {"weapons", "armors"}
233
234
235## @brief Byte offsets inside the uint32 upgrade field of a 16-byte item record:
236# the LOW byte (+12) is the reinforcement level (0..10); the next byte (+13) is the
237# infusion id. Both verified on a mule save whose high bytes were 1/2/3/4/8.
238DS2_REINF_OFF, DS2_INFUSE_OFF = 12, 13
239
240
241## @brief DS2 infusion ids to names. From Atvaark's DS2 SOTFS Cheat Engine guide
242# attachments (the "Infusion IDs" list). 0 (None) carries no prefix.
243DS2_INFUSION = {
244 1: "Fire",
245 2: "Magic",
246 3: "Lightning",
247 4: "Dark",
248 5: "Poison",
249 6: "Bleed",
250 7: "Raw",
251 8: "Enchanted",
252 9: "Mundane",
253}
254
255
256## @brief The four DS2 "Old" great souls (from the Lost Sinner, the Rotten, the
257# Old Iron King, and the Duke's Dear Freja). The game treats these apart from the
258# ordinary boss souls, so the output does too.
259DS2_GREAT_SOULS = {
260 "Old Witch Soul",
261 "Old Dead One Soul",
262 "Old King Soul",
263 "Old Paledrake Soul",
264}
265
266
267## @brief Read a DS2 name, or None for an empty slot.
268def ds2_name(buf):
269 name = read_utf16(buf, DS2_NAME_OFF, 16)
270 return name if is_valid_name(name) else None
271
272
273##
274# @brief Sort both DS2 inventory regions into categories.
275# @return @c (buckets, unknown_count).
276def ds2_inventory(buf, item_db):
277 buckets, unknown = defaultdict(list), 0
278 for start, end in (DS2_INV_RANGE, DS2_KEY_RANGE):
279 o = start
280 while o + 16 <= min(end, len(buf)):
281 # Count is the low uint16 of the +8 field, not a full uint32: special
282 # items pack extra state into the high two bytes. The Estus Flask keeps
283 # its current/max charges there, e.g. 01 00 07 07 = one flask, 7/7
284 # charges. No stackable count exceeds 65535, so the low uint16 is the
285 # real total, and the high two bytes are the flask's charge pair.
286 iid, qty = u32(buf, o), u16(buf, o + 8)
287 cur, mx = u8(buf, o + 10), u8(buf, o + 11)
288 reinf = u8(buf, o + DS2_REINF_OFF)
289 infuse = u8(buf, o + DS2_INFUSE_OFF)
290 o += 16
291 if not iid:
292 continue
293 info = item_db.get(iid)
294 if info is None:
295 unknown += 1
296 continue
297 name, cat = info
298 if name == "Estus Flask" and mx:
299 name = f"{name} ({cur}/{mx} charges)"
300 if cat in DS2_UPGRADEABLE:
301 # Reinforcement and infusion are baked into a separate record field,
302 # not the id (unlike DS1), so a +10 weapon carries the plain base id.
303 # Prefix the infusion (weapons only — armour cannot be infused) and
304 # suffix the +N level; the id table stays base-keyed.
305 if cat == "weapons" and infuse in DS2_INFUSION:
306 name = f"{DS2_INFUSION[infuse]} {name}"
307 if reinf:
308 name = f"{name} +{reinf}"
309 buckets[cat].append((name, qty if cat in DS2_STACKABLE else 1))
310 return buckets, unknown
311
312
313## @brief Physical attack bonus (ATK: Str/Dex) at a stat value: linear-interpolate the
314# decade breakpoints of @ref DS2_PHYS_ATK_BP, floored to the game's integer display.
315def ds2_phys_atk(stat):
316 stat = max(0, min(stat, 99))
317 lo = min((stat // 10) * 10, 90)
318 hi = 99 if lo == 90 else lo + 10
319 vlo, vhi = DS2_PHYS_ATK_BP[lo], DS2_PHYS_ATK_BP[hi]
320 return vlo if hi == lo else int(vlo + (vhi - vlo) * (stat - lo) / (hi - lo))
321
322
323## @brief Shared DS2 elemental-defence curve: +6/pt to 10, +8/pt to 20, +1/pt to 60,
324# +0.5/pt (one every other) to 99. Base 0. See @ref DS2_PHYS_ATK_BP note for the map.
325def ds2_elem_def(stat):
326 stat = max(0, min(stat, 99))
327 d = 6 * min(stat, 10)
328 if stat > 10:
329 d += 8 * (min(stat, 20) - 10)
330 if stat > 20:
331 d += 1 * (min(stat, 60) - 20)
332 if stat > 60:
333 d += (min(stat, 99) - 60) // 2 # +0.5/pt = one point every other level
334 return d
335
336
337##
338# @brief Compute DS2 base derived stats from the attribute block.
339# @details Base = before rings/equipment; the in-game screen adds ring/gear bonuses on
340# top (e.g. a +HP ring, a load ring). Stamina, equip load and agility are pure
341# attribute functions verified against a real save; i-frames come from the agility
342# breakpoint table (@ref DS2_IFRAMES), omitted below AGL 85 (undocumented).
343# @return dict: stamina (int), equip_load (float), agility (int), iframes (int|None).
344def ds2_derived_stats(stats):
345 end = stats.get("Endurance", 0) or 0
346 vit = stats.get("Vitality", 0) or 0
347 adp = stats.get("Adaptability", 0) or 0
348 atn = stats.get("Attunement", 0) or 0
349 stg = stats.get("Strength", 0) or 0
350 dex = stats.get("Dexterity", 0) or 0
351 intel = stats.get("Intelligence", 0) or 0
352 fth = stats.get("Faith", 0) or 0
353 stamina = DS2_STAMINA_BASE + 2 * min(end, 20) + max(0, min(end, 99) - 20)
354 if end >= 99:
355 stamina += 1 # the 98->99 step is +2, not +1
356 load = DS2_EQUIP_BASE + 1.5 * min(vit, 29)
357 if vit > 29:
358 load += 1.0 * (min(vit, 49) - 29)
359 if vit > 49:
360 load += 0.5 * (min(vit, 70) - 49)
361 if vit > 70:
362 load += 0.5 * ((min(vit, 99) - 70) // 2) # +0.5 per two points past 70
363 agl = DS2_AGL_BASE + int(0.75 * adp + 0.25 * atn + 1e-9)
364 iframes = None
365 for k, v in DS2_IFRAMES.items():
366 if agl >= k:
367 iframes = v
368 slots = sum(1 for b in DS2_SLOT_BREAKS if atn >= b)
369 # Base poise: scales on the LOWER of Endurance/Adaptability. 0.3/pt to 30, 0.2 to
370 # 50, 0.1 to 98, +0.2 at 99. Verified: min(END31,ADP20)=20 -> 0.3*20 = 6.0.
371 n = min(end, adp)
372 poise = 0.3 * min(n, 30)
373 if n > 30:
374 poise += 0.2 * (min(n, 50) - 30)
375 if n > 50:
376 poise += 0.1 * (min(n, 98) - 50)
377 if n >= 99:
378 poise += 0.2
379 return {
380 "stamina": stamina,
381 "equip_load": load,
382 "agility": agl,
383 "iframes": iframes,
384 "slots": slots,
385 "poise": poise,
386 "atk_str": ds2_phys_atk(stg),
387 "atk_dex": ds2_phys_atk(dex),
388 "magic_def": ds2_elem_def(intel),
389 "fire_def": ds2_elem_def((intel + fth) // 2),
390 "lightning_def": ds2_elem_def(fth),
391 "dark_def": ds2_elem_def(min(intel, fth)),
392 }
393
394
395##
396# @brief Every DS2 covenant the character has discovered, with its rank.
397# @details Two dense byte runs in covenant-id order (@ref DS2_COV_DISC_D /
398# @ref DS2_COV_RANK_D past the current-covenant byte). Both runs are validated as a
399# whole before anything is returned — a discovered flag must be 0 or 1, a rank must be
400# 0..3, and a rank cannot be nonzero where the covenant was never discovered. If any
401# of that fails the offsets have moved, so the feature turns itself off rather than
402# printing a wrong rank.
403# @return An OrderedDict {covenant: [description]}, or None.
405 out = OrderedDict()
406 for cid, name in sorted(DS2_COVENANT.items()):
407 disc = u8(buf, DS2_COVENANT_OFF + DS2_COV_DISC_D + cid - 1)
408 rank = u8(buf, DS2_COVENANT_OFF + DS2_COV_RANK_D + cid - 1)
409 if disc is None or rank is None or disc > 1 or rank > DS2_COV_MAX_RANK:
410 return None
411 if rank and not disc:
412 return None
413 if disc:
414 out[name] = [f"rank {rank} of {DS2_COV_MAX_RANK}" if rank else "discovered"]
415 return out or None
416
417
418## @brief Parse one DS2 slot into the unified character dict, or None if empty.
419# @param game Which DS2 release this slot came from. The layout is identical for
420# both (see DS2_GAMES), so this only labels the output.
421def ds2_parse(buf, item_db, game="ds2sotfs"):
422 if ds2_name(buf) is None:
423 return None
424 stats = OrderedDict((k, u16(buf, o) or 0) for k, o in DS2_STAT_OFF.items())
425 buckets, unknown = ds2_inventory(buf, item_db)
426 inv = {c: merge_qty(v) for c, v in buckets.items()}
427 return {
428 "tier": "full",
429 "game": game,
430 "name": ds2_name(buf),
431 "klass": DS2_CLASS.get(u8(buf, DS2_CLASS_OFF)),
432 "covenant": DS2_COVENANT.get(u8(buf, DS2_COVENANT_OFF)),
433 "covenants": ds2_covenants(buf),
434 "gender": DS2_GENDER.get(u8(buf, DS2_GENDER_OFF)),
435 "level": stats.pop("Level"),
436 "stats": stats,
437 "souls": u32(buf, DS2_SOULS_OFF),
438 "soul_memory": u32(buf, DS2_SOULMEM_OFF),
439 "humanity": None,
440 "stamina": None,
441 "hp": u32(buf, DS2_HP_OFF),
442 "ng_plus": max(0, (u16(buf, DS2_NG_OFF) or 1) - 1),
443 "hollow_lvl": u8(buf, DS2_HOLLOW_OFF),
444 "deaths": u32(buf, DS2_DEATHS_OFF),
445 # DS2 boss souls are a real inventory category (bosssouls), rendered and
446 # graded there, so the top boss-souls section is left empty for DS2.
447 "boss_souls": [],
448 "key_items": inv.pop("keys", []),
449 "inv": inv,
450 "unknown_count": unknown,
451 }
452
453
454## @brief Load the DS2 bonfire id→name table (db_ds2/bonfires.json, keyed by the
455# low-16-bit id as 4-hex). Cached after first read. Returns {} if the file is absent.
456_DS2_BONFIRE_CACHE = {}
457
458
459def load_ds2_bonfires(base_dir):
460 if base_dir not in _DS2_BONFIRE_CACHE:
461 path = os.path.join(base_dir, "db_ds2", "bonfires.json")
462 try:
463 with open(path, encoding="utf-8") as f:
464 raw = json.load(f)
465 _DS2_BONFIRE_CACHE[base_dir] = {int(k, 16): v for k, v in raw.items()}
466 except (OSError, ValueError):
467 _DS2_BONFIRE_CACHE[base_dir] = {}
468 return _DS2_BONFIRE_CACHE[base_dir]
469
470
471## @brief Load the DS2 bonfire→area table (db_ds2/bonfire_areas.json): bonfire id →
472# the area it belongs to, so discovered bonfires group the way DS1's and DS3's do
473# instead of listing 77 flat names. Generated from the fextralife Bonfires page,
474# which lists every bonfire under its location; the two bonfires sharing the name
475# "Tower of Prayer" are split by the id's own map cluster. Cached. Returns {} if
476# absent, and the grouping then falls back to the flat list.
477_DS2_AREA_CACHE = {}
478
479
481 if base_dir not in _DS2_AREA_CACHE:
482 path = os.path.join(base_dir, "db_ds2", "bonfire_areas.json")
483 try:
484 with open(path, encoding="utf-8") as f:
485 raw = json.load(f)
486 _DS2_AREA_CACHE[base_dir] = {int(k, 16): v for k, v in raw.items()}
487 except (OSError, ValueError):
488 _DS2_AREA_CACHE[base_dir] = {}
489 return _DS2_AREA_CACHE[base_dir]
490
491
492## @brief Load the DS2 boss-defeat flag table (db_ds2/boss_flags.json, world-block
493# byte offset as hex → boss name). Cached. Returns {} if the file is absent.
494_DS2_BOSS_CACHE = {}
495
496
497def load_ds2_bosses(base_dir):
498 if base_dir not in _DS2_BOSS_CACHE:
499 path = os.path.join(base_dir, "db_ds2", "boss_flags.json")
500 try:
501 with open(path, encoding="utf-8") as f:
502 raw = json.load(f)
503 _DS2_BOSS_CACHE[base_dir] = {int(k, 16): v for k, v in raw.items()}
504 except (OSError, ValueError):
505 _DS2_BOSS_CACHE[base_dir] = {}
506 return _DS2_BOSS_CACHE[base_dir]
507
508
509## @brief Load the DS2 boss-soul → boss-name table (db_ds2/boss_souls.json). Cached.
510_DS2_BOSS_SOUL_CACHE = {}
511
512
513def load_ds2_boss_souls(base_dir):
514 if base_dir not in _DS2_BOSS_SOUL_CACHE:
515 path = os.path.join(base_dir, "db_ds2", "boss_souls.json")
516 try:
517 with open(path, encoding="utf-8") as f:
518 _DS2_BOSS_SOUL_CACHE[base_dir] = json.load(f)
519 except (OSError, ValueError):
520 _DS2_BOSS_SOUL_CACHE[base_dir] = {}
521 return _DS2_BOSS_SOUL_CACHE[base_dir]
522
523
524## @brief Progression gates: a boss proven dead by something the character has. Only
525# DS2's STRICTLY-LINEAR endgame qualifies — the mid-game is four parallel, largely
526# skippable paths, so a mid-game gate would risk a false kill (the core rule). The
527# endgame is unskippable: Drangleic Castle → Looking Glass Knight → Shrine of Amana →
528# Demon of Song → Undead Crypt → Velstadt → (King's Ring, behind him) → King's Gate →
529# Throne → Throne Watcher & Defender → Nashandra. Sources: fextralife Game Progress
530# Route + King's Ring page.
531## @brief Bonfire present ⇒ these bosses dead (the bonfire is only reachable past them).
532DS2_BOSS_GATE = {
533 # No-Man's Wharf is only reachable through Heide's Tower of Flame, and the way out
534 # of Heide's opens when the Dragonrider dies — a fog gate, not a suggested order.
535 # This is the one MID-game gate here; the rest of DS2's middle is four parallel,
536 # largely skippable paths, which is why the others below are all endgame.
537 "Unseen Path to Heide": ("Dragonrider",),
538 "Undead Crypt Entrance": ("Looking Glass Knight", "Demon of Song"),
539 "Throne Floor": (
540 "Looking Glass Knight",
541 "Demon of Song",
542 "Velstadt, the Royal Aegis",
543 ),
544}
545
546
547## @brief Inventory item held ⇒ boss dead (item only obtainable past it). The King's
548# Ring sits in the room behind Velstadt and cannot be had otherwise.
549## @brief Item ⇒ boss it sits behind. Each item has exactly one documented source, and
550# that source is past the boss's fog gate, so holding it is a certain kill. The two DLC
551# gank bosses drop no soul at all, which is why they need this route:
552# Pharros Mask lies on a corpse past the (Blue) Smelter Demon's arena in Iron Passage,
553# and the Flower Skirt is in the chest between Cave of the Dead's trio and the exit.
554DS2_ITEM_GATE = {
555 "King's Ring": ("Velstadt, the Royal Aegis",),
556 "Pharros Mask": ("Blue Smelter Demon",),
557 "Flower Skirt": ("Graverobber, Varg, and Cerah",),
558}
559
560
561## @brief Boss defeated ⇒ its mandatory predecessors also defeated (each list is the
562# full transitive set, so a single pass closes it). Endgame only, where the order is
563# forced.
564DS2_BOSS_PREREQ = {
565 "Nashandra": (
566 "Throne Watcher",
567 "Throne Defender",
568 "Velstadt, the Royal Aegis",
569 "Demon of Song",
570 "Looking Glass Knight",
571 ),
572 "Throne Watcher": (
573 "Velstadt, the Royal Aegis",
574 "Demon of Song",
575 "Looking Glass Knight",
576 ),
577 "Throne Defender": (
578 "Velstadt, the Royal Aegis",
579 "Demon of Song",
580 "Looking Glass Knight",
581 ),
582 "Velstadt, the Royal Aegis": ("Demon of Song", "Looking Glass Knight"),
583 "Demon of Song": ("Looking Glass Knight",),
584}
585
586
587##
588# @brief An inventory name with its " +N" reinforcement suffix removed.
589# @details Gate matching compares against the plain db name, and armour/weapons render
590# upgraded. Only strips a trailing " +digits", so a name that genuinely ends that way is
591# untouched (none does).
593 head, sep, tail = name.rpartition(" +")
594 return head if sep and tail.isdigit() else name
595
596
597##
598# @brief Bosses this character has defeated, as @c {boss: [evidence]}, or None.
599# @details A FLOOR from three independent, positive-only signals — each is certain
600# when it fires, none is exhaustive:
601# - @b flag: a mapped defeat event flag is set (world block; see boss_flags.json).
602# Verified by the 41-boss differential matrix. Only a handful are mapped.
603# - @b soul: the boss's soul is still in inventory. Cannot be obtained without the
604# kill, but a consumed/traded soul goes invisible.
605# - @b progression: a bonfire the character has can only be reached past this boss
606# (@ref DS2_BOSS_GATE).
607# A boss absent here may still be defeated (its soul consumed and not gated). Sources
608# are merged per boss so overlap reads as corroboration.
609def ds2_infer_bosses(world, ch, base_dir):
610 out = defaultdict(set)
611 for off, name in load_ds2_bosses(base_dir).items():
612 if world and u8(world, off):
613 out[name].add("flag")
614 soul_db = load_ds2_boss_souls(base_dir)
615 for name, _qty in ch["inv"].get("bosssouls", []):
616 boss = soul_db.get(name)
617 if boss:
618 out[boss].add("soul")
619 for bonfire in ch.get("bonfires") or []:
620 for boss in DS2_BOSS_GATE.get(bonfire, ()):
621 out[boss].add("gate")
622 # Armour and weapons render with a " +N" reinforcement suffix, so strip it before
623 # matching — an upgraded Pharros Mask is still the same gate item.
624 held = {ds2_base_name(n) for items in ch["inv"].values() for n, _ in items}
625 held.update(n for n, _ in ch.get("key_items", []))
626 for item, bosses in DS2_ITEM_GATE.items():
627 if item in held:
628 for boss in bosses:
629 out[boss].add("gate")
630 # NG+ proves the game was finished, so its final boss (and, via the closure below,
631 # the whole forced endgame chain) is dead — even if the soul was long since spent.
632 if (ch.get("ng_plus") or 0) > 0:
633 out["Nashandra"].add("clear")
634 # Close over mandatory predecessors: any boss reached above implies the bosses
635 # the game forces you through before it. One pass suffices (lists are transitive).
636 for boss in list(out):
637 for pre in DS2_BOSS_PREREQ.get(boss, ()):
638 out[pre].add("gate")
639 if not out:
640 return None
641 return {b: sorted(out[b]) for b in sorted(out)}
642
643
644##
645# @brief Names of the bonfires this character has discovered, or None.
646# @details The world block holds a contiguous u16 array of bonfire ids and, exactly
647# DS2_BONFIRE_FLAG_DELTA bytes later, one unlock byte per id (non-zero = discovered).
648# The array's offset shifts between saves, so it is located by content: the start of
649# the longest run of known bonfire ids (a false run is astronomically unlikely given
650# the ~78-id vocabulary in the u16 space). Returns the discovered names in world
651# order, or None when the array can't be found (no world block / unknown layout).
652def ds2_visited_bonfires(world, bf_db):
653 if not world or not bf_db:
654 return None
655 best_start, best_run, run, run_start, o = -1, 0, 0, 0, 0
656 while o + 2 <= len(world):
657 if u16(world, o) in bf_db:
658 run_start = o if run == 0 else run_start
659 run += 1
660 if run > best_run:
661 best_run, best_start = run, run_start
662 else:
663 run = 0
664 o += 2
665 if best_run < DS2_BONFIRE_MIN_RUN:
666 return None
667 ids = []
668 o = best_start
669 while o + 2 <= len(world) and len(ids) < DS2_BONFIRE_FLAG_DELTA // 2:
670 v = u16(world, o)
671 if v == 0:
672 break
673 ids.append(v)
674 o += 2
675 flag_base = best_start + DS2_BONFIRE_FLAG_DELTA
676 visited = []
677 for idx, bid in enumerate(ids):
678 if u8(world, flag_base + idx):
679 visited.append((bid, bf_db.get(bid, f"(bonfire {bid:#06x})")))
680 return visited
681
682
683##
684# @brief Group discovered bonfires by area, in the (area, count, names) shape DS1
685# and DS3 already emit, so all three render through the same section.
686# @param visited The (id, name) pairs from ds2_visited_bonfires.
687# @param area_db Bonfire id → area, from load_ds2_bonfire_areas.
688# @return The grouped list, or None when no id has a known area (the caller then
689# keeps the flat list rather than inventing an "Unknown" bucket).
690def ds2_bonfire_areas(visited, area_db, bf_db):
691 if not visited or not area_db:
692 return None
693 seen = {bid for bid, _n in visited}
694 order = [area_db[bid] for bid, _n in visited if bid in area_db]
695 areas = OrderedDict((a, ([], [])) for a in order)
696 # Walk the whole area table, not just what was visited, so an area the character
697 # has not reached still prints as 0/N rather than vanishing.
698 for bid, area in area_db.items():
699 got, miss = areas.setdefault(area, ([], []))
700 name = bf_db.get(bid)
701 if name is None:
702 continue
703 (got if bid in seen else miss).append(name)
704 out = [
705 (a, len(got), got, len(got) + len(miss), miss)
706 for a, (got, miss) in areas.items()
707 ]
708 return out if any(c for _a, c, _n, _t, _m in out) else None
709
710
711## @brief DS2-only augment: attach world-block progression (bonfires, bosses) to a
712# parsed character. The world block for status entry @c i is entry
713# @c i+DS2_WORLD_ENTRY_DELTA; a missing/undecryptable block leaves both fields None
714# (sections omitted). Decrypts the world block once for both reads.
715def ds2_augment(ch, data, entries, i, base_dir, dec=decrypt_ds2):
716 # Play time lives in the header title record (one per slot), not the character
717 # block. Title index for block entry i is i - slots.start, and DS2 starts at 1.
718 if entries:
719 hdr = dec(data[entries[0].offset : entries[0].offset + entries[0].size])
720 if hdr is not None:
721 base = DS2_TITLE_NAME_OFF + DS2_TITLE_STRIDE * (i - 1)
722 ch["play_time"] = u32(hdr, base + DS2_TITLE_PLAYTIME_OFF)
723 w = i + DS2_WORLD_ENTRY_DELTA
724 if w >= len(entries):
725 return
726 world = dec(data[entries[w].offset : entries[w].offset + entries[w].size])
727 visited = ds2_visited_bonfires(world, load_ds2_bonfires(base_dir))
728 # The flat name list stays: DS2_BOSS_GATE is keyed by bonfire name, so the boss
729 # inference below reads it. The grouped view is what gets rendered.
730 ch["bonfires"] = [name for _, name in visited] if visited else visited
731 areas = ds2_bonfire_areas(
732 visited, load_ds2_bonfire_areas(base_dir), load_ds2_bonfires(base_dir)
733 )
734 if areas:
735 ch["bonfire_areas"] = areas
736 ch["bosses"] = ds2_infer_bosses(world, ch, base_dir)
737
738
739##
740# @brief Which DS2 block entries hold a character still listed in the menu.
741# @details Deleting a character in-game only clears its entry in the header title
742# list (BND4 entry 0) — the encrypted slot block is left untouched, so a plain scan
743# resurrects deleted "ghost" characters. The title list is the menu's source of
744# truth: block entry @c i owns title index @c i-slots.start, occupied only when that
745# title name field holds a valid name. Reads through the bounds-checked helpers, so
746# a short/garbled header yields None and the caller then skips the filter (degrade
747# to showing everything rather than wrongly hiding a real character). An empty
748# result is treated the same way: more likely a shifted offset on a future patch
749# than a save the user would bother converting with every character deleted.
750# @return The set of active entry indices, or None if the header can't be read or
751# the list came back empty (caller then applies no filter).
752def ds2_active_slots(data, entries, slots, dec=decrypt_ds2):
753 if not entries:
754 return None
755 hdr = dec(data[entries[0].offset : entries[0].offset + entries[0].size])
756 if hdr is None:
757 return None
758 active = set()
759 for i in slots:
760 off = DS2_TITLE_NAME_OFF + DS2_TITLE_STRIDE * (i - slots.start)
761 if is_valid_name(read_utf16(hdr, off, 16)):
762 active.add(i)
763 return active or None
ds2_phys_atk(stat)
Physical attack bonus (ATK: Str/Dex) at a stat value: linear-interpolate the decade breakpoints of DS...
Definition ds2.py:315
ds2_elem_def(stat)
Shared DS2 elemental-defence curve: +6/pt to 10, +8/pt to 20, +1/pt to 60, +0.5/pt (one every other) ...
Definition ds2.py:325
ds2_infer_bosses(world, ch, base_dir)
Bosses this character has defeated, as {boss: [evidence]}, or None.
Definition ds2.py:609
ds2_name(buf)
Read a DS2 name, or None for an empty slot.
Definition ds2.py:268
ds2_base_name(name)
An inventory name with its " +N" reinforcement suffix removed.
Definition ds2.py:592
load_ds2_bonfires(base_dir)
Definition ds2.py:459
ds2_inventory(buf, item_db)
Sort both DS2 inventory regions into categories.
Definition ds2.py:276
ds2_visited_bonfires(world, bf_db)
Names of the bonfires this character has discovered, or None.
Definition ds2.py:652
load_ds2_bonfire_areas(base_dir)
Definition ds2.py:480
ds2_covenants(buf)
Every DS2 covenant the character has discovered, with its rank.
Definition ds2.py:404
ds2_bonfire_areas(visited, area_db, bf_db)
Group discovered bonfires by area, in the (area, count, names) shape DS1 and DS3 already emit,...
Definition ds2.py:690