mobile-textdb 0.1.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.
- mobile_textdb/__init__.py +66 -0
- mobile_textdb/__main__.py +6 -0
- mobile_textdb/cli.py +106 -0
- mobile_textdb/codec.py +191 -0
- mobile_textdb/database.py +160 -0
- mobile_textdb/models.py +84 -0
- mobile_textdb/operations.py +101 -0
- mobile_textdb/toc.py +51 -0
- mobile_textdb/txd.py +113 -0
- mobile_textdb/txt.py +87 -0
- mobile_textdb-0.1.0.dist-info/METADATA +17 -0
- mobile_textdb-0.1.0.dist-info/RECORD +15 -0
- mobile_textdb-0.1.0.dist-info/WHEEL +5 -0
- mobile_textdb-0.1.0.dist-info/entry_points.txt +2 -0
- mobile_textdb-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GTA SA / VC Mobile textdb library (DXT / ETC / PVR).
|
|
3
|
+
|
|
4
|
+
The game reads ``*.txt``, ``*.dat`` and ``*.toc``. Thumbnail ``*.tmb`` files are
|
|
5
|
+
optional preview caches and are **not** modified by this library.
|
|
6
|
+
|
|
7
|
+
Quick start::
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from PIL import Image
|
|
11
|
+
from mobile_textdb import TextureDatabase
|
|
12
|
+
|
|
13
|
+
db = TextureDatabase.open(Path("textdb/gta3"))
|
|
14
|
+
db.replace("radar00", Image.open("radar00.png"))
|
|
15
|
+
db.save()
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from mobile_textdb.codec import (
|
|
19
|
+
ENCODING_DXT1,
|
|
20
|
+
ENCODING_DXT5,
|
|
21
|
+
RLE_SEGMENT_SIZE,
|
|
22
|
+
build_texture_block,
|
|
23
|
+
crc_hex,
|
|
24
|
+
decode_block_pixels,
|
|
25
|
+
export_texture_png,
|
|
26
|
+
hash_name,
|
|
27
|
+
read_dat_block,
|
|
28
|
+
rle_compress,
|
|
29
|
+
rle_decompress,
|
|
30
|
+
)
|
|
31
|
+
from mobile_textdb.database import TextureDatabase
|
|
32
|
+
from mobile_textdb.models import (
|
|
33
|
+
AffiliateEntry,
|
|
34
|
+
CatEntry,
|
|
35
|
+
EntryKind,
|
|
36
|
+
TextureBlock,
|
|
37
|
+
TextureEntry,
|
|
38
|
+
)
|
|
39
|
+
from mobile_textdb.operations import add_texture_from_image, reassemble_dat
|
|
40
|
+
from mobile_textdb.txd import TxdTexture, load_txd
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"AffiliateEntry",
|
|
44
|
+
"CatEntry",
|
|
45
|
+
"ENCODING_DXT1",
|
|
46
|
+
"ENCODING_DXT5",
|
|
47
|
+
"EntryKind",
|
|
48
|
+
"RLE_SEGMENT_SIZE",
|
|
49
|
+
"TextureBlock",
|
|
50
|
+
"TextureDatabase",
|
|
51
|
+
"TextureEntry",
|
|
52
|
+
"TxdTexture",
|
|
53
|
+
"add_texture_from_image",
|
|
54
|
+
"build_texture_block",
|
|
55
|
+
"crc_hex",
|
|
56
|
+
"decode_block_pixels",
|
|
57
|
+
"export_texture_png",
|
|
58
|
+
"hash_name",
|
|
59
|
+
"load_txd",
|
|
60
|
+
"read_dat_block",
|
|
61
|
+
"reassemble_dat",
|
|
62
|
+
"rle_compress",
|
|
63
|
+
"rle_decompress",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
__version__ = "0.1.0"
|
mobile_textdb/cli.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CLI for mobile-textdb."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
from mobile_textdb import TextureDatabase, add_texture_from_image, load_txd
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load_png(path: Path) -> tuple[str, Image.Image]:
|
|
16
|
+
return path.stem, Image.open(path).convert("RGBA")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _collect_pngs(png_dir: Path, name_prefix: str | None) -> list[tuple[str, Path]]:
|
|
20
|
+
out: list[tuple[str, Path]] = []
|
|
21
|
+
for path in sorted(png_dir.glob("*.png")):
|
|
22
|
+
if name_prefix and not path.stem.startswith(name_prefix):
|
|
23
|
+
continue
|
|
24
|
+
out.append((path.stem, path))
|
|
25
|
+
return out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main(argv: list[str] | None = None) -> int:
|
|
29
|
+
parser = argparse.ArgumentParser(description="GTA Mobile textdb texture tool")
|
|
30
|
+
parser.add_argument("--folder", type=Path, default=Path.cwd(), help="textdb pack folder")
|
|
31
|
+
parser.add_argument("--format", default="dxt", help="cache extension: dxt, etc, pvr")
|
|
32
|
+
parser.add_argument("--replace", action="store_true")
|
|
33
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
34
|
+
parser.add_argument("--png", type=Path, action="append", default=[])
|
|
35
|
+
parser.add_argument("--png-dir", type=Path)
|
|
36
|
+
parser.add_argument("--name-prefix")
|
|
37
|
+
parser.add_argument("--txd", type=Path, action="append", default=[])
|
|
38
|
+
parser.add_argument("--alphamode", type=int)
|
|
39
|
+
args = parser.parse_args(argv)
|
|
40
|
+
|
|
41
|
+
folder = args.folder.resolve()
|
|
42
|
+
db = TextureDatabase.open(folder, format_ext=args.format)
|
|
43
|
+
|
|
44
|
+
jobs: list[tuple[str, Image.Image]] = []
|
|
45
|
+
for png_path in args.png:
|
|
46
|
+
jobs.append(_load_png(png_path.resolve()))
|
|
47
|
+
if args.png_dir:
|
|
48
|
+
for name, path in _collect_pngs(args.png_dir.resolve(), args.name_prefix):
|
|
49
|
+
jobs.append((name, Image.open(path).convert("RGBA")))
|
|
50
|
+
for txd_path in args.txd:
|
|
51
|
+
for tex in load_txd(txd_path.resolve()):
|
|
52
|
+
jobs.append((tex.name, tex.image))
|
|
53
|
+
|
|
54
|
+
if not jobs:
|
|
55
|
+
parser.error("No textures. Use --png, --png-dir or --txd.")
|
|
56
|
+
|
|
57
|
+
added = replaced = skipped = 0
|
|
58
|
+
for name, img in jobs:
|
|
59
|
+
try:
|
|
60
|
+
extra: dict[str, int] = {}
|
|
61
|
+
if args.alphamode is not None:
|
|
62
|
+
extra["alphamode"] = args.alphamode
|
|
63
|
+
elif args.replace and (old := db.get(name)) and "alphamode" in old.props:
|
|
64
|
+
extra["alphamode"] = int(old.props["alphamode"])
|
|
65
|
+
|
|
66
|
+
existed = db.get(name) is not None
|
|
67
|
+
if args.dry_run:
|
|
68
|
+
action = "replace" if existed and args.replace else "add"
|
|
69
|
+
print(f" PLAN {action}: {name} ({img.width}x{img.height})")
|
|
70
|
+
if action == "replace":
|
|
71
|
+
replaced += 1
|
|
72
|
+
else:
|
|
73
|
+
added += 1
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
add_texture_from_image(
|
|
77
|
+
db,
|
|
78
|
+
name,
|
|
79
|
+
img,
|
|
80
|
+
replace=args.replace,
|
|
81
|
+
extra_props=extra or None,
|
|
82
|
+
)
|
|
83
|
+
if existed and args.replace:
|
|
84
|
+
replaced += 1
|
|
85
|
+
else:
|
|
86
|
+
added += 1
|
|
87
|
+
print(f" OK {'replace' if existed and args.replace else 'add'}: {name}")
|
|
88
|
+
except (ValueError, OSError) as exc:
|
|
89
|
+
skipped += 1
|
|
90
|
+
print(f" SKIP {name}: {exc}", file=sys.stderr)
|
|
91
|
+
|
|
92
|
+
if args.dry_run:
|
|
93
|
+
print(f"Dry run: add={added} replace={replaced} skip={skipped}")
|
|
94
|
+
return 0
|
|
95
|
+
if added == 0 and replaced == 0:
|
|
96
|
+
print("Nothing to write.")
|
|
97
|
+
return 1
|
|
98
|
+
|
|
99
|
+
db.save()
|
|
100
|
+
print(f"Done. add={added} replace={replaced} skip={skipped}")
|
|
101
|
+
print(f"Wrote {db.paths.txt.name}, {db.paths.dat.name}, {db.paths.toc.name}")
|
|
102
|
+
return 0 if skipped == 0 else 1
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
raise SystemExit(main())
|
mobile_textdb/codec.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
import zlib
|
|
5
|
+
|
|
6
|
+
from PIL import Image
|
|
7
|
+
import texture2ddecoder as t2d
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from texfury import BCFormat, Texture
|
|
11
|
+
except ImportError:
|
|
12
|
+
Texture = None # type: ignore
|
|
13
|
+
BCFormat = None # type: ignore
|
|
14
|
+
|
|
15
|
+
from mobile_textdb.models import TextureBlock
|
|
16
|
+
|
|
17
|
+
RLE_SEGMENT_SIZE = 8
|
|
18
|
+
ENCODING_DXT1 = 0x83F0
|
|
19
|
+
ENCODING_DXT5 = 0x83F3
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def hash_name(name: str) -> int:
|
|
23
|
+
h = 0
|
|
24
|
+
for b in name.encode("ascii", errors="replace"):
|
|
25
|
+
h = (h + ((h << 5) + b)) & 0xFFFFFFFF
|
|
26
|
+
h = (h + (h >> 5)) & 0xFFFFFFFF
|
|
27
|
+
return h & 0xFFFF
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def crc_hex(data: bytes) -> str:
|
|
31
|
+
return f"{zlib.crc32(data) & 0xFFFFFFFF:08x}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def rle_decompress(data: bytes, segment_size: int, indicator: int) -> bytes:
|
|
35
|
+
out = bytearray()
|
|
36
|
+
i = 0
|
|
37
|
+
while i < len(data):
|
|
38
|
+
b = data[i]
|
|
39
|
+
i += 1
|
|
40
|
+
if b == indicator:
|
|
41
|
+
rep = data[i]
|
|
42
|
+
i += 1
|
|
43
|
+
seg = data[i : i + segment_size]
|
|
44
|
+
i += segment_size
|
|
45
|
+
out.extend(seg * rep)
|
|
46
|
+
else:
|
|
47
|
+
seg = bytes([b]) + data[i : i + segment_size - 1]
|
|
48
|
+
i += segment_size - 1
|
|
49
|
+
out.extend(seg)
|
|
50
|
+
return bytes(out)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def rle_compress(raw: bytes, segment_size: int = RLE_SEGMENT_SIZE) -> tuple[bytes, int]:
|
|
54
|
+
if len(raw) < segment_size * 4:
|
|
55
|
+
return raw, 0
|
|
56
|
+
|
|
57
|
+
for indicator in (0xFD, 0xFE, 0xFC, 0x01, 0x02, 0x03):
|
|
58
|
+
if indicator in raw:
|
|
59
|
+
continue
|
|
60
|
+
out = bytearray()
|
|
61
|
+
i = 0
|
|
62
|
+
while i < len(raw):
|
|
63
|
+
seg = raw[i : i + segment_size]
|
|
64
|
+
if len(seg) < segment_size:
|
|
65
|
+
out.extend(seg)
|
|
66
|
+
break
|
|
67
|
+
best_rep = 1
|
|
68
|
+
while (
|
|
69
|
+
i + best_rep * segment_size <= len(raw)
|
|
70
|
+
and raw[i : i + segment_size]
|
|
71
|
+
== raw[i + best_rep * segment_size : i + (best_rep + 1) * segment_size]
|
|
72
|
+
):
|
|
73
|
+
best_rep += 1
|
|
74
|
+
if best_rep >= 3:
|
|
75
|
+
out.append(indicator)
|
|
76
|
+
out.append(best_rep)
|
|
77
|
+
out.extend(seg)
|
|
78
|
+
i += best_rep * segment_size
|
|
79
|
+
else:
|
|
80
|
+
out.append(seg[0])
|
|
81
|
+
out.extend(seg[1:])
|
|
82
|
+
i += segment_size
|
|
83
|
+
compressed = bytes(out)
|
|
84
|
+
if len(compressed) < len(raw):
|
|
85
|
+
return compressed, indicator
|
|
86
|
+
return raw, 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _next_power_of_two(n: int) -> int:
|
|
90
|
+
p = 1
|
|
91
|
+
while p < n:
|
|
92
|
+
p <<= 1
|
|
93
|
+
return p
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def resize_to_po2(img: Image.Image, max_size: int | None = None) -> Image.Image:
|
|
97
|
+
w, h = img.size
|
|
98
|
+
if max_size:
|
|
99
|
+
scale = min(1.0, max_size / max(w, h))
|
|
100
|
+
if scale < 1.0:
|
|
101
|
+
w = max(1, int(w * scale))
|
|
102
|
+
h = max(1, int(h * scale))
|
|
103
|
+
img = img.resize((w, h), Image.Resampling.LANCZOS)
|
|
104
|
+
w, h = img.size
|
|
105
|
+
tw, th = _next_power_of_two(w), _next_power_of_two(h)
|
|
106
|
+
if (tw, th) != (w, h):
|
|
107
|
+
img = img.resize((tw, th), Image.Resampling.LANCZOS)
|
|
108
|
+
return img.convert("RGBA")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _compress_dxt(img: Image.Image, use_alpha: bool) -> bytes:
|
|
112
|
+
if Texture is None:
|
|
113
|
+
raise RuntimeError("texfury is required: pip install mobile-textdb")
|
|
114
|
+
fmt = BCFormat.BC3 if use_alpha else BCFormat.BC1
|
|
115
|
+
return Texture.from_pil(img, format=fmt, generate_mipmaps=False).data
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _decode_dxt(encoding: int, width: int, height: int, raw: bytes) -> Image.Image:
|
|
119
|
+
use_alpha = (encoding & 0xFF) >= 0xF3
|
|
120
|
+
blocks_w = max(1, width // 4)
|
|
121
|
+
blocks_h = max(1, height // 4)
|
|
122
|
+
bpb = 16 if use_alpha else 8
|
|
123
|
+
need = blocks_w * blocks_h * bpb
|
|
124
|
+
data = raw[:need]
|
|
125
|
+
px = t2d.decode_bc3(data, width, height) if use_alpha else t2d.decode_bc1(data, width, height)
|
|
126
|
+
return Image.frombytes("RGBA", (width, height), px, "raw", "RGBA")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def has_visible_alpha(img: Image.Image) -> bool:
|
|
130
|
+
if img.mode != "RGBA":
|
|
131
|
+
return False
|
|
132
|
+
return img.getchannel("A").getextrema()[0] < 255
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def build_texture_block(img: Image.Image, name: str, use_rle: bool = True) -> TextureBlock:
|
|
136
|
+
use_alpha = has_visible_alpha(img)
|
|
137
|
+
raw = _compress_dxt(img, use_alpha)
|
|
138
|
+
payload, rle_ind = rle_compress(raw) if use_rle else (raw, 0)
|
|
139
|
+
return TextureBlock(
|
|
140
|
+
hash=hash_name(name),
|
|
141
|
+
encoding=ENCODING_DXT5 if use_alpha else ENCODING_DXT1,
|
|
142
|
+
width=img.width,
|
|
143
|
+
height=img.height,
|
|
144
|
+
no_mip=True,
|
|
145
|
+
payload=payload,
|
|
146
|
+
rle_indicator=rle_ind,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def read_dat_block(dat: bytes, offset: int) -> TextureBlock:
|
|
151
|
+
if offset < 0:
|
|
152
|
+
raise ValueError("affiliate texture has no DAT block")
|
|
153
|
+
nh, enc, w, hm = struct.unpack_from("<HHHH", dat, offset)
|
|
154
|
+
cs, rle = struct.unpack_from("<II", dat, offset + 8)
|
|
155
|
+
height = hm & 0x7FFF
|
|
156
|
+
payload = dat[offset + 16 : offset + 16 + cs]
|
|
157
|
+
return TextureBlock(
|
|
158
|
+
hash=nh,
|
|
159
|
+
encoding=enc,
|
|
160
|
+
width=w,
|
|
161
|
+
height=height,
|
|
162
|
+
no_mip=bool(hm & 0x8000),
|
|
163
|
+
payload=payload,
|
|
164
|
+
rle_indicator=rle,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def decode_block_pixels(block: TextureBlock) -> bytes:
|
|
169
|
+
raw = block.payload
|
|
170
|
+
if block.rle_indicator:
|
|
171
|
+
raw = rle_decompress(raw, RLE_SEGMENT_SIZE, block.rle_indicator & 0xFF)
|
|
172
|
+
return raw
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def export_texture_png(block: TextureBlock) -> Image.Image:
|
|
176
|
+
raw = decode_block_pixels(block)
|
|
177
|
+
return _decode_dxt(block.encoding, block.width, block.height, raw)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def pack_dat_blocks(blocks: list[TextureBlock]) -> bytes:
|
|
181
|
+
out = bytearray()
|
|
182
|
+
for i, block in enumerate(blocks):
|
|
183
|
+
blob = block.to_bytes()
|
|
184
|
+
if i == 0:
|
|
185
|
+
out.extend(blob)
|
|
186
|
+
else:
|
|
187
|
+
if len(out) < 4:
|
|
188
|
+
raise ValueError("invalid DAT layout while packing")
|
|
189
|
+
out[-4:] = blob[:4]
|
|
190
|
+
out.extend(blob[4:])
|
|
191
|
+
return bytes(out)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Iterator
|
|
5
|
+
|
|
6
|
+
from PIL import Image
|
|
7
|
+
|
|
8
|
+
from mobile_textdb.codec import export_texture_png, read_dat_block
|
|
9
|
+
from mobile_textdb.models import (
|
|
10
|
+
AffiliateEntry,
|
|
11
|
+
CatEntry,
|
|
12
|
+
TextureBlock,
|
|
13
|
+
TextureDatabasePaths,
|
|
14
|
+
TextureEntry,
|
|
15
|
+
)
|
|
16
|
+
from mobile_textdb.operations import add_texture_from_image, iter_texture_blocks
|
|
17
|
+
from mobile_textdb.toc import dat_offset_for_entry_index, load_toc, rebuild_toc_from_dat, write_toc
|
|
18
|
+
from mobile_textdb.txt import load_txt, save_txt
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TextureDatabase:
|
|
22
|
+
"""Read/write a GTA Mobile texture pack (``*.txt`` + ``*.dat`` + ``*.toc``).
|
|
23
|
+
|
|
24
|
+
Thumbnail ``*.tmb`` files are ignored — the game does not require them.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
paths: TextureDatabasePaths,
|
|
30
|
+
entries: list[CatEntry | TextureEntry | AffiliateEntry],
|
|
31
|
+
dat: bytearray,
|
|
32
|
+
toc_offsets: list[int],
|
|
33
|
+
) -> None:
|
|
34
|
+
self.paths = paths
|
|
35
|
+
self.entries = entries
|
|
36
|
+
self.dat = dat
|
|
37
|
+
self.toc_offsets = toc_offsets
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def name(self) -> str:
|
|
41
|
+
return self.paths.name
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def format_ext(self) -> str:
|
|
45
|
+
return self.paths.format_ext
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def folder(self) -> Path:
|
|
49
|
+
return self.paths.folder
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def open(
|
|
53
|
+
cls,
|
|
54
|
+
folder: str | Path,
|
|
55
|
+
name: str | None = None,
|
|
56
|
+
*,
|
|
57
|
+
format_ext: str = "dxt",
|
|
58
|
+
) -> TextureDatabase:
|
|
59
|
+
folder = Path(folder)
|
|
60
|
+
pack_name = name or folder.name
|
|
61
|
+
paths = TextureDatabasePaths(folder=folder, name=pack_name, format_ext=format_ext)
|
|
62
|
+
|
|
63
|
+
if not paths.txt.is_file():
|
|
64
|
+
raise FileNotFoundError(paths.txt)
|
|
65
|
+
if not paths.dat.is_file():
|
|
66
|
+
raise FileNotFoundError(paths.dat)
|
|
67
|
+
if not paths.toc.is_file():
|
|
68
|
+
raise FileNotFoundError(paths.toc)
|
|
69
|
+
|
|
70
|
+
dat = bytearray(paths.dat.read_bytes())
|
|
71
|
+
entries = load_txt(paths.txt)
|
|
72
|
+
toc_offsets = load_toc(paths.toc, len(dat))
|
|
73
|
+
if not toc_offsets:
|
|
74
|
+
toc_offsets = rebuild_toc_from_dat(dat, entries)
|
|
75
|
+
|
|
76
|
+
return cls(paths=paths, entries=entries, dat=dat, toc_offsets=toc_offsets)
|
|
77
|
+
|
|
78
|
+
def save(self) -> None:
|
|
79
|
+
"""Write ``*.txt``, ``*.dat`` and ``*.toc`` (never touches ``*.tmb``)."""
|
|
80
|
+
save_txt(self.paths.txt, self.entries)
|
|
81
|
+
self.paths.dat.write_bytes(self.dat)
|
|
82
|
+
write_toc(self.paths.toc, len(self.dat), self.toc_offsets)
|
|
83
|
+
|
|
84
|
+
def list_textures(self) -> list[str]:
|
|
85
|
+
return [e.name for e in self.entries if isinstance(e, TextureEntry)]
|
|
86
|
+
|
|
87
|
+
def get(self, name: str) -> TextureEntry | None:
|
|
88
|
+
for entry in self.entries:
|
|
89
|
+
if isinstance(entry, TextureEntry) and entry.name == name:
|
|
90
|
+
return entry
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
def index_of(self, name: str) -> int:
|
|
94
|
+
for i, entry in enumerate(self.entries):
|
|
95
|
+
if isinstance(entry, TextureEntry) and entry.name == name:
|
|
96
|
+
return i
|
|
97
|
+
raise KeyError(name)
|
|
98
|
+
|
|
99
|
+
def read_block(self, name: str) -> TextureBlock:
|
|
100
|
+
idx = self.index_of(name)
|
|
101
|
+
off = dat_offset_for_entry_index(idx, self.toc_offsets)
|
|
102
|
+
return read_dat_block(self.dat, off)
|
|
103
|
+
|
|
104
|
+
def export_image(self, name: str) -> Image.Image:
|
|
105
|
+
return export_texture_png(self.read_block(name))
|
|
106
|
+
|
|
107
|
+
def iter_blocks(self) -> Iterator[tuple[TextureEntry, TextureBlock]]:
|
|
108
|
+
return iter_texture_blocks(self.dat, self.entries, self.toc_offsets)
|
|
109
|
+
|
|
110
|
+
def add(
|
|
111
|
+
self,
|
|
112
|
+
name: str,
|
|
113
|
+
image: Image.Image,
|
|
114
|
+
*,
|
|
115
|
+
alphamode: int | None = None,
|
|
116
|
+
extra_props: dict[str, str | int] | None = None,
|
|
117
|
+
use_rle: bool = True,
|
|
118
|
+
max_size: int | None = None,
|
|
119
|
+
) -> TextureEntry:
|
|
120
|
+
return add_texture_from_image(
|
|
121
|
+
self,
|
|
122
|
+
name,
|
|
123
|
+
image,
|
|
124
|
+
replace=False,
|
|
125
|
+
alphamode=alphamode,
|
|
126
|
+
extra_props=extra_props,
|
|
127
|
+
use_rle=use_rle,
|
|
128
|
+
max_size=max_size,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def replace(
|
|
132
|
+
self,
|
|
133
|
+
name: str,
|
|
134
|
+
image: Image.Image,
|
|
135
|
+
*,
|
|
136
|
+
alphamode: int | None = None,
|
|
137
|
+
extra_props: dict[str, str | int] | None = None,
|
|
138
|
+
use_rle: bool = True,
|
|
139
|
+
max_size: int | None = None,
|
|
140
|
+
) -> TextureEntry:
|
|
141
|
+
return add_texture_from_image(
|
|
142
|
+
self,
|
|
143
|
+
name,
|
|
144
|
+
image,
|
|
145
|
+
replace=True,
|
|
146
|
+
alphamode=alphamode,
|
|
147
|
+
extra_props=extra_props,
|
|
148
|
+
use_rle=use_rle,
|
|
149
|
+
max_size=max_size,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def add_or_replace(
|
|
153
|
+
self,
|
|
154
|
+
name: str,
|
|
155
|
+
image: Image.Image,
|
|
156
|
+
*,
|
|
157
|
+
replace: bool = False,
|
|
158
|
+
**kwargs,
|
|
159
|
+
) -> TextureEntry:
|
|
160
|
+
return add_texture_from_image(self, name, image, replace=replace, **kwargs)
|
mobile_textdb/models.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EntryKind(Enum):
|
|
9
|
+
CAT = "cat"
|
|
10
|
+
TEXTURE = "texture"
|
|
11
|
+
AFFILIATE = "affiliate"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class CatEntry:
|
|
16
|
+
kind: EntryKind = EntryKind.CAT
|
|
17
|
+
line: str = ""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class AffiliateEntry:
|
|
22
|
+
kind: EntryKind = EntryKind.AFFILIATE
|
|
23
|
+
name: str = ""
|
|
24
|
+
affiliate: str = ""
|
|
25
|
+
raw_line: str = ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class TextureEntry:
|
|
30
|
+
kind: EntryKind = EntryKind.TEXTURE
|
|
31
|
+
name: str = ""
|
|
32
|
+
width: int = 0
|
|
33
|
+
height: int = 0
|
|
34
|
+
props: dict[str, str | int] = field(default_factory=dict)
|
|
35
|
+
raw_line: str = ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class TextureBlock:
|
|
40
|
+
hash: int
|
|
41
|
+
encoding: int
|
|
42
|
+
width: int
|
|
43
|
+
height: int
|
|
44
|
+
no_mip: bool
|
|
45
|
+
payload: bytes
|
|
46
|
+
rle_indicator: int = 0
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def height_mask(self) -> int:
|
|
50
|
+
h = self.height & 0x7FFF
|
|
51
|
+
return h | (0x8000 if self.no_mip else 0)
|
|
52
|
+
|
|
53
|
+
def to_bytes(self) -> bytes:
|
|
54
|
+
import struct
|
|
55
|
+
|
|
56
|
+
header = struct.pack(
|
|
57
|
+
"<HHHHII",
|
|
58
|
+
self.hash & 0xFFFF,
|
|
59
|
+
self.encoding,
|
|
60
|
+
self.width,
|
|
61
|
+
self.height_mask,
|
|
62
|
+
len(self.payload),
|
|
63
|
+
self.rle_indicator,
|
|
64
|
+
)
|
|
65
|
+
return header + self.payload
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class TextureDatabasePaths:
|
|
70
|
+
folder: Path
|
|
71
|
+
name: str
|
|
72
|
+
format_ext: str
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def txt(self) -> Path:
|
|
76
|
+
return self.folder / f"{self.name}.txt"
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def dat(self) -> Path:
|
|
80
|
+
return self.folder / f"{self.name}.{self.format_ext}.dat"
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def toc(self) -> Path:
|
|
84
|
+
return self.folder / f"{self.name}.{self.format_ext}.toc"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Iterator
|
|
4
|
+
|
|
5
|
+
from PIL import Image
|
|
6
|
+
|
|
7
|
+
from mobile_textdb.codec import (
|
|
8
|
+
build_texture_block,
|
|
9
|
+
crc_hex,
|
|
10
|
+
decode_block_pixels,
|
|
11
|
+
export_texture_png,
|
|
12
|
+
pack_dat_blocks,
|
|
13
|
+
read_dat_block,
|
|
14
|
+
resize_to_po2,
|
|
15
|
+
has_visible_alpha,
|
|
16
|
+
)
|
|
17
|
+
from mobile_textdb.models import TextureBlock, TextureEntry
|
|
18
|
+
from mobile_textdb.toc import dat_offset_for_entry_index, rebuild_toc_from_dat, write_toc
|
|
19
|
+
from mobile_textdb.txt import format_texture_line
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def iter_texture_blocks(
|
|
23
|
+
dat: bytes,
|
|
24
|
+
entries: list,
|
|
25
|
+
toc_offsets: list[int],
|
|
26
|
+
) -> Iterator[tuple[TextureEntry, TextureBlock]]:
|
|
27
|
+
for i, entry in enumerate(entries):
|
|
28
|
+
if not isinstance(entry, TextureEntry):
|
|
29
|
+
continue
|
|
30
|
+
off = dat_offset_for_entry_index(i, toc_offsets)
|
|
31
|
+
if off < 0:
|
|
32
|
+
continue
|
|
33
|
+
yield entry, read_dat_block(dat, off)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def reassemble_dat(db, new_blocks: dict[str, TextureBlock]) -> None:
|
|
37
|
+
dat_blocks: list[TextureBlock] = []
|
|
38
|
+
for entry, block in iter_texture_blocks(db.dat, db.entries, db.toc_offsets):
|
|
39
|
+
dat_blocks.append(new_blocks.get(entry.name, block))
|
|
40
|
+
db.dat = bytearray(pack_dat_blocks(dat_blocks))
|
|
41
|
+
db.toc_offsets = rebuild_toc_from_dat(db.dat, db.entries)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def add_texture_from_image(
|
|
45
|
+
db,
|
|
46
|
+
name: str,
|
|
47
|
+
image: Image.Image,
|
|
48
|
+
*,
|
|
49
|
+
replace: bool = False,
|
|
50
|
+
alphamode: int | None = None,
|
|
51
|
+
extra_props: dict[str, str | int] | None = None,
|
|
52
|
+
use_rle: bool = True,
|
|
53
|
+
max_size: int | None = None,
|
|
54
|
+
) -> TextureEntry:
|
|
55
|
+
image = resize_to_po2(image, max_size)
|
|
56
|
+
block = build_texture_block(image, name, use_rle=use_rle)
|
|
57
|
+
img_hash = crc_hex(decode_block_pixels(block))
|
|
58
|
+
|
|
59
|
+
existing = db.get(name)
|
|
60
|
+
if existing and not replace:
|
|
61
|
+
raise ValueError(f"Texture {name!r} already exists (use replace=True)")
|
|
62
|
+
|
|
63
|
+
props: dict[str, str | int] = dict(extra_props or {})
|
|
64
|
+
if alphamode is not None:
|
|
65
|
+
props["alphamode"] = alphamode
|
|
66
|
+
elif has_visible_alpha(image):
|
|
67
|
+
props.setdefault("alphamode", 2)
|
|
68
|
+
|
|
69
|
+
entry = TextureEntry(
|
|
70
|
+
name=name,
|
|
71
|
+
width=image.width,
|
|
72
|
+
height=image.height,
|
|
73
|
+
props=props,
|
|
74
|
+
raw_line="",
|
|
75
|
+
)
|
|
76
|
+
entry.raw_line = format_texture_line(entry, None, img_hash)
|
|
77
|
+
|
|
78
|
+
if existing and replace:
|
|
79
|
+
idx = db.index_of(name)
|
|
80
|
+
off = dat_offset_for_entry_index(idx, db.toc_offsets)
|
|
81
|
+
old = read_dat_block(db.dat, off)
|
|
82
|
+
if len(block.payload) < len(old.payload):
|
|
83
|
+
block.payload += b"\x00" * (len(old.payload) - len(block.payload))
|
|
84
|
+
elif len(block.payload) > len(old.payload):
|
|
85
|
+
reassemble_dat(db, {name: block})
|
|
86
|
+
db.entries[idx] = entry
|
|
87
|
+
return entry
|
|
88
|
+
new_bytes = block.to_bytes()
|
|
89
|
+
if len(new_bytes) != len(old.to_bytes()):
|
|
90
|
+
reassemble_dat(db, {name: block})
|
|
91
|
+
db.entries[idx] = entry
|
|
92
|
+
return entry
|
|
93
|
+
db.dat[off : off + len(new_bytes)] = new_bytes
|
|
94
|
+
db.entries[idx] = entry
|
|
95
|
+
return entry
|
|
96
|
+
|
|
97
|
+
new_offset = len(db.dat)
|
|
98
|
+
db.dat.extend(block.to_bytes())
|
|
99
|
+
db.entries.append(entry)
|
|
100
|
+
db.toc_offsets.append(new_offset)
|
|
101
|
+
return entry
|
mobile_textdb/toc.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from mobile_textdb.codec import read_dat_block
|
|
7
|
+
from mobile_textdb.models import AffiliateEntry, CatEntry, TextureEntry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_toc(path: Path, dat_size: int) -> list[int]:
|
|
11
|
+
raw = path.read_bytes()
|
|
12
|
+
stated = struct.unpack_from("<I", raw, 0)[0]
|
|
13
|
+
offsets = list(struct.unpack_from("<" + "i" * ((len(raw) - 4) // 4), raw, 4))
|
|
14
|
+
if stated != dat_size:
|
|
15
|
+
return []
|
|
16
|
+
return offsets
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def dat_offset_for_entry_index(entry_index: int, offsets: list[int]) -> int:
|
|
20
|
+
if entry_index <= 0:
|
|
21
|
+
return 0
|
|
22
|
+
return offsets[entry_index - 1]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def write_toc(path: Path, dat_size: int, offsets: list[int]) -> None:
|
|
26
|
+
header = struct.pack("<I", dat_size)
|
|
27
|
+
body = struct.pack("<" + "i" * len(offsets), *offsets)
|
|
28
|
+
path.write_bytes(header + body)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def rebuild_toc_from_dat(
|
|
32
|
+
dat: bytes,
|
|
33
|
+
entries: list[CatEntry | TextureEntry | AffiliateEntry],
|
|
34
|
+
) -> list[int]:
|
|
35
|
+
new_offsets: list[int] = []
|
|
36
|
+
pos = 0
|
|
37
|
+
for i, entry in enumerate(entries):
|
|
38
|
+
if i == 0:
|
|
39
|
+
continue
|
|
40
|
+
if isinstance(entry, AffiliateEntry):
|
|
41
|
+
new_offsets.append(-1)
|
|
42
|
+
elif isinstance(entry, TextureEntry):
|
|
43
|
+
new_offsets.append(pos)
|
|
44
|
+
block = read_dat_block(dat, pos)
|
|
45
|
+
advance = len(block.to_bytes())
|
|
46
|
+
if any(isinstance(entries[j], TextureEntry) for j in range(i + 1, len(entries))):
|
|
47
|
+
advance = max(0, advance - 4)
|
|
48
|
+
pos += advance
|
|
49
|
+
else:
|
|
50
|
+
new_offsets.append(-1)
|
|
51
|
+
return new_offsets
|
mobile_textdb/txd.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Extract textures from GTA SA/VC RenderWare TXD files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import struct
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
D3DFMT_DXT1 = 0x31545844
|
|
13
|
+
D3DFMT_DXT3 = 0x33545844
|
|
14
|
+
D3DFMT_DXT5 = 0x35545844
|
|
15
|
+
D3DFMT_A8R8G8B8 = 0x15
|
|
16
|
+
RW_TEXTURE_NATIVE = 0x15
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class TxdTexture:
|
|
21
|
+
name: str
|
|
22
|
+
width: int
|
|
23
|
+
height: int
|
|
24
|
+
image: Image.Image
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _decode_dxt(fmt: int, w: int, h: int, raw: bytes) -> Image.Image:
|
|
28
|
+
import texture2ddecoder as t2d
|
|
29
|
+
|
|
30
|
+
bw, bh = max(1, w // 4), max(1, h // 4)
|
|
31
|
+
if fmt == D3DFMT_DXT1:
|
|
32
|
+
px = t2d.decode_bc1(raw[: bw * bh * 8], w, h)
|
|
33
|
+
elif fmt in (D3DFMT_DXT3, D3DFMT_DXT5):
|
|
34
|
+
px = t2d.decode_bc3(raw[: bw * bh * 16], w, h)
|
|
35
|
+
elif fmt == D3DFMT_A8R8G8B8:
|
|
36
|
+
return Image.frombytes("RGBA", (w, h), raw[: w * h * 4], "raw", "BGRA")
|
|
37
|
+
else:
|
|
38
|
+
raise ValueError(f"Unsupported D3D format 0x{fmt:08X}")
|
|
39
|
+
return Image.frombytes("RGBA", (w, h), px, "raw", "RGBA")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _mip_chain_size(dim: int, bpb: int = 8) -> int:
|
|
43
|
+
total = 0
|
|
44
|
+
d = dim
|
|
45
|
+
while d >= 1:
|
|
46
|
+
total += max(1, d // 4) ** 2 * bpb
|
|
47
|
+
d //= 2
|
|
48
|
+
return total
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _infer_dim_from_size(data_len: int, bpb: int = 8) -> int:
|
|
52
|
+
for dim in (512, 256, 128, 64, 32, 16, 8):
|
|
53
|
+
if abs(_mip_chain_size(dim, bpb) - data_len) <= 256:
|
|
54
|
+
return dim
|
|
55
|
+
for dim in (512, 256, 128, 64, 32, 16, 8):
|
|
56
|
+
if _mip_chain_size(dim, bpb) <= data_len:
|
|
57
|
+
return dim
|
|
58
|
+
return 128
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract_from_native_body(body: bytes) -> list[TxdTexture]:
|
|
62
|
+
out: list[TxdTexture] = []
|
|
63
|
+
pos = 0
|
|
64
|
+
while True:
|
|
65
|
+
tag_pos = -1
|
|
66
|
+
fmt = D3DFMT_DXT1
|
|
67
|
+
for tag, f in ((b"DXT1", D3DFMT_DXT1), (b"DXT3", D3DFMT_DXT3), (b"DXT5", D3DFMT_DXT5)):
|
|
68
|
+
p = body.find(tag, pos)
|
|
69
|
+
if p >= 0 and (tag_pos < 0 or p < tag_pos):
|
|
70
|
+
tag_pos, fmt = p, f
|
|
71
|
+
if tag_pos < 0:
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
window = body[max(0, tag_pos - 96) : tag_pos]
|
|
75
|
+
names_found = re.findall(rb"[\x20-\x7e]{3,48}\x00", window)
|
|
76
|
+
name = (
|
|
77
|
+
names_found[-1][:-1].decode("ascii", errors="replace")
|
|
78
|
+
if names_found
|
|
79
|
+
else f"texture_{len(out)}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
next_tags = [body.find(t, tag_pos + 4) for t in (b"DXT1", b"DXT3", b"DXT5")]
|
|
83
|
+
next_tags = [t for t in next_tags if t > tag_pos]
|
|
84
|
+
data_end = min(next_tags) if next_tags else len(body)
|
|
85
|
+
raw = body[tag_pos + 4 : data_end]
|
|
86
|
+
bpb = 16 if fmt != D3DFMT_DXT1 else 8
|
|
87
|
+
dim = _infer_dim_from_size(len(raw), bpb)
|
|
88
|
+
try:
|
|
89
|
+
img = _decode_dxt(fmt, dim, dim, raw)
|
|
90
|
+
out.append(TxdTexture(name=name, width=dim, height=dim, image=img))
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
pos = tag_pos + 4
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_txd(path: str | Path) -> list[TxdTexture]:
|
|
98
|
+
data = Path(path).read_bytes()
|
|
99
|
+
textures: list[TxdTexture] = []
|
|
100
|
+
|
|
101
|
+
pos = 0
|
|
102
|
+
while pos + 12 <= len(data):
|
|
103
|
+
size, ctype, _lib = struct.unpack_from("<III", data, pos)
|
|
104
|
+
if size < 12:
|
|
105
|
+
break
|
|
106
|
+
body = data[pos + 12 : pos + size]
|
|
107
|
+
if (ctype & 0xFFFF) == RW_TEXTURE_NATIVE:
|
|
108
|
+
textures.extend(_extract_from_native_body(body))
|
|
109
|
+
pos += size
|
|
110
|
+
|
|
111
|
+
if textures:
|
|
112
|
+
return textures
|
|
113
|
+
return _extract_from_native_body(data)
|
mobile_textdb/txt.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from mobile_textdb.models import AffiliateEntry, CatEntry, TextureEntry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_txt_line(line: str) -> CatEntry | TextureEntry | AffiliateEntry | None:
|
|
10
|
+
line = line.rstrip("\n")
|
|
11
|
+
if not line.strip():
|
|
12
|
+
return None
|
|
13
|
+
if line.startswith("cat="):
|
|
14
|
+
return CatEntry(line=line)
|
|
15
|
+
|
|
16
|
+
m = re.match(r'"([^"]+)"(.*)', line)
|
|
17
|
+
if not m:
|
|
18
|
+
return None
|
|
19
|
+
name, rest = m.group(1), m.group(2).strip()
|
|
20
|
+
|
|
21
|
+
if rest.startswith('"affiliate=') or "affiliate=" in rest:
|
|
22
|
+
aff_m = re.search(r'affiliate=([^\s"]+)', rest)
|
|
23
|
+
return AffiliateEntry(
|
|
24
|
+
name=name,
|
|
25
|
+
affiliate=aff_m.group(1) if aff_m else "",
|
|
26
|
+
raw_line=line,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
props: dict[str, str | int] = {}
|
|
30
|
+
for key, val in re.findall(r"(\w+)=([^\s\"]+)", rest):
|
|
31
|
+
if key in ("width", "height", "alphamode", "hasdetail", "isdetail", "detailtile", "format"):
|
|
32
|
+
props[key] = int(val)
|
|
33
|
+
else:
|
|
34
|
+
props[key] = val
|
|
35
|
+
|
|
36
|
+
if "width" not in props or "height" not in props:
|
|
37
|
+
return AffiliateEntry(name=name, affiliate=rest, raw_line=line)
|
|
38
|
+
|
|
39
|
+
return TextureEntry(
|
|
40
|
+
name=name,
|
|
41
|
+
width=int(props["width"]),
|
|
42
|
+
height=int(props["height"]),
|
|
43
|
+
props=props,
|
|
44
|
+
raw_line=line,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def format_texture_line(entry: TextureEntry, png_hash: str | None, img_hash: str) -> str:
|
|
49
|
+
parts = [f'"{entry.name}"', f"width={entry.width}", f"height={entry.height}"]
|
|
50
|
+
if png_hash:
|
|
51
|
+
parts.append(f"png={png_hash}")
|
|
52
|
+
parts.append(f"img={img_hash}")
|
|
53
|
+
for key in (
|
|
54
|
+
"alphamode",
|
|
55
|
+
"hasdetail",
|
|
56
|
+
"isdetail",
|
|
57
|
+
"detailtile",
|
|
58
|
+
"hassibling",
|
|
59
|
+
"mipmode",
|
|
60
|
+
"camnorm",
|
|
61
|
+
"format",
|
|
62
|
+
"streammode",
|
|
63
|
+
):
|
|
64
|
+
if key in entry.props:
|
|
65
|
+
parts.append(f"{key}={entry.props[key]}")
|
|
66
|
+
return " ".join(parts)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_txt(path: Path) -> list[CatEntry | TextureEntry | AffiliateEntry]:
|
|
70
|
+
entries: list[CatEntry | TextureEntry | AffiliateEntry] = []
|
|
71
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
72
|
+
parsed = parse_txt_line(line)
|
|
73
|
+
if parsed is not None:
|
|
74
|
+
entries.append(parsed)
|
|
75
|
+
return entries
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def save_txt(path: Path, entries: list[CatEntry | TextureEntry | AffiliateEntry]) -> None:
|
|
79
|
+
lines: list[str] = []
|
|
80
|
+
for entry in entries:
|
|
81
|
+
if isinstance(entry, CatEntry):
|
|
82
|
+
lines.append(entry.line)
|
|
83
|
+
elif isinstance(entry, AffiliateEntry):
|
|
84
|
+
lines.append(entry.raw_line)
|
|
85
|
+
elif isinstance(entry, TextureEntry):
|
|
86
|
+
lines.append(entry.raw_line)
|
|
87
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mobile-textdb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read/write GTA San Andreas & Vice City mobile texture databases (textdb)
|
|
5
|
+
Author: GTA Mobile modding
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: gta,san-andreas,textdb,textures,dxt
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Topic :: Games/Entertainment
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: Pillow>=10.0.0
|
|
14
|
+
Requires-Dist: texture2ddecoder>=1.0.0
|
|
15
|
+
Requires-Dist: texfury>=0.1.0
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
mobile_textdb/__init__.py,sha256=qRP1yNbh6FqXHIVR6YaymjtAwQ385BMeQT51jE2SmQ0,1486
|
|
2
|
+
mobile_textdb/__main__.py,sha256=DhSSpyXKzDbPcHj9LGEylv1Qz0GV-J7iWpwf4JckZ7k,134
|
|
3
|
+
mobile_textdb/cli.py,sha256=MlFNtNPWXaggq-eVK7xI5D0-yTS1MJ1TNZbdGSndF6k,3748
|
|
4
|
+
mobile_textdb/codec.py,sha256=2k1KFtlltDYWkQMNHy9gWiDknEXujg8sqE-HcO1gGcQ,5682
|
|
5
|
+
mobile_textdb/database.py,sha256=LRs22hhdhl9L_QOyDlq-I5AKMc0rKWwdxBbi519UWCM,4794
|
|
6
|
+
mobile_textdb/models.py,sha256=lKSrpmMq767I5wp5S_ZpQpYyNH1PSUKcrR2xx5GRksM,1682
|
|
7
|
+
mobile_textdb/operations.py,sha256=63juACMKTk-_AjPajoHPngcMykRSuNxPdBbfEukMqfA,3152
|
|
8
|
+
mobile_textdb/toc.py,sha256=kgpGri5gM0gp8DQh-UFghmGdifzL8EXg8j9bzM3DB10,1575
|
|
9
|
+
mobile_textdb/txd.py,sha256=OvuRa27F92Mnq2U2K480KDX1zpSSHqCM224HFpZxj-o,3340
|
|
10
|
+
mobile_textdb/txt.py,sha256=HK-jvs3BuqRlRLmkuL4NPjGvqrpdmMLXHW6cb0Ld_30,2723
|
|
11
|
+
mobile_textdb-0.1.0.dist-info/METADATA,sha256=a3DhsM1lPIokA9i--uy4HyaAp2xsd1iP9h1mtVY8gVc,604
|
|
12
|
+
mobile_textdb-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
mobile_textdb-0.1.0.dist-info/entry_points.txt,sha256=wupGx3CJia3vc-hZgjLtKuCMDBapE9fv2yyXEaoy0lY,57
|
|
14
|
+
mobile_textdb-0.1.0.dist-info/top_level.txt,sha256=SuefcasMTy6WDls08fC2lxZUUjgojsFM_Aj4ertBQOM,14
|
|
15
|
+
mobile_textdb-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mobile_textdb
|