pyfield-esm 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyfield/__init__.py +58 -0
- pyfield/assets/__init__.py +0 -0
- pyfield/assets/ba2.py +183 -0
- pyfield/assets/deps.py +179 -0
- pyfield/assets/index.py +140 -0
- pyfield/assets/pack.py +173 -0
- pyfield/capabilities.py +46 -0
- pyfield/cli.py +334 -0
- pyfield/core/__init__.py +9 -0
- pyfield/core/compression.py +37 -0
- pyfield/core/format.py +37 -0
- pyfield/core/formids.py +161 -0
- pyfield/core/groups.py +19 -0
- pyfield/core/header.py +40 -0
- pyfield/core/index.py +143 -0
- pyfield/core/loadorder.py +343 -0
- pyfield/core/nodes.py +317 -0
- pyfield/core/plugin.py +514 -0
- pyfield/core/subrecords.py +70 -0
- pyfield/diff.py +134 -0
- pyfield/edit.py +385 -0
- pyfield/errors.py +41 -0
- pyfield/games/__init__.py +78 -0
- pyfield/games/base.py +113 -0
- pyfield/games/oblivion/__init__.py +38 -0
- pyfield/games/starfield/__init__.py +124 -0
- pyfield/games/starfield/assets.py +74 -0
- pyfield/games/starfield/biomes.py +269 -0
- pyfield/games/starfield/btd.py +253 -0
- pyfield/games/starfield/data/subrecord_order.json +4956 -0
- pyfield/games/starfield/data/xedit_sf1_inventory.json +201504 -0
- pyfield/games/starfield/outpost.py +539 -0
- pyfield/games/starfield/overlay.py +460 -0
- pyfield/games/starfield/records.py +29 -0
- pyfield/games/starfield/save.py +199 -0
- pyfield/games/starfield/schema.py +250 -0
- pyfield/games/starfield/ships.py +244 -0
- pyfield/games/tes4family.py +195 -0
- pyfield/games/vmad.py +432 -0
- pyfield/landscape/__init__.py +2 -0
- pyfield/landscape/fit.py +493 -0
- pyfield/landscape/heightfield.py +183 -0
- pyfield/remap.py +166 -0
- pyfield/scene/__init__.py +8 -0
- pyfield/scene/capture.py +77 -0
- pyfield/scene/compile.py +342 -0
- pyfield/scene/diff.py +49 -0
- pyfield/scene/graph.py +191 -0
- pyfield/scene/runtime.py +216 -0
- pyfield/scene/spatial.py +86 -0
- pyfield/scene/transform.py +133 -0
- pyfield/schema/codecs.py +469 -0
- pyfield/schema/records.py +395 -0
- pyfield/testing/__init__.py +0 -0
- pyfield/testing/fixtures.py +68 -0
- pyfield/testing/roundtrip.py +106 -0
- pyfield/testing/toy_adapter.py +95 -0
- pyfield/transplant.py +376 -0
- pyfield/validate.py +213 -0
- pyfield_esm-0.3.0.dist-info/METADATA +122 -0
- pyfield_esm-0.3.0.dist-info/RECORD +67 -0
- pyfield_esm-0.3.0.dist-info/WHEEL +5 -0
- pyfield_esm-0.3.0.dist-info/entry_points.txt +2 -0
- pyfield_esm-0.3.0.dist-info/licenses/LICENSE +21 -0
- pyfield_esm-0.3.0.dist-info/licenses/LICENSES/MPL-2.0.txt +373 -0
- pyfield_esm-0.3.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +22 -0
- pyfield_esm-0.3.0.dist-info/top_level.txt +1 -0
pyfield/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""PyField: Bethesda game worlds as structured, persistent, validated content.
|
|
2
|
+
|
|
3
|
+
Stable public API (covered by tests/test_public_api.py; see docs/api.md):
|
|
4
|
+
|
|
5
|
+
open_plugin, create_plugin, open_load_order
|
|
6
|
+
Plugin, LoadOrder, Query, Transaction, FormKey, Unresolved
|
|
7
|
+
RecordNode, GroupNode, Subrecord, TypedRecord
|
|
8
|
+
validate_plugin, Report, diff_plugins, Diff
|
|
9
|
+
games (adapter registry), Capability/Status
|
|
10
|
+
errors: PyFieldError and subclasses
|
|
11
|
+
|
|
12
|
+
Everything else (including attributes starting with "_") may change between minor versions.
|
|
13
|
+
"""
|
|
14
|
+
from .capabilities import Capability, CapabilitySet, Status
|
|
15
|
+
from .core.formids import FormKey, Unresolved
|
|
16
|
+
from .core.loadorder import LoadOrder, Query
|
|
17
|
+
from .core.nodes import GroupNode, RecordNode
|
|
18
|
+
from .core.plugin import Plugin
|
|
19
|
+
from .core.subrecords import Subrecord
|
|
20
|
+
from .diff import Diff, diff_plugins
|
|
21
|
+
from .edit import Transaction
|
|
22
|
+
from .errors import (FormatError, PyFieldError, ResolutionError, SchemaError, TransactionError,
|
|
23
|
+
UnsupportedOperation, ValidationError)
|
|
24
|
+
from .schema.records import TypedRecord
|
|
25
|
+
from .validate import Report, validate_plugin
|
|
26
|
+
from . import games
|
|
27
|
+
|
|
28
|
+
__version__ = '0.3.0'
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
'open_plugin', 'create_plugin', 'open_load_order',
|
|
32
|
+
'Plugin', 'LoadOrder', 'Query', 'Transaction', 'FormKey', 'Unresolved',
|
|
33
|
+
'RecordNode', 'GroupNode', 'Subrecord', 'TypedRecord',
|
|
34
|
+
'validate_plugin', 'Report', 'diff_plugins', 'Diff',
|
|
35
|
+
'games', 'Capability', 'CapabilitySet', 'Status',
|
|
36
|
+
'PyFieldError', 'FormatError', 'ResolutionError', 'SchemaError', 'TransactionError',
|
|
37
|
+
'UnsupportedOperation', 'ValidationError', '__version__',
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def open_plugin(path, game=None, **kwargs) -> Plugin:
|
|
42
|
+
"""Open one plugin file; the game is detected from its header unless given."""
|
|
43
|
+
return Plugin.open(path, game, **kwargs)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create_plugin(name: str, game: str, masters=(), *, flags: int = 0, author: str = '',
|
|
47
|
+
description: str = '', master_kinds: dict | None = None) -> Plugin:
|
|
48
|
+
"""A new empty plugin. Pass master_kinds (name -> 'full'|'medium'|'small') when the
|
|
49
|
+
masters are not on disk next to where it will be saved."""
|
|
50
|
+
plugin = Plugin.create(name, game, masters, flags=flags, author=author, description=description)
|
|
51
|
+
if master_kinds:
|
|
52
|
+
plugin.set_master_kinds(master_kinds)
|
|
53
|
+
return plugin
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def open_load_order(data_dir, plugins, game=None, with_masters: bool = True) -> LoadOrder:
|
|
57
|
+
"""Open plugins from a data folder (masters added first, recursively)."""
|
|
58
|
+
return LoadOrder.open(data_dir, plugins, game, with_masters)
|
|
File without changes
|
pyfield/assets/ba2.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""BA2 archives (Fallout 4 / Starfield "BTDX"): read GNRL and DX10, write uncompressed GNRL.
|
|
2
|
+
|
|
3
|
+
Reader adapted from the workspace's proven bolero/ScreenSFSE `ba2.py` (GNRL v1-3, DX10 with
|
|
4
|
+
LZ4 block or zlib chunks, DDS header reconstruction for textures).
|
|
5
|
+
|
|
6
|
+
The writer is for tests and an experimental fallback only: production archives are built
|
|
7
|
+
with the game's Archive2.exe (see pyfield.assets.pack). Member names are stored as given
|
|
8
|
+
(normalized to backslashes); name/directory hashes are zero, which PyField's reader does not
|
|
9
|
+
need but the game does — never ship archives from this writer.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import struct
|
|
16
|
+
import zlib
|
|
17
|
+
|
|
18
|
+
from ..errors import FormatError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize(name: str) -> str:
|
|
22
|
+
"""Archive member key: forward slashes, lower case, no leading 'data/'."""
|
|
23
|
+
name = name.replace('\\', '/').lstrip('/').lower()
|
|
24
|
+
return name[5:] if name.startswith('data/') else name
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def lz4_block(src: bytes, size: int) -> bytes:
|
|
28
|
+
"""Decode one LZ4 block (no frame header) to `size` bytes."""
|
|
29
|
+
out = bytearray()
|
|
30
|
+
i, n = 0, len(src)
|
|
31
|
+
while i < n:
|
|
32
|
+
token = src[i]
|
|
33
|
+
i += 1
|
|
34
|
+
lit = token >> 4
|
|
35
|
+
if lit == 15:
|
|
36
|
+
while True:
|
|
37
|
+
b = src[i]
|
|
38
|
+
i += 1
|
|
39
|
+
lit += b
|
|
40
|
+
if b != 255:
|
|
41
|
+
break
|
|
42
|
+
out += src[i:i + lit]
|
|
43
|
+
i += lit
|
|
44
|
+
if i >= n:
|
|
45
|
+
break
|
|
46
|
+
offset = src[i] | (src[i + 1] << 8)
|
|
47
|
+
i += 2
|
|
48
|
+
ml = token & 15
|
|
49
|
+
if ml == 15:
|
|
50
|
+
while True:
|
|
51
|
+
b = src[i]
|
|
52
|
+
i += 1
|
|
53
|
+
ml += b
|
|
54
|
+
if b != 255:
|
|
55
|
+
break
|
|
56
|
+
ml += 4
|
|
57
|
+
start = len(out) - offset
|
|
58
|
+
if offset >= ml:
|
|
59
|
+
out += out[start:start + ml]
|
|
60
|
+
else:
|
|
61
|
+
for k in range(ml):
|
|
62
|
+
out.append(out[start + k])
|
|
63
|
+
if len(out) != size:
|
|
64
|
+
raise FormatError(f'LZ4 block decoded to {len(out)} bytes, expected {size}')
|
|
65
|
+
return bytes(out)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
BLOCK_BYTES = {70: 8, 71: 8, 72: 8, 79: 8, 80: 8, 81: 8, 73: 16, 74: 16, 75: 16, 76: 16, 77: 16, 78: 16,
|
|
69
|
+
82: 16, 83: 16, 84: 16, 94: 16, 95: 16, 96: 16, 97: 16, 98: 16, 99: 16}
|
|
70
|
+
PIXEL_BYTES = {28: 4, 29: 4, 87: 4, 91: 4, 61: 1, 49: 2, 10: 8, 2: 16, 24: 4, 56: 2}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def dds_header(width, height, mips, dxgi, cubemap) -> bytes:
|
|
74
|
+
if dxgi in BLOCK_BYTES:
|
|
75
|
+
pitch = max(1, (width + 3) // 4) * BLOCK_BYTES[dxgi] * max(1, (height + 3) // 4)
|
|
76
|
+
flags = 0x1 | 0x2 | 0x4 | 0x1000 | 0x20000 | 0x80000
|
|
77
|
+
else:
|
|
78
|
+
pitch = width * PIXEL_BYTES.get(dxgi, 4)
|
|
79
|
+
flags = 0x1 | 0x2 | 0x4 | 0x1000 | 0x20000 | 0x8
|
|
80
|
+
caps = 0x1000 | (0x400008 if mips > 1 else 0) | (0x8 if cubemap else 0)
|
|
81
|
+
caps2 = 0xFE00 if cubemap else 0
|
|
82
|
+
head = struct.pack('<4sIIIIIII44x', b'DDS ', 124, flags, height, width, pitch, 0, mips)
|
|
83
|
+
pf = struct.pack('<II4s20x', 32, 0x4, b'DX10')
|
|
84
|
+
head += pf + struct.pack('<IIII4x', caps, caps2, 0, 0)
|
|
85
|
+
return head + struct.pack('<IIIII', dxgi, 3, 0x4 if cubemap else 0, 1, 0)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Member:
|
|
90
|
+
name: str # normalized key
|
|
91
|
+
stored_name: str # as written in the archive's name table
|
|
92
|
+
kind: str # 'GNRL' or 'DX10'
|
|
93
|
+
info: tuple
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Archive:
|
|
97
|
+
"""One BA2 file. `members` maps normalized names to Member."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, path):
|
|
100
|
+
self.path = Path(path)
|
|
101
|
+
self.members: dict[str, Member] = {}
|
|
102
|
+
with open(self.path, 'rb') as f:
|
|
103
|
+
head = f.read(36)
|
|
104
|
+
if len(head) < 24 or head[:4] != b'BTDX':
|
|
105
|
+
raise FormatError(f'{self.path.name}: not a BA2 archive')
|
|
106
|
+
_, self.version, kind, count, table = struct.unpack_from('<4sI4sIQ', head)
|
|
107
|
+
self.kind = kind.decode('latin-1')
|
|
108
|
+
header = 24 + (8 if self.version >= 2 else 0) + (4 if self.version == 3 else 0)
|
|
109
|
+
self.compression = struct.unpack_from('<I', head, 32)[0] if self.version == 3 else 0
|
|
110
|
+
f.seek(header)
|
|
111
|
+
records = []
|
|
112
|
+
if self.kind == 'GNRL':
|
|
113
|
+
for _ in range(count):
|
|
114
|
+
_, _, _, _, offset, packed, size, _ = struct.unpack('<I4sIIQIII', f.read(36))
|
|
115
|
+
records.append(('GNRL', (offset, packed, size)))
|
|
116
|
+
elif self.kind == 'DX10':
|
|
117
|
+
for _ in range(count):
|
|
118
|
+
_, _, _, _, nchunks, _, h, w, mips, fmt, cube, _ = struct.unpack('<I4sIBBHHHBBBB', f.read(24))
|
|
119
|
+
chunks = [struct.unpack('<QIIHHI', f.read(24)) for _ in range(nchunks)]
|
|
120
|
+
records.append(('DX10', ((w, h, mips, fmt, cube), chunks)))
|
|
121
|
+
else:
|
|
122
|
+
raise FormatError(f'{self.path.name}: unsupported BA2 type {self.kind}')
|
|
123
|
+
f.seek(table)
|
|
124
|
+
for kind_, info in records:
|
|
125
|
+
n, = struct.unpack('<H', f.read(2))
|
|
126
|
+
stored = f.read(n).decode('utf-8', 'replace')
|
|
127
|
+
key = normalize(stored)
|
|
128
|
+
self.members[key] = Member(key, stored, kind_, info)
|
|
129
|
+
|
|
130
|
+
def __contains__(self, name):
|
|
131
|
+
return normalize(name) in self.members
|
|
132
|
+
|
|
133
|
+
def names(self) -> list[str]:
|
|
134
|
+
return sorted(self.members)
|
|
135
|
+
|
|
136
|
+
def _unpack(self, f, offset, packed, size):
|
|
137
|
+
f.seek(offset)
|
|
138
|
+
if not packed:
|
|
139
|
+
return f.read(size)
|
|
140
|
+
data = f.read(packed)
|
|
141
|
+
if self.compression == 3:
|
|
142
|
+
return lz4_block(data, size)
|
|
143
|
+
return zlib.decompress(data)
|
|
144
|
+
|
|
145
|
+
def read(self, name: str) -> bytes:
|
|
146
|
+
m = self.members[normalize(name)]
|
|
147
|
+
with open(self.path, 'rb') as f:
|
|
148
|
+
if m.kind == 'GNRL':
|
|
149
|
+
return self._unpack(f, *m.info)
|
|
150
|
+
(w, h, mips, fmt, cube), chunks = m.info
|
|
151
|
+
body = b''.join(self._unpack(f, off, packed, size) for off, packed, size, _, _, _ in chunks)
|
|
152
|
+
return dds_header(w, h, mips, fmt, cube) + body
|
|
153
|
+
|
|
154
|
+
def __repr__(self):
|
|
155
|
+
return f'<Archive {self.path.name} {self.kind} {len(self.members)} members>'
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def write_gnrl(path, files: dict[str, bytes], version: int = 2):
|
|
159
|
+
"""Experimental: uncompressed GNRL archive (tests/fallback only; hashes are zero)."""
|
|
160
|
+
names = sorted(files)
|
|
161
|
+
header_size = 24 + (8 if version >= 2 else 0) + (4 if version == 3 else 0)
|
|
162
|
+
data_offset = header_size + 36 * len(names)
|
|
163
|
+
entries, blobs, offset = [], [], data_offset
|
|
164
|
+
for name in names:
|
|
165
|
+
data = files[name]
|
|
166
|
+
ext = (name.rsplit('.', 1)[-1] if '.' in name else '').encode('latin-1')[:4].ljust(4, b'\0')
|
|
167
|
+
entries.append(struct.pack('<I4sIIQIII', 0, ext, 0, 0, offset, 0, len(data), 0xBAADF00D))
|
|
168
|
+
blobs.append(data)
|
|
169
|
+
offset += len(data)
|
|
170
|
+
table = offset
|
|
171
|
+
out = bytearray(struct.pack('<4sI4sIQ', b'BTDX', version, b'GNRL', len(names), table))
|
|
172
|
+
if version >= 2:
|
|
173
|
+
out += struct.pack('<II', 1, 0)
|
|
174
|
+
if version == 3:
|
|
175
|
+
out += struct.pack('<I', 0)
|
|
176
|
+
for e in entries:
|
|
177
|
+
out += e
|
|
178
|
+
for b in blobs:
|
|
179
|
+
out += b
|
|
180
|
+
for name in names:
|
|
181
|
+
stored = name.replace('/', '\\').encode('utf-8')
|
|
182
|
+
out += struct.pack('<H', len(stored)) + stored
|
|
183
|
+
Path(path).write_bytes(bytes(out))
|
pyfield/assets/deps.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Asset dependency discovery: records -> files -> files, every candidate verified.
|
|
2
|
+
|
|
3
|
+
Strings are found by scanning (records: path-like text in any subrecord; NIF and other
|
|
4
|
+
binaries: length-prefixed strings; JSON: every string value). A candidate becomes a
|
|
5
|
+
dependency only if the AssetIndex resolves it to an existing file; everything else is
|
|
6
|
+
reported as unresolved, never assumed. Adapter hooks add structured dependencies that are
|
|
7
|
+
not plain paths (Starfield: Wwise events -> soundbank files).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import deque
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import struct
|
|
16
|
+
|
|
17
|
+
from .index import AssetIndex
|
|
18
|
+
|
|
19
|
+
PRINTABLE = re.compile(rb'[\x20-\x7e]{4,260}')
|
|
20
|
+
PATHY = re.compile(r'[\\/]|\.[A-Za-z0-9]{2,7}$')
|
|
21
|
+
SCANNED_EXTENSIONS = ('.nif', '.mat', '.json', '.txt', '.agx', '.rig', '.bgsm', '.bgem')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class AssetRef:
|
|
26
|
+
path: str # resolved, normalized asset path
|
|
27
|
+
source: str # what referenced it ('STAT 01000800:MODL', 'meshes/x.nif', ...)
|
|
28
|
+
provider: str = '' # 'archive:<file>' / 'loose:<file>'
|
|
29
|
+
category: str = '' # owned | vanilla | foreign | loose
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class AssetClosure:
|
|
34
|
+
files: dict = field(default_factory=dict) # path -> AssetRef (first reference)
|
|
35
|
+
unresolved: list = field(default_factory=list) # (source, text) that looked like paths but resolve nowhere
|
|
36
|
+
external: list = field(default_factory=list) # (source, text) living in a compiled database (unverifiable)
|
|
37
|
+
|
|
38
|
+
def by_category(self) -> dict:
|
|
39
|
+
out = {}
|
|
40
|
+
for ref in self.files.values():
|
|
41
|
+
out.setdefault(ref.category, []).append(ref.path)
|
|
42
|
+
return {k: sorted(v) for k, v in out.items()}
|
|
43
|
+
|
|
44
|
+
def to_dict(self):
|
|
45
|
+
return {'files': {p: {'source': r.source, 'provider': r.provider, 'category': r.category}
|
|
46
|
+
for p, r in sorted(self.files.items())},
|
|
47
|
+
'unresolved': self.unresolved, 'external_unverified': self.external,
|
|
48
|
+
'by_category': self.by_category()}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def record_strings(fields) -> list[tuple[str, str]]:
|
|
52
|
+
"""(subrecord signature, text) for path-like printable runs in a record's subrecords."""
|
|
53
|
+
out = []
|
|
54
|
+
for f in fields:
|
|
55
|
+
for m in PRINTABLE.finditer(f.data):
|
|
56
|
+
text = m.group(0).decode('latin-1').strip()
|
|
57
|
+
if PATHY.search(text):
|
|
58
|
+
out.append((f.signature, text))
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def binary_strings(data: bytes) -> list[str]:
|
|
63
|
+
"""Length-prefixed (u32 or u16) printable strings, as NIF and similar formats store them."""
|
|
64
|
+
out, n = set(), len(data)
|
|
65
|
+
for i in range(n - 6):
|
|
66
|
+
for width, fmt in ((4, '<I'), (2, '<H')):
|
|
67
|
+
length = struct.unpack_from(fmt, data, i)[0]
|
|
68
|
+
if 4 <= length <= 260 and i + width + length <= n:
|
|
69
|
+
chunk = data[i + width:i + width + length]
|
|
70
|
+
if chunk.isascii() and all(32 <= b < 127 for b in chunk.rstrip(b'\0')):
|
|
71
|
+
text = chunk.rstrip(b'\0').decode('ascii')
|
|
72
|
+
if PATHY.search(text) or '\\' in text:
|
|
73
|
+
out.add(text)
|
|
74
|
+
break
|
|
75
|
+
return sorted(out)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def json_strings(data: bytes) -> list[str]:
|
|
79
|
+
try:
|
|
80
|
+
obj = json.loads(data)
|
|
81
|
+
except ValueError:
|
|
82
|
+
return [m.group(0).decode('latin-1') for m in PRINTABLE.finditer(data)]
|
|
83
|
+
out = []
|
|
84
|
+
|
|
85
|
+
def walk(o):
|
|
86
|
+
if isinstance(o, str):
|
|
87
|
+
out.append(o)
|
|
88
|
+
elif isinstance(o, dict):
|
|
89
|
+
for v in o.values():
|
|
90
|
+
walk(v)
|
|
91
|
+
elif isinstance(o, list):
|
|
92
|
+
for v in o:
|
|
93
|
+
walk(v)
|
|
94
|
+
walk(obj)
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def file_strings(path: str, data: bytes) -> list[str]:
|
|
99
|
+
if path.endswith(('.mat', '.json')):
|
|
100
|
+
return json_strings(data)
|
|
101
|
+
if path.endswith('.txt'):
|
|
102
|
+
return [line.strip() for line in data.decode('utf-8', 'replace').splitlines() if line.strip()]
|
|
103
|
+
return binary_strings(data)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class DependencyFinder:
|
|
107
|
+
def __init__(self, assets: AssetIndex, record_hooks: dict | None = None, source_plugin: str | None = None):
|
|
108
|
+
"""record_hooks: {signature: fn(view, assets, cache) -> [paths]} for structured references."""
|
|
109
|
+
self.assets = assets
|
|
110
|
+
self.hooks = record_hooks or {}
|
|
111
|
+
self.source_plugin = source_plugin
|
|
112
|
+
self._cache = {}
|
|
113
|
+
|
|
114
|
+
def _ref(self, path, source):
|
|
115
|
+
prov = self.assets.provider(path)
|
|
116
|
+
return AssetRef(path, source, str(prov), self.assets.classify(prov, self.source_plugin))
|
|
117
|
+
|
|
118
|
+
def _unresolved(self, closure, source, text):
|
|
119
|
+
if not re.search(r'\.(nif|mat|dds|wem|bnk|mesh|af|afx|rig|pex|swf)$', text, re.I):
|
|
120
|
+
return
|
|
121
|
+
if self.assets.rules.is_unverifiable(text):
|
|
122
|
+
closure.external.append((source, text))
|
|
123
|
+
else:
|
|
124
|
+
closure.unresolved.append((source, text))
|
|
125
|
+
|
|
126
|
+
def for_records(self, views, follow: bool = True) -> AssetClosure:
|
|
127
|
+
closure = AssetClosure()
|
|
128
|
+
queue = deque()
|
|
129
|
+
for view in views:
|
|
130
|
+
label = f'{view.signature} {view.form_key}'
|
|
131
|
+
found = set()
|
|
132
|
+
for sig, text in record_strings(view.node.fields):
|
|
133
|
+
resolved = self.assets.resolve(text)
|
|
134
|
+
if resolved:
|
|
135
|
+
found.add((resolved, f'{label}:{sig}'))
|
|
136
|
+
else:
|
|
137
|
+
self._unresolved(closure, f'{label}:{sig}', text)
|
|
138
|
+
hook = self.hooks.get(view.signature)
|
|
139
|
+
if hook:
|
|
140
|
+
for path in hook(view, self.assets, self._cache):
|
|
141
|
+
found.add((path, f'{label}:{view.signature}-hook'))
|
|
142
|
+
for path, source in sorted(found):
|
|
143
|
+
if path not in closure.files:
|
|
144
|
+
closure.files[path] = self._ref(path, source)
|
|
145
|
+
queue.append(path)
|
|
146
|
+
if follow:
|
|
147
|
+
self._follow(closure, queue)
|
|
148
|
+
return closure
|
|
149
|
+
|
|
150
|
+
def for_files(self, paths) -> AssetClosure:
|
|
151
|
+
closure = AssetClosure()
|
|
152
|
+
queue = deque()
|
|
153
|
+
for p in paths:
|
|
154
|
+
resolved = self.assets.resolve(p)
|
|
155
|
+
if resolved is None:
|
|
156
|
+
closure.unresolved.append(('request', p))
|
|
157
|
+
continue
|
|
158
|
+
closure.files[resolved] = self._ref(resolved, 'request')
|
|
159
|
+
queue.append(resolved)
|
|
160
|
+
self._follow(closure, queue)
|
|
161
|
+
return closure
|
|
162
|
+
|
|
163
|
+
def _follow(self, closure: AssetClosure, queue: deque):
|
|
164
|
+
while queue:
|
|
165
|
+
path = queue.popleft()
|
|
166
|
+
if not path.endswith(SCANNED_EXTENSIONS) or path.startswith(self.assets.rules.opaque_prefixes):
|
|
167
|
+
continue
|
|
168
|
+
try:
|
|
169
|
+
data = self.assets.read(path)
|
|
170
|
+
except FileNotFoundError:
|
|
171
|
+
continue
|
|
172
|
+
for text in file_strings(path, data):
|
|
173
|
+
resolved = self.assets.resolve(text)
|
|
174
|
+
if resolved is None:
|
|
175
|
+
self._unresolved(closure, path, text)
|
|
176
|
+
continue
|
|
177
|
+
if resolved != path and resolved not in closure.files:
|
|
178
|
+
closure.files[resolved] = self._ref(resolved, path)
|
|
179
|
+
queue.append(resolved)
|
pyfield/assets/index.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Asset index: which file (loose or in which archive) provides an asset path.
|
|
2
|
+
|
|
3
|
+
Precedence follows the game: archives in load order (base archives first, then each
|
|
4
|
+
plugin's `<plugin> - *.ba2`), later ones overriding earlier; loose files override archives.
|
|
5
|
+
Resolution of a referenced string is *verified*: a candidate path counts only if a provider
|
|
6
|
+
exists for it. Game knowledge (asset roots, default roots per extension, implicit
|
|
7
|
+
extensions, official plugin names) comes from an `AssetRules` object supplied by the adapter.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
import fnmatch
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .ba2 import Archive, normalize
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class AssetRules:
|
|
20
|
+
roots: tuple[str, ...] # top-level asset folders, lower case
|
|
21
|
+
extension_roots: dict = field(default_factory=dict) # '.nif' -> 'meshes/'
|
|
22
|
+
implicit: tuple = () # (root, extension) tried for extension-less names
|
|
23
|
+
base_archives: tuple[str, ...] = () # glob patterns of base-game archives
|
|
24
|
+
official_plugins: tuple[str, ...] = () # glob patterns (lower case) of first-party plugins
|
|
25
|
+
texture_extensions: tuple[str, ...] = ('.dds',)
|
|
26
|
+
unverifiable: tuple = () # (root, extension): may live in a compiled database
|
|
27
|
+
opaque_prefixes: tuple[str, ...] = () # files not scanned generically (handled by hooks)
|
|
28
|
+
|
|
29
|
+
def is_unverifiable(self, text: str) -> bool:
|
|
30
|
+
return any(c.startswith(root) and c.endswith(ext) for c in self.candidates(text)
|
|
31
|
+
for root, ext in self.unverifiable)
|
|
32
|
+
|
|
33
|
+
def candidates(self, text: str) -> list[str]:
|
|
34
|
+
path = normalize(text.strip().strip('\0'))
|
|
35
|
+
if not path:
|
|
36
|
+
return []
|
|
37
|
+
out = [path]
|
|
38
|
+
ext = Path(path).suffix.lower()
|
|
39
|
+
first = path.split('/', 1)[0]
|
|
40
|
+
if ext and first not in self.roots and ext in self.extension_roots:
|
|
41
|
+
out.append(self.extension_roots[ext] + path)
|
|
42
|
+
if not ext:
|
|
43
|
+
for root, implicit_ext in self.implicit:
|
|
44
|
+
out.append((path if first == root.rstrip('/') else root + path) + implicit_ext)
|
|
45
|
+
return list(dict.fromkeys(out))
|
|
46
|
+
|
|
47
|
+
def is_official(self, plugin_name: str) -> bool:
|
|
48
|
+
name = plugin_name.lower()
|
|
49
|
+
return any(fnmatch.fnmatchcase(name, pat) for pat in self.official_plugins)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Provider:
|
|
54
|
+
kind: str # 'loose' | 'archive'
|
|
55
|
+
path: Path # loose file or archive file
|
|
56
|
+
owner: str | None # plugin stem owning the archive (None for base archives / loose)
|
|
57
|
+
|
|
58
|
+
def __str__(self):
|
|
59
|
+
return f'{self.kind}:{self.path.name}'
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AssetIndex:
|
|
63
|
+
def __init__(self, data_dir, rules: AssetRules, plugins=(), include_loose: bool = True):
|
|
64
|
+
"""plugins: plugin file names in load order (their `<stem> - *.ba2` archives are indexed)."""
|
|
65
|
+
self.data_dir = Path(data_dir)
|
|
66
|
+
self.rules = rules
|
|
67
|
+
self.include_loose = include_loose
|
|
68
|
+
self.archives: list[Archive] = []
|
|
69
|
+
self._owner: dict[Path, str | None] = {}
|
|
70
|
+
self._entries: dict[str, Archive] = {}
|
|
71
|
+
files = sorted(p for p in self.data_dir.iterdir() if p.suffix.lower() == '.ba2') \
|
|
72
|
+
if self.data_dir.is_dir() else []
|
|
73
|
+
base = [p for p in files if any(fnmatch.fnmatchcase(p.name.lower(), g.lower()) for g in rules.base_archives)]
|
|
74
|
+
for p in base:
|
|
75
|
+
self._add(p, None)
|
|
76
|
+
for plugin in plugins:
|
|
77
|
+
stem = Path(plugin).stem.lower()
|
|
78
|
+
for p in files:
|
|
79
|
+
if p not in base and p.name.lower().startswith(stem + ' - '):
|
|
80
|
+
self._add(p, Path(plugin).stem)
|
|
81
|
+
|
|
82
|
+
def _add(self, path: Path, owner):
|
|
83
|
+
arc = Archive(path)
|
|
84
|
+
self.archives.append(arc)
|
|
85
|
+
self._owner[arc.path] = owner
|
|
86
|
+
for name in arc.members:
|
|
87
|
+
self._entries[name] = arc
|
|
88
|
+
|
|
89
|
+
# -- lookup -----------------------------------------------------------------------------
|
|
90
|
+
def _loose(self, name: str) -> Path | None:
|
|
91
|
+
if not self.include_loose:
|
|
92
|
+
return None
|
|
93
|
+
p = self.data_dir / name
|
|
94
|
+
return p if p.is_file() else None
|
|
95
|
+
|
|
96
|
+
def provider(self, name: str) -> Provider | None:
|
|
97
|
+
name = normalize(name)
|
|
98
|
+
loose = self._loose(name)
|
|
99
|
+
if loose is not None:
|
|
100
|
+
return Provider('loose', loose, None)
|
|
101
|
+
arc = self._entries.get(name)
|
|
102
|
+
if arc is not None:
|
|
103
|
+
return Provider('archive', arc.path, self._owner[arc.path])
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def exists(self, name: str) -> bool:
|
|
107
|
+
return self.provider(name) is not None
|
|
108
|
+
|
|
109
|
+
def resolve(self, text: str) -> str | None:
|
|
110
|
+
"""The asset path a referenced string denotes, if a provider exists for it."""
|
|
111
|
+
for cand in self.rules.candidates(text):
|
|
112
|
+
if self.exists(cand):
|
|
113
|
+
return cand
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def read(self, name: str) -> bytes:
|
|
117
|
+
name = normalize(name)
|
|
118
|
+
loose = self._loose(name)
|
|
119
|
+
if loose is not None:
|
|
120
|
+
return loose.read_bytes()
|
|
121
|
+
arc = self._entries.get(name)
|
|
122
|
+
if arc is None:
|
|
123
|
+
raise FileNotFoundError(name)
|
|
124
|
+
return arc.read(name)
|
|
125
|
+
|
|
126
|
+
def names(self, prefix: str = '') -> list[str]:
|
|
127
|
+
prefix = normalize(prefix)
|
|
128
|
+
return sorted(n for n in self._entries if n.startswith(prefix))
|
|
129
|
+
|
|
130
|
+
def classify(self, provider: Provider, source_plugin: str | None = None) -> str:
|
|
131
|
+
"""owned (by source_plugin) | vanilla | foreign | loose."""
|
|
132
|
+
if provider.kind == 'loose':
|
|
133
|
+
return 'loose'
|
|
134
|
+
if provider.owner is None:
|
|
135
|
+
return 'vanilla'
|
|
136
|
+
if source_plugin and provider.owner.lower() == Path(source_plugin).stem.lower():
|
|
137
|
+
return 'owned'
|
|
138
|
+
if self.rules.is_official(provider.owner + '.esm'):
|
|
139
|
+
return 'vanilla'
|
|
140
|
+
return 'foreign'
|