Quick and dirty initial commit to share progress
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.egg-info/
|
||||||
|
/venv/
|
||||||
8
README.md
Normal file
8
README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Quick & dirty commit of current progress to share with others.
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install --editable .[dev]
|
||||||
|
d2dump /path/to/stash.d2i
|
||||||
|
```
|
||||||
1
d2warehouse/__init__.py
Normal file
1
d2warehouse/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.1.0"
|
||||||
34
d2warehouse/dump.py
Normal file
34
d2warehouse/dump.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from d2warehouse.parser import parse_stash
|
||||||
|
|
||||||
|
from bitarray import bitarray
|
||||||
|
|
||||||
|
|
||||||
|
def txtbits(bits: bitarray) -> str:
|
||||||
|
txt = "".join(str(b) for b in bits)
|
||||||
|
grouped = [txt[i : i + 8] for i in range(0, len(txt), 8)]
|
||||||
|
return " ".join(grouped)
|
||||||
|
|
||||||
|
|
||||||
|
def read_stash(path):
|
||||||
|
with path.open("rb") as f:
|
||||||
|
stash = parse_stash(f.read())
|
||||||
|
|
||||||
|
print(f"Stash has {len(stash.tabs)} tabs")
|
||||||
|
for i, tab in enumerate(stash.tabs):
|
||||||
|
print(f" - tab {i}")
|
||||||
|
print(f" - {len(tab.items)} items")
|
||||||
|
print(f" - {tab.item_data.hex()}")
|
||||||
|
for j, item in enumerate(tab.items):
|
||||||
|
item.print()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="d2dump",
|
||||||
|
description="dump a text description of the items in d2r shared stash files",
|
||||||
|
)
|
||||||
|
parser.add_argument("stashfile", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
read_stash(args.stashfile)
|
||||||
57
d2warehouse/huffman.py
Normal file
57
d2warehouse/huffman.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from bitarray import bitarray, decodetree
|
||||||
|
import itertools
|
||||||
|
|
||||||
|
code = {
|
||||||
|
"a": bitarray("11110"),
|
||||||
|
"b": bitarray("0101"),
|
||||||
|
"c": bitarray("01000"),
|
||||||
|
"d": bitarray("110001"),
|
||||||
|
"e": bitarray("110000"),
|
||||||
|
"f": bitarray("010011"),
|
||||||
|
"g": bitarray("11010"),
|
||||||
|
"h": bitarray("00011"),
|
||||||
|
"i": bitarray("1111110"),
|
||||||
|
"j": bitarray("000101110"),
|
||||||
|
"k": bitarray("010010"),
|
||||||
|
"l": bitarray("11101"),
|
||||||
|
"m": bitarray("01101"),
|
||||||
|
"n": bitarray("001101"),
|
||||||
|
"o": bitarray("1111111"),
|
||||||
|
"p": bitarray("11001"),
|
||||||
|
"q": bitarray("11011001"),
|
||||||
|
"r": bitarray("11100"),
|
||||||
|
"s": bitarray("0010"),
|
||||||
|
"t": bitarray("01100"),
|
||||||
|
"u": bitarray("00001"),
|
||||||
|
"v": bitarray("1101110"),
|
||||||
|
"w": bitarray("00000"),
|
||||||
|
"x": bitarray("00111"),
|
||||||
|
"y": bitarray("0001010"),
|
||||||
|
"z": bitarray("11011000"),
|
||||||
|
"0": bitarray("11111011"),
|
||||||
|
"1": bitarray("1111100"),
|
||||||
|
"2": bitarray("001100"),
|
||||||
|
"3": bitarray("1101101"),
|
||||||
|
"4": bitarray("11111010"),
|
||||||
|
"5": bitarray("00010110"),
|
||||||
|
"6": bitarray("1101111"),
|
||||||
|
"7": bitarray("01111"),
|
||||||
|
"8": bitarray("000100"),
|
||||||
|
"9": bitarray("01110"),
|
||||||
|
" ": bitarray("10"),
|
||||||
|
}
|
||||||
|
|
||||||
|
decode_tree = decodetree(code)
|
||||||
|
|
||||||
|
|
||||||
|
def decode(bits: bitarray, n) -> tuple[str, int]:
|
||||||
|
s = "".join(itertools.islice(bits.iterdecode(decode_tree), n))
|
||||||
|
l = len(encode(s))
|
||||||
|
return s, l
|
||||||
|
|
||||||
|
|
||||||
|
def encode(s: str) -> bitarray:
|
||||||
|
bits = bitarray(endian="little")
|
||||||
|
bits.encode(code, s)
|
||||||
|
bits.encode(code, " ")
|
||||||
|
return bits
|
||||||
67
d2warehouse/item.py
Normal file
67
d2warehouse/item.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
from bitarray import bitarray
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class Quality(Enum):
|
||||||
|
LOW = 1
|
||||||
|
NORMAL = 2
|
||||||
|
HIGH = 3
|
||||||
|
MAGIC = 4
|
||||||
|
SET = 5
|
||||||
|
RARE = 6
|
||||||
|
UNIQUE = 7
|
||||||
|
CRAFTED = 8
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name.capitalize()
|
||||||
|
|
||||||
|
|
||||||
|
def txtbits(bits: bitarray) -> str:
|
||||||
|
txt = "".join(str(b) for b in bits)
|
||||||
|
grouped = [txt[i : i + 8] for i in range(0, len(txt), 8)]
|
||||||
|
return " ".join(grouped)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Item:
|
||||||
|
raw_data: bytes
|
||||||
|
is_identified: bool
|
||||||
|
is_socketed: bool
|
||||||
|
is_beginner: bool
|
||||||
|
is_simple: bool
|
||||||
|
is_ethereal: bool
|
||||||
|
is_personalized: bool
|
||||||
|
is_runeword: bool
|
||||||
|
pos_x: int
|
||||||
|
pos_y: int
|
||||||
|
kind: str
|
||||||
|
uid: int | None
|
||||||
|
lvl: int | None
|
||||||
|
quality: Quality | None
|
||||||
|
graphic: int | None
|
||||||
|
inherent: int | None
|
||||||
|
|
||||||
|
def print(self, indent=5):
|
||||||
|
properties = []
|
||||||
|
print(" " * indent, self.kind)
|
||||||
|
if self.lvl:
|
||||||
|
print(" " * indent, f"ilvl {self.lvl}")
|
||||||
|
if self.is_simple:
|
||||||
|
properties.append("Simple")
|
||||||
|
else:
|
||||||
|
properties.append("Extended")
|
||||||
|
if self.is_ethereal:
|
||||||
|
properties.append("Ethereal")
|
||||||
|
if not self.is_identified:
|
||||||
|
properties.append("Unidentified")
|
||||||
|
if self.is_socketed:
|
||||||
|
properties.append("Socketed")
|
||||||
|
if properties:
|
||||||
|
print(" " * indent, ", ".join(properties))
|
||||||
|
print(" " * indent, f"at {self.pos_x}, {self.pos_y}")
|
||||||
|
bits = bitarray(endian="little")
|
||||||
|
bits.frombytes(self.raw_data)
|
||||||
|
print(" " * indent, txtbits(bits))
|
||||||
|
print("")
|
||||||
|
print("")
|
||||||
200
d2warehouse/parser.py
Normal file
200
d2warehouse/parser.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import struct
|
||||||
|
from bitarray import bitarray
|
||||||
|
from bitarray.util import ba2int
|
||||||
|
from d2warehouse.stash import Stash, StashTab
|
||||||
|
from d2warehouse.item import Item, Quality
|
||||||
|
import d2warehouse.huffman as huffman
|
||||||
|
|
||||||
|
STASH_TAB_MAGIC = b"\x55\xAA\x55\xAA"
|
||||||
|
ITEM_DATA_MAGIC = b"JM"
|
||||||
|
|
||||||
|
|
||||||
|
class ParseError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedItemError(ParseError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_length(data, n):
|
||||||
|
if len(data) < n:
|
||||||
|
nearby = data[:10].hex()
|
||||||
|
raise ParseError(
|
||||||
|
f"Expected {n} bytes but only {len(data)} bytes are available near {nearby}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_fixed(data: bytes, prefix: bytes) -> bytes:
|
||||||
|
if data.startswith(prefix):
|
||||||
|
return data[len(prefix) :]
|
||||||
|
raise ParseError(f"Expected {prefix} near {data[:10].hex()}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bytes(data: bytes, n: int) -> tuple[bytes, bytes]:
|
||||||
|
ensure_length(data, n)
|
||||||
|
return data[n:], data[:n]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_u32(data: bytes) -> tuple[bytes, int]:
|
||||||
|
ensure_length(data, 4)
|
||||||
|
return data[4:], struct.unpack("<I", data[:4])[0]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_u16(data: bytes) -> tuple[bytes, int]:
|
||||||
|
ensure_length(data, 2)
|
||||||
|
return data[2:], struct.unpack("<H", data[:2])[0]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_stash(data) -> Stash:
|
||||||
|
stash = Stash()
|
||||||
|
while len(data) > 0:
|
||||||
|
data, tab = parse_stash_tab(data)
|
||||||
|
stash.tabs.append(tab)
|
||||||
|
return stash
|
||||||
|
|
||||||
|
|
||||||
|
def parse_stash_tab(data: bytes) -> tuple[bytes, StashTab]:
|
||||||
|
data = parse_fixed(data, STASH_TAB_MAGIC)
|
||||||
|
data, unknown = parse_u32(data)
|
||||||
|
data, version = parse_u32(data)
|
||||||
|
|
||||||
|
if unknown != 1:
|
||||||
|
ParseError("Unknown stash tab field is not 1")
|
||||||
|
if version != 99:
|
||||||
|
ParseError(f"Unsupported stash tab version ({version} instead of 99)")
|
||||||
|
|
||||||
|
tab = StashTab()
|
||||||
|
data, gold = parse_u32(data)
|
||||||
|
tab.gold = gold
|
||||||
|
|
||||||
|
# Length is the total length of the tab data in bytes. This includes all
|
||||||
|
# fields that are already parsed above.
|
||||||
|
data, length = parse_u32(data)
|
||||||
|
|
||||||
|
# Skip what is probably zero padding (?) but check just in case it contains data
|
||||||
|
data, pad = parse_bytes(data, 44)
|
||||||
|
if pad != b"\x00" * 44:
|
||||||
|
raise ParseError(f"Unexpecteed data in zero padding: {pad}")
|
||||||
|
|
||||||
|
# Separate out the item data so we can easilly verify items parsed the correct
|
||||||
|
# number of bytes
|
||||||
|
data, item_data = parse_bytes(data, length - 64)
|
||||||
|
|
||||||
|
tab.item_data = item_data
|
||||||
|
tab.items = parse_items(item_data)
|
||||||
|
|
||||||
|
return data, tab
|
||||||
|
|
||||||
|
|
||||||
|
def parse_item(data: bytes) -> tuple[bytes, Item]:
|
||||||
|
bits = bitarray(endian="little")
|
||||||
|
bits.frombytes(data)
|
||||||
|
|
||||||
|
is_identified = bool(bits[4])
|
||||||
|
is_socketed = bool(bits[11])
|
||||||
|
is_ear = bool(bits[16])
|
||||||
|
is_beginner = bool(bits[17])
|
||||||
|
is_simple = bool(bits[21])
|
||||||
|
is_ethereal = bool(bits[22])
|
||||||
|
is_personalized = bool(bits[24])
|
||||||
|
is_runeword = bool(bits[26])
|
||||||
|
pos_x = ba2int(bits[42:46])
|
||||||
|
pos_y = ba2int(bits[46:50])
|
||||||
|
kind, kind_end = huffman.decode(bits[53:], 3)
|
||||||
|
kind_end += 53
|
||||||
|
# TODO: verify that this socket thing is really 1 bit for simple items...?
|
||||||
|
sockets_end = kind_end + 1 if is_simple else kind_end + 3
|
||||||
|
sockets_count = ba2int(bits[kind_end:sockets_end])
|
||||||
|
print("sockets", sockets_count)
|
||||||
|
if is_ear:
|
||||||
|
raise UnsupportedItemError("Ear items are not supported")
|
||||||
|
|
||||||
|
uid, lvl, quality, graphic, inherent = None, None, None, None, None
|
||||||
|
if not is_simple:
|
||||||
|
uid, lvl, quality = parse_extended_item(bits[sockets_end:])
|
||||||
|
|
||||||
|
extended_end = sockets_end + 32 + 7 + 4
|
||||||
|
|
||||||
|
graphic, graphic_end = parse_item_graphic(bits[extended_end:])
|
||||||
|
graphic_end += extended_end
|
||||||
|
|
||||||
|
inherent, inherent_end = parse_inherent_mod(bits[graphic_end:])
|
||||||
|
inherent_end += graphic_end
|
||||||
|
|
||||||
|
item = Item(
|
||||||
|
data[:], # TODO: only take parsed bytes
|
||||||
|
is_identified,
|
||||||
|
is_socketed,
|
||||||
|
is_beginner,
|
||||||
|
is_simple,
|
||||||
|
is_ethereal,
|
||||||
|
is_personalized,
|
||||||
|
is_runeword,
|
||||||
|
pos_x,
|
||||||
|
pos_y,
|
||||||
|
kind,
|
||||||
|
uid,
|
||||||
|
lvl,
|
||||||
|
quality,
|
||||||
|
graphic,
|
||||||
|
inherent,
|
||||||
|
)
|
||||||
|
return b"", item # TODO: properly return remaining data
|
||||||
|
|
||||||
|
|
||||||
|
def parse_item_graphic(bits: bitarray) -> tuple[int | None, int]:
|
||||||
|
if bits[0]:
|
||||||
|
return None, 1
|
||||||
|
else:
|
||||||
|
return ba2int(bits[1:4]), 4
|
||||||
|
|
||||||
|
|
||||||
|
def parse_inherent_mod(bits: bitarray) -> tuple[int | None, int]:
|
||||||
|
if bits[0]:
|
||||||
|
return None, 1
|
||||||
|
else:
|
||||||
|
return ba2int(bits[1:4]), 4
|
||||||
|
|
||||||
|
|
||||||
|
def parse_extended_item(bits: bitarray) -> tuple[int, int, Quality]:
|
||||||
|
uid = ba2int(bits[:32])
|
||||||
|
lvl = ba2int(bits[32:39])
|
||||||
|
quality = ba2int(bits[39:43])
|
||||||
|
return uid, lvl, Quality(quality)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: figure out what we want to return here
|
||||||
|
# TODO: Introduce enums for quality
|
||||||
|
def parse_quality_data(bits: bitarray, quality: int):
|
||||||
|
if quality == 1:
|
||||||
|
return parse_low_quality_data(bits)
|
||||||
|
elif quality == 2:
|
||||||
|
return None, 0
|
||||||
|
elif quality == 3:
|
||||||
|
return parse_high_quality_data(bits)
|
||||||
|
elif quality == 4:
|
||||||
|
return parse_magic_data(bits)
|
||||||
|
elif quality == 5:
|
||||||
|
return parse_set_data(bits)
|
||||||
|
elif quality == 6:
|
||||||
|
return parse_rare_data(bits)
|
||||||
|
elif quality == 7:
|
||||||
|
return parse_unique_data(bits)
|
||||||
|
elif quality == 8:
|
||||||
|
return parse_crafted_data(bits)
|
||||||
|
# magic
|
||||||
|
|
||||||
|
|
||||||
|
def parse_items(data: bytes) -> list[Item]:
|
||||||
|
data = parse_fixed(data, ITEM_DATA_MAGIC)
|
||||||
|
data, num = parse_u16(data)
|
||||||
|
|
||||||
|
items: list[Item] = []
|
||||||
|
while data:
|
||||||
|
data, item = parse_item(data)
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
# TODO: check if num == len(items)
|
||||||
|
|
||||||
|
return items
|
||||||
13
d2warehouse/stash.py
Normal file
13
d2warehouse/stash.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from bitarray import bitarray
|
||||||
|
|
||||||
|
|
||||||
|
class Stash:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.tabs: list[StashTab] = []
|
||||||
|
|
||||||
|
|
||||||
|
class StashTab:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.gold: int = 0
|
||||||
|
self.item_data: bytes = b""
|
||||||
|
self.items: list[Item] = []
|
||||||
56
d2warehouse/test.py
Normal file
56
d2warehouse/test.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from bitarray import bitarray
|
||||||
|
from d2warehouse.parser import parse_item
|
||||||
|
import d2warehouse.huffman as huffman
|
||||||
|
|
||||||
|
test_items = [
|
||||||
|
(
|
||||||
|
"Leather armor (16 armor, 24/24 durability)",
|
||||||
|
"102080000500f40e2f087bf2b426041ac0c0f01f",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Ethereal Leather Armor (24 armor, 13/13 durability)",
|
||||||
|
"1000c0000500f40e2f9c8627a70404226868f01f",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Unidentified Ethereal Magic Leather armor (27 armor, 13/13 dura)",
|
||||||
|
"0000c0000500f40e2f08a37fd61388470040091a1a4088f01f",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"^ The same item but identified (+17% ED, Sturdy prefix",
|
||||||
|
"1000c0000500f40e2f08a37fd61388470040091a1a4088f01f",
|
||||||
|
),
|
||||||
|
("minor healing potion", "1000a008050014cf4f00"),
|
||||||
|
("beginner minor healing potion", "1020a200050014cf4f00"),
|
||||||
|
("minor mana potion", "1000a0000500d4ce4f00"),
|
||||||
|
("minor rejuvenation potion", "1000a0000500f4ec28"),
|
||||||
|
("stamina potion", "1000a0000500743729"),
|
||||||
|
("antidote potion", "1000a0000500143529"),
|
||||||
|
("thawing potion", "1000a000050014580a"),
|
||||||
|
("beginner scroll of identify", "1000a2000500f44722"),
|
||||||
|
("scroll of identify", "1000a0000500f44722"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def txtbits(bits: bitarray) -> str:
|
||||||
|
txt = "".join(str(b) for b in bits)
|
||||||
|
grouped = [txt[i : i + 8] for i in range(0, len(txt), 8)]
|
||||||
|
return " ".join(grouped)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
for descr, hex in test_items:
|
||||||
|
print(descr)
|
||||||
|
print(hex)
|
||||||
|
raw = bytes.fromhex(hex)
|
||||||
|
bits = bitarray(endian="little")
|
||||||
|
bits.frombytes(raw)
|
||||||
|
print(txtbits(bits))
|
||||||
|
_, item = parse_item(raw)
|
||||||
|
print(f"kind: {item.kind}")
|
||||||
|
print(f"simple: {item.is_simple}")
|
||||||
|
print(f"identified: {item.is_identified}")
|
||||||
|
print(f"beginner: {item.is_beginner}")
|
||||||
|
print("--")
|
||||||
|
print(huffman.encode("hp1"))
|
||||||
|
print(huffman.encode("mp1"))
|
||||||
|
print(huffman.encode("rvs"))
|
||||||
40
pyproject.toml
Normal file
40
pyproject.toml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "d2warehouse"
|
||||||
|
authors = [
|
||||||
|
{name = "omicron"},
|
||||||
|
]
|
||||||
|
description = "personal notes keeping"
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Environment :: Console",
|
||||||
|
|
||||||
|
]
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
license = {text = "GPLv3 License"}
|
||||||
|
dependencies = [
|
||||||
|
"PyYAML",
|
||||||
|
"bitarray",
|
||||||
|
]
|
||||||
|
dynamic = ["version"]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"coverage",
|
||||||
|
"pytest",
|
||||||
|
"pytest-cov",
|
||||||
|
"mypy",
|
||||||
|
"types-PyYAML",
|
||||||
|
"flake8",
|
||||||
|
"black",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
d2dump = "d2warehouse.dump:main"
|
||||||
|
d2test = "d2warehouse.test:main"
|
||||||
|
|
||||||
|
[tool.setuptools.dynamic]
|
||||||
|
version = {attr = "d2warehouse.__version__"}
|
||||||
Reference in New Issue
Block a user