SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
ds1.py
Go to the documentation of this file.
1"""Dark Souls 1 family: Remastered and Prepare to Die Edition."""
2
3import json
4import os
5from collections import OrderedDict, defaultdict
6
7from .itemdb import merge_qty
8from .progress import attach_defeated_bosses, find_boss_souls, find_key_goods
9from .reader import is_valid_name, read_utf16, u8, u16, u32
10
11
12##
13# @brief DS1 base derived stats that are closed-form functions of attributes only.
14# @details Only the two the equipment screen shows that need no gear: base Equip Load
15# (@c 40 + Endurance — fextralife's table is dead linear, END 10 -> 50.0, 99 -> 139.0)
16# and attunement slots (@ref DS1_SLOT_BREAKS). Stamina and Max HP are read from the
17# save, so they are not recomputed; poise comes from armour alone and item discovery
18# needs covenant/gear, so neither is derived. @param stats The attribute dict.
19def ds1_derived_stats(stats):
20 end = stats.get("Endurance", 0) or 0
21 atn = stats.get("Attunement", 0) or 0
22 return {
23 "slots": sum(1 for b in DS1_SLOT_BREAKS if atn >= b),
24 "equip_load": float(DS1_EQUIP_BASE + end),
25 }
26
27
28## @brief Load the DS1 bonfire table (db_ds1/bonfires.json, NetBonfireDb id → [name,
29# area]). Cached. Returns {} if absent.
30_DS1_BONFIRE_CACHE = {}
31
32
33def load_ds1_bonfires(base_dir):
34 if base_dir not in _DS1_BONFIRE_CACHE:
35 path = os.path.join(base_dir, "db_ds1", "bonfires.json")
36 try:
37 with open(path, encoding="utf-8") as f:
38 _DS1_BONFIRE_CACHE[base_dir] = {
39 int(k): tuple(v) for k, v in json.load(f).items()
40 }
41 except (OSError, ValueError):
42 _DS1_BONFIRE_CACHE[base_dir] = {}
43 return _DS1_BONFIRE_CACHE[base_dir]
44
45
46## @brief Load the DS1 boss-defeat flag table (db_ds1/boss_flags.json, canonical boss
47# name → [region byte offset, uint32 mask]). Cached. Returns {} if absent.
48_DS1_BOSSFLAG_CACHE = {}
49
50
51def load_ds1_boss_flags(base_dir):
52 if base_dir not in _DS1_BOSSFLAG_CACHE:
53 path = os.path.join(base_dir, "db_ds1", "boss_flags.json")
54 try:
55 with open(path, encoding="utf-8") as f:
56 _DS1_BOSSFLAG_CACHE[base_dir] = json.load(f)
57 except (OSError, ValueError):
58 _DS1_BOSSFLAG_CACHE[base_dir] = {}
59 return _DS1_BOSSFLAG_CACHE[base_dir]
60
61
62## @brief Where the event-flag region starts in a decrypted DS1 slot, per game. The
63# published DS1 flag addressing (group base + area*0x500 + section*128 + number/8,
64# MSB-first mask) gives offsets INSIDE the region; the region's own position is not
65# published, so it was searched for. In the DSR mule — an NG+2 character with all 43
66# bonfires — exactly ONE offset in the whole 393216-byte slot has all twelve boss
67# flags and both Bells of Awakening set, and both PtDE saves independently agree on
68# their own single value, so the base is a per-game constant rather than a per-save
69# search.
70DS1_FLAG_BASE = {"dsr": 127721, "ptde": 127273}
71
72
73## @brief Sanity gate on that base. A real flag region is overwhelmingly zero — it
74# measures ~0.006 set bits at the true base against ~0.32 for ordinary save data — so
75# anything denser than this means the region moved and the feature turns itself off
76# rather than reporting bosses off the wrong bytes.
77DS1_FLAG_MAX_DENSITY, DS1_FLAG_SPAN = 0.05, 23156
78
79
80##
81# @brief Merge DS1 boss-defeat FLAGS into @c ch["bosses"] as @c flag evidence.
82# @details Unlike the held-soul floor this sees a boss whose soul was long since
83# consumed. Guarded twice: the region base must be known for the game, and the region
84# must actually read sparse (@ref DS1_FLAG_MAX_DENSITY) — a moved region fails that
85# and nothing is reported. Boss names are canonicalised to the boss_souls.json
86# spelling in the db, so a flag kill and a soul kill dedup onto one boss.
87def ds1_attach_flags(ch, buf, base_dir, game):
88 base = DS1_FLAG_BASE.get(game)
89 table = load_ds1_boss_flags(base_dir)
90 if base is None or not table or base + DS1_FLAG_SPAN > len(buf):
91 return
92 region = buf[base : base + DS1_FLAG_SPAN]
93 if sum(bin(b).count("1") for b in region) > len(region) * 8 * DS1_FLAG_MAX_DENSITY:
94 return
95 bosses = {b: set(s) for b, s in (ch.get("bosses") or {}).items()}
96 for name, (off, mask) in table.items():
97 v = u32(buf, base + off)
98 if v is not None and v & mask:
99 bosses.setdefault(name, set()).add("flag")
100 if bosses:
101 ch["bosses"] = {b: sorted(bosses[b]) for b in bosses}
102 # World state: the bells, the Lordvessel, shortcut doors and levers, fog gates,
103 # NPCs and the covenant joined. Same region, same guards — they cost one more table
104 # lookup each and are the largest single thing DS1 was leaving on the floor.
105 world = []
106 for cat, rows in load_ds1_known_flags(base_dir).items():
107 got, missing = [], []
108 for off, mask, name in rows:
109 v = u32(buf, base + off)
110 (got if v is not None and v & mask else missing).append(name)
111 world.append((cat, len(got), got, len(rows), missing))
112 if any(c for _a, c, _n, _t, _m in world):
113 ch["world_flags"] = world
114 # World EVENTS: the same region again, from a much larger table extracted out of the
115 # committed event scripts. Counts only — the names are FromSoft's own Japanese and are
116 # not translated here. Two of its families are known not to mean what they say (see
117 # tools/gen_ds1_world_events.py), which is why this is a separate section from the
118 # named world state above rather than more rows inside it.
119 events = []
120 for cat, rows in load_ds1_world_events(base_dir).items():
121 got = sum(1 for off, mask, _n in rows if (u32(buf, base + off) or 0) & mask)
122 events.append((cat, got, len(rows)))
123 if any(c for _a, c, _t in events):
124 ch["world_events"] = events
125
126
127##
128# @brief Load the DS1 world-state flag table (category → [[offset, mask, name]]).
129# @details Generated by `tools/gen_ds1_known_flags.py`, whose id→(offset, mask) function
130# reproduces all twelve hand-checked entries of `boss_flags.json` exactly — which is why
131# these 44 are trusted without each being verified on its own. Cached per dir.
132_KNOWN_FLAG_CACHE = {}
133
134
136 if base_dir not in _KNOWN_FLAG_CACHE:
137 path = os.path.join(base_dir, "db_ds1", "known_flags.json")
138 try:
139 with open(path, encoding="utf-8") as f:
140 _KNOWN_FLAG_CACHE[base_dir] = json.load(f)
141 except (OSError, ValueError):
142 _KNOWN_FLAG_CACHE[base_dir] = {}
143 return _KNOWN_FLAG_CACHE[base_dir]
144
145
146##
147# @brief Load the DS1 world-event table (family → [[offset, mask, Japanese name]]).
148# @details Generated by `tools/gen_ds1_world_events.py` from the committed `.emevd` plus
149# FromSoft's own `.emeld` event names. Every row is `derived` and none is verified against
150# a save here — it decodes and it discriminates, and that is all that is claimed. Read the
151# generator before believing a family name: `Levers` reads 0 on a finished run and
152# `Boss-fight flags` counts flags rather than bosses. Cached per dir.
153_WORLD_EVENT_CACHE = {}
154
155
157 if base_dir not in _WORLD_EVENT_CACHE:
158 path = os.path.join(base_dir, "db_ds1", "world_events.json")
159 try:
160 with open(path, encoding="utf-8") as f:
161 _WORLD_EVENT_CACHE[base_dir] = json.load(f)
162 except (OSError, ValueError):
163 _WORLD_EVENT_CACHE[base_dir] = {}
164 return _WORLD_EVENT_CACHE[base_dir]
165
166
167## @brief DS1 bonfire record: 20 bytes, id then state (the rest is unread flags).
168# Unlike DS2 and DS3, DS1 does NOT keep bonfires as event flags — they are a
169# NetBonfireDb list of {id, state} records, so this walks records rather than bits.
170DS1_BONFIRE_REC, DS1_BONFIRE_STATE_D = 20, 4
171
172
173## @brief The state values a real record can hold, and what each means. Anything else
174# ends the walk, which is what keeps a misaligned start from inventing bonfires.
175DS1_BONFIRE_STATE = {
176 0: "discovered",
177 10: "lit",
178 20: "kindled +1",
179 30: "kindled +2",
180 40: "kindled +3",
181}
182
183
184## @brief Shortest believable run, so a stray id in unrelated data cannot pass.
185DS1_BONFIRE_MIN_RUN = 5
186
187
188##
189# @brief DS1 bonfires the character has found, grouped by area.
190# @details The list's offset moves between saves, so it is located BY CONTENT: the
191# longest run of consecutive 20-byte records whose id is a real bonfire and whose
192# state is one of @ref DS1_BONFIRE_STATE, with no id repeating. Shared by DSR and
193# PtDE — the layout is identical, only the decryption differs.
194# @return @c [(area, count, [names])] in the DS3 shape so it renders the same way,
195# or None when no believable run exists.
196def ds1_bonfires(buf, db):
197 if not db:
198 return None
199 best, o = [], 0
200 while o + DS1_BONFIRE_REC <= len(buf):
201 if u32(buf, o) in db:
202 run, p, seen = [], o, set()
203 while p + DS1_BONFIRE_REC <= len(buf):
204 bid = u32(buf, p)
205 state = u32(buf, p + DS1_BONFIRE_STATE_D)
206 if bid not in db or state not in DS1_BONFIRE_STATE or bid in seen:
207 break
208 seen.add(bid)
209 run.append((bid, state))
210 p += DS1_BONFIRE_REC
211 if len(run) > len(best):
212 best = run
213 o = max(p, o + 1)
214 else:
215 o += 1
216 if len(best) < DS1_BONFIRE_MIN_RUN:
217 return None
218 found = {bid: state for bid, state in best}
219 areas = OrderedDict()
220 for bid, (name, area) in db.items():
221 got, miss = areas.setdefault(area, ([], []))
222 if bid in found:
223 got.append(f"{name} ({DS1_BONFIRE_STATE[found[bid]]})")
224 else:
225 miss.append(name)
226 return [
227 (a, len(got), got, len(got) + len(miss), miss)
228 for a, (got, miss) in areas.items()
229 ]
230
231
232## @brief DS1-only augment: attach the bonfire list, which needs the db folder the
233# parse function never sees. Re-decrypts the slot rather than threading the buffer
234# through, so the generic loop keeps its (ch, data, entries, i, base_dir) shape.
235# @param dec The game's decrypt callable (DSR is encrypted, PtDE is not).
236def ds1_augment(ch, data, entries, i, base_dir, dec):
237 if i >= len(entries):
238 return
239 buf = dec(data[entries[i].offset : entries[i].offset + entries[i].size])
240 if buf is None:
241 return
242 if DS1_MENU_ENTRY < len(entries):
243 e = entries[DS1_MENU_ENTRY]
244 ds1_attach_playtime(ch, dec(data[e.offset : e.offset + e.size]))
245 areas = ds1_bonfires(buf, load_ds1_bonfires(base_dir))
246 if areas:
247 ch["bonfire_areas"] = areas
248 # Order matters: attach_defeated_bosses refuses to run once `bosses` exists (that
249 # guard is what stops it trampling DS2's richer inference), so the soul/NG+ floor
250 # has to be built BEFORE the flags are merged on top. The caller's own call then
251 # no-ops on the same guard.
252 attach_defeated_bosses(ch, base_dir)
253 ds1_attach_flags(ch, buf, base_dir, ch.get("game"))
254
255
256## @brief Anchor pattern that sits next to the DSR character block. Stats are read
257# at signed distances from wherever this is found.
258DSR_MAGIC = bytes.fromhex("00FFFFFFFF000000000000000000000000FFFFFFFF")
259
260
261## @brief DSR field distances from the anchor.
262DSR_SOULS_D, DSR_HP_D, DSR_STAM_D, DSR_LEVEL_D, DSR_CLASS_D, DSR_HUM_D = (
263 -291,
264 -419,
265 -391,
266 -295,
267 -233,
268 -307,
269)
270
271
272DSR_NG_D, DSR_NAME_D = 0x1E3A7, -271
273
274
275## @brief Gender (u8) distance from the anchor. Two independent sources agree, which
276# is what makes this shippable without a differential save: alfizari's DSR editor puts
277# Gender at magic-237, and tarvitz/dsfp (a PtDE parser) has a boolean `male` field 34
278# bytes past the name — the same byte, since the name sits at magic-271. Both call 1
279# Male, so DS1's polarity is the OPPOSITE of DS2's (where 1 is Female). Cross-read on
280# a real save: dsfp's frame and ours both report male=1 for the same character.
281DSR_GENDER_D = -237
282
283
284## @brief DS1 gender enum. Note the inverted polarity against DS2_GENDER.
285DS1_GENDER = {0: "Female", 1: "Male"}
286
287
288## @brief Total deaths (u32), at a slot-absolute offset per release rather than a
289# distance from the moving anchor — the counter lives in a fixed struct near the
290# event-flag region, not in the character block. PtDE's offset is dsfp's (0x1F128 in
291# its frame, which is 16 bytes ahead of ours), verified on two real saves: an
292# all-items mule reads 459 and a real playthrough reads 39. DSR shifts this struct by
293# the same 448 bytes its event-flag region moves (see DS1_FLAG_BASE), and the
294# neighbouring fields confirm it: both releases read [deaths][0xFFFFFFFF][~1.5M][2048]
295# in that order. DS1_DEATHS_SENTINEL is checked before the value is used, so a moved
296# struct omits the field instead of printing whatever is there.
297DS1_DEATHS_OFF = {"ptde": 0x1F118, "dsr": 0x1F2D8}
298
299
300DS1_DEATHS_SENTINEL, DS1_DEATHS_SENTINEL_D = 0xFFFFFFFF, 4
301
302
303## @brief DS1's load-screen roster lives in BND4 entry 10, one fixed record per slot:
304# name at +0 (UTF-16), soul level at +36, play time at +40 as a uint32 of SECONDS.
305# dsfp documents the 0x170 record stride, which its own play-time constant confirms
306# (its file-absolute index minus the record base is exactly this +40). The block's
307# start differs between the releases, so the record is located by the character's own
308# name and only accepted when the level at +36 matches the level parsed from the
309# slot — a self-consistency gate, the same trick DS3's equip slots use. Verified on
310# three saves: 25:16:39 at level 95 (DSR), 19:25:30 at 81 (PtDE), 151h on a mule.
311DS1_MENU_ENTRY = 10
312
313
314DS1_MENU_LEVEL_D, DS1_MENU_PLAYTIME_D = 36, 40
315
316
317## @brief DS1 derived values that are pure functions of one attribute, so they can be
318# computed exactly rather than guessed. Equip Load is 40 + Endurance (fextralife's
319# table: END 10 -> 50.0, 40 -> 80.0, 99 -> 139.0, dead linear with base 40).
320# Attunement slots are the documented breakpoints, 10 slots max at 50. Stamina and HP
321# are NOT computed — DS1 stores both in the save, so they are read. Poise is armour-
322# only and everything else is gear-scaled, so nothing else is derived.
323DS1_EQUIP_BASE = 40
324
325
326DS1_SLOT_BREAKS = (10, 12, 14, 16, 19, 23, 28, 34, 41, 50)
327
328
329## @brief DSR attribute distances from the anchor (uint8 each), in display order.
330DSR_STAT_D = OrderedDict(
331 [
332 ("Vitality", -375),
333 ("Attunement", -367),
334 ("Endurance", -359),
335 ("Strength", -351),
336 ("Dexterity", -343),
337 ("Resistance", -303),
338 ("Intelligence", -335),
339 ("Faith", -327),
340 ]
341)
342
343
344## @brief DS1 class ids to names.
345DS1_CLASS = {
346 0: "Warrior",
347 1: "Knight",
348 2: "Wanderer",
349 3: "Thief",
350 4: "Bandit",
351 5: "Hunter",
352 6: "Sorcerer",
353 7: "Pyromancer",
354 8: "Cleric",
355 9: "Deprived",
356}
357
358
359## @brief DS1 inventory slot type (top nibble) to category.
360DS1_CAT = {
361 0x00000000: "weapons",
362 0x10000000: "armors",
363 0x20000000: "rings",
364 0x40000000: "goods",
365}
366
367
368## @brief Where the DS1 inventory scan begins, and the anchor that marks the first
369# real slot.
370DS1_INV_START, DS1_INV_ANCHOR = 0x988, bytes.fromhex("0000000000000000A0BB0D00")
371
372
373## @brief End-of-inventory marker.
374DS1_INV_END = bytes.fromhex("00000000FFFFFFFFFFFFFFFF")
375
376
377## @brief DS1 weapon infusion paths, keyed by the hundreds digit of the id's
378# upgrade suffix (id = base + path*100 + level). Path 0 is plain reinforcement.
379DS1_INFUSION = {
380 1: "Crystal",
381 2: "Lightning",
382 3: "Raw",
383 4: "Magic",
384 5: "Enchanted",
385 6: "Divine",
386 7: "Occult",
387 8: "Fire",
388 9: "Chaos",
389}
390
391
392##
393# @brief Resolve a DS1 item id to a display name, unwrapping any upgrade baked in.
394# @details Weapons and armour store their reinforcement — and, for weapons, their
395# infusion — inside the id as @c base+path*100+level, where @c base ends in 000. A
396# direct hit is tried first; failing that, the base is looked up and a "+N" (with
397# the infusion name for weapons) suffix is appended. Rings and goods do not upgrade,
398# so they only ever match directly.
399# @return The display name, or None if even the base is unknown.
400def ds1_resolve(item_db, cat, iid):
401 table = item_db.get(cat, {})
402 if iid in table:
403 return table[iid]
404 # Rings carry no upgrade, and the table keeps them at 1/1000 of the stored id.
405 if cat == "rings":
406 return table.get(iid // 1000)
407 if cat not in ("weapons", "armors"):
408 return None
409 base, path, level = iid - iid % 1000, (iid % 1000) // 100, iid % 100
410 name = table.get(base)
411 if name is None:
412 return None
413 infusion = DS1_INFUSION.get(path) if cat == "weapons" else None
414 suffix = f" +{level}" if level else ""
415 return f"{name}{suffix} ({infusion})" if infusion else f"{name}{suffix}"
416
417
418##
419# @brief Find the true DSR stat anchor.
420# @details The magic pattern recurs inside runs of empty inventory slots, so a
421# match is not enough — the right one is where the whole stat block also reads as
422# plausible (level in range, every attribute 0..99). This is why a wrong anchor
423# never slips through on an all-items save.
424# @param buf The decrypted slot data.
425# @return The anchor offset, or None if no sane one exists.
427 o = 0
428 while True:
429 m = buf.find(DSR_MAGIC, o)
430 if m == -1:
431 return None
432 lvl = u16(buf, m + DSR_LEVEL_D)
433 stats = [u8(buf, m + d) for d in DSR_STAT_D.values()]
434 if (
435 lvl is not None
436 and 1 <= lvl <= 838
437 and all(v is not None and 0 <= v <= 99 for v in stats)
438 ):
439 return m
440 o = m + 1
441
442
443##
444# @brief Sort the DS1 inventory into categories. Shared by DSR and PtDE.
445# @return @c (buckets, unknown_count).
446def ds1_inventory(buf, item_db):
447 buckets, unknown = defaultdict(list), 0
448 start = buf.find(DS1_INV_ANCHOR, DS1_INV_START)
449 if start == -1:
450 return buckets, unknown
451 end = buf.find(DS1_INV_END, start)
452 if end == -1:
453 end = len(buf)
454 o = start
455 while o + 28 <= end:
456 stype, iid, qty = u32(buf, o + 4), u32(buf, o + 8), u32(buf, o + 12)
457 o += 28
458 if not iid:
459 continue
460 cat = DS1_CAT.get(stype & 0xF0000000) if stype is not None else None
461 # A spell IS a good as far as the slot type is concerned — only the id says
462 # otherwise, which is why the spell table is separate and consulted here.
463 if cat == "goods" and iid in item_db.get("spells", {}):
464 cat = "spells"
465 name = ds1_resolve(item_db, cat, iid) if cat else None
466 if name is None:
467 unknown += 1
468 continue
469 buckets[cat].append((name, qty))
470 return buckets, unknown
471
472
473##
474# @brief Total deaths for a DS1 slot, or None when the struct isn't where expected.
475# @details Guarded by the sentinel that follows the counter in both releases: if the
476# uint32 at +4 isn't 0xFFFFFFFF the struct has moved and the field is dropped rather
477# than read from the wrong place.
478# @param buf The decrypted slot.
479# @param game "dsr" or "ptde".
480# @return The death count, or None.
481def ds1_deaths(buf, game):
482 off = DS1_DEATHS_OFF.get(game)
483 if off is None:
484 return None
485 if u32(buf, off + DS1_DEATHS_SENTINEL_D) != DS1_DEATHS_SENTINEL:
486 return None
487 return u32(buf, off)
488
489
490##
491# @brief Attach play time from DS1's load-screen roster block.
492# @details The roster record is found by the character's own name and accepted only
493# when the level stored beside it matches the level already parsed from the slot, so a
494# renamed/duplicate name or a shifted block turns the field off instead of attaching
495# another character's clock.
496# @param ch The parsed character (read for name/level, written for play_time).
497# @param menu The decrypted menu block, or None.
499 if not menu or not ch.get("name") or ch.get("level") is None:
500 return
501 want = ch["name"].encode("utf-16-le")
502 pos = menu.find(want)
503 while pos >= 0:
504 if u32(menu, pos + DS1_MENU_LEVEL_D) == ch["level"]:
505 ch["play_time"] = u32(menu, pos + DS1_MENU_PLAYTIME_D)
506 return
507 pos = menu.find(want, pos + 2)
508
509
510##
511# @brief Build the unified full-tier dict from a located DS1 stat anchor.
512# @details Shared by DSR and PtDE: the two games carry the *same* stat block —
513# same fields at the same signed distances from the same anchor point (proven by
514# reading a real PtDE save byte-for-byte against the DSR distances). Only the way
515# the anchor is *found* differs, and NG+ is DSR-file-specific, so the caller
516# passes it (PtDE has no calibrated NG+ field and passes None).
517# @param m The stat anchor (a DSR-equivalent anchor position).
518# @param ng New Game+ count, or None to omit the field.
519def ds1_character(buf, item_db, m, game, ng):
520 stats = OrderedDict((k, u8(buf, m + d)) for k, d in DSR_STAT_D.items())
521 buckets, unknown = ds1_inventory(buf, item_db)
522 inv = {c: merge_qty(v) for c, v in buckets.items()}
523 name = read_utf16(buf, m + DSR_NAME_D, 13)
524 return {
525 "tier": "full",
526 "game": game,
527 "name": name if is_valid_name(name) else "(unnamed slot)",
528 "klass": DS1_CLASS.get(u8(buf, m + DSR_CLASS_D)),
529 "gender": DS1_GENDER.get(u8(buf, m + DSR_GENDER_D)),
530 "deaths": ds1_deaths(buf, game),
531 "level": u16(buf, m + DSR_LEVEL_D),
532 "stats": stats,
533 "souls": u32(buf, m + DSR_SOULS_D),
534 "soul_memory": None,
535 "humanity": u8(buf, m + DSR_HUM_D),
536 "stamina": u32(buf, m + DSR_STAM_D),
537 "hp": u32(buf, m + DSR_HP_D),
538 "ng_plus": ng,
539 "boss_souls": find_boss_souls(inv.get("goods", [])),
540 "key_items": find_key_goods(inv.get("goods", [])),
541 "inv": inv,
542 "unknown_count": unknown,
543 }
544
545
546## @brief Parse one DSR slot into the unified dict (full tier), or None if empty.
547def dsr_parse(buf, item_db):
548 m = dsr_find_anchor(buf)
549 if m is None:
550 return None
551 return ds1_character(buf, item_db, m, "dsr", u8(buf, m + DSR_NG_D) or 0)
552
553
554##
555# @brief Find the PtDE stat anchor (full tier).
556# @details PtDE has no DSR_MAGIC to key on, but its stat block is laid out
557# exactly like DSR's around the character name. So the name *is* the anchor: for
558# each position that decodes as a valid name, treat it as DSR's name field, back
559# out the equivalent anchor, and accept it only if the whole stat block there
560# also reads sane (level in range, every attribute 0..99). Requiring a valid name
561# *and* a valid stat block is what stops a false match inside the repeating
562# inventory runs of an all-items save — the real block sits before the inventory,
563# so the first such match from the top is the character.
564# @return The anchor offset, or None if no sane one exists.
566 o, n = 0, len(buf) - 1
567 while o < n:
568 name = read_utf16(buf, o, 13)
569 if len(name) >= 2 and is_valid_name(name):
570 m = o - DSR_NAME_D
571 lvl = u16(buf, m + DSR_LEVEL_D)
572 stats = [u8(buf, m + d) for d in DSR_STAT_D.values()]
573 if (
574 lvl is not None
575 and 1 <= lvl <= 838
576 and all(v is not None and 0 <= v <= 99 for v in stats)
577 ):
578 return m
579 o += 1
580 return None
581
582
583##
584# @brief Parse one PtDE slot (full tier).
585# @details Unencrypted DS1. Same stat layout as DSR (see @ref ds1_character),
586# found via the name anchor. NG+ is not calibrated for PtDE, so it is omitted.
587def ptde_parse(buf, item_db):
588 m = ptde_find_anchor(buf)
589 if m is None:
590 return None
591 return ds1_character(buf, item_db, m, "ptde", None)
ds1_attach_flags(ch, buf, base_dir, game)
Merge DS1 boss-defeat FLAGS into ch["bosses"] as flag evidence.
Definition ds1.py:87
ptde_find_anchor(buf)
Find the PtDE stat anchor (full tier).
Definition ds1.py:565
ds1_character(buf, item_db, m, game, ng)
Build the unified full-tier dict from a located DS1 stat anchor.
Definition ds1.py:519
ds1_deaths(buf, game)
Total deaths for a DS1 slot, or None when the struct isn't where expected.
Definition ds1.py:481
load_ds1_bonfires(base_dir)
Definition ds1.py:33
ds1_resolve(item_db, cat, iid)
Resolve a DS1 item id to a display name, unwrapping any upgrade baked in.
Definition ds1.py:400
ds1_bonfires(buf, db)
DS1 bonfires the character has found, grouped by area.
Definition ds1.py:196
load_ds1_world_events(base_dir)
Definition ds1.py:156
ds1_inventory(buf, item_db)
Sort the DS1 inventory into categories.
Definition ds1.py:446
dsr_find_anchor(buf)
Find the true DSR stat anchor.
Definition ds1.py:426
ds1_attach_playtime(ch, menu)
Attach play time from DS1's load-screen roster block.
Definition ds1.py:498
load_ds1_known_flags(base_dir)
Definition ds1.py:135