├── .editorconfig ├── .github └── workflow │ └── build.yml ├── .gitignore ├── BSPConvert.Cmd ├── BSPConvert.Cmd.csproj ├── ConsoleLogger.cs └── Program.cs ├── BSPConvert.Lib ├── Assets │ └── materials │ │ └── tools │ │ ├── toolsinvisibledisplacement.vmt │ │ └── toolsinvisibledisplacement.vtf ├── BSPConvert.Lib.csproj ├── Dependencies │ ├── DevIL.dll │ ├── HLLib.dll │ ├── VTFCmd.exe │ ├── VTFLib.dll │ └── libBSP.dll ├── Q3Content │ └── instructions.txt └── Source │ ├── BSPConverter.cs │ ├── BezierPatch.cs │ ├── ColorRGBExp32.cs │ ├── Constants.cs │ ├── ContentManager.cs │ ├── EntityConverter.cs │ ├── ExternalLightmapLoader.cs │ ├── HullConverter.cs │ ├── ILogger.cs │ ├── MaterialConverter.cs │ ├── Shader.cs │ ├── ShaderLoader.cs │ ├── ShaderParser.cs │ ├── SoundConverter.cs │ ├── TextureConverter.cs │ ├── Utilities │ ├── BSPUtil.cs │ ├── ColorUtil.cs │ └── FileUtil.cs │ └── VTFFile.cs ├── BSPConvert.Test ├── BSPConvert.Test.csproj ├── BSPConverterTest.cs ├── DebugLogger.cs ├── Test Files │ ├── test-nonsolid-patch │ │ ├── test-nonsolid-patch.bsp │ │ ├── test-nonsolid-patch.map │ │ └── test-nonsolid-patch.txt │ ├── test-push-horizontal │ │ ├── test-push-horizontal.bsp │ │ ├── test-push-horizontal.map │ │ └── test-push-horizontal.txt │ └── test-push-vertical │ │ ├── test-push-vertical.bsp │ │ ├── test-push-vertical.map │ │ └── test-push-vertical.txt └── Usings.cs ├── BSPConvert.sln ├── LICENSE.md └── README.md /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | indent_style = tab 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | # Tom: Disabling all default rules for now. Have a stronger ruleset on the 12 | # wip/format-and-linting branch. 13 | [*.cs] 14 | dotnet_analyzer_diagnostic.severity = none 15 | 16 | dotnet_diagnostic.CA1304.severity = error 17 | dotnet_diagnostic.CA1304.severity = error 18 | dotnet_diagnostic.CA1305.severity = error 19 | dotnet_diagnostic.CA1307.severity = error 20 | dotnet_diagnostic.CA1308.severity = suggestion # "Normalize strings to uppercase", someone unwanted 21 | dotnet_diagnostic.CA1309.severity = error 22 | dotnet_diagnostic.CA1310.severity = error 23 | dotnet_diagnostic.CA1311.severity = error 24 | dotnet_diagnostic.CA2101.severity = error 25 | -------------------------------------------------------------------------------- /.github/workflow/build.yml: -------------------------------------------------------------------------------- 1 | name: Format Check 2 | 3 | on: 4 | pull_request: 5 | branches: [ "master", "develop" ] 6 | 7 | jobs: 8 | build: 9 | permissions: 10 | contents: write 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | submodules: true 18 | 19 | - name: Setup .NET 20 | uses: actions/setup-dotnet@v4 21 | with: 22 | dotnet-version: 7.0.x 23 | 24 | - name: Check dotnet format 25 | run: dotnet format analyzers --verify-no-changes 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # ASP.NET Scaffolding 66 | ScaffoldingReadMe.txt 67 | 68 | # StyleCop 69 | StyleCopReport.xml 70 | 71 | # Files built by Visual Studio 72 | *_i.c 73 | *_p.c 74 | *_h.h 75 | *.ilk 76 | *.meta 77 | *.obj 78 | *.iobj 79 | *.pch 80 | *.pdb 81 | *.ipdb 82 | *.pgc 83 | *.pgd 84 | *.rsp 85 | *.sbr 86 | *.tlb 87 | *.tli 88 | *.tlh 89 | *.tmp 90 | *.tmp_proj 91 | *_wpftmp.csproj 92 | *.log 93 | *.tlog 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 298 | *.vbp 299 | 300 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 301 | *.dsw 302 | *.dsp 303 | 304 | # Visual Studio 6 technical files 305 | *.ncb 306 | *.aps 307 | 308 | # Visual Studio LightSwitch build output 309 | **/*.HTMLClient/GeneratedArtifacts 310 | **/*.DesktopClient/GeneratedArtifacts 311 | **/*.DesktopClient/ModelManifest.xml 312 | **/*.Server/GeneratedArtifacts 313 | **/*.Server/ModelManifest.xml 314 | _Pvt_Extensions 315 | 316 | # Paket dependency manager 317 | .paket/paket.exe 318 | paket-files/ 319 | 320 | # FAKE - F# Make 321 | .fake/ 322 | 323 | # CodeRush personal settings 324 | .cr/personal 325 | 326 | # Python Tools for Visual Studio (PTVS) 327 | __pycache__/ 328 | *.pyc 329 | 330 | # Cake - Uncomment if you are using it 331 | # tools/** 332 | # !tools/packages.config 333 | 334 | # Tabs Studio 335 | *.tss 336 | 337 | # Telerik's JustMock configuration file 338 | *.jmconfig 339 | 340 | # BizTalk build output 341 | *.btp.cs 342 | *.btm.cs 343 | *.odx.cs 344 | *.xsd.cs 345 | 346 | # OpenCover UI analysis results 347 | OpenCover/ 348 | 349 | # Azure Stream Analytics local run output 350 | ASALocalRun/ 351 | 352 | # MSBuild Binary and Structured Log 353 | *.binlog 354 | 355 | # NVidia Nsight GPU debugger configuration file 356 | *.nvuser 357 | 358 | # MFractors (Xamarin productivity tool) working folder 359 | .mfractor/ 360 | 361 | # Local History for Visual Studio 362 | .localhistory/ 363 | 364 | # Visual Studio History (VSHistory) files 365 | .vshistory/ 366 | 367 | # BeatPulse healthcheck temp database 368 | healthchecksdb 369 | 370 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 371 | MigrationBackup/ 372 | 373 | # Ionide (cross platform F# VS Code tools) working folder 374 | .ionide/ 375 | 376 | # Fody - auto-generated XML schema 377 | FodyWeavers.xsd 378 | 379 | # VS Code files for those working on multiple tools 380 | .vscode/* 381 | !.vscode/settings.json 382 | !.vscode/tasks.json 383 | !.vscode/launch.json 384 | !.vscode/extensions.json 385 | *.code-workspace 386 | 387 | # Local History for Visual Studio Code 388 | .history/ 389 | 390 | # Windows Installer files from build outputs 391 | *.cab 392 | *.msi 393 | *.msix 394 | *.msm 395 | *.msp 396 | 397 | # JetBrains Rider 398 | *.sln.iml 399 | 400 | .idea/ 401 | -------------------------------------------------------------------------------- /BSPConvert.Cmd/BSPConvert.Cmd.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net7.0 6 | enable 7 | enable 8 | BSPConv 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /BSPConvert.Cmd/ConsoleLogger.cs: -------------------------------------------------------------------------------- 1 | using BSPConvert.Lib; 2 | 3 | namespace BSPConvert.Cmd 4 | { 5 | public class ConsoleLogger : ILogger 6 | { 7 | public void Log(string message) 8 | { 9 | Console.WriteLine(message); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /BSPConvert.Cmd/Program.cs: -------------------------------------------------------------------------------- 1 | using BSPConvert.Lib; 2 | using CommandLine; 3 | using CommandLine.Text; 4 | 5 | namespace BSPConvert.Cmd 6 | { 7 | internal class Program 8 | { 9 | class Options 10 | { 11 | [Option("nopak", Required = false, HelpText = "Export materials into folders instead of embedding them in the BSP.")] 12 | public bool NoPak { get; set; } 13 | 14 | [Option("notooldisps", Required = false, HelpText = "Skip converting patches with tool textures to displacements.")] 15 | public bool NoToolDisplacements { get; set; } 16 | 17 | [Option("subdiv", Required = false, Default = 4, HelpText = "Displacement subdivisions [2-4].")] 18 | public int DisplacementPower { get; set; } 19 | 20 | [Option("mindmg", Required = false, Default = 50, HelpText = "Minimum damage to convert trigger_hurt into trigger_teleport.")] 21 | public int MinDamageToConvertTrigger { get; set; } 22 | 23 | [Option("nozones", Required = false, HelpText = "Ignore timer zone triggers.")] 24 | public bool IgnoreZones { get; set; } 25 | 26 | //[Option("oldbsp", Required = false, HelpText = "Use BSP version 20 (HL2 / CS:S).")] 27 | //public bool OldBSP { get; set; } 28 | 29 | [Option("prefix", Required = false, Default = "df_", HelpText = "Prefix for the converted BSP's file name.")] 30 | public string Prefix { get; set; } 31 | 32 | [Option("output", Required = false, HelpText = "Output game directory for converted BSP/materials.")] 33 | public string OutputDirectory { get; set; } 34 | 35 | [Value(0, MetaName = "input files", Required = true, HelpText = "Input Quake 3 BSP/PK3 file(s) to be converted.")] 36 | public IEnumerable InputFiles { get; set; } 37 | } 38 | 39 | static void Main(string[] args) 40 | { 41 | //args = new string[] 42 | //{ 43 | // @"c:\users\tyler\documents\tools\source engine\bspconvert\dfwc2017-6.pk3", 44 | // "--output", @"c:\users\tyler\documents\tools\source engine\bspconvert\output", 45 | //}; 46 | 47 | var parser = new Parser(with => with.HelpWriter = null); 48 | var parserResult = parser.ParseArguments(args); 49 | parserResult 50 | .WithParsed(options => RunCommand(options)) 51 | .WithNotParsed(errors => DisplayHelp(errors, parserResult)); 52 | } 53 | 54 | static void RunCommand(Options options) 55 | { 56 | if (options.DisplacementPower < 2 || options.DisplacementPower > 4) 57 | throw new ArgumentOutOfRangeException("Displacement power must be between 2 and 4."); 58 | 59 | if (options.OutputDirectory == null) 60 | options.OutputDirectory = Path.GetDirectoryName(options.InputFiles.First()); 61 | 62 | foreach (var inputEntry in options.InputFiles) 63 | { 64 | var converterOptions = new BSPConverterOptions() 65 | { 66 | noPak = options.NoPak, 67 | noToolDisplacements = options.NoToolDisplacements, 68 | DisplacementPower = options.DisplacementPower, 69 | minDamageToConvertTrigger = options.MinDamageToConvertTrigger, 70 | ignoreZones = options.IgnoreZones, 71 | //oldBSP = options.OldBSP, 72 | prefix = options.Prefix, 73 | inputFile = inputEntry, 74 | outputDir = options.OutputDirectory 75 | }; 76 | var converter = new BSPConverter(converterOptions, new ConsoleLogger()); 77 | converter.Convert(); 78 | } 79 | } 80 | 81 | static void DisplayHelp(IEnumerable errors, ParserResult parserResult) 82 | { 83 | const string version = "BSP Convert 0.0.3-alpha"; 84 | if (errors.IsVersion()) 85 | { 86 | Console.WriteLine(version); 87 | return; 88 | } 89 | 90 | var helpText = HelpText.AutoBuild(parserResult, h => 91 | { 92 | h.AdditionalNewLineAfterOption = false; 93 | h.MaximumDisplayWidth = 400; 94 | h.Heading = version; 95 | h.Copyright = ""; 96 | h.AddPostOptionsLine("EXAMPLE:\n .\\BSPConv.exe \"C:\\Users\\\\Documents\\BSPConvert\\nood-aDr.pk3\" --output \"C:\\Program Files (x86)\\Steam\\steamapps\\common\\Momentum Mod Playtest\\momentum\" --prefix \"df_\""); 97 | 98 | return HelpText.DefaultParsingErrorsHandler(parserResult, h); 99 | }, e => e); 100 | Console.WriteLine(helpText); 101 | } 102 | } 103 | } -------------------------------------------------------------------------------- /BSPConvert.Lib/Assets/materials/tools/toolsinvisibledisplacement.vmt: -------------------------------------------------------------------------------- 1 | "LightmappedGeneric" 2 | { 3 | "$baseTexture" "tools/toolsinvisibledisplacement" 4 | "$decal" 1 5 | "$surfaceprop" "dirt" 6 | "$translucent" 1 7 | "$vertexcolor" "1" 8 | "$vertexalpha" "1" 9 | "%tooltexture" "tools/toolsinvisibledisplacement_tooltexture" 10 | "%keywords" "tools" 11 | } -------------------------------------------------------------------------------- /BSPConvert.Lib/Assets/materials/tools/toolsinvisibledisplacement.vtf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/momentum-mod/BSPConvert/4be5eeb3f53a8c73287b0d3852b6afb80ff8714f/BSPConvert.Lib/Assets/materials/tools/toolsinvisibledisplacement.vtf -------------------------------------------------------------------------------- /BSPConvert.Lib/BSPConvert.Lib.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Dependencies\libBSP.dll 17 | 18 | 19 | 20 | 21 | 22 | PreserveNewest 23 | 24 | 25 | PreserveNewest 26 | 27 | 28 | PreserveNewest 29 | 30 | 31 | PreserveNewest 32 | 33 | 34 | PreserveNewest 35 | 36 | 37 | PreserveNewest 38 | 39 | 40 | PreserveNewest 41 | 42 | 43 | PreserveNewest 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /BSPConvert.Lib/Dependencies/DevIL.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/momentum-mod/BSPConvert/4be5eeb3f53a8c73287b0d3852b6afb80ff8714f/BSPConvert.Lib/Dependencies/DevIL.dll -------------------------------------------------------------------------------- /BSPConvert.Lib/Dependencies/HLLib.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/momentum-mod/BSPConvert/4be5eeb3f53a8c73287b0d3852b6afb80ff8714f/BSPConvert.Lib/Dependencies/HLLib.dll -------------------------------------------------------------------------------- /BSPConvert.Lib/Dependencies/VTFCmd.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/momentum-mod/BSPConvert/4be5eeb3f53a8c73287b0d3852b6afb80ff8714f/BSPConvert.Lib/Dependencies/VTFCmd.exe -------------------------------------------------------------------------------- /BSPConvert.Lib/Dependencies/VTFLib.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/momentum-mod/BSPConvert/4be5eeb3f53a8c73287b0d3852b6afb80ff8714f/BSPConvert.Lib/Dependencies/VTFLib.dll -------------------------------------------------------------------------------- /BSPConvert.Lib/Dependencies/libBSP.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/momentum-mod/BSPConvert/4be5eeb3f53a8c73287b0d3852b6afb80ff8714f/BSPConvert.Lib/Dependencies/libBSP.dll -------------------------------------------------------------------------------- /BSPConvert.Lib/Q3Content/instructions.txt: -------------------------------------------------------------------------------- 1 | Extract contents of pak0.pk3 from Quake III Arena installation here. -------------------------------------------------------------------------------- /BSPConvert.Lib/Source/BezierPatch.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Numerics; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | 8 | namespace BSPConvert.Lib 9 | { 10 | #if UNITY 11 | using Vector3 = UnityEngine.Vector3; 12 | #elif GODOT 13 | using Vector3 = Godot.Vector3; 14 | #elif NEOAXIS 15 | using Vector3 = NeoAxis.Vector3F; 16 | #else 17 | using Vector3 = System.Numerics.Vector3; 18 | #endif 19 | 20 | public class BezierPatch 21 | { 22 | private Vector3[] controlPoints; 23 | 24 | public BezierPatch(Vector3[] controlPoints) 25 | { 26 | if (controlPoints.Length != 9) 27 | throw new ArgumentException("Invalid patch control point count"); 28 | 29 | this.controlPoints = controlPoints; 30 | } 31 | 32 | /// 33 | /// Returns a point along the quadratic bezier patch 34 | /// 35 | /// [0-1] fraction along the width of the patch 36 | /// [0-1] fraction along the height of the patch 37 | public Vector3 GetPoint(float u, float v) 38 | { 39 | var bi = QuadraticBezier(u); 40 | var bj = QuadraticBezier(v); 41 | 42 | var result = new Vector3(0f, 0f, 0f); 43 | for (var i = 0; i < 3; i++) 44 | { 45 | for (var j = 0; j < 3; j++) 46 | { 47 | result += controlPoints[i + j * 3] * bi[i] * bj[j]; 48 | } 49 | } 50 | 51 | return result; 52 | } 53 | 54 | private float[] QuadraticBezier(float t) 55 | { 56 | return new float[3] 57 | { 58 | (1f - t) * (1f - t), 59 | 2f * t * (1f - t), 60 | t * t 61 | }; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /BSPConvert.Lib/Source/ColorRGBExp32.cs: -------------------------------------------------------------------------------- 1 | namespace BSPConvert.Lib 2 | { 3 | public struct ColorRGBExp32 4 | { 5 | public byte r; 6 | public byte g; 7 | public byte b; 8 | public sbyte exponent; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /BSPConvert.Lib/Source/Constants.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace BSPConvert.Lib 8 | { 9 | [Flags] 10 | public enum DisplacementFlags 11 | { 12 | SURF_BUMPED = 1, 13 | SURF_NOPHYSICS_COLL = 2, 14 | SURF_NOHULL_COLL = 4, 15 | SURF_NORAY_COLL = 8 16 | } 17 | 18 | [Flags] 19 | public enum Q3ContentsFlags : uint 20 | { 21 | CONTENTS_SOLID = 0x1, // an eye is never valid in a solid 22 | CONTENTS_LAVA = 0x8, 23 | CONTENTS_SLIME = 0x10, 24 | CONTENTS_WATER = 0x20, 25 | CONTENTS_FOG = 0x40, 26 | 27 | CONTENTS_NOTTEAM1 = 0x80, 28 | CONTENTS_NOTTEAM2 = 0x100, 29 | CONTENTS_NOBOTCLIP = 0x200, 30 | 31 | CONTENTS_AREAPORTAL = 0x8000, 32 | 33 | CONTENTS_PLAYERCLIP = 0x10000, 34 | CONTENTS_MONSTERCLIP = 0x20000, 35 | //bot specific contents types 36 | CONTENTS_TELEPORTER = 0x40000, 37 | CONTENTS_JUMPPAD = 0x80000, 38 | CONTENTS_CLUSTERPORTAL = 0x100000, 39 | CONTENTS_DONOTENTER = 0x200000, 40 | CONTENTS_BOTCLIP = 0x400000, 41 | CONTENTS_MOVER = 0x800000, 42 | 43 | CONTENTS_ORIGIN = 0x1000000, // removed before bsping an entity 44 | 45 | CONTENTS_BODY = 0x2000000, // should never be on a brush, only in game 46 | CONTENTS_CORPSE = 0x4000000, 47 | CONTENTS_DETAIL = 0x8000000, // brushes not used for the bsp 48 | CONTENTS_STRUCTURAL = 0x10000000, // brushes used for the bsp 49 | CONTENTS_TRANSLUCENT = 0x20000000, // don't consume surface fragments inside 50 | CONTENTS_TRIGGER = 0x40000000, 51 | CONTENTS_NODROP = 0x80000000 // don't leave bodies or items (death fog, lava) 52 | } 53 | 54 | [Flags] 55 | public enum SourceContentsFlags 56 | { 57 | CONTENTS_EMPTY = 0, // No contents 58 | 59 | CONTENTS_SOLID = 0x1, // an eye is never valid in a solid 60 | CONTENTS_WINDOW = 0x2, // translucent, but not watery (glass) 61 | CONTENTS_AUX = 0x4, 62 | CONTENTS_GRATE = 0x8, // alpha-tested "grate" textures. Bullets/sight pass through, but solids don't 63 | CONTENTS_SLIME = 0x10, 64 | CONTENTS_WATER = 0x20, 65 | CONTENTS_BLOCKLOS = 0x40, // block AI line of sight 66 | CONTENTS_OPAQUE = 0x80, // things that cannot be seen through (may be non-solid though) 67 | LAST_VISIBLE_CONTENTS = 0x80, 68 | 69 | ALL_VISIBLE_CONTENTS = (LAST_VISIBLE_CONTENTS | (LAST_VISIBLE_CONTENTS-1)), 70 | 71 | CONTENTS_TESTFOGVOLUME = 0x100, 72 | CONTENTS_UNUSED = 0x200, 73 | 74 | // unused 75 | // NOTE: If it's visible, grab from the top + update LAST_VISIBLE_CONTENTS 76 | // if not visible, then grab from the bottom. 77 | CONTENTS_UNUSED6 = 0x400, 78 | 79 | CONTENTS_TEAM1 = 0x800, // per team contents used to differentiate collisions 80 | CONTENTS_TEAM2 = 0x1000, // between players and objects on different teams 81 | 82 | // ignore CONTENTS_OPAQUE on surfaces that have SURF_NODRAW 83 | CONTENTS_IGNORE_NODRAW_OPAQUE = 0x2000, 84 | 85 | // hits entities which are MOVETYPE_PUSH (doors, plats, etc.) 86 | CONTENTS_MOVEABLE = 0x4000, 87 | 88 | // remaining contents are non-visible, and don't eat brushes 89 | CONTENTS_AREAPORTAL = 0x8000, 90 | 91 | CONTENTS_PLAYERCLIP = 0x10000, 92 | CONTENTS_MONSTERCLIP = 0x20000, 93 | 94 | // currents can be added to any other contents, and may be mixed 95 | CONTENTS_CURRENT_0 = 0x40000, 96 | CONTENTS_CURRENT_90 = 0x80000, 97 | CONTENTS_CURRENT_180 = 0x100000, 98 | CONTENTS_CURRENT_270 = 0x200000, 99 | CONTENTS_CURRENT_UP = 0x400000, 100 | CONTENTS_CURRENT_DOWN = 0x800000, 101 | 102 | CONTENTS_ORIGIN = 0x1000000, // removed before bsping an entity 103 | 104 | CONTENTS_MONSTER = 0x2000000, // should never be on a brush, only in game 105 | CONTENTS_DEBRIS = 0x4000000, 106 | CONTENTS_DETAIL = 0x8000000, // brushes to be added after vis leafs 107 | CONTENTS_TRANSLUCENT = 0x10000000, // auto set if any surface has trans 108 | CONTENTS_LADDER = 0x20000000, 109 | CONTENTS_HITBOX = 0x40000000 // use accurate hitboxes on trace 110 | } 111 | 112 | [Flags] 113 | public enum Q3SurfaceFlags 114 | { 115 | SURF_NODAMAGE = 0x1, // never give falling damage 116 | SURF_SLICK = 0x2, // effects game physics 117 | SURF_SKY = 0x4, // lighting from environment map 118 | SURF_LADDER = 0x8, 119 | SURF_NOIMPACT = 0x10, // don't make missile explosions 120 | SURF_NOMARKS = 0x20, // don't leave missile marks 121 | SURF_FLESH = 0x40, // make flesh sounds and effects 122 | SURF_NODRAW = 0x80, // don't generate a drawsurface at all 123 | SURF_HINT = 0x100, // make a primary bsp splitter 124 | SURF_SKIP = 0x200, // completely ignore, allowing non-closed brushes 125 | SURF_NOLIGHTMAP = 0x400, // surface doesn't need a lightmap 126 | SURF_POINTLIGHT = 0x800, // generate lighting info at vertexes 127 | SURF_METALSTEPS = 0x1000, // clanking footsteps 128 | SURF_NOSTEPS = 0x2000, // no footstep sounds 129 | SURF_NONSOLID = 0x4000, // don't collide against curves with this set 130 | SURF_LIGHTFILTER = 0x8000, // act as a light filter during q3map -light 131 | SURF_ALPHASHADOW = 0x10000, // do per-pixel light shadow casting in q3map 132 | SURF_NODLIGHT = 0x20000, // don't dlight even if solid (solid lava, skies) 133 | SURF_DUST = 0x40000 // leave a dust trail when walking on this surface 134 | } 135 | 136 | [Flags] 137 | public enum SourceSurfaceFlags 138 | { 139 | SURF_LIGHT = 0x0001, // value will hold the light strength 140 | SURF_SKY2D = 0x0002, // don't draw, indicates we should skylight + draw 2d sky but not draw the 3D skybox 141 | SURF_SKY = 0x0004, // don't draw, but add to skybox 142 | SURF_WARP = 0x0008, // turbulent water warp 143 | SURF_TRANS = 0x0010, 144 | SURF_NOPORTAL = 0x0020, // the surface can not have a portal placed on it 145 | SURF_TRIGGER = 0x0040, // FIXME: This is an xbox hack to work around elimination of trigger surfaces, which breaks occluders 146 | SURF_NODRAW = 0x0080, // don't bother referencing the texture 147 | 148 | SURF_HINT = 0x0100, // make a primary bsp splitter 149 | 150 | SURF_SKIP = 0x0200, // completely ignore, allowing non-closed brushes 151 | SURF_NOLIGHT = 0x0400, // Don't calculate light 152 | SURF_BUMPLIGHT = 0x0800, // calculate three lightmaps for the surface for bumpmapping 153 | SURF_NOSHADOWS = 0x1000, // Don't receive shadows 154 | SURF_NODECALS = 0x2000, // Don't receive decals 155 | SURF_NOPAINT = SURF_NODECALS, // the surface can not have paint placed on it 156 | SURF_NOCHOP = 0x4000, // Don't subdivide patches on this surface 157 | SURF_HITBOX = 0x8000, // surface is part of a hitbox 158 | SURF_SKYNOEMIT = 0x10000, // surface will show the skybox but does not emit light 159 | SURF_SKYOCCLUSION = 0x20000, // surface will draw the skybox before any solids 160 | SURF_SLICK = 0x40000 // surface is zero friction 161 | } 162 | 163 | public struct InfoParm 164 | { 165 | public string name; 166 | public int clearSolid; 167 | public Q3SurfaceFlags surfaceFlags; 168 | public Q3ContentsFlags contents; 169 | 170 | public InfoParm(string name, int clearSolid, Q3SurfaceFlags surfaceFlags, Q3ContentsFlags contents) 171 | { 172 | this.name = name; 173 | this.clearSolid = clearSolid; 174 | this.surfaceFlags = surfaceFlags; 175 | this.contents = contents; 176 | } 177 | } 178 | 179 | public static class Constants 180 | { 181 | public static InfoParm[] infoParms = 182 | { 183 | // server relevant contents 184 | new InfoParm("water", 1, 0, Q3ContentsFlags.CONTENTS_WATER ), 185 | new InfoParm("slime", 1, 0, Q3ContentsFlags.CONTENTS_SLIME ), // mildly damaging 186 | new InfoParm("lava", 1, 0, Q3ContentsFlags.CONTENTS_LAVA ), // very damaging 187 | new InfoParm("playerclip", 1, 0, Q3ContentsFlags.CONTENTS_PLAYERCLIP ), 188 | new InfoParm("monsterclip", 1, 0, Q3ContentsFlags.CONTENTS_MONSTERCLIP ), 189 | new InfoParm("nodrop", 1, 0, Q3ContentsFlags.CONTENTS_NODROP ), // don't drop items or leave bodies (death fog, lava, etc) 190 | new InfoParm("nonsolid", 1, Q3SurfaceFlags.SURF_NONSOLID, 0), // clears the solid flag 191 | 192 | // utility relevant attributes 193 | new InfoParm("origin", 1, 0, Q3ContentsFlags.CONTENTS_ORIGIN ), // center of rotating brushes 194 | new InfoParm("trans", 0, 0, Q3ContentsFlags.CONTENTS_TRANSLUCENT ), // don't eat contained surfaces 195 | new InfoParm("detail", 0, 0, Q3ContentsFlags.CONTENTS_DETAIL ), // don't include in structural bsp 196 | new InfoParm("structural", 0, 0, Q3ContentsFlags.CONTENTS_STRUCTURAL ), // force into structural bsp even if trnas 197 | new InfoParm("areaportal", 1, 0, Q3ContentsFlags.CONTENTS_AREAPORTAL ), // divides areas 198 | new InfoParm("clusterportal", 1,0, Q3ContentsFlags.CONTENTS_CLUSTERPORTAL ), // for bots 199 | new InfoParm("donotenter", 1, 0, Q3ContentsFlags.CONTENTS_DONOTENTER ), // for bots 200 | 201 | new InfoParm("fog", 1, 0, Q3ContentsFlags.CONTENTS_FOG), // carves surfaces entering 202 | new InfoParm("sky", 0, Q3SurfaceFlags.SURF_SKY, 0 ), // emit light from an environment map 203 | new InfoParm("lightfilter", 0, Q3SurfaceFlags.SURF_LIGHTFILTER, 0 ), // filter light going through it 204 | new InfoParm("alphashadow", 0, Q3SurfaceFlags.SURF_ALPHASHADOW, 0 ), // test light on a per-pixel basis 205 | new InfoParm("hint", 0, Q3SurfaceFlags.SURF_HINT, 0 ), // use as a primary splitter 206 | 207 | // server attributes 208 | new InfoParm("slick", 0, Q3SurfaceFlags.SURF_SLICK, 0 ), 209 | new InfoParm("noimpact", 0, Q3SurfaceFlags.SURF_NOIMPACT, 0 ), // don't make impact explosions or marks 210 | new InfoParm("nomarks", 0, Q3SurfaceFlags.SURF_NOMARKS, 0 ), // don't make impact marks, but still explode 211 | new InfoParm("ladder", 0, Q3SurfaceFlags.SURF_LADDER, 0 ), 212 | new InfoParm("nodamage", 0, Q3SurfaceFlags.SURF_NODAMAGE, 0 ), 213 | new InfoParm("metalsteps", 0, Q3SurfaceFlags.SURF_METALSTEPS,0 ), 214 | new InfoParm("flesh", 0, Q3SurfaceFlags.SURF_FLESH, 0 ), 215 | new InfoParm("nosteps", 0, Q3SurfaceFlags.SURF_NOSTEPS, 0 ), 216 | 217 | // drawsurf attributes 218 | new InfoParm("nodraw", 0, Q3SurfaceFlags.SURF_NODRAW, 0 ), // don't generate a drawsurface (or a lightmap) 219 | new InfoParm("pointlight", 0, Q3SurfaceFlags.SURF_POINTLIGHT, 0 ), // sample lighting at vertexes 220 | new InfoParm("nolightmap", 0, Q3SurfaceFlags.SURF_NOLIGHTMAP,0 ), // don't generate a lightmap 221 | new InfoParm("nodlight", 0, Q3SurfaceFlags.SURF_NODLIGHT, 0 ), // don't ever add dynamic lights 222 | new InfoParm("dust", 0, Q3SurfaceFlags.SURF_DUST, 0) // leave a dust trail when walking on this surface 223 | }; 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /BSPConvert.Lib/Source/ContentManager.cs: -------------------------------------------------------------------------------- 1 | using LibBSP; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.IO.Compression; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | 9 | namespace BSPConvert.Lib 10 | { 11 | public class ContentManager : IDisposable 12 | { 13 | private string contentDir; 14 | public string ContentDir 15 | { 16 | get { return contentDir; } 17 | } 18 | 19 | private BSP[] bspFiles; 20 | public BSP[] BSPFiles 21 | { 22 | get { return bspFiles; } 23 | } 24 | 25 | private static string Q3CONTENT_FOLDER = "Q3Content"; 26 | 27 | public ContentManager(string inputFile) 28 | { 29 | if (!File.Exists(inputFile)) 30 | throw new FileNotFoundException(inputFile); 31 | 32 | CreateContentDir(inputFile); 33 | LoadBSPFiles(inputFile); 34 | } 35 | 36 | // Create a temp directory used for converting assets across engines 37 | private void CreateContentDir(string inputFile) 38 | { 39 | var fileName = Path.GetFileNameWithoutExtension(inputFile); 40 | contentDir = Path.Combine(Path.GetTempPath(), fileName); 41 | 42 | // Delete any pre-existing temp content directory 43 | if (Directory.Exists(contentDir)) 44 | Directory.Delete(contentDir, true); 45 | 46 | Directory.CreateDirectory(contentDir); 47 | } 48 | 49 | private void LoadBSPFiles(string inputFile) 50 | { 51 | var ext = Path.GetExtension(inputFile); 52 | if (ext == ".bsp") 53 | bspFiles = new BSP[] { new BSP(new FileInfo(inputFile)) }; 54 | else if (ext == ".pk3") 55 | { 56 | // Extract bsp's from pk3 archive 57 | ZipFile.ExtractToDirectory(inputFile, contentDir); 58 | 59 | var files = Directory.GetFiles(ContentDir, "*.bsp", SearchOption.AllDirectories); 60 | bspFiles = new BSP[files.Length]; 61 | for (var i = 0; i < files.Length; i++) 62 | bspFiles[i] = new BSP(new FileInfo(files[i])); 63 | } 64 | else 65 | throw new Exception("Invalid input file extension: " + ext); 66 | } 67 | 68 | public static string GetQ3ContentDir() 69 | { 70 | return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Q3CONTENT_FOLDER); 71 | } 72 | 73 | public void Dispose() 74 | { 75 | // Delete temp content directory 76 | if (Directory.Exists(contentDir)) 77 | Directory.Delete(contentDir, true); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /BSPConvert.Lib/Source/EntityConverter.cs: -------------------------------------------------------------------------------- 1 | using LibBSP; 2 | using SharpCompress.Common; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Numerics; 7 | using System.Text; 8 | using System.Text.RegularExpressions; 9 | using System.Threading.Tasks; 10 | using System.Globalization; 11 | 12 | namespace BSPConvert.Lib 13 | { 14 | public class EntityConverter 15 | { 16 | [Flags] 17 | private enum TargetInitFlags 18 | { 19 | KeepArmor = 1, 20 | KeepHealth = 2, 21 | KeepWeapons = 4, 22 | KeepPowerUps = 8, 23 | KeepHoldable = 16, 24 | RemoveMachineGun = 32 25 | } 26 | 27 | [Flags] 28 | private enum FuncButtonFlags 29 | { 30 | DontMove = 1, 31 | TouchActivates = 256, 32 | DamageActivates = 512, 33 | } 34 | 35 | [Flags] 36 | private enum FuncDoorFlags 37 | { 38 | StartOpen = 1, 39 | Passable = 8, 40 | Toggle = 32, 41 | } 42 | 43 | [Flags] 44 | private enum Q3TriggerTeleportFlags 45 | { 46 | Spectator = 1, 47 | KeepSpeed = 2 48 | } 49 | 50 | [Flags] 51 | private enum Q3TriggerPushVelocityFlags 52 | { 53 | PLAYERDIR_XY = 1 << 0, 54 | ADD_XY = 1 << 1, 55 | PLAYERDIR_Z = 1 << 2, 56 | ADD_Z = 1 << 3, 57 | BIDIRECTIONAL_XY = 1 << 4, 58 | BIDIRECTIONAL_Z = 1 << 5, 59 | CLAMP_NEGATIVE_ADDS = 1 << 6 60 | } 61 | 62 | [Flags] 63 | private enum TargetSpeakerFlags 64 | { 65 | LoopedOn = 1, 66 | LoopedOff = 2, 67 | Global = 4, 68 | Activator = 8 69 | } 70 | 71 | [Flags] 72 | private enum AmbientGenericFlags 73 | { 74 | InfiniteRange = 1, 75 | StartSilent = 16, 76 | IsNotLooped = 32 77 | } 78 | 79 | [Flags] 80 | private enum TargetFragsFilterFlags 81 | { 82 | Remover = 1, 83 | Reset = 8, 84 | Match = 16 85 | } 86 | 87 | private Entities q3Entities; 88 | private Entities sourceEntities; 89 | private Dictionary shaderDict; 90 | private int minDamageToConvertTrigger; 91 | private bool ignoreZones; 92 | private Dictionary> entityDict = new Dictionary>(); 93 | private List removeEntities = new List(); // Entities to remove after conversion (ex: remove weapons after converting a trigger_multiple that references target_give). TODO: It might be better to convert entities by priority, such as trigger_multiples first so that target_give weapons can be ignored after 94 | private int currentCheckpointIndex = 2; 95 | private Lump q3Models; 96 | 97 | private const string MOMENTUM_START_ENTITY = "_momentum_player_start_"; 98 | private const string MOMENTUM_MATH_COUNTER = "_momentum_math_counter_"; 99 | private const string MOMENTUM_LOGIC_CASE = "_momentum_logic_case_"; 100 | private const int q3LipMod = 2; // Quake adds 2 units to button/door lip for some reason 101 | 102 | public EntityConverter(Lump q3Models, Entities q3Entities, Entities sourceEntities, Dictionary shaderDict, int minDamageToConvertTrigger, bool ignoreZones) 103 | { 104 | this.q3Entities = q3Entities; 105 | this.sourceEntities = sourceEntities; 106 | this.shaderDict = shaderDict; 107 | this.minDamageToConvertTrigger = minDamageToConvertTrigger; 108 | this.ignoreZones = ignoreZones; 109 | this.q3Models = q3Models; 110 | 111 | foreach (var entity in q3Entities) 112 | { 113 | if (!entityDict.ContainsKey(entity.Name)) 114 | entityDict.Add(entity.Name, new List() { entity }); 115 | else 116 | entityDict[entity.Name].Add(entity); 117 | } 118 | } 119 | 120 | public void Convert() 121 | { 122 | var giveTargets = GetGiveTargets(); 123 | 124 | foreach (var entity in q3Entities) 125 | { 126 | var ignoreEntity = false; 127 | 128 | switch (entity.ClassName) 129 | { 130 | case "worldspawn": 131 | ConvertWorldspawn(entity); 132 | break; 133 | case "info_player_start": 134 | ConvertPlayerStart(entity); 135 | break; 136 | case "info_player_deathmatch": 137 | ConvertPlayerStart(entity); 138 | break; 139 | case "trigger_hurt": 140 | ConvertTriggerHurt(entity); 141 | break; 142 | case "trigger_multiple": 143 | ConvertTriggerMultiple(entity); 144 | break; 145 | case "trigger_push": 146 | case "trigger_push_velocity": 147 | ConvertTriggerPush(entity); 148 | break; 149 | case "trigger_teleport": 150 | ConvertTriggerTeleport(entity); 151 | break; 152 | case "misc_teleporter_dest": 153 | ConvertTeleportDestination(entity); 154 | break; 155 | case "func_door": 156 | ConvertFuncDoor(entity); 157 | break; 158 | case "func_button": 159 | ConvertFuncButton(entity); 160 | break; 161 | case "func_rotating": 162 | ConvertFuncRotating(entity); 163 | break; 164 | case "func_static": 165 | ConvertFuncStatic(entity); 166 | break; 167 | case "func_plat": 168 | ConvertFuncPlat(entity); 169 | break; 170 | // Ignore these entities since they have no use in Source engine 171 | case "target_speaker": // converting this entity without a trigger input currently does nothing, convert during trigger_multiple conversion instead for now 172 | case "target_startTimer": 173 | case "target_stopTimer": 174 | case "target_checkpoint": 175 | case "target_give": 176 | case "target_init": 177 | case "target_delay": 178 | ignoreEntity = true; 179 | break; 180 | default: 181 | { 182 | if (!giveTargets.Contains(entity.Name)) // Don't convert equipment linked to target_give 183 | ConvertEquipment(entity); 184 | 185 | break; 186 | } 187 | } 188 | 189 | if (!ignoreEntity) 190 | { 191 | ConvertAngles(entity); 192 | sourceEntities.Add(entity); 193 | } 194 | } 195 | 196 | foreach (var entity in removeEntities) 197 | sourceEntities.Remove(entity); 198 | } 199 | 200 | private void ConvertTeleportDestination(Entity entity) 201 | { 202 | SetTeleportOrigin(entity); 203 | entity.ClassName = "info_teleport_destination"; 204 | } 205 | 206 | private HashSet GetGiveTargets() 207 | { 208 | var targets = new HashSet(); 209 | foreach (var entity in q3Entities) 210 | { 211 | if (entity.ClassName == "target_give" && entity.TryGetValue("target", out var target)) 212 | targets.Add(target); 213 | } 214 | 215 | return targets; 216 | } 217 | 218 | private void ConvertFuncRotating(Entity funcRotating) 219 | { 220 | if (!float.TryParse(funcRotating["speed"], out var speed)) 221 | speed = 100; 222 | 223 | funcRotating["spawnflags"] = "1"; 224 | funcRotating["maxspeed"] = speed.ToString(CultureInfo.InvariantCulture); 225 | } 226 | 227 | private void ConvertFuncStatic(Entity funcStatic) 228 | { 229 | if (funcStatic["notcpm"] == "1") // TODO: Figure out how to handle gamemode specific entities more robustly 230 | return; 231 | 232 | funcStatic.ClassName = "func_brush"; 233 | } 234 | 235 | private void ConvertFuncPlat(Entity entity) 236 | { 237 | var moveDistance = 0f; 238 | var brushThickness = GetBrushThickness(entity); 239 | 240 | if (float.TryParse(entity["height"], out var height)) 241 | moveDistance = height + brushThickness; 242 | else if (float.TryParse(entity["lip"], out var lip)) 243 | moveDistance = -(lip - q3LipMod - (brushThickness * 2)); 244 | 245 | if (string.IsNullOrEmpty(entity.Name)) 246 | { 247 | entity.Name = $"plat{entity.ModelNumber}"; 248 | CreatePlatTrigger(entity); 249 | } 250 | entity.ClassName = "func_door"; 251 | entity["lip"] = moveDistance.ToString(CultureInfo.InvariantCulture); 252 | entity["movedir"] = "-90 0 0"; 253 | entity["spawnpos"] = "1"; 254 | entity["spawnflags"] = "0"; 255 | entity["wait"] = "-1"; 256 | } 257 | 258 | private void CreatePlatTrigger(Entity entity) 259 | { 260 | var trigger = new Entity 261 | { 262 | ClassName = "trigger_multiple", 263 | Model = entity.Model, 264 | Spawnflags = 1, 265 | Origin = new Vector3(entity.Origin.X, entity.Origin.Y, entity.Origin.Z + 2) 266 | }; 267 | trigger["parentname"] = entity.Name; 268 | sourceEntities.Add(trigger); 269 | 270 | AddPlatTriggerConnections(entity, trigger); 271 | } 272 | 273 | private void AddPlatTriggerConnections(Entity plat, Entity trigger) 274 | { 275 | var connection = new Entity.EntityConnection() 276 | { 277 | name = "onStartTouch", 278 | target = plat.Name, 279 | action = "close", 280 | param = null, 281 | delay = 0, 282 | fireOnce = -1 283 | }; 284 | trigger.connections.Add(connection); 285 | 286 | var connection2 = new Entity.EntityConnection() 287 | { 288 | name = "onFullyClosed", 289 | target = plat.Name, 290 | action = "open", 291 | param = null, 292 | delay = 3, // placeholder value TODO: replicate actual func_plat behaviour 293 | fireOnce = -1 294 | }; 295 | plat.connections.Add(connection2); 296 | } 297 | 298 | private float GetBrushThickness(Entity entity) 299 | { 300 | var model = q3Models[entity.ModelNumber]; 301 | 302 | return model.Maximums.Z - model.Minimums.Z; 303 | } 304 | 305 | private void ConvertFuncDoor(Entity door) 306 | { 307 | SetMoveDir(door); 308 | 309 | if (string.IsNullOrEmpty(door["wait"])) 310 | door["wait"] = "2"; 311 | else if (door["wait"] == "-1") // A value of -1 in quake is instantly reset position, in source it is don't reset position. 312 | door["wait"] = "0.001"; // exactly 0 also behaves as don't reset in source, so the delay is as short as possible without being 0. 313 | 314 | if (string.IsNullOrEmpty(door["speed"])) 315 | door["speed"] = "400"; 316 | else if (door["speed"] == "-1") // A value of -1 in quake is teleport to end position, in source it is don't move. Set speed as fast as possible in source. 317 | door["speed"] = "99999"; 318 | 319 | if (!float.TryParse(door["lip"], out var lip)) 320 | door["lip"] = "6"; 321 | else 322 | door["lip"] = $"{lip - q3LipMod}"; 323 | 324 | var spawnflags = (FuncDoorFlags)door.Spawnflags; 325 | 326 | if (spawnflags.HasFlag(FuncDoorFlags.StartOpen)) 327 | { 328 | door["spawnpos"] = "1"; 329 | door.Spawnflags = (int)FuncDoorFlags.Toggle; 330 | 331 | ResetDoorPosition(door); // Door doesn't automatically reopen if StartOpen spawnflag is set 332 | } 333 | 334 | if (float.TryParse(door["health"], out _)) 335 | { 336 | CreateNewDoorButton(door); // Health is obsolete on func_door, make a new button parented to the door to open it. TODO: Fix in engine and delete this? 337 | door.Spawnflags |= (int)FuncDoorFlags.Passable; // Make the door non-solid so you can shoot the new parented button through the door 338 | } 339 | 340 | var target = GetTargetEntities(door).FirstOrDefault(); 341 | if (target != null) 342 | { 343 | var input = "OnFullyOpen"; 344 | if (door["spawnpos"] == "1") 345 | input = "OnFullyClosed"; 346 | 347 | ConvertEntityTargetsRecursive(door, door, input, 0, new HashSet()); 348 | } 349 | } 350 | 351 | private void CreateNewDoorButton(Entity door) 352 | { 353 | var button = new Entity(); 354 | 355 | if (string.IsNullOrEmpty(door.Name)) 356 | door.Name = $"door{door.ModelNumber}"; 357 | 358 | button["parentname"] = door.Name; 359 | button.ClassName = "func_button"; 360 | button.Model = door.Model; 361 | button["rendermode"] = "1"; 362 | button["renderamt"] = "0"; // Make button invisible 363 | button.Spawnflags |= (int)FuncButtonFlags.DamageActivates; 364 | button.Spawnflags |= (int)FuncButtonFlags.DontMove; 365 | 366 | sourceEntities.Add(button); 367 | 368 | OpenDoorOnOutput(button, door, "OnPressed", 0); 369 | } 370 | 371 | private static void ResetDoorPosition(Entity door) 372 | { 373 | if (!float.TryParse(door["wait"], out var delay)) 374 | return; 375 | 376 | var connection = new Entity.EntityConnection() 377 | { 378 | name = "OnFullyClosed", 379 | target = "!self", 380 | action = "Open", 381 | param = null, 382 | delay = delay, 383 | fireOnce = -1 384 | }; 385 | door.connections.Add(connection); 386 | } 387 | 388 | private void ConvertFuncButton(Entity button) 389 | { 390 | SetMoveDir(button); 391 | SetButtonFlags(button); 392 | 393 | var delay = 0f; 394 | ConvertEntityTargetsRecursive(button, button, "OnIn", delay, new HashSet()); 395 | 396 | if (string.IsNullOrEmpty(button["speed"])) 397 | button["speed"] = "40"; 398 | else if (button["speed"] == "-1") // A value of -1 in quake is teleport to end position, in source it is don't move. Set speed as fast as possible in source. 399 | button["speed"] = "99999"; 400 | 401 | if (string.IsNullOrEmpty(button["wait"])) 402 | button["wait"] = "1"; 403 | else if (button["wait"] == "-1") // A value of -1 in quake is instantly reset position, in source it is don't reset position. 404 | button["wait"] = "0.001"; // exactly 0 also behaves as don't reset in source, so the delay is as short as possible without being 0. 405 | 406 | if (!float.TryParse(button["lip"], out var lip)) 407 | button["lip"] = "2"; 408 | else 409 | button["lip"] = $"{lip - q3LipMod}"; 410 | 411 | button["customsound"] = "movers/switches/butn2.wav"; 412 | button["sounds"] = "-1"; 413 | } 414 | 415 | private static void OpenDoorOnOutput(Entity entity, Entity door, string output, float delay) 416 | { 417 | var input = "Open"; 418 | var spawnflags = (FuncDoorFlags)door.Spawnflags; 419 | 420 | if (spawnflags.HasFlag(FuncDoorFlags.StartOpen) || door["spawnpos"] == "1") 421 | input = "Close"; 422 | 423 | var connection = new Entity.EntityConnection() 424 | { 425 | name = output, 426 | target = door["targetname"], 427 | action = input, 428 | param = null, 429 | delay = delay, 430 | fireOnce = -1 431 | }; 432 | entity.connections.Add(connection); 433 | } 434 | 435 | private void FireTargetSpeedOnOutput(Entity entity, Entity targetSpeed, string output, float delay) 436 | { 437 | var connection = new Entity.EntityConnection() 438 | { 439 | name = output, 440 | target = targetSpeed["targetname"], 441 | action = "Fire", 442 | param = null, 443 | delay = delay, 444 | fireOnce = -1 445 | }; 446 | entity.connections.Add(connection); 447 | 448 | if (targetSpeed.ClassName != "player_speed") 449 | ConvertTargetSpeed(targetSpeed); 450 | } 451 | 452 | private static void SetButtonFlags(Entity button) 453 | { 454 | if (!float.TryParse(button["speed"], out var speed)) 455 | speed = 40; 456 | 457 | var spawnflags = 0; 458 | 459 | if ((speed == -1 || speed >= 9999) && (button["wait"] == "-1")) // TODO: Add customization setting for the upper bounds potentially? 460 | spawnflags |= (int)FuncButtonFlags.DontMove; 461 | 462 | if (!float.TryParse(button["health"], out var health) || button["health"] == "0") 463 | spawnflags |= (int)FuncButtonFlags.TouchActivates; 464 | else 465 | spawnflags |= (int)FuncButtonFlags.DamageActivates; 466 | 467 | button["spawnflags"] = spawnflags.ToString(CultureInfo.InvariantCulture); 468 | } 469 | 470 | private static void SetMoveDir(Entity entity) 471 | { 472 | if (!float.TryParse(entity["angle"], out var angle)) 473 | { 474 | if (!string.IsNullOrEmpty(entity["angles"])) 475 | entity["movedir"] = entity["angles"]; 476 | else 477 | entity["movedir"] = "0 0 0"; 478 | } 479 | else if (angle == -1) // UP 480 | entity["movedir"] = "-90 0 0"; 481 | else if (angle == -2) // DOWN 482 | entity["movedir"] = "90 0 0"; 483 | else 484 | entity["movedir"] = $"0 {angle} 0"; 485 | 486 | entity.Remove("angle"); 487 | entity.Remove("angles"); 488 | } 489 | 490 | private void ConvertWorldspawn(Entity worldspawn) 491 | { 492 | foreach (var shader in shaderDict.Values) 493 | { 494 | if (shader.skyParms != null) 495 | { 496 | var skyName = shader.skyParms.outerBox; 497 | if (!string.IsNullOrEmpty(skyName)) 498 | worldspawn["skyname"] = skyName; 499 | } 500 | } 501 | } 502 | 503 | private void ConvertPlayerStart(Entity playerStart) 504 | { 505 | playerStart.ClassName = "info_player_start"; 506 | playerStart.Name = MOMENTUM_START_ENTITY; 507 | 508 | var targets = GetTargetEntities(playerStart); 509 | if (targets.Any()) 510 | { 511 | var logicAuto = new Entity(); 512 | logicAuto.ClassName = "logic_auto"; 513 | 514 | ConvertEntityTargetsRecursive(logicAuto, playerStart, "OnMapSpawn", 0, new HashSet()); 515 | 516 | sourceEntities.Add(logicAuto); 517 | } 518 | } 519 | 520 | private void ConvertTriggerHurt(Entity trigger) 521 | { 522 | if (int.TryParse(trigger["dmg"], out var damage)) 523 | { 524 | if (damage >= minDamageToConvertTrigger) 525 | { 526 | trigger.ClassName = "trigger_teleport"; 527 | trigger["target"] = MOMENTUM_START_ENTITY; 528 | trigger["spawnflags"] = "1"; 529 | trigger["velocitymode"] = "1"; 530 | } 531 | } 532 | } 533 | 534 | private void ConvertTriggerMultiple(Entity trigger) 535 | { 536 | var delay = 0f; 537 | ConvertEntityTargetsRecursive(trigger, trigger, "OnTrigger", delay, new HashSet()); 538 | 539 | trigger["spawnflags"] = "1"; 540 | } 541 | 542 | private void ConvertEntityTargetsRecursive(Entity entity, Entity targetEntity, string output, float delay, HashSet visited) 543 | { 544 | var targets = GetTargetEntities(targetEntity); 545 | foreach (var target in targets) 546 | { 547 | if (visited.Contains(target) || targetEntity == target) 548 | continue; 549 | 550 | switch (target.ClassName) 551 | { 552 | case "target_stopTimer": 553 | ConvertTimerTrigger(entity, "trigger_momentum_timer_stop", 0); 554 | break; 555 | case "target_checkpoint": 556 | ConvertTimerTrigger(entity, "trigger_momentum_timer_checkpoint", currentCheckpointIndex); 557 | currentCheckpointIndex++; 558 | break; 559 | case "target_delay": 560 | delay += ConvertTargetDelay(target); 561 | break; 562 | case "target_give": 563 | FireTargetGiveOnOutput(entity, target, output, delay); 564 | break; 565 | case "target_teleporter": 566 | FireTargetTeleporterOnOutput(entity, target, output, delay); 567 | break; 568 | case "target_kill": 569 | ConvertKillTrigger(entity); 570 | break; 571 | case "target_init": 572 | FireTargetInitOnOutput(entity, target, output, delay); 573 | break; 574 | case "target_speaker": 575 | case "ambient_generic": 576 | FireTargetSpeakerOnOutput(entity, target, output, delay); 577 | break; 578 | case "target_print": 579 | case "target_smallprint": 580 | case "game_text": 581 | FireTargetPrintOnOutput(entity, target, output, delay); 582 | break; 583 | case "target_speed": 584 | case "player_speed": 585 | FireTargetSpeedOnOutput(entity, target, output, delay); 586 | break; 587 | case "target_push": 588 | FireTargetPushOnOutput(entity, target, output, delay); 589 | break; 590 | case "target_remove_powerups": 591 | SetHasteOnOutput(entity, "0", output, delay); 592 | SetQuadOnOutput(entity, "0", output, delay); 593 | break; 594 | case "func_door": 595 | OpenDoorOnOutput(entity, target, output, delay); 596 | break; 597 | case "target_relay": 598 | case "logic_relay": 599 | FireTargetRelayOnOutput(entity, target, output, delay); 600 | break; 601 | case "target_fragsFilter": 602 | ConvertFragsFilter(entity, target, output, delay); 603 | break; 604 | case "target_score": 605 | ConvertTargetScore(entity, target, output, delay); 606 | break; 607 | } 608 | 609 | visited.Add(target); 610 | if (target.ClassName != "logic_relay") // logic_relay moves the next target's inputs to a separate entity instead, break from the loop 611 | ConvertEntityTargetsRecursive(entity, target, output, delay, visited); 612 | } 613 | } 614 | 615 | private void FireTargetTeleporterOnOutput(Entity entity, Entity targetTeleporter, string output, float delay) 616 | { 617 | var targets = GetTargetEntities(targetTeleporter); 618 | foreach (var target in targets) 619 | { 620 | if (target.ClassName != "point_teleport") 621 | { 622 | if (target.ClassName != "info_teleport_destination") //if already a teleport_destination, origin has been fixed elsewhere 623 | SetTeleportOrigin(target); 624 | 625 | target.ClassName = "point_teleport"; 626 | target["target"] = "!player"; 627 | target["velocitymode"] = "3"; 628 | target["setspeed"] = targetTeleporter.Spawnflags == 1 ? "0" : "400"; //spawnflag 1 is keep speed, else set speed to 400 629 | target.Spawnflags = 0; 630 | target["usedestinationangles"] = "1"; 631 | } 632 | 633 | var connection = new Entity.EntityConnection() 634 | { 635 | name = output, 636 | target = target.Name, 637 | action = "Teleport", 638 | param = null, 639 | delay = delay, 640 | fireOnce = -1 641 | }; 642 | entity.connections.Add(connection); 643 | } 644 | } 645 | 646 | private void ConvertTargetScore(Entity entity, Entity targetScore, string output, float delay) 647 | { 648 | if (!sourceEntities.Any(x => x.ClassName == "math_counter")) // Check if math_counter exists 649 | CreateMathCounter(); 650 | 651 | if (!float.TryParse(targetScore["count"], out var count)) 652 | count = 1; 653 | 654 | ModifyMathCounter(entity, output, "Add", count.ToString(CultureInfo.InvariantCulture), delay); 655 | } 656 | 657 | private Entity CreateLogicCase() 658 | { 659 | var logicCase = new Entity(); 660 | logicCase.ClassName = "logic_case"; 661 | logicCase.Name = MOMENTUM_LOGIC_CASE; 662 | 663 | for (var i = 1; i <= 16; i++) // Logic_case supports 16 different outputs 664 | { 665 | var caseNum = $"case{i:D2}"; 666 | logicCase[caseNum] = (i-1).ToString(CultureInfo.InvariantCulture); // case01 = 0, case02 = 1 etc 667 | } 668 | 669 | var connection = new Entity.EntityConnection() 670 | { 671 | name = "OnUsed", 672 | target = "*_mom_relay*", // Disable all logic_relays 673 | action = "Disable", 674 | param = null, 675 | delay = 0, 676 | fireOnce = -1 677 | }; 678 | logicCase.connections.Add(connection); 679 | 680 | sourceEntities.Add(logicCase); 681 | 682 | return logicCase; 683 | } 684 | 685 | private void CreateMathCounter() 686 | { 687 | var counter = new Entity(); 688 | counter.ClassName = "math_counter"; 689 | counter.Name = MOMENTUM_MATH_COUNTER; 690 | counter["startvalue"] = "0"; 691 | counter["min"] = "0"; 692 | counter["max"] = "16"; 693 | 694 | var connection = new Entity.EntityConnection() 695 | { 696 | name = "OutValue", 697 | target = MOMENTUM_LOGIC_CASE, 698 | action = "InValue", 699 | param = null, 700 | delay = 0, 701 | fireOnce = -1 702 | }; 703 | counter.connections.Add(connection); 704 | 705 | sourceEntities.Add(counter); 706 | } 707 | 708 | private void ConvertFragsFilter(Entity entity, Entity targetFragsFilter, string output, float delay) 709 | { 710 | if (!int.TryParse(targetFragsFilter["frags"], out var frags)) 711 | frags = 1; // Default number of frags is 1 if no value is specified 712 | 713 | targetFragsFilter["startdisabled"] = (frags > 0) ? "1" : "0"; // Players start with 0 frags, disable entities that require > 0 714 | 715 | targetFragsFilter.Name += $"_mom_relay{frags:D2}"; // Name needs a unique relay number for the logic_case to target 716 | 717 | var match = false; 718 | var spawnflags = (TargetFragsFilterFlags)targetFragsFilter.Spawnflags; 719 | 720 | if (spawnflags.HasFlag(TargetFragsFilterFlags.Reset)) // Reset frags to 0 721 | ModifyMathCounter(targetFragsFilter, "OnTrigger", "SetValue", "0", delay); 722 | else if (spawnflags.HasFlag(TargetFragsFilterFlags.Remover)) // Remove frags when used 723 | ModifyMathCounter(targetFragsFilter, "OnTrigger", "Subtract", frags.ToString(CultureInfo.InvariantCulture), delay); 724 | 725 | if (spawnflags.HasFlag(TargetFragsFilterFlags.Match)) 726 | match = true; 727 | 728 | FireTargetRelayOnOutput(entity, targetFragsFilter, output, delay); 729 | 730 | AddLogicCaseOutput(targetFragsFilter.Name, frags, match); 731 | } 732 | 733 | private void FireTargetRelayOnOutput(Entity entity, Entity targetRelay, string output, float delay) 734 | { 735 | var connection = new Entity.EntityConnection() 736 | { 737 | name = output, 738 | target = targetRelay.Name, 739 | action = "Trigger", 740 | param = null, 741 | delay = delay, 742 | fireOnce = -1 743 | }; 744 | entity.connections.Add(connection); 745 | 746 | if (targetRelay.ClassName != "logic_relay") 747 | { 748 | targetRelay.ClassName = "logic_relay"; 749 | targetRelay.Spawnflags = 2; 750 | 751 | ConvertEntityTargetsRecursive(targetRelay, targetRelay, "OnTrigger", 0, new HashSet()); 752 | } 753 | } 754 | 755 | private void AddLogicCaseOutput(string targetName, int frags, bool match) 756 | { 757 | var logicCase = sourceEntities.Find(x => x.ClassName == "logic_case") ?? CreateLogicCase(); 758 | 759 | var min = frags; 760 | var max = match ? frags : 16; // Either force frags to match case number on true, else allow any cases over the frag count to trigger 761 | 762 | for (var i = min; i <= max; i++) 763 | { 764 | var caseNum = $"case{i+1:D2}"; 765 | 766 | var connection = new Entity.EntityConnection() 767 | { 768 | name = $"On{caseNum}", 769 | target = targetName, 770 | action = "Enable", 771 | param = null, 772 | delay = 0.008f, 773 | fireOnce = -1 774 | }; 775 | logicCase.connections.Add(connection); 776 | } 777 | } 778 | 779 | private void ModifyMathCounter(Entity entity, string output, string input, string value, float delay) 780 | { 781 | var connection = new Entity.EntityConnection() 782 | { 783 | name = output, 784 | target = MOMENTUM_MATH_COUNTER, 785 | action = input, 786 | param = value, 787 | delay = delay, 788 | fireOnce = -1 789 | }; 790 | entity.connections.Add(connection); 791 | } 792 | 793 | private float ConvertTargetDelay(Entity targetDelay) 794 | { 795 | if (float.TryParse(targetDelay["delay"], out var delay)) 796 | return delay; 797 | else if (float.TryParse(targetDelay["wait"], out var wait)) 798 | return wait; 799 | else 800 | return 1; 801 | } 802 | 803 | private void FireTargetPushOnOutput(Entity entity, Entity targetPush, string output, float delay) 804 | { 805 | var launchVector = "0 0 0"; 806 | var targetPosition = GetTargetEntities(targetPush).FirstOrDefault(); 807 | 808 | if (targetPosition != null) 809 | { 810 | targetPosition.ClassName = "info_target"; 811 | launchVector = GetLaunchVectorWithTarget(targetPush, targetPosition); 812 | } 813 | else 814 | launchVector = GetLaunchVector(targetPush); 815 | 816 | SetLocalVelocityOnOutput(entity, launchVector, output, delay); 817 | } 818 | 819 | private static void SetLocalVelocityOnOutput(Entity entity, string launchVector, string output, float delay) 820 | { 821 | var connection = new Entity.EntityConnection() 822 | { 823 | name = output, 824 | target = "!player", 825 | action = "SetLocalVelocity", 826 | param = launchVector, 827 | delay = delay, 828 | fireOnce = -1 829 | }; 830 | entity.connections.Add(connection); 831 | } 832 | 833 | private static string GetLaunchVector(Entity targetPush) 834 | { 835 | var angles = "0 0 0"; 836 | 837 | if (!string.IsNullOrEmpty(targetPush["angles"])) 838 | angles = targetPush["angles"]; 839 | else if (float.TryParse(targetPush["angle"], out var angle)) 840 | angles = $"0 {angle} 0"; 841 | 842 | var angleString = angles.Split(' '); 843 | 844 | var pitchDegrees = float.Parse(angleString[0], CultureInfo.InvariantCulture); 845 | var yawDegrees = float.Parse(angleString[1], CultureInfo.InvariantCulture); 846 | 847 | var launchDir = ConvertAnglesToVector(pitchDegrees, yawDegrees); 848 | 849 | if (!float.TryParse(targetPush["speed"], out var speed)) 850 | speed = 1000; 851 | else 852 | speed = float.Parse(targetPush["speed"], CultureInfo.InvariantCulture); 853 | 854 | var launchVector = launchDir * speed; 855 | return $"{launchVector.X} {launchVector.Y} {launchVector.Z}"; 856 | } 857 | 858 | private string GetLaunchVectorWithTarget(Entity targetPush, Entity targetPosition) 859 | { 860 | var gravity = 800f; 861 | var height = targetPosition.Origin.Z - targetPush.Origin.Z; 862 | var time = Math.Sqrt(height / (.5 * gravity)); // Calculates how many seconds it takes to reach the apex of the launch 863 | 864 | var xDist = targetPosition.Origin.X - targetPush.Origin.X; 865 | var yDist = targetPosition.Origin.Y - targetPush.Origin.Y; 866 | 867 | var xSpeed = xDist / time; 868 | var ySpeed = yDist / time; 869 | var zSpeed = time * gravity; 870 | 871 | return $"{xSpeed} {ySpeed} {zSpeed}"; 872 | } 873 | 874 | private static Vector3 ConvertAnglesToVector(float pitchDegrees, float yawDegrees) 875 | { 876 | var yaw = Math.PI * yawDegrees / 180.0; 877 | var pitch = Math.PI * -pitchDegrees / 180.0; 878 | 879 | var x = Math.Cos(yaw) * Math.Cos(pitch); 880 | var y = Math.Sin(yaw) * Math.Cos(pitch); 881 | var z = Math.Sin(pitch); 882 | 883 | return new Vector3((float)x, (float)y, (float)z); 884 | } 885 | 886 | private void ConvertTargetSpeed(Entity targetSpeed) 887 | { 888 | if (targetSpeed["notcpm"] == "1") // TODO: Figure out how to handle gamemode specific entities more robustly 889 | return; 890 | 891 | targetSpeed.ClassName = "player_speed"; 892 | 893 | if (!targetSpeed.TryGetValue("speed", out var speed)) 894 | targetSpeed["speed"] = "100"; 895 | } 896 | 897 | private void FireTargetPrintOnOutput(Entity entity, Entity targetPrint, string output, float delay) 898 | { 899 | var connection = new Entity.EntityConnection() 900 | { 901 | name = output, 902 | target = targetPrint["targetname"], 903 | action = "Display", 904 | param = null, 905 | delay = delay, 906 | fireOnce = -1 907 | }; 908 | entity.connections.Add(connection); 909 | 910 | if (targetPrint.ClassName != "game_text") 911 | ConvertTargetPrint(targetPrint); 912 | } 913 | 914 | private void ConvertTargetPrint(Entity targetPrint) 915 | { 916 | var regex = new Regex("\\^[1-9]"); 917 | targetPrint["message"] = regex.Replace(targetPrint["message"].Replace("\\n", "\n", StringComparison.InvariantCulture), ""); // Removes q3 colour codes from string and fixes broken newline character 918 | targetPrint.ClassName = "game_text"; 919 | targetPrint["color"] = "255 255 255"; 920 | targetPrint["color2"] = "255 255 255"; 921 | targetPrint["effect"] = "0"; 922 | targetPrint["fadein"] = "0.5"; 923 | targetPrint["fadeout"] = "0.5"; 924 | targetPrint["holdtime"] = "3"; 925 | targetPrint["x"] = "-1"; 926 | targetPrint["y"] = "0.2"; 927 | } 928 | 929 | private void FireTargetSpeakerOnOutput(Entity entity, Entity targetSpeaker, string output, float delay) 930 | { 931 | var connection = new Entity.EntityConnection() 932 | { 933 | name = output, 934 | target = targetSpeaker["targetname"], 935 | action = "PlaySound", 936 | param = null, 937 | delay = delay, 938 | fireOnce = -1 939 | }; 940 | entity.connections.Add(connection); 941 | 942 | if (targetSpeaker.ClassName != "ambient_generic") 943 | ConvertTargetSpeaker(targetSpeaker); 944 | } 945 | 946 | private void ConvertTargetSpeaker(Entity targetSpeaker) 947 | { 948 | var noise = targetSpeaker["noise"]; 949 | noise = RemoveFirstOccurrence(noise, "sound/"); 950 | 951 | targetSpeaker.ClassName = "ambient_generic"; 952 | targetSpeaker["message"] = noise; 953 | targetSpeaker["health"] = "10"; // Volume 954 | targetSpeaker["radius"] = "1250"; 955 | targetSpeaker["pitch"] = "100"; 956 | 957 | SetAmbientGenericFlags(targetSpeaker); 958 | } 959 | 960 | private string RemoveFirstOccurrence(string noise, string removeStr) 961 | { 962 | if (!noise.StartsWith(removeStr, StringComparison.OrdinalIgnoreCase)) 963 | return noise; 964 | 965 | return noise.Remove(0, removeStr.Length); 966 | } 967 | 968 | private void SetAmbientGenericFlags(Entity targetSpeaker) 969 | { 970 | var q3flags = (TargetSpeakerFlags)targetSpeaker.Spawnflags; 971 | var sourceflags = 0; 972 | 973 | if (q3flags.HasFlag(TargetSpeakerFlags.LoopedOff)) 974 | sourceflags |= (int)AmbientGenericFlags.StartSilent; 975 | else if (!q3flags.HasFlag(TargetSpeakerFlags.LoopedOn)) 976 | sourceflags |= (int)AmbientGenericFlags.IsNotLooped; 977 | 978 | if (q3flags.HasFlag(TargetSpeakerFlags.Global) || q3flags.HasFlag(TargetSpeakerFlags.Activator)) 979 | sourceflags |= (int)AmbientGenericFlags.InfiniteRange; 980 | 981 | targetSpeaker["spawnflags"] = sourceflags.ToString(CultureInfo.InvariantCulture); 982 | } 983 | 984 | private void FireTargetInitOnOutput(Entity entity, Entity targetInit, string output, float delay) 985 | { 986 | var spawnflags = (TargetInitFlags)targetInit.Spawnflags; 987 | if (!spawnflags.HasFlag(TargetInitFlags.KeepPowerUps)) 988 | { 989 | SetHasteOnOutput(entity, "0", output, delay); 990 | SetQuadOnOutput(entity, "0", output, delay); 991 | } 992 | if (!spawnflags.HasFlag(TargetInitFlags.KeepWeapons)) 993 | { 994 | RemoveWeaponOnOutput(entity, "weapon_momentum_df_grenadelauncher", output, delay); 995 | RemoveWeaponOnOutput(entity, "weapon_momentum_df_rocketlauncher", output, delay); 996 | RemoveWeaponOnOutput(entity, "weapon_momentum_df_plasmagun", output, delay); 997 | RemoveWeaponOnOutput(entity, "weapon_momentum_df_lightninggun", output, delay); 998 | RemoveWeaponOnOutput(entity, "weapon_momentum_df_railgun", output, delay); 999 | RemoveWeaponOnOutput(entity, "weapon_momentum_df_bfg", output, delay); 1000 | RemoveWeaponOnOutput(entity, "weapon_momentum_df_shotgun", output, delay); 1001 | } 1002 | if (spawnflags.HasFlag(TargetInitFlags.RemoveMachineGun)) 1003 | { 1004 | RemoveWeaponOnOutput(entity, "weapon_momentum_df_machinegun", output, delay); 1005 | } 1006 | } 1007 | 1008 | private static void RemoveWeaponOnOutput(Entity entity, string weaponName, string output, float delay) 1009 | { 1010 | var connection = new Entity.EntityConnection() 1011 | { 1012 | name = output, 1013 | target = "!player", 1014 | action = "RemoveWeapon", 1015 | param = weaponName, 1016 | delay = delay, 1017 | fireOnce = -1 1018 | }; 1019 | entity.connections.Add(connection); 1020 | } 1021 | 1022 | private void ConvertKillTrigger(Entity trigger) 1023 | { 1024 | if (!trigger.ClassName.StartsWith("trigger", StringComparison.OrdinalIgnoreCase)) 1025 | return; 1026 | 1027 | trigger.ClassName = "trigger_teleport"; 1028 | trigger["target"] = MOMENTUM_START_ENTITY; 1029 | trigger["velocitymode"] = "1"; 1030 | } 1031 | 1032 | private void ConvertTimerTrigger(Entity trigger, string className, int zoneNumber) 1033 | { 1034 | if (ignoreZones || !trigger.ClassName.StartsWith("trigger", StringComparison.OrdinalIgnoreCase)) 1035 | return; 1036 | 1037 | var newTrigger = new Entity(); 1038 | 1039 | newTrigger.ClassName = className; 1040 | newTrigger.Model = trigger.Model; 1041 | newTrigger.Spawnflags = 1; 1042 | newTrigger["zone_number"] = zoneNumber.ToString(CultureInfo.InvariantCulture); 1043 | 1044 | sourceEntities.Add(newTrigger); 1045 | } 1046 | 1047 | // TODO: Convert target_give for player spawn entities 1048 | private void FireTargetGiveOnOutput(Entity entity, Entity targetGive, string output, float delay) 1049 | { 1050 | // TODO: Support more entities (health, armor, etc.) 1051 | var targets = GetTargetEntities(targetGive); 1052 | foreach (var target in targets) 1053 | { 1054 | switch (target.ClassName) 1055 | { 1056 | case "item_haste": 1057 | SetHasteOnOutput(entity, ConvertPowerupCount(target["count"]), output, delay + 0.008f); //hack to make giving haste happen after target_init strip 1058 | break; 1059 | case "item_enviro": // TODO: Not supported yet 1060 | break; 1061 | case "item_flight": // TODO: Not supported yet 1062 | break; 1063 | case "item_quad": 1064 | SetQuadOnOutput(entity, ConvertPowerupCount(target["count"]), output, delay + 0.008f); //hack to make giving quad happen after target_init strip 1065 | break; 1066 | default: 1067 | if (target.ClassName.StartsWith("weapon_", StringComparison.OrdinalIgnoreCase)) 1068 | GiveWeaponOnOutput(entity, target, output, delay); 1069 | else if (target.ClassName.StartsWith("ammo_", StringComparison.OrdinalIgnoreCase)) 1070 | GiveAmmoOnOutput(entity, target, output, delay); 1071 | break; 1072 | } 1073 | 1074 | removeEntities.Add(target); 1075 | } 1076 | } 1077 | 1078 | private void SetHasteOnOutput(Entity entity, string duration, string output, float delay) 1079 | { 1080 | var connection = new Entity.EntityConnection() 1081 | { 1082 | name = output, 1083 | target = "!player", 1084 | action = "SetHaste", 1085 | param = duration, 1086 | delay = delay, 1087 | fireOnce = -1 1088 | }; 1089 | entity.connections.Add(connection); 1090 | } 1091 | 1092 | private static void SetQuadOnOutput(Entity entity, string duration, string output, float delay) 1093 | { 1094 | var connection = new Entity.EntityConnection() 1095 | { 1096 | name = output, 1097 | target = "!player", 1098 | action = "SetDamageBoost", 1099 | param = duration, 1100 | delay = delay, 1101 | fireOnce = -1 1102 | }; 1103 | entity.connections.Add(connection); 1104 | } 1105 | 1106 | private void GiveWeaponOnOutput(Entity entity, Entity weaponEnt, string output, float delay) 1107 | { 1108 | var weaponName = GetMomentumWeaponName(weaponEnt.ClassName); 1109 | if (string.IsNullOrEmpty(weaponName)) 1110 | return; 1111 | 1112 | // TODO: Support weapon count 1113 | var connection = new Entity.EntityConnection() 1114 | { 1115 | name = output, 1116 | target = "!player", 1117 | action = "GiveWeapon", 1118 | param = weaponName, 1119 | delay = delay + 0.008f, //hack to make giving weapon happen after target_init strip 1120 | fireOnce = -1 1121 | }; 1122 | entity.connections.Add(connection); 1123 | 1124 | GiveWeaponAmmoOnOutput(entity, weaponEnt, output, delay); 1125 | } 1126 | 1127 | private void GiveWeaponAmmoOnOutput(Entity entity, Entity weaponEnt, string output, float delay) 1128 | { 1129 | var count = ConvertWeaponAmmoCount(weaponEnt.ClassName, weaponEnt["count"]); 1130 | if (float.Parse(count, CultureInfo.InvariantCulture) < 0) 1131 | return; 1132 | 1133 | var ammoType = GetWeaponAmmoType(weaponEnt.ClassName); 1134 | if (string.IsNullOrEmpty(ammoType)) 1135 | return; 1136 | 1137 | var connection = new Entity.EntityConnection() 1138 | { 1139 | name = output, 1140 | target = "!player", 1141 | action = ammoType, 1142 | param = count, 1143 | delay = delay, 1144 | fireOnce = -1 1145 | }; 1146 | entity.connections.Add(connection); 1147 | } 1148 | 1149 | private string ConvertWeaponAmmoCount(string weaponName, string count) 1150 | { 1151 | if (!string.IsNullOrEmpty(count) && count != "0") 1152 | return count; 1153 | 1154 | switch (weaponName) 1155 | { 1156 | case "weapon_machinegun": 1157 | return "40"; 1158 | case "weapon_grenadelauncher": 1159 | return "10"; 1160 | case "weapon_rocketlauncher": 1161 | return "10"; 1162 | case "weapon_plasmagun": 1163 | return "50"; 1164 | case "weapon_lightning": 1165 | return "100"; 1166 | case "weapon_bfg": 1167 | return "20"; 1168 | case "weapon_shotgun": 1169 | return "10"; 1170 | default: 1171 | return "-1"; 1172 | } 1173 | } 1174 | 1175 | private string GetWeaponAmmoType(string weaponName) 1176 | { 1177 | switch (weaponName) 1178 | { 1179 | case "weapon_machinegun": 1180 | return "SetBullets"; 1181 | case "weapon_grenadelauncher": 1182 | return "SetGrenades"; 1183 | case "weapon_rocketlauncher": 1184 | return "SetRockets"; 1185 | case "weapon_plasmagun": 1186 | return "SetCells"; 1187 | case "weapon_lightning": 1188 | return "SetLightning"; 1189 | case "weapon_railgun": 1190 | return "SetRails"; 1191 | case "weapon_bfg": 1192 | return "SetBfgRockets"; 1193 | case "weapon_shotgun": 1194 | return "SetShells"; 1195 | default: 1196 | return string.Empty; 1197 | } 1198 | } 1199 | 1200 | private void GiveAmmoOnOutput(Entity entity, Entity ammoEnt, string output, float delay) 1201 | { 1202 | if (ammoEnt["notcpm"] == "1") // TODO: Figure out how to handle gamemode specific entities more robustly 1203 | return; 1204 | 1205 | var ammoOutput = GetAmmoOutput(ammoEnt.ClassName); 1206 | if (string.IsNullOrEmpty(ammoOutput)) 1207 | return; 1208 | 1209 | var count = ConvertAmmoCount(ammoEnt.ClassName, ammoEnt["count"]); 1210 | if (float.Parse(count, CultureInfo.InvariantCulture) < 0) 1211 | ammoOutput = ammoOutput.Replace("Add", "Set", StringComparison.OrdinalIgnoreCase); // Applies infinite ammo when count is set to a negative value to mimic q3 behaviour 1212 | 1213 | var connection = new Entity.EntityConnection() 1214 | { 1215 | name = output, 1216 | target = "!player", 1217 | action = ammoOutput, 1218 | param = count, 1219 | delay = delay + 0.008f, //hack to make adding ammo happen after setting ammo 1220 | fireOnce = -1 1221 | }; 1222 | entity.connections.Add(connection); 1223 | } 1224 | 1225 | private string ConvertAmmoCount(string ammoName, string count) 1226 | { 1227 | if (!string.IsNullOrEmpty(count) && count != "0") 1228 | return count; 1229 | 1230 | switch (ammoName) 1231 | { 1232 | case "ammo_bfg": 1233 | return "15"; 1234 | case "ammo_bullets": // Machine gun 1235 | return "50"; 1236 | case "ammo_cells": // Plasma gun 1237 | return "30"; 1238 | case "ammo_grenades": 1239 | return "5"; 1240 | case "ammo_lightning": 1241 | return "60"; 1242 | case "ammo_rockets": 1243 | return "5"; 1244 | case "ammo_shells": // Shotgun 1245 | return "10"; 1246 | case "ammo_slugs": // Railgun 1247 | return "10"; 1248 | default: 1249 | return "0"; 1250 | } 1251 | } 1252 | 1253 | private string GetAmmoOutput(string ammoName) 1254 | { 1255 | switch (ammoName) 1256 | { 1257 | case "ammo_bfg": 1258 | return "AddBfgRockets"; 1259 | case "ammo_bullets": // Machine gun 1260 | return "AddBullets"; 1261 | case "ammo_cells": // Plasma gun 1262 | return "AddCells"; 1263 | case "ammo_grenades": 1264 | return "AddGrenades"; 1265 | case "ammo_lightning": 1266 | return "AddLightning"; 1267 | case "ammo_rockets": 1268 | return "AddRockets"; 1269 | case "ammo_shells": // Shotgun 1270 | return "AddShells"; 1271 | case "ammo_slugs": // Railgun 1272 | return "AddRails"; 1273 | default: 1274 | return string.Empty; 1275 | } 1276 | } 1277 | 1278 | private string ConvertPowerupCount(string count) 1279 | { 1280 | if (float.TryParse(count, out var duration) && duration != 0 && duration < 99) 1281 | return count; 1282 | else if (duration >= 99) 1283 | return "-1"; 1284 | else 1285 | return "30"; 1286 | } 1287 | 1288 | private void ConvertTeleportTrigger(Entity trigger, Entity targetTele) 1289 | { 1290 | var target = GetTargetEntities(targetTele).FirstOrDefault(); 1291 | if (target != null) 1292 | { 1293 | trigger.ClassName = "trigger_teleport"; 1294 | trigger["target"] = target.Name; 1295 | 1296 | if (target.ClassName != "info_teleport_destination") 1297 | ConvertTeleportDestination(target); 1298 | } 1299 | 1300 | if (targetTele["spawnflags"] == "1") 1301 | { 1302 | trigger["velocitymode"] = "3"; 1303 | trigger["setspeed"] = "0"; 1304 | } 1305 | else 1306 | { 1307 | trigger["velocitymode"] = "3"; 1308 | trigger["setspeed"] = "400"; 1309 | } 1310 | } 1311 | 1312 | private void ConvertTriggerPush(Entity trigger) 1313 | { 1314 | var target = GetTargetEntities(trigger).FirstOrDefault(); 1315 | if (target != null) 1316 | { 1317 | target.ClassName = "info_target"; 1318 | ConvertTriggerJumppad(trigger, target.Name); 1319 | } 1320 | } 1321 | 1322 | private static void ConvertTriggerJumppad(Entity trigger, string target) 1323 | { 1324 | // TODO: Convert other trigger_push_velocity flags 1325 | var spawnflags = (Q3TriggerPushVelocityFlags)trigger.Spawnflags; 1326 | if (spawnflags.HasFlag(Q3TriggerPushVelocityFlags.ADD_XY)) 1327 | trigger["KeepHorizontalSpeed"] = "1"; 1328 | if (spawnflags.HasFlag(Q3TriggerPushVelocityFlags.ADD_Z)) 1329 | trigger["KeepVerticalSpeed"] = "1"; 1330 | 1331 | trigger.ClassName = "trigger_jumppad"; 1332 | trigger["launchtarget"] = target; 1333 | trigger["launchsound"] = "world/jumppad.wav"; 1334 | trigger["spawnflags"] = "1"; 1335 | } 1336 | 1337 | private void ConvertTriggerTeleport(Entity trigger) 1338 | { 1339 | var spawnflags = (Q3TriggerTeleportFlags)trigger.Spawnflags; 1340 | 1341 | if (spawnflags.HasFlag(Q3TriggerTeleportFlags.KeepSpeed)) 1342 | { 1343 | trigger["velocitymode"] = "3"; 1344 | trigger["setspeed"] = "0"; 1345 | } 1346 | else 1347 | { 1348 | if (spawnflags.HasFlag(Q3TriggerTeleportFlags.Spectator)) 1349 | return; 1350 | 1351 | trigger["velocitymode"] = "3"; 1352 | trigger["setspeed"] = "400"; 1353 | } 1354 | trigger["spawnflags"] = "1"; 1355 | 1356 | var targets = GetTargetEntities(trigger); 1357 | foreach (var target in targets) 1358 | { 1359 | if (target.ClassName != "info_teleport_destination" && target.ClassName != "point_teleport") 1360 | ConvertTeleportDestination(target); 1361 | } 1362 | } 1363 | 1364 | private void ConvertEquipment(Entity entity) 1365 | { 1366 | if (entity.ClassName.StartsWith("weapon_", StringComparison.OrdinalIgnoreCase)) 1367 | ConvertWeapon(entity); 1368 | else if (entity.ClassName.StartsWith("ammo_", StringComparison.OrdinalIgnoreCase)) 1369 | ConvertAmmo(entity); 1370 | else if (entity.ClassName.StartsWith("item_", StringComparison.OrdinalIgnoreCase)) 1371 | ConvertItem(entity); 1372 | } 1373 | 1374 | private void ConvertWeapon(Entity weaponEnt) 1375 | { 1376 | var target = GetTargetEntities(weaponEnt).FirstOrDefault(); 1377 | if (target != null) 1378 | ConvertEntityTargetsRecursive(weaponEnt, weaponEnt, "OnPickup", 0, new HashSet()); 1379 | 1380 | weaponEnt["resettime"] = GetWeaponRespawnTime(weaponEnt); 1381 | weaponEnt["weaponname"] = GetMomentumWeaponName(weaponEnt.ClassName); 1382 | weaponEnt["pickupammo"] = ConvertWeaponAmmoCount(weaponEnt.ClassName, weaponEnt["count"]); 1383 | weaponEnt.ClassName = "momentum_weapon_spawner"; 1384 | } 1385 | 1386 | private string GetWeaponRespawnTime(Entity weaponEnt) 1387 | { 1388 | if (weaponEnt.TryGetValue("wait", out var wait) && wait != "0") 1389 | return wait; 1390 | 1391 | return "5"; 1392 | } 1393 | 1394 | private string GetMomentumWeaponName(string q3WeaponName) 1395 | { 1396 | switch (q3WeaponName) 1397 | { 1398 | case "weapon_machinegun": 1399 | return "weapon_momentum_df_machinegun"; 1400 | case "weapon_gauntlet": 1401 | return "weapon_momentum_df_knife"; 1402 | case "weapon_grenadelauncher": 1403 | return "weapon_momentum_df_grenadelauncher"; 1404 | case "weapon_rocketlauncher": 1405 | return "weapon_momentum_df_rocketlauncher"; 1406 | case "weapon_plasmagun": 1407 | return "weapon_momentum_df_plasmagun"; 1408 | case "weapon_lightning": 1409 | return "weapon_momentum_df_lightninggun"; 1410 | case "weapon_railgun": 1411 | return "weapon_momentum_df_railgun"; 1412 | case "weapon_bfg": 1413 | return "weapon_momentum_df_bfg"; 1414 | case "weapon_shotgun": 1415 | return "weapon_momentum_df_shotgun"; 1416 | case "item_haste": 1417 | return "momentum_powerup_haste"; 1418 | case "item_quad": 1419 | return "momentum_powerup_damage_boost"; 1420 | default: 1421 | return string.Empty; 1422 | } 1423 | } 1424 | 1425 | private void ConvertAmmo(Entity ammoEnt) 1426 | { 1427 | var target = GetTargetEntities(ammoEnt).FirstOrDefault(); 1428 | if (target != null) 1429 | ConvertEntityTargetsRecursive(ammoEnt, ammoEnt, "OnPickup", 0, new HashSet()); 1430 | 1431 | ammoEnt["resettime"] = ConvertAmmoRespawnTime(ammoEnt); 1432 | ammoEnt["ammoname"] = GetMomentumAmmoName(ammoEnt.ClassName); 1433 | ammoEnt["pickupammo"] = ConvertAmmoCount(ammoEnt.ClassName, ammoEnt["count"]); 1434 | ammoEnt.ClassName = "momentum_pickup_ammo"; 1435 | } 1436 | 1437 | private string ConvertAmmoRespawnTime(Entity ammoEnt) 1438 | { 1439 | if (ammoEnt.TryGetValue("wait", out var wait) && wait != "0") 1440 | return wait; 1441 | 1442 | return "40"; 1443 | } 1444 | 1445 | private string GetMomentumAmmoName(string q3AmmoName) 1446 | { 1447 | switch (q3AmmoName) 1448 | { 1449 | case "ammo_bfg": 1450 | return "bfg_rockets"; 1451 | case "ammo_bullets": // Machine gun 1452 | return "bullets"; 1453 | case "ammo_cells": // Plasma gun 1454 | return "cells"; 1455 | case "ammo_grenades": 1456 | return "grenades"; 1457 | case "ammo_lightning": 1458 | return "lightning"; 1459 | case "ammo_rockets": 1460 | return "rockets"; 1461 | case "ammo_shells": // Shotgun 1462 | return "shells"; 1463 | case "ammo_slugs": // Railgun 1464 | return "rails"; 1465 | default: 1466 | return string.Empty; 1467 | } 1468 | } 1469 | 1470 | private void ConvertItem(Entity itemEnt) 1471 | { 1472 | var target = GetTargetEntities(itemEnt).FirstOrDefault(); 1473 | if (target != null) 1474 | { 1475 | ConvertEntityTargetsRecursive(itemEnt, itemEnt, "OnPickup", 0, new HashSet()); 1476 | 1477 | if (itemEnt.ClassName.StartsWith("item_armor", StringComparison.OrdinalIgnoreCase) 1478 | || itemEnt.ClassName.StartsWith("item_health", StringComparison.OrdinalIgnoreCase)) 1479 | { 1480 | CreatePlaceHolderItem(itemEnt); // needs a placeholder pickup to trigger target entities since we dont have health/armor 1481 | return; 1482 | } 1483 | } 1484 | 1485 | itemEnt.ClassName = GetMomentumItemName(itemEnt.ClassName); 1486 | itemEnt["resettime"] = GetItemRespawnTime(itemEnt); 1487 | 1488 | if (itemEnt.ClassName == "momentum_powerup_haste") 1489 | itemEnt["hastetime"] = ConvertPowerupCount(itemEnt["count"]); 1490 | else if (itemEnt.ClassName == "momentum_powerup_damage_boost") 1491 | itemEnt["damageboosttime"] = ConvertPowerupCount(itemEnt["count"]); 1492 | } 1493 | 1494 | private void CreatePlaceHolderItem(Entity itemEnt) 1495 | { 1496 | itemEnt.ClassName = "momentum_pickup_ammo"; 1497 | itemEnt["ammoname"] = "bullets"; 1498 | itemEnt["pickupammo"] = "0"; 1499 | itemEnt["resettime"] = GetItemRespawnTime(itemEnt); 1500 | } 1501 | 1502 | private string GetItemRespawnTime(Entity itemEnt) 1503 | { 1504 | if (itemEnt.TryGetValue("wait", out var wait) && wait != "0") 1505 | return wait; 1506 | 1507 | return "120"; 1508 | } 1509 | 1510 | private string GetMomentumItemName(string q3ItemName) 1511 | { 1512 | switch (q3ItemName) 1513 | { 1514 | case "item_haste": 1515 | return "momentum_powerup_haste"; 1516 | case "item_quad": 1517 | return "momentum_powerup_damage_boost"; 1518 | default: 1519 | return string.Empty; 1520 | } 1521 | } 1522 | 1523 | private void ConvertAngles(Entity entity) 1524 | { 1525 | if (float.TryParse(entity["angle"], out var angle)) 1526 | { 1527 | entity.Angles = new Vector3(0f, angle, 0f); 1528 | entity.Remove("angle"); 1529 | } 1530 | } 1531 | 1532 | private void SetTeleportOrigin(Entity teleDest) 1533 | { 1534 | var origin = teleDest.Origin; 1535 | origin.Z -= 23; // Teleport destinations are 23 units too high once converted 1536 | teleDest.Origin = origin; 1537 | } 1538 | 1539 | private List GetTargetEntities(Entity sourceEntity) 1540 | { 1541 | if (sourceEntity.TryGetValue("target", out var target) && entityDict.ContainsKey(target)) 1542 | return entityDict[target]; 1543 | 1544 | return new List(); 1545 | } 1546 | } 1547 | } 1548 | -------------------------------------------------------------------------------- /BSPConvert.Lib/Source/ExternalLightmapLoader.cs: -------------------------------------------------------------------------------- 1 | using SixLabors.ImageSharp; 2 | using SixLabors.ImageSharp.PixelFormats; 3 | 4 | namespace BSPConvert.Lib 5 | { 6 | #if UNITY 7 | using Vector2 = UnityEngine.Vector2; 8 | #elif GODOT 9 | using Vector2 = Godot.Vector2; 10 | #elif NEOAXIS 11 | using Vector2 = NeoAxis.Vector2F; 12 | #else 13 | using Vector2 = System.Numerics.Vector2; 14 | #endif 15 | 16 | public class LightmapData 17 | { 18 | public Vector2 size; 19 | public byte[] data; 20 | } 21 | 22 | public class ExternalLightmapLoader 23 | { 24 | private Dictionary shaderDict; 25 | private string contentDir; 26 | 27 | private readonly HashSet validLightmapFormats = new HashSet() 28 | { 29 | ".tga", 30 | ".jpg", 31 | ".jpeg", 32 | ".png", 33 | ".bmp" 34 | }; 35 | 36 | public ExternalLightmapLoader(Dictionary shaderDict, string contentDir) 37 | { 38 | this.shaderDict = shaderDict; 39 | this.contentDir = contentDir; 40 | } 41 | 42 | public Dictionary LoadLightmaps() 43 | { 44 | var lightmapDict = new Dictionary(); 45 | var curOffset = 0; 46 | 47 | foreach (var shader in shaderDict.Values) 48 | { 49 | var stage = shader.stages.FirstOrDefault(x => x.bundles[0].tcGen == TexCoordGen.TCGEN_LIGHTMAP && x.bundles[0].images[0] != "$lightmap"); 50 | if (stage == null) 51 | continue; 52 | 53 | var lmImage = stage.bundles[0].images[0]; 54 | if (lightmapDict.ContainsKey(lmImage)) // Only add unique lightmaps 55 | continue; 56 | 57 | try 58 | { 59 | (var data, var size) = GetExternalLightmapData(stage); 60 | 61 | var lightmapData = new LightmapData(); 62 | lightmapData.data = data; 63 | lightmapData.size = size; 64 | 65 | curOffset += data.Length; 66 | 67 | lightmapDict.Add(lmImage, lightmapData); 68 | } 69 | catch (Exception ex) 70 | { 71 | Console.WriteLine(ex.Message); 72 | } 73 | } 74 | 75 | return lightmapDict; 76 | } 77 | 78 | private (byte[] data, Vector2 size) GetExternalLightmapData(ShaderStage stage) 79 | { 80 | var lmImage = stage.bundles[0].images[0]; 81 | var lmPath = Path.Combine(contentDir, Path.GetDirectoryName(lmImage)); 82 | 83 | // Look for any valid image files with matching name 84 | var lmFile = Directory.GetFiles(lmPath, Path.GetFileNameWithoutExtension(lmImage) + ".*").FirstOrDefault(); 85 | if (lmFile == null || !validLightmapFormats.Contains(Path.GetExtension(lmFile))) 86 | throw new Exception($"Lightmap image {lmImage} not found"); 87 | 88 | using var image = Image.Load(lmFile); 89 | 90 | var data = new byte[image.Height * image.Width * 3]; 91 | var curPixel = 0; 92 | 93 | image.ProcessPixelRows(accessor => 94 | { 95 | for (var y = 0; y < accessor.Height; y++) 96 | { 97 | var pixelRow = accessor.GetRowSpan(y); 98 | 99 | // pixelRow.Length has the same value as accessor.Width, 100 | // but using pixelRow.Length allows the JIT to optimize away bounds checks: 101 | for (var x = 0; x < pixelRow.Length; x++) 102 | { 103 | // Get a reference to the pixel at position x 104 | ref var pixel = ref pixelRow[x]; 105 | data[curPixel * 3 + 0] = pixel.R; 106 | data[curPixel * 3 + 1] = pixel.G; 107 | data[curPixel * 3 + 2] = pixel.B; 108 | 109 | curPixel++; 110 | } 111 | } 112 | }); 113 | 114 | return (data, new Vector2(image.Width, image.Height)); 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /BSPConvert.Lib/Source/HullConverter.cs: -------------------------------------------------------------------------------- 1 | using LibBSP; 2 | using System; 3 | using System.Collections.Generic; 4 | 5 | namespace BSPConvert.Lib 6 | { 7 | #if UNITY 8 | using Vector3 = UnityEngine.Vector3; 9 | #elif GODOT 10 | using Vector3 = Godot.Vector3; 11 | #elif NEOAXIS 12 | using Vector3 = NeoAxis.Vector3F; 13 | #else 14 | using Vector3 = System.Numerics.Vector3; 15 | #endif 16 | 17 | public class HullConverter 18 | { 19 | /// 20 | /// Converts the specified face vertices into a convex polygonal hull using the gift wrapping algorithm 21 | /// 22 | public static List ConvertConvexHull(Vertex[] faceVerts, Vector3 faceNormal) 23 | { 24 | // Treat face vertices as an arbitrary set of points on a plane and use the gift wrapping algorithm to generate a convex polygon 25 | var hullVerts = new List(); 26 | 27 | var pointOnHull = GetFurthestPointFromCenter(faceVerts); 28 | Vertex endPoint; 29 | do 30 | { 31 | hullVerts.Add(pointOnHull); 32 | endPoint = faceVerts[0]; 33 | for (var j = 1; j < faceVerts.Length; j++) 34 | { 35 | if (endPoint.position == pointOnHull.position || IsLeftOfLine(pointOnHull, endPoint, faceVerts[j], faceNormal)) 36 | endPoint = faceVerts[j]; 37 | } 38 | 39 | pointOnHull = endPoint; 40 | } 41 | while (endPoint.position != hullVerts[0].position && hullVerts.Count < faceVerts.Length); 42 | 43 | return hullVerts; 44 | } 45 | 46 | private static Vertex GetFurthestPointFromCenter(Vertex[] faceVerts) 47 | { 48 | var center = new Vector3(); 49 | foreach (var vert in faceVerts) 50 | center += vert.position; 51 | 52 | center /= faceVerts.Length; 53 | 54 | var furthestPoint = new Vertex(); 55 | var furthestDist = 0f; 56 | foreach (var vert in faceVerts) 57 | { 58 | var distance = Vector3.Distance(vert.position, center); 59 | if (distance > furthestDist) 60 | { 61 | furthestPoint = vert; 62 | furthestDist = distance; 63 | } 64 | } 65 | 66 | return furthestPoint; 67 | } 68 | 69 | private static bool IsLeftOfLine(Vertex pointOnHull, Vertex endPoint, Vertex vertex, Vector3 faceNormal) 70 | { 71 | var a = endPoint.position - pointOnHull.position; 72 | var b = vertex.position - pointOnHull.position; 73 | var cross = Vector3.Cross(a, b); 74 | 75 | // Use face normal to determine if the vertex is on the left side of the line 76 | // TODO: Handle collinear vertices (dot product should be 0) 77 | return Vector3.Dot(cross, faceNormal) > 0; 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /BSPConvert.Lib/Source/ILogger.cs: -------------------------------------------------------------------------------- 1 | namespace BSPConvert.Lib 2 | { 3 | public interface ILogger 4 | { 5 | void Log(string message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /BSPConvert.Lib/Source/MaterialConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using System.Globalization; 7 | 8 | namespace BSPConvert.Lib 9 | { 10 | public class MaterialConverter 11 | { 12 | private string pk3Dir; 13 | private Dictionary shaderDict; 14 | private Dictionary pk3ImageDict; 15 | private Dictionary q3ImageDict; 16 | 17 | private string[] skySuffixes = 18 | { 19 | "bk", 20 | "dn", 21 | "ft", 22 | "lf", 23 | "rt", 24 | "up" 25 | }; 26 | 27 | public MaterialConverter(string pk3Dir, Dictionary shaderDict) 28 | { 29 | this.pk3Dir = pk3Dir; 30 | this.shaderDict = shaderDict; 31 | pk3ImageDict = GetImageLookupDictionary(pk3Dir); 32 | q3ImageDict = GetImageLookupDictionary(ContentManager.GetQ3ContentDir()); 33 | } 34 | 35 | // Create a dictionary that maps relative texture paths to the full file paths in the content folder 36 | private Dictionary GetImageLookupDictionary(string contentDir) 37 | { 38 | var imageDict = new Dictionary(); 39 | 40 | foreach (var file in Directory.GetFiles(contentDir, "*.*", SearchOption.AllDirectories)) 41 | { 42 | var ext = Path.GetExtension(file); 43 | if (ext == ".tga" || ext == ".jpg") 44 | { 45 | var texturePath = file 46 | .Replace(contentDir + Path.DirectorySeparatorChar, "", StringComparison.OrdinalIgnoreCase) 47 | .Replace(Path.DirectorySeparatorChar, '/') 48 | .Replace(ext, "", StringComparison.OrdinalIgnoreCase) 49 | .ToLower(CultureInfo.InvariantCulture); 50 | 51 | if (!imageDict.ContainsKey(texturePath)) 52 | imageDict.Add(texturePath, file); 53 | } 54 | } 55 | 56 | return imageDict; 57 | } 58 | 59 | public void Convert(string texture) 60 | { 61 | if (shaderDict.TryGetValue(texture, out var shader)) 62 | CreateShaderVMT(texture, shader); 63 | else 64 | CreateDefaultVMT(texture); 65 | } 66 | 67 | private void CreateShaderVMT(string texture, Shader shader) 68 | { 69 | if (shader.fogParms != null) 70 | CreateFogVMT(texture, shader); 71 | else if (shader.skyParms != null && !string.IsNullOrEmpty(shader.skyParms.outerBox)) 72 | CreateSkyboxVMT(shader); 73 | else if (shader.GetImageStages().Any(x => !string.IsNullOrEmpty(x.bundles[0].images[0]))) 74 | CreateBaseShaderVMT(texture, shader); 75 | } 76 | 77 | private void CreateFogVMT(string texture, Shader shader) 78 | { 79 | var fogVmt = GenerateFogVMT(shader); 80 | WriteVMT(texture, fogVmt); 81 | } 82 | 83 | private string GenerateFogVMT(Shader shader) 84 | { 85 | var fogParms = shader.fogParms; 86 | var fogColor = $"{fogParms.color.X * 255} {fogParms.color.Y * 255} {fogParms.color.Z * 255}"; 87 | 88 | return $$""" 89 | Water 90 | { 91 | $forceexpensive 1 92 | 93 | %tooltexture "dev/water_normal" 94 | 95 | $refracttexture "_rt_WaterRefraction" 96 | $refractamount 0 97 | 98 | $scale "[1 1]" 99 | 100 | $bottommaterial "dev/dev_water3_beneath" 101 | 102 | $normalmap "dev/bump_normal" 103 | 104 | %compilewater 1 105 | $surfaceprop "water" 106 | 107 | $fogenable 1 108 | $fogcolor "{{fogColor}}" 109 | 110 | $fogstart 0 111 | $fogend {{fogParms.depthForOpaque}} 112 | 113 | $abovewater 1 114 | } 115 | """; 116 | } 117 | 118 | private void CreateSkyboxVMT(Shader shader) 119 | { 120 | foreach (var suffix in skySuffixes) 121 | { 122 | var skyTexture = $"{shader.skyParms.outerBox}_{suffix}"; 123 | if (!PrepareSkyboxImage(skyTexture)) 124 | continue; 125 | 126 | var baseTexture = $"skybox/{shader.skyParms.outerBox}{suffix}"; 127 | var skyboxVmt = GenerateSkyboxVMT(baseTexture); 128 | WriteVMT(baseTexture, skyboxVmt); 129 | } 130 | } 131 | 132 | // Try to find the sky image file and move it to skybox folder in order for Source engine to detect it properly 133 | private bool PrepareSkyboxImage(string skyTexture) 134 | { 135 | var skyboxDir = Path.Combine(pk3Dir, "skybox"); 136 | if (pk3ImageDict.TryGetValue(skyTexture, out var pk3Path)) 137 | { 138 | var newPath = pk3Path.Replace(pk3Dir, skyboxDir, StringComparison.OrdinalIgnoreCase); 139 | var destFile = newPath.Remove(newPath.LastIndexOf('_'), 1); // Remove underscore from skybox suffix 140 | 141 | FileUtil.MoveFile(pk3Path, destFile); 142 | 143 | return true; 144 | } 145 | else if (q3ImageDict.TryGetValue(skyTexture, out var q3Path)) 146 | { 147 | var q3ContentDir = ContentManager.GetQ3ContentDir(); 148 | var newPath = q3Path.Replace(q3ContentDir, skyboxDir, StringComparison.OrdinalIgnoreCase); 149 | var destFile = newPath.Remove(newPath.LastIndexOf('_'), 1); // Remove underscore from skybox suffix 150 | 151 | FileUtil.CopyFile(q3Path, destFile); 152 | 153 | return true; 154 | } 155 | 156 | return false; // No sky image found 157 | } 158 | 159 | private void CreateBaseShaderVMT(string texture, Shader shader) 160 | { 161 | var images = shader.GetImageStages().SelectMany(x => x.bundles[0].images); 162 | foreach (var image in images) 163 | { 164 | if (string.IsNullOrEmpty(image)) 165 | continue; 166 | 167 | var baseTexture = Path.ChangeExtension(image, null); 168 | TryCopyQ3Content(baseTexture); 169 | } 170 | 171 | var shaderVmt = GenerateVMT(shader); 172 | WriteVMT(texture, shaderVmt); 173 | } 174 | 175 | private string GenerateVMT(Shader shader) 176 | { 177 | if (shader.surfaceFlags.HasFlag(Q3SurfaceFlags.SURF_NOLIGHTMAP)) 178 | return GenerateUnlitVMT(shader); 179 | else 180 | return GenerateLitVMT(shader); 181 | } 182 | 183 | private void WriteVMT(string texture, string vmt) 184 | { 185 | var vmtPath = Path.Combine(pk3Dir, $"{texture}.vmt"); 186 | Directory.CreateDirectory(Path.GetDirectoryName(vmtPath)); 187 | 188 | File.WriteAllText(vmtPath, vmt); 189 | } 190 | 191 | // Copies content from the Q3Content folder if it exists 192 | private void TryCopyQ3Content(string texturePath) 193 | { 194 | if (q3ImageDict.TryGetValue(texturePath, out var q3TexturePath)) 195 | { 196 | var q3ContentDir = ContentManager.GetQ3ContentDir(); 197 | var newPath = q3TexturePath.Replace(q3ContentDir, pk3Dir, StringComparison.OrdinalIgnoreCase); 198 | FileUtil.CopyFile(q3TexturePath, newPath); 199 | } 200 | } 201 | 202 | private string GenerateUnlitVMT(Shader shader) 203 | { 204 | var sb = new StringBuilder(); 205 | sb.AppendLine("UnlitGeneric"); 206 | sb.AppendLine("{"); 207 | 208 | AppendShaderParameters(sb, shader); 209 | 210 | sb.AppendLine("}"); 211 | 212 | return sb.ToString(); 213 | } 214 | 215 | private string GenerateLitVMT(Shader shader) 216 | { 217 | var sb = new StringBuilder(); 218 | sb.AppendLine("LightmappedGeneric"); 219 | sb.AppendLine("{"); 220 | 221 | AppendShaderParameters(sb, shader); 222 | 223 | sb.AppendLine("}"); 224 | 225 | return sb.ToString(); 226 | } 227 | 228 | private void AppendShaderParameters(StringBuilder sb, Shader shader) 229 | { 230 | var stages = shader.GetImageStages(); 231 | var textureStage = stages.FirstOrDefault(x => x.bundles[0].tcGen != TexCoordGen.TCGEN_ENVIRONMENT_MAPPED && x.bundles[0].tcGen != TexCoordGen.TCGEN_LIGHTMAP); 232 | if (textureStage != null) 233 | { 234 | var texture = Path.ChangeExtension(textureStage.bundles[0].images[0], null); 235 | sb.AppendLine(CultureInfo.InvariantCulture, $"\t$basetexture \"{texture}\""); 236 | 237 | if (textureStage.rgbGen.HasFlag(ColorGen.CGEN_CONST)) 238 | { 239 | var color = textureStage.constantColor; 240 | var colorStr = $"{color[0]} {color[1]} {color[2]}"; 241 | sb.AppendLine("\t$color \"{" + colorStr + "}\""); 242 | } 243 | 244 | if (textureStage.alphaGen.HasFlag(AlphaGen.AGEN_CONST)) 245 | { 246 | var alpha = (float)textureStage.constantColor[3] / 255; 247 | sb.AppendLine(CultureInfo.InvariantCulture, $"\t$alpha {alpha}"); 248 | } 249 | } 250 | 251 | var envMapStage = stages.FirstOrDefault(x => x.bundles[0].tcGen == TexCoordGen.TCGEN_ENVIRONMENT_MAPPED); 252 | if (envMapStage != null) 253 | { 254 | sb.AppendLine($"\t$envmap \"engine/defaultcubemap\""); 255 | 256 | if (envMapStage.alphaGen == AlphaGen.AGEN_CONST) 257 | { 258 | var alpha = (float)envMapStage.constantColor[3] / 255; 259 | sb.AppendLine(CultureInfo.InvariantCulture, $"\t$envmaptint \"[{alpha} {alpha} {alpha}]\""); 260 | } 261 | else if (envMapStage.rgbGen == ColorGen.CGEN_WAVEFORM) 262 | { 263 | var alpha = envMapStage.rgbWave.base_; 264 | sb.AppendLine(CultureInfo.InvariantCulture, $"\t$envmaptint \"[{alpha} {alpha} {alpha}]\""); 265 | } 266 | } 267 | 268 | if (shader.cullType == CullType.TWO_SIDED) 269 | sb.AppendLine("\t$nocull 1"); 270 | 271 | var flags = (textureStage?.flags ?? 0) | (envMapStage?.flags ?? 0); 272 | if (flags.HasFlag(ShaderStageFlags.GLS_ATEST_GE_80)) 273 | { 274 | sb.AppendLine("\t$alphatest 1"); 275 | sb.AppendLine("\t$alphatestreference 0.5"); 276 | } 277 | 278 | var firstImageStage = stages.FirstOrDefault(); 279 | if (firstImageStage != null) 280 | { 281 | if (firstImageStage.flags.HasFlag(ShaderStageFlags.GLS_SRCBLEND_ONE | ShaderStageFlags.GLS_DSTBLEND_ONE)) // blendFunc add 282 | sb.AppendLine("\t$additive 1"); 283 | 284 | if (firstImageStage.flags.HasFlag(ShaderStageFlags.GLS_SRCBLEND_SRC_ALPHA | ShaderStageFlags.GLS_DSTBLEND_ONE_MINUS_SRC_ALPHA)) // blendFunc blend 285 | sb.AppendLine("\t$translucent 1"); 286 | } 287 | 288 | if (textureStage != null && textureStage.bundles[0].texMods.Any(y => y.type == TexMod.TMOD_SCROLL || y.type == TexMod.TMOD_ROTATE || 289 | y.type == TexMod.TMOD_STRETCH || y.type == TexMod.TMOD_SCALE)) 290 | ConvertTexMods(sb, textureStage); 291 | } 292 | 293 | private void ConvertTexMods(StringBuilder sb, ShaderStage texModStage) 294 | { 295 | AppendProxyVars(sb, texModStage); 296 | 297 | foreach (var texModInfo in texModStage.bundles[0].texMods) 298 | { 299 | if (texModInfo.type == TexMod.TMOD_ROTATE) 300 | ConvertTexModRotate(sb, texModInfo); 301 | else if (texModInfo.type == TexMod.TMOD_SCROLL) 302 | ConvertTexModScroll(sb, texModInfo); 303 | else if (texModInfo.type == TexMod.TMOD_STRETCH) 304 | ConvertTexModStretch(sb, texModInfo); 305 | 306 | if (texModInfo.type == TexMod.TMOD_ROTATE || texModInfo.type == TexMod.TMOD_SCROLL || 307 | texModInfo.type == TexMod.TMOD_STRETCH || texModInfo.type == TexMod.TMOD_SCALE) 308 | AppendTextureTransform(sb, texModStage); 309 | } 310 | sb.AppendLine("\t}"); 311 | } 312 | 313 | private static void AppendTextureTransform(StringBuilder sb, ShaderStage texModStage) 314 | { 315 | sb.AppendLine("\t\tTextureTransform"); 316 | sb.AppendLine("\t\t{"); 317 | 318 | foreach (var texModInfo in texModStage.bundles[0].texMods) 319 | { 320 | if (texModInfo.type == TexMod.TMOD_ROTATE) 321 | { 322 | sb.AppendLine("\t\t\trotateVar $angle"); 323 | sb.AppendLine("\t\t\tcenterVar $center"); 324 | } 325 | else if (texModInfo.type == TexMod.TMOD_SCROLL) 326 | sb.AppendLine("\t\t\ttranslateVar $translate"); 327 | else if (texModInfo.type == TexMod.TMOD_STRETCH || texModInfo.type == TexMod.TMOD_SCALE) 328 | sb.AppendLine("\t\t\tscaleVar $scale"); 329 | } 330 | 331 | sb.AppendLine("\t\t\tinitialValue 0"); 332 | sb.AppendLine("\t\t\tresultVar $basetexturetransform"); 333 | sb.AppendLine("\t\t}"); 334 | } 335 | 336 | private static void AppendProxyVars(StringBuilder sb, ShaderStage texModStage) 337 | { 338 | foreach (var texModInfo in texModStage.bundles[0].texMods) 339 | { 340 | if (texModInfo.type == TexMod.TMOD_ROTATE) 341 | { 342 | sb.AppendLine("\t$angle 0.0"); 343 | sb.AppendLine("\t$center \"[0.5 0.5]\""); 344 | } 345 | else if (texModInfo.type == TexMod.TMOD_SCROLL) 346 | sb.AppendLine("\t$translate \"[0.0 0.0]\""); 347 | else if (texModInfo.type == TexMod.TMOD_SCALE) 348 | sb.AppendLine(CultureInfo.InvariantCulture, $"\t$scale \"[{texModInfo.scale[0]} {texModInfo.scale[1]}]\""); 349 | else if (texModInfo.type == TexMod.TMOD_STRETCH) 350 | sb.AppendLine("\t$scale 1"); 351 | 352 | if (texModInfo.wave.func == GenFunc.GF_SQUARE) 353 | { 354 | sb.AppendLine(CultureInfo.InvariantCulture, $"\t$min {texModInfo.wave.base_}"); 355 | sb.AppendLine(CultureInfo.InvariantCulture, $"\t$max {texModInfo.wave.amplitude}"); 356 | sb.AppendLine(CultureInfo.InvariantCulture, $"\t$mid {(texModInfo.wave.amplitude + texModInfo.wave.base_) / 2}"); 357 | } 358 | } 359 | 360 | sb.AppendLine("\tProxies"); 361 | sb.AppendLine("\t{"); 362 | } 363 | 364 | // TODO: Convert other waveforms 365 | private static void ConvertTexModStretch(StringBuilder sb, TexModInfo texModInfo) 366 | { 367 | switch (texModInfo.wave.func) 368 | { 369 | case GenFunc.GF_SIN: 370 | ConvertSineWaveStretch(sb, texModInfo); 371 | break; 372 | case GenFunc.GF_SQUARE: 373 | ConvertSquareWaveStretch(sb, texModInfo); 374 | break; 375 | case GenFunc.GF_SAWTOOTH: 376 | case GenFunc.GF_INVERSE_SAWTOOTH: 377 | break; 378 | } 379 | } 380 | 381 | private static void ConvertSineWaveStretch(StringBuilder sb, TexModInfo texModInfo) 382 | { 383 | sb.AppendLine("\t\tSine"); 384 | sb.AppendLine("\t\t{"); 385 | sb.AppendLine(CultureInfo.InvariantCulture, $"\t\t\tsinemin {texModInfo.wave.base_}"); 386 | sb.AppendLine(CultureInfo.InvariantCulture, $"\t\t\tsinemax {texModInfo.wave.amplitude}"); 387 | sb.AppendLine(CultureInfo.InvariantCulture, $"\t\t\tsineperiod {1 / texModInfo.wave.frequency}"); 388 | sb.AppendLine("\t\t\tinitialValue 0.0"); 389 | sb.AppendLine("\t\t\tresultVar $scale"); 390 | sb.AppendLine("\t\t}"); 391 | } 392 | 393 | private static void ConvertSquareWaveStretch(StringBuilder sb, TexModInfo texModInfo) 394 | { 395 | sb.AppendLine("\t\tSine"); 396 | sb.AppendLine("\t\t{"); 397 | sb.AppendLine(CultureInfo.InvariantCulture, $"\t\t\tsinemin {texModInfo.wave.base_}"); 398 | sb.AppendLine(CultureInfo.InvariantCulture, $"\t\t\tsinemax {texModInfo.wave.amplitude}"); 399 | sb.AppendLine(CultureInfo.InvariantCulture, $"\t\t\tsineperiod {1 / texModInfo.wave.frequency}"); 400 | sb.AppendLine("\t\t\tinitialValue 0.0"); 401 | sb.AppendLine("\t\t\tresultVar $sineOutput"); 402 | sb.AppendLine("\t\t}"); 403 | 404 | sb.AppendLine("\t\tLessOrEqual"); 405 | sb.AppendLine("\t\t{"); 406 | sb.AppendLine($"\t\t\tlessEqualVar $min"); 407 | sb.AppendLine($"\t\t\tgreaterVar $max"); 408 | sb.AppendLine($"\t\t\tsrcVar1 $sineOutput"); 409 | sb.AppendLine($"\t\t\tsrcVar2 $mid"); 410 | sb.AppendLine($"\t\t\tresultVar $scale"); 411 | sb.AppendLine("\t\t}"); 412 | } 413 | 414 | private static void ConvertTexModScroll(StringBuilder sb, TexModInfo texModInfo) 415 | { 416 | sb.AppendLine("\t\tLinearRamp"); 417 | sb.AppendLine("\t\t{"); 418 | sb.AppendLine(CultureInfo.InvariantCulture, $"\t\t\trate {texModInfo.scroll[0]}"); 419 | sb.AppendLine("\t\t\tinitialValue 0.0"); 420 | sb.AppendLine("\t\t\tresultVar \"$translate[0]\""); 421 | sb.AppendLine("\t\t}"); 422 | 423 | sb.AppendLine("\t\tLinearRamp"); 424 | sb.AppendLine("\t\t{"); 425 | sb.AppendLine(CultureInfo.InvariantCulture, $"\t\t\trate {texModInfo.scroll[1]}"); 426 | sb.AppendLine("\t\t\tinitialValue 0.0"); 427 | sb.AppendLine("\t\t\tresultVar \"$translate[1]\""); 428 | sb.AppendLine("\t\t}"); 429 | } 430 | 431 | private static void ConvertTexModRotate(StringBuilder sb, TexModInfo texModInfo) 432 | { 433 | sb.AppendLine("\t\tLinearRamp"); 434 | sb.AppendLine("\t\t{"); 435 | sb.AppendLine(CultureInfo.InvariantCulture, $"\t\t\trate {texModInfo.rotateSpeed}"); 436 | sb.AppendLine("\t\t\tinitialValue 0.0"); 437 | sb.AppendLine("\t\t\tresultVar $angle"); 438 | sb.AppendLine("\t\t}"); 439 | } 440 | 441 | private string GenerateSkyboxVMT(string baseTexture) 442 | { 443 | var sb = new StringBuilder(); 444 | sb.AppendLine("UnlitGeneric"); 445 | sb.AppendLine("{"); 446 | 447 | sb.AppendLine(CultureInfo.InvariantCulture, $"\t\"$basetexture\" \"{baseTexture}\""); 448 | sb.AppendLine("\t\"$nofog\" 1"); 449 | sb.AppendLine("\t\"$ignorez\" 1"); 450 | 451 | sb.AppendLine("}"); 452 | 453 | return sb.ToString(); 454 | } 455 | 456 | private void CreateDefaultVMT(string texture) 457 | { 458 | TryCopyQ3Content(texture); 459 | 460 | var vmt = GenerateDefaultLitVMT(texture); 461 | WriteVMT(texture, vmt); 462 | } 463 | 464 | private string GenerateDefaultLitVMT(string texture) 465 | { 466 | return $$""" 467 | LightmappedGeneric 468 | { 469 | $basetexture "{{texture}}" 470 | } 471 | """; 472 | } 473 | } 474 | } 475 | -------------------------------------------------------------------------------- /BSPConvert.Lib/Source/Shader.cs: -------------------------------------------------------------------------------- 1 | using System.Numerics; 2 | 3 | namespace BSPConvert.Lib 4 | { 5 | [Flags] 6 | public enum ShaderStageFlags 7 | { 8 | GLS_SRCBLEND_ZERO = 0x00000001, 9 | GLS_SRCBLEND_ONE = 0x00000002, 10 | GLS_SRCBLEND_DST_COLOR = 0x00000003, 11 | GLS_SRCBLEND_ONE_MINUS_DST_COLOR = 0x00000004, 12 | GLS_SRCBLEND_SRC_ALPHA = 0x00000005, 13 | GLS_SRCBLEND_ONE_MINUS_SRC_ALPHA = 0x00000006, 14 | GLS_SRCBLEND_DST_ALPHA = 0x00000007, 15 | GLS_SRCBLEND_ONE_MINUS_DST_ALPHA = 0x00000008, 16 | GLS_SRCBLEND_ALPHA_SATURATE = 0x00000009, 17 | GLS_SRCBLEND_BITS = 0x0000000f, 18 | 19 | GLS_DSTBLEND_ZERO = 0x00000010, 20 | GLS_DSTBLEND_ONE = 0x00000020, 21 | GLS_DSTBLEND_SRC_COLOR = 0x00000030, 22 | GLS_DSTBLEND_ONE_MINUS_SRC_COLOR = 0x00000040, 23 | GLS_DSTBLEND_SRC_ALPHA = 0x00000050, 24 | GLS_DSTBLEND_ONE_MINUS_SRC_ALPHA = 0x00000060, 25 | GLS_DSTBLEND_DST_ALPHA = 0x00000070, 26 | GLS_DSTBLEND_ONE_MINUS_DST_ALPHA = 0x00000080, 27 | GLS_DSTBLEND_BITS = 0x000000f0, 28 | 29 | GLS_DEPTHMASK_TRUE = 0x00000100, 30 | 31 | GLS_POLYMODE_LINE = 0x00001000, 32 | 33 | GLS_DEPTHTEST_DISABLE = 0x00010000, 34 | GLS_DEPTHFUNC_EQUAL = 0x00020000, 35 | 36 | GLS_ATEST_GT_0 = 0x10000000, 37 | GLS_ATEST_LT_80 = 0x20000000, 38 | GLS_ATEST_GE_80 = 0x40000000, 39 | GLS_ATEST_BITS = 0x70000000, 40 | 41 | GLS_DEFAULT = GLS_DEPTHMASK_TRUE 42 | } 43 | 44 | public enum AlphaGen 45 | { 46 | AGEN_IDENTITY, 47 | AGEN_SKIP, 48 | AGEN_ENTITY, 49 | AGEN_ONE_MINUS_ENTITY, 50 | AGEN_VERTEX, 51 | AGEN_ONE_MINUS_VERTEX, 52 | AGEN_LIGHTING_SPECULAR, 53 | AGEN_WAVEFORM, 54 | AGEN_PORTAL, 55 | AGEN_CONST 56 | } 57 | 58 | public enum ColorGen 59 | { 60 | CGEN_BAD, 61 | CGEN_IDENTITY_LIGHTING, // tr.identityLight 62 | CGEN_IDENTITY, // always (1,1,1,1) 63 | CGEN_ENTITY, // grabbed from entity's modulate field 64 | CGEN_ONE_MINUS_ENTITY, // grabbed from 1 - entity.modulate 65 | CGEN_EXACT_VERTEX, // tess.vertexColors 66 | CGEN_VERTEX, // tess.vertexColors * tr.identityLight 67 | CGEN_ONE_MINUS_VERTEX, 68 | CGEN_WAVEFORM, // programmatically generated 69 | CGEN_LIGHTING_DIFFUSE, 70 | CGEN_FOG, // standard fog 71 | CGEN_CONST // fixed color 72 | } 73 | 74 | public enum TexCoordGen 75 | { 76 | TCGEN_BAD, 77 | TCGEN_IDENTITY, // clear to 0,0 78 | TCGEN_LIGHTMAP, 79 | TCGEN_TEXTURE, 80 | TCGEN_ENVIRONMENT_MAPPED, 81 | TCGEN_FOG, 82 | TCGEN_VECTOR // S and T from world coordinates 83 | } 84 | 85 | public enum TexMod 86 | { 87 | TMOD_NONE, 88 | TMOD_TRANSFORM, 89 | TMOD_TURBULENT, 90 | TMOD_SCROLL, 91 | TMOD_SCALE, 92 | TMOD_STRETCH, 93 | TMOD_ROTATE, 94 | TMOD_ENTITY_TRANSLATE 95 | } 96 | 97 | public enum CullType 98 | { 99 | FRONT_SIDED, 100 | BACK_SIDED, 101 | TWO_SIDED 102 | } 103 | 104 | public enum GenFunc 105 | { 106 | GF_NONE, 107 | GF_SIN, 108 | GF_SQUARE, 109 | GF_TRIANGLE, 110 | GF_SAWTOOTH, 111 | GF_INVERSE_SAWTOOTH, 112 | GF_NOISE 113 | } 114 | 115 | public class WaveForm 116 | { 117 | public GenFunc func; 118 | 119 | public float base_; 120 | public float amplitude; 121 | public float phase; 122 | public float frequency; 123 | } 124 | 125 | public class TexModInfo 126 | { 127 | public TexMod type; 128 | public WaveForm wave = new WaveForm(); 129 | public float[][] matrix = new float[2][]; 130 | public float[] translate = new float[2]; 131 | public float[] scale = new float[2]; 132 | public float[] scroll = new float[2]; 133 | public float rotateSpeed; 134 | 135 | public TexModInfo() 136 | { 137 | for (var i = 0; i < 2; i++) 138 | matrix[i] = new float[2]; 139 | } 140 | } 141 | 142 | public class ShaderStage 143 | { 144 | public const int NUM_TEXTURE_BUNDLES = 2; 145 | 146 | public TextureBundle[] bundles = new TextureBundle[NUM_TEXTURE_BUNDLES]; // Path to image file 147 | public ShaderStageFlags flags; 148 | 149 | public WaveForm rgbWave = new WaveForm(); 150 | public ColorGen rgbGen; 151 | 152 | public WaveForm alphaWave = new WaveForm(); 153 | public AlphaGen alphaGen; 154 | 155 | public byte[] constantColor = new byte[4]; 156 | 157 | public ShaderStage() 158 | { 159 | for (var i = 0; i < NUM_TEXTURE_BUNDLES; i++) 160 | bundles[i] = new TextureBundle(); 161 | } 162 | } 163 | 164 | public class TextureBundle 165 | { 166 | public const int MAX_IMAGE_ANIMATIONS = 8; 167 | 168 | public string[] images = new string[MAX_IMAGE_ANIMATIONS]; // Path to image files 169 | public int numImageAnimations; 170 | public float imageAnimationSpeed; 171 | 172 | public TexCoordGen tcGen; 173 | public Vector3[] tcGenVectors = new Vector3[2]; 174 | public List texMods = new List(); 175 | } 176 | 177 | public class Shader 178 | { 179 | public class SkyParms 180 | { 181 | public string outerBox; 182 | public string cloudHeight; 183 | public string innerBox; 184 | } 185 | 186 | public class FogParms 187 | { 188 | public Vector3 color; 189 | public float depthForOpaque; 190 | } 191 | 192 | public SkyParms skyParms; 193 | public FogParms fogParms; 194 | public Q3SurfaceFlags surfaceFlags; 195 | public Q3ContentsFlags contents; 196 | public CullType cullType; 197 | public ShaderStage[] stages; 198 | 199 | /// 200 | /// Returns all stages with images (ignores $lightmap and $whiteimage) 201 | /// 202 | public IEnumerable GetImageStages() 203 | { 204 | return stages.Where(s => !string.IsNullOrEmpty(s.bundles[0].images[0]) && 205 | !s.bundles[0].images[0].StartsWith('$')); 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /BSPConvert.Lib/Source/ShaderLoader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace BSPConvert.Lib 8 | { 9 | public class ShaderLoader 10 | { 11 | private IEnumerable shaderFiles; 12 | 13 | public ShaderLoader(IEnumerable shaderFiles) 14 | { 15 | this.shaderFiles = shaderFiles; 16 | } 17 | 18 | public Dictionary LoadShaders() 19 | { 20 | var shaderDict = new Dictionary(); 21 | 22 | foreach (var file in shaderFiles) 23 | { 24 | var shaderParser = new ShaderParser(file); 25 | var newShaderDict = shaderParser.ParseShaders(); 26 | foreach (var kv in newShaderDict) 27 | { 28 | if (!shaderDict.ContainsKey(kv.Key)) 29 | shaderDict.Add(kv.Key, kv.Value); 30 | } 31 | } 32 | 33 | return shaderDict; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /BSPConvert.Lib/Source/ShaderParser.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Linq; 5 | using System.Numerics; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | using System.Globalization; 9 | 10 | namespace BSPConvert.Lib 11 | { 12 | public class ShaderParser 13 | { 14 | private string shaderFile; 15 | 16 | public ShaderParser(string shaderFile) 17 | { 18 | this.shaderFile = shaderFile; 19 | } 20 | 21 | public Dictionary ParseShaders() 22 | { 23 | var shaderDict = new Dictionary(); 24 | 25 | var fileEnumerator = File.ReadLines(shaderFile).GetEnumerator(); 26 | string line; 27 | while ((line = GetNextValidLine(fileEnumerator)) != null) 28 | { 29 | var textureName = line; 30 | if (TryParseShader(fileEnumerator, out var shader)) 31 | shaderDict[textureName] = shader; 32 | } 33 | 34 | return shaderDict; 35 | } 36 | 37 | private bool TryParseShader(IEnumerator fileEnumerator, out Shader shader) 38 | { 39 | shader = new Shader(); 40 | var stages = new List(); 41 | 42 | var line = GetNextValidLine(fileEnumerator); 43 | if (string.IsNullOrEmpty(line) || !line.StartsWith('{')) 44 | { 45 | Debug.WriteLine("Warning: Expecting '{', found '" + line + "' instead in shader file: " + shaderFile); 46 | return false; 47 | } 48 | 49 | while ((line = GetNextValidLine(fileEnumerator)) != null) 50 | { 51 | if (line == "}") // End of shader definition 52 | break; 53 | else if (line.StartsWith('{')) 54 | { 55 | stages.Add(ParseStage(fileEnumerator)); 56 | continue; 57 | } 58 | 59 | // Parse shader parameter 60 | var split = line.Split(); 61 | switch (split[0].ToUpperInvariant()) 62 | { 63 | case "q3map_sun": 64 | break; 65 | case "deformvertexes": 66 | break; 67 | case "tesssize": 68 | break; 69 | case "clamptime": 70 | break; 71 | case "q3map": 72 | break; 73 | case "surfaceparm": 74 | { 75 | var infoParm = ParseSurfaceParm(split[1]); 76 | shader.surfaceFlags |= infoParm.surfaceFlags; 77 | shader.contents |= infoParm.contents; 78 | break; 79 | } 80 | case "nomipmaps": 81 | break; 82 | case "nopicmip": 83 | break; 84 | case "polygonoffset": 85 | break; 86 | case "entitymergable": 87 | break; 88 | case "fogparms": 89 | shader.fogParms = ParseFogParms(split); 90 | break; 91 | case "portal": 92 | break; 93 | case "skyparms": 94 | shader.skyParms = ParseSkyParms(split); 95 | break; 96 | case "light": 97 | break; 98 | case "cull": 99 | shader.cullType = ParseCullType(split[1]); 100 | break; 101 | case "sort": 102 | break; 103 | default: 104 | if (!split[0].StartsWith("qer", StringComparison.OrdinalIgnoreCase)) 105 | Debug.WriteLine("Warning: Unknown shader parameter '" + split[0] + "' in shader file: " + shaderFile); 106 | 107 | break; 108 | } 109 | } 110 | 111 | shader.stages = stages.ToArray(); 112 | 113 | return true; 114 | } 115 | 116 | private ShaderStage ParseStage(IEnumerator fileEnumerator) 117 | { 118 | var stage = new ShaderStage(); 119 | 120 | string line; 121 | while ((line = GetNextValidLine(fileEnumerator)) != null) 122 | { 123 | if (line == "}") // End of shader pass definition 124 | break; 125 | 126 | var split = line.Split((char[])null, StringSplitOptions.RemoveEmptyEntries); 127 | switch (split[0].ToUpperInvariant()) 128 | { 129 | case "map": 130 | stage.bundles[0].images[0] = split[1]; 131 | break; 132 | case "clampmap": 133 | break; 134 | case "animmap": 135 | stage.bundles[0] = ParseAnimMap(split); 136 | break; 137 | case "videomap": 138 | break; 139 | case "alphafunc": 140 | stage.flags |= ParseAlphaFunc(split[1]); 141 | break; 142 | case "depthfunc": 143 | break; 144 | case "detail": 145 | break; 146 | case "blendfunc": 147 | stage.flags |= ParseBlendFunc(split); 148 | break; 149 | case "rgbgen": 150 | ParseRGBGen(stage, split); 151 | break; 152 | case "alphagen": 153 | ParseAlphaGen(stage, split); 154 | break; 155 | case "texgen": 156 | case "tcgen": 157 | stage.bundles[0].tcGen = ParseTCGen(split[1]); 158 | break; 159 | case "tcmod": 160 | stage.bundles[0].texMods.Add(ParseTCModInfo(split)); 161 | break; 162 | case "depthwrite": 163 | break; 164 | default: 165 | Debug.WriteLine($"Warning: Unknown shader parameter: {split[0]}"); 166 | break; 167 | } 168 | } 169 | 170 | return stage; 171 | } 172 | 173 | private TextureBundle ParseAnimMap(string[] split) 174 | { 175 | var bundle = new TextureBundle(); 176 | 177 | if (split.Length < 3) 178 | { 179 | Debug.WriteLine("Warning: Missing parameters in shader: " + shaderFile); 180 | return bundle; 181 | } 182 | 183 | bundle.imageAnimationSpeed = float.Parse(split[1], CultureInfo.InvariantCulture); 184 | 185 | for (var i = 2; i < split.Length; i++) 186 | { 187 | if (bundle.numImageAnimations >= TextureBundle.MAX_IMAGE_ANIMATIONS) 188 | break; 189 | 190 | bundle.images[bundle.numImageAnimations] = split[i]; 191 | bundle.numImageAnimations++; 192 | } 193 | 194 | return bundle; 195 | } 196 | 197 | private InfoParm ParseSurfaceParm(string surfaceParm) 198 | { 199 | foreach (var infoParm in Constants.infoParms) 200 | { 201 | if (surfaceParm == infoParm.name) 202 | return infoParm; 203 | } 204 | 205 | return default; 206 | } 207 | 208 | private Shader.FogParms ParseFogParms(string[] split) 209 | { 210 | var fogParms = new Shader.FogParms(); 211 | 212 | if (split[1] != "(") 213 | { 214 | Debug.WriteLine("Warning: Missing parenthesis in shader: " + shaderFile); 215 | return null; 216 | } 217 | 218 | if (!float.TryParse(split[2], out fogParms.color.X) || 219 | !float.TryParse(split[3], out fogParms.color.Y) || 220 | !float.TryParse(split[4], out fogParms.color.Z)) 221 | { 222 | Debug.WriteLine("Warning: Missing vector3 element in shader: " + shaderFile); 223 | return null; 224 | } 225 | 226 | if (split[5] != ")") 227 | { 228 | Debug.WriteLine("Warning: Missing parenthesis in shader: " + shaderFile); 229 | return null; 230 | } 231 | 232 | if (!float.TryParse(split[6], out fogParms.depthForOpaque)) 233 | { 234 | Debug.WriteLine("Warning: Missing parm for 'fogParms' keyword in shader: " + shaderFile); 235 | return null; 236 | } 237 | 238 | return fogParms; 239 | } 240 | 241 | private Shader.SkyParms ParseSkyParms(string[] split) 242 | { 243 | return new Shader.SkyParms() 244 | { 245 | outerBox = split[1], 246 | cloudHeight = split[2], 247 | innerBox = split[3] 248 | }; 249 | } 250 | 251 | private CullType ParseCullType(string cullType) 252 | { 253 | switch (cullType.ToUpperInvariant()) 254 | { 255 | case "none": 256 | case "twosided": 257 | case "disable": 258 | return CullType.TWO_SIDED; 259 | case "back": 260 | case "backside": 261 | case "backsided": 262 | return CullType.BACK_SIDED; 263 | default: 264 | return CullType.FRONT_SIDED; 265 | } 266 | } 267 | 268 | private ShaderStageFlags ParseAlphaFunc(string func) 269 | { 270 | switch (func.ToUpperInvariant()) 271 | { 272 | case "gt0": 273 | return ShaderStageFlags.GLS_ATEST_GT_0; 274 | case "lt128": 275 | return ShaderStageFlags.GLS_ATEST_LT_80; 276 | case "ge128": 277 | return ShaderStageFlags.GLS_ATEST_GE_80; 278 | } 279 | 280 | // Invalid alphaFunc 281 | return 0; 282 | } 283 | 284 | private ShaderStageFlags ParseBlendFunc(string[] split) 285 | { 286 | switch (split[1].ToUpperInvariant()) 287 | { 288 | case "add": 289 | return ShaderStageFlags.GLS_SRCBLEND_ONE | ShaderStageFlags.GLS_DSTBLEND_ONE; 290 | case "filter": 291 | return ShaderStageFlags.GLS_SRCBLEND_DST_COLOR | ShaderStageFlags.GLS_DSTBLEND_ZERO; 292 | case "blend": 293 | return ShaderStageFlags.GLS_SRCBLEND_SRC_ALPHA | ShaderStageFlags.GLS_DSTBLEND_ONE_MINUS_SRC_ALPHA; 294 | default: 295 | if (split.Length == 3) 296 | return ParseSrcBlendMode(split[1]) | ParseDestBlendMode(split[2]); 297 | else 298 | return 0; 299 | } 300 | } 301 | 302 | private ShaderStageFlags ParseSrcBlendMode(string src) 303 | { 304 | switch (src.ToUpperInvariant()) 305 | { 306 | case "GL_ONE": 307 | return ShaderStageFlags.GLS_SRCBLEND_ONE; 308 | case "GL_ZERO": 309 | return ShaderStageFlags.GLS_SRCBLEND_ZERO; 310 | case "GL_DST_COLOR": 311 | return ShaderStageFlags.GLS_SRCBLEND_DST_COLOR; 312 | case "GL_ONE_MINUS_DST_COLOR": 313 | return ShaderStageFlags.GLS_SRCBLEND_ONE_MINUS_DST_COLOR; 314 | case "GL_SRC_ALPHA": 315 | return ShaderStageFlags.GLS_SRCBLEND_SRC_ALPHA; 316 | case "GL_ONE_MINUS_SRC_ALPHA": 317 | return ShaderStageFlags.GLS_SRCBLEND_ONE_MINUS_SRC_ALPHA; 318 | case "GL_DST_ALPHA": 319 | return ShaderStageFlags.GLS_SRCBLEND_DST_ALPHA; 320 | case "GL_ONE_MINUS_DST_ALPHA": 321 | return ShaderStageFlags.GLS_SRCBLEND_ONE_MINUS_DST_ALPHA; 322 | case "GL_SRC_ALPHA_SATURATE": 323 | return ShaderStageFlags.GLS_SRCBLEND_ALPHA_SATURATE; 324 | default: 325 | Debug.WriteLine($"Warning: Unknown blend mode: {src}"); 326 | return ShaderStageFlags.GLS_SRCBLEND_ONE; 327 | } 328 | } 329 | 330 | private ShaderStageFlags ParseDestBlendMode(string dest) 331 | { 332 | switch (dest.ToUpperInvariant()) 333 | { 334 | case "GL_ONE": 335 | return ShaderStageFlags.GLS_DSTBLEND_ONE; 336 | case "GL_ZERO": 337 | return ShaderStageFlags.GLS_DSTBLEND_ZERO; 338 | case "GL_SRC_ALPHA": 339 | return ShaderStageFlags.GLS_DSTBLEND_SRC_ALPHA; 340 | case "GL_ONE_MINUS_SRC_ALPHA": 341 | return ShaderStageFlags.GLS_DSTBLEND_ONE_MINUS_SRC_ALPHA; 342 | case "GL_DST_ALPHA": 343 | return ShaderStageFlags.GLS_DSTBLEND_DST_ALPHA; 344 | case "GL_ONE_MINUS_DST_ALPHA": 345 | return ShaderStageFlags.GLS_DSTBLEND_ONE_MINUS_DST_ALPHA; 346 | case "GL_SRC_COLOR": 347 | return ShaderStageFlags.GLS_DSTBLEND_SRC_COLOR; 348 | case "GL_ONE_MINUS_SRC_COLOR": 349 | return ShaderStageFlags.GLS_DSTBLEND_ONE_MINUS_SRC_COLOR; 350 | default: 351 | Debug.WriteLine($"Warning: Unknown blend mode: {dest}"); 352 | return ShaderStageFlags.GLS_DSTBLEND_ONE; 353 | } 354 | } 355 | 356 | private void ParseRGBGen(ShaderStage stage, string[] split) 357 | { 358 | switch (split[1].ToUpperInvariant()) 359 | { 360 | case "wave": 361 | { 362 | stage.rgbGen = ColorGen.CGEN_WAVEFORM; 363 | 364 | stage.rgbWave = ParseWaveform(new ArraySegment(split, 2, 5)); 365 | break; 366 | } 367 | case "const": 368 | { 369 | stage.rgbGen = ColorGen.CGEN_CONST; 370 | 371 | var color = ParseVector(new ArraySegment(split, 2, 5)); 372 | stage.constantColor[0] = (byte)(255 * color[0]); 373 | stage.constantColor[1] = (byte)(255 * color[1]); 374 | stage.constantColor[2] = (byte)(255 * color[2]); 375 | break; 376 | } 377 | case "identity": 378 | stage.rgbGen = ColorGen.CGEN_IDENTITY; 379 | break; 380 | case "identitylighting": 381 | stage.rgbGen = ColorGen.CGEN_IDENTITY_LIGHTING; 382 | break; 383 | case "entity": 384 | stage.rgbGen = ColorGen.CGEN_ENTITY; 385 | break; 386 | case "oneminusentity": 387 | stage.rgbGen = ColorGen.CGEN_ONE_MINUS_ENTITY; 388 | break; 389 | case "vertex": 390 | // TODO: Set alphagen 391 | stage.rgbGen = ColorGen.CGEN_VERTEX; 392 | break; 393 | case "exactvertex": 394 | stage.rgbGen = ColorGen.CGEN_EXACT_VERTEX; 395 | break; 396 | case "lightingdiffuse": 397 | stage.rgbGen = ColorGen.CGEN_LIGHTING_DIFFUSE; 398 | break; 399 | case "oneminusvertex": 400 | stage.rgbGen = ColorGen.CGEN_ONE_MINUS_VERTEX; 401 | break; 402 | default: 403 | Debug.WriteLine("Warning: Unknown rgbGen param in shader: " + shaderFile); 404 | stage.rgbGen = ColorGen.CGEN_BAD; 405 | break; 406 | } 407 | } 408 | 409 | private Vector3 ParseVector(ArraySegment split) 410 | { 411 | if (split[0] != "(" || split[4] != ")") 412 | { 413 | Debug.WriteLine("Warning: Missing parenthesis in shader: " + shaderFile); 414 | return Vector3.Zero; 415 | } 416 | 417 | return new Vector3( 418 | float.Parse(split[1], CultureInfo.InvariantCulture), 419 | float.Parse(split[2], CultureInfo.InvariantCulture), 420 | float.Parse(split[3], CultureInfo.InvariantCulture)); 421 | } 422 | 423 | private WaveForm ParseWaveform(ArraySegment split) 424 | { 425 | var wave = new WaveForm(); 426 | 427 | wave.func = NameToGenFunc(split[0]); 428 | float.TryParse(split[1], out wave.base_); 429 | float.TryParse(split[2], out wave.amplitude); 430 | float.TryParse(split[3], out wave.phase); 431 | float.TryParse(split[4], out wave.frequency); 432 | 433 | return wave; 434 | } 435 | 436 | private void ParseAlphaGen(ShaderStage stage, string[] split) 437 | { 438 | switch (split[1].ToUpperInvariant()) 439 | { 440 | case "wave": 441 | { 442 | stage.alphaGen = AlphaGen.AGEN_WAVEFORM; 443 | 444 | stage.alphaWave = ParseWaveform(new ArraySegment(split, 2, 5)); 445 | break; 446 | } 447 | case "const": 448 | { 449 | stage.alphaGen = AlphaGen.AGEN_CONST; 450 | 451 | if (float.TryParse(split[2], out var alpha)) 452 | stage.constantColor[3] = (byte)(alpha * 255); 453 | break; 454 | } 455 | case "identity": 456 | stage.alphaGen = AlphaGen.AGEN_IDENTITY; 457 | break; 458 | case "entity": 459 | stage.alphaGen = AlphaGen.AGEN_ENTITY; 460 | break; 461 | case "oneminusentity": 462 | stage.alphaGen = AlphaGen.AGEN_ONE_MINUS_ENTITY; 463 | break; 464 | case "vertex": 465 | stage.alphaGen = AlphaGen.AGEN_VERTEX; 466 | break; 467 | case "lightingspecular": 468 | stage.alphaGen = AlphaGen.AGEN_LIGHTING_SPECULAR; 469 | break; 470 | case "oneminusvertex": 471 | stage.alphaGen = AlphaGen.AGEN_ONE_MINUS_VERTEX; 472 | break; 473 | case "portal": 474 | // TODO: Parse portal 475 | stage.alphaGen = AlphaGen.AGEN_PORTAL; 476 | break; 477 | default: 478 | Debug.WriteLine("Warning: Unknown alphaGen param in shader: " + shaderFile); 479 | stage.alphaGen = AlphaGen.AGEN_IDENTITY; 480 | break; 481 | } 482 | } 483 | 484 | private TexCoordGen ParseTCGen(string tcGen) 485 | { 486 | switch (tcGen.ToUpperInvariant()) 487 | { 488 | case "environment": 489 | return TexCoordGen.TCGEN_ENVIRONMENT_MAPPED; 490 | case "lightmap": 491 | return TexCoordGen.TCGEN_LIGHTMAP; 492 | case "texture": 493 | case "base": 494 | return TexCoordGen.TCGEN_TEXTURE; 495 | case "vector": 496 | // TODO: Handle vector parsing 497 | break; 498 | default: 499 | Debug.WriteLine("Warning: Unknown texgen param in shader: " + shaderFile); 500 | break; 501 | } 502 | 503 | return TexCoordGen.TCGEN_TEXTURE; 504 | } 505 | 506 | private TexModInfo ParseTCModInfo(string[] tcMod) 507 | { 508 | switch (tcMod[1].ToUpperInvariant()) 509 | { 510 | case "turb": 511 | return ParseTCModInfoTurb(tcMod); 512 | case "scale": 513 | return ParseTCModInfoScale(tcMod); 514 | case "scroll": 515 | return ParseTCModInfoScroll(tcMod); 516 | case "stretch": 517 | return ParseTCModInfoStretch(tcMod); 518 | case "transform": 519 | return ParseTCModInfoTransform(tcMod); 520 | case "rotate": 521 | return ParseTCModInfoRotate(tcMod); 522 | case "entitytranslate": 523 | return ParseTCModInfoTranslate(tcMod); 524 | default: 525 | Debug.WriteLine("Warning: Unknown texmod param in shader: " + shaderFile); 526 | return new TexModInfo(); 527 | } 528 | } 529 | 530 | private TexModInfo ParseTCModInfoTurb(string[] tcMod) 531 | { 532 | var texModInfo = new TexModInfo(); 533 | if (tcMod.Length < 6) 534 | { 535 | Debug.WriteLine("Warning: missing tcMod turb in shader: " + shaderFile); 536 | return texModInfo; 537 | } 538 | 539 | float.TryParse(tcMod[2], out texModInfo.wave.base_); 540 | float.TryParse(tcMod[3], out texModInfo.wave.amplitude); 541 | float.TryParse(tcMod[4], out texModInfo.wave.phase); 542 | float.TryParse(tcMod[5], out texModInfo.wave.frequency); 543 | 544 | texModInfo.type = TexMod.TMOD_TURBULENT; 545 | 546 | return texModInfo; 547 | } 548 | 549 | private TexModInfo ParseTCModInfoScale(string[] tcMod) 550 | { 551 | var texModInfo = new TexModInfo(); 552 | if (tcMod.Length < 4) 553 | { 554 | Debug.WriteLine("Warning: missing scale parms in shader: " + shaderFile); 555 | return texModInfo; 556 | } 557 | 558 | float.TryParse(tcMod[2], out texModInfo.scale[0]); 559 | float.TryParse(tcMod[3], out texModInfo.scale[1]); 560 | 561 | texModInfo.type = TexMod.TMOD_SCALE; 562 | 563 | return texModInfo; 564 | } 565 | 566 | private TexModInfo ParseTCModInfoScroll(string[] tcMod) 567 | { 568 | var texModInfo = new TexModInfo(); 569 | if (tcMod.Length < 4) 570 | { 571 | Debug.WriteLine("Warning: missing scale scroll parms in shader: " + shaderFile); 572 | return texModInfo; 573 | } 574 | 575 | float.TryParse(tcMod[2], out texModInfo.scroll[0]); 576 | float.TryParse(tcMod[3], out texModInfo.scroll[1]); 577 | 578 | texModInfo.type = TexMod.TMOD_SCROLL; 579 | 580 | return texModInfo; 581 | } 582 | 583 | private TexModInfo ParseTCModInfoStretch(string[] tcMod) 584 | { 585 | var texModInfo = new TexModInfo(); 586 | if (tcMod.Length < 7) 587 | { 588 | Debug.WriteLine("Warning: missing stretch parms in shader: " + shaderFile); 589 | return texModInfo; 590 | } 591 | 592 | texModInfo.wave.func = NameToGenFunc(tcMod[2]); 593 | float.TryParse(tcMod[3], out texModInfo.wave.base_); 594 | float.TryParse(tcMod[4], out texModInfo.wave.amplitude); 595 | float.TryParse(tcMod[5], out texModInfo.wave.phase); 596 | float.TryParse(tcMod[6], out texModInfo.wave.frequency); 597 | 598 | texModInfo.type = TexMod.TMOD_STRETCH; 599 | 600 | return texModInfo; 601 | } 602 | 603 | private TexModInfo ParseTCModInfoTransform(string[] tcMod) 604 | { 605 | var texModInfo = new TexModInfo(); 606 | if (tcMod.Length < 8) 607 | { 608 | Debug.WriteLine("Warning: missing transform parms in shader: " + shaderFile); 609 | return texModInfo; 610 | } 611 | 612 | float.TryParse(tcMod[2], out texModInfo.matrix[0][0]); 613 | float.TryParse(tcMod[3], out texModInfo.matrix[0][1]); 614 | float.TryParse(tcMod[4], out texModInfo.matrix[1][0]); 615 | float.TryParse(tcMod[5], out texModInfo.matrix[1][1]); 616 | float.TryParse(tcMod[6], out texModInfo.translate[0]); 617 | float.TryParse(tcMod[7], out texModInfo.translate[1]); 618 | 619 | texModInfo.type = TexMod.TMOD_TRANSFORM; 620 | 621 | return texModInfo; 622 | } 623 | 624 | private TexModInfo ParseTCModInfoRotate(string[] tcMod) 625 | { 626 | var texModInfo = new TexModInfo(); 627 | if (tcMod.Length < 3) 628 | { 629 | Debug.WriteLine("Warning: missing scale parms in shader: " + shaderFile); 630 | return texModInfo; 631 | } 632 | 633 | texModInfo.rotateSpeed = float.Parse(tcMod[2], CultureInfo.InvariantCulture); 634 | 635 | texModInfo.type = TexMod.TMOD_ROTATE; 636 | 637 | return texModInfo; 638 | } 639 | 640 | private TexModInfo ParseTCModInfoTranslate(string[] tcMod) 641 | { 642 | var texModInfo = new TexModInfo(); 643 | 644 | texModInfo.type = TexMod.TMOD_ENTITY_TRANSLATE; 645 | 646 | return texModInfo; 647 | } 648 | 649 | private GenFunc NameToGenFunc(string funcName) 650 | { 651 | switch (funcName) 652 | { 653 | case "sin": 654 | return GenFunc.GF_SIN; 655 | case "square": 656 | return GenFunc.GF_SQUARE; 657 | case "triangle": 658 | return GenFunc.GF_TRIANGLE; 659 | case "sawtooth": 660 | return GenFunc.GF_SAWTOOTH; 661 | case "inversesawtooth": 662 | return GenFunc.GF_INVERSE_SAWTOOTH; 663 | case "noise": 664 | return GenFunc.GF_NOISE; 665 | default: 666 | Debug.WriteLine("Warning: invalid genfunc name in shader " + shaderFile); 667 | return GenFunc.GF_SIN; 668 | } 669 | } 670 | 671 | // TODO: Get next valid token instead of line 672 | private string GetNextValidLine(IEnumerator fileEnumerator) 673 | { 674 | while (fileEnumerator.MoveNext()) 675 | { 676 | var line = TrimLine(fileEnumerator.Current); 677 | if (!string.IsNullOrEmpty(line)) 678 | return line; 679 | } 680 | 681 | return null; 682 | } 683 | 684 | private string TrimLine(string line) 685 | { 686 | var trimmed = line.Trim(); 687 | 688 | // Remove comments from line 689 | if (trimmed.Contains("//", StringComparison.OrdinalIgnoreCase)) 690 | trimmed = trimmed.Substring(0, trimmed.IndexOf("//", StringComparison.OrdinalIgnoreCase)); 691 | 692 | // TODO: Handle multi-line comments 693 | 694 | return trimmed; 695 | } 696 | } 697 | } 698 | -------------------------------------------------------------------------------- /BSPConvert.Lib/Source/SoundConverter.cs: -------------------------------------------------------------------------------- 1 | using LibBSP; 2 | using SharpCompress.Archives; 3 | 4 | namespace BSPConvert.Lib.Source 5 | { 6 | public class SoundConverter 7 | { 8 | private string pk3Dir; 9 | private BSP bsp; 10 | private string outputDir; 11 | private Entities sourceEntities; 12 | 13 | public SoundConverter(string pk3Dir, BSP bsp, Entities sourceEntities) 14 | { 15 | this.pk3Dir = pk3Dir; 16 | this.bsp = bsp; 17 | this.sourceEntities = sourceEntities; 18 | } 19 | 20 | public SoundConverter(string pk3Dir, string outputDir, Entities sourceEntities) 21 | { 22 | this.pk3Dir = pk3Dir; 23 | this.outputDir = outputDir; 24 | this.sourceEntities = sourceEntities; 25 | } 26 | 27 | public void Convert() 28 | { 29 | var customSounds = FindCustomSounds(); 30 | if (!customSounds.Any()) 31 | return; 32 | 33 | foreach (var sound in customSounds) 34 | MoveToPk3SoundDir(sound); 35 | 36 | var soundFiles = Directory.GetFiles(pk3Dir, "*.wav", SearchOption.AllDirectories); 37 | FixSoundPaths(soundFiles); 38 | 39 | if (bsp != null) 40 | EmbedFiles(soundFiles); 41 | else 42 | MoveFilesToOutputDir(soundFiles); 43 | } 44 | 45 | private List FindCustomSounds() 46 | { 47 | var soundHashSet = new HashSet(); 48 | foreach (var entity in sourceEntities) 49 | { 50 | switch (entity.ClassName) 51 | { 52 | case "trigger_jumppad": 53 | soundHashSet.Add(entity["launchsound"].Replace('/', Path.DirectorySeparatorChar)); 54 | break; 55 | case "func_button": 56 | soundHashSet.Add(entity["customsound"].Replace('/', Path.DirectorySeparatorChar)); 57 | break; 58 | case "ambient_generic": 59 | soundHashSet.Add(entity["message"].Replace('/', Path.DirectorySeparatorChar)); 60 | break; 61 | } 62 | } 63 | 64 | return soundHashSet.ToList(); 65 | } 66 | 67 | private void MoveToPk3SoundDir(string sound) 68 | { 69 | var q3ContentDir = ContentManager.GetQ3ContentDir(); 70 | var soundPath = Path.Combine(q3ContentDir, "sound", sound); 71 | if (!File.Exists(soundPath)) 72 | return; 73 | 74 | var newPath = Path.Combine(pk3Dir, "sound", sound); 75 | Directory.CreateDirectory(Path.GetDirectoryName(newPath)); 76 | 77 | File.Copy(soundPath, newPath, true); 78 | } 79 | 80 | // Move sound files that are not in the "sound" folder (music, custom sounds) 81 | private void FixSoundPaths(string[] soundFiles) 82 | { 83 | for (var i = 0; i < soundFiles.Length; i++) 84 | { 85 | var file = soundFiles[i]; 86 | var relativePath = file.Replace(pk3Dir + Path.DirectorySeparatorChar, "", StringComparison.OrdinalIgnoreCase); 87 | if (!relativePath.StartsWith("sound" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) // Sound file is not in "sound" folder 88 | { 89 | var newPath = Path.Combine(pk3Dir, "sound", relativePath); 90 | Directory.CreateDirectory(Path.GetDirectoryName(newPath)); 91 | 92 | File.Move(file, newPath, true); 93 | soundFiles[i] = newPath; 94 | } 95 | } 96 | } 97 | 98 | private void EmbedFiles(string[] soundFiles) 99 | { 100 | using (var archive = bsp.PakFile.GetZipArchive()) 101 | { 102 | foreach (var file in soundFiles) 103 | { 104 | var newPath = file.Replace(pk3Dir + Path.DirectorySeparatorChar, "", StringComparison.OrdinalIgnoreCase); 105 | archive.AddEntry(newPath, new FileInfo(file)); 106 | } 107 | 108 | bsp.PakFile.SetZipArchive(archive, true); 109 | } 110 | } 111 | 112 | private void MoveFilesToOutputDir(string[] soundFiles) 113 | { 114 | foreach (var file in soundFiles) 115 | { 116 | var newPath = file.Replace(pk3Dir, outputDir, StringComparison.OrdinalIgnoreCase); 117 | FileUtil.MoveFile(file, newPath); 118 | } 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /BSPConvert.Lib/Source/TextureConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Linq; 5 | using System.Text; 6 | using System.IO; 7 | using LibBSP; 8 | using SharpCompress.Archives.Zip; 9 | using SharpCompress.Archives; 10 | 11 | namespace BSPConvert.Lib 12 | { 13 | public class TextureConverter 14 | { 15 | private string pk3Dir; 16 | private BSP bsp; 17 | private string outputDir; 18 | 19 | public TextureConverter(string pk3Dir, BSP bsp) 20 | { 21 | this.pk3Dir = pk3Dir; 22 | this.bsp = bsp; 23 | } 24 | 25 | public TextureConverter(string pk3Dir, string outputDir) 26 | { 27 | this.pk3Dir = pk3Dir; 28 | this.outputDir = outputDir; 29 | } 30 | 31 | public void Convert() 32 | { 33 | var startInfo = new ProcessStartInfo(); 34 | startInfo.FileName = "Dependencies\\VTFCmd.exe"; 35 | startInfo.Arguments = $"-folder {pk3Dir}\\*.* -resize -recurse -silent"; 36 | 37 | var process = Process.Start(startInfo); 38 | process.EnableRaisingEvents = true; 39 | process.Exited += (x, y) => OnFinishedConvertingTextures(); 40 | 41 | process.WaitForExit(); 42 | } 43 | 44 | private void OnFinishedConvertingTextures() 45 | { 46 | // TODO: Find textures using shader texture paths 47 | var vtfFiles = Directory.GetFiles(pk3Dir, "*.vtf", SearchOption.AllDirectories); 48 | var vmtFiles = Directory.GetFiles(pk3Dir, "*.vmt", SearchOption.AllDirectories); 49 | var textureFiles = vtfFiles.Concat(vmtFiles); 50 | 51 | if (bsp != null) 52 | EmbedFiles(textureFiles); 53 | else 54 | MoveFilesToOutputDir(textureFiles); 55 | } 56 | 57 | // Embed vtf/vmt files into BSP pak lump 58 | private void EmbedFiles(IEnumerable textureFiles) 59 | { 60 | using (var archive = bsp.PakFile.GetZipArchive()) 61 | { 62 | foreach (var file in textureFiles) 63 | { 64 | var newPath = file.Replace(pk3Dir, "materials", StringComparison.OrdinalIgnoreCase); 65 | archive.AddEntry(newPath, new FileInfo(file)); 66 | } 67 | 68 | bsp.PakFile.SetZipArchive(archive, true); 69 | } 70 | } 71 | 72 | // Move vtf/vmt files into output directory 73 | private void MoveFilesToOutputDir(IEnumerable textureFiles) 74 | { 75 | foreach (var file in textureFiles) 76 | { 77 | var materialDir = Path.Combine(outputDir, "materials"); 78 | var newPath = file.Replace(pk3Dir, materialDir, StringComparison.OrdinalIgnoreCase); 79 | FileUtil.MoveFile(file, newPath); 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /BSPConvert.Lib/Source/Utilities/BSPUtil.cs: -------------------------------------------------------------------------------- 1 | using LibBSP; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | 8 | namespace BSPConvert.Lib 9 | { 10 | public static class BSPUtil 11 | { 12 | public static int GetHashCode(TextureInfo textureInfo) 13 | { 14 | return (textureInfo.UAxis, textureInfo.VAxis, textureInfo.LightmapUAxis, textureInfo.LightmapVAxis, textureInfo.TextureIndex).GetHashCode(); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /BSPConvert.Lib/Source/Utilities/ColorUtil.cs: -------------------------------------------------------------------------------- 1 | namespace BSPConvert.Lib 2 | { 3 | public static class ColorUtil 4 | { 5 | public static ColorRGBExp32 ConvertQ3LightmapToColorRGBExp32(byte r, byte g, byte b) 6 | { 7 | var color = new ColorRGBExp32(); 8 | 9 | var rf = GammaToLinear(r) * 4f; // Multiply by 4 since Source expects lightmap values in 0-4 range 10 | var gf = GammaToLinear(g) * 4f; 11 | var bf = GammaToLinear(b) * 4f; 12 | 13 | var max = Math.Max(rf, Math.Max(gf, bf)); 14 | var exp = CalcExponent(max); 15 | 16 | var fbits = (uint)((127 - exp) << 23); 17 | var scalar = BitConverter.UInt32BitsToSingle(fbits); 18 | 19 | color.r = (byte)(rf * scalar); 20 | color.g = (byte)(gf * scalar); 21 | color.b = (byte)(bf * scalar); 22 | color.exponent = (sbyte)exp; 23 | 24 | return color; 25 | } 26 | 27 | private static float GammaToLinear(byte gamma) 28 | { 29 | return (float)(255.0 * Math.Pow(gamma / 255.0, 2.2)); 30 | } 31 | 32 | private static int CalcExponent(float max) 33 | { 34 | if (max == 0f) 35 | return 0; 36 | 37 | var fbits = BitConverter.SingleToUInt32Bits(max); 38 | 39 | // Extract the exponent component from the floating point bits (bits 23 - 30) 40 | var expComponent = (int)((fbits & 0x7F800000) >> 23); 41 | 42 | const int biasedSeven = 7 + 127; 43 | expComponent -= biasedSeven; 44 | 45 | return expComponent; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /BSPConvert.Lib/Source/Utilities/FileUtil.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace BSPConvert.Lib 8 | { 9 | public static class FileUtil 10 | { 11 | /// 12 | /// Moves a file and creates the destination directory if it doesn't exist. 13 | /// 14 | public static void MoveFile(string sourceFileName, string destFileName) 15 | { 16 | Directory.CreateDirectory(Path.GetDirectoryName(destFileName)); 17 | File.Move(sourceFileName, destFileName, true); 18 | } 19 | 20 | /// 21 | /// Copies a file and creates the destination directory if it doesn't exist. 22 | /// 23 | public static void CopyFile(string sourceFileName, string destFileName) 24 | { 25 | Directory.CreateDirectory(Path.GetDirectoryName(destFileName)); 26 | File.Copy(sourceFileName, destFileName, true); 27 | } 28 | 29 | /// 30 | /// Deserializes a file using the specified deserialization function. 31 | /// 32 | public static T DeserializeFromFile(string path, Func deserializeFunc) 33 | { 34 | using (var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read)) 35 | using (var reader = new BinaryReader(stream)) 36 | { 37 | return deserializeFunc(reader); 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /BSPConvert.Lib/Source/VTFFile.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace BSPConvert.Lib 8 | { 9 | public class VTFFile 10 | { 11 | public class Header 12 | { 13 | public string signature; 14 | public uint[] version = new uint[2]; 15 | public uint headerSize; 16 | public ushort width; 17 | public ushort height; 18 | public uint flags; 19 | public ushort frames; 20 | public ushort firstFrame; 21 | public byte[] padding0; 22 | public float reflectivityX; 23 | public float reflectivityY; 24 | public float reflectivityZ; 25 | public byte[] padding1; 26 | public float bumpmapScale; 27 | public uint highResImageFormat; 28 | public byte mipmapCount; 29 | public byte lowResImageFormat; 30 | public byte lowResImageWidth; 31 | public byte lowResImageHeight; 32 | 33 | public static Header Deserialize(BinaryReader reader) 34 | { 35 | var header = new Header(); 36 | 37 | header.signature = reader.ReadChars(4).ToString(); 38 | header.version[0] = reader.ReadUInt32(); 39 | header.version[1] = reader.ReadUInt32(); 40 | header.headerSize = reader.ReadUInt32(); 41 | header.width = reader.ReadUInt16(); 42 | header.height = reader.ReadUInt16(); 43 | header.flags = reader.ReadUInt32(); 44 | header.frames = reader.ReadUInt16(); 45 | header.firstFrame = reader.ReadUInt16(); 46 | header.padding0 = reader.ReadBytes(4); 47 | header.reflectivityX = reader.ReadSingle(); 48 | header.reflectivityY = reader.ReadSingle(); 49 | header.reflectivityZ = reader.ReadSingle(); 50 | header.padding1 = reader.ReadBytes(4); 51 | header.bumpmapScale = reader.ReadSingle(); 52 | header.highResImageFormat = reader.ReadUInt32(); 53 | header.mipmapCount = reader.ReadByte(); 54 | header.lowResImageFormat = reader.ReadByte(); 55 | header.lowResImageWidth = reader.ReadByte(); 56 | header.lowResImageHeight = reader.ReadByte(); 57 | 58 | return header; 59 | } 60 | } 61 | 62 | public Header header; 63 | 64 | public static VTFFile Deserialize(BinaryReader reader) 65 | { 66 | var vtfFile = new VTFFile(); 67 | 68 | vtfFile.header = Header.Deserialize(reader); 69 | 70 | return vtfFile; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /BSPConvert.Test/BSPConvert.Test.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | PreserveNewest 27 | 28 | 29 | Never 30 | 31 | 32 | PreserveNewest 33 | 34 | 35 | PreserveNewest 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /BSPConvert.Test/BSPConverterTest.cs: -------------------------------------------------------------------------------- 1 | using BSPConvert.Lib; 2 | 3 | namespace BSPConvert.Test 4 | { 5 | public class Tests 6 | { 7 | [SetUp] 8 | public void Setup() 9 | { 10 | // Clear "Converted" folder 11 | var outputDir = Path.Combine(TestContext.CurrentContext.TestDirectory, "Converted"); 12 | if (Directory.Exists(outputDir)) 13 | Directory.Delete(outputDir, true); 14 | 15 | Directory.CreateDirectory(outputDir); 16 | } 17 | 18 | [Test] 19 | public void ConvertTestFiles() 20 | { 21 | var testFilesDir = Path.Combine(TestContext.CurrentContext.TestDirectory, "Test Files"); 22 | var files = Directory.GetFiles(testFilesDir, "*.bsp", SearchOption.AllDirectories); 23 | foreach (var file in files) 24 | Convert(file); 25 | 26 | Assert.Pass(); 27 | } 28 | 29 | private void Convert(string bspFile) 30 | { 31 | var outputDir = Path.Combine(TestContext.CurrentContext.TestDirectory, "Converted"); 32 | var options = new BSPConverterOptions() 33 | { 34 | noPak = false, 35 | DisplacementPower = 4, 36 | minDamageToConvertTrigger = 50, 37 | ignoreZones = false, 38 | oldBSP = false, 39 | prefix = "df_", 40 | inputFile = bspFile, 41 | outputDir = outputDir 42 | }; 43 | 44 | var converter = new BSPConverter(options, new DebugLogger()); 45 | converter.Convert(); 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /BSPConvert.Test/DebugLogger.cs: -------------------------------------------------------------------------------- 1 | using BSPConvert.Lib; 2 | using System.Diagnostics; 3 | 4 | namespace BSPConvert.Test 5 | { 6 | public class DebugLogger : ILogger 7 | { 8 | public void Log(string message) 9 | { 10 | Debug.WriteLine(message); 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /BSPConvert.Test/Test Files/test-nonsolid-patch/test-nonsolid-patch.bsp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/momentum-mod/BSPConvert/4be5eeb3f53a8c73287b0d3852b6afb80ff8714f/BSPConvert.Test/Test Files/test-nonsolid-patch/test-nonsolid-patch.bsp -------------------------------------------------------------------------------- /BSPConvert.Test/Test Files/test-nonsolid-patch/test-nonsolid-patch.map: -------------------------------------------------------------------------------- 1 | 2 | // entity 0 3 | { 4 | "classname" "worldspawn" 5 | // brush 0 6 | { 7 | brushDef 8 | { 9 | ( 512 512 512 ) ( 512 -512 512 ) ( -512 512 512 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 10 | ( 512 512 256 ) ( -512 512 256 ) ( 512 512 0 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 11 | ( 512 512 256 ) ( 512 512 0 ) ( 512 -512 256 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 12 | ( -512 -512 0 ) ( -512 -512 256 ) ( 512 -512 0 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 13 | ( -512 -512 0 ) ( -512 512 0 ) ( -512 -512 256 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 14 | ( -512 512 256 ) ( 512 -512 256 ) ( 512 512 256 ) ( ( 0.0078125 0 -0 ) ( -0 0.0078125 0 ) ) gothic_block/blocks10 0 0 0 15 | } 16 | } 17 | // brush 1 18 | { 19 | brushDef 20 | { 21 | ( 512 512 256 ) ( 512 -512 256 ) ( -512 512 256 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 22 | ( 512 768 256 ) ( -512 768 256 ) ( 512 768 0 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 23 | ( 512 512 256 ) ( 512 512 0 ) ( 512 -512 256 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 24 | ( -512 -512 0 ) ( 512 -512 0 ) ( -512 512 0 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 25 | ( -512 -512 0 ) ( -512 512 0 ) ( -512 -512 256 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 26 | ( 512 512 0 ) ( -512 512 256 ) ( 512 512 256 ) ( ( 0.0078125 0 -0 ) ( -0 0.0078125 0 ) ) gothic_block/blocks10 0 0 0 27 | } 28 | } 29 | // brush 2 30 | { 31 | brushDef 32 | { 33 | ( 512 512 256 ) ( 512 -512 256 ) ( -512 512 256 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 34 | ( 512 512 256 ) ( -512 512 256 ) ( 512 512 0 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 35 | ( 768 512 256 ) ( 768 512 0 ) ( 768 -512 256 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 36 | ( -512 -512 0 ) ( 512 -512 0 ) ( -512 512 0 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 37 | ( -512 -512 0 ) ( -512 -512 256 ) ( 512 -512 0 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 38 | ( 512 -512 256 ) ( 512 512 0 ) ( 512 512 256 ) ( ( 0.0078125 0 -0 ) ( -0 0.0078125 0 ) ) gothic_block/blocks10 0 0 0 39 | } 40 | } 41 | // brush 3 42 | { 43 | brushDef 44 | { 45 | ( 512 512 256 ) ( -512 512 256 ) ( 512 512 0 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 46 | ( 512 512 256 ) ( 512 512 0 ) ( 512 -512 256 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 47 | ( -512 -512 -256 ) ( 512 -512 -256 ) ( -512 512 -256 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 48 | ( -512 -512 0 ) ( -512 -512 256 ) ( 512 -512 0 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 49 | ( -512 -512 0 ) ( -512 512 0 ) ( -512 -512 256 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 50 | ( -512 512 0 ) ( 512 -512 0 ) ( -512 -512 0 ) ( ( 0.0078125 0 -0 ) ( -0 0.0078125 0 ) ) gothic_block/blocks10 0 0 0 51 | } 52 | } 53 | // brush 4 54 | { 55 | brushDef 56 | { 57 | ( 512 512 256 ) ( 512 -512 256 ) ( -512 512 256 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 58 | ( 512 512 256 ) ( 512 512 0 ) ( 512 -512 256 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 59 | ( -512 -512 0 ) ( 512 -512 0 ) ( -512 512 0 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 60 | ( -512 -768 0 ) ( -512 -768 256 ) ( 512 -768 0 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 61 | ( -512 -512 0 ) ( -512 512 0 ) ( -512 -512 256 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 62 | ( 512 -512 0 ) ( -512 -512 256 ) ( -512 -512 0 ) ( ( 0.0078125 0 -0 ) ( -0 0.0078125 0 ) ) gothic_block/blocks10 0 0 0 63 | } 64 | } 65 | // brush 5 66 | { 67 | brushDef 68 | { 69 | ( 512 512 256 ) ( 512 -512 256 ) ( -512 512 256 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 70 | ( 512 512 256 ) ( -512 512 256 ) ( 512 512 0 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 71 | ( -512 -512 0 ) ( 512 -512 0 ) ( -512 512 0 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 72 | ( -512 -512 0 ) ( -512 -512 256 ) ( 512 -512 0 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 73 | ( -768 -512 0 ) ( -768 512 0 ) ( -768 -512 256 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 74 | ( -512 -512 256 ) ( -512 512 0 ) ( -512 -512 0 ) ( ( 0.0078125 0 -0 ) ( -0 0.0078125 0 ) ) gothic_block/blocks10 0 0 0 75 | } 76 | } 77 | // brush 6 78 | { 79 | patchDef2 80 | { 81 | common/nodrawnonsolid 82 | ( 9 3 0 0 0 ) 83 | ( 84 | ( ( -64 0 0 0 0 ) ( -64 0 128 0 -0.75 ) ( -64 0 256 0 -1.5 ) ) 85 | ( ( -64 -64 0 0.5 0 ) ( -64 -64 128 0.5 -0.75 ) ( -64 -64 256 0.5 -1.5 ) ) 86 | ( ( 0 -64 0 1 0 ) ( 0 -64 128 1 -0.75 ) ( 0 -64 256 1 -1.5 ) ) 87 | ( ( 64 -64 0 1.5 0 ) ( 64 -64 128 1.5 -0.75 ) ( 64 -64 256 1.5 -1.5 ) ) 88 | ( ( 64 0 0 2 0 ) ( 64 0 128 2 -0.75 ) ( 64 0 256 2 -1.5 ) ) 89 | ( ( 64 64 0 2.5 0 ) ( 64 64 128 2.5 -0.75 ) ( 64 64 256 2.5 -1.5 ) ) 90 | ( ( 0 64 0 3 0 ) ( 0 64 128 3 -0.75 ) ( 0 64 256 3 -1.5 ) ) 91 | ( ( -64 64 0 3.5 0 ) ( -64 64 128 3.5 -0.75 ) ( -64 64 256 3.5 -1.5 ) ) 92 | ( ( -64 0 0 4 0 ) ( -64 0 128 4 -0.75 ) ( -64 0 256 4 -1.5 ) ) 93 | ) 94 | } 95 | } 96 | } 97 | // entity 1 98 | { 99 | "classname" "info_player_start" 100 | "origin" "-256 0 32" 101 | } 102 | -------------------------------------------------------------------------------- /BSPConvert.Test/Test Files/test-nonsolid-patch/test-nonsolid-patch.txt: -------------------------------------------------------------------------------- 1 | - Patch/displacement should be nonsolid -------------------------------------------------------------------------------- /BSPConvert.Test/Test Files/test-push-horizontal/test-push-horizontal.bsp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/momentum-mod/BSPConvert/4be5eeb3f53a8c73287b0d3852b6afb80ff8714f/BSPConvert.Test/Test Files/test-push-horizontal/test-push-horizontal.bsp -------------------------------------------------------------------------------- /BSPConvert.Test/Test Files/test-push-horizontal/test-push-horizontal.map: -------------------------------------------------------------------------------- 1 | 2 | // entity 0 3 | { 4 | "classname" "worldspawn" 5 | // brush 0 6 | { 7 | brushDef 8 | { 9 | ( 128 64 1664 ) ( 128 -64 1664 ) ( 0 64 1664 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 10 | ( 64 128 1024 ) ( -64 128 1024 ) ( 64 128 960 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 11 | ( 3072 0 1024 ) ( 3072 0 960 ) ( 3072 -128 1024 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 12 | ( -64 -128 960 ) ( -64 -128 1024 ) ( 64 -128 960 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 13 | ( 0 -64 960 ) ( 0 64 960 ) ( 0 -64 1024 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 14 | ( 0 64 1536 ) ( 128 -64 1536 ) ( 128 64 1536 ) ( ( 0.0078125 0 -0 ) ( -0 0.0078125 0 ) ) gothic_ceiling/woodceiling1a 0 0 0 15 | } 16 | } 17 | // brush 1 18 | { 19 | brushDef 20 | { 21 | ( 128 64 1536 ) ( 128 -64 1536 ) ( 0 64 1536 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 22 | ( 64 256 1024 ) ( -64 256 1024 ) ( 64 256 960 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 23 | ( 3072 0 1024 ) ( 3072 0 960 ) ( 3072 -128 1024 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 24 | ( -64 -64 1024 ) ( 64 -64 1024 ) ( -64 64 1024 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 25 | ( 0 -64 960 ) ( 0 64 960 ) ( 0 -64 1024 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 26 | ( 64 128 960 ) ( -64 128 1024 ) ( 64 128 1024 ) ( ( 0.0078125 0 -0 ) ( -0 0.0078125 0 ) ) gothic_ceiling/woodceiling1a 0 0 0 27 | } 28 | } 29 | // brush 2 30 | { 31 | brushDef 32 | { 33 | ( 128 64 1536 ) ( 128 -64 1536 ) ( 0 64 1536 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 34 | ( 64 128 1024 ) ( -64 128 1024 ) ( 64 128 960 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 35 | ( 3200 0 1024 ) ( 3200 0 960 ) ( 3200 -128 1024 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 36 | ( -64 -64 1024 ) ( 64 -64 1024 ) ( -64 64 1024 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 37 | ( -64 -128 960 ) ( -64 -128 1024 ) ( 64 -128 960 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 38 | ( 3072 -128 1024 ) ( 3072 0 960 ) ( 3072 0 1024 ) ( ( 0.0078125 0 -0 ) ( -0 0.0078125 0 ) ) gothic_ceiling/woodceiling1a 0 0 0 39 | } 40 | } 41 | // brush 3 42 | { 43 | brushDef 44 | { 45 | ( 64 128 1024 ) ( -64 128 1024 ) ( 64 128 960 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 46 | ( 3072 0 1024 ) ( 3072 0 960 ) ( 3072 -128 1024 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 47 | ( -64 -64 896 ) ( 64 -64 896 ) ( -64 64 896 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 48 | ( -64 -128 960 ) ( -64 -128 1024 ) ( 64 -128 960 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 49 | ( 0 -64 960 ) ( 0 64 960 ) ( 0 -64 1024 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 50 | ( -64 64 1024 ) ( 64 -64 1024 ) ( -64 -64 1024 ) ( ( 0.00390625 0 -0 ) ( -0 0.00390625 0 ) ) gothic_floor/largerblock3b3dim 0 0 0 51 | } 52 | } 53 | // brush 4 54 | { 55 | brushDef 56 | { 57 | ( 128 64 1536 ) ( 128 -64 1536 ) ( 0 64 1536 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 58 | ( 3072 0 1024 ) ( 3072 0 960 ) ( 3072 -128 1024 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 59 | ( -64 -64 1024 ) ( 64 -64 1024 ) ( -64 64 1024 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 60 | ( -64 -256 960 ) ( -64 -256 1024 ) ( 64 -256 960 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 61 | ( 0 -64 960 ) ( 0 64 960 ) ( 0 -64 1024 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 62 | ( 64 -128 960 ) ( -64 -128 1024 ) ( -64 -128 960 ) ( ( 0.0078125 0 -0 ) ( -0 0.0078125 0 ) ) gothic_ceiling/woodceiling1a 0 0 0 63 | } 64 | } 65 | // brush 5 66 | { 67 | brushDef 68 | { 69 | ( 128 64 1536 ) ( 128 -64 1536 ) ( 0 64 1536 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 70 | ( 64 128 1024 ) ( -64 128 1024 ) ( 64 128 960 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 71 | ( -64 -64 1024 ) ( 64 -64 1024 ) ( -64 64 1024 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 72 | ( -64 -128 960 ) ( -64 -128 1024 ) ( 64 -128 960 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 73 | ( -128 -64 960 ) ( -128 64 960 ) ( -128 -64 1024 ) ( ( 0.03125 0 -0 ) ( -0 0.03125 0 ) ) common/caulk 0 0 0 74 | ( 0 -64 1024 ) ( 0 64 960 ) ( 0 -64 960 ) ( ( 0.0078125 0 -0 ) ( -0 0.0078125 0 ) ) gothic_ceiling/woodceiling1a 0 0 0 75 | } 76 | } 77 | // brush 6 78 | { 79 | brushDef 80 | { 81 | ( 512 128 1152 ) ( 512 -128 1152 ) ( 0 128 1152 ) ( ( 0.00390625 0 -0 ) ( -0 0.00390625 0 ) ) gothic_floor/largerblock3b 0 0 0 82 | ( 512 128 1152 ) ( 0 128 1152 ) ( 512 128 1024 ) ( ( 0.00390625 0 -0 ) ( -0 0.00390625 0 ) ) gothic_floor/largerblock3b 0 0 0 83 | ( 256 -128 1152 ) ( 256 -128 1024 ) ( 256 -384 1152 ) ( ( 0.00390625 0 -0 ) ( -0 0.00390625 0 ) ) gothic_floor/largerblock3b 0 0 0 84 | ( 0 -128 1024 ) ( 512 -128 1024 ) ( 0 128 1024 ) ( ( 0.00390625 0 -0 ) ( -0 0.00390625 0 ) ) gothic_floor/largerblock3b 0 0 0 85 | ( 0 -128 1024 ) ( 0 -128 1152 ) ( 512 -128 1024 ) ( ( 0.00390625 0 -0 ) ( -0 0.00390625 0 ) ) gothic_floor/largerblock3b 0 0 0 86 | ( 0 -128 1024 ) ( 0 128 1024 ) ( 0 -128 1152 ) ( ( 0.00390625 0 -0 ) ( -0 0.00390625 0 ) ) gothic_floor/largerblock3b 0 0 0 87 | } 88 | } 89 | // brush 7 90 | { 91 | brushDef 92 | { 93 | ( 3072 128 1152 ) ( 3072 -128 1152 ) ( 2816 128 1152 ) ( ( 0.00390625 0 -0 ) ( -0 0.00390625 0 ) ) gothic_floor/largerblock3b 0 0 0 94 | ( 3072 128 1152 ) ( 2816 128 1152 ) ( 3072 128 1024 ) ( ( 0.00390625 0 -0 ) ( -0 0.00390625 0 ) ) gothic_floor/largerblock3b 0 0 0 95 | ( 3072 128 1152 ) ( 3072 128 1024 ) ( 3072 -128 1152 ) ( ( 0.00390625 0 -0 ) ( -0 0.00390625 0 ) ) gothic_floor/largerblock3b 0 0 0 96 | ( 2816 -128 1024 ) ( 3072 -128 1024 ) ( 2816 128 1024 ) ( ( 0.00390625 0 -0 ) ( -0 0.00390625 0 ) ) gothic_floor/largerblock3b 0 0 0 97 | ( 2816 -128 1024 ) ( 2816 -128 1152 ) ( 3072 -128 1024 ) ( ( 0.00390625 0 -0 ) ( -0 0.00390625 0 ) ) gothic_floor/largerblock3b 0 0 0 98 | ( 2816 -128 1024 ) ( 2816 128 1024 ) ( 2816 -128 1152 ) ( ( 0.00390625 0 -0 ) ( -0 0.00390625 0 ) ) gothic_floor/largerblock3b 0 0 0 99 | } 100 | } 101 | // brush 8 102 | { 103 | brushDef 104 | { 105 | ( 320 96 1160 ) ( 320 32 1160 ) ( 256 96 1160 ) ( ( 0.015625 0 -0.5 ) ( 0 0.0156250019 0.9999995232 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 106 | ( 256 96 1664 ) ( 192 96 1664 ) ( 256 96 896 ) ( ( 0.0156249972 0 0.9999995232 ) ( 0 0.1250003874 0.0004425049 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 107 | ( 256 96 1664 ) ( 256 96 896 ) ( 256 32 1664 ) ( ( 0.015625 0 -0.5 ) ( 0 0.1249996275 0.9995727539 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 108 | ( 224 32 1152 ) ( 288 32 1152 ) ( 224 96 1152 ) ( ( 0.015625 0 -0.5 ) ( 0 0.0156250019 0.0000004768 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 109 | ( 192 32 896 ) ( 192 32 1664 ) ( 256 32 896 ) ( ( 0.0156249972 0 0.0000004768 ) ( 0 0.1250003874 0.0004425049 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 110 | ( 192 32 896 ) ( 192 96 896 ) ( 192 32 1664 ) ( ( 0.015625 0 0.5 ) ( 0 0.1250004023 0.0004577637 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 111 | } 112 | } 113 | // brush 9 114 | { 115 | brushDef 116 | { 117 | ( 320 -32 1160 ) ( 320 -96 1160 ) ( 256 -32 1160 ) ( ( 0.015625 0 0.5 ) ( 0 0.0156250019 0.9999995232 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 118 | ( 256 -32 1664 ) ( 192 -32 1664 ) ( 256 -32 896 ) ( ( 0.0156249972 0 0.9999995232 ) ( 0 0.1250003874 0.0004425049 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 119 | ( 256 -32 1664 ) ( 256 -32 896 ) ( 256 -96 1664 ) ( ( 0.015625 0 0.5 ) ( 0 0.1249996275 0.9995727539 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 120 | ( 224 -96 1152 ) ( 288 -96 1152 ) ( 224 -32 1152 ) ( ( 0.015625 0 0.5 ) ( 0 0.0156250019 0.0000004768 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 121 | ( 192 -96 896 ) ( 192 -96 1664 ) ( 256 -96 896 ) ( ( 0.0156249972 0 0.0000004768 ) ( 0 0.1250003874 0.0004425049 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 122 | ( 192 -96 896 ) ( 192 -32 896 ) ( 192 -96 1664 ) ( ( 0.015625 0 -0.5 ) ( 0 0.1250004023 0.0004577637 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 123 | } 124 | } 125 | } 126 | // entity 1 127 | { 128 | "classname" "trigger_push" 129 | "target" "target1" 130 | // brush 0 131 | { 132 | brushDef 133 | { 134 | ( 320 96 1168 ) ( 320 32 1168 ) ( 256 96 1168 ) ( ( 0.0490722656 0 -0.5703125 ) ( 0 0.049804695 0.1874985695 ) ) common/trigger 0 0 0 135 | ( 256 96 1672 ) ( 192 96 1672 ) ( 256 96 904 ) ( ( 0.0490722582 0 0.1406235695 ) ( 0 0.3984387219 0.0014203639 ) ) common/trigger 0 0 0 136 | ( 256 96 1672 ) ( 256 96 904 ) ( 256 32 1672 ) ( ( 0.0490722656 0 -0.5703125 ) ( 0 0.3984363079 0.1861286163 ) ) common/trigger 0 0 0 137 | ( 224 32 1160 ) ( 288 32 1160 ) ( 224 96 1160 ) ( ( 0.0490722656 0 -0.5703125 ) ( 0 0.049804695 0.0000015198 ) ) common/trigger 0 0 0 138 | ( 192 32 904 ) ( 192 32 1672 ) ( 256 32 904 ) ( ( 0.0490722582 0 0.0000014975 ) ( 0 0.3984387219 0.0014203639 ) ) common/trigger 0 0 0 139 | ( 192 32 904 ) ( 192 96 904 ) ( 192 32 1672 ) ( ( 0.0490722656 0 0.7109375 ) ( 0 0.3984387815 0.0014693813 ) ) common/trigger 0 0 0 140 | } 141 | } 142 | } 143 | // entity 2 144 | { 145 | "classname" "info_player_start" 146 | "origin" "128 0 1184" 147 | } 148 | // entity 3 149 | { 150 | "classname" "target_location" 151 | "origin" "64 0 1160" 152 | "targetname" "start" 153 | } 154 | // entity 4 155 | { 156 | "classname" "trigger_teleport" 157 | "target" "start" 158 | // brush 0 159 | { 160 | brushDef 161 | { 162 | ( 2816 128 1120 ) ( 2816 -128 1120 ) ( 256 128 1120 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/trigger 0 0 0 163 | ( 2816 128 1152 ) ( 256 128 1152 ) ( 2816 128 1024 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0.8125001192 ) ) common/trigger 0 0 0 164 | ( 2816 128 1152 ) ( 2816 128 1024 ) ( 2816 -128 1152 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0.8125001192 ) ) common/trigger 0 0 0 165 | ( 256 -128 1024 ) ( 2816 -128 1024 ) ( 256 128 1024 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/trigger 0 0 0 166 | ( 256 -128 1024 ) ( 256 -128 1152 ) ( 2816 -128 1024 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0.8125001192 ) ) common/trigger 0 0 0 167 | ( 256 -128 1024 ) ( 256 128 1024 ) ( 256 -128 1152 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0.8125001192 ) ) common/trigger 0 0 0 168 | } 169 | } 170 | } 171 | // entity 5 172 | { 173 | "classname" "target_position" 174 | "origin" "1368 64 1280" 175 | "targetname" "target1" 176 | } 177 | // entity 6 178 | { 179 | "classname" "trigger_push" 180 | "target" "target2" 181 | // brush 0 182 | { 183 | brushDef 184 | { 185 | ( 320 -32 1168 ) ( 320 -96 1168 ) ( 256 -32 1168 ) ( ( 0.0490722656 0 0.7109375 ) ( 0 0.049804695 0.1874985695 ) ) common/trigger 0 0 0 186 | ( 256 -32 1672 ) ( 192 -32 1672 ) ( 256 -32 904 ) ( ( 0.0490722582 0 0.1406235695 ) ( 0 0.3984387219 0.0014203639 ) ) common/trigger 0 0 0 187 | ( 256 -32 1672 ) ( 256 -32 904 ) ( 256 -96 1672 ) ( ( 0.0490722656 0 0.7109375 ) ( 0 0.3984363079 0.1861286163 ) ) common/trigger 0 0 0 188 | ( 224 -96 1160 ) ( 288 -96 1160 ) ( 224 -32 1160 ) ( ( 0.0490722656 0 0.7109375 ) ( 0 0.049804695 0.0000015198 ) ) common/trigger 0 0 0 189 | ( 192 -96 904 ) ( 192 -96 1672 ) ( 256 -96 904 ) ( ( 0.0490722582 0 0.0000014975 ) ( 0 0.3984387219 0.0014203639 ) ) common/trigger 0 0 0 190 | ( 192 -96 904 ) ( 192 -32 904 ) ( 192 -96 1672 ) ( ( 0.0490722656 0 -0.5703125 ) ( 0 0.3984387815 0.0014693813 ) ) common/trigger 0 0 0 191 | } 192 | } 193 | } 194 | // entity 7 195 | { 196 | "classname" "target_position" 197 | "origin" "1370 -64 1280" 198 | "targetname" "target2" 199 | } 200 | -------------------------------------------------------------------------------- /BSPConvert.Test/Test Files/test-push-horizontal/test-push-horizontal.txt: -------------------------------------------------------------------------------- 1 | - Left trigger_push should not pass the gap 2 | - Right trigger_push should pass the gap -------------------------------------------------------------------------------- /BSPConvert.Test/Test Files/test-push-vertical/test-push-vertical.bsp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/momentum-mod/BSPConvert/4be5eeb3f53a8c73287b0d3852b6afb80ff8714f/BSPConvert.Test/Test Files/test-push-vertical/test-push-vertical.bsp -------------------------------------------------------------------------------- /BSPConvert.Test/Test Files/test-push-vertical/test-push-vertical.map: -------------------------------------------------------------------------------- 1 | 2 | // entity 0 3 | { 4 | "classname" "worldspawn" 5 | // brush 0 6 | { 7 | brushDef 8 | { 9 | ( 128 320 1600 ) ( 128 64 1600 ) ( -128 320 1600 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/caulk 0 0 0 10 | ( 128 192 64 ) ( -128 192 64 ) ( 128 192 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 11 | ( 192 128 64 ) ( 192 128 0 ) ( 192 -128 64 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 12 | ( -128 -192 0 ) ( -128 -192 64 ) ( 128 -192 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 13 | ( -192 -128 0 ) ( -192 128 0 ) ( -192 -128 64 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 14 | ( -128 320 1536 ) ( 128 64 1536 ) ( 128 320 1536 ) ( ( 0.0078125 0 0 ) ( 0 0.0078125 0 ) ) gothic_ceiling/woodceiling1a 0 0 0 15 | } 16 | } 17 | // brush 1 18 | { 19 | brushDef 20 | { 21 | ( 128 320 1536 ) ( 128 64 1536 ) ( -128 320 1536 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/caulk 0 0 0 22 | ( 128 256 64 ) ( -128 256 64 ) ( 128 256 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 23 | ( 192 128 64 ) ( 192 128 0 ) ( 192 -128 64 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 24 | ( -128 -128 0 ) ( 128 -128 0 ) ( -128 128 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/caulk 0 0 0 25 | ( -192 -128 0 ) ( -192 128 0 ) ( -192 -128 64 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 26 | ( 128 192 0 ) ( -128 192 64 ) ( 128 192 64 ) ( ( 0.0078125 0 0 ) ( 0 0.0078125 -0 ) ) gothic_ceiling/woodceiling1a 0 0 0 27 | } 28 | } 29 | // brush 2 30 | { 31 | brushDef 32 | { 33 | ( 128 320 1536 ) ( 128 64 1536 ) ( -128 320 1536 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/caulk 0 0 0 34 | ( 128 192 64 ) ( -128 192 64 ) ( 128 192 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 35 | ( 256 128 64 ) ( 256 128 0 ) ( 256 -128 64 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 36 | ( -128 -128 0 ) ( 128 -128 0 ) ( -128 128 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/caulk 0 0 0 37 | ( -128 -192 0 ) ( -128 -192 64 ) ( 128 -192 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 38 | ( 192 -128 64 ) ( 192 128 0 ) ( 192 128 64 ) ( ( 0.0078125 0 0 ) ( 0 0.0078125 -0 ) ) gothic_ceiling/woodceiling1a 0 0 0 39 | } 40 | } 41 | // brush 3 42 | { 43 | brushDef 44 | { 45 | ( 128 192 64 ) ( -128 192 64 ) ( 128 192 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 46 | ( 192 128 64 ) ( 192 128 0 ) ( 192 -128 64 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 47 | ( -128 -128 -64 ) ( 128 -128 -64 ) ( -128 128 -64 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/caulk 0 0 0 48 | ( -128 -192 0 ) ( -128 -192 64 ) ( 128 -192 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 49 | ( -192 -128 0 ) ( -192 128 0 ) ( -192 -128 64 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 50 | ( -128 128 0 ) ( 128 -128 0 ) ( -128 -128 0 ) ( ( 0.00390625 0 0 ) ( 0 0.00390625 0 ) ) gothic_floor/largerblock3b 0 0 0 51 | } 52 | } 53 | // brush 4 54 | { 55 | brushDef 56 | { 57 | ( 128 320 1536 ) ( 128 64 1536 ) ( -128 320 1536 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/caulk 0 0 0 58 | ( 192 128 64 ) ( 192 128 0 ) ( 192 -128 64 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 59 | ( -128 -128 0 ) ( 128 -128 0 ) ( -128 128 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/caulk 0 0 0 60 | ( -128 -256 0 ) ( -128 -256 64 ) ( 128 -256 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 61 | ( -192 -128 0 ) ( -192 128 0 ) ( -192 -128 64 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 62 | ( 128 -192 0 ) ( -128 -192 64 ) ( -128 -192 0 ) ( ( 0.0078125 0 0 ) ( 0 0.0078125 -0 ) ) gothic_ceiling/woodceiling1a 0 0 0 63 | } 64 | } 65 | // brush 5 66 | { 67 | brushDef 68 | { 69 | ( 128 320 1536 ) ( 128 64 1536 ) ( -128 320 1536 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/caulk 0 0 0 70 | ( 128 192 64 ) ( -128 192 64 ) ( 128 192 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 71 | ( -128 -128 0 ) ( 128 -128 0 ) ( -128 128 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/caulk 0 0 0 72 | ( -128 -192 0 ) ( -128 -192 64 ) ( 128 -192 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 73 | ( -256 -128 0 ) ( -256 128 0 ) ( -256 -128 64 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/caulk 0 0 0 74 | ( -192 -128 64 ) ( -192 128 0 ) ( -192 -128 0 ) ( ( 0.0078125 0 0 ) ( 0 0.0078125 -0 ) ) gothic_ceiling/woodceiling1a 0 0 0 75 | } 76 | } 77 | // brush 6 78 | { 79 | brushDef 80 | { 81 | ( 192 192 1024 ) ( 192 0 1024 ) ( -192 192 1024 ) ( ( 0.00390625 0 -0 ) ( -0 0.00390625 0 ) ) gothic_floor/largerblock3b 0 0 0 82 | ( 192 192 1024 ) ( -192 192 1024 ) ( 192 192 0 ) ( ( 0.0078125 0 -0 ) ( -0 0.0078125 0 ) ) gothic_ceiling/woodceiling1a 0 0 0 83 | ( 192 192 1024 ) ( 192 192 0 ) ( 192 0 1024 ) ( ( 0.0078125 0 -0 ) ( -0 0.0078125 0 ) ) gothic_ceiling/woodceiling1a 0 0 0 84 | ( -192 0 0 ) ( 192 0 0 ) ( -192 192 0 ) ( ( 0.0078125 0 -0 ) ( -0 0.0078125 0 ) ) gothic_ceiling/woodceiling1a 0 0 0 85 | ( -192 0 0 ) ( -192 0 1024 ) ( 192 0 0 ) ( ( 0.0078125 0 -0 ) ( -0 0.0078125 0 ) ) gothic_ceiling/woodceiling1a 0 0 0 86 | ( -192 0 0 ) ( -192 192 0 ) ( -192 0 1024 ) ( ( 0.0078125 0 -0 ) ( -0 0.0078125 0 ) ) gothic_ceiling/woodceiling1a 0 0 0 87 | } 88 | } 89 | // brush 7 90 | { 91 | brushDef 92 | { 93 | ( -32 0 8 ) ( -32 -64 8 ) ( -96 0 8 ) ( ( 0.015625 0 0 ) ( 0 0.0156249991 0.4999999106 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 94 | ( -32 0 8 ) ( -96 0 8 ) ( -32 0 -56 ) ( ( 0.015625 0 -0.5 ) ( 0 0.1249999925 0 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 95 | ( -32 0 8 ) ( -32 0 -56 ) ( -32 -64 8 ) ( ( 0.015625 0 0 ) ( 0 0.1249999925 0 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 96 | ( -96 -64 0 ) ( -32 -64 0 ) ( -96 0 0 ) ( ( 0.015625 0 0 ) ( 0 0.0156249991 -0.4999999702 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 97 | ( -96 -64 -56 ) ( -96 -64 8 ) ( -32 -64 -56 ) ( ( 0.015625 0 0.5 ) ( 0 0.1249999925 0 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 98 | ( -96 -64 -56 ) ( -96 0 -56 ) ( -96 -64 8 ) ( ( 0.015625 0 0 ) ( 0 0.1249999925 0 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 99 | } 100 | } 101 | // brush 8 102 | { 103 | brushDef 104 | { 105 | ( 96 0 8 ) ( 96 -64 8 ) ( 32 0 8 ) ( ( 0.015625 0 0 ) ( 0 0.0156249991 -0.4999999702 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 106 | ( 96 0 8 ) ( 32 0 8 ) ( 96 0 -56 ) ( ( 0.015625 0 0.5 ) ( 0 0.1249999925 0 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 107 | ( 96 0 8 ) ( 96 0 -56 ) ( 96 -64 8 ) ( ( 0.015625 0 0 ) ( 0 0.1249999925 0 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 108 | ( 32 -64 0 ) ( 96 -64 0 ) ( 32 0 0 ) ( ( 0.015625 0 0 ) ( 0 0.0156249991 0.4999999106 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 109 | ( 32 -64 -56 ) ( 32 -64 8 ) ( 96 -64 -56 ) ( ( 0.015625 0 -0.5 ) ( 0 0.1249999925 0 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 110 | ( 32 -64 -56 ) ( 32 0 -56 ) ( 32 -64 8 ) ( ( 0.015625 0 0 ) ( 0 0.1249999925 0 ) ) gothic_ceiling/ceilingtech02_d 0 0 0 111 | } 112 | } 113 | } 114 | // entity 1 115 | { 116 | "classname" "info_player_start" 117 | "origin" "0 -128 32" 118 | "angle" "90" 119 | } 120 | // entity 2 121 | { 122 | "classname" "trigger_push" 123 | "target" "target1" 124 | // brush 0 125 | { 126 | brushDef 127 | { 128 | ( -32 0 16 ) ( -32 -64 16 ) ( -96 0 16 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/trigger 0 0 0 129 | ( -32 0 8 ) ( -96 0 8 ) ( -32 0 0 ) ( ( 0.03125 0 -0 ) ( 0 0.03125 0 ) ) common/trigger 0 0 0 130 | ( -32 0 8 ) ( -32 0 0 ) ( -32 -64 8 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/trigger 0 0 0 131 | ( -96 -64 8 ) ( -32 -64 8 ) ( -96 0 8 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/trigger 0 0 0 132 | ( -96 -64 0 ) ( -96 -64 8 ) ( -32 -64 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/trigger 0 0 0 133 | ( -96 -64 0 ) ( -96 0 0 ) ( -96 -64 8 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/trigger 0 0 0 134 | } 135 | } 136 | } 137 | // entity 3 138 | { 139 | "classname" "target_position" 140 | "origin" "-64 0 948" 141 | "targetname" "target1" 142 | } 143 | // entity 4 144 | { 145 | "classname" "trigger_push" 146 | "target" "target2" 147 | // brush 0 148 | { 149 | brushDef 150 | { 151 | ( 96 0 16 ) ( 96 -64 16 ) ( 32 0 16 ) ( ( 0.03125 0 0 ) ( 0 0.03125 -0 ) ) common/trigger 0 0 0 152 | ( 96 0 8 ) ( 32 0 8 ) ( 96 0 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/trigger 0 0 0 153 | ( 96 0 8 ) ( 96 0 0 ) ( 96 -64 8 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/trigger 0 0 0 154 | ( 32 -64 8 ) ( 96 -64 8 ) ( 32 0 8 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/trigger 0 0 0 155 | ( 32 -64 0 ) ( 32 -64 8 ) ( 96 -64 0 ) ( ( 0.03125 0 -0 ) ( 0 0.03125 0 ) ) common/trigger 0 0 0 156 | ( 32 -64 0 ) ( 32 0 0 ) ( 32 -64 8 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) common/trigger 0 0 0 157 | } 158 | } 159 | } 160 | // entity 5 161 | { 162 | "classname" "target_position" 163 | "origin" "64 0 949" 164 | "targetname" "target2" 165 | } 166 | -------------------------------------------------------------------------------- /BSPConvert.Test/Test Files/test-push-vertical/test-push-vertical.txt: -------------------------------------------------------------------------------- 1 | - Left trigger_push should not reach the top 2 | - Right trigger_push should reach the top -------------------------------------------------------------------------------- /BSPConvert.Test/Usings.cs: -------------------------------------------------------------------------------- 1 | global using NUnit.Framework; -------------------------------------------------------------------------------- /BSPConvert.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.2.32630.192 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BSPConvert.Lib", "BSPConvert.Lib\BSPConvert.Lib.csproj", "{6A369C04-C7A1-4F9C-AA95-C13F81052494}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BSPConvert.Cmd", "BSPConvert.Cmd\BSPConvert.Cmd.csproj", "{BB53D427-9FD9-4CDE-B6C5-03988FA9B5D3}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BSPConvert.Test", "BSPConvert.Test\BSPConvert.Test.csproj", "{BBDB0A09-6B83-46CC-983B-349ECFD8BB6B}" 11 | EndProject 12 | Global 13 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 14 | Debug|Any CPU = Debug|Any CPU 15 | Release|Any CPU = Release|Any CPU 16 | EndGlobalSection 17 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 18 | {6A369C04-C7A1-4F9C-AA95-C13F81052494}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 19 | {6A369C04-C7A1-4F9C-AA95-C13F81052494}.Debug|Any CPU.Build.0 = Debug|Any CPU 20 | {6A369C04-C7A1-4F9C-AA95-C13F81052494}.Release|Any CPU.ActiveCfg = Release|Any CPU 21 | {6A369C04-C7A1-4F9C-AA95-C13F81052494}.Release|Any CPU.Build.0 = Release|Any CPU 22 | {BB53D427-9FD9-4CDE-B6C5-03988FA9B5D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {BB53D427-9FD9-4CDE-B6C5-03988FA9B5D3}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {BB53D427-9FD9-4CDE-B6C5-03988FA9B5D3}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {BB53D427-9FD9-4CDE-B6C5-03988FA9B5D3}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {BBDB0A09-6B83-46CC-983B-349ECFD8BB6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {BBDB0A09-6B83-46CC-983B-349ECFD8BB6B}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {BBDB0A09-6B83-46CC-983B-349ECFD8BB6B}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {BBDB0A09-6B83-46CC-983B-349ECFD8BB6B}.Release|Any CPU.Build.0 = Release|Any CPU 30 | EndGlobalSection 31 | GlobalSection(SolutionProperties) = preSolution 32 | HideSolutionNode = FALSE 33 | EndGlobalSection 34 | GlobalSection(ExtensibilityGlobals) = postSolution 35 | SolutionGuid = {754548DC-6523-4496-9E64-1EA9AB121FD2} 36 | EndGlobalSection 37 | EndGlobal 38 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Tyler Befferman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BSP Convert 2 | C# tools and library for converting BSP files between engine versions without decompilation. This approach has the following advantages: 3 | - Lightmaps can be preserved across engine versions (lighting information is lost when decompiling Quake BSP's) 4 | - Maps can be ported in seconds with one command rather than many hours of manual effort 5 | 6 | Check out this video for a demonstration of what this tool is currently capable of: https://www.youtube.com/watch?v=Tg6_sGcCLJ4 7 | 8 | # BSP Convert Usage 9 | 10 | Available command line arguments: 11 | ``` 12 | --nopak Export materials into folders instead of embedding them in the BSP. 13 | --subdiv (Default: 4) Displacement subdivisions [2-4]. 14 | --mindmg (Default: 50) Minimum damage to convert trigger_hurt into trigger_teleport. 15 | --prefix Prefix for the converted BSP's file name. 16 | --output Output game directory for converted BSP/materials. 17 | --help Display this help screen. 18 | --version Display version information. 19 | input files (pos. 0) Required. Input Quake 3 BSP/PK3 file(s) to be converted. 20 | ``` 21 | 22 | Example usage: 23 | `.\BSPConv.exe "C:\Users\\Documents\BSPConvert\nood-aDr.pk3" --output "C:\Program Files (x86)\Steam\steamapps\common\Momentum Mod Playtest\momentum"` 24 | 25 | # Supported BSP conversions 26 | - Quake 3 -> Source Engine **(WIP)** 27 | - Half-Life (GoldSrc) -> Source Engine **(Not Started)** 28 | --------------------------------------------------------------------------------