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.
Files changed (67) hide show
  1. pyfield/__init__.py +58 -0
  2. pyfield/assets/__init__.py +0 -0
  3. pyfield/assets/ba2.py +183 -0
  4. pyfield/assets/deps.py +179 -0
  5. pyfield/assets/index.py +140 -0
  6. pyfield/assets/pack.py +173 -0
  7. pyfield/capabilities.py +46 -0
  8. pyfield/cli.py +334 -0
  9. pyfield/core/__init__.py +9 -0
  10. pyfield/core/compression.py +37 -0
  11. pyfield/core/format.py +37 -0
  12. pyfield/core/formids.py +161 -0
  13. pyfield/core/groups.py +19 -0
  14. pyfield/core/header.py +40 -0
  15. pyfield/core/index.py +143 -0
  16. pyfield/core/loadorder.py +343 -0
  17. pyfield/core/nodes.py +317 -0
  18. pyfield/core/plugin.py +514 -0
  19. pyfield/core/subrecords.py +70 -0
  20. pyfield/diff.py +134 -0
  21. pyfield/edit.py +385 -0
  22. pyfield/errors.py +41 -0
  23. pyfield/games/__init__.py +78 -0
  24. pyfield/games/base.py +113 -0
  25. pyfield/games/oblivion/__init__.py +38 -0
  26. pyfield/games/starfield/__init__.py +124 -0
  27. pyfield/games/starfield/assets.py +74 -0
  28. pyfield/games/starfield/biomes.py +269 -0
  29. pyfield/games/starfield/btd.py +253 -0
  30. pyfield/games/starfield/data/subrecord_order.json +4956 -0
  31. pyfield/games/starfield/data/xedit_sf1_inventory.json +201504 -0
  32. pyfield/games/starfield/outpost.py +539 -0
  33. pyfield/games/starfield/overlay.py +460 -0
  34. pyfield/games/starfield/records.py +29 -0
  35. pyfield/games/starfield/save.py +199 -0
  36. pyfield/games/starfield/schema.py +250 -0
  37. pyfield/games/starfield/ships.py +244 -0
  38. pyfield/games/tes4family.py +195 -0
  39. pyfield/games/vmad.py +432 -0
  40. pyfield/landscape/__init__.py +2 -0
  41. pyfield/landscape/fit.py +493 -0
  42. pyfield/landscape/heightfield.py +183 -0
  43. pyfield/remap.py +166 -0
  44. pyfield/scene/__init__.py +8 -0
  45. pyfield/scene/capture.py +77 -0
  46. pyfield/scene/compile.py +342 -0
  47. pyfield/scene/diff.py +49 -0
  48. pyfield/scene/graph.py +191 -0
  49. pyfield/scene/runtime.py +216 -0
  50. pyfield/scene/spatial.py +86 -0
  51. pyfield/scene/transform.py +133 -0
  52. pyfield/schema/codecs.py +469 -0
  53. pyfield/schema/records.py +395 -0
  54. pyfield/testing/__init__.py +0 -0
  55. pyfield/testing/fixtures.py +68 -0
  56. pyfield/testing/roundtrip.py +106 -0
  57. pyfield/testing/toy_adapter.py +95 -0
  58. pyfield/transplant.py +376 -0
  59. pyfield/validate.py +213 -0
  60. pyfield_esm-0.3.0.dist-info/METADATA +122 -0
  61. pyfield_esm-0.3.0.dist-info/RECORD +67 -0
  62. pyfield_esm-0.3.0.dist-info/WHEEL +5 -0
  63. pyfield_esm-0.3.0.dist-info/entry_points.txt +2 -0
  64. pyfield_esm-0.3.0.dist-info/licenses/LICENSE +21 -0
  65. pyfield_esm-0.3.0.dist-info/licenses/LICENSES/MPL-2.0.txt +373 -0
  66. pyfield_esm-0.3.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +22 -0
  67. 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)
@@ -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'