diff --git a/src/szio/gta5/__init__.py b/src/szio/gta5/__init__.py index bbc5cd8..bfbd8f6 100644 --- a/src/szio/gta5/__init__.py +++ b/src/szio/gta5/__init__.py @@ -127,6 +127,7 @@ MAP_GRASS_INSTANCES_DTYPE, MAP_LOD_LIGHT_DTYPE, AssetMapData, + AssetMapParentTxds, MapBlockDescription, MapBoxOccluder, MapCarGenerator, diff --git a/src/szio/gta5/assets.py b/src/szio/gta5/assets.py index 5f60462..41866d2 100644 --- a/src/szio/gta5/assets.py +++ b/src/szio/gta5/assets.py @@ -52,6 +52,7 @@ class AssetType(Enum): CLOTH_DICTIONARY = auto() MAP_TYPES = auto() MAP_DATA = auto() + MAP_PARENT_TXDS = auto() TEXTURE_DICTIONARY = auto() diff --git a/src/szio/gta5/cwxml/adapters/__init__.py b/src/szio/gta5/cwxml/adapters/__init__.py index 271aa88..b821efc 100644 --- a/src/szio/gta5/cwxml/adapters/__init__.py +++ b/src/szio/gta5/cwxml/adapters/__init__.py @@ -29,5 +29,7 @@ ) from .map import ( load_map_data_from_cw, + load_map_parent_txds_from_cw, save_map_data_to_cw, + save_map_parent_txds_to_cw, ) diff --git a/src/szio/gta5/cwxml/adapters/map.py b/src/szio/gta5/cwxml/adapters/map.py index b3df4a5..2fd42d0 100644 --- a/src/szio/gta5/cwxml/adapters/map.py +++ b/src/szio/gta5/cwxml/adapters/map.py @@ -6,6 +6,7 @@ from ... import jenkhash from ...entities import Entity, MapEntity from ...maps import ( + AssetMapParentTxds, MAP_DISTANT_LOD_LIGHT_DTYPE, MAP_GRASS_INSTANCES_DTYPE, MAP_LOD_LIGHT_DTYPE, @@ -405,3 +406,24 @@ def save_map_data_to_cw(asset: AssetMapData) -> cw.CMapData: if asset.description is not None: _save_description(asset.description, t.block) return t + + +def load_map_parent_txds_from_cw(t: cw.CMapParentTxds) -> AssetMapParentTxds: + parents = {} + for relationship in t.txd_relationships: + if relationship.parent and relationship.child: + # A texture dictionary can only inherit from a single parent, keep the first one + parents.setdefault(relationship.child, relationship.parent) + + return AssetMapParentTxds(parents=parents) + + +def save_map_parent_txds_to_cw(asset: AssetMapParentTxds) -> cw.CMapParentTxds: + t = cw.CMapParentTxds() + for child, parent in asset.parents.items(): + relationship = cw.TxdRelationship() + relationship.parent = parent + relationship.child = child + t.txd_relationships.append(relationship) + + return t diff --git a/src/szio/gta5/cwxml/provider.py b/src/szio/gta5/cwxml/provider.py index 8d6aa72..4a9de40 100644 --- a/src/szio/gta5/cwxml/provider.py +++ b/src/szio/gta5/cwxml/provider.py @@ -9,7 +9,7 @@ from ..cloths import AssetClothDictionary from ..drawables import AssetDrawable, AssetDrawableDictionary from ..fragments import AssetFragment -from ..maps import AssetMapData +from ..maps import AssetMapData, AssetMapParentTxds from ..textures import AssetTextureDictionary from . import bound as cwbnd from . import cloth as cwcloth @@ -32,6 +32,8 @@ save_drawable_to_cw, save_fragment_to_cw, save_map_data_to_cw, + load_map_parent_txds_from_cw, + save_map_parent_txds_to_cw, save_map_types_to_cw, save_txd_to_cw, ) @@ -53,7 +55,21 @@ class CWProvider(ABC): ".ytd": "TextureDictionary", } + #: gtxd files are plain XML but do not follow the `..xml` convention of the other assets + GTXD_EXTENSIONS = (".meta", ".ymt.rbf.xml") + + def _supports_gtxd_file(self, path: ProviderPath) -> bool: + name = path.name.lower() + if not any(name.endswith(ext) for ext in CWProvider.GTXD_EXTENSIONS) or not path.is_file(): + return False + + with import_source(path) as src: + return get_xml_root_tag(src) == "CMapParentTxds" + def supports_file(self, path: ProviderPath) -> bool: + if self._supports_gtxd_file(path): + return True + suffixes = path.suffixes if ( len(suffixes) >= 2 @@ -69,6 +85,10 @@ def supports_file(self, path: ProviderPath) -> bool: return False def load_file(self, path: ProviderPath) -> Asset: + if self._supports_gtxd_file(path): + with import_source(path) as src: + return load_map_parent_txds_from_cw(cwmap.CMapParentTxds.from_xml_file(src)) + suffixes = path.suffixes with import_source(path) as src: match suffixes[-2].lower(): @@ -118,6 +138,9 @@ def save_asset(self, asset: Asset, directory: Path, name: str, tool_metadata: tu elif isinstance(asset, AssetTextureDictionary): path = directory / f"{name}.ytd.xml" save_txd_to_cw(asset).write_xml(path) + elif isinstance(asset, AssetMapParentTxds): + path = directory / f"{name}.meta" + save_map_parent_txds_to_cw(asset).write_xml(path) else: raise ValueError(f"Unsupported asset '{asset}' (name: '{name}', directory: '{str(directory)}')") diff --git a/src/szio/gta5/cwxml/ymap.py b/src/szio/gta5/cwxml/ymap.py index f49d8d4..a22bf16 100644 --- a/src/szio/gta5/cwxml/ymap.py +++ b/src/szio/gta5/cwxml/ymap.py @@ -774,3 +774,34 @@ def __init__(self): self.lod_lights_soa = LODLightsSOA() self.distant_lod_lights_soa = DistantLODLightsSOA() self.block = Block() + + +class TxdRelationship(ElementTree): + tag_name = "item" + + def __init__(self): + super().__init__() + self.parent = TextPropertyRequired("parent") + self.child = TextPropertyRequired("child") + + +class TxdRelationshipsList(ListPropertyRequired): + list_type = TxdRelationship + tag_name = "txdRelationships" + + @classmethod + def from_xml(cls, element: ET.Element): + # `gtxd.ymt` uses `item` and `gtxd.meta` uses `Item`, accept both + new = cls(element.tag) + new.value.extend(TxdRelationship.from_xml(child) for child in element if child.tag.lower() == "item") + return new + + +class CMapParentTxds(ElementTree): + """Parent-child texture dictionary relationships, as stored in `gtxd.meta` and `gtxd.ymt`.""" + + tag_name = "CMapParentTxds" + + def __init__(self): + super().__init__() + self.txd_relationships = TxdRelationshipsList() diff --git a/src/szio/gta5/maps.py b/src/szio/gta5/maps.py index 4a4a03f..106d890 100644 --- a/src/szio/gta5/maps.py +++ b/src/szio/gta5/maps.py @@ -310,3 +310,18 @@ class AssetMapData: lod_lights: MapLodLights | None = None distant_lod_lights: MapDistantLodLights | None = None description: MapBlockDescription | None = None + + +@dataclass(slots=True) +class AssetMapParentTxds: + """Parent-child texture dictionary relationships, as stored in `gtxd.meta` and `gtxd.ymt`. + + A texture dictionary inherits the textures of its parent, and a parent may itself be the child of another + texture dictionary, so the relationships can form a hierarchy of any depth. + """ + + ASSET_GAME: AssetGame = AssetGame.GTA5 + ASSET_TYPE: AssetType = AssetType.MAP_PARENT_TXDS + + #: Maps each child texture dictionary to the name of its parent. + parents: dict[str, str] = field(default_factory=dict)