├── .gitmodules ├── README.md ├── assets └── eye.dmx ├── convert_camera_data.py ├── convert_model.py ├── convert_sfm_session.py ├── eyes_converter.py ├── main.py ├── material_converter.py ├── requirements.txt ├── shader_converters ├── eyerefract.py ├── lightmappedgeneric.py ├── mouth.py ├── shader_base.py ├── unlitgeneric.py └── vertexlitgeneric.py ├── source2converter ├── materials │ ├── __init__.py │ ├── material_converter_tags.py │ ├── source1 │ │ ├── common.py │ │ └── vertex_lit_generic.py │ └── types.py ├── mdl │ ├── __init__.py │ ├── model_converter_tags.py │ ├── utils.py │ └── v49 │ │ └── __init__.py ├── model │ ├── __init__.py │ └── skeleton.py └── utils │ ├── __init__.py │ ├── math_utils.py │ └── vmdl.py ├── utils.py ├── vmf_convert_materials.py └── vmf_convert_props.py /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "SourceIO"] 2 | path = SourceIO 3 | url = https://github.com/REDxEYE/SourceIO 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Source2Converter 2 | 3 | Toolset for converting Source1 props to Source2 4 | If you using "Download ZIP" button, make sure to download SourceIO and put content of downloaded SourceIO zip archive into Source2Converter/SourceIO 5 | 6 | 7 | 8 | 9 | Pre requirements: 10 | 11 | * Install python3.10 or higher 12 | * Open a command line in /Source2Converter folder. 13 | * Run command `pip install -r requirements.txt` to install required modules 14 | * Run `python convert_model.py --help` to see all available arguments 15 | 16 | 17 | 18 | ~~How to use (semi GUI way)~~ **DISABLED RIGHT NOW**: 19 | 20 | * Open a command line in /Source2Converter folder. 21 | * Run `python convert_model.py` 22 | * Select output folder in a pop-up window (Source2 mod content folder) 23 | * Select input folder in a pop-up window (Source1 folder with models you want to convert, for single model use -m argument) 24 | 25 | How to use (CLI way): 26 | 27 | * Open a command line in /Source2Converter folder. 28 | * Run `python convert_model.py` with arguments you need, use `--help` arguments to see available arguments 29 | -------------------------------------------------------------------------------- /assets/eye.dmx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/REDxEYE/Source2Converter/96c9de3da0a8087564c86e6fd8a42b2146e82db3/assets/eye.dmx -------------------------------------------------------------------------------- /convert_camera_data.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from SourceIO.library.utils.datamodel import DataModel, load, Color, _dmxtypes, _dmxtypes_all, _dmxtypes_str, Element 4 | import sys 5 | 6 | 7 | class FixedColor(Color): 8 | 9 | def __repr__(self): 10 | return " ".join([str(int(ord)) for ord in self]) 11 | 12 | 13 | class EmptyElement(Element): 14 | 15 | def get_kv2(self, deep=True): 16 | return '' 17 | 18 | def __repr__(self): 19 | return '' 20 | 21 | 22 | _dmxtypes.append(FixedColor) 23 | _dmxtypes_all.append(FixedColor) 24 | _dmxtypes_str.append('color') 25 | 26 | _dmxtypes.append(EmptyElement) 27 | _dmxtypes_all.append(EmptyElement) 28 | _dmxtypes_str.append('element') 29 | 30 | if __name__ == '__main__': 31 | 32 | if len(sys.argv) != 2: 33 | dmx_file = input("Source2 camera animation DMX file:") 34 | # print("Provide exactly 1 camera animation dmx file as an argument!") 35 | else: 36 | dmx_file = sys.argv[1] 37 | dmx_file = dmx_file.strip('"\'') 38 | 39 | s2 = load(dmx_file) 40 | 41 | for elem in s2.find_elements(elemtype='DmeChannelsClip'): 42 | elem['color'] = FixedColor([int(a) for a in elem['color']]) 43 | 44 | 45 | def get_elemnts(elem_type): 46 | return s2.find_elements(elemtype=elem_type) or [] 47 | 48 | 49 | elements_to_fix = get_elemnts('DmeFloatLog') + \ 50 | get_elemnts('DmeVector3Log') + \ 51 | get_elemnts('DmeQuaternionLog') + \ 52 | get_elemnts('DmeBoolLog') 53 | 54 | for elem in elements_to_fix: 55 | if elem['curveinfo'] is None: 56 | elem['curveinfo'] = EmptyElement(s2, '', 'element') 57 | 58 | elements_to_fix = get_elemnts('DmeChannel') 59 | 60 | for elem in elements_to_fix: 61 | if elem['fromElement'] is None: 62 | elem['fromElement'] = EmptyElement(s2, '', 'element') 63 | if elem['toElement'] is None: 64 | elem['toElement'] = EmptyElement(s2, '', 'element') 65 | 66 | elements_to_fix = get_elemnts('DmeCamera') 67 | 68 | for elem in elements_to_fix: 69 | if elem['shape'] is None: 70 | elem['shape'] = EmptyElement(s2, '', 'element') 71 | 72 | s2.format_ver = 18 73 | path = Path(dmx_file) 74 | path = path.with_name(path.stem + "_s1cvt").with_suffix('.dmx') 75 | s2.write(path, 'keyvalues2', 1) 76 | -------------------------------------------------------------------------------- /convert_model.py: -------------------------------------------------------------------------------- 1 | import os 2 | from subprocess import Popen, PIPE 3 | from typing import Optional, Tuple, List 4 | 5 | import numpy as np 6 | 7 | from SourceIO.library.shared.content_providers.content_manager import ContentManager 8 | from SourceIO.library.source1.dmx.source1_to_dmx import DmxModel2 9 | from SourceIO.library.models.mdl.structs.bodygroup import BodyPart 10 | from SourceIO.library.models.mdl.structs.flex import VertexAminationType 11 | from SourceIO.library.models.mdl.structs.model import Model 12 | from SourceIO.library.models.vtx import open_vtx 13 | from SourceIO.library.models.vtx.v7.structs.bodypart import BodyPart as VtxBodyPart 14 | from SourceIO.library.models.vtx.v7.structs.model import Model as VtxModel 15 | from SourceIO.library.models.vvd import Vvd 16 | from SourceIO.library.source2.utils.kv3_generator import KV3mdl 17 | from SourceIO.library.utils import FileBuffer, datamodel 18 | from SourceIO.library.utils.common import get_slice 19 | from SourceIO.library.utils.path_utilities import find_vtx_cm 20 | from material_converter import convert_material, Material, GameType 21 | 22 | from pathlib import Path 23 | import argparse 24 | 25 | from ctypes import windll 26 | from SourceIO.logger import SourceLogMan 27 | from logging import DEBUG, INFO 28 | from SourceIO.library.models.mdl.v49.mdl_file import MdlV49 29 | 30 | from utils import normalize_path, collect_materials, sanitize_name 31 | 32 | k32 = windll.LoadLibrary('kernel32.dll') 33 | setConsoleModeProc = k32.SetConsoleMode 34 | setConsoleModeProc(k32.GetStdHandle(-11), 0x0001 | 0x0002 | 0x0004) 35 | 36 | 37 | def get_s2_material_path(mat_name, s1_materials): 38 | for mat, mat_path, _ in s1_materials: 39 | if mat == mat_name: 40 | path = normalize_path((Path('materials') / mat_path / mat).with_suffix('.vmat')).resolve() 41 | return path 42 | 43 | 44 | def _convert_model(mdl: MdlV49, vvd: Vvd, model: Model, vtx_model: VtxModel, s2_output_path: Path, 45 | materials: List[Material]) -> Path: 46 | print(f'\t\033[94mGenerating DMX file for\033[0m \033[92m"{model.name}" mesh\033[0m') 47 | model_name = sanitize_name(model.name) 48 | dm_model = DmxModel2(model_name, 22) 49 | dm_model.add_skeleton(model_name + "_skeleton") 50 | content_path = normalize_path(mdl.header.name).with_suffix("") 51 | output_path = Path("models", content_path, model_name + ".dmx") 52 | 53 | has_flexes = any(mesh.flexes for mesh in model.meshes) 54 | 55 | for bone in mdl.bones: 56 | dm_model.add_bone(bone.name, bone.position, bone.quat, 57 | mdl.bones[bone.parent_bone_id].name if bone.parent_bone_id != -1 else None) 58 | 59 | for material in mdl.materials: 60 | full_material_path = next( 61 | filter(lambda a: a[0].as_posix() == sanitize_name(material.name), materials), None) 62 | if full_material_path is None: 63 | full_material_path = material.name 64 | else: 65 | full_material_path = Path(full_material_path[1], full_material_path[0]) 66 | dm_model.add_material(sanitize_name(material.name), full_material_path) 67 | 68 | if has_flexes: 69 | flex_controllers = {} 70 | # for flex_ui_controller in mdl.flex_ui_controllers: 71 | # flex_controller = dm_model.add_flex_controller(flex_ui_controller.name, flex_ui_controller.stereo, 72 | # False) 73 | for mesh in model.meshes: 74 | for flex in mesh.flexes: 75 | flex_name = mdl.flex_names[flex.flex_desc_index] 76 | if flex.partner_index != 0: 77 | assert flex_name[-1] in "RL" 78 | flex_name = flex_name[:-1] 79 | if flex_name in flex_controllers: 80 | continue 81 | flex_controller = dm_model.add_flex_controller(flex_name, flex.partner_index != 0, False) 82 | # print(flex_ui_controller) 83 | # for flex_rule in mdl.flex_rules: 84 | # print("\t", mdl.flex_names[flex_rule.flex_index], flex_rule) 85 | 86 | # for flex_controller in mdl.flex_controllers: 87 | # print("\t", flex_controller) 88 | dm_model.flex_controller_add_delta_name(flex_controller, flex_name, 0) 89 | flex_controllers[flex_name] = flex_controller 90 | 91 | for flex_controller in flex_controllers.values(): 92 | dm_model.flex_controller_finish(flex_controller, len(flex_controller["rawControlNames"])) 93 | 94 | bone_names = [bone.name for bone in mdl.bones] 95 | vertices = vvd.lod_data[0] 96 | 97 | dm_mesh = dm_model.add_mesh(model_name, has_flexes) 98 | model_vertices = get_slice(vertices, model.vertex_offset, model.vertex_count) 99 | 100 | dm_model.mesh_add_attribute(dm_mesh, "pos", model_vertices["vertex"], datamodel.Vector3) 101 | dm_model.mesh_add_attribute(dm_mesh, "norm", model_vertices["normal"], datamodel.Vector3) 102 | dm_model.mesh_add_attribute(dm_mesh, "texco", model_vertices["uv"], datamodel.Vector2) 103 | dm_model.mesh_add_bone_weights(dm_mesh, bone_names, model_vertices["weight"], model_vertices["bone_id"]) 104 | 105 | for mesh, vmesh in zip(model.meshes, vtx_model.model_lods[0].meshes): 106 | for strip_group in vmesh.strip_groups: 107 | indices = np.add(strip_group.vertexes[strip_group.indices]["original_mesh_vertex_index"], 108 | mesh.vertex_index_start) 109 | 110 | dm_model.mesh_add_faceset(dm_mesh, sanitize_name(mdl.materials[mesh.material_index].name), indices) 111 | 112 | tmp_vertices = model_vertices['vertex'] 113 | if tmp_vertices.size > 0: 114 | dimm = tmp_vertices.max() - tmp_vertices.min() 115 | balance_width = dimm * (1 - (99.3 / 100)) 116 | balance = model_vertices['vertex'][:, 0] 117 | balance = np.clip((-balance / balance_width / 2) + 0.5, 0, 1) 118 | dm_model.mesh_add_attribute(dm_mesh, "balance", balance, float) 119 | 120 | vertex_data = dm_mesh["bindState"] 121 | vertex_data["flipVCoordinates"] = False 122 | vertex_data["jointCount"] = 3 123 | 124 | if has_flexes: 125 | attribute_names = dm_model.supported_attributes() 126 | delta_states = {} 127 | for mesh in model.meshes: 128 | for mdl_flex in mesh.flexes: 129 | flex_name = mdl.flex_names[mdl_flex.flex_desc_index] 130 | if mdl_flex.partner_index != 0: 131 | flex_name = flex_name[:-1] 132 | if flex_name not in delta_states: 133 | vertex_delta_data = delta_states[flex_name] = \ 134 | dm_model.mesh_add_delta_state(dm_mesh, flex_name) 135 | vertex_delta_data[attribute_names['pos']] = datamodel.make_array([], datamodel.Vector3) 136 | vertex_delta_data[attribute_names['pos'] + "Indices"] = datamodel.make_array([], int) 137 | vertex_delta_data[attribute_names['norm']] = datamodel.make_array([], datamodel.Vector3) 138 | vertex_delta_data[attribute_names['norm'] + "Indices"] = datamodel.make_array([], int) 139 | 140 | if mdl_flex.vertex_anim_type == VertexAminationType.WRINKLE: 141 | vertex_delta_data["vertexFormat"].append(attribute_names["wrinkle"]) 142 | vertex_delta_data[attribute_names["wrinkle"]] = datamodel.make_array([], float) 143 | vertex_delta_data[attribute_names["wrinkle"] + "Indices"] = datamodel.make_array([], int) 144 | 145 | for mesh in model.meshes: 146 | for mdl_flex in mesh.flexes: 147 | flex_name = mdl.flex_names[mdl_flex.flex_desc_index] 148 | if mdl_flex.partner_index != 0: 149 | flex_name = flex_name[:-1] 150 | vertex_delta_data = delta_states[flex_name] 151 | flex_indices = mdl_flex.vertex_animations["index"] + mesh.vertex_index_start 152 | vertex_delta_data[attribute_names['pos']].extend( 153 | map(datamodel.Vector3, mdl_flex.vertex_animations["vertex_delta"])) 154 | vertex_delta_data[attribute_names['pos'] + "Indices"].extend(flex_indices.ravel()) 155 | 156 | vertex_delta_data[attribute_names['norm']].extend( 157 | map(datamodel.Vector3, mdl_flex.vertex_animations["normal_delta"])) 158 | vertex_delta_data[attribute_names['norm'] + "Indices"].extend(flex_indices.ravel()) 159 | 160 | if mdl_flex.vertex_anim_type == VertexAminationType.WRINKLE: 161 | vertex_delta_data[attribute_names["wrinkle"]].extend( 162 | mdl_flex.vertex_animations["wrinkle_delta"].ravel()) 163 | vertex_delta_data[attribute_names["wrinkle"] + "Indices"].extend(flex_indices.ravel()) 164 | 165 | dm_model.save(s2_output_path / output_path, "keyvalues2", 1) 166 | 167 | return output_path 168 | 169 | 170 | def convert_mdl(mdl_path: Path, s2_output_path: Path, game: GameType = GameType.CS2): 171 | cm = ContentManager() 172 | cm.scan_for_content(mdl_path) 173 | with FileBuffer(mdl_path) as f: 174 | mdl = MdlV49.from_buffer(f) 175 | with cm.find_file(mdl_path.with_suffix('.vvd')) as f: 176 | vvd = Vvd.from_buffer(f) 177 | with find_vtx_cm(mdl_path, cm) as f: 178 | vtx = open_vtx(f) 179 | 180 | rel_model_path = Path("models", normalize_path(mdl.header.name)) 181 | content_path = s2_output_path / rel_model_path.with_suffix('') 182 | os.makedirs(content_path, exist_ok=True) 183 | s1_materials = collect_materials(mdl) 184 | 185 | print(f'\033[94mDecompiling model \033[92m"{rel_model_path}"\033[0m') 186 | 187 | main_bodypart_guess: Optional[Tuple[BodyPart, VtxBodyPart]] = None 188 | 189 | for bodypart, vtx_bodypart in zip(mdl.body_parts, vtx.body_parts): 190 | if len(bodypart.models) != 1: 191 | continue 192 | for model, vtx_model in zip(bodypart.models, vtx_bodypart.models): 193 | if not model.meshes: 194 | continue 195 | main_bodypart_guess = bodypart, vtx_bodypart 196 | break 197 | 198 | vmdl = KV3mdl() 199 | if main_bodypart_guess: 200 | bodypart, vtx_bodypart = main_bodypart_guess 201 | dmx_filename = _convert_model(mdl, vvd, bodypart.models[0], vtx_bodypart.models[0], s2_output_path, 202 | s1_materials) 203 | vmdl.add_render_mesh(sanitize_name(bodypart.name), dmx_filename) 204 | 205 | for bodypart, vtx_bodypart in zip(mdl.body_parts, vtx.body_parts): 206 | if main_bodypart_guess and main_bodypart_guess[0] == bodypart: 207 | continue 208 | for model, vtx_model in zip(bodypart.models, vtx_bodypart.models): 209 | if not model.meshes: 210 | continue 211 | 212 | dmx_filename = _convert_model(mdl, vvd, model, vtx_model, s2_output_path, s1_materials) 213 | vmdl.add_render_mesh(sanitize_name(model.name), dmx_filename) 214 | 215 | for s1_bodygroup in mdl.body_parts: 216 | if main_bodypart_guess and main_bodypart_guess[0] == s1_bodygroup: 217 | continue 218 | if 'clamped' in s1_bodygroup.name: 219 | continue 220 | bodygroup = vmdl.add_bodygroup(sanitize_name(s1_bodygroup.name)) 221 | for mesh in s1_bodygroup.models: 222 | if len(mesh.meshes) == 0 or mesh.name == 'blank': 223 | vmdl.add_bodygroup_choice(bodygroup, []) 224 | continue 225 | vmdl.add_bodygroup_choice(bodygroup, sanitize_name(mesh.name)) 226 | 227 | s2_vmodel = (s2_output_path / rel_model_path.with_suffix('.vmdl')) 228 | with s2_vmodel.open('w') as f: 229 | f.write(vmdl.dump()) 230 | 231 | print('\033[94mConverting materials\033[0m') 232 | for mat in s1_materials: 233 | mat_name = normalize_path(mat[0]) 234 | print('\t\033[94mConverting \033[92m"{}"\033[0m'.format(mat_name)) 235 | result, error_message = convert_material(mat, s2_output_path, game) 236 | if result: 237 | pass 238 | else: 239 | print(f'\033[91m{error_message}\033[0m') 240 | 241 | return s2_vmodel 242 | 243 | 244 | def compile_model(vmdl_path, base_path): 245 | resource_compiler = base_path.parent.parent.parent / 'game' / 'bin' / 'win64' / 'resourcecompiler.exe' 246 | if resource_compiler.exists() and resource_compiler.is_file(): 247 | print('\033[92mResourceCompiler Detected\033[0m') 248 | print(f'\033[94mCompiling model:\033[0m {vmdl_path}') 249 | pipe = Popen([str(resource_compiler), str(vmdl_path)], stdout=PIPE) 250 | while True: 251 | line = pipe.stdout.readline().decode('utf-8') 252 | if not line: 253 | break 254 | print(line.rstrip()) 255 | 256 | 257 | if __name__ == '__main__': 258 | 259 | args = argparse.ArgumentParser(description='Convert Source1 models to Source2') 260 | args.add_argument('-g', '--game', type=str, default=GameType.CS2, required=True, 261 | dest="game", 262 | help=f"Select a target game, supported: {', '.join(map(lambda a: a.value, list(GameType)))}") 263 | args.add_argument('-a', '--addon', type=str, required=True, help='path to source2 add-on folder', 264 | dest='s2_addon_path') 265 | args.add_argument('-m', '--model', type=str, nargs='+', required=True, help='path to source1 model or folder', 266 | dest='s1_model_path') 267 | args.add_argument('-c', '--compile', action='store_const', const=True, default=True, required=False, 268 | help='Automatically compile (if resourcecompiler detected)', 269 | dest='auto_compile') 270 | # args.add_argument('-s', '--sbox', action='store_const', const=True, default=False, required=False, 271 | # help='Convert to S&Box format, otherwise converted to HLA format', 272 | # dest='sbox') 273 | args.add_argument('-d', '--debug', action='store_const', const=True, help='Enable debug output') 274 | # args.add_argument('-f', '--with_flex_rules', action='store_const', const=True, help='Enable flex rules conversion') 275 | args = args.parse_args() 276 | 277 | output_folder = Path(args.s2_addon_path) 278 | files = args.s1_model_path 279 | # output_folder = Path(args.s2_addon_path or askdirectory(title="Path to Source2 add-on folder: ").replace('"', '')) 280 | # files = args.s1_model_path or [askdirectory(title="Path to Source1 model: ").replace('"', '')] 281 | if args.debug: 282 | SourceLogMan().set_logging_level(DEBUG) 283 | else: 284 | SourceLogMan().set_logging_level(INFO) 285 | 286 | for file in files: 287 | file = Path(file) 288 | if file.is_dir(): 289 | for glob_file in file.rglob('*.mdl'): 290 | if not glob_file.with_suffix('.vvd').exists(): 291 | print(f'\033[91mSkipping {glob_file.relative_to(file)} because of missing .vvd file\033[0m') 292 | continue 293 | vmdl_file = convert_mdl(glob_file, output_folder, GameType(args.game)) 294 | if args.auto_compile: 295 | compile_model(vmdl_file, output_folder) 296 | elif file.is_file() and file.exists(): 297 | vmdl_file = convert_mdl(file, output_folder, GameType(args.game)) 298 | if args.auto_compile: 299 | compile_model(vmdl_file, output_folder) 300 | -------------------------------------------------------------------------------- /convert_sfm_session.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from SourceIO.library.utils.datamodel import load 4 | 5 | if __name__ == '__main__': 6 | session_path = Path( 7 | r"H:\SteamLibrary\SteamApps\common\SourceFilmmaker\game\usermod\elements\sessions\hijack_output.dmx") 8 | session = load(session_path) 9 | for game_model in session.find_elements(elemtype='DmeGameModel'): 10 | if not game_model['modelName']: 11 | continue 12 | model_path = game_model['modelName'] 13 | if model_path.endswith('.mdl'): 14 | model_path = model_path.replace('.mdl', '.vmdl') 15 | game_model['modelName'] = model_path 16 | session.write(session_path.with_name(session_path.stem + '_sfm2.dmx'), 'binary', 5) 17 | -------------------------------------------------------------------------------- /eyes_converter.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | from pathlib import Path 3 | from typing import Optional, Union, Tuple 4 | 5 | import numpy as np 6 | 7 | from SourceIO.library.source1.dmx.source1_to_dmx import normalize_path 8 | from SourceIO.library.source1.mdl.structs.bone import Bone 9 | from SourceIO.library.source1.mdl.structs.eyeball import Eyeball 10 | from SourceIO.library.shared.content_providers.content_manager import ContentManager 11 | from SourceIO.library.utils.byte_io_mdl import ByteIO 12 | from SourceIO.library.utils.datamodel import DataModel, load 13 | from SourceIO.library.utils import datamodel 14 | from SourceIO.library.source1.mdl.v49.mdl_file import MdlV49 15 | from SourceIO.library.source1.vmt import VMT 16 | 17 | 18 | class EyeConverter: 19 | def __init__(self): 20 | self.right_axis = 1 21 | self.material_file: Optional[Tuple[Union[Path, StringIO], Path]] = None 22 | self._vmt: Optional[VMT] = None 23 | self._mdl: Optional[MdlV49] = None 24 | 25 | @staticmethod 26 | def get_eye_asset(): 27 | return load('assets/eye.dmx') 28 | 29 | def process_mdl(self, mdl: MdlV49, output_path): 30 | self._mdl = mdl 31 | output_path = Path(output_path) 32 | eyeballs = [] 33 | dmx_eyebals = [] 34 | for bodygroup in mdl.body_parts: 35 | for model in bodygroup.models: 36 | if model.eyeballs: 37 | eyeballs.extend(model.eyeballs) 38 | for eyeball in eyeballs: 39 | parent_bone = mdl.bones[eyeball.bone_index] 40 | 41 | up_axis = np.abs(vector_i_rotate(eyeball.up, parent_bone.pose_to_bone)) 42 | if np.isclose(up_axis[1], 1, atol=0.1): 43 | self.right_axis = 0 44 | if np.isclose(up_axis[2], 1, atol=0.1): 45 | self.right_axis = 2 46 | if np.isclose(up_axis[0], 1, atol=0.1): 47 | self.right_axis = 1 48 | 49 | if eyeball.name == "": 50 | eyeball.name = Path(mdl.materials[eyeball.material_id].name).stem 51 | 52 | mat_path = material_name = mdl.materials[eyeball.material_id].name 53 | for cd_mat in mdl.materials_paths: 54 | full_path = ContentManager().find_material(Path(cd_mat) / material_name) 55 | if full_path is not None: 56 | mat_path = Path('materials') / Path(normalize_path(cd_mat)) / normalize_path(material_name) 57 | self.material_file = full_path, mat_path 58 | break 59 | 60 | eye_asset = self.get_eye_asset() 61 | eye_asset = self.adjust_uv(eye_asset, eyeball) 62 | eye_asset = self.adjust_position(eye_asset, eyeball, parent_bone) 63 | eye_asset = self.adjust_bones(eye_asset, eyeball, parent_bone) 64 | mat = eye_asset.find_elements(elemtype='DmeMaterial')[0] 65 | faceset = eye_asset.find_elements(elemtype='DmeFaceSet')[0] 66 | 67 | mat.name = material_name 68 | mat['mtlName'] = str(mat_path) 69 | faceset['mtlName'] = str(mat_path) 70 | faceset.name = mdl.materials[eyeball.material_id].name 71 | 72 | eyeball_filename = output_path / (eyeball.name + '.dmx') 73 | dmx_eyebals.append((eyeball.name, eyeball_filename)) 74 | eye_asset.write(eyeball_filename, 'binary', 9) 75 | return dmx_eyebals 76 | 77 | def load_material(self) -> Optional[VMT]: 78 | if self._vmt: 79 | return self._vmt 80 | if self.material_file: 81 | file, path = self.material_file 82 | f = ByteIO(file) 83 | self._vmt = VMT(StringIO(f.read().decode('latin1')), path.as_posix()) 84 | f.close() 85 | return self._vmt 86 | else: 87 | return None 88 | 89 | def adjust_uv(self, eye_asset: DataModel, eyeball: Eyeball): 90 | vertex_data_block = eye_asset.find_elements('bind', elemtype='DmeVertexData')[0] 91 | uv_data = np.array(vertex_data_block['texcoord$0']) 92 | non_zero = np.where((uv_data != [0.0, 1.0]).all(axis=1))[0] 93 | scale = eyeball.iris_scale 94 | eye_material = self.load_material() 95 | if eye_material is not None: 96 | scale *= eye_material.get_float('$eyeballradius', 0.5) 97 | uv_data[non_zero] *= scale 98 | uv_data[non_zero] += 0.5 99 | vertex_data_block['texcoord$0'] = datamodel.make_array(uv_data, datamodel.Vector2) 100 | return eye_asset 101 | 102 | def adjust_position(self, eye_asset: DataModel, eyeball: Eyeball, parent_bone: Bone): 103 | vertex_data_block = eye_asset.find_elements('bind', elemtype='DmeVertexData')[0] 104 | vertex_data_pos = np.array(vertex_data_block['position$0']) 105 | vertex_data_norm = np.array(vertex_data_block['normal$0']) 106 | eye_material = self.load_material() 107 | 108 | scale = eyeball.radius 109 | if eye_material is not None: 110 | vmt_scale = eye_material.get_float('$eyeballradius', None) 111 | if vmt_scale: 112 | scale = vmt_scale 113 | vertex_data_pos *= scale 114 | eye_org = np.array(eyeball.org) 115 | 116 | eyeball_orientation_matrix = rotation_matrix([0, 0, 0]) 117 | 118 | if self.right_axis == 0: 119 | eyeball_orientation_matrix = rotation_matrix([0, -90, 0]) 120 | if self.right_axis == 2: 121 | eyeball_orientation_matrix = rotation_matrix([0, 0, -90]) 122 | 123 | vertex_data_pos = np.dot(eyeball_orientation_matrix, vertex_data_pos.T).T 124 | vertex_data_norm = np.dot(eyeball_orientation_matrix, vertex_data_norm.T).T 125 | 126 | transform = collect_transforms(self._mdl, parent_bone) 127 | 128 | M = np.ones([4, 1], dtype=np.float32) 129 | M[0:3, 0] = eye_org 130 | eye_org = transform @ M 131 | eye_org = eye_org[0:3, 0] 132 | vertex_data_pos = np.add(vertex_data_pos, eye_org) 133 | 134 | vertex_data_block['position$0'] = datamodel.make_array(vertex_data_pos, datamodel.Vector3) 135 | vertex_data_block['normal$0'] = datamodel.make_array(vertex_data_norm, datamodel.Vector3) 136 | return eye_asset 137 | 138 | @staticmethod 139 | def adjust_bones(eye_asset: DataModel, eyeball: Eyeball, parent_bone: Bone): 140 | head_bone: datamodel.Element = find_element(eye_asset, name='HEAD', elem_type='DmeJoint') 141 | head_transform: datamodel.Element = head_bone['transform'] 142 | 143 | eye_bone: datamodel.Element = find_element(eye_asset, name='EYE', elem_type='DmeJoint') 144 | eye_transform: datamodel.Element = eye_bone['transform'] 145 | 146 | eye_bone.name = eyeball.name 147 | eye_transform.name = eyeball.name 148 | 149 | head_bone.name = parent_bone.name 150 | head_transform.name = parent_bone.name 151 | 152 | eye_org = np.array(eyeball.org) 153 | # eye_org[0] *= -1 154 | 155 | eye_transform['position'] = datamodel.Vector3(eye_org) 156 | head_transform['position'] = datamodel.Vector3(parent_bone.position) 157 | head_transform['orientation'] = datamodel.Quaternion(parent_bone.quat) 158 | 159 | head_transform2, eye_transform2 = eye_asset.root['skeleton']['baseStates'][0]['transforms'][:2] 160 | 161 | eye_transform2['position'] = datamodel.Vector3(eye_org) 162 | eye_transform2.name = eyeball.name 163 | head_transform2['position'] = datamodel.Vector3(parent_bone.position) 164 | head_transform2['orientation'] = datamodel.Quaternion(parent_bone.quat) 165 | head_transform2.name = parent_bone.name 166 | 167 | return eye_asset 168 | 169 | 170 | def find_element(dm: DataModel, name=None, elem_type=None): 171 | for elem in dm.elements: 172 | elem: datamodel.Element 173 | if name is not None and elem.name != name: 174 | continue 175 | if elem_type is not None and elem.type != elem_type: 176 | continue 177 | return elem 178 | 179 | 180 | def rotation_matrix(euler_angles): 181 | x, y, z = euler_angles 182 | cx, cy, cz = np.cos(x), np.cos(y), np.cos(z) 183 | sx, sy, sz = np.sin(x), np.sin(y), np.sin(z) 184 | 185 | rx = np.array([[1, 0, 0, 0], [0, cx, -sx, 0], [0, sx, cx, 0], [0, 0, 0, 1]]) 186 | ry = np.array([[cy, 0, sy, 0], [0, 1, 0, 0], [-sy, 0, cy, 0], [0, 0, 0, 1]]) 187 | rz = np.array([[cz, -sz, 0, 0], [sz, cz, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]) 188 | 189 | return rz @ ry @ rx 190 | 191 | 192 | def normalized(a, axis=-1, order=2): 193 | l2 = np.atleast_1d(np.linalg.norm(a, order, axis)) 194 | l2[l2 == 0] = 1 195 | return a / np.expand_dims(l2, axis) 196 | 197 | 198 | def collect_transforms(mdl: MdlV49, bone: Bone, rotate_matrix=None): 199 | # bone.rotation - Euler 200 | # bone.position - Vector3D 201 | matrix = bone.matrix # 4x4 Matrix 202 | if rotate_matrix is not None: 203 | matrix = rotate_matrix @ matrix 204 | 205 | if bone.parent_bone_id != -1: 206 | parent_transform = collect_transforms(mdl, mdl.bones[bone.parent_bone_id]) 207 | return np.dot(parent_transform, matrix, ) 208 | else: 209 | return matrix 210 | 211 | 212 | def vector_i_rotate(inp, matrix): 213 | return np.dot(inp, matrix[:3, :3]) 214 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import numpy as np 4 | 5 | from SourceIO.library.shared.content_providers.content_manager import ContentManager 6 | from SourceIO.library.source1.dmx.source1_to_dmx import DmxModel2 7 | from SourceIO.library.source1.vmt import VMT 8 | from SourceIO.library.utils import datamodel 9 | from SourceIO.library.utils.datamodel import Vector3, Vector2 10 | from SourceIO.library.utils.s1_keyvalues import KVWriter 11 | from SourceIO.logger import SourceLogMan 12 | from source2converter.materials.material_converter_tags import choose_material_converter, SourceType, GameType 13 | from source2converter.mdl import choose_model_converter 14 | from source2converter.model import NullSubModel, LoddedSubModel 15 | from source2converter.model.skeleton import AttachmentParentType 16 | from source2converter.utils.math_utils import quaternion_to_euler 17 | from source2converter.utils.vmdl import Vmdl, BodyGroupList, BodyGroup, BodyGroupChoice, RenderMeshList, RenderMeshFile, \ 18 | LODGroupList, LODGroup, BoneMarkupList, AnimationList, EmptyAnim, AttachmentList, Attachment 19 | from utils import sanitize_name, normalize_path 20 | 21 | log_manager = SourceLogMan() 22 | logger = log_manager.get_logger('S2Conv') 23 | 24 | if __name__ == '__main__': 25 | def main(): 26 | content_path = Path( 27 | r"D:\SteamLibrary\steamapps\common\Counter-Strike Global Offensive\content\csgo_addons\s2fm") 28 | cm = ContentManager() 29 | cm.scan_for_content(r"D:\SteamLibrary\steamapps\common\Half-Life 2\hl2\models") 30 | model_path = Path("models/combine_soldier.mdl") 31 | buffer = cm.find_file(model_path) 32 | ident, version = buffer.read_fmt("4sI") 33 | cp = cm.get_content_provider_from_asset_path(model_path) 34 | buffer.seek(0) 35 | handler = choose_model_converter(ident, version, ((cp.steam_id or None) if cp else None)) 36 | model = handler(model_path, buffer, cm) 37 | 38 | vmdl = Vmdl() 39 | vmdl_bodygroups = vmdl.append(BodyGroupList()) 40 | vmdl_animation_list = vmdl.append(AnimationList()) 41 | vmdl_animation_list.append(EmptyAnim()) 42 | vmdl_lod_group_list = vmdl.append(LODGroupList()) 43 | vmdl_render_mesh_list = vmdl.append(RenderMeshList()) 44 | vmdl.append(BoneMarkupList()) 45 | lod_groups = {} 46 | for bodygroup in model.bodygroups: 47 | vmld_bodygroup = BodyGroup(bodygroup.name) 48 | vmdl_bodygroups.append(vmld_bodygroup) 49 | for sub_model in bodygroup.sub_models: 50 | if isinstance(sub_model, NullSubModel): 51 | vmld_bodygroup.append(BodyGroupChoice([])) 52 | elif isinstance(sub_model, LoddedSubModel): 53 | sub_model_name = sanitize_name(sub_model.name) 54 | vmdl_bodygroup_choice = BodyGroupChoice([]) 55 | vmld_bodygroup.append(vmdl_bodygroup_choice) 56 | 57 | for i, lod in enumerate(sub_model.lods): 58 | if i in lod_groups: 59 | log_group = lod_groups[i] 60 | else: 61 | if lod.switch_point < 0: 62 | switch_point = 99999999.0 63 | else: 64 | switch_point = lod.switch_point 65 | log_group = lod_groups[i] = LODGroup(switch_point) 66 | vmdl_lod_group_list.append(log_group) 67 | 68 | if i == 0: 69 | mesh_filename = sub_model_name 70 | else: 71 | mesh_filename = f"{sub_model_name}_LOD{i}" 72 | vmdl_bodygroup_choice.meshes.append(mesh_filename) 73 | dm_model = export_dmx(lod, model, sub_model) 74 | model_content_path = normalize_path(model_path.with_suffix("")) 75 | 76 | log_group.meshes.append(mesh_filename) 77 | render_mesh = RenderMeshFile(mesh_filename, 78 | (model_content_path / (mesh_filename + ".dmx")).as_posix()) 79 | vmdl_render_mesh_list.append(render_mesh) 80 | 81 | dmx_output_path = content_path / model_content_path 82 | dmx_path = dmx_output_path / (mesh_filename + ".dmx") 83 | logger.info(f"Writting mesh file to {dmx_path}") 84 | dm_model.save(dmx_path, "binary", 9) 85 | else: 86 | logger.warn("Unknown submodel type") 87 | continue 88 | vmdl_data = vmdl.write() 89 | with (content_path / model_path.with_suffix(".vmdl")).open("w", encoding="utf8") as f: 90 | f.write(vmdl_data) 91 | 92 | vmdl_attachtments = vmdl.append(AttachmentList()) 93 | for attachment in model.attachments: 94 | if attachment.parent_type != AttachmentParentType.SINGLE_BONE: 95 | logger.warn(f"Non SINGLE_BONE attachments({attachment.name}) not supported") 96 | continue 97 | vmdl_attachment = Attachment(attachment.name, attachment.parent_name, attachment.translation, 98 | quaternion_to_euler(attachment.rotation)) 99 | vmdl_attachtments.append(vmdl_attachment) 100 | for material in model.materials: 101 | if material.full_path.endswith(".vmt"): 102 | material_data = VMT(material.buffer, material.full_path) 103 | material.buffer.seek(0) 104 | logger.info(f"Processing Source1 material: {material.full_path}") 105 | converter = choose_material_converter(SourceType.Source1Source, GameType.CS2, material_data.shader, 106 | model.has_shape_keys) 107 | if converter is not None: 108 | vmat_props, textures = converter(Path(material.full_path), material.buffer, cm) 109 | tmp = Path(material.full_path) 110 | materials_output_path = content_path / tmp.parent 111 | material_save_path = materials_output_path / (tmp.stem + ".vmat") 112 | material_save_path.parent.mkdir(parents=True, exist_ok=True) 113 | with material_save_path.open('w') as file: 114 | file.write('// Generated by Source2Converter\r\n') 115 | writer = KVWriter(file) 116 | writer.write(('Layer0', vmat_props), 1, True) 117 | for texture in textures: 118 | texture_save_path = (content_path / texture.filepath) 119 | texture_save_path.parent.mkdir(parents=True, exist_ok=True) 120 | texture.image.save(texture_save_path) 121 | 122 | else: 123 | logger.warn(f"No converter found for {material.full_path} material") 124 | 125 | 126 | def export_dmx(lod, model, sub_model): 127 | dm_model = DmxModel2(model.name) 128 | attribute_names = dm_model.supported_attributes() 129 | for material in model.materials: 130 | dm_model.add_material(sanitize_name(material.name), 131 | normalize_path(material.full_path).with_suffix("")) 132 | bone_names = [] 133 | if model.skeleton is not None: 134 | dm_model.add_skeleton(sanitize_name(sub_model.name) + "_skeleton") 135 | for bone in model.skeleton.bones: 136 | bone_names.append(bone.name) 137 | dm_model.add_bone(bone.name, bone.translation, bone.rotation, bone.parent_name) 138 | mesh = lod.mesh 139 | dm_mesh = dm_model.add_mesh(sub_model.name, len(mesh.shape_keys) > 0) 140 | for mat_id, indices in mesh.strips: 141 | material = model.materials[mat_id] 142 | dm_model.mesh_add_faceset(dm_mesh, sanitize_name(material.name), indices) 143 | dm_model.mesh_add_attribute(dm_mesh, "pos", mesh.vertex_attributes["positions"], Vector3) 144 | dm_model.mesh_add_attribute(dm_mesh, "norm", mesh.vertex_attributes["normals"], Vector3) 145 | dm_model.mesh_add_attribute(dm_mesh, "texco", mesh.vertex_attributes["uv0"], Vector2) 146 | tmp_vertices = mesh.vertex_attributes["positions"] 147 | dimm = tmp_vertices.max() - tmp_vertices.min() 148 | balance_width = dimm * (1 - (99.3 / 100)) 149 | balance = tmp_vertices[:, 0] 150 | balance = np.clip((-balance / balance_width / 2) + 0.5, 0, 1) 151 | dm_model.mesh_add_attribute(dm_mesh, "balance", balance, float) 152 | flex_controllers = {} 153 | for shape_key in mesh.shape_keys: 154 | flex_controller = dm_model.add_flex_controller(shape_key.name, shape_key.stereo, False) 155 | dm_model.flex_controller_add_delta_name(flex_controller, shape_key.name, 0) 156 | flex_controllers[shape_key.name] = flex_controller 157 | 158 | vertex_delta_data = dm_model.mesh_add_delta_state(dm_mesh, shape_key.name) 159 | 160 | vertex_delta_data[attribute_names['pos']] = datamodel.make_array( 161 | shape_key.delta_attributes["positions"], Vector3) 162 | vertex_delta_data[attribute_names['pos'] + "Indices"] = datamodel.make_array( 163 | shape_key.indices, 164 | int) 165 | vertex_delta_data[attribute_names['norm']] = datamodel.make_array( 166 | shape_key.delta_attributes["normals"], Vector3) 167 | vertex_delta_data[attribute_names['norm'] + "Indices"] = datamodel.make_array( 168 | shape_key.indices, 169 | int) 170 | for flex_controller in flex_controllers.values(): 171 | dm_model.flex_controller_finish(flex_controller, len(flex_controller["rawControlNames"])) 172 | if bone_names: 173 | dm_model.mesh_add_bone_weights(dm_mesh, bone_names, mesh.vertex_attributes["blend_weights"], 174 | mesh.vertex_attributes["blend_indices"]) 175 | vertex_data = dm_mesh["bindState"] 176 | vertex_data["flipVCoordinates"] = False 177 | vertex_data["jointCount"] = 3 178 | return dm_model 179 | 180 | 181 | main() 182 | -------------------------------------------------------------------------------- /material_converter.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | from typing import Tuple, TypeVar, Type 4 | 5 | from SourceIO.library.source1.vmt import VMT 6 | from shader_converters.eyerefract import EyeRefract 7 | from shader_converters.lightmappedgeneric import LightmappedGeneric 8 | from shader_converters.shader_base import ShaderBase, GameType 9 | from shader_converters.unlitgeneric import UnlitGeneric 10 | from shader_converters.vertexlitgeneric import VertexLitGeneric 11 | from utils import normalize_path 12 | 13 | MaterialName = TypeVar('MaterialName', str, str) 14 | CdPath = TypeVar('CdPath', str, str) 15 | MaterialPath = TypeVar('MaterialPath', str, str) 16 | Material = Tuple[MaterialName, CdPath, MaterialPath] 17 | 18 | s1_to_s2_shader = { 19 | "worldvertextransition": LightmappedGeneric, 20 | "lightmappedgeneric": LightmappedGeneric, 21 | "vertexlitgeneric": VertexLitGeneric, 22 | "teeth": VertexLitGeneric, 23 | "unlitgeneric": UnlitGeneric, 24 | "eyes": EyeRefract, 25 | "eyerefract": EyeRefract, 26 | } 27 | 28 | def convert_material(material: Material, s2_output_path: Path, game: GameType = GameType.CS2): 29 | if not (material[0] and material[2]): 30 | return False, f"Failed to open file {material[0]}" 31 | vmt = VMT(material[2], material[0]) 32 | shader_converter: Type[ShaderBase] = s1_to_s2_shader.get(vmt.shader, None) 33 | if shader_converter is None: 34 | # sys.stderr.write(f'Unsupported shader: "{vmt.shader}"\n') 35 | return False, f'Unsupported Source1 shader {vmt.shader}!' 36 | 37 | mat_path = normalize_path(Path(material[1]) / material[0]) 38 | mat_name = mat_path.stem 39 | mat_path = mat_path.parent 40 | converter = shader_converter(mat_name, mat_path, vmt, s2_output_path, game) 41 | try: 42 | converter.convert() 43 | except Exception as ex: 44 | print(f'Failed to convert {material[2]} due to {ex}') 45 | converter.write_vmat() 46 | return True, vmt.shader 47 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy~=1.26.4 2 | Pillow~=10.3.0 3 | -------------------------------------------------------------------------------- /shader_converters/eyerefract.py: -------------------------------------------------------------------------------- 1 | from PIL import ImageOps 2 | 3 | from .shader_base import ShaderBase 4 | 5 | 6 | class EyeRefract(ShaderBase): 7 | def convert(self): 8 | material = self._vmt 9 | vmat_params = self._vmat_params 10 | 11 | base_texture_param = material.get_string('$iris', None) 12 | if base_texture_param is not None: 13 | basetexture = self._textures['color_map'] = self.load_texture(base_texture_param) 14 | self._textures['color_map'] = basetexture.convert('RGB') 15 | vmat_params['TextureColor'] = self.write_texture(self._textures['color_map'].convert("RGB"), 'color') 16 | -------------------------------------------------------------------------------- /shader_converters/lightmappedgeneric.py: -------------------------------------------------------------------------------- 1 | from PIL import ImageOps 2 | 3 | from .shader_base import ShaderBase 4 | 5 | 6 | class LightmappedGeneric(ShaderBase): 7 | 8 | def convert(self): 9 | material = self._vmt 10 | vmat_params = self._vmat_params 11 | 12 | if material.get('proxies', None): 13 | proxies = material.get('proxies') 14 | for proxy_name, proxy_data in proxies.items(): 15 | if proxy_name == 'selectfirstifnonzero': 16 | result_var = proxy_data.get('resultvar') 17 | src1_var = proxy_data.get('srcvar1') 18 | src2_var = proxy_data.get('srcvar2') 19 | src1_value, src1_type = material.get_vector(src1_var, [0]) 20 | if not all(src1_value): 21 | material.data[result_var] = material.get(src2_var) 22 | else: 23 | material.data[result_var] = material.get(src1_var) 24 | 25 | base_texture_param = material.get_string('$basetexture', None) 26 | if base_texture_param is not None: 27 | base_texture = self._textures['color_map'] = self.load_texture(base_texture_param) 28 | 29 | if material.get_int('$basemapalphaphongmask', 0): 30 | self._textures['phong_map'] = base_texture.getchannel('A') 31 | if material.get_int('$basemapalphaenvmapmask', 0): 32 | self._textures['env_map'] = base_texture.getchannel('A') 33 | if material.get_int('$selfillum', 0) and \ 34 | material.get_string('$selfillummask', None) is None: 35 | self._textures['illum_mask'] = base_texture.getchannel('A') 36 | if material.get_int('$translucent', 0) or material.get_int('$alphatest', 0): 37 | self._textures['alpha'] = base_texture.getchannel('A') 38 | if material.get_int('$basealphaenvmapmask', 0): 39 | self._textures['env_map'] = base_texture.getchannel('A') 40 | if material.get_int('$blendtintbybasealpha', 0): 41 | self._textures['color_mask'] = base_texture.getchannel('A') 42 | 43 | normal_texture_param = material.get_string('$bumpmap', None) 44 | if normal_texture_param is None: 45 | normal_texture_param = material.get_string('$normalmap', None) 46 | if normal_texture_param is not None: 47 | normal_texture = self._textures['normal_map'] = self.load_texture(normal_texture_param) 48 | if material.get_int('$basemapalphaphongmask', 0): 49 | self._textures['phong_map'] = normal_texture.getchannel('A') 50 | if material.get_int('$normalmapalphaenvmapmask', 0): 51 | self._textures['env_map'] = normal_texture.getchannel('A') 52 | 53 | env_mask_texture_param = material.get_string('$envmapmask', None) 54 | if material.get_string("$envmap", None) is not None and env_mask_texture_param is not None: 55 | self._textures['env_map'] = self.load_texture(env_mask_texture_param) 56 | 57 | phong_exp_texture_param = material.get_string('$phongexponenttexture', None) 58 | if phong_exp_texture_param is not None: 59 | self._textures['phong_exp_map'] = self.load_texture(phong_exp_texture_param) 60 | 61 | selfillum_mask_texture_param = material.get_string('$selfillummask', None) 62 | if selfillum_mask_texture_param is not None and material.get_int('$selfillum', 0): 63 | self._textures['illum_mask'] = self.load_texture(selfillum_mask_texture_param) 64 | 65 | ao_texture_param = material.get_string('$ambientoccltexture', None) 66 | if ao_texture_param is None: 67 | ao_texture_param = material.get_string('$ambientocclusiontexture', None) 68 | if ao_texture_param is not None: 69 | self._textures['ao_map'] = self.load_texture(ao_texture_param) 70 | 71 | if 'color_map' in self._textures: 72 | vmat_params['TextureColor'] = self.write_texture(self._textures['color_map'].convert("RGB"), 'color') 73 | if 'normal_map' in self._textures and not material.get_int('$ssbump', 0): 74 | vmat_params['TextureNormal'] = self.write_texture(self._textures['normal_map'].convert("RGB"), 'normal', 75 | {"legacy_source1_inverted_normal": 1}) 76 | if 'phong_map' in self._textures: 77 | props = {} 78 | if material.get_int('$phongboost', 0): 79 | props['brightness'] = material.get_float('$phongboost') 80 | vmat_params['TextureAmbientOcclusion'] = self.write_texture(self._textures['phong_map'], 'ao', props) 81 | vmat_params['g_vReflectanceRange'] = [0.0, 0.5] 82 | elif 'env_map' in self._textures: 83 | vmat_params['TextureAmbientOcclusion'] = self.write_texture(self._textures['env_map'], 'ao') 84 | vmat_params['g_flAmbientOcclusionDirectSpecular'] = 0.0 85 | elif 'ao_map' in self._textures: 86 | vmat_params['TextureAmbientOcclusion'] = self.write_texture(self._textures['ao_map'], 'ao') 87 | vmat_params['g_flAmbientOcclusionDirectSpecular'] = 0.0 88 | 89 | if material.get_int('$phong', 0): 90 | vmat_params['F_SPECULAR'] = 1 91 | if 'phong_exp_map' in self._textures: 92 | phong_exp_map_flip = self._textures['phong_exp_map'].convert('RGB') 93 | phong_exp_map_flip = ImageOps.invert(phong_exp_map_flip) 94 | vmat_params['TextureRoughness'] = self.write_texture(phong_exp_map_flip, 'rough') 95 | elif material.get_int('$phongexponent', 0): 96 | spec_value = material.get_int('$phongexponent', 0) 97 | spec_final = (-10642.28 + (254.2042 - -10642.28) / (1 + (spec_value / 2402433e6) ** 0.1705696)) / 255 98 | spec_final *= 1.5 99 | vmat_params['TextureRoughness'] = self._write_vector([spec_final, spec_final, spec_final, 0.0]) 100 | else: 101 | vmat_params['TextureRoughness'] = self._write_vector([60.0, 60.0, 60.0, 0.0]) 102 | 103 | if material.get_int('$selfillum', 0) and 'illum_mask' in self._textures: 104 | vmat_params['F_SELF_ILLUM'] = 1 105 | vmat_params['TextureSelfIllumMask'] = self.write_texture(self._textures['illum_mask']) 106 | if material.get_vector('$selfillumtint', [0, 0, 0])[1] is not None: 107 | value, vtype = material.get_vector('$selfillumtint') 108 | if vtype is int: 109 | value = [v / 255 for v in value] 110 | vmat_params['g_vSelfIllumTint'] = self._write_vector(self.ensure_length(value, 3, 1.0)) 111 | if material.get_int('$selfillummaskscale', 0): 112 | vmat_params['g_flSelfIllumScale'] = material.get_int('$selfillummaskscale') 113 | 114 | if material.get_int('$translucent', 0) and material.get_int('$alphatest', 0): 115 | if material.get_int('$translucent', 0): 116 | vmat_params['F_TRANSLUCENT'] = 1 117 | elif material.get_int('$alphatest', 0): 118 | vmat_params['F_ALPHA_TEST'] = 1 119 | if material.get_int('$additive', 0): 120 | vmat_params['F_ADDITIVE_BLEND'] = 1 121 | vmat_params['TextureTranslucency'] = self.write_texture(self._textures['alpha'], 'trans') 122 | 123 | if material.get_vector('$color', None)[1] is not None: 124 | value, vtype = material.get_vector('$color') 125 | if vtype is int: 126 | value = [v / 255 for v in value] 127 | vmat_params['g_vColorTint'] = self._write_vector(self.ensure_length(value, 3, 1.0)) 128 | elif material.get_vector('$color2', None)[1] is not None: 129 | if material.get_int('$blendtintbybasealpha', 0): 130 | vmat_params['F_TINT_MASK'] = 1 131 | vmat_params['TextureTintMask'] = self.write_texture(self._textures['color_mask'], 'colormask') 132 | value, vtype = material.get_vector('$color2') 133 | if vtype is int: 134 | value = [v / 255 for v in value] 135 | vmat_params['g_vColorTint'] = self._write_vector(self.ensure_length(value, 3, 1.0)) 136 | 137 | if material.get_string('$detail', None) is not None and False: 138 | vmat_params['TextureDetail'] = 'NOT IMPLEMENTED' 139 | vmat_params['F_DETAIL_TEXTURE'] = 2 if material.get_int('$detailblendmode', 0) else 1 140 | if material.get_int('$detailscale', 0): 141 | value = material.get_int('$detailscale', 0) 142 | vmat_params['g_vDetailTexCoordScale'] = f'[{value} {value}]' 143 | if material.get_int('$detailblendfactor', 0): 144 | value = material.get_int('$detailblendfactor', 0) 145 | vmat_params['g_flDetailBlendFactor'] = value 146 | -------------------------------------------------------------------------------- /shader_converters/mouth.py: -------------------------------------------------------------------------------- 1 | from PIL import ImageOps 2 | 3 | from .shader_base import ShaderBase 4 | 5 | 6 | class VertexLitGeneric(ShaderBase): 7 | 8 | def convert(self): 9 | material = self._vmt 10 | vmat_params = self._vmat_params 11 | 12 | if material.get('proxies', None): 13 | proxies = material.get('proxies') 14 | for proxy_name, proxy_data in proxies.items(): 15 | if proxy_name == 'selectfirstifnonzero': 16 | result_var = proxy_data.get('resultvar') 17 | src1_var = proxy_data.get('srcvar1') 18 | src2_var = proxy_data.get('srcvar2') 19 | src1_value, src1_type = material.get_vector(src1_var, [0]) 20 | if not all(src1_value): 21 | material.data[result_var] = material.get(src2_var) 22 | else: 23 | material.data[result_var] = material.get(src1_var) 24 | 25 | base_texture_param = material.get_string('$basetexture', None) 26 | if base_texture_param is not None: 27 | base_texture = self._textures['color_map'] = self.load_texture(base_texture_param) 28 | 29 | if material.get_int('$basemapalphaphongmask', 0): 30 | self._textures['phong_map'] = base_texture.getchannel('A') 31 | if material.get_int('$basemapalphaenvmapmask', 0): 32 | self._textures['env_map'] = base_texture.getchannel('A') 33 | if material.get_int('$selfillum', 0) and \ 34 | material.get_string('$selfillummask', None) is None: 35 | self._textures['illum_mask'] = base_texture.getchannel('A') 36 | if material.get_int('$translucent', 0) or material.get_int('$alphatest', 0): 37 | self._textures['alpha'] = base_texture.getchannel('A') 38 | if material.get_int('$basealphaenvmapmask', 0): 39 | self._textures['env_map'] = base_texture.getchannel('A') 40 | if material.get_int('$blendtintbybasealpha', 0): 41 | self._textures['color_mask'] = base_texture.getchannel('A') 42 | 43 | normal_texture_param = material.get_string('$bumpmap', None) 44 | if normal_texture_param is None: 45 | normal_texture_param = material.get_string('$normalmap', None) 46 | if normal_texture_param is not None: 47 | normal_texture = self._textures['normal_map'] = self.load_texture(normal_texture_param) 48 | if material.get_int('$basemapalphaphongmask', 0): 49 | self._textures['phong_map'] = normal_texture.getchannel('A') 50 | if material.get_int('$normalmapalphaenvmapmask', 0): 51 | self._textures['env_map'] = normal_texture.getchannel('A') 52 | 53 | env_mask_texture_param = material.get_string('$envmapmask', None) 54 | if material.get_string("$envmap", None) is not None and env_mask_texture_param is not None: 55 | self._textures['env_map'] = self.load_texture(env_mask_texture_param) 56 | 57 | phong_exp_texture_param = material.get_string('$phongexponenttexture', None) 58 | if phong_exp_texture_param is not None: 59 | self._textures['phong_exp_map'] = self.load_texture(phong_exp_texture_param) 60 | 61 | selfillum_mask_texture_param = material.get_string('$selfillummask', None) 62 | if selfillum_mask_texture_param is not None and material.get_int('$selfillum', 0): 63 | self._textures['illum_mask'] = self.load_texture(selfillum_mask_texture_param) 64 | 65 | ao_texture_param = material.get_string('$ambientoccltexture', None) 66 | if ao_texture_param is None: 67 | ao_texture_param = material.get_string('$ambientocclusiontexture', None) 68 | if ao_texture_param is not None: 69 | self._textures['ao_map'] = self.load_texture(ao_texture_param) 70 | 71 | if 'color_map' in self._textures: 72 | vmat_params['TextureColor'] = self.write_texture(self._textures['color_map'].convert("RGB"), 'color') 73 | if 'normal_map' in self._textures and not material.get_int('$ssbump', 0): 74 | vmat_params['TextureNormal'] = self.write_texture(self._textures['normal_map'].convert("RGB"), 'normal', 75 | {"legacy_source1_inverted_normal": 1}) 76 | if 'phong_map' in self._textures: 77 | props = {} 78 | if material.get_int('$phongboost', 0): 79 | props['brightness'] = material.get_float('$phongboost') 80 | vmat_params['TextureAmbientOcclusion'] = self.write_texture(self._textures['phong_map'], 'ao', props) 81 | vmat_params['g_vReflectanceRange'] = [0.0, 0.5] 82 | elif 'env_map' in self._textures: 83 | vmat_params['TextureAmbientOcclusion'] = self.write_texture(self._textures['env_map'], 'ao') 84 | vmat_params['g_flAmbientOcclusionDirectSpecular'] = 0.0 85 | elif 'ao_map' in self._textures: 86 | vmat_params['TextureAmbientOcclusion'] = self.write_texture(self._textures['ao_map'], 'ao') 87 | vmat_params['g_flAmbientOcclusionDirectSpecular'] = 0.0 88 | 89 | if material.get_int('$phong', 0): 90 | vmat_params['F_SPECULAR'] = 1 91 | if 'phong_exp_map' in self._textures: 92 | phong_exp_map_flip = self._textures['phong_exp_map'].convert('RGB') 93 | phong_exp_map_flip = ImageOps.invert(phong_exp_map_flip) 94 | vmat_params['TextureRoughness'] = self.write_texture(phong_exp_map_flip, 'rough') 95 | elif material.get_int('$phongexponent', 0): 96 | spec_value = material.get_int('$phongexponent', 0) 97 | spec_final = (-10642.28 + (254.2042 - -10642.28) / (1 + (spec_value / 2402433e6) ** 0.1705696)) / 255 98 | spec_final *= 1.5 99 | vmat_params['TextureRoughness'] = self._write_vector([spec_final, spec_final, spec_final, 0.0]) 100 | else: 101 | vmat_params['TextureRoughness'] = self._write_vector([60.0, 60.0, 60.0, 0.0]) 102 | 103 | if material.get_int('$selfillum', 0) and 'illum_mask' in self._textures: 104 | vmat_params['F_SELF_ILLUM'] = 1 105 | vmat_params['TextureSelfIllumMask'] = self.write_texture(self._textures['illum_mask']) 106 | if material.get_vector('$selfillumtint', [0, 0, 0])[1] is not None: 107 | value, vtype = material.get_vector('$selfillumtint') 108 | if vtype is int: 109 | value = [v / 255 for v in value] 110 | vmat_params['g_vSelfIllumTint'] = self._write_vector(self.ensure_length(value, 3, 1.0)) 111 | if material.get_int('$selfillummaskscale', 0): 112 | vmat_params['g_flSelfIllumScale'] = material.get_int('$selfillummaskscale') 113 | 114 | if material.get_int('$translucent', 0) and material.get_int('$alphatest', 0): 115 | if material.get_int('$translucent', 0): 116 | vmat_params['F_TRANSLUCENT'] = 1 117 | elif material.get_int('$alphatest', 0): 118 | vmat_params['F_ALPHA_TEST'] = 1 119 | if material.get_int('$additive', 0): 120 | vmat_params['F_ADDITIVE_BLEND'] = 1 121 | vmat_params['TextureTranslucency'] = self.write_texture(self._textures['alpha'], 'trans') 122 | 123 | if material.get_vector('$color', None)[1] is not None: 124 | value, vtype = material.get_vector('$color') 125 | if vtype is int: 126 | value = [v / 255 for v in value] 127 | vmat_params['g_vColorTint'] = self._write_vector(self.ensure_length(value, 3, 1.0)) 128 | elif material.get_vector('$color2', None)[1] is not None: 129 | if material.get_int('$blendtintbybasealpha', 0): 130 | vmat_params['F_TINT_MASK'] = 1 131 | vmat_params['TextureTintMask'] = self.write_texture(self._textures['color_mask'], 'colormask') 132 | value, vtype = material.get_vector('$color2') 133 | if vtype is int: 134 | value = [v / 255 for v in value] 135 | vmat_params['g_vColorTint'] = self._write_vector(self.ensure_length(value, 3, 1.0)) 136 | 137 | if material.get_string('$detail', None) is not None and False: 138 | vmat_params['TextureDetail'] = 'NOT IMPLEMENTED' 139 | vmat_params['F_DETAIL_TEXTURE'] = 2 if material.get_int('$detailblendmode', 0) else 1 140 | if material.get_int('$detailscale', 0): 141 | value = material.get_int('$detailscale', 0) 142 | vmat_params['g_vDetailTexCoordScale'] = f'[{value} {value}]' 143 | if material.get_int('$detailblendfactor', 0): 144 | value = material.get_int('$detailblendfactor', 0) 145 | vmat_params['g_flDetailBlendFactor'] = value 146 | -------------------------------------------------------------------------------- /shader_converters/shader_base.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from typing import Dict 4 | from enum import Enum 5 | 6 | import numpy as np 7 | from PIL import Image 8 | 9 | from SourceIO.library.source1.vmt import VMT 10 | from SourceIO.library.shared.content_providers.content_manager import ContentManager 11 | from SourceIO.library.source1.vtf import load_texture 12 | from SourceIO.library.utils.s1_keyvalues import KVWriter 13 | 14 | from SourceIO.library.utils.logging_stub import BPYLoggingManager 15 | 16 | log_manager = BPYLoggingManager() 17 | 18 | 19 | class GameType(Enum): 20 | HLA = "HL:A" 21 | SBOX = "S&Box" 22 | CS2 = "CS2" 23 | 24 | 25 | shader_names = { 26 | GameType.CS2: "csgo_complex.vfx", 27 | GameType.SBOX: "complex.vfx", 28 | GameType.HLA: "vr_complex.vfx", 29 | } 30 | 31 | 32 | class ShaderBase: 33 | def __init__(self, name, sub_path, vmt: VMT, output_path: Path, game: GameType = GameType.CS2): 34 | self.name = name 35 | self.sub_path = sub_path 36 | self._vmt = vmt 37 | self._output_path = output_path 38 | self._textures: Dict[str, Image.Image] = {} 39 | self._vmat_params = {'shader': shader_names[game], 'F_MORPH_SUPPORTED': 1} 40 | 41 | self.logger = log_manager.get_logger(self.__class__.__name__) 42 | 43 | def convert(self): 44 | raise NotImplemented('Implement me') 45 | 46 | @staticmethod 47 | def _write_vector(array): 48 | return f"[{' '.join(map(str, array))}]" 49 | 50 | def write_vmat(self): 51 | save_path = self._output_path / self.sub_path / f'{self.name}.vmat' 52 | save_path.parent.mkdir(parents=True, exist_ok=True) 53 | with save_path.open('w') as file: 54 | file.write('// Generated by Source2Converter\r\n') 55 | writer = KVWriter(file) 56 | writer.write(('Layer0', self._vmat_params), 1, True) 57 | 58 | def load_texture(self, texture_path): 59 | content_manager = ContentManager() 60 | self.logger.info(f"Loading texture {texture_path}") 61 | texture_data = content_manager.find_texture(texture_path) 62 | if texture_path: 63 | texture_data, width, height = load_texture(texture_data) 64 | texture_data = np.flipud(texture_data) 65 | texture = Image.frombytes("RGBA", (height, width), (texture_data * 255).astype(np.uint8)) 66 | return texture 67 | else: 68 | self.logger.error(f"Texture {texture_path} not found!") 69 | return None 70 | 71 | @staticmethod 72 | def _write_settings(filename: Path, props: dict): 73 | with filename.open('w') as settings: 74 | settings.write('"settings"\n{\n') 75 | for _key, _value in props.items(): 76 | settings.write(f'\t"{_key}"\t{_value}\n') 77 | settings.write('}\n') 78 | 79 | def write_texture(self, image: Image.Image, suffix='unk', settings=None): 80 | save_path = self._output_path / 'materials' / self.sub_path 81 | os.makedirs(save_path, exist_ok=True) 82 | save_path /= f'{self.name}_{suffix}.tga' 83 | self.logger.info(f'Wrote texture to {save_path}') 84 | image.save(save_path) 85 | if settings is not None and isinstance(settings, dict): 86 | self._write_settings(save_path.with_suffix('.txt'), settings) 87 | return str(save_path.relative_to(self._output_path)) 88 | 89 | @staticmethod 90 | def ensure_length(array: list, length, filler): 91 | if len(array) < length: 92 | array.extend([filler] * (length - len(array))) 93 | return array 94 | elif len(array) > length: 95 | return array[:length] 96 | return array 97 | -------------------------------------------------------------------------------- /shader_converters/unlitgeneric.py: -------------------------------------------------------------------------------- 1 | from .shader_base import ShaderBase 2 | 3 | 4 | class UnlitGeneric(ShaderBase): 5 | def convert(self): 6 | material = self._vmt 7 | vmat_params = self._vmat_params 8 | 9 | base_texture_param = material.get_string('$basetexture', None) 10 | if base_texture_param is not None: 11 | base_texture = self._textures['color_map'] = self.load_texture(base_texture_param) 12 | if material.get_int('$translucent', 0) or material.get_int('$alphatest', 0): 13 | self._textures['alpha'] = base_texture.getchannel('A') 14 | 15 | vmat_params['F_SELF_ILLUM'] = 1 16 | if 'color_map' in self._textures: 17 | vmat_params['TextureColor'] = self.write_texture(self._textures['color_map'].convert("RGB"), 'color') 18 | 19 | if material.get_vector('$color', None)[1] is not None: 20 | value, vtype = material.get_vector('$color') 21 | if vtype is int: 22 | value = [v / 255 for v in value] 23 | vmat_params['g_vColorTint'] = self._write_vector(self.ensure_length(value, 3, 1.0)) 24 | if material.get_vector('$color2', None)[1] is not None: 25 | value, vtype = material.get_vector('$color2') 26 | if vtype is int: 27 | value = [v / 255 for v in value] 28 | vmat_params['g_vColorTint'] = self._write_vector(self.ensure_length(value, 3, 1.0)) 29 | -------------------------------------------------------------------------------- /shader_converters/vertexlitgeneric.py: -------------------------------------------------------------------------------- 1 | from PIL import ImageOps 2 | 3 | from .shader_base import ShaderBase 4 | 5 | 6 | class VertexLitGeneric(ShaderBase): 7 | 8 | def convert(self): 9 | material = self._vmt 10 | vmat_params = self._vmat_params 11 | 12 | if material.get('proxies', None): 13 | proxies = material.get('proxies') 14 | for proxy_name, proxy_data in proxies.items(): 15 | if proxy_name == 'selectfirstifnonzero': 16 | result_var = proxy_data.get('resultvar') 17 | src1_var = proxy_data.get('srcvar1') 18 | src2_var = proxy_data.get('srcvar2') 19 | src1_value, src1_type = material.get_vector(src1_var, [0]) 20 | if not all(src1_value): 21 | material.data[result_var] = material.get(src2_var) 22 | else: 23 | material.data[result_var] = material.get(src1_var) 24 | 25 | base_texture_param = material.get_string('$basetexture', None) 26 | if base_texture_param is not None: 27 | base_texture = self._textures['color_map'] = self.load_texture(base_texture_param) 28 | 29 | if material.get_int('$basemapalphaphongmask', 0): 30 | self._textures['phong_map'] = base_texture.getchannel('A') 31 | if material.get_int('$basemapalphaenvmapmask', 0): 32 | self._textures['env_map'] = base_texture.getchannel('A') 33 | if material.get_int('$selfillum', 0) and \ 34 | material.get_string('$selfillummask', None) is None: 35 | self._textures['illum_mask'] = base_texture.getchannel('A') 36 | if material.get_int('$translucent', 0) or material.get_int('$alphatest', 0): 37 | self._textures['alpha'] = base_texture.getchannel('A') 38 | if material.get_int('$basealphaenvmapmask', 0): 39 | self._textures['env_map'] = base_texture.getchannel('A') 40 | if material.get_int('$blendtintbybasealpha', 0): 41 | self._textures['color_mask'] = base_texture.getchannel('A') 42 | 43 | normal_texture_param = material.get_string('$bumpmap', None) 44 | if normal_texture_param is None: 45 | normal_texture_param = material.get_string('$normalmap', None) 46 | if normal_texture_param is not None: 47 | normal_texture = self._textures['normal_map'] = self.load_texture(normal_texture_param) 48 | if material.get_int('$basemapalphaphongmask', 0): 49 | self._textures['phong_map'] = normal_texture.getchannel('A') 50 | if material.get_int('$normalmapalphaenvmapmask', 0): 51 | self._textures['env_map'] = normal_texture.getchannel('A') 52 | 53 | env_mask_texture_param = material.get_string('$envmapmask', None) 54 | if material.get_string("$envmap", None) is not None and env_mask_texture_param is not None: 55 | self._textures['env_map'] = self.load_texture(env_mask_texture_param) 56 | 57 | phong_exp_texture_param = material.get_string('$phongexponenttexture', None) 58 | if phong_exp_texture_param is not None: 59 | self._textures['phong_exp_map'] = self.load_texture(phong_exp_texture_param) 60 | 61 | selfillum_mask_texture_param = material.get_string('$selfillummask', None) 62 | if selfillum_mask_texture_param is not None and material.get_int('$selfillum', 0): 63 | self._textures['illum_mask'] = self.load_texture(selfillum_mask_texture_param) 64 | 65 | ao_texture_param = material.get_string('$ambientoccltexture', None) 66 | if ao_texture_param is None: 67 | ao_texture_param = material.get_string('$ambientocclusiontexture', None) 68 | if ao_texture_param is not None: 69 | self._textures['ao_map'] = self.load_texture(ao_texture_param) 70 | 71 | if 'color_map' in self._textures: 72 | vmat_params['TextureColor'] = self.write_texture(self._textures['color_map'].convert("RGB"), 'color') 73 | if 'normal_map' in self._textures and not material.get_int('$ssbump', 0): 74 | vmat_params['TextureNormal'] = self.write_texture(self._textures['normal_map'].convert("RGB"), 'normal', 75 | {"legacy_source1_inverted_normal": 1}) 76 | if 'phong_map' in self._textures: 77 | props = {} 78 | if material.get_int('$phongboost', 0): 79 | props['brightness'] = material.get_float('$phongboost') 80 | vmat_params['TextureAmbientOcclusion'] = self.write_texture(self._textures['phong_map'], 'ao', props) 81 | vmat_params['g_vReflectanceRange'] = [0.0, 0.5] 82 | elif 'env_map' in self._textures: 83 | vmat_params['TextureAmbientOcclusion'] = self.write_texture(self._textures['env_map'], 'ao') 84 | vmat_params['g_flAmbientOcclusionDirectSpecular'] = 0.0 85 | elif 'ao_map' in self._textures: 86 | vmat_params['TextureAmbientOcclusion'] = self.write_texture(self._textures['ao_map'], 'ao') 87 | vmat_params['g_flAmbientOcclusionDirectSpecular'] = 0.0 88 | 89 | if material.get_int('$phong', 0): 90 | vmat_params['F_SPECULAR'] = 1 91 | if 'phong_exp_map' in self._textures: 92 | phong_exp_map_flip = ImageOps.invert(self._textures['phong_exp_map'].getchannel("R")) 93 | vmat_params['TextureRoughness'] = self.write_texture(phong_exp_map_flip, 'rough') 94 | elif material.get_int('$phongexponent', 0): 95 | spec_value = material.get_int('$phongexponent', 0) 96 | spec_final = (-10642.28 + (254.2042 - -10642.28) / (1 + (spec_value / 2402433e6) ** 0.1705696)) / 255 97 | spec_final *= 1.5 98 | vmat_params['TextureRoughness'] = self._write_vector([spec_final, spec_final, spec_final, 0.0]) 99 | else: 100 | vmat_params['TextureRoughness'] = self._write_vector([60.0, 60.0, 60.0, 0.0]) 101 | 102 | if material.get_int('$selfillum', 0) and 'illum_mask' in self._textures: 103 | vmat_params['F_SELF_ILLUM'] = 1 104 | vmat_params['TextureSelfIllumMask'] = self.write_texture(self._textures['illum_mask']) 105 | if material.get_vector('$selfillumtint', [0, 0, 0])[1] is not None: 106 | value, vtype = material.get_vector('$selfillumtint') 107 | if vtype is int: 108 | value = [v / 255 for v in value] 109 | vmat_params['g_vSelfIllumTint'] = self._write_vector(self.ensure_length(value, 3, 1.0)) 110 | if material.get_int('$selfillummaskscale', 0): 111 | vmat_params['g_flSelfIllumScale'] = material.get_int('$selfillummaskscale') 112 | 113 | if material.get_int('$translucent', 0) and material.get_int('$alphatest', 0): 114 | if material.get_int('$translucent', 0): 115 | vmat_params['F_TRANSLUCENT'] = 1 116 | elif material.get_int('$alphatest', 0): 117 | vmat_params['F_ALPHA_TEST'] = 1 118 | if material.get_int('$additive', 0): 119 | vmat_params['F_ADDITIVE_BLEND'] = 1 120 | vmat_params['TextureTranslucency'] = self.write_texture(self._textures['alpha'], 'trans') 121 | 122 | if material.get_vector('$color', None)[1] is not None: 123 | value, vtype = material.get_vector('$color') 124 | if vtype is int: 125 | value = [v / 255 for v in value] 126 | vmat_params['g_vColorTint'] = self._write_vector(self.ensure_length(value, 3, 1.0)) 127 | elif material.get_vector('$color2', None)[1] is not None: 128 | if material.get_int('$blendtintbybasealpha', 0): 129 | vmat_params['F_TINT_MASK'] = 1 130 | vmat_params['TextureTintMask'] = self.write_texture(self._textures['color_mask'], 'colormask') 131 | value, vtype = material.get_vector('$color2') 132 | if vtype is int: 133 | value = [v / 255 for v in value] 134 | vmat_params['g_vColorTint'] = self._write_vector(self.ensure_length(value, 3, 1.0)) 135 | 136 | if material.get_string('$detail', None) is not None and False: 137 | vmat_params['TextureDetail'] = 'NOT IMPLEMENTED' 138 | vmat_params['F_DETAIL_TEXTURE'] = 2 if material.get_int('$detailblendmode', 0) else 1 139 | if material.get_int('$detailscale', 0): 140 | value = material.get_int('$detailscale', 0) 141 | vmat_params['g_vDetailTexCoordScale'] = f'[{value} {value}]' 142 | if material.get_int('$detailblendfactor', 0): 143 | value = material.get_int('$detailblendfactor', 0) 144 | vmat_params['g_flDetailBlendFactor'] = value 145 | -------------------------------------------------------------------------------- /source2converter/materials/__init__.py: -------------------------------------------------------------------------------- 1 | from .source1 import vertex_lit_generic -------------------------------------------------------------------------------- /source2converter/materials/material_converter_tags.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | from pathlib import Path 4 | from typing import Callable, Optional 5 | 6 | from SourceIO.library.shared.content_providers.content_manager import ContentManager 7 | from SourceIO.library.utils import Buffer 8 | from SourceIO.logger import SourceLogMan 9 | from source2converter.materials.types import ValveMaterial, ValveTexture 10 | 11 | log_manager = SourceLogMan() 12 | logger = log_manager.get_logger('MDL Converter Tags') 13 | 14 | 15 | class SourceType(Enum): 16 | UnknownSource = "Unknown engine" 17 | Source1Source = "Source1 engine" 18 | 19 | 20 | class GameType(Enum): 21 | UnspecifiedGame = "UnspecifiedGame" 22 | CS2 = "Counter-Strike 2" 23 | HLA = "Half-Life: Alyx" 24 | 25 | 26 | @dataclass(slots=True) 27 | class MaterialConverterTag: 28 | source: SourceType 29 | game: GameType 30 | type: str 31 | morph_support: bool 32 | 33 | 34 | MaterialConvertFunction = Callable[[Path, Buffer, ContentManager], tuple[ValveMaterial, list[ValveTexture]]] 35 | MATERIAL_CONVERTERS: list[tuple[MaterialConverterTag, MaterialConvertFunction]] = [] 36 | 37 | 38 | def register_material_converter(source: SourceType, game: GameType, type: str, morph_support: bool = False): 39 | def inner(func: MaterialConvertFunction) -> MaterialConvertFunction: 40 | MATERIAL_CONVERTERS.append((MaterialConverterTag(source, game, type, morph_support), func)) 41 | return func 42 | 43 | return inner 44 | 45 | 46 | def choose_material_converter(source: SourceType, game: GameType, mat_type: str, 47 | morph_support: bool = False) -> Optional[MaterialConvertFunction]: 48 | best_match = None 49 | best_score = 0 # Start with a score lower than any possible match score 50 | 51 | for handler_tag, handler_func in MATERIAL_CONVERTERS: 52 | score = 0 53 | # Check ident and version match 54 | if handler_tag.source == source and handler_tag.game == game and handler_tag.type == mat_type: 55 | if morph_support == handler_tag.morph_support: 56 | score += 2 57 | score += 2 # Base score for ident and version match 58 | 59 | # Update best match if this handler has a higher score 60 | if score > best_score: 61 | best_score = score 62 | best_match = handler_func 63 | if best_match is None: 64 | logger.error(f'Could not find converter from {source.value!r} {mat_type} ' 65 | f'{"with morph support" if morph_support else "without morph support"} to {game.value!r}') 66 | return best_match 67 | -------------------------------------------------------------------------------- /source2converter/materials/source1/common.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import numpy as np 4 | from PIL import Image 5 | 6 | from SourceIO.library.shared.content_providers.content_manager import ContentManager 7 | from SourceIO.logger import SourceLogMan 8 | from SourceIO.library.source1.vtf import load_texture as load_vtf 9 | from source2converter.materials.types import ValveTexture 10 | 11 | log_manager = SourceLogMan() 12 | logger = log_manager.get_logger('Material converter') 13 | 14 | 15 | def load_texture(texture_path, content_manager: ContentManager): 16 | logger.info(f"Loading texture {texture_path}") 17 | texture_data = content_manager.find_texture(texture_path) 18 | if texture_path: 19 | texture_data, width, height = load_vtf(texture_data) 20 | texture_data = np.flipud(texture_data) 21 | texture = Image.frombytes("RGBA", (height, width), (texture_data * 255).astype(np.uint8)) 22 | return texture 23 | else: 24 | logger.error(f"Texture {texture_path} not found!") 25 | return None 26 | 27 | 28 | def write_texture(export_texture_list: list[ValveTexture], image: Image.Image, name: str, content_path: Path, 29 | **settings): 30 | output_path = content_path / (name + ".png") 31 | export_texture_list.append(ValveTexture(output_path, image, settings)) 32 | return output_path.as_posix() 33 | 34 | 35 | def write_vector(array): 36 | return f"[{' '.join(map(str, array))}]" 37 | 38 | 39 | def ensure_length(arr: list, length, filler): 40 | if len(arr) < length: 41 | arr.extend([filler] * (length - len(arr))) 42 | return arr 43 | elif len(arr) > length: 44 | return arr[:length] 45 | return arr 46 | -------------------------------------------------------------------------------- /source2converter/materials/source1/vertex_lit_generic.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import numpy as np 4 | from PIL import ImageChops, Image, ImageOps 5 | 6 | from SourceIO.library.shared.content_providers.content_manager import ContentManager 7 | from SourceIO.library.source1.vmt import VMT 8 | from SourceIO.library.utils import Buffer 9 | from source2converter.materials.material_converter_tags import register_material_converter, SourceType, GameType 10 | from source2converter.materials.source1.common import load_texture, write_texture, write_vector, ensure_length 11 | 12 | def phong_to_pbr_roughness(phongexponent, phongboost, max_observed_phongexponent=256): 13 | # Normalize phongexponent to a 0-1 range, assuming max_observed_phongexponent as a reference maximum 14 | normalized_exponent = np.clip(phongexponent / max_observed_phongexponent, 0, 1) 15 | 16 | # Convert normalized exponent to a preliminary roughness value 17 | # Assuming an inverse relationship; high exponent means low roughness 18 | preliminary_roughness = 1 - normalized_exponent 19 | 20 | # Adjust roughness based on phongboost, this adjustment is heuristic and may need tuning 21 | # Assuming phongboost scales between 0 and 10 as a common range; adjust if your range differs 22 | boost_adjustment = np.log1p(phongboost) / np.log1p(50) # Logarithmic adjustment for smoother scaling 23 | 24 | # Final roughness combines preliminary roughness with an adjustment based on phongboost 25 | # The direction and magnitude of this adjustment can be tweaked as necessary 26 | final_roughness = np.clip(preliminary_roughness + (boost_adjustment * 0.1) - 0.05, 0, 1) 27 | 28 | return final_roughness 29 | 30 | @register_material_converter(SourceType.Source1Source, GameType.CS2, "vertexlitgeneric", True) 31 | def convert_vertex_lit_generic_flexed(material_path: Path, buffer: Buffer, content_manager: ContentManager, 32 | enable_flexes: bool = True): 33 | material = VMT(buffer, material_path.as_posix()) 34 | texture_output_path = material_path.parent 35 | 36 | textures = {} 37 | if material.get('proxies', None): 38 | proxies = material.get('proxies') 39 | for proxy_name, proxy_data in proxies.items(): 40 | if proxy_name == 'selectfirstifnonzero': 41 | result_var = proxy_data.get('resultvar') 42 | src1_var = proxy_data.get('srcvar1') 43 | src2_var = proxy_data.get('srcvar2') 44 | src1_value, src1_type = material.get_vector(src1_var, [0]) 45 | if not all(src1_value): 46 | material.data[result_var] = material.get(src2_var) 47 | else: 48 | material.data[result_var] = material.get(src1_var) 49 | 50 | base_texture_param = material.get_string('$basetexture', None) 51 | if base_texture_param is not None: 52 | base_texture = textures['color_map'] = load_texture(base_texture_param, content_manager) 53 | 54 | if material.get_int('$basemapalphaphongmask', 0): 55 | textures['phong_map'] = base_texture.getchannel('A') 56 | if (material.get_int('$basemapalphaenvmapmask', 0) or 57 | material.get_int('$basealphaenvmapmask', 0)): 58 | textures['env_map'] = base_texture.getchannel('A') 59 | if (material.get_int('$selfillum', 0) and 60 | material.get_string('$selfillummask', None) is None): 61 | textures['illum_mask'] = base_texture.getchannel('A') 62 | if (material.get_int('$translucent', 0) or 63 | material.get_int('$alphatest', 0)): 64 | textures['alpha'] = base_texture.getchannel('A') 65 | if material.get_int('$blendtintbybasealpha', 0): 66 | textures['color_mask'] = base_texture.getchannel('A') 67 | 68 | normal_texture_param = material.get_string('$bumpmap', None) 69 | if normal_texture_param is None: 70 | normal_texture_param = material.get_string('$normalmap', None) 71 | if normal_texture_param is not None: 72 | normal_texture = textures['normal_map'] = load_texture(normal_texture_param, content_manager) 73 | if material.get_int('$normalmapalphaenvmapmask', 0): 74 | textures['env_map'] = normal_texture.getchannel('A') 75 | 76 | env_mask_texture_param = material.get_string('$envmapmask', None) 77 | if material.get_string("$envmap", None) is not None and env_mask_texture_param is not None: 78 | textures['env_map'] = load_texture(env_mask_texture_param, content_manager) 79 | 80 | phong_exp_texture_param = material.get_string('$phongexponenttexture', None) 81 | if phong_exp_texture_param is not None: 82 | textures['phong_exp_map'] = load_texture(phong_exp_texture_param, content_manager) 83 | 84 | selfillum_mask_texture_param = material.get_string('$selfillummask', None) 85 | if selfillum_mask_texture_param is not None and material.get_int('$selfillum', 0): 86 | textures['illum_mask'] = load_texture(selfillum_mask_texture_param, content_manager) 87 | 88 | ao_texture_param = material.get_string('$ambientoccltexture', None) 89 | if ao_texture_param is None: 90 | ao_texture_param = material.get_string('$ambientocclusiontexture', None) 91 | if ao_texture_param is not None: 92 | textures['ao_map'] = load_texture(ao_texture_param, content_manager) 93 | # TODO: basemapluminancephongmask 94 | params = {} 95 | exported_textures = [] 96 | if enable_flexes: 97 | params["F_MORPH_SUPPORTED"] = 1 98 | params["shader"] = "csgo_character.vfx" 99 | else: 100 | params["shader"] = "csgo_complex.vfx" 101 | 102 | if 'color_map' in textures: 103 | params['TextureColor'] = write_texture(exported_textures, textures['color_map'].convert("RGB"), 104 | material_path.stem + '_color', texture_output_path) 105 | 106 | if 'normal_map' in textures and not material.get_int('$ssbump', 0): 107 | tmp = textures['normal_map'].convert("RGB") 108 | r, g, b = tmp.split() 109 | g = ImageChops.invert(g) 110 | tmp = Image.merge('RGB', (r, g, b)) 111 | params['TextureNormal'] = write_texture(exported_textures, tmp, material_path.stem + '_normal', 112 | texture_output_path) 113 | 114 | if 'phong_map' in textures: 115 | props = {} 116 | if material.get_int('$phongboost', 0): 117 | props['brightness'] = material.get_float('$phongboost') 118 | params['TextureAmbientOcclusion'] = write_texture(exported_textures, textures['phong_map'], 119 | material_path.stem + '_ao', 120 | texture_output_path, **props) 121 | params['g_vReflectanceRange'] = [0.0, 0.5] 122 | elif 'env_map' in textures: 123 | params['TextureAmbientOcclusion'] = write_texture(exported_textures, textures['env_map'], 124 | material_path.stem + '_ao', 125 | texture_output_path) 126 | params['g_flAmbientOcclusionDirectSpecular'] = 0.0 127 | elif 'ao_map' in textures: 128 | params['TextureAmbientOcclusion'] = write_texture(exported_textures, textures['ao_map'], 129 | material_path.stem + '_ao', 130 | texture_output_path) 131 | params['g_flAmbientOcclusionDirectSpecular'] = 0.0 132 | 133 | if material.get_int('$phong', 0): 134 | params['F_SPECULAR'] = 1 135 | exponent = material.get_int('$phongexponent', 0) 136 | boost = material.get_int('$phongboost', 1) 137 | if 'phong_exp_map' in textures: 138 | phong_exp_map_flip = ImageOps.invert(textures['phong_exp_map'].getchannel("R")) 139 | params['TextureRoughness'] = write_texture(exported_textures, phong_exp_map_flip, 140 | material_path.stem + '_rough', 141 | texture_output_path) 142 | elif exponent: 143 | spec_value = exponent 144 | spec_final = (-10642.28 + (254.2042 - -10642.28) / (1 + (spec_value / 2402433e6) ** 0.1705696)) / 255 145 | spec_final = spec_final * 255/boost 146 | spec_final = min(255, max(0, spec_final*1.5)) 147 | params['TextureRoughness'] = write_vector([spec_final, spec_final, spec_final, 0.0]) 148 | else: 149 | params['TextureRoughness'] = write_vector([60.0, 60.0, 60.0, 0.0]) 150 | 151 | if material.get_int('$selfillum', 0) and 'illum_mask' in textures: 152 | params['F_SELF_ILLUM'] = 1 153 | params['TextureSelfIllumMask'] = write_texture(exported_textures, textures['illum_mask'], 154 | material_path.stem + 'illum_mask', 155 | texture_output_path) 156 | if material.get_vector('$selfillumtint', [0, 0, 0])[1] is not None: 157 | value, vtype = material.get_vector('$selfillumtint') 158 | if vtype is int: 159 | value = [v / 255 for v in value] 160 | params['g_vSelfIllumTint'] = write_vector(ensure_length(value, 3, 1.0)) 161 | if material.get_int('$selfillummaskscale', 0): 162 | params['g_flSelfIllumScale'] = material.get_int('$selfillummaskscale') 163 | 164 | if material.get_int('$translucent', 0) and material.get_int('$alphatest', 0): 165 | if material.get_int('$translucent', 0): 166 | params['F_TRANSLUCENT'] = 1 167 | elif material.get_int('$alphatest', 0): 168 | params['F_ALPHA_TEST'] = 1 169 | if material.get_int('$additive', 0): 170 | params['F_ADDITIVE_BLEND'] = 1 171 | params['TextureTranslucency'] = write_texture(exported_textures, textures['alpha'], 172 | material_path.stem + 'trans', 173 | texture_output_path) 174 | 175 | if material.get_vector('$color', None)[1] is not None: 176 | value, vtype = material.get_vector('$color') 177 | if vtype is int: 178 | value = [v / 255 for v in value] 179 | params['g_vColorTint'] = write_vector(ensure_length(value, 3, 1.0)) 180 | elif material.get_vector('$color2', None)[1] is not None: 181 | if material.get_int('$blendtintbybasealpha', 0): 182 | params['F_TINT_MASK'] = 1 183 | params['TextureTintMask'] = write_texture(exported_textures, textures['color_mask'], 184 | material_path.stem + 'colormask', 185 | texture_output_path) 186 | value, vtype = material.get_vector('$color2') 187 | if vtype is int: 188 | value = [v / 255 for v in value] 189 | params['g_vColorTint'] = write_vector(ensure_length(value, 3, 1.0)) 190 | 191 | if material.get_string('$detail', None) is not None and False: 192 | params['TextureDetail'] = 'NOT IMPLEMENTED' 193 | params['F_DETAIL_TEXTURE'] = 2 if material.get_int('$detailblendmode', 0) else 1 194 | if material.get_int('$detailscale', 0): 195 | value = material.get_int('$detailscale', 0) 196 | params['g_vDetailTexCoordScale'] = f'[{value} {value}]' 197 | if material.get_int('$detailblendfactor', 0): 198 | value = material.get_int('$detailblendfactor', 0) 199 | params['g_flDetailBlendFactor'] = value 200 | 201 | print("Unused params:") 202 | for k, v in material.get_unvisited_params().items(): 203 | print(f"{k} = {v}") 204 | 205 | return params, exported_textures 206 | 207 | 208 | @register_material_converter(SourceType.Source1Source, GameType.CS2, "vertexlitgeneric", False) 209 | def convert_vertex_lit_generic(material_path: Path, buffer: Buffer, content_manager: ContentManager): 210 | return convert_vertex_lit_generic_flexed(material_path, buffer, content_manager, False) 211 | -------------------------------------------------------------------------------- /source2converter/materials/types.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from pathlib import Path 3 | from typing import Any 4 | 5 | from PIL import Image 6 | 7 | 8 | class ValveMaterial: 9 | pass 10 | 11 | 12 | @dataclass 13 | class ValveTexture: 14 | filepath: Path 15 | image: Image.Image 16 | settings: dict[str, Any] 17 | -------------------------------------------------------------------------------- /source2converter/mdl/__init__.py: -------------------------------------------------------------------------------- 1 | from . import v49 2 | 3 | from .model_converter_tags import choose_model_converter, register_model_converter 4 | -------------------------------------------------------------------------------- /source2converter/mdl/model_converter_tags.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from pathlib import Path 3 | from typing import Callable, Optional 4 | 5 | from SourceIO.library.shared.app_id import SteamAppId 6 | from SourceIO.library.shared.content_providers.content_manager import ContentManager 7 | from SourceIO.library.utils import Buffer 8 | from SourceIO.logger import SourceLogMan 9 | from source2converter.model import Model 10 | 11 | log_manager = SourceLogMan() 12 | logger = log_manager.get_logger('MDL Converter Tags') 13 | 14 | 15 | @dataclass(slots=True) 16 | class ModelConverterTag: 17 | ident: bytes 18 | version: int 19 | steam_id: SteamAppId 20 | 21 | 22 | ModelConvertFunction = Callable[[Path, Buffer, ContentManager], Model] 23 | MODEL_CONVERTERS: list[tuple[ModelConverterTag, ModelConvertFunction]] = [] 24 | 25 | 26 | def register_model_converter(ident: bytes, version: int, steam_id: Optional[SteamAppId] = None): 27 | def inner(func: ModelConvertFunction) -> ModelConvertFunction: 28 | MODEL_CONVERTERS.append((ModelConverterTag(ident, version, steam_id), func)) 29 | return func 30 | 31 | return inner 32 | 33 | 34 | def choose_model_converter(ident: bytes, version: int, 35 | steam_id: Optional[SteamAppId] = None) -> Optional[ModelConvertFunction]: 36 | best_match = None 37 | best_score = 0 # Start with a score lower than any possible match score 38 | 39 | for handler_tag, handler_func in MODEL_CONVERTERS: 40 | score = 0 41 | # Check ident and version match 42 | if handler_tag.ident == ident and handler_tag.version == version: 43 | score += 2 # Base score for ident and version match 44 | 45 | # If steam_id is provided and matches, increase the score 46 | if steam_id is not None and handler_tag.steam_id == steam_id: 47 | score += 1 # Additional score for steam_id match 48 | 49 | # Update best match if this handler has a higher score 50 | if score > best_score: 51 | best_score = score 52 | best_match = handler_func 53 | if best_match is None: 54 | logger.error(f'Could not find converter for {ident!r} version {version}') 55 | return best_match 56 | -------------------------------------------------------------------------------- /source2converter/mdl/utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from SourceIO.library.models.mdl.structs.model import Model 4 | from SourceIO.library.models.vtx.v7.structs.lod import ModelLod as VtxLod 5 | from SourceIO.library.models.vtx.v7.structs.mesh import Mesh as VtxMesh 6 | 7 | 8 | def merge_strip_groups(vtx_mesh: VtxMesh): 9 | indices_accumulator = [] 10 | vertex_accumulator = [] 11 | vertex_offset = 0 12 | for strip_group in vtx_mesh.strip_groups: 13 | indices_accumulator.append(np.add(strip_group.indices, vertex_offset)) 14 | vertex_accumulator.append(strip_group.vertexes['original_mesh_vertex_index'].reshape(-1)) 15 | vertex_offset += sum(strip.vertex_count for strip in strip_group.strips) 16 | return np.hstack(indices_accumulator), np.hstack(vertex_accumulator), vertex_offset 17 | 18 | 19 | def merge_meshes(model: Model, vtx_lod: VtxLod): 20 | vtx_vertices = [] 21 | acc = 0 22 | mat_arrays = [] 23 | indices_array = [] 24 | for n, (vtx_mesh, mesh) in enumerate(zip(vtx_lod.meshes, model.meshes)): 25 | 26 | if not vtx_mesh.strip_groups: 27 | continue 28 | 29 | vertex_start = mesh.vertex_index_start 30 | indices, vertices, offset = merge_strip_groups(vtx_mesh) 31 | indices = np.add(indices, acc) 32 | mat_array = np.full(indices.shape[0] // 3, mesh.material_index) 33 | mat_arrays.append(mat_array) 34 | vtx_vertices.extend(np.add(vertices, vertex_start)) 35 | indices_array.append(indices) 36 | acc += offset 37 | 38 | return vtx_vertices, np.hstack(indices_array), np.hstack(mat_arrays) 39 | -------------------------------------------------------------------------------- /source2converter/mdl/v49/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional 3 | 4 | import numpy as np 5 | 6 | from SourceIO.library.models.mdl.structs.header import StudioHDRFlags 7 | from SourceIO.library.models.mdl.v49 import MdlV49 8 | from SourceIO.library.models.mdl.structs import ( 9 | Bone as MdlBone, 10 | Model as MdlModel, 11 | Mesh as MdlMesh, 12 | BodyPart as MdlBodyPart 13 | ) 14 | from SourceIO.library.models.vtx import open_vtx 15 | from SourceIO.library.models.vtx.v7.structs import ( 16 | BodyPart as VtxBodyPart, 17 | Model as VtxModel, 18 | ModelLod as VtxModelLod, 19 | Mesh as VtxMesh 20 | ) 21 | from SourceIO.library.models.vvd import Vvd 22 | from SourceIO.library.shared.content_providers.content_manager import ContentManager 23 | from SourceIO.library.utils import Buffer 24 | from SourceIO.library.utils.common import get_slice 25 | from SourceIO.library.utils.path_utilities import find_vtx_cm, path_stem, collect_full_material_names 26 | from SourceIO.logger import SourceLogMan 27 | from source2converter.mdl.model_converter_tags import register_model_converter 28 | from source2converter.model import Model, Skeleton, Material, Mesh, Lod, SubModel, BodyGroup, ShapeKey, LoddedSubModel, \ 29 | NullSubModel 30 | from source2converter.model.skeleton import Bone, Attachment, AttachmentParentType 31 | from source2converter.utils.math_utils import decompose_matrix_to_rts, quaternion_to_euler 32 | 33 | log_manager = SourceLogMan() 34 | logger = log_manager.get_logger('MDL Converter') 35 | 36 | 37 | def convert_skeleton(mdl: MdlV49): 38 | if mdl.header.flags & StudioHDRFlags.STATIC_PROP: 39 | return None 40 | mdl_bones: list[MdlBone] = mdl.bones 41 | skeleton = Skeleton() 42 | for mdl_bone in mdl_bones: 43 | parent_id = mdl_bone.parent_bone_id 44 | parent_name = mdl_bones[parent_id].name if parent_id >= 0 else None 45 | bone = Bone(mdl_bone.name, parent_name, mdl_bone.position, mdl_bone.quat) 46 | skeleton.bones.append(bone) 47 | return skeleton 48 | 49 | 50 | def update_mesh_for_lod(mesh): 51 | # Step 1: Identify all used vertices across strips 52 | all_used_indices = np.unique(np.concatenate([strip[1] for strip in mesh.strips])) 53 | 54 | # Map old indices to new indices 55 | index_mapping = np.full(np.max(all_used_indices) + 1, -1, dtype=int) 56 | index_mapping[all_used_indices] = np.arange(all_used_indices.size) 57 | 58 | # Step 2: Remap indices in each strip 59 | mesh.strips = [(mat_idx, index_mapping[indices]) for mat_idx, indices in mesh.strips] 60 | 61 | # Step 3 & 4: Adjust and Remap Shape Key Indices 62 | for shape_key in mesh.shape_keys: 63 | # Determine which indices in the shape key are also in the LOD 64 | valid_indices_mask = np.isin(shape_key.indices, all_used_indices) 65 | filtered_indices = shape_key.indices[valid_indices_mask] 66 | 67 | # Remap these indices to the new indices in the LOD mesh 68 | remapped_indices = index_mapping[filtered_indices] 69 | 70 | # Filter and remap shape key indices 71 | shape_key.indices = remapped_indices 72 | 73 | # Step 5: Adjust delta_attributes only for valid (remaining) indices 74 | for attr, deltas in shape_key.delta_attributes.items(): 75 | shape_key.delta_attributes[attr] = deltas[valid_indices_mask] 76 | 77 | # Create new vertex attributes containing only used vertices 78 | mesh.vertex_attributes = {attr: data[all_used_indices] for attr, data in mesh.vertex_attributes.items()} 79 | 80 | 81 | @register_model_converter(b"IDST", 44) 82 | @register_model_converter(b"IDST", 45) 83 | @register_model_converter(b"IDST", 46) 84 | @register_model_converter(b"IDST", 47) 85 | @register_model_converter(b"IDST", 49) 86 | def convert_mdl_v49(model_path: Path, buffer: Buffer, content_manager: ContentManager) -> Optional[Model]: 87 | mdl = MdlV49.from_buffer(buffer) 88 | vtx_buffer = find_vtx_cm(model_path, content_manager) 89 | vvd_buffer = content_manager.find_file(model_path.with_suffix(".vvd")) 90 | if vtx_buffer is None or vvd_buffer is None: 91 | logger.error(f"Could not find VTX and/or VVD file for {model_path}") 92 | return None 93 | vtx = open_vtx(vtx_buffer) 94 | vvd = Vvd.from_buffer(vvd_buffer) 95 | 96 | mdl_name = path_stem(mdl.header.name) 97 | 98 | skeleton = convert_skeleton(mdl) 99 | 100 | model = Model(mdl_name, skeleton) 101 | 102 | full_material_names = collect_full_material_names([mat.name for mat in mdl.materials], mdl.materials_paths, 103 | content_manager) 104 | for material_name, material_path in full_material_names.items(): 105 | model.materials.append( 106 | Material(material_name, material_path + ".vmt", content_manager.find_material(material_path))) 107 | 108 | for vtx_body_part, body_part in zip(vtx.body_parts, mdl.body_parts): 109 | vtx_body_part: VtxBodyPart 110 | body_part: MdlBodyPart 111 | submodels = [] 112 | for vtx_model, mdl_model in zip(vtx_body_part.models, body_part.models): 113 | vtx_model: VtxModel 114 | mdl_model: MdlModel 115 | lods = [] 116 | if mdl_model.vertex_count == 0: 117 | submodels.append(NullSubModel()) 118 | continue 119 | for vtx_lod in vtx_model.model_lods: 120 | lod_vertices = get_slice(vvd.lod_data[0], mdl_model.vertex_offset, mdl_model.vertex_count) 121 | vertex_attributes = { 122 | "positions": lod_vertices["vertex"], 123 | "normals": lod_vertices["normal"], 124 | "uv0": lod_vertices["uv"], 125 | "blend_weights": lod_vertices["weight"], 126 | "blend_indices": lod_vertices["bone_id"], 127 | } 128 | strips = [] 129 | shape_keys = {} 130 | for n, (vtx_mesh, mdl_mesh) in enumerate(zip(vtx_lod.meshes, mdl_model.meshes)): 131 | vtx_mesh: VtxMesh 132 | mdl_mesh: MdlMesh 133 | if not vtx_mesh.strip_groups: 134 | continue 135 | for mdl_flex in mdl_mesh.flexes: 136 | model.has_shape_keys = True 137 | flex_name = mdl.flex_names[mdl_flex.flex_desc_index] 138 | if flex_name in shape_keys: 139 | shape_key = shape_keys[flex_name] 140 | shape_key.indices = np.append(shape_key.indices, 141 | mdl_flex.vertex_animations[ 142 | "index"] + mdl_mesh.vertex_index_start).ravel() 143 | shape_key.delta_attributes["positions"] = np.append(shape_key.delta_attributes["positions"], 144 | mdl_flex.vertex_animations[ 145 | "vertex_delta"]).reshape(-1, 3) 146 | shape_key.delta_attributes["normals"] = np.append(shape_key.delta_attributes["normals"], 147 | mdl_flex.vertex_animations[ 148 | "normal_delta"]).reshape(-1, 3) 149 | else: 150 | shape_key = ShapeKey(flex_name, 151 | mdl_flex.vertex_animations[ 152 | "index"].ravel() + mdl_mesh.vertex_index_start, 153 | { 154 | "positions": mdl_flex.vertex_animations["vertex_delta"].reshape(-1, 155 | 3), 156 | "normals": mdl_flex.vertex_animations["normal_delta"].reshape(-1, 157 | 3), 158 | }) 159 | shape_key.stereo = mdl_flex.partner_index != 0 160 | shape_keys[flex_name] = shape_key 161 | 162 | for strip_group in vtx_mesh.strip_groups: 163 | indices = np.add( 164 | strip_group.vertexes[strip_group.indices]["original_mesh_vertex_index"].astype(np.uint32), 165 | mdl_mesh.vertex_index_start) 166 | strips.append((mdl_mesh.material_index, indices.ravel())) 167 | mesh = Mesh(mdl_model.name, strips, vertex_attributes, list(shape_keys.values())) 168 | update_mesh_for_lod(mesh) 169 | lods.append( 170 | Lod( 171 | vtx_lod.switch_point, 172 | mesh 173 | ) 174 | ) 175 | submodels.append(LoddedSubModel(mdl_model.name, lods)) 176 | model.bodygroups.append(BodyGroup(body_part.name, submodels)) 177 | 178 | for mdl_attachment in mdl.attachments: 179 | rotation, translation, scale = decompose_matrix_to_rts(np.asarray(mdl_attachment.matrix).reshape(3, 4)) 180 | attachment = Attachment(mdl_attachment.name, AttachmentParentType.SINGLE_BONE, 181 | mdl.bones[mdl_attachment.parent_bone].name, translation, rotation) 182 | model.attachments.append(attachment) 183 | 184 | return model 185 | -------------------------------------------------------------------------------- /source2converter/model/__init__.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Optional 3 | 4 | import numpy as np 5 | 6 | from SourceIO.library.utils import Buffer 7 | from source2converter.model.skeleton import Skeleton, Attachment 8 | 9 | 10 | @dataclass 11 | class ShapeKey: 12 | name: str 13 | indices: np.ndarray = field(repr=False) 14 | delta_attributes: dict[str, np.ndarray] = field(repr=False) 15 | stereo: bool = False 16 | 17 | 18 | @dataclass 19 | class Mesh: 20 | name: str 21 | strips: list[tuple[int, np.ndarray]] = field(repr=False) 22 | vertex_attributes: dict[str, np.ndarray] = field(repr=False) 23 | shape_keys: list[ShapeKey] 24 | 25 | 26 | @dataclass 27 | class Lod: 28 | switch_point: float 29 | mesh: Mesh 30 | # meshes: list[Mesh] 31 | 32 | 33 | class SubModel: 34 | pass 35 | 36 | 37 | class NullSubModel(SubModel): 38 | pass 39 | 40 | 41 | @dataclass 42 | class LoddedSubModel(SubModel): 43 | name: str 44 | lods: list[Lod] 45 | 46 | 47 | @dataclass 48 | class BodyGroup: 49 | name: str 50 | sub_models: list[SubModel] 51 | 52 | 53 | @dataclass 54 | class Material: 55 | name: str 56 | full_path: str 57 | buffer: Buffer 58 | 59 | 60 | @dataclass 61 | class Model: 62 | name: str 63 | skeleton: Optional[Skeleton] = None 64 | attachments: list[Attachment] = field(default_factory=list) 65 | bodygroups: list[BodyGroup] = field(default_factory=list) 66 | materials: list[Material] = field(default_factory=list) 67 | has_shape_keys: bool = False 68 | -------------------------------------------------------------------------------- /source2converter/model/skeleton.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Optional 3 | 4 | import numpy as np 5 | 6 | from SourceIO.library.shared.types import Vector3, Vector4 7 | 8 | 9 | @dataclass 10 | class Bone: 11 | name: str 12 | parent_name: Optional[str] 13 | translation: Vector3 14 | rotation: Vector4 15 | 16 | 17 | class AttachmentParentType: 18 | NONE = 0 19 | SINGLE_BONE = 1 20 | MULTI_BONE = 2 21 | 22 | 23 | @dataclass 24 | class Attachment: 25 | name: str 26 | parent_type: AttachmentParentType 27 | parent_name: str 28 | translation: Vector3 29 | rotation: Vector4 30 | 31 | 32 | @dataclass 33 | class Skeleton: 34 | bones: list[Bone] = field(default_factory=list) 35 | -------------------------------------------------------------------------------- /source2converter/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/REDxEYE/Source2Converter/96c9de3da0a8087564c86e6fd8a42b2146e82db3/source2converter/utils/__init__.py -------------------------------------------------------------------------------- /source2converter/utils/math_utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | def rotation_matrix_to_quaternion(rot_mat): 5 | """ 6 | Method to convert a rotation matrix to a quaternion 7 | Assumes `rot_mat` is a proper rotation matrix 8 | """ 9 | qw = np.sqrt(1 + rot_mat[0, 0] + rot_mat[1, 1] + rot_mat[2, 2]) / 2 10 | qx = (rot_mat[2, 1] - rot_mat[1, 2]) / (4 * qw) 11 | qy = (rot_mat[0, 2] - rot_mat[2, 0]) / (4 * qw) 12 | qz = (rot_mat[1, 0] - rot_mat[0, 1]) / (4 * qw) 13 | return np.array([qw, qx, qy, qz]) 14 | 15 | 16 | def decompose_matrix_to_rts(mat): 17 | # Ensure the matrix is a numpy array 18 | mat = np.array(mat) 19 | 20 | # Translation is the last column of the first three rows 21 | translation = mat[:3, 3] 22 | 23 | # Scale is computed from the columns of the rotation-scale submatrix 24 | scale = np.linalg.norm(mat[:3, :3], axis=0) 25 | 26 | # Prevent division by zero in case of zero scale 27 | scale_with_no_zeros = np.where(scale == 0, 1, scale) 28 | 29 | # Rotation matrix is the rotation-scale matrix normalized by scale 30 | rotation_matrix = mat[:3, :3] / scale_with_no_zeros 31 | 32 | # Convert rotation matrix to quaternion 33 | quaternion = rotation_matrix_to_quaternion(rotation_matrix) 34 | 35 | return quaternion, translation, scale 36 | 37 | 38 | def quaternion_to_euler(q): 39 | # Extract the quaternion components 40 | w, x, y, z = q 41 | 42 | # Pre-compute repeated values for efficiency 43 | t0 = +2.0 * (w * x + y * z) 44 | t1 = +1.0 - 2.0 * (x * x + y * y) 45 | roll_x = np.arctan2(t0, t1) 46 | 47 | t2 = +2.0 * (w * y - z * x) 48 | t2 = np.where(t2 > +1.0, +1.0, t2) # Clamp to avoid NaN in arctan2 49 | t2 = np.where(t2 < -1.0, -1.0, t2) 50 | pitch_y = np.arcsin(t2) 51 | 52 | t3 = +2.0 * (w * z + x * y) 53 | t4 = +1.0 - 2.0 * (y * y + z * z) 54 | yaw_z = np.arctan2(t3, t4) 55 | 56 | return yaw_z, pitch_y, roll_x # Return in ZYX order 57 | -------------------------------------------------------------------------------- /source2converter/utils/vmdl.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from dataclasses import dataclass, field 3 | from typing import Any, TypeVar 4 | 5 | from SourceIO.library.utils.s2_keyvalues import KeyValues 6 | 7 | 8 | @dataclass 9 | class Node(dict[str, Any]): 10 | _class: str = field(init=False, default="") 11 | 12 | def dump(self): 13 | data = dataclasses.asdict(self) 14 | data["_class"] = self.__class__.__name__ 15 | return data 16 | 17 | 18 | T = TypeVar("T", bound=Node) 19 | 20 | 21 | @dataclass 22 | class ListNode(Node[T]): 23 | children: list[T] = field(default_factory=list, init=False) 24 | 25 | def append(self, child: T): 26 | self.children.append(child) 27 | return self 28 | 29 | def dump(self): 30 | data = dataclasses.asdict(self) 31 | data["children"] = [child.dump() for child in self.children] 32 | data["_class"] = self.__class__.__name__ 33 | return data 34 | 35 | 36 | @dataclass 37 | class RootNode(ListNode[T]): 38 | model_archetype: str = "" 39 | primary_associated_entity: str = "" 40 | anim_graph_name: str = "" 41 | 42 | 43 | @dataclass 44 | class LODGroup(Node): 45 | switch_threshold: float = 0 46 | meshes: list[str] = field(default_factory=list) 47 | 48 | 49 | @dataclass 50 | class LODGroupList(ListNode[LODGroup]): 51 | pass 52 | 53 | 54 | @dataclass 55 | class RenderMeshFile(Node): 56 | name: str 57 | filename: str 58 | import_scale: float = 1.0 59 | 60 | 61 | @dataclass 62 | class RenderMeshList(ListNode[RenderMeshFile]): 63 | pass 64 | 65 | 66 | @dataclass 67 | class AnimationList(ListNode): 68 | pass 69 | 70 | 71 | @dataclass 72 | class BodyGroupChoice(Node): 73 | meshes: list[str] 74 | 75 | 76 | @dataclass 77 | class BodyGroup(ListNode[BodyGroupChoice]): 78 | name: str 79 | hidden_in_tools: bool = False 80 | 81 | 82 | @dataclass 83 | class BodyGroupList(ListNode[BodyGroup]): 84 | pass 85 | 86 | 87 | @dataclass 88 | class JiggleBoneList(ListNode): 89 | pass 90 | 91 | 92 | @dataclass 93 | class MaterialGroupList(ListNode): 94 | pass 95 | 96 | 97 | @dataclass 98 | class MorphControlList(ListNode): 99 | pass 100 | 101 | 102 | @dataclass 103 | class MorphRuleList(ListNode): 104 | pass 105 | 106 | 107 | @dataclass 108 | class BoneMarkupList(Node): 109 | bone_cull_type: str = "None" 110 | 111 | 112 | @dataclass 113 | class EmptyAnim(Node): 114 | activity_name: str = "" 115 | activity_weight: int = 1 116 | anim_markup_ordered: bool = False 117 | delta: bool = False 118 | disable_compression: bool = False 119 | fade_in_time: float = 0.2 120 | fade_out_time: float = 0.2 121 | frame_count: int = 1 122 | frame_rate: int = 30 123 | hidden: bool = False 124 | looping: bool = False 125 | name: str = "ref" 126 | weight_list_name: str = "" 127 | worldSpace: bool = False 128 | 129 | 130 | @dataclass 131 | class MaterialGroup(Node): 132 | name: str 133 | remaps: list[dict] = False 134 | 135 | def add_remap(self, from_: str, to: str): 136 | self.remaps.append({"from": from_, "to": to}) 137 | return self 138 | 139 | 140 | @dataclass 141 | class Attachment(Node): 142 | name: str 143 | parent_bone: str 144 | relative_origin: tuple[float, float, float] 145 | relative_angles: tuple[float, float, float] 146 | weight: float = 1.0 147 | ignore_rotation: bool = False 148 | 149 | 150 | class AttachmentList(ListNode[Attachment]): 151 | pass 152 | 153 | 154 | # @dataclass 155 | # class JiggleBone(Node): 156 | # name: str 157 | # hidden_in_tools: bool = False 158 | 159 | 160 | class Vmdl: 161 | def __init__(self): 162 | self._root_node = RootNode() 163 | 164 | def append(self, child: T) -> T: 165 | self._root_node.append(child) 166 | return child 167 | 168 | def write(self): 169 | return KeyValues.dump_str('KV3', 170 | ('text', 'e21c7f3c-8a33-41c5-9977-a76d3a32aa0d'), 171 | ('modeldoc28', 'fb63b6ca-f435-4aa0-a2c7-c66ddc651dca'), 172 | {"rootNode": self._root_node.dump()} 173 | ) 174 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from SourceIO.library.shared.content_providers.content_manager import ContentManager 4 | from SourceIO.library.models.mdl.v49.mdl_file import MdlV49 5 | 6 | 7 | def collect_materials(mdl: MdlV49): 8 | materials = [] 9 | content_manager = ContentManager() 10 | 11 | # collect materials 12 | for material in mdl.materials: 13 | for cd_material_path in mdl.materials_paths: 14 | material_full_path = content_manager.find_material(Path(cd_material_path) / material.name) 15 | if material_full_path: 16 | materials.append((normalize_path(material.name), cd_material_path, material_full_path)) 17 | break 18 | else: 19 | print(f'\033[91mFailed to find {material.name}\033[0m') 20 | materials.append((normalize_path(material.name), '', None)) 21 | 22 | return materials 23 | 24 | 25 | def remove_ext(path): 26 | path = Path(path) 27 | return path.with_suffix("") 28 | 29 | 30 | def sanitize_name(name): 31 | return (Path(name).stem.lower() 32 | .replace(' ', '_') 33 | .replace('-', '_') 34 | .replace('.', '_') 35 | .replace('[', '') 36 | .replace(']', '')) 37 | 38 | 39 | def normalize_path(path): 40 | return Path(str(path).lower().replace(' ', '_').replace('-', '_').strip('/\\')) 41 | -------------------------------------------------------------------------------- /vmf_convert_materials.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | from typing import Set 4 | import os 5 | 6 | os.environ['NO_BPY'] = '1' 7 | 8 | from SourceIO.library.shared.content_providers.content_manager import ContentManager 9 | from SourceIO.library.utils.s1_keyvalues import KVParser 10 | from material_converter import convert_material, Material 11 | 12 | used_materials: Set[str] = set() 13 | 14 | if __name__ == '__main__': 15 | ContentManager().scan_for_content(r'D:\GAMES\hl2_beta\hl2') 16 | with open(r"D:\GAMES\hl2_beta\hl2\maps_src\d1_town\d1_town_02.vmf", 'r') as f: 17 | for line in f.readlines(): 18 | if 'material' in line: 19 | line = line.strip('\r\t\n ') 20 | _, material_name = line.split(' ') 21 | used_materials.add(material_name.replace("\"", "")) 22 | 23 | content_manager = ContentManager() 24 | for material in used_materials: 25 | material = Path(material) 26 | full_path = content_manager.find_material(material) 27 | convert_material((material.stem.lower(), str(material.parent).lower(), full_path), 28 | Path(r'F:\SteamLibrary\steamapps\common\Half-Life Alyx\content\hlvr_addons\half_life2')) 29 | -------------------------------------------------------------------------------- /vmf_convert_props.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | from typing import Set 4 | import os 5 | 6 | from convert_model import convert_model, compile_model 7 | 8 | os.environ['NO_BPY'] = '1' 9 | 10 | from SourceIO.library.shared.content_providers.content_manager import ContentManager 11 | from SourceIO.library.utils.s1_keyvalues import KVParser 12 | from material_converter import convert_material, Material 13 | 14 | used_models: Set[str] = set() 15 | 16 | if __name__ == '__main__': 17 | mod_path = Path(r'F:\SteamLibrary\steamapps\common\Half-Life Alyx\content\hlvr_addons\half_life2') 18 | 19 | ContentManager().scan_for_content(r'D:\GAMES\hl2_beta\hl2') 20 | with open(r"D:\GAMES\hl2_beta\hl2\maps_src\d1_town\d1_town_01.vmf", 'r') as f: 21 | for line in f.readlines(): 22 | if 'model' in line: 23 | line = line.strip('\r\t\n ') 24 | _, material_name = line.split(' ') 25 | used_models.add(material_name.replace("\"", "")) 26 | 27 | content_manager = ContentManager() 28 | for model in used_models: 29 | model = Path(model) 30 | full_path = content_manager.find_path(model, extension='.mdl') 31 | vmdl_file = convert_model(full_path, mod_path) 32 | compile_model(vmdl_file, mod_path) 33 | --------------------------------------------------------------------------------