mobile-textdb 0.1.0__tar.gz

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.
@@ -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,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"
@@ -0,0 +1,6 @@
1
+ """GTA Mobile textdb CLI entry point."""
2
+
3
+ from mobile_textdb.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -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())
@@ -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)
@@ -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
@@ -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
@@ -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)
@@ -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,17 @@
1
+ pyproject.toml
2
+ mobile_textdb/__init__.py
3
+ mobile_textdb/__main__.py
4
+ mobile_textdb/cli.py
5
+ mobile_textdb/codec.py
6
+ mobile_textdb/database.py
7
+ mobile_textdb/models.py
8
+ mobile_textdb/operations.py
9
+ mobile_textdb/toc.py
10
+ mobile_textdb/txd.py
11
+ mobile_textdb/txt.py
12
+ mobile_textdb.egg-info/PKG-INFO
13
+ mobile_textdb.egg-info/SOURCES.txt
14
+ mobile_textdb.egg-info/dependency_links.txt
15
+ mobile_textdb.egg-info/entry_points.txt
16
+ mobile_textdb.egg-info/requires.txt
17
+ mobile_textdb.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mobile-textdb = mobile_textdb.cli:main
@@ -0,0 +1,6 @@
1
+ Pillow>=10.0.0
2
+ texture2ddecoder>=1.0.0
3
+ texfury>=0.1.0
4
+
5
+ [dev]
6
+ pytest>=7.0
@@ -0,0 +1 @@
1
+ mobile_textdb
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mobile-textdb"
7
+ version = "0.1.0"
8
+ description = "Read/write GTA San Andreas & Vice City mobile texture databases (textdb)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "GTA Mobile modding" }]
13
+ keywords = ["gta", "san-andreas", "textdb", "textures", "dxt"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: OS Independent",
17
+ "Topic :: Games/Entertainment",
18
+ ]
19
+ dependencies = [
20
+ "Pillow>=10.0.0",
21
+ "texture2ddecoder>=1.0.0",
22
+ "texfury>=0.1.0",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["pytest>=7.0"]
27
+
28
+ [project.scripts]
29
+ mobile-textdb = "mobile_textdb.cli:main"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["."]
33
+ include = ["mobile_textdb*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+