SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
ds3.py
Go to the documentation of this file.
1"""Dark Souls III."""
2
3import json
4import os
5import re
6from collections import OrderedDict, defaultdict
7
8from .progress import find_boss_souls
9from .reader import is_valid_name, u8, u16, u32
10from .roster import ROSTER_PARAMS
11
12## @brief DS3 attunement-slot breakpoints (fextralife Attunement table): the nth
13# entry is the ATN needed for the nth spell slot. Slots = count of these <= ATN.
14DS3_SLOT_BREAKS = (10, 14, 18, 24, 30, 40, 50, 60, 80, 99)
15
16
17## @brief Load db_ds3/ring_effects.json: ring name → @c {"effect": [lines],
18# "mods": {level: {stat: value}}}. Effect text is one line for the base ring plus one
19# per reinforcement level, verbatim from the fextralife Rings table; @c mods exists
20# only for the six rings that move a value this tool derives. Generated by
21# scratch/gen_ds3_ring_effects.py. Cached. Returns {} if absent.
22_DS3_RING_CACHE = {}
23
24
26 if base_dir not in _DS3_RING_CACHE:
27 path = os.path.join(base_dir, "db_ds3", "ring_effects.json")
28 try:
29 with open(path, encoding="utf-8") as f:
30 _DS3_RING_CACHE[base_dir] = json.load(f)
31 except OSError:
32 _DS3_RING_CACHE[base_dir] = {}
33 return _DS3_RING_CACHE[base_dir]
34
35
36##
37# @brief Split an equipped ring name into its table key and reinforcement level.
38# @details The db spells three of the four Ring of Favor ids "Ring of Favor+N" and the
39# fourth "Ring of Favor +3", so the suffix is matched with the space optional.
40# @return @c (base name, level).
42 m = re.match(r"^(.*?)\s*\+(\d)$", name)
43 return (m.group(1), int(m.group(2))) if m else (name, 0)
44
45
46##
47# @brief Attach each equipped ring's documented effect, and the subset of those effects
48# that is a structured modifier to a derived stat.
49# @details @c ch["ring_effects"] is @c [[name, effect text]] for the display; the text
50# is the base line plus the worn level's own line, so a +2 ring shows what +2 does
51# rather than only what the base ring does. @c ch["ring_mods"] is @c [[name, mods]] for
52# the rings the derived stats fold in. Both are omitted entirely when nothing resolves,
53# so a game without the table renders exactly as before.
54# @param ch The character dict. @param base_dir Folder holding @c db_ds3.
55def ds3_attach_ring_effects(ch, base_dir):
56 table = load_ds3_ring_effects(base_dir)
57 if not table:
58 return
59 effects, mods = [], []
60 for name in ch.get("equipped_rings") or ():
61 base, lvl = ds3_ring_level(name)
62 entry = table.get(base)
63 if not entry:
64 continue
65 lines = entry["effect"]
66 text = lines[0]
67 for extra in lines[1:]:
68 if extra.startswith(f"+{lvl}:"):
69 text += f" ({extra})"
70 effects.append([name, text])
71 m = (entry.get("mods") or {}).get(str(lvl))
72 if m:
73 mods.append([name, m])
74 if effects:
75 ch["ring_effects"] = effects
76 if mods:
77 ch["ring_mods"] = mods
78
79
80##
81# @brief Sum the worn rings' structured modifiers.
82# @param ring_mods @c ch["ring_mods"], or None.
83# @return @c (totals, contributors) keyed by stat; contributors is
84# @c {stat: [(ring name, value)]} so the render can name who supplied what.
85def ds3_ring_bonuses(ring_mods):
86 keys = ("load_pct", "discovery", "slots", "hp_pct", "stam_pct")
87 total = dict.fromkeys(keys, 0)
88 who = {k: [] for k in keys}
89 for name, mods in ring_mods or ():
90 for k, v in mods.items():
91 if k in total:
92 total[k] += v
93 who[k].append((name, v))
94 return total, who
95
96
97##
98# @brief DS3 base derived stats that are closed-form functions of attributes only,
99# plus the worn rings' documented bonuses to those same three values.
100# @details The attribute-only halves are the three the character screen shows that
101# don't need gear: attunement slots (breakpoint count), base Equip Load (@c 40 +
102# Vitality) and base Item Discovery (@c 100 + Luck, hard cap 199). HP/FP/stamina are
103# read from the save, not recomputed; poise is gear-only in DS3, and defences,
104# resistances and attack power are gear- and level-scaled, so none of those are derived
105# here. Formulas from the fextralife Equipment Load / Attunement / Item Discovery pages.
106#
107# Rings are folded in because these three values are NOT stored in the save, so unlike
108# Max HP and stamina nothing else would ever show the bonus. Only the six rings with a
109# structured mod count, and only into the value they name — a ring that boosts sorcery
110# damage has nothing to add to a number printed here. Equip-load percentages are summed
111# rather than compounded (DS3's equip-load rates are additive); the base and each
112# contributor are kept so the render prints the sum's parts and the reader can check it.
113# @c equip_load_base / @c item_discovery_base / @c slots_base keep the ringless figures.
114# @param stats The attribute dict. @param ring_mods @c ch["ring_mods"], or None.
115def ds3_derived_stats(stats, ring_mods=None):
116 atn = stats.get("Attunement", 0) or 0
117 vit = stats.get("Vitality", 0) or 0
118 lck = stats.get("Luck", 0) or 0
119 total, who = ds3_ring_bonuses(ring_mods)
120 base_load = 40.0 + vit
121 # Floored to one decimal, the precision the game's own screen shows -- the Ring of
122 # Favor save pair proves it truncates rather than rounds (see the change log).
123 load = int(base_load * (100.0 + total["load_pct"]) / 10.0) / 10.0
124 slots = sum(1 for b in DS3_SLOT_BREAKS if atn >= b)
125 # The 199 ceiling is Luck's own; ring points are documented to stack past it.
126 discovery = min(199, 100 + lck)
127 return {
128 "slots": slots + int(total["slots"]),
129 "slots_base": slots,
130 "equip_load": load,
131 "equip_load_base": base_load,
132 "item_discovery": int(discovery + total["discovery"]),
133 "item_discovery_base": discovery,
134 "ring_bonus": who,
135 }
136
137
138## @brief Load the DS3 bonfire table (db_ds3/bonfires.json): area → list of
139# @c [distance, bit, name], one entry per bonfire. Distance is from the event-flag
140# base, bit is within that byte. Generated from the SoulSplitter/TGA flag list via
141# the confirmed flag-id→bit formula (each bonfire flag `f`: group `f//1000`,
142# `n=f%1000`, byte `= (n>>5)*4 + 3-((n&31)>>3)`, bit `= 7-(n&7)`; save distance =
143# memory byte + 111, one constant delta verified across all 16 groups against real
144# saves). Cached. Returns {} if absent.
145_DS3_BONFIRE_CACHE = {}
146
147
148def load_ds3_bonfires(base_dir):
149 if base_dir not in _DS3_BONFIRE_CACHE:
150 path = os.path.join(base_dir, "db_ds3", "bonfires.json")
151 try:
152 with open(path, encoding="utf-8") as f:
153 _DS3_BONFIRE_CACHE[base_dir] = json.load(f)
154 except (OSError, ValueError):
155 _DS3_BONFIRE_CACHE[base_dir] = {}
156 return _DS3_BONFIRE_CACHE[base_dir]
157
158
159## @brief Load the DS3 boss-defeat flag table (db_ds3/boss_flags.json): boss name →
160# @c [distance, bit] of its single "boss dead" event flag (the @c 13xxxx8xx per-map
161# flag). Generated from the SoulSplitter boss list via the same flag-id→bit formula as
162# bonfires; every offset independently reproduced the old hand-checked table, so a set
163# bit is a certain kill. Cached. Returns {} if absent.
164_DS3_BOSS_CACHE = {}
165
166
167def load_ds3_boss_flags(base_dir):
168 if base_dir not in _DS3_BOSS_CACHE:
169 path = os.path.join(base_dir, "db_ds3", "boss_flags.json")
170 try:
171 with open(path, encoding="utf-8") as f:
172 _DS3_BOSS_CACHE[base_dir] = json.load(f)
173 except (OSError, ValueError):
174 _DS3_BOSS_CACHE[base_dir] = {}
175 return _DS3_BOSS_CACHE[base_dir]
176
177
178## @brief Load the DS3 boss-victory flag table (db_ds3/boss_victory.json): boss name →
179# @c [distance, bit] of its @c 63xx "boss victory" event flag, a SECOND independent
180# kill signal that survives the soul being consumed. These live in common group 6,
181# whose save base (879) was derived from the Rosaria join differential and is
182# confirmed here by fifteen more flags: walking the whole ladder, every one first
183# turns on in exactly the snapshot the boss died in. Covers Stray Demon, which the
184# per-map table does not. Cached. Returns {} if absent.
185_DS3_VICTORY_CACHE = {}
186
187
188def load_ds3_boss_victory(base_dir):
189 if base_dir not in _DS3_VICTORY_CACHE:
190 path = os.path.join(base_dir, "db_ds3", "boss_victory.json")
191 try:
192 with open(path, encoding="utf-8") as f:
193 _DS3_VICTORY_CACHE[base_dir] = json.load(f)
194 except (OSError, ValueError):
195 _DS3_VICTORY_CACHE[base_dir] = {}
196 return _DS3_VICTORY_CACHE[base_dir]
197
198
199## @brief Load the DS3 Lords-of-Cinder table (db_ds3/lord_cinders.json): lord name →
200# @c [distance, bit] of the flag set when that lord's Cinders are placed on the
201# Firelink throne. All four are listed now, each pinned by its own offering window
202# (14000125 Abyss Watchers first — the only flag gained anywhere in the m40 group
203# across a 46-second window — then Yhorm, Aldrich, and Twin Princes; they turned out
204# to be the four ODD ids in one byte, which is what settled the last seat). Cached.
205# Returns {}.
206_DS3_CINDER_CACHE = {}
207
208
210 if base_dir not in _DS3_CINDER_CACHE:
211 path = os.path.join(base_dir, "db_ds3", "lord_cinders.json")
212 try:
213 with open(path, encoding="utf-8") as f:
214 _DS3_CINDER_CACHE[base_dir] = json.load(f)
215 except (OSError, ValueError):
216 _DS3_CINDER_CACHE[base_dir] = {}
217 return _DS3_CINDER_CACHE[base_dir]
218
219
220## @brief Load the DS3 NPC-questline table (db_ds3/questlines.json): NPC/source →
221# list of @c [distance, bit, reward] — one per reward that NPC hands out, each a
222# "you received this" event flag in the common group 50006. The group-50006 save
223# base (86639, region-relative) was derived empirically from a Hawkwood Heavy-Gem
224# differential (the map-flag `k*0x500` bases don't cover the common groups) and
225# verified against a real save. A set flag means that reward was obtained — a
226# questline-progress floor. Cached. Returns {} if absent.
227_DS3_QUEST_CACHE = {}
228
229
231 if base_dir not in _DS3_QUEST_CACHE:
232 path = os.path.join(base_dir, "db_ds3", "questlines.json")
233 try:
234 with open(path, encoding="utf-8") as f:
235 _DS3_QUEST_CACHE[base_dir] = json.load(f)
236 except (OSError, ValueError):
237 _DS3_QUEST_CACHE[base_dir] = {}
238 return _DS3_QUEST_CACHE[base_dir]
239
240
241## @brief Load the DS3 world item-pickup table (db_ds3/item_pickups.json): area →
242# list of @c [distance, bit, item] — one per one-off item lying in that area, each a
243# "you picked this up" event flag in the world groups 533xx–540xx. Only SIX groups
244# are in the file, the ones whose save base could be derived by windowed ladder
245# timing (an item's flag must read 0 in every snapshot before the character first
246# held it and 1 in every snapshot after, plus no flag in the group may ever clear);
247# all six landed on the published `k*0x500 + 111` grid, which is independent
248# corroboration. Groups whose base is still unknown are ABSENT rather than guessed —
249# a wrong base invents pickups. Cached. Returns {} if absent.
250_DS3_PICKUP_CACHE = {}
251
252
253def load_ds3_pickups(base_dir):
254 if base_dir not in _DS3_PICKUP_CACHE:
255 path = os.path.join(base_dir, "db_ds3", "item_pickups.json")
256 try:
257 with open(path, encoding="utf-8") as f:
258 _DS3_PICKUP_CACHE[base_dir] = json.load(f)
259 except (OSError, ValueError):
260 _DS3_PICKUP_CACHE[base_dir] = {}
261 return _DS3_PICKUP_CACHE[base_dir]
262
263
264## @brief Load the one-time-enemy table (db_ds3/enemies.json): area → list of
265# @c [distance, bit, enemy type] — one per enemy that does not respawn and whose death
266# DS3 therefore has to remember. Mimics, Crystal Lizards, Black Knights, the Boreal
267# Outrider Knights. Extracted from the committed `.emevd`, where each is a call to a
268# `common_func` template taking a death flag and an entity id, so these are DEATH flags
269# rather than the drop flags the same enemies' loot already sets. Trusted because the
270# count climbs 3 → 98 with no regressions across a 79-save ladder and the per-area
271# breakdown tracks where the character actually went; see `tools/gen_ds3_enemies.py`.
272# Unlike the per-map boss flags these survive a new journey. Cached. Returns {} if absent.
273_DS3_ENEMY_CACHE = {}
274
275
276def load_ds3_enemies(base_dir):
277 if base_dir not in _DS3_ENEMY_CACHE:
278 path = os.path.join(base_dir, "db_ds3", "enemies.json")
279 try:
280 with open(path, encoding="utf-8") as f:
281 _DS3_ENEMY_CACHE[base_dir] = json.load(f)
282 except (OSError, ValueError):
283 _DS3_ENEMY_CACHE[base_dir] = {}
284 return _DS3_ENEMY_CACHE[base_dir]
285
286
287## @brief Load the DS3 NPC-state table (db_ds3/npcs.json): family → [[distance, bit,
288# label]] — NPCs killed, NPCs turned hostile, questline states. Generated by
289# `tools/gen_ds3_npcs.py`, on the common-group base `111 + 128*g` derived there and
290# confirmed by a dated kill on the ladder. Two caveats live in that generator and are
291# repeated in the section note: an English description can sit on the wrong flag (one
292# demonstrably does), and a row with no description prints an entity id because inventing
293# a name is worse. Cached. Returns {} if absent.
294_DS3_NPC_CACHE = {}
295
296
297def load_ds3_npcs(base_dir):
298 if base_dir not in _DS3_NPC_CACHE:
299 path = os.path.join(base_dir, "db_ds3", "npcs.json")
300 try:
301 with open(path, encoding="utf-8") as f:
302 _DS3_NPC_CACHE[base_dir] = json.load(f)
303 except (OSError, ValueError):
304 _DS3_NPC_CACHE[base_dir] = {}
305 return _DS3_NPC_CACHE[base_dir]
306
307
308## @brief Load the DS3 covenant table (db_ds3/covenants.json,
309# {covenant: [[region distance, bit, what it proves]]}). Built from the same
310# item-pickup flag list as the questlines, on the group-6 base (879) derived from a
311# real Rosaria's-Fingers join differential and confirmed by the whole ladder reading
312# chronologically (Way of Blue and Warrior of Sunlight appearing together at the
313# High Wall → Undead Settlement step, Blue Sentinels at Road of Sacrifices, Rosaria
314# only in the final save, and no DLC or rank flags anywhere). A set flag means the
315# covenant was found, or that rank reward collected — a floor, like the questlines.
316# Cached. Returns {} if absent.
317_DS3_COV_CACHE = {}
318
319
320def load_ds3_covenants(base_dir):
321 if base_dir not in _DS3_COV_CACHE:
322 path = os.path.join(base_dir, "db_ds3", "covenants.json")
323 try:
324 with open(path, encoding="utf-8") as f:
325 _DS3_COV_CACHE[base_dir] = json.load(f)
326 except (OSError, ValueError):
327 _DS3_COV_CACHE[base_dir] = {}
328 return _DS3_COV_CACHE[base_dir]
329
330
331## @brief Load the DS3 endings table (db_ds3/endings.json): ending name →
332# @c [distance, bit]. All four endings live in ONE byte, one bit each — the same
333# shape as the lord-cinder byte. Three bits were pinned by a three-way differential:
334# the same pre-ending save was finished three different ways, and each ending flipped
335# exactly one bit of it (bit 7 Link the First Flame, bit 5 The End of Fire, bit 4 the
336# Fire-Keeper-slain variant), with nine further flips common to all three and so
337# generic. The remaining bit 6 is The Usurpation of Fire BY ELIMINATION — the game has
338# four endings, three seats are pinned by a labelled save, and the family is closed;
339# it is the one field here not observed set, because reaching it needs a save from a
340# playthrough that took it. Flags are cumulative, so a character can hold more than
341# one. Cached. Returns {} if absent.
342_DS3_ENDING_CACHE = {}
343
344
345def load_ds3_endings(base_dir):
346 if base_dir not in _DS3_ENDING_CACHE:
347 path = os.path.join(base_dir, "db_ds3", "endings.json")
348 try:
349 with open(path, encoding="utf-8") as f:
350 _DS3_ENDING_CACHE[base_dir] = json.load(f)
351 except (OSError, ValueError):
352 _DS3_ENDING_CACHE[base_dir] = {}
353 return _DS3_ENDING_CACHE[base_dir]
354
355
356## @brief DS3 held-item record size and the offset of the quantity within it.
357DS3_RECORD, DS3_QTY_OFF = 16, 4
358
359
360## @brief DS3 stat block as signed distances from the Vigor field (the anchor).
361# Storage order is NOT the level-up display order: eight contiguous uint32
362# (Vigor, Attunement, Endurance, Strength, Dexterity, Intelligence, Faith, Luck),
363# then Vitality alone after a two-field gap. Listed here in display order pointing
364# at the true distances. Calibrated against a real lopsided build (Joy, STR 18 /
365# VIT 14 / LCK 11 read out as VIT 18 / STR 9 / LCK 14 under the old naive mapping,
366# which the order-independent level-sum identity could not catch).
367DS3_STAT_D = OrderedDict(
368 [
369 ("Vigor", 0),
370 ("Attunement", 4),
371 ("Endurance", 8),
372 ("Vitality", 40),
373 ("Strength", 12),
374 ("Dexterity", 16),
375 ("Intelligence", 20),
376 ("Faith", 24),
377 ("Luck", 28),
378 ]
379)
380
381
382## @brief DS3 max HP, FP, stamina, soul level and souls, same anchor-relative scheme.
383# HP/FP/stamina each store a current+max triple; these offsets point at the MAX
384# copy (a lopsided real save read HP 728 max / 681 current at -40 / -36 — we take
385# the max). FP at -28 verified 72 at ATN 6 and 450 at a high-attunement char.
386DS3_HP_D, DS3_FP_D, DS3_STAM_D, DS3_LEVEL_D, DS3_SOULS_D = -40, -28, -12, 44, 48
387
388
389## @brief DS3 embered flag: a lone uint8 at +188 in the stat-mirror struct behind the
390# stat anchor — 1 = embered, 0 = hollow. Embered restores the ~30% Max HP bonus (which
391# is why the stored Max HP at DS3_HP_D reads base*1.3 while this byte is 1), so reading
392# it labels whether the Max HP figure is the embered or the base value. Pinned by a real
393# Joy differential (using an Ember flipped 0->1 and Max HP 817->1062 = *1.30) and
394# cross-checked on the all-items mule: its two embered slots read 1 with HP = base*1.3
395# (vig 99, 1400->1819) and its hollow slot reads 0 with base HP (vig 16, 594) — both
396# polarities, HP corroborating each. Guarded to {0,1}; any other value omits the field.
397DS3_EMBER_D = 188
398
399
400## @brief DS3 covenant: a uint32 EQUIP HANDLE at +3944 from the stat anchor, not a
401# small enum — the game models the covenant as a worn accessory, so the field holds
402# 0xA00027xx and the covenant's own item id is the low 28 bits (Rosaria's Fingers
403# 0xA0002760 -> 10080). Ids and names come from the TGA Cheat Engine table's
404# "Current Covenant" dropdown. Pinned by a real Joy join differential: the two saves
405# are 37 seconds apart with the same level, bonfires and souls, and joining Rosaria's
406# Fingers is the only thing that happened — the handle appears EXACTLY ONCE in the
407# after save and NOT AT ALL in the before one. Cross-checked across the whole ladder
408# (all 27 earlier saves read 0 = no covenant) and against the all-items mule, whose
409# two cheated slots hold values that are not covenant ids at all and are dropped by
410# the table lookup rather than printed.
411DS3_COVENANT_D = 3944
412
413
414DS3_COVENANT = {
415 10000: "Blade of the Darkmoon",
416 10020: "Watchdogs of Farron",
417 10030: "Aldrich Faithful",
418 10040: "Warrior of Sunlight",
419 10050: "Mound-makers",
420 10060: "Way of Blue",
421 10070: "Blue Sentinels",
422 10080: "Rosaria's Fingers",
423 10090: "Spears of the Church",
424}
425
426
427## @brief DS3's soul-level identity: level == (sum of all nine attributes) - 89.
428# Deprived (all 10, sum 90) is level 1, and it holds at every level. This is the
429# content check that pins the stat block without a per-patch offset table.
430DS3_LEVEL_BASE = 89
431
432
433## @brief Shortest run of consecutive records that counts as real inventory. Long
434# enough to shrug off a stray id landing in unrelated data.
435SCAN_MIN_RUN = 3
436
437
438## @brief Largest gap (bytes) a run may bridge. The inventory is a 16-byte record
439# grid, but records holding an UNTABLED id (not in our db) leave holes, so two
440# known records can sit 32/48 bytes apart with the hole(s) between them. Bridging
441# a gap that is a multiple of @c DS3_RECORD up to this bound keeps such a run
442# whole, so an item flanked by unknowns on both sides (an island the strict
443# stride-16 rule dropped) is still read. Observed holes are single-record (gap
444# 32); the extra record of slack tolerates a rare double hole. Kept small so the
445# run can't leap to unrelated on-grid coincidences. (Found via a Soul of a Stray
446# Demon that read out present in the buffer but vanished from the list, wedged
447# between two untabled items.)
448DS3_MAX_RUN_GAP = 48
449
450
451## @brief DS3 goods ids carry a type prefix (0x40000000); the real id is what the
452# game's own EquipParamGoods table uses, and it blocks out cleanly by kind. Splitting
453# `goods` on those blocks gives DS3 the same finer categories DS2 already renders
454# (upgrade / consumables / online / keys / boss souls) instead of one 100-row dump.
455# Ranges read off the table itself (`db_ds3/goods.json`, sorted by id), not guessed:
456# 100..119 soapstones+orbs+Darksign, 240..519 the whole consumable block, 520..524
457# the multiplayer carvings, 700..799 boss souls, 1000..1030 titanite, 1100..1250
458# infusion gems, 2001..2014 door keys, 2101..2159 tomes/coals/ashes/banners.
459DS3_GOODS_ID_BASE = 0x40000000
460
461
462DS3_GOODS_RANGES = (
463 (100, 149, "online"),
464 (150, 519, "consumables"),
465 (520, 599, "online"),
466 (600, 699, "consumables"),
467 (700, 799, "bosssouls"),
468 (1000, 1299, "upgrade"),
469 (2000, 2199, "keys"),
470)
471
472
473## @brief The two flask upgrades that sit in the key-item block but are materials.
474DS3_GOODS_OVERRIDE = {
475 117: "consumables", # Darksign (not a summon item)
476 2141: "upgrade",
477 2143: "upgrade",
478} # Estus Shard, Bone Shard
479
480
481## @brief Refine a DS3 `goods` id to its finer category (see @ref DS3_GOODS_RANGES).
482# An id outside every block keeps `goods`, so an unmapped one is still printed.
484 real = iid - DS3_GOODS_ID_BASE
485 if real in DS3_GOODS_OVERRIDE:
486 return DS3_GOODS_OVERRIDE[real]
487 for lo, hi, cat in DS3_GOODS_RANGES:
488 if lo <= real <= hi:
489 return cat
490 return "goods"
491
492
493## @brief Where ammunition lives in the weapon id space. Arrows and bolts ARE weapons
494# to the param — and to Paramdex, which is where the full table comes from — but the
495# report has always printed them under Ammunition, and @ref ds3_equipped_ammo gates on
496# that category, so they are re-homed on the way in. Bows start at 1300000, so the
497# block is unambiguous.
498DS3_AMMO_LO, DS3_AMMO_HI = 400000, 409999
499
500
501##
502# @brief Refine a scanned DS3 id's category. The one place that knows the id blocks
503# differ from the file an id happens to be listed in.
504def ds3_item_cat(iid, cat):
505 if cat == "goods":
506 return ds3_goods_cat(iid)
507 if cat == "weapons" and DS3_AMMO_LO <= iid <= DS3_AMMO_HI:
508 return "bolts"
509 return cat
510
511
512##
513# @brief Find inventory by scanning for known item ids in fixed-size records.
514# @details Collects every offset whose uint32 is a known id, groups those on the
515# same 16-byte record grid into runs (bridging holes left by untabled items, up to
516# @ref DS3_MAX_RUN_GAP), and keeps runs of at least @c SCAN_MIN_RUN. Each surviving
517# record contributes its id and quantity. Duplicate ids are summed. A quantity
518# outside a sane range drops the record — a cheap guard against a false run of
519# look-alike bytes.
520# @param buf The decrypted slot data.
521# @param iddb The flat id lookup from @ref load_scan_db.
522# @return @c (buckets, unknown_count), buckets mapping category to @c (name, qty).
523def scan_inventory(buf, iddb):
524 positions = [
525 o
526 for o in range(0, len(buf) - 8)
527 if int.from_bytes(buf[o : o + 4], "little") in iddb
528 ]
529 buckets, seen, unknown = defaultdict(dict), set(), 0
530 i, n = 0, len(positions)
531 while i < n:
532 j = i
533 while j + 1 < n:
534 d = positions[j + 1] - positions[j]
535 if d % DS3_RECORD == 0 and d <= DS3_MAX_RUN_GAP:
536 j += 1
537 else:
538 break
539 if j - i + 1 >= SCAN_MIN_RUN:
540 # Walk the run's whole record grid, not just the positions that matched
541 # the table — the holes between them are real records too, and a held
542 # weapon sits in one whenever it is reinforced (the inventory stores the
543 # exact base+infusion*100+level id, so only a +0 weapon is a direct hit).
544 # The run itself is still built from direct hits alone, so this can only
545 # ADD items, never move a run boundary and drop one.
546 for o in range(positions[i], positions[j] + 1, DS3_RECORD):
547 if o in seen:
548 continue
549 seen.add(o)
550 iid = int.from_bytes(buf[o : o + 4], "little")
551 qty = u32(buf, o + DS3_QTY_OFF) or 0
552 if not 1 <= qty <= 9999:
553 continue
554 entry = iddb.get(iid)
555 if entry is None:
556 reinf = ds3_resolve_weapon(iddb, iid)
557 estus = None if reinf else ds3_resolve_estus(iid)
558 if reinf is None and estus is None:
559 unknown += 1
560 continue
561 entry = (reinf, "weapons") if reinf else (estus, "consumables")
562 name, cat = entry
563 bucket = buckets[cat]
564 bucket[name] = bucket.get(name, 0) + qty
565 i = j + 1
566 return {c: list(v.items()) for c, v in buckets.items()}, unknown
567
568
569##
570# @brief Locate the DS3 stat block by content, or None if none validates.
571# @details DS3's stat offsets move between patches, so the block is not read from
572# a fixed offset — it is found. For each 4-aligned position, treat the next nine
573# uint32 as the attributes and accept only where each is 1..99 *and* their sum
574# minus @ref DS3_LEVEL_BASE equals the stored soul level. That identity is DS3's
575# own level formula, so a coincidental match on unrelated bytes is not credible.
576# @param buf The decrypted slot data.
577# @return The Vigor-field offset (the anchor), or None.
579 dists = list(DS3_STAT_D.values())
580 v, end = 0, len(buf) - DS3_SOULS_D - 4
581 while v < end:
582 first = u32(buf, v)
583 if first is not None and 1 <= first <= 99:
584 vals = [u32(buf, v + d) for d in dists]
585 lvl = u32(buf, v + DS3_LEVEL_D)
586 if (
587 all(x is not None and 1 <= x <= 99 for x in vals)
588 and lvl is not None
589 and 1 <= lvl <= 802
590 and sum(vals) - DS3_LEVEL_BASE == lvl
591 ):
592 return v
593 v += 4
594 return None
595
596
597## @brief DS3 embered state for a slot, or None. @param v The stat anchor from
598# @ref ds3_find_stats (None → feature off). Reads the @ref DS3_EMBER_D byte and only
599# trusts a clean boolean: 1 → True (embered), 0 → False (hollow), anything else → None
600# (never guess). @return True/False, or None when unlocated or out of range.
601def ds3_embered(buf, v):
602 if v is None:
603 return None
604 e = u8(buf, v + DS3_EMBER_D)
605 return True if e == 1 else False if e == 0 else None
606
607
608## @brief DS3 covenant name for a slot, or None. @param v The stat anchor from
609# @ref ds3_find_stats (None → feature off). The field is an equip handle, so the
610# covenant id is its low 28 bits and only ids in @ref DS3_COVENANT are named —
611# 0 means no covenant, and a cheated slot's junk handle resolves to nothing and is
612# omitted rather than guessed. @return The covenant name, or None.
613def ds3_covenant(buf, v):
614 if v is None:
615 return None
616 h = u32(buf, v + DS3_COVENANT_D)
617 return DS3_COVENANT.get(h & 0x0FFFFFFF) if h else None
618
619
620## @brief EquipGameData sits a fixed 664 bytes past the stat (Vigor) anchor —
621# invariant across saves whose anchor itself moves (56124..75156 on the Joy
622# ladder), so it is read at a fixed anchor-relative offset, not searched for.
623DS3_EQUIP_D = 664
624
625
626## @brief Armour sub-offsets inside EquipGameData (base +0x20..+0x2C), in the
627# game's own head-to-toe order. Each holds a GaItem *handle*, not an id.
628DS3_ARMOR_SLOTS = OrderedDict(
629 [("Head", 0x20), ("Chest", 0x24), ("Hands", 0x28), ("Legs", 0x2C)]
630)
631
632
633## @brief The four ring sub-offsets inside EquipGameData (base +0x34..+0x40).
634DS3_RING_SLOTS = (0x34, 0x38, 0x3C, 0x40)
635
636
637## @brief The ammo sub-offsets (Arrow 1 / Bolt 1 / Arrow 2 / Bolt 2) at base
638# +0x08..+0x14 — GaItem handles like weapons/armour (arrows and bolts share the
639# db's `bolts` category).
640DS3_AMMO_SLOTS = (0x08, 0x0C, 0x10, 0x14)
641
642
643## @brief The six weapon sub-offsets, in the game's own two-hand-by-slot order.
644# The struct interleaves the hands (LH1, RH1, LH2, RH2, LH3, RH3) starting
645# 0x10 BEFORE the armour base — so relative to @ref DS3_EQUIP_D the right hand
646# is -0x0C/-0x04/+0x04 and the left is -0x10/-0x08/+0x00. Each holds a GaItem
647# *handle*, resolved through the same map as armour/ammo. Pinned by a real
648# weapon-swap differential (a Deep Battle Axe moved right→left read out at
649# RH1 then LH1, everything else unchanged), retiring the old "hand slots
650# unverifiable" blocker. The id carries the infusion (Deep = base+900), so an
651# infused weapon names itself; the +N reinforcement lives in the 52-byte
652# weapon record and is still not read.
653DS3_WEAPON_SLOTS = OrderedDict(
654 [
655 ("Right Hand", -0x0C),
656 ("Right Hand 2", -0x04),
657 ("Right Hand 3", 0x04),
658 ("Left Hand", -0x10),
659 ("Left Hand 2", -0x08),
660 ("Left Hand 3", 0x00),
661 ]
662)
663
664
665## @brief Item id of the default bare fist (an empty weapon slot reads this, not
666# a null handle), so a slot resolving to it is skipped as "unarmed".
667DS3_FISTS = 110000
668
669
670## @brief Max reinforcement level, a sanity bound on the id-baked "+N".
671DS3_REINF_MAX = 10
672
673
674##
675# @brief Resolve an equipped DS3 weapon id to a name, unwrapping the baked "+N".
676# @details The GaItem array stores a weapon at its EXACT id, and reinforcement is
677# folded into that id as @c base+infusion*100+level (the same scheme DS1 uses; a
678# Deep Battle Axe +0/+1 read out as 7010900/7010901). Unlike DS1, the DS3 db keys
679# every infusion by name (7010900 → "Deep Battle Axe"), so only the LEVEL (the
680# units) is stripped — the base-infusion id already carries the infusion name. A
681# direct hit wins; otherwise the level is peeled off and a " +N" suffix appended.
682# The infusion itself is thus named for free; the level shows when > 0.
683# @param iddb The flat DS3 id lookup.
684# @param iid The exact equipped-weapon id.
685# @return The display name, or None if it is not a (base) weapon.
686def ds3_resolve_weapon(iddb, iid):
687 entry = iddb.get(iid)
688 if entry and entry[1] == "weapons":
689 return entry[0]
690 level = iid % 100
691 if not 1 <= level <= DS3_REINF_MAX:
692 return None
693 base = iddb.get(iid - level)
694 return f"{base[0]} +{level}" if base and base[1] == "weapons" else None
695
696
697## @brief The two Estus flasks' base goods ids, and the reinforcement ceiling.
698# Like weapons, a flask's level is baked into its id, but it takes TWO consecutive
699# ids per level (150/151 = Estus +0, 152/153 = +1 … 170/171 = +10; 190/191 = Ashen
700# +0 … 210/211 = +10), so the flask is resolved arithmetically rather than listed —
701# a name-keyed db cannot hold one name under two ids. Verified across Joy's 38-save
702# ladder, where the id steps 151 → 153 → 155 → 157 → 159 (and the Ashen id 190 → 192
703# → 194 → 196 → 198 in lockstep), each step inside a 12-15 second window — one visit
704# to Andre. Both members of a pair really occur: the all-items mule holds 171/211
705# (both +10) and 151/191, which is what pins the pairing and the endpoints.
706DS3_GOODS_TYPE = 0x40000000
707
708
709DS3_ESTUS = ((150, "Estus Flask"), (190, "Ashen Estus Flask"))
710
711
712DS3_ESTUS_MAX = 10
713
714
715##
716# @brief Resolve a DS3 goods id to an Estus flask name with its @c +N, or None.
717# @details Only consulted after the id-scan table misses, so it can never shadow a
718# real listed good. @param iid The exact goods id. @return The name, or None.
720 raw = iid - DS3_GOODS_TYPE
721 for base, name in DS3_ESTUS:
722 level = (raw - base) // 2
723 if raw >= base and 0 <= level <= DS3_ESTUS_MAX:
724 return name if level == 0 else f"{name} +{level}"
725 return None
726
727
728## @brief A ring's equip handle encodes its own id: the low 28 bits are shared
729# and the type nibble is 0xA where the goods/ring id's is 0x2 — so the id is
730# the handle with its top nibble rewritten to 2 (verified 4/4 on the Joy ring
731# set: 0xa0004e20 → 0x20004e20 = Life Ring, etc.). Rings are NOT in the GaItem
732# array (that array is weapons/armour only), which is why they need this direct
733# transform instead of a handle lookup.
734DS3_RING_ID_MASK = 0x0FFFFFFF
735
736
737DS3_RING_ID_TYPE = 0x20000000
738
739
740##
741# @brief Map every GaItem *handle* to its item id by walking the GaItem array.
742# @details Equip slots reference items by handle; the array (same walk the
743# event-flag base uses) is the handle→id table. Big records (weapon/armour
744# types) are 60 bytes, everything else 8. Stops at the first unreadable slot.
745# @param buf The decrypted slot.
746# @return { handle: item_id } for every populated entry.
748 out, off = {}, DS3_GAITEM_START
749 for _ in range(DS3_GAITEM_SLOTS):
750 handle = u32(buf, off)
751 if handle is None:
752 break
753 iid = u32(buf, off + 4)
754 if handle and iid:
755 out[handle] = iid
756 big = handle and (handle & 0xF0000000) in DS3_GAITEM_TYPES_BIG
757 off += DS3_GAITEM_BIG if big else 8
758 return out
759
760
761##
762# @brief Equipped weapons (up to three per hand) from EquipGameData.
763# @details Reads the six handles at @ref DS3_WEAPON_SLOTS, resolves each through
764# the GaItem map, and keeps a slot only when it lands on a real *weapons* item
765# that is not the bare @ref DS3_FISTS (an empty hand reads Fists, not a null
766# handle). Right/left labelling is verified by a weapon-swap differential; the
767# resolved id carries both the infusion (a Deep weapon names itself) and the
768# reinforcement, which @ref ds3_resolve_weapon peels off as a " +N" suffix
769# (verified by a Deep Battle Axe +0→+1 differential: id 7010900 → 7010901).
770# @param buf The decrypted slot.
771# @param iddb The flat DS3 id lookup.
772# @param v The stat anchor (None → feature off).
773# @return { slot label: weapon name } for each occupied, resolvable slot.
774def ds3_equipped_weapons(buf, iddb, v):
775 if v is None:
776 return {}
777 hmap = ds3_gaitem_map(buf)
778 base = v + DS3_EQUIP_D
779 out = OrderedDict()
780 for slot, d in DS3_WEAPON_SLOTS.items():
781 handle = u32(buf, base + d)
782 iid = hmap.get(handle) if handle else None
783 if not iid or iid == DS3_FISTS:
784 continue
785 name = ds3_resolve_weapon(iddb, iid)
786 if name:
787 out[slot] = name
788 return out
789
790
791##
792# @brief Equipped armour (the four protection slots) from EquipGameData.
793# @details Reads the four handles at @ref DS3_ARMOR_SLOTS, resolves each
794# through the GaItem map, and keeps it only when it lands on a real *armour*
795# item — a self-consistency gate: an equipped piece is one you own, and the
796# category proves the slot read a protector and not a stray weapon/ring
797# handle. Verified across the Joy ladder, including a Northern→Fallen Knight
798# set change and a lone gauntlet swap, so the slots track real gear. Weapons,
799# rings and the covenant slot are NOT read: their save layout does not match
800# the runtime editor tables (the +0x50 slot holds a left-hand weapon, not the
801# covenant), and pinning them needs a swap differential — omitted, not guessed.
802# @param buf The decrypted slot.
803# @param iddb The flat DS3 id lookup.
804# @param v The stat anchor (None → feature off).
805# @return { slot: armour name } for each occupied, resolvable slot (may be empty).
806def ds3_equipped_armor(buf, iddb, v):
807 if v is None:
808 return {}
809 hmap = ds3_gaitem_map(buf)
810 base = v + DS3_EQUIP_D
811 out = OrderedDict()
812 for slot, d in DS3_ARMOR_SLOTS.items():
813 handle = u32(buf, base + d)
814 iid = hmap.get(handle) if handle else None
815 entry = iddb.get(iid) if iid else None
816 if entry and entry[1] == "armors":
817 out[slot] = entry[0]
818 return out
819
820
821##
822# @brief Equipped rings (up to four) from EquipGameData.
823# @details Each ring slot holds an accessory *handle* whose id is the handle
824# with its type nibble rewritten from 0xA to 0x2 (@ref DS3_RING_ID_TYPE) — rings
825# are not in the GaItem array, so this direct transform stands in for a lookup.
826# A slot is kept only when the derived id is a real *rings* item (self-consistency
827# gate). The transform also carries a ring's reinforcement, so a +N ring names
828# itself (the all-items mule reads "Ring of Steel Protection +3"). Verified across
829# the Joy ladder, tracking her 2→4 ring growth.
830# @param buf The decrypted slot.
831# @param iddb The flat DS3 id lookup.
832# @param v The stat anchor (None → feature off).
833# @return An ordered list of ring names (may be empty).
834def ds3_equipped_rings(buf, iddb, v):
835 if v is None:
836 return []
837 base = v + DS3_EQUIP_D
838 out = []
839 for d in DS3_RING_SLOTS:
840 handle = u32(buf, base + d)
841 if not handle:
842 continue
843 iid = (handle & DS3_RING_ID_MASK) | DS3_RING_ID_TYPE
844 entry = iddb.get(iid)
845 if entry and entry[1] == "rings":
846 out.append(entry[0])
847 return out
848
849
850##
851# @brief Equipped ammunition (the arrow/bolt quiver slots) from EquipGameData.
852# @details The four slots at @ref DS3_AMMO_SLOTS hold GaItem handles resolved
853# through the same handle→id map as armour; a slot is kept only when it lands on
854# a `bolts` item (the db's shared arrow/bolt category), which gates out an empty
855# or non-ammo slot. Verified on the Joy ladder (Standard Arrow/Bolt) and the
856# all-items mule (Millwood Greatarrow, Exploding Bolt, …).
857# @param buf The decrypted slot.
858# @param iddb The flat DS3 id lookup.
859# @param v The stat anchor (None → feature off).
860# @return An ordered list of ammo names (may be empty).
861def ds3_equipped_ammo(buf, iddb, v):
862 if v is None:
863 return []
864 hmap = ds3_gaitem_map(buf)
865 base = v + DS3_EQUIP_D
866 out = []
867 for d in DS3_AMMO_SLOTS:
868 handle = u32(buf, base + d)
869 iid = hmap.get(handle) if handle else None
870 entry = iddb.get(iid) if iid else None
871 if entry and entry[1] == "bolts":
872 out.append(entry[0])
873 return out
874
875
876##
877# @brief Parse one DS3 slot into the unified dict (full tier where stats validate).
878# @details Inventory comes from the id-scan; the name is supplied by the caller
879# from the load-screen roster. Stats are located by content (@ref ds3_find_stats)
880# and, when the level identity confirms them, promote the slot to full tier. When
881# it does not (an unrecognised patch), stats are dropped and the slot stays
882# inventory tier — a missing number beats a wrong one. Origin class and NG+ are
883# not calibrated and are omitted. Returns None when the slot has no inventory.
884# @param buf The decrypted slot data.
885# @param iddb The flat DS3 id lookup.
886# @param name The character name from the roster, or None.
887# @return A unified character dict, or None if the slot is empty.
888def ds3_parse(buf, iddb, name):
889 inv = scan_inventory(buf, iddb)[0]
890 if not inv:
891 return None
892 # Boss souls stay in the inventory (their own category, as in DS2) but are also
893 # handed to the kill inference; key items follow DS2 and move out of the
894 # inventory entirely, since the Key Items section already prints them.
895 goods = inv.get("bosssouls", []) + inv.get("goods", [])
896 key_items = inv.pop("keys", [])
897 v = ds3_find_stats(buf)
898 stats = (
899 OrderedDict((k, u32(buf, v + d)) for k, d in DS3_STAT_D.items())
900 if v is not None
901 else OrderedDict()
902 )
903 return {
904 "tier": "full" if stats else "inventory",
905 "game": "ds3",
906 "name": name if (name and is_valid_name(name)) else "(unnamed slot)",
907 "klass": None,
908 "stats": stats,
909 "soul_memory": None,
910 "humanity": None,
911 "ng_plus": None,
912 "level": u32(buf, v + DS3_LEVEL_D) if v is not None else None,
913 "souls": u32(buf, v + DS3_SOULS_D) if v is not None else None,
914 "stamina": u32(buf, v + DS3_STAM_D) if v is not None else None,
915 "hp": u32(buf, v + DS3_HP_D) if v is not None else None,
916 "fp": u32(buf, v + DS3_FP_D) if v is not None else None,
917 "embered": ds3_embered(buf, v),
918 "covenant": ds3_covenant(buf, v),
919 "equipped_weapons": ds3_equipped_weapons(buf, iddb, v),
920 "equipped_armor": ds3_equipped_armor(buf, iddb, v),
921 "equipped_rings": ds3_equipped_rings(buf, iddb, v),
922 "equipped_ammo": ds3_equipped_ammo(buf, iddb, v),
923 "boss_souls": find_boss_souls(goods),
924 "key_items": key_items,
925 "inv": inv,
926 "unknown_count": 0,
927 }
928
929
930## @brief DS3 id-scan tables: filename stem to category.
931DS3_DB_FILES = {
932 "weapons": "weapons",
933 "armors": "armors",
934 "rings": "rings",
935 "goods": "goods",
936 "bolts": "bolts",
937 "spells": "spells",
938}
939
940
941## @brief Play time (seconds, uint32) inside slot @p i's DS3 roster descriptor.
942# The status block does not carry it — it lives in the header menu block, at the
943# same per-slot descriptor as the name, @c +38 past the descriptor start. Pinned
944# by a ~17-minute differential (a real Joy save, 7573 s -> 8603 s, matching an
945# on-screen 2:23:24). Per-character (one descriptor per slot).
946DS3_ROSTER_PLAYTIME_OFF = 38
947
948
949def ds3_playtime(menu_data, i):
950 p = ROSTER_PARAMS["ds3"]
951 return u32(menu_data, p["desc"] + p["stride"] * i + DS3_ROSTER_PLAYTIME_OFF)
952
953
954# ─────────────────────────────────────────────────────────────────────────────
955# DS3 event flags — bonfires discovered + bosses defeated (read from the save)
956# ─────────────────────────────────────────────────────────────────────────────
957# DS3 serialises event flags in a large region inside each character slot, located
958# by walking the variable-length blocks that precede it (the GaItem array, then
959# inventory / storage / gesture / NG+ headers). Offsets/constants come from the
960# alfizari DS3 save editor (main_ds3.py parse_save) and were verified against a real
961# save: Joy reads Iudex Gundyr defeated + Cemetery-of-Ash / High-Wall bonfires and
962# NOTHING else — zero false positives across 25 bosses and 12 areas on two backups.
963# This retires the old "DS3 flags aren't in the save" blocker.
964#
965# Our decrypted slot drops the 4-byte length prefix alfizari's buffer keeps, so every
966# absolute offset is theirs minus 4: the GaItem walk starts at 0x6C (their 0x70); the
967# rest is block-relative and self-corrects.
968DS3_GAITEM_START = 0x6C
969
970
971DS3_GAITEM_SLOTS = 6144
972
973
974DS3_GAITEM_BIG = 60 # weapon/armour record; all else 8
975
976
977DS3_GAITEM_TYPES_BIG = (0x80000000, 0x90000000) # weapon, armour top nibbles
978
979
980# Event flags are sparse: even a 100%-complete NG+ character sets only ~0.2% of the
981# region's bits (the all-items mule's finished slot measures 0.0022, a real mid-game
982# save 0.0023). Ordinary save data is far denser, so a base that lands off the region
983# gives itself away — the mule's OTHER slot walks to a wrong base and measures 0.0285,
984# where the flag reads degenerate into solid runs of consecutive "set" flags. Without
985# this gate that slot invented four covenants and a Stray Demon kill. The threshold is
986# ~4x the highest real reading and ~3x below the bad one, so it is a wide separation,
987# not a fitted one. Same defence as DS1's DS1_FLAG_MAX_DENSITY.
988DS3_FLAG_MAX_DENSITY = 0.01
989
990
991DS3_FLAG_SAMPLE = 0x8000
992
993
994##
995# @brief Locate the DS3 event-flag region base in a decrypted slot, or None.
996# @details Walks the same block chain the alfizari editor uses. Every read is
997# bounds-checked (u32 returns None past the buffer), so a short or edited save turns
998# the feature off rather than reading garbage. The located base is then sanity-checked
999# on bit density (see @ref DS3_FLAG_MAX_DENSITY) — a mislocated base reads dense and is
1000# rejected, turning every flag feature off for that slot rather than inventing progress.
1001# @return The base offset, or None.
1002def ds3_event_flag_base(buf):
1003 off = DS3_GAITEM_START
1004 for _ in range(DS3_GAITEM_SLOTS):
1005 handle = u32(buf, off)
1006 if handle is None:
1007 return None
1008 big = handle and (handle & 0xF0000000) in DS3_GAITEM_TYPES_BIG
1009 off += DS3_GAITEM_BIG if big else 8
1010 above_counter = off + 0x13F + 0x1DD + 0x8808 + 0x11C
1011 above_size = u32(buf, above_counter)
1012 if above_size is None:
1013 return None
1014 gesture_end = above_counter + 4 + above_size * 8 + 0x18C + 0x4 + 0x8800 + 0xC + 0xA4
1015 table2_size = u32(buf, gesture_end)
1016 if table2_size is None:
1017 return None
1018 base = gesture_end + 4 + table2_size * 4 + 0x92 + 0xBCC - 0x12
1019 if not 0 <= base < len(buf):
1020 return None
1021 sample = buf[base : base + DS3_FLAG_SAMPLE]
1022 if not sample:
1023 return None
1024 density = sum(bin(b).count("1") for b in sample) / (len(sample) * 8)
1025 return base if density <= DS3_FLAG_MAX_DENSITY else None
1026
1027
1028##
1029# @brief Read DS3 bonfires + boss-defeat flags off the event-flag region and attach
1030# them to @p ch: @c bonfire_areas as [(area, count, [names])] — every lit
1031# bonfire named from @ref load_ds3_bonfires — and merge @c flag boss evidence
1032# into @c ch["bosses"] (deduping with any soul/gate evidence already there,
1033# from both the per-map and the group-6 victory tables), @c cinders as the
1034# Lords of Cinder whose ashes are on the throne, and @c endings as the endings
1035# this character has reached (cumulative, so an NG+ save can hold several).
1036# No-op if @p base is None (region not located).
1037# @param ch A parsed character. @param buf The decrypted slot.
1038# @param base The event-flag base from @ref ds3_event_flag_base.
1039# @param base_dir Repo root holding the db_* folders.
1040def ds3_attach_flags(ch, buf, base, base_dir):
1041 if base is None:
1042 return
1043 areas, any_lit = [], False
1044 for area, bonfires in load_ds3_bonfires(base_dir).items():
1045 named, missing = [], []
1046 for dist, bit, name in bonfires:
1047 val = u8(buf, base + dist)
1048 (named if val is not None and val & (1 << bit) else missing).append(name)
1049 any_lit = any_lit or bool(named)
1050 areas.append((area, len(named), named, len(bonfires), missing))
1051 # Every area is kept, lit or not — an area reading 0/5 is the useful half of the
1052 # report. Only a slot with nothing lit anywhere gets no section at all.
1053 if any_lit:
1054 ch["bonfire_areas"] = areas
1055 bosses = {b: set(s) for b, s in (ch.get("bosses") or {}).items()}
1056 for table in (load_ds3_boss_flags(base_dir), load_ds3_boss_victory(base_dir)):
1057 for name, (dist, bit) in table.items():
1058 val = u8(buf, base + dist)
1059 if val is not None and val & (1 << bit):
1060 bosses.setdefault(name, set()).add("flag")
1061 if bosses:
1062 ch["bosses"] = {b: sorted(bosses[b]) for b in bosses}
1063 cinders = [
1064 lord
1065 for lord, (dist, bit) in load_ds3_lord_cinders(base_dir).items()
1066 if (u8(buf, base + dist) or 0) & (1 << bit)
1067 ]
1068 if cinders:
1069 ch["cinders"] = cinders
1070 quests = OrderedDict()
1071 for src, rewards in load_ds3_questlines(base_dir).items():
1072 got = [
1073 rw for dist, bit, rw in rewards if (u8(buf, base + dist) or 0) & (1 << bit)
1074 ]
1075 if got:
1076 quests[src] = got
1077 if quests:
1078 ch["questlines"] = quests
1079 # World pickups: only the areas whose flag group has a derived base are in the
1080 # table at all, so an area missing here means "not tracked", never "nothing found".
1081 picks, any_found = [], False
1082 for area, items in load_ds3_pickups(base_dir).items():
1083 got, missing = [], []
1084 for dist, bit, item, where in items:
1085 val = u8(buf, base + dist)
1086 # A missing item carries WHERE it is, when the table knows one: the list is
1087 # a to-do list, and "Titanite Shard" on its own is not one. About a quarter
1088 # of the flags have no location and stay a bare name.
1089 label = f"{item} — {where}" if where else item
1090 (got if val is not None and val & (1 << bit) else missing).append(label)
1091 any_found = any_found or bool(got)
1092 picks.append((area, len(got), len(items), missing))
1093 if any_found:
1094 ch["pickups"] = picks
1095 # One-time enemies, in the bonfire shape because a kill is a named thing and the
1096 # reader wants to know WHICH — the same call Sekiro's minibosses got.
1097 foes, any_dead = [], False
1098 for area, enemies in load_ds3_enemies(base_dir).items():
1099 dead, alive = [], []
1100 for dist, bit, name in enemies:
1101 val = u8(buf, base + dist)
1102 (dead if val is not None and val & (1 << bit) else alive).append(name)
1103 any_dead = any_dead or bool(dead)
1104 foes.append((area, len(dead), dead, len(enemies), alive))
1105 if any_dead:
1106 ch["enemies"] = foes
1107 # NPC states: killed, turned hostile, questline milestones. Counts and the names of
1108 # what fired — the ones that have NOT fired are deliberately not listed, because half
1109 # of them are mutually exclusive outcomes of one questline and a "missing" list would
1110 # read as a to-do list of things a player cannot all do.
1111 npcs = []
1112 for family, marks in load_ds3_npcs(base_dir).items():
1113 got = []
1114 for dist, bit, label in marks:
1115 val = u8(buf, base + dist)
1116 if val is not None and val & (1 << bit):
1117 got.append(label)
1118 npcs.append((family, len(got), got, len(marks)))
1119 if any(c for _f, c, _g, _t in npcs):
1120 ch["npc_states"] = npcs
1121
1122 covs = OrderedDict()
1123 for cov, marks in load_ds3_covenants(base_dir).items():
1124 got = [
1125 what
1126 for dist, bit, what in marks
1127 if (u8(buf, base + dist) or 0) & (1 << bit)
1128 ]
1129 if got:
1130 covs[cov] = got
1131 if covs:
1132 ch["covenants"] = covs
1133
1134 endings = [
1135 end
1136 for end, (dist, bit) in load_ds3_endings(base_dir).items()
1137 if (u8(buf, base + dist) or 0) & (1 << bit)
1138 ]
1139 if endings:
1140 ch["endings"] = endings
1141
1142
1143## @brief DS3 New Game+ cycle (journey count), a uint16 just before the event-flag
1144# region, or None. new_game_plus sits at @c base + 0x12 - 0xBCC (base is that region
1145# less 0x12 — see @ref ds3_event_flag_base). Guarded to a sane range: a cheated mule
1146# read 0xFFFF here, so an out-of-range value is omitted rather than printed wrong.
1147# Verified: Joy = 0 (New Game), a real NG+1 char = 1. @return The cycle, or None.
1148DS3_NG_MAX = 99
1149
1150
1151def ds3_journey(buf, base):
1152 if base is None:
1153 return None
1154 ng = u16(buf, base + 0x12 - 0xBCC)
1155 return ng if ng is not None and 0 <= ng <= DS3_NG_MAX else None
load_ds3_npcs(base_dir)
Definition ds3.py:297
load_ds3_ring_effects(base_dir)
Definition ds3.py:25
ds3_equipped_weapons(buf, iddb, v)
Equipped weapons (up to three per hand) from EquipGameData.
Definition ds3.py:774
ds3_equipped_rings(buf, iddb, v)
Equipped rings (up to four) from EquipGameData.
Definition ds3.py:834
ds3_covenant(buf, v)
DS3 covenant name for a slot, or None.
Definition ds3.py:613
load_ds3_endings(base_dir)
Definition ds3.py:345
load_ds3_enemies(base_dir)
Definition ds3.py:276
ds3_resolve_estus(iid)
Resolve a DS3 goods id to an Estus flask name with its +N, or None.
Definition ds3.py:719
ds3_goods_cat(iid)
Refine a DS3 goods id to its finer category (see DS3_GOODS_RANGES).
Definition ds3.py:483
ds3_gaitem_map(buf)
Map every GaItem handle to its item id by walking the GaItem array.
Definition ds3.py:747
ds3_equipped_armor(buf, iddb, v)
Equipped armour (the four protection slots) from EquipGameData.
Definition ds3.py:806
ds3_resolve_weapon(iddb, iid)
Resolve an equipped DS3 weapon id to a name, unwrapping the baked "+N".
Definition ds3.py:686
load_ds3_questlines(base_dir)
Definition ds3.py:230
ds3_equipped_ammo(buf, iddb, v)
Equipped ammunition (the arrow/bolt quiver slots) from EquipGameData.
Definition ds3.py:861
ds3_ring_level(name)
Split an equipped ring name into its table key and reinforcement level.
Definition ds3.py:41
ds3_ring_bonuses(ring_mods)
Sum the worn rings' structured modifiers.
Definition ds3.py:85
ds3_embered(buf, v)
DS3 embered state for a slot, or None.
Definition ds3.py:601
ds3_find_stats(buf)
Locate the DS3 stat block by content, or None if none validates.
Definition ds3.py:578
load_ds3_bonfires(base_dir)
Definition ds3.py:148
scan_inventory(buf, iddb)
Find inventory by scanning for known item ids in fixed-size records.
Definition ds3.py:523
load_ds3_pickups(base_dir)
Definition ds3.py:253
load_ds3_lord_cinders(base_dir)
Definition ds3.py:209