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 583 and not 826.
406SDT_PICKUP_NOTE = (
407 "one-off world items picked up, from each area's item-lot flags — covers the "
408 "nine areas whose flag bank is mapped, 583 of the 826 lots the table knows"
409)
410
411
412# Enemies that do not respawn, so the game has to remember each one dead. These are the
413# enemy's own DEATH flag, not the pickup flag its loot sets, which matters: on a finished
414# run one of the fourteen Symbol of Avarice pickups is set and all fourteen mimics are
415# accounted for here. Names are types, so the count beside a repeated name is real.
416# Every row here is DERIVED from the committed event scripts and none is verified against
417# a save, which is why it prints counts and not names: the names are FromSoft's own
418# Japanese and translating them would be inventing a label. Two families are known not to
419# mean what they say — see tools/gen_ds1_world_events.py — and the note says so out loud
420# rather than quietly printing a number a reader would take for a boss count.
421DS1_EVENT_NOTE = (
422 "world events from the game's own event scripts, counted by kind — sourced, "
423 "not verified here; some are transient (set while the event runs), so a "
424 "finished run can honestly read 0 in a family"
425)
426
427
428# The labels come from FromSoft's own descriptions where there is one, and the ladder has
429# already caught one sitting on the wrong flag, so the entity id rides along for checking.
430DS3_NPC_NOTE = (
431 "NPC deaths, hostility and questline milestones, each its own flag — the "
432 "descriptions are the source's and one is known to be on the wrong flag, so "
433 "an entity id is printed beside them where there is one"
434)
435
436
437DS3_ENEMY_NOTE = (
438 "each enemy's own defeat flag — exact, not inferred, and it carries "
439 "across a new journey; names are enemy types, so repeats in an area "
440 "are different enemies"
441)
442
443
444##
445# @brief The Lords-of-Cinder line: how many of the four are on the throne, which ones
446# the mapped flags name, and the two counts the number came from.
447# @param lords The @c ch["lords"] dict from @ref attach_progress_totals.
448def lords_line(lords):
449 # Mid-dot separated: "Aldrich, Devourer of Gods" has a comma of its own.
450 named = f" — {' · '.join(lords['named'])}" if lords["named"] else ""
451 if lords["placed"] is None: # NG+: the thrones reset, the defeat flags do not
452 return (
453 f"{len(lords['named'])} of {lords['total']}{named}"
454 " _(NG+ — only the mapped throne flags are read, so this is a floor)_"
455 )
456 return (
457 f"{lords['placed']} of {lords['total']}{named}"
458 f" _({lords['dead']} of the four lords defeated, {lords['held']} set"
459 f"{'' if lords['held'] == 1 else 's'} of cinders still held)_"
460 )
461
462
463##
464# @brief Render "N of M" plus the names still missing, for a progress section.
465# @param missing The names with no evidence. Long lists stay one italic line so the
466# section keeps its shape.
467def missing_note(label, missing):
468 return f"_{label}: {' · '.join(missing)}._" if missing else None
469
470
471##
472# @brief Collapse repeats in a name list to @c "name ×N", keeping first-seen order.
473# @details An area holds seven separate mimics and a dozen Titanite Shards, each with
474# its own flag. Printing the name seven times is faithful but unreadable; the count
475# says the same thing. @param names The list. @return The collapsed list.
476def count_dupes(names):
477 seen = OrderedDict()
478 for n in names:
479 seen[n] = seen.get(n, 0) + 1
480 return [n if c == 1 else f"{n} ×{c}" for n, c in seen.items()]
481
482
483def md_for_character(ch, slot_no):
484 L = [f"## Slot {slot_no}: {ch['name']}", ""]
485 if ch["level"] is not None:
486 L.append(
487 f"- **{'Level' if ch['game'] == 'er' else 'Soul Level'}:** {ch['level']}"
488 )
489 if ch["klass"]:
490 L.append(f"- **Class:** {ch['klass']}")
491 if ch.get("covenant"):
492 L.append(f"- **Covenant:** {ch['covenant']}")
493 if ch.get("gender"):
494 L.append(f"- **Sex:** {ch['gender']}")
495 if ch["ng_plus"] is not None:
496 ng = "New Game" if ch["ng_plus"] == 0 else f"New Game +{ch['ng_plus']}"
497 L.append(f"- **Playthrough:** {ng}")
498 if ch["soul_memory"] is not None:
499 L.append(
500 f"- **Soul Memory:** {fmt(ch['soul_memory'])} _(total souls earned — main progress metric)_"
501 )
502 if ch.get("play_time"):
503 L.append(f"- **Play Time:** {fmt_playtime(ch['play_time'])}")
504 if ch["souls"] is not None:
505 L.append(f"- **{CURRENCY.get(ch['game'], 'Souls')} held:** {fmt(ch['souls'])}")
506 if ch.get("attack") is not None:
507 L.append(f"- **Attack Power:** {ch['attack']}")
508 if ch.get("vitality") is not None:
509 L.append(
510 f"- **Vitality:** {ch['vitality']} _({vitality_necklaces(ch['vitality'])})_"
511 )
512 if ch.get("gourd") is not None:
513 L.append(
514 f"- **Healing Gourd:** {ch['gourd']} charge"
515 f"{'' if ch['gourd'] == 1 else 's'} _({gourd_seeds(ch['gourd'])})_"
516 )
517 if ch.get("skill_points") is not None:
518 L.append(
519 f"- **Skill Points Held:** {ch['skill_points']}"
520 " _(spendable at a Sculptor's Idol — the points already spent are "
521 "not stored, so this is what is banked, not what has been earned)_"
522 )
523 if ch.get("memories"):
524 L.append(f"- **Memories:** {memories_line(ch['memories'])}")
525 if ch["humanity"] is not None:
526 L.append(f"- **Humanity:** {ch['humanity']}")
527 if ch["hp"] is not None:
528 L.append(f"- **Max HP:** {fmt(ch['hp'])}")
529 if ch.get("posture") is not None:
530 L.append(f"- **Max Posture:** {fmt(ch['posture'])}")
531 if ch.get("embered") is not None:
532 L.append(
533 "- **Embered:** Yes _(Max HP above includes the +30% ember bonus)_"
534 if ch["embered"]
535 else "- **Embered:** No _(hollow — Max HP above is the base value)_"
536 )
537 if ch.get("fp") is not None:
538 L.append(f"- **Max FP:** {fmt(ch['fp'])}")
539 if ch.get("hollow_lvl"):
540 L.append(
541 f"- **Hollowing:** {ch['hollow_lvl']} _(higher = more deaths without an effigy)_"
542 )
543 if ch.get("deaths") is not None:
544 L.append(f"- **Deaths:** {fmt(ch['deaths'])}")
545 if ch["stamina"] is not None:
546 L.append(f"- **Stamina:** {fmt(ch['stamina'])}")
547 if ch.get("lords"):
548 L.append(f"- **Cinders of a Lord Placed:** {lords_line(ch['lords'])}")
549 if ch.get("endings"):
550 ends = ch["endings"]
551 L.append(
552 f"- **Ending{'' if len(ends) == 1 else 's'} Reached:** " + " · ".join(ends)
553 )
554 build = guess_build(ch["stats"])
555 if build:
556 L.append(f"- **Build:** {build}")
557 L.append("")
558
559 if ch["stats"]:
560 keys = list(ch["stats"].keys())
561 L += [
562 "### Attributes",
563 "",
564 "| " + " | ".join(STAT_ABBR.get(k, k[:3].upper()) for k in keys) + " |",
565 "|" + "----|" * len(keys),
566 "| " + " | ".join(str(ch["stats"][k]) for k in keys) + " |",
567 "",
568 ]
569 gov = stat_governs_for(ch["game"])
570 cap = stat_caps_for(ch["game"])
571 rows = [k for k in keys if k in gov]
572 if rows:
573 # Fixed game-mechanics reference, identical in every export bar the
574 # current values — folded away so it does not sit between the save's own
575 # numbers and its progress (and so two exports diff cleanly).
576 L += [
577 "<details>",
578 "<summary><b>Attribute Scaling</b> — what each stat "
579 "scales and its soft caps (game-mechanics reference, not read from "
580 "this save)</summary>",
581 "",
582 ]
583 for k in rows:
584 caps = f" {cap[k][:1].upper() + cap[k][1:]}." if cap.get(k) else ""
585 L.append(f"- **{k}** ({ch['stats'][k]}) — {gov[k]}.{caps}")
586 L += ["", "</details>", ""]
587 if ch["game"] in DS2_GAMES:
588 d = ds2_derived_stats(ch["stats"])
589 agl = f"{d['agility']}" + (
590 f" _({d['iframes']} roll i-frames)_" if d["iframes"] else ""
591 )
592 L += [
593 "### Derived Stats _(computed from attributes — base values before "
594 "rings & equipment; the in-game screen adds ring/gear bonuses on top)_",
595 "",
596 f"- **Stamina:** {d['stamina']}",
597 f"- **Equip Load (max capacity):** {d['equip_load']:.1f}",
598 f"- **Attunement Slots:** {d['slots']}",
599 f"- **Agility (AGL):** {agl}",
600 f"- **Poise (base):** {d['poise']:.1f}",
601 f"- **ATK: Str:** {d['atk_str']}",
602 f"- **ATK: Dex:** {d['atk_dex']}",
603 f"- **Magic DEF:** {d['magic_def']}",
604 f"- **Fire DEF:** {d['fire_def']}",
605 f"- **Lightning DEF:** {d['lightning_def']}",
606 f"- **Dark DEF:** {d['dark_def']}",
607 "",
608 ]
609 if ch["game"] == "ds3":
610 d = ds3_derived_stats(ch["stats"], ch.get("ring_mods"))
611 w = d["ring_bonus"]
612
613 # "73.0 base, +5% Ring of Favor, +15% Havel's Ring" -- the sum is only as
614 # good as its parts, so the parts are printed beside it.
615 def credit(base, kind, unit):
616 if not w[kind]:
617 return ""
618 parts = ", ".join(f"+{v:g}{unit} {n}" for n, v in w[kind])
619 return f" _({base} base, {parts})_"
620
621 L += [
622 "### Derived Stats _(computed from attributes, plus the documented "
623 "bonus of any worn ring named beside the value — the game's other "
624 "gear is not read)_",
625 "",
626 f"- **Attunement Slots:** {d['slots']}"
627 + credit(d["slots_base"], "slots", ""),
628 f"- **Equip Load (max capacity):** {d['equip_load']:.1f}"
629 + credit(f"{d['equip_load_base']:.1f}", "load_pct", "%"),
630 f"- **Item Discovery:** {d['item_discovery']}"
631 + credit(d["item_discovery_base"], "discovery", ""),
632 ]
633 # HP and stamina are read fields, so a ring that boosts them is already in
634 # the numbers above -- say so rather than adding it a second time.
635 already = [
636 f"+{v:g}% {'Max HP' if k == 'hp_pct' else 'Stamina'} ({n})"
637 for k in ("hp_pct", "stam_pct")
638 for n, v in w[k]
639 ]
640 if already:
641 L.append(
642 f"- **Also from rings:** {', '.join(already)} _(Max HP and "
643 f"Stamina are read from the save, so they already include "
644 f"these)_"
645 )
646 L.append("")
647 if ch["game"] in ("dsr", "ptde"):
648 d = ds1_derived_stats(ch["stats"])
649 L += [
650 "### Derived Stats _(computed from attributes — base values before "
651 "rings & equipment)_",
652 "",
653 f"- **Attunement Slots:** {d['slots']}",
654 f"- **Equip Load (max capacity):** {d['equip_load']:.1f}",
655 "",
656 ]
657 elif ch["tier"] == "roster":
658 L += [
659 "_Nightreign is read at roster tier: the save decrypts and every entry "
660 "verifies its own checksum, so the name above is certain, and nothing "
661 "else is claimed. The game keeps no persistent level, attributes or "
662 "souls to print — its progression is relics, unlocked Nightfarers and "
663 "Nightlord kills, and none of those has been pinned against a second "
664 "save yet._",
665 "",
666 ]
667 elif ch["tier"] == "inventory":
668 L += [
669 "_Attributes are not printed for this slot: its stat block did not "
670 "validate (an unrecognised patch or an edited save), and a wrong "
671 "number is worse than none. Inventory and progress below are read "
672 "directly._",
673 "",
674 ]
675
676 def bullets(items):
677 return [f"- {n}" + (f" ×{q}" if q and q > 1 else "") for n, q in items]
678
679 # Boss souls / remembrances that live in their own top section (every game but
680 # DS2, whose boss souls are a proper inventory category — see below).
681 # Boss souls get a top section only where the inventory does NOT already have a
682 # category holding them (DS2 and DS3 have `bosssouls`, Sekiro has `memories`) —
683 # printing both is the same list twice.
684 if ch["boss_souls"] and not (
685 ch["inv"].get("bosssouls") or ch["inv"].get("memories")
686 ):
687 header = (
688 "### Remembrances Held _(major bosses defeated, not yet traded)_"
689 if ch["game"] == "er"
690 else "### Boss Souls Held _(bosses defeated, soul not yet consumed)_"
691 )
692 L += [header, ""] + bullets(ch["boss_souls"]) + [""]
693 if ch["key_items"]:
694 L += ["### Key Items _(progress / areas & shortcuts unlocked)_", ""]
695 L += bullets(ch["key_items"]) + [""]
696 # DS2 keeps a flat name list for the boss-gate logic, but renders the grouped
697 # view when the area table resolved it; the flat list is the fallback.
698 if ch.get("bonfires") and not ch.get("bonfire_areas"):
699 L += [
700 f"### Bonfires Discovered ({len(ch['bonfires'])}) _(areas reached — a "
701 "floor on progress)_",
702 "",
703 ]
704 L += [f"- {b}" for b in ch["bonfires"]] + [""]
705 if ch.get("bonfire_areas"):
706 lit = sum(c for _a, c, _n, _t, _m in ch["bonfire_areas"])
707 total = sum(t for _a, _c, _n, t, _m in ch["bonfire_areas"])
708 n = sum(1 for _a, c, _n, _t, _m in ch["bonfire_areas"] if c)
709 areas = len(ch["bonfire_areas"])
710 # DS1 reads the real bonfire list (so it can say kindle level, and can list a
711 # discovered-but-unlit one); DS3 only has flag bits per area. Different note.
712 note = (
713 DS1_BONFIRE_NOTE
714 if ch.get("game") in ("dsr", "ptde")
715 else DS2_BONFIRE_NOTE
716 if ch.get("game") in DS2_GAMES
717 else SDT_BONFIRE_NOTE
718 if ch.get("game") == "sdt"
719 else DS3_BONFIRE_NOTE
720 )
721 # Sekiro has no bonfires; calling its idols one would be the same kind of wrong
722 # as printing a soul level for a game that has none.
723 head = (
724 "Sculptor's Idols Lit" if ch.get("game") == "sdt" else "Bonfires Discovered"
725 )
726 L += [f"### {head} ({lit} of {total}, in {n} of {areas} areas) _({note})_", ""]
727 for name, c, named, tot, missing in ch["bonfire_areas"]:
728 row = f"- {name}: {c}/{tot}"
729 if named:
730 row += f" — {', '.join(named)}"
731 # Only a STARTED area lists what is left; an untouched area would just
732 # print the whole game back at you.
733 if missing:
734 row += f" _(missing: {' · '.join(missing)})_"
735 L.append(row)
736 L.append("")
737 if ch.get("covenants"):
738 found, total = len(ch["covenants"]), ch.get("covenant_total")
739 count = f"{found} of {total}" if total else f"{found}"
740 L += [
741 f"### Covenants Found ({count}) _(discovered — a floor; "
742 "the one currently worn is the Covenant field above)_",
743 "",
744 ]
745 L += [f"- **{cov}:** {', '.join(w)}" for cov, w in ch["covenants"].items()] + [
746 ""
747 ]
748 note = missing_note("Not found yet", ch.get("covenants_missing"))
749 if note:
750 L += [note, ""]
751 if ch.get("world_flags"):
752 got = sum(c for _a, c, _n, _t, _m in ch["world_flags"])
753 total = sum(t for _a, _c, _n, t, _m in ch["world_flags"])
754 L += [f"### World State ({got} of {total} tracked) _({DS1_WORLD_NOTE})_", ""]
755 for cat, c, names, tot, missing in ch["world_flags"]:
756 row = f"- {cat}: {c}/{tot}"
757 # Mid-dot, not comma: half these names contain a comma of their own
758 # ("Sen's Fortress, Fog Gate 1"), so a comma-joined list is unreadable.
759 if names:
760 row += f" — {' · '.join(names)}"
761 if missing:
762 row += f" _(not yet: {' · '.join(missing)})_"
763 L.append(row)
764 L.append("")
765 if ch.get("minibosses"):
766 dead = sum(c for _a, c, _n, _t, _m in ch["minibosses"])
767 total = sum(t for _a, _c, _n, t, _m in ch["minibosses"])
768 L += [
769 f"### Minibosses Defeated ({dead} of {total} tracked) _({SDT_MINIBOSS_NOTE})_",
770 "",
771 ]
772 for area, c, names, tot, alive in ch["minibosses"]:
773 row = f"- {area}: {c}/{tot}"
774 # Who is dead leads, the same way the boss section names its kills — the
775 # count alone cannot tell you whether the Blazing Bull is behind you.
776 if names:
777 row += f" — {' · '.join(count_dupes(names))}"
778 # Same rule as every other progress section: an area you have started
779 # says what is left in it, an untouched one would print a walkthrough
780 # back at you.
781 if alive:
782 row += f" _(still alive: {' · '.join(count_dupes(alive))})_"
783 L.append(row)
784 L.append("")
785 if ch.get("npc_states"):
786 got = sum(c for _f, c, _g, _t in ch["npc_states"])
787 total = sum(t for _f, _c, _g, t in ch["npc_states"])
788 L += [
789 f"### NPC States ({got} of {total} tracked) _({DS3_NPC_NOTE})_",
790 "",
791 ]
792 for family, c, names, tot in ch["npc_states"]:
793 row = f"- {family}: {c}/{tot}"
794 # Only what FIRED is listed, and of that only what has a real description.
795 # Two reasons: half of these are mutually exclusive outcomes of one questline,
796 # so a "missing" list would read as a to-do list of things a player cannot all
797 # do; and a bare entity id teaches a reader nothing, so those are counted
798 # instead of printed.
799 said = [n for n in names if not n.startswith("character ")]
800 rest = len(names) - len(said)
801 if said:
802 row += f" — {' · '.join(count_dupes(said))}"
803 if rest:
804 row += f" _(+{rest} more, known only by entity id)_"
805 L.append(row)
806 L.append("")
807 if ch.get("world_events"):
808 got = sum(c for _f, c, _t in ch["world_events"])
809 total = sum(t for _f, _c, t in ch["world_events"])
810 L += [
811 f"### World Events ({got} of {total} tracked) _({DS1_EVENT_NOTE})_",
812 "",
813 ]
814 for family, c, tot in ch["world_events"]:
815 L.append(f"- {family}: {c}/{tot}")
816 L.append("")
817 if ch.get("enemies"):
818 dead = sum(c for _a, c, _n, _t, _m in ch["enemies"])
819 total = sum(t for _a, _c, _n, t, _m in ch["enemies"])
820 L += [
821 f"### One-Time Enemies Defeated ({dead} of {total} tracked)"
822 f" _({DS3_ENEMY_NOTE})_",
823 "",
824 ]
825 for area, c, names, tot, alive in ch["enemies"]:
826 row = f"- {area}: {c}/{tot}"
827 if names:
828 row += f" — {' · '.join(count_dupes(names))}"
829 if alive:
830 row += f" _(still alive: {' · '.join(count_dupes(alive))})_"
831 L.append(row)
832 L.append("")
833 if ch.get("pickups"):
834 got = sum(c for _a, c, _t, _m in ch["pickups"])
835 total = sum(t for _a, _c, t, _m in ch["pickups"])
836 note = SDT_PICKUP_NOTE if ch["game"] == "sdt" else DS3_PICKUP_NOTE
837 L += [
838 f"### Items Collected ({got} of {total} tracked) _({note})_",
839 "",
840 ]
841 for area, c, tot, missing in ch["pickups"]:
842 row = f"- {area}: {c}/{tot}"
843 # Same rule as bonfires — an area you have started counts what is left in
844 # it; an untouched one would print a walkthrough back at you.
845 if c and missing:
846 row += f" _({len(missing)} still out there)_"
847 L.append(row)
848 L.append("")
849 # The list itself is folded away. It carries a location per item, which makes
850 # it a to-do list rather than a tally — and also long enough to bury the
851 # numbers above it if it were printed inline.
852 todo = [
853 (area, missing) for area, c, _t, missing in ch["pickups"] if c and missing
854 ]
855 if todo:
856 L += [
857 "<details>",
858 "<summary>Where the missing items are — the ones with a known "
859 "location</summary>",
860 "",
861 ]
862 for area, missing in todo:
863 L.append(f"**{area}** — {len(missing)} missing")
864 L.append("")
865 L += [f"- {item}" for item in count_dupes(missing)]
866 L.append("")
867 L += ["</details>", ""]
868 if ch.get("questlines"):
869 # Not all of these are NPCs — the same reward flags cover a few landmark
870 # pickups and enemy drops, so the heading says rewards, not questlines.
871 L += [
872 "### Rewards Obtained _(one-off rewards from NPCs, invaders and "
873 "landmark pickups — a progress floor)_",
874 "",
875 ]
876 L += [
877 f"- **{src}:** {', '.join(rw)}" for src, rw in ch["questlines"].items()
878 ] + [""]
879 if ch.get("bosses"):
880 SRC = {
881 "flag": "confirmed",
882 "soul": "soul held",
883 "gate": "progression",
884 "clear": "cleared (NG+)",
885 }
886 found, total = len(ch["bosses"]), ch.get("boss_total")
887 count = f"{found} of {total} tracked" if total else f"{found}"
888 L += [
889 f"### Bosses Defeated ({count}) _(a floor — from defeat "
890 "flags, held boss souls, progression, and NG+ clears; a boss whose soul "
891 "was consumed and isn't gated may still be missing)_",
892 "",
893 ]
894 for boss, srcs in ch["bosses"].items():
895 L.append(f"- {boss} _({', '.join(SRC[s] for s in srcs)})_")
896 L.append("")
897 # The missing list splits in two where a route graph exists: what is open to
898 # you now, and what is still behind something. The split is game structure,
899 # not a save read — the note says so.
900 avail = ch.get("bosses_available") or []
901 rest = [b for b in (ch.get("bosses_missing") or []) if b not in avail]
902 if avail:
903 L += [
904 f"_Available now — every prerequisite dead and the area already "
905 f"reached (from the game's fixed route, not this save): "
906 f"{' · '.join(avail)}._",
907 "",
908 ]
909 note = missing_note(
910 "No evidence yet" + (", and behind something else" if avail else ""), rest
911 )
912 if note:
913 L += [note, ""]
914
915 if (
916 ch.get("equipped_weapons")
917 or ch.get("equipped_armor")
918 or ch.get("equipped_rings")
919 or ch.get("equipped_ammo")
920 ):
921 L += ["### Equipped _(worn gear read from the equip slots)_", ""]
922 L += [
923 f"- **{slot}:** {name}"
924 for slot, name in ch.get("equipped_weapons", {}).items()
925 ]
926 L += [
927 f"- **{slot}:** {name}"
928 for slot, name in ch.get("equipped_armor", {}).items()
929 ]
930 if ch.get("equipped_rings"):
931 # With the effect table loaded each ring gets its own line and what it
932 # does; without it (or for a ring the table doesn't cover) the old
933 # one-line list is still the fallback, so nothing is lost.
934 eff = dict(ch.get("ring_effects") or [])
935 if eff:
936 L.append("- **Rings:**")
937 L += [
938 f" - {n}" + (f" — {eff[n]}" if n in eff else "")
939 for n in ch["equipped_rings"]
940 ]
941 else:
942 L.append(f"- **Rings:** {', '.join(ch['equipped_rings'])}")
943 if ch.get("equipped_ammo"):
944 L.append(f"- **Ammo:** {', '.join(ch['equipped_ammo'])}")
945 L.append("")
946
947 # A game with nothing to list gets no heading. Nightreign at roster tier has no
948 # inventory read at all, and an empty "Inventory" reads as "you have nothing".
949 if any(ch["inv"].get(c) for c in CAT_ORDER):
950 L += ["### Inventory", ""]
951 # DS1 and ER keep boss souls and key items inside the flat `goods` bucket, which
952 # already has its own section above — list each item once and point at it.
953 listed = {n for n, _q in ch["boss_souls"]} | {n for n, _q in ch["key_items"]}
954 for cat in CAT_ORDER:
955 items = ch["inv"].get(cat)
956 if not items:
957 continue
958 if cat == "goods" and listed:
959 items = [it for it in items if it[0] not in listed]
960 if not items:
961 continue
962 # Boss souls split into the game's own two grades: the four "Old" great
963 # souls, then the ordinary boss souls. Everything else is one heading.
964 if cat == "bosssouls":
965 great = [it for it in items if it[0] in DS2_GREAT_SOULS]
966 normal = [it for it in items if it[0] not in DS2_GREAT_SOULS]
967 for title, group in (("Great Boss Souls", great), ("Boss Souls", normal)):
968 if group:
969 L += [f"#### {title}", ""] + bullets(group) + [""]
970 else:
971 title = cat_title(ch["game"], cat)
972 if cat == "goods" and listed:
973 title += " _(boss souls and key items are listed above)_"
974 L += [f"#### {title}", ""] + bullets(items) + [""]
975 if ch["unknown_count"]:
976 L += [
977 f"_{ch['unknown_count']} inventory item(s) had IDs not in the name "
978 "database (upgraded / infused variants) and were omitted._",
979 "",
980 ]
981 if ch.get("internal_count"):
982 L += [
983 f"_{ch['internal_count']} further entr"
984 f"{'y' if ch['internal_count'] == 1 else 'ies'} carried only an engine "
985 "development name — placeholder rows and debug items rather than "
986 "anything the game hands you — and were left out._",
987 "",
988 ]
989 if ch.get("suppressed_count"):
990 L += [
991 f"_{ch['suppressed_count']} further entr"
992 f"{'y' if ch['suppressed_count'] == 1 else 'ies'} were engine state "
993 "rather than inventory — Sekiro has no armour system, so the character's "
994 "own body models sit in the protector table, and the `Virtual Weapon:` "
995 "rows restate a Combat Art already listed under its own name. Counted "
996 "here, not printed._",
997 "",
998 ]
999 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