SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
render.py
Go to the documentation of this file.
1"""Markdown rendering: one section per character, plus the shared tables."""
2
3from collections import OrderedDict
4
5from .ds1 import ds1_derived_stats
6from .ds2 import DS2_FAMILY, DS2_GAMES, DS2_GREAT_SOULS, ds2_derived_stats
7from .ds3 import ds3_derived_stats
8
9## @brief Short attribute headers for the table.
10STAT_ABBR = {
11 "Vigor": "VGR",
12 "Endurance": "END",
13 "Vitality": "VIT",
14 "Attunement": "ATN",
15 "Strength": "STR",
16 "Dexterity": "DEX",
17 "Adaptability": "ADP",
18 "Intelligence": "INT",
19 "Faith": "FTH",
20 "Resistance": "RES",
21 "Luck": "LCK",
22 "Mind": "MND",
23 "Arcane": "ARC",
24}
25
26
27## @brief What each attribute governs, per game. Static game-design fact — NOT read
28# from the save and never a copied stat value, so it is true for any build and can
29# never be the "wrong field" the core rule forbids. Keyed by game family because the
30# same attribute name means different things across games (DS1 Vitality is HP;
31# DS2/DS3 Vitality is equip load) — exactly the nuance the rule cares about. From the
32# games' own status screens / community wikis.
33STAT_GOVERNS = {
34 "ds1": OrderedDict(
35 [
36 ("Vitality", "Max HP"),
37 ("Attunement", "Attunement (spell) slots"),
38 ("Endurance", "Stamina, equip load, physical defense"),
39 ("Strength", "Physical attack, strength-weapon scaling"),
40 ("Dexterity", "Physical attack, dex-weapon scaling, faster casting"),
41 ("Resistance", "Poison/bleed resistance, fire defense"),
42 ("Intelligence", "Magic attack, sorcery scaling"),
43 ("Faith", "Miracle scaling, lightning & magic defense"),
44 ]
45 ),
46 "ds2sotfs": OrderedDict(
47 [
48 ("Vigor", "Max HP"),
49 ("Endurance", "Stamina"),
50 ("Vitality", "Equip load, physical defense, petrify resistance"),
51 ("Attunement", "Attunement (spell) slots, casting speed"),
52 ("Strength", "Physical attack, strength-weapon scaling"),
53 ("Dexterity", "Physical attack, dex-weapon scaling, casting speed"),
54 ("Adaptability", "Agility (i-frames), poison/bleed/petrify resistance"),
55 ("Intelligence", "Magic & dark attack, sorcery/hex scaling"),
56 ("Faith", "Lightning & dark attack, miracle/hex scaling"),
57 ]
58 ),
59 "ds3": OrderedDict(
60 [
61 ("Vigor", "Max HP"),
62 ("Attunement", "FP, attunement (spell) slots"),
63 ("Endurance", "Stamina"),
64 ("Vitality", "Equip load, physical defense"),
65 ("Strength", "Physical attack, strength-weapon scaling"),
66 ("Dexterity", "Physical attack, dex-weapon scaling, faster casting"),
67 ("Intelligence", "Magic attack, sorcery & pyromancy scaling"),
68 ("Faith", "Lightning & dark attack, miracle & pyromancy scaling"),
69 ("Luck", "Item discovery, bleed/poison buildup, hollow-weapon scaling"),
70 ]
71 ),
72 "er": OrderedDict(
73 [
74 ("Vigor", "Max HP, fire defense & immunity"),
75 ("Mind", "FP (skill/spell points), focus resistance"),
76 ("Endurance", "Stamina, equip load, robustness"),
77 ("Strength", "Physical attack, strength-weapon scaling"),
78 ("Dexterity", "Dex-weapon scaling, faster casting, less fall damage"),
79 ("Intelligence", "Sorcery scaling, magic defense"),
80 ("Faith", "Incantation scaling"),
81 ("Arcane", "Item discovery, arcane-weapon scaling, death/holy resistance"),
82 ]
83 ),
84}
85
86
87## @brief Map a per-slot game id to its STAT_GOVERNS family (DSR and PtDE share DS1).
89 return STAT_GOVERNS.get(DS2_FAMILY.get(game, game), {})
90
91
92## @brief Soft-cap / per-level breakpoint reference per attribute, per game. These are
93# the documented scaling RATES and soft-cap levels (a game-mechanics fact, true for any
94# build), NOT a per-character computed value — computing the absolute would be wrong
95# (DS2 Vigor 36 gives HP 1351 in-save vs 1420 from the flat table, because the real
96# curve carries base/class offsets the summaries drop). So the tool prints the rate
97# table and the character's own stat value, and never a derived absolute it cannot
98# verify. Sourced from the fextralife stat pages (DS1/DS2/DS3/ER), fetched per stat.
99STAT_CAPS = {
100 "ds1": OrderedDict(
101 [
102 (
103 "Vitality",
104 "soft caps 30 (~1,100 HP) & 50 (~1,500 HP), rising to ~1,900 at 99",
105 ),
106 (
107 "Attunement",
108 "1 slot at 10, then 12/14/16/19/23/28/34/41/50 — 10 slots max at 50",
109 ),
110 (
111 "Endurance",
112 "stamina maxes at 40 (160); equip load keeps rising (~+1/lvl) to 99",
113 ),
114 ("Strength", "scaling soft cap 40"),
115 ("Dexterity", "scaling soft cap 40; cast speed improves to 45"),
116 ("Resistance", "minor per-level gains — commonly a dump stat"),
117 ("Intelligence", "scaling soft cap 40"),
118 ("Faith", "scaling soft cap 40"),
119 ]
120 ),
121 "ds2sotfs": OrderedDict(
122 [
123 ("Vigor", "soft caps 20 & 50; +30 HP/lvl to 20, +20 to 50, +5 after"),
124 ("Endurance", "soft cap 20; +2 stamina/lvl to 20, +1 after"),
125 (
126 "Vitality",
127 "soft caps 29/49/70; +1.5 load/lvl to 29, +1 to 49, +0.5 to 69, +0.25 after",
128 ),
129 (
130 "Attunement",
131 "slots at 10/13/16/20/25/30/40/50/60/75/94; cast-speed breakpoints 30/45/60/80",
132 ),
133 ("Strength", "scaling soft caps 40 & 50"),
134 ("Dexterity", "scaling soft caps 40 & 50"),
135 ("Adaptability", "raises Agility (with Attunement); gains taper past ~40"),
136 ("Intelligence", "scaling soft caps 40 & 50"),
137 ("Faith", "scaling soft caps 40 & 50"),
138 ]
139 ),
140 "ds3": OrderedDict(
141 [
142 ("Vigor", "soft caps ~27 & 50; ~1,300 HP at 50, only ~100 more to 99"),
143 (
144 "Attunement",
145 "FP soft cap 35 (450 max at 99); slots at 10/14/18/24/30/40/50/60/80/99",
146 ),
147 ("Endurance", "stamina soft cap 40"),
148 ("Vitality", "roughly linear to 99"),
149 ("Strength", "scaling soft caps 40 & 60"),
150 ("Dexterity", "scaling soft caps 40 & 60"),
151 ("Intelligence", "scaling soft caps 40 & 60"),
152 ("Faith", "scaling soft caps 40 & 60"),
153 ("Luck", "+1 item discovery/pt (base 100); bleed/poison speed soft cap 50"),
154 ]
155 ),
156 "er": OrderedDict(
157 [
158 ("Vigor", "soft caps 40 & 60"),
159 ("Mind", "soft caps 50 & 60"),
160 ("Endurance", "stamina soft caps 15/30/50; equip load 25/60"),
161 ("Strength", "scaling soft caps 20/50/80"),
162 ("Dexterity", "scaling soft caps 20/50/80"),
163 ("Intelligence", "scaling soft caps 20/50/80"),
164 ("Faith", "scaling soft caps 20/50/80"),
165 ("Arcane", "scaling soft caps 20/50/80; also raises item discovery"),
166 ]
167 ),
168}
169
170
171## @brief Soft-cap reference for a per-slot game id (DSR and PtDE share DS1).
173 return STAT_CAPS.get(DS2_FAMILY.get(game, game), {})
174
175
176## @brief Category id to printed heading (covers every id scheme / game).
177CAT_TITLE = {
178 "weapons": "Weapons",
179 "armors": "Armor",
180 "rings": "Rings",
181 "talismans": "Talismans",
182 "spells": "Spells",
183 "bolts": "Ammunition",
184 "upgrade": "Upgrade Materials",
185 "consumables": "Consumables",
186 "online": "Summon & Covenant Items",
187 "goods": "Consumables & Goods",
188 "ashes": "Ashes of War",
189 "emotes": "Gestures",
190 "bosssouls": "Boss Souls",
191 "items": "Items Owned",
192 # Sekiro's own six. Its storage box stands alone because an item in the
193 # box is owned but not carried, and only a read that keeps them apart can
194 # say which.
195 "arts": "Combat Arts",
196 "prosthetics": "Prosthetic Tools",
197 "skills": "Skills & Techniques",
198 "beads": "Prayer Beads & Gourd Seeds",
199 "memories": "Memories & Remnants",
200 "storage": "Storage (item box)",
201}
202
203
204##
205# @brief Per-game overrides for a category heading, where the shared name is wrong for
206# that game. @c "Armor" is wrong for Sekiro under any reading: the game has no
207# armour system, and the rows that survive the suppression filter are cosmetic
208# attire, so the heading says what they are.
209CAT_TITLE_GAME = {"sdt": {"armors": "Attire"}}
210
211
212def cat_title(game, cat):
213 return CAT_TITLE_GAME.get(game, {}).get(cat, CAT_TITLE[cat])
214
215
216## @brief Print order for inventory categories, mirroring the in-game item menu.
217# (`goods` is the lumped consumables bucket the non-DS2 games still use.)
218CAT_ORDER = [
219 "weapons",
220 "arts",
221 "prosthetics",
222 "skills",
223 "armors",
224 "rings",
225 "talismans",
226 "spells",
227 "bolts",
228 "upgrade",
229 "consumables",
230 "beads",
231 "goods",
232 "ashes",
233 "online",
234 "bosssouls",
235 "memories",
236 "emotes",
237 "items",
238 "storage",
239]
240
241
242##
243# @brief Guess a build label from the attribute spread. A rough label, not gospel.
244# @param stats The character's attribute dict.
245# @return A short description, or None if there are no stats to judge.
246def guess_build(stats):
247 if not stats:
248 return None
249
250 def g(k):
251 return stats.get(k) or 0
252
253 phys, cast = (
254 g("Strength") + g("Dexterity"),
255 g("Intelligence") + g("Faith") + g("Attunement"),
256 )
257 if cast > phys:
258 return "caster / hybrid (high INT/FTH/ATN)"
259 if g("Strength") >= g("Dexterity") + 6:
260 return "strength-focused melee"
261 if g("Dexterity") >= g("Strength") + 6:
262 return "dexterity-focused melee"
263 return "quality / balanced melee"
264
265
266## @brief What a game calls the money in your pocket. Souls unless it says otherwise.
267CURRENCY = {"er": "Runes", "sdt": "Sen"}
268
269
270##
271# @brief Sekiro's Memory line: how many boss Memories have been spent, and what that
272# makes the kill count.
273# @details Worth its own function because it is the one place in this tool where a
274# CONSUMED progress token is still countable. Attack Power rises by exactly one per
275# Memory consumed, so the arithmetic recovers what every other game's soul floor loses
276# the moment the soul is spent. Both of its limits are printed, not buried: the count
277# is a floor for bosses that drop no Memory at all, and past journey 0 it covers every
278# lap rather than this one, because Attack Power carries over and the Memories do not.
279# @param m The @c ch["memories"] dict from @ref sl2.sdt.sdt_parse.
280def memories_line(m):
281 total = m["spent"] + m["held"]
282 lap = (
283 "across every journey so far — Attack Power carries into New Game+ while "
284 "the Memories do not"
285 if m["cumulative"]
286 else "this journey; a boss that drops no Memory is not counted either way"
287 )
288 return (
289 f"{total} Memory-dropping boss{'' if total == 1 else 'es'} defeated"
290 f" _({m['spent']} Memor{'y' if m['spent'] == 1 else 'ies'} already spent, "
291 f"read back from Attack Power, plus {m['held']} still held — {lap})_"
292 )
293
294
295##
296# @brief What a Vitality figure says about Prayer Necklaces used.
297# @details The Memory trick again, on Sekiro's other upgrade track: a necklace is
298# consumed on use, so nothing in the inventory records that it was ever held, but
299# Vitality rises by exactly one each time and a fresh character reads 1. So the
300# subtraction recovers a spent token the item list cannot see. It carries into New
301# Game+ the same way Attack Power does, which is why the count is never claimed to
302# belong to this journey.
303# @param vitality The stored Vitality. @return The parenthetical, without brackets.
304def vitality_necklaces(vitality):
305 used = vitality - 1
306 return (
307 "no Prayer Necklace used yet"
308 if used == 0
309 else f"{used} Prayer Necklace{'' if used == 1 else 's'} used — four Prayer Beads "
310 f"each, read back from Vitality across every journey so far"
311 )
312
313
314##
315# @brief What a Healing Gourd charge count says about Gourd Seeds used.
316# @details The Memory trick a third time, and the one where the subtraction earns the
317# most: a seed is consumed the moment Emma takes it, so a finished upgrade leaves
318# nothing at all in the inventory to say it happened. One seed buys one charge and a
319# fresh gourd holds one, so the charges recover the spent tokens. Like the other two
320# counters it carries into New Game+, which is why the count is never claimed to belong
321# to this journey.
322# @param gourd The gourd's charge count. @return The parenthetical, without brackets.
323def gourd_seeds(gourd):
324 used = gourd - 1
325 return (
326 "no Gourd Seed used yet"
327 if used == 0
328 else f"{used} Gourd Seed{'' if used == 1 else 's'} used — handed to Emma, read "
329 f"back from the gourd's own capacity across every journey so far"
330 )
331
332
333## @brief Format a value, or "—" when it is unknown (None).
334def fmt(value):
335 return (
336 "—" if value is None else f"{value:,}" if isinstance(value, int) else str(value)
337 )
338
339
340## @brief Format a play-time count of seconds as H:MM:SS (hours can exceed 24).
341def fmt_playtime(seconds):
342 h, rem = divmod(seconds, 3600)
343 mn, s = divmod(rem, 60)
344 return f"{h}:{mn:02d}:{s:02d}"
345
346
347##
348# @brief Render one full/inventory-tier character as a Markdown section.
349# @param ch A unified character dict.
350# @param slot_no The 1-based save-slot number.
351# @return The Markdown for this character.
352DS1_BONFIRE_NOTE = "each bonfire's own record, with how far it is kindled — a floor"
353
354
355DS3_BONFIRE_NOTE = "bonfires lit, inferred from each area's flag bits — a floor"
356
357
358# DS2 reads the world block's own discovered-bonfire array, so it names every one it
359# found; the areas are a grouping of that list, not an inference.
360DS2_BONFIRE_NOTE = "each bonfire the save records as discovered, by area — a floor"
361
362
363# Sekiro's equivalent of a bonfire is a Sculptor's Idol, so it gets its own heading as
364# well as its own note. It also RESETS on a new journey, which no other game's bonfire
365# section has to say — DS1/DS2/DS3 all carry theirs across NG+.
366SDT_BONFIRE_NOTE = (
367 "each idol's own flag bit, by area — a floor, and one that starts "
368 "again on a new journey"
369)
370
371
372# Sekiro files a miniboss's defeat under its own ENTITY id, so unlike the Memory bosses
373# these are exact rather than inferred. The names are enemy TYPES from the only published
374# table that lists them, which is why four of them are "Shura Samurai" — those are four
375# different enemies, not one printed four times.
376# DS1's event-flag region carries far more than boss kills: the bells, the Lordvessel,
377# every shortcut door and lever, the non-boss fog gates, NPC states and the covenant
378# joined. Each is exact — a flag is set or it is not — but the SET is only what has been
379# named, so the denominator is "tracked", not "in the game".
380DS1_WORLD_NOTE = (
381 "one-off world events, each read from its own flag — exact, but only "
382 "the flags that have been named are counted"
383)
384
385
386SDT_MINIBOSS_NOTE = (
387 "each miniboss's own defeat flag — exact, not inferred, but it "
388 "resets on a new journey; names are the game's own, so a repeat in an "
389 "area is a second placement of that character"
390)
391
392
393# Only six areas have a derived flag-group base, so this counts what is TRACKED, not
394# what the game ships. An area absent from the list is unmapped, not empty.
395DS3_PICKUP_NOTE = (
396 "one-off world items picked up, from each area's pickup flags — "
397 "covers only the areas whose flag group is mapped"
398)
399
400
401# Sekiro counts the same thing off its own item-lot flags, and its gap is the reverse
402# shape: the addressing is solved, but 237 of the 826 known lots sit in families that no
403# idol names, and six more are in a top-level group with no seat, so they have no
404# category to be read from. The nine areas here are the ones that do, which is why the
405# denominator is 581 and not 826 — two fewer than the seats alone allow, because one row
406# is misfiled under another lot's id and one lot's real flag is in a family with no seat
407# (see load_sdt_lot_flags).
408SDT_PICKUP_NOTE = (
409 "one-off world items picked up, from each area's item-lot flags — covers the "
410 "nine areas whose flag bank is mapped, 581 of the 826 lots the table knows"
411)
412
413
414# Enemies that do not respawn, so the game has to remember each one dead. These are the
415# enemy's own DEATH flag, not the pickup flag its loot sets, which matters: on a finished
416# run one of the fourteen Symbol of Avarice pickups is set and all fourteen mimics are
417# accounted for here. Names are types, so the count beside a repeated name is real.
418# Every row here is DERIVED from the committed event scripts and none is verified against
419# a save, which is why it prints counts and not names: the names are FromSoft's own
420# Japanese and translating them would be inventing a label. Two families are known not to
421# mean what they say — see tools/gen_ds1_world_events.py — and the note says so out loud
422# rather than quietly printing a number a reader would take for a boss count.
423DS1_EVENT_NOTE = (
424 "world events from the game's own event scripts, counted by kind — sourced, "
425 "not verified here; some are transient (set while the event runs), so a "
426 "finished run can honestly read 0 in a family"
427)
428
429
430# The labels come from FromSoft's own descriptions where there is one, and the ladder has
431# already caught one sitting on the wrong flag, so the entity id rides along for checking.
432DS3_NPC_NOTE = (
433 "NPC deaths, hostility and questline milestones, each its own flag — the "
434 "descriptions are the source's and one is known to be on the wrong flag, so "
435 "an entity id is printed beside them where there is one"
436)
437
438
439DS3_ENEMY_NOTE = (
440 "each enemy's own defeat flag — exact, not inferred, and it carries "
441 "across a new journey; names are enemy types, so repeats in an area "
442 "are different enemies"
443)
444
445
446##
447# @brief The Lords-of-Cinder line: how many of the four are on the throne, which ones
448# the mapped flags name, and the two counts the number came from.
449# @param lords The @c ch["lords"] dict from @ref attach_progress_totals.
450def lords_line(lords):
451 # Mid-dot separated: "Aldrich, Devourer of Gods" has a comma of its own.
452 named = f" — {' · '.join(lords['named'])}" if lords["named"] else ""
453 if lords["placed"] is None: # NG+: the thrones reset, the defeat flags do not
454 return (
455 f"{len(lords['named'])} of {lords['total']}{named}"
456 " _(NG+ — only the mapped throne flags are read, so this is a floor)_"
457 )
458 return (
459 f"{lords['placed']} of {lords['total']}{named}"
460 f" _({lords['dead']} of the four lords defeated, {lords['held']} set"
461 f"{'' if lords['held'] == 1 else 's'} of cinders still held)_"
462 )
463
464
465##
466# @brief Render "N of M" plus the names still missing, for a progress section.
467# @param missing The names with no evidence. Long lists stay one italic line so the
468# section keeps its shape.
469def missing_note(label, missing):
470 return f"_{label}: {' · '.join(missing)}._" if missing else None
471
472
473##
474# @brief Collapse repeats in a name list to @c "name ×N", keeping first-seen order.
475# @details An area holds seven separate mimics and a dozen Titanite Shards, each with
476# its own flag. Printing the name seven times is faithful but unreadable; the count
477# says the same thing. @param names The list. @return The collapsed list.
478def count_dupes(names):
479 seen = OrderedDict()
480 for n in names:
481 seen[n] = seen.get(n, 0) + 1
482 return [n if c == 1 else f"{n} ×{c}" for n, c in seen.items()]
483
484
485def md_for_character(ch, slot_no):
486 L = [f"## Slot {slot_no}: {ch['name']}", ""]
487 if ch["level"] is not None:
488 L.append(
489 f"- **{'Level' if ch['game'] == 'er' else 'Soul Level'}:** {ch['level']}"
490 )
491 if ch["klass"]:
492 L.append(f"- **Class:** {ch['klass']}")
493 if ch.get("covenant"):
494 L.append(f"- **Covenant:** {ch['covenant']}")
495 if ch.get("gender"):
496 L.append(f"- **Sex:** {ch['gender']}")
497 if ch["ng_plus"] is not None:
498 ng = "New Game" if ch["ng_plus"] == 0 else f"New Game +{ch['ng_plus']}"
499 L.append(f"- **Playthrough:** {ng}")
500 if ch["soul_memory"] is not None:
501 L.append(
502 f"- **Soul Memory:** {fmt(ch['soul_memory'])} _(total souls earned — main progress metric)_"
503 )
504 if ch.get("play_time"):
505 L.append(f"- **Play Time:** {fmt_playtime(ch['play_time'])}")
506 if ch["souls"] is not None:
507 L.append(f"- **{CURRENCY.get(ch['game'], 'Souls')} held:** {fmt(ch['souls'])}")
508 if ch.get("attack") is not None:
509 L.append(f"- **Attack Power:** {ch['attack']}")
510 if ch.get("vitality") is not None:
511 L.append(
512 f"- **Vitality:** {ch['vitality']} _({vitality_necklaces(ch['vitality'])})_"
513 )
514 if ch.get("gourd") is not None:
515 L.append(
516 f"- **Healing Gourd:** {ch['gourd']} charge"
517 f"{'' if ch['gourd'] == 1 else 's'} _({gourd_seeds(ch['gourd'])})_"
518 )
519 if ch.get("skill_points") is not None:
520 L.append(
521 f"- **Skill Points Held:** {ch['skill_points']}"
522 " _(spendable at a Sculptor's Idol — the points already spent are "
523 "not stored, so this is what is banked, not what has been earned)_"
524 )
525 if ch.get("memories"):
526 L.append(f"- **Memories:** {memories_line(ch['memories'])}")
527 if ch["humanity"] is not None:
528 L.append(f"- **Humanity:** {ch['humanity']}")
529 if ch["hp"] is not None:
530 L.append(f"- **Max HP:** {fmt(ch['hp'])}")
531 if ch.get("posture") is not None:
532 L.append(f"- **Max Posture:** {fmt(ch['posture'])}")
533 if ch.get("embered") is not None:
534 L.append(
535 "- **Embered:** Yes _(Max HP above includes the +30% ember bonus)_"
536 if ch["embered"]
537 else "- **Embered:** No _(hollow — Max HP above is the base value)_"
538 )
539 if ch.get("fp") is not None:
540 L.append(f"- **Max FP:** {fmt(ch['fp'])}")
541 if ch.get("hollow_lvl"):
542 L.append(
543 f"- **Hollowing:** {ch['hollow_lvl']} _(higher = more deaths without an effigy)_"
544 )
545 if ch.get("deaths") is not None:
546 L.append(f"- **Deaths:** {fmt(ch['deaths'])}")
547 if ch["stamina"] is not None:
548 L.append(f"- **Stamina:** {fmt(ch['stamina'])}")
549 if ch.get("lords"):
550 L.append(f"- **Cinders of a Lord Placed:** {lords_line(ch['lords'])}")
551 if ch.get("endings"):
552 ends = ch["endings"]
553 L.append(
554 f"- **Ending{'' if len(ends) == 1 else 's'} Reached:** " + " · ".join(ends)
555 )
556 build = guess_build(ch["stats"])
557 if build:
558 L.append(f"- **Build:** {build}")
559 L.append("")
560
561 if ch["stats"]:
562 keys = list(ch["stats"].keys())
563 L += [
564 "### Attributes",
565 "",
566 "| " + " | ".join(STAT_ABBR.get(k, k[:3].upper()) for k in keys) + " |",
567 "|" + "----|" * len(keys),
568 "| " + " | ".join(str(ch["stats"][k]) for k in keys) + " |",
569 "",
570 ]
571 gov = stat_governs_for(ch["game"])
572 cap = stat_caps_for(ch["game"])
573 rows = [k for k in keys if k in gov]
574 if rows:
575 # Fixed game-mechanics reference, identical in every export bar the
576 # current values — folded away so it does not sit between the save's own
577 # numbers and its progress (and so two exports diff cleanly).
578 L += [
579 "<details>",
580 "<summary><b>Attribute Scaling</b> — what each stat "
581 "scales and its soft caps (game-mechanics reference, not read from "
582 "this save)</summary>",
583 "",
584 ]
585 for k in rows:
586 caps = f" {cap[k][:1].upper() + cap[k][1:]}." if cap.get(k) else ""
587 L.append(f"- **{k}** ({ch['stats'][k]}) — {gov[k]}.{caps}")
588 L += ["", "</details>", ""]
589 if ch["game"] in DS2_GAMES:
590 d = ds2_derived_stats(ch["stats"])
591 agl = f"{d['agility']}" + (
592 f" _({d['iframes']} roll i-frames)_" if d["iframes"] else ""
593 )
594 L += [
595 "### Derived Stats _(computed from attributes — base values before "
596 "rings & equipment; the in-game screen adds ring/gear bonuses on top)_",
597 "",
598 f"- **Stamina:** {d['stamina']}",
599 f"- **Equip Load (max capacity):** {d['equip_load']:.1f}",
600 f"- **Attunement Slots:** {d['slots']}",
601 f"- **Agility (AGL):** {agl}",
602 f"- **Poise (base):** {d['poise']:.1f}",
603 f"- **ATK: Str:** {d['atk_str']}",
604 f"- **ATK: Dex:** {d['atk_dex']}",
605 f"- **Magic DEF:** {d['magic_def']}",
606 f"- **Fire DEF:** {d['fire_def']}",
607 f"- **Lightning DEF:** {d['lightning_def']}",
608 f"- **Dark DEF:** {d['dark_def']}",
609 "",
610 ]
611 if ch["game"] == "ds3":
612 d = ds3_derived_stats(ch["stats"], ch.get("ring_mods"))
613 w = d["ring_bonus"]
614
615 # "73.0 base, +5% Ring of Favor, +15% Havel's Ring" -- the sum is only as
616 # good as its parts, so the parts are printed beside it.
617 def credit(base, kind, unit):
618 if not w[kind]:
619 return ""
620 parts = ", ".join(f"+{v:g}{unit} {n}" for n, v in w[kind])
621 return f" _({base} base, {parts})_"
622
623 L += [
624 "### Derived Stats _(computed from attributes, plus the documented "
625 "bonus of any worn ring named beside the value — the game's other "
626 "gear is not read)_",
627 "",
628 f"- **Attunement Slots:** {d['slots']}"
629 + credit(d["slots_base"], "slots", ""),
630 f"- **Equip Load (max capacity):** {d['equip_load']:.1f}"
631 + credit(f"{d['equip_load_base']:.1f}", "load_pct", "%"),
632 f"- **Item Discovery:** {d['item_discovery']}"
633 + credit(d["item_discovery_base"], "discovery", ""),
634 ]
635 # HP and stamina are read fields, so a ring that boosts them is already in
636 # the numbers above -- say so rather than adding it a second time.
637 already = [
638 f"+{v:g}% {'Max HP' if k == 'hp_pct' else 'Stamina'} ({n})"
639 for k in ("hp_pct", "stam_pct")
640 for n, v in w[k]
641 ]
642 if already:
643 L.append(
644 f"- **Also from rings:** {', '.join(already)} _(Max HP and "
645 f"Stamina are read from the save, so they already include "
646 f"these)_"
647 )
648 L.append("")
649 if ch["game"] in ("dsr", "ptde"):
650 d = ds1_derived_stats(ch["stats"])
651 L += [
652 "### Derived Stats _(computed from attributes — base values before "
653 "rings & equipment)_",
654 "",
655 f"- **Attunement Slots:** {d['slots']}",
656 f"- **Equip Load (max capacity):** {d['equip_load']:.1f}",
657 "",
658 ]
659 elif ch["tier"] == "roster":
660 L += [
661 "_Nightreign is read at roster tier: the save decrypts and every entry "
662 "verifies its own checksum, so the name above is certain, and nothing "
663 "else is claimed. The game keeps no persistent level, attributes or "
664 "souls to print — its progression is relics, unlocked Nightfarers and "
665 "Nightlord kills, and none of those has been pinned against a second "
666 "save yet._",
667 "",
668 ]
669 elif ch["tier"] == "inventory":
670 L += [
671 "_Attributes are not printed for this slot: its stat block did not "
672 "validate (an unrecognised patch or an edited save), and a wrong "
673 "number is worse than none. Inventory and progress below are read "
674 "directly._",
675 "",
676 ]
677
678 def bullets(items):
679 return [f"- {n}" + (f" ×{q}" if q and q > 1 else "") for n, q in items]
680
681 # Boss souls / remembrances that live in their own top section (every game but
682 # DS2, whose boss souls are a proper inventory category — see below).
683 # Boss souls get a top section only where the inventory does NOT already have a
684 # category holding them (DS2 and DS3 have `bosssouls`, Sekiro has `memories`) —
685 # printing both is the same list twice.
686 # Sekiro's held Memories are already in Key Items, where the game lists them.
687 if (
688 ch["boss_souls"]
689 and ch["game"] != "sdt"
690 and not (ch["inv"].get("bosssouls") or ch["inv"].get("memories"))
691 ):
692 header = (
693 "### Remembrances Held _(major bosses defeated, not yet traded)_"
694 if ch["game"] == "er"
695 else "### Boss Souls Held _(bosses defeated, soul not yet consumed)_"
696 )
697 L += [header, ""] + bullets(ch["boss_souls"]) + [""]
698 if ch["key_items"]:
699 L += ["### Key Items _(progress / areas & shortcuts unlocked)_", ""]
700 L += bullets(ch["key_items"]) + [""]
701 # DS2 keeps a flat name list for the boss-gate logic, but renders the grouped
702 # view when the area table resolved it; the flat list is the fallback.
703 if ch.get("bonfires") and not ch.get("bonfire_areas"):
704 L += [
705 f"### Bonfires Discovered ({len(ch['bonfires'])}) _(areas reached — a "
706 "floor on progress)_",
707 "",
708 ]
709 L += [f"- {b}" for b in ch["bonfires"]] + [""]
710 if ch.get("bonfire_areas"):
711 lit = sum(c for _a, c, _n, _t, _m in ch["bonfire_areas"])
712 total = sum(t for _a, _c, _n, t, _m in ch["bonfire_areas"])
713 n = sum(1 for _a, c, _n, _t, _m in ch["bonfire_areas"] if c)
714 areas = len(ch["bonfire_areas"])
715 # DS1 reads the real bonfire list (so it can say kindle level, and can list a
716 # discovered-but-unlit one); DS3 only has flag bits per area. Different note.
717 note = (
718 DS1_BONFIRE_NOTE
719 if ch.get("game") in ("dsr", "ptde")
720 else DS2_BONFIRE_NOTE
721 if ch.get("game") in DS2_GAMES
722 else SDT_BONFIRE_NOTE
723 if ch.get("game") == "sdt"
724 else DS3_BONFIRE_NOTE
725 )
726 # Sekiro has no bonfires; calling its idols one would be the same kind of wrong
727 # as printing a soul level for a game that has none.
728 head = (
729 "Sculptor's Idols Lit" if ch.get("game") == "sdt" else "Bonfires Discovered"
730 )
731 L += [f"### {head} ({lit} of {total}, in {n} of {areas} areas) _({note})_", ""]
732 for name, c, named, tot, missing in ch["bonfire_areas"]:
733 row = f"- {name}: {c}/{tot}"
734 if named:
735 row += f" — {', '.join(named)}"
736 # Only a STARTED area lists what is left; an untouched area would just
737 # print the whole game back at you.
738 if missing:
739 row += f" _(missing: {' · '.join(missing)})_"
740 L.append(row)
741 L.append("")
742 if ch.get("covenants"):
743 found, total = len(ch["covenants"]), ch.get("covenant_total")
744 count = f"{found} of {total}" if total else f"{found}"
745 L += [
746 f"### Covenants Found ({count}) _(discovered — a floor; "
747 "the one currently worn is the Covenant field above)_",
748 "",
749 ]
750 L += [f"- **{cov}:** {', '.join(w)}" for cov, w in ch["covenants"].items()] + [
751 ""
752 ]
753 note = missing_note("Not found yet", ch.get("covenants_missing"))
754 if note:
755 L += [note, ""]
756 if ch.get("world_flags"):
757 got = sum(c for _a, c, _n, _t, _m in ch["world_flags"])
758 total = sum(t for _a, _c, _n, t, _m in ch["world_flags"])
759 L += [f"### World State ({got} of {total} tracked) _({DS1_WORLD_NOTE})_", ""]
760 for cat, c, names, tot, missing in ch["world_flags"]:
761 row = f"- {cat}: {c}/{tot}"
762 # Mid-dot, not comma: half these names contain a comma of their own
763 # ("Sen's Fortress, Fog Gate 1"), so a comma-joined list is unreadable.
764 if names:
765 row += f" — {' · '.join(names)}"
766 if missing:
767 row += f" _(not yet: {' · '.join(missing)})_"
768 L.append(row)
769 L.append("")
770 if ch.get("minibosses"):
771 dead = sum(c for _a, c, _n, _t, _m in ch["minibosses"])
772 total = sum(t for _a, _c, _n, t, _m in ch["minibosses"])
773 L += [
774 f"### Minibosses Defeated ({dead} of {total} tracked) _({SDT_MINIBOSS_NOTE})_",
775 "",
776 ]
777 for area, c, names, tot, alive in ch["minibosses"]:
778 row = f"- {area}: {c}/{tot}"
779 # Who is dead leads, the same way the boss section names its kills — the
780 # count alone cannot tell you whether the Blazing Bull is behind you.
781 if names:
782 row += f" — {' · '.join(count_dupes(names))}"
783 # Same rule as every other progress section: an area you have started
784 # says what is left in it, an untouched one would print a walkthrough
785 # back at you.
786 if alive:
787 row += f" _(still alive: {' · '.join(count_dupes(alive))})_"
788 L.append(row)
789 L.append("")
790 if ch.get("npc_states"):
791 got = sum(c for _f, c, _g, _t in ch["npc_states"])
792 total = sum(t for _f, _c, _g, t in ch["npc_states"])
793 L += [
794 f"### NPC States ({got} of {total} tracked) _({DS3_NPC_NOTE})_",
795 "",
796 ]
797 for family, c, names, tot in ch["npc_states"]:
798 row = f"- {family}: {c}/{tot}"
799 # Only what FIRED is listed, and of that only what has a real description.
800 # Two reasons: half of these are mutually exclusive outcomes of one questline,
801 # so a "missing" list would read as a to-do list of things a player cannot all
802 # do; and a bare entity id teaches a reader nothing, so those are counted
803 # instead of printed.
804 said = [n for n in names if not n.startswith("character ")]
805 rest = len(names) - len(said)
806 if said:
807 row += f" — {' · '.join(count_dupes(said))}"
808 if rest:
809 row += f" _(+{rest} more, known only by entity id)_"
810 L.append(row)
811 L.append("")
812 if ch.get("world_events"):
813 got = sum(c for _f, c, _t in ch["world_events"])
814 total = sum(t for _f, _c, t in ch["world_events"])
815 L += [
816 f"### World Events ({got} of {total} tracked) _({DS1_EVENT_NOTE})_",
817 "",
818 ]
819 for family, c, tot in ch["world_events"]:
820 L.append(f"- {family}: {c}/{tot}")
821 L.append("")
822 if ch.get("enemies"):
823 dead = sum(c for _a, c, _n, _t, _m in ch["enemies"])
824 total = sum(t for _a, _c, _n, t, _m in ch["enemies"])
825 L += [
826 f"### One-Time Enemies Defeated ({dead} of {total} tracked)"
827 f" _({DS3_ENEMY_NOTE})_",
828 "",
829 ]
830 for area, c, names, tot, alive in ch["enemies"]:
831 row = f"- {area}: {c}/{tot}"
832 if names:
833 row += f" — {' · '.join(count_dupes(names))}"
834 if alive:
835 row += f" _(still alive: {' · '.join(count_dupes(alive))})_"
836 L.append(row)
837 L.append("")
838 if ch.get("pickups"):
839 got = sum(c for _a, c, _t, _m in ch["pickups"])
840 total = sum(t for _a, _c, t, _m in ch["pickups"])
841 note = SDT_PICKUP_NOTE if ch["game"] == "sdt" else DS3_PICKUP_NOTE
842 L += [
843 f"### Items Collected ({got} of {total} tracked) _({note})_",
844 "",
845 ]
846 for area, c, tot, missing in ch["pickups"]:
847 row = f"- {area}: {c}/{tot}"
848 # Same rule as bonfires — an area you have started counts what is left in
849 # it; an untouched one would print a walkthrough back at you.
850 if c and missing:
851 row += f" _({len(missing)} still out there)_"
852 L.append(row)
853 L.append("")
854 # The list itself is folded away. It carries a location per item, which makes
855 # it a to-do list rather than a tally — and also long enough to bury the
856 # numbers above it if it were printed inline.
857 todo = [
858 (area, missing) for area, c, _t, missing in ch["pickups"] if c and missing
859 ]
860 if todo:
861 L += [
862 "<details>",
863 "<summary>Where the missing items are — the ones with a known "
864 "location</summary>",
865 "",
866 ]
867 for area, missing in todo:
868 L.append(f"**{area}** — {len(missing)} missing")
869 L.append("")
870 L += [f"- {item}" for item in count_dupes(missing)]
871 L.append("")
872 L += ["</details>", ""]
873 if ch.get("questlines"):
874 # Not all of these are NPCs — the same reward flags cover a few landmark
875 # pickups and enemy drops, so the heading says rewards, not questlines.
876 L += [
877 "### Rewards Obtained _(one-off rewards from NPCs, invaders and "
878 "landmark pickups — a progress floor)_",
879 "",
880 ]
881 L += [
882 f"- **{src}:** {', '.join(rw)}" for src, rw in ch["questlines"].items()
883 ] + [""]
884 if ch.get("bosses"):
885 SRC = {
886 "flag": "confirmed",
887 "soul": "soul held",
888 "gate": "progression",
889 "clear": "cleared (NG+)",
890 }
891 found, total = len(ch["bosses"]), ch.get("boss_total")
892 count = f"{found} of {total} tracked" if total else f"{found}"
893 L += [
894 f"### Bosses Defeated ({count}) _(a floor — from defeat "
895 "flags, held boss souls, progression, and NG+ clears; a boss whose soul "
896 "was consumed and isn't gated may still be missing)_",
897 "",
898 ]
899 for boss, srcs in ch["bosses"].items():
900 L.append(f"- {boss} _({', '.join(SRC[s] for s in srcs)})_")
901 L.append("")
902 # The missing list splits in two where a route graph exists: what is open to
903 # you now, and what is still behind something. The split is game structure,
904 # not a save read — the note says so.
905 avail = ch.get("bosses_available") or []
906 rest = [b for b in (ch.get("bosses_missing") or []) if b not in avail]
907 if avail:
908 L += [
909 f"_Available now — every prerequisite dead and the area already "
910 f"reached (from the game's fixed route, not this save): "
911 f"{' · '.join(avail)}._",
912 "",
913 ]
914 note = missing_note(
915 "No evidence yet" + (", and behind something else" if avail else ""), rest
916 )
917 if note:
918 L += [note, ""]
919
920 if (
921 ch.get("equipped_weapons")
922 or ch.get("equipped_armor")
923 or ch.get("equipped_rings")
924 or ch.get("equipped_ammo")
925 ):
926 L += ["### Equipped _(worn gear read from the equip slots)_", ""]
927 L += [
928 f"- **{slot}:** {name}"
929 for slot, name in ch.get("equipped_weapons", {}).items()
930 ]
931 L += [
932 f"- **{slot}:** {name}"
933 for slot, name in ch.get("equipped_armor", {}).items()
934 ]
935 if ch.get("equipped_rings"):
936 # With the effect table loaded each ring gets its own line and what it
937 # does; without it (or for a ring the table doesn't cover) the old
938 # one-line list is still the fallback, so nothing is lost.
939 eff = dict(ch.get("ring_effects") or [])
940 if eff:
941 L.append("- **Rings:**")
942 L += [
943 f" - {n}" + (f" — {eff[n]}" if n in eff else "")
944 for n in ch["equipped_rings"]
945 ]
946 else:
947 L.append(f"- **Rings:** {', '.join(ch['equipped_rings'])}")
948 if ch.get("equipped_ammo"):
949 L.append(f"- **Ammo:** {', '.join(ch['equipped_ammo'])}")
950 L.append("")
951
952 # A game with nothing to list gets no heading. Nightreign at roster tier has no
953 # inventory read at all, and an empty "Inventory" reads as "you have nothing".
954 if any(ch["inv"].get(c) for c in CAT_ORDER):
955 L += ["### Inventory", ""]
956 # DS1 and ER keep boss souls and key items inside the flat `goods` bucket, which
957 # already has its own section above — list each item once and point at it.
958 listed = {n for n, _q in ch["boss_souls"]} | {n for n, _q in ch["key_items"]}
959 for cat in CAT_ORDER:
960 items = ch["inv"].get(cat)
961 if not items:
962 continue
963 if cat == "goods" and listed:
964 items = [it for it in items if it[0] not in listed]
965 if not items:
966 continue
967 # Boss souls split into the game's own two grades: the four "Old" great
968 # souls, then the ordinary boss souls. Everything else is one heading.
969 if cat == "bosssouls":
970 great = [it for it in items if it[0] in DS2_GREAT_SOULS]
971 normal = [it for it in items if it[0] not in DS2_GREAT_SOULS]
972 for title, group in (("Great Boss Souls", great), ("Boss Souls", normal)):
973 if group:
974 L += [f"#### {title}", ""] + bullets(group) + [""]
975 else:
976 title = cat_title(ch["game"], cat)
977 if cat == "goods" and listed:
978 title += " _(boss souls and key items are listed above)_"
979 L += [f"#### {title}", ""] + bullets(items) + [""]
980 if ch["unknown_count"]:
981 L += [
982 f"_{ch['unknown_count']} inventory item(s) had IDs not in the name "
983 "database (upgraded / infused variants) and were omitted._",
984 "",
985 ]
986 if ch.get("internal_count"):
987 L += [
988 f"_{ch['internal_count']} further entr"
989 f"{'y' if ch['internal_count'] == 1 else 'ies'} carried only an engine "
990 "development name — placeholder rows and debug items rather than "
991 "anything the game hands you — and were left out._",
992 "",
993 ]
994 if ch.get("suppressed_count"):
995 L += [
996 f"_{ch['suppressed_count']} further entr"
997 f"{'y' if ch['suppressed_count'] == 1 else 'ies'} were engine state "
998 "rather than inventory — Sekiro has no armour system, so the character's "
999 "own body models sit in the protector table, the `Virtual Weapon:` "
1000 "rows restate a Combat Art already listed under its own name, and the "
1001 "two plain `Memory` rows are the game's counters of Memories spent and "
1002 "held. Counted here, not printed._",
1003 "",
1004 ]
1005 return "\n".join(L)
stat_caps_for(game)
Soft-cap reference for a per-slot game id (DSR and PtDE share DS1).
Definition render.py:172
stat_governs_for(game)
Map a per-slot game id to its STAT_GOVERNS family (DSR and PtDE share DS1).
Definition render.py:88