├── .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 |
--------------------------------------------------------------------------------