diff --git a/.gitignore b/.gitignore index e44143d9..1da23701 100644 --- a/.gitignore +++ b/.gitignore @@ -353,3 +353,4 @@ cmake-build-* # some Android-specific build stuff 3rdparty/SDL 3rdparty/hlsdk-portable +!scripts/build-ninja.py diff --git a/android/app/CMakeLists.txt b/android/app/CMakeLists.txt deleted file mode 100644 index 06121e6f..00000000 --- a/android/app/CMakeLists.txt +++ /dev/null @@ -1,89 +0,0 @@ -cmake_minimum_required(VERSION 3.22.1) - -# Only used to build Android project - -project(xash3d-fwgs-android) - -# armeabi-v7a requires cpufeatures library -if (ANDROID) - include_directories(${ANDROID_NDK}/sources/android/cpufeatures) - add_library(cpufeatures ${ANDROID_NDK}/sources/android/cpufeatures/cpu-features.c) - target_link_libraries(cpufeatures dl) -endif () - -include(FindPython) - -if("${CMAKE_BUILD_TYPE}" STREQUAL "Debug") - set(BUILD_TYPE "debug") -else() - set(BUILD_TYPE "release") - list(APPEND WAF_EXTRA_ARGS --enable-poly-opt --enable-lto --enable-limited-debuginfo) -endif() - -if (ANDROID_ABI STREQUAL "x86") - # HACKHACK: I don't know why but engine gets built as 64-bit binary here - list(APPEND WAF_EXTRA_ARGS -4) -endif () - -set(CMAKE_VERBOSE_MAKEFILE ON) - - -# not cleanest way to get upper directory -set(ENGINE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../") - -set(WAF_CC "${CMAKE_C_COMPILER} --target=${CMAKE_C_COMPILER_TARGET}") -set(WAF_CXX "${CMAKE_CXX_COMPILER} --target=${CMAKE_CXX_COMPILER_TARGET}") -set(WAF ${Python_EXECUTABLE} ${ENGINE_SOURCE_DIR}waf -t ${ENGINE_SOURCE_DIR} -o ${CMAKE_CURRENT_BINARY_DIR}/xash3d-fwgs) - -# try to build minimal SDL. Enable features as we're gonna use them -foreach (X IN ITEMS SDL_RENDER SDL_POWER SDL_VULKAN SDL_DISKAUDIO SDL_DUMMYAUDIO SDL_DUMMYVIDEO SDL_VULKAN SDL_OFFSCREEN SDL_STATIC) - set(${X} OFF CACHE BOOL "" FORCE) -endforeach () -add_subdirectory("${ENGINE_SOURCE_DIR}/3rdparty/SDL" SDL) - -include(ExternalProject) - -# gradle passes backslashes to cmake, how does this even work for everybody else? -string(REPLACE "\\" "/" CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_LIBRARY_OUTPUT_DIRECTORY}) - -ExternalProject_Add( - Xash3DFWGS - SOURCE_DIR ${ENGINE_SOURCE_DIR} - INSTALL_DIR ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - BUILD_IN_SOURCE TRUE - DEPENDS SDL2 - BUILD_ALWAYS TRUE - LOG_CONFIGURE TRUE - LOG_BUILD TRUE - LOG_INSTALL TRUE - LOG_OUTPUT_ON_FAILURE TRUE - LOG_MERGED_STDOUTERR TRUE - -# USES_TERMINAL_CONFIGURE TRUE -# USES_TERMINAL_BUILD TRUE -# USES_TERMINAL_INSTALL TRUE - - # NOTE: setting up WAFLOCK is important to avoid possible race conditions - CONFIGURE_COMMAND ${CMAKE_COMMAND} -E env - ANDROID_NDK=${ANDROID_NDK} - BUILD_CMAKE_LIBRARY_OUTPUT_DIRECTORY=${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - WAFLOCK=.lock-waf_android_${ANDROID_ABI}_build - ${WAF} configure -T ${BUILD_TYPE} --android=${ANDROID_ABI},,${ANDROID_PLATFORM_LEVEL} - -s "${ENGINE_SOURCE_DIR}/3rdparty/SDL" --enable-bundled-deps ${WAF_EXTRA_ARGS} - - BUILD_COMMAND ${CMAKE_COMMAND} -E env - WAFLOCK=.lock-waf_android_${ANDROID_ABI}_build - ${WAF} build -v - - INSTALL_COMMAND ${CMAKE_COMMAND} -E env - WAFLOCK=.lock-waf_android_${ANDROID_ABI}_build - ${WAF} install --destdir=${CMAKE_LIBRARY_OUTPUT_DIRECTORY} -) - -add_subdirectory("${ENGINE_SOURCE_DIR}/3rdparty/hlsdk-portable" hlsdk-portable) - -# a1ba: without this, xash3d target will be ignored as nothing depends on it -add_dependencies(client Xash3DFWGS) -add_dependencies(server Xash3DFWGS) - - diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 345d9371..4309fc68 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,4 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.time.LocalDateTime import java.time.Month import java.time.temporal.ChronoUnit @@ -9,18 +10,35 @@ plugins { android { namespace = "su.xash.engine" - ndkVersion = "28.0.13004108" + ndkVersion = "28.2.13676358" compileSdk = 35 defaultConfig { applicationId = "su.xash.engine" - versionName = "0.21" + versionName = "0.21-" + getGitHash() versionCode = getBuildNum() minSdk = 21 targetSdk = 35 - ndk { - abiFilters.addAll(setOf("armeabi-v7a", "arm64-v8a")) + externalNativeBuild { + val engineRoot = projectDir.parentFile.parent + + experimentalProperties["ninja.abiFilters"] = setOf("armeabi-v7a", "arm64-v8a") + experimentalProperties["ninja.path"] = File(engineRoot, "wscript").path + experimentalProperties["ninja.configure"] = "run-python" + experimentalProperties["ninja.arguments"] = setOf( + File(engineRoot, "scripts/configure-ninja.py").path, + engineRoot, + "--variant=\${ndk.variantName}", + "--abi=Android-\${ndk.abi}", + "--configuration-dir=\${ndk.buildRoot}", + "--ndk-version=\${ndk.moduleNdkVersion}", + "--min-sdk-version=\${ndk.minPlatform}", + "--ndk-root=${android.ndkDirectory}", + // shut up, fake options + "-p:Configuration=\${ndk.variantName}", + "-p:Platform=\${ndk.abi}" + ) } } @@ -29,14 +47,9 @@ android { targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { - jvmTarget = "11" - } - - externalNativeBuild { - cmake { - path = file("CMakeLists.txt") - version = "3.22.1" + kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_11 } } @@ -116,3 +129,9 @@ fun getBuildNum(): Int { val minuteOfDay = now.hour * 60 + now.minute return (qBuildNum * 10000 + minuteOfDay).toInt() } + +fun getGitHash(): String { + val process = ProcessBuilder("git", "rev-parse", "--short", "HEAD").directory(project.rootDir) + .redirectErrorStream(true).start() + return process.inputStream.bufferedReader().readText().trim() +} diff --git a/android/app/run-python b/android/app/run-python new file mode 100755 index 00000000..88892d53 --- /dev/null +++ b/android/app/run-python @@ -0,0 +1,3 @@ +#!/bin/bash + +exec python $@ diff --git a/android/app/run-python.bat b/android/app/run-python.bat new file mode 100644 index 00000000..6401ceed --- /dev/null +++ b/android/app/run-python.bat @@ -0,0 +1 @@ +python %* diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 5c95cf48..f24e65d1 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -3,7 +3,8 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="match_parent"> + android:layout_height="match_parent" + android:fitsSystemWindows="true"> str + if isinstance(node, str): + return escape_path(node) + else: + return escape_path(node.abspath()) + + +class TaskAdapter(ABC): + task_type = None # type: str + + def __init__(self, task): # type: (Task) -> None + if not task.inputs: + raise ValueError() + + if not task.outputs: + raise ValueError() + + self.task = task # type: Task + + @classmethod + def write_rule(cls, writer): # type: (Writer) -> None + writer.rule( + name=cls.task_type, + command="$cmd", + description="Building {} object $out".format(cls.task_type) + ) + writer.newline() + + @abstractmethod + def write_build(self, writer): # type: (Writer) -> None + pass + + def write_target(self, writer): # type: (Writer) -> None + pass + + def get_inputs(self): # type: () -> List[str] + return [] + + def get_outputs(self): # type: () -> List[str] + return [] + + +class CAdapter(TaskAdapter): + task_type = "c" # type: str + + def write_build(self, writer): # type: (Writer) -> None + output_file = self.task.outputs[0].path_from(self.task.generator.bld.bldnode) + input_files = [] + + cmd = " ".join(self.task.last_cmd) + + for node in self.task.inputs: + cmd = cmd.replace(node.path_from(self.task.get_cwd()), node.abspath()) + input_files.append(node.abspath()) + + cmd = cmd.replace(self.task.outputs[0].abspath(), output_file) + + for inc in self.task.env.INCPATHS: + cwd = self.task.get_cwd().abspath() + path = os.path.normpath(os.path.join(cwd, inc)) + cmd = cmd.replace(self.task.env.CPPPATH_ST % inc, + self.task.env.CPPPATH_ST % path) + + writer.build( + rule=self.task_type, + outputs=output_file, + inputs=input_files, + variables={ + "cmd": cmd, + }) + writer.newline() + + +class CxxAdapter(CAdapter): + task_type = "cxx" # type: str + + +class CStLibAdapter(TaskAdapter): + task_type = "cstlib" # type: str + + def write_build(self, writer): # type: (Writer) -> None + output_file = self.task.outputs[0].path_from(self.task.generator.bld.bldnode) + input_files = [] + + cmd = " ".join(self.task.last_cmd) + + for node in self.task.inputs: + cmd = cmd.replace(node.path_from(self.task.get_cwd()), node.abspath()) + input_files.append(node.abspath()) + + cmd = cmd.replace(self.task.outputs[0].abspath(), output_file) + + writer.build( + rule=self.task_type, + outputs=output_file, + inputs=input_files, + variables={ + "cmd": cmd, + }) + writer.newline() + + def write_target(self, writer): # type: (Writer) -> None + writer.build( + rule="waf_build", + outputs="{}.passthrough".format(self.get_outputs()[0]), + inputs=self.get_inputs(), + variables={ + "tgt": self.task.generator.name + }) + writer.newline() + + def get_inputs(self): # type: () -> List[str] + return [node.abspath() for node in self.task.generator.source] + + def get_outputs(self): # type: () -> List[str] + return [self.task.outputs[0].path_from(self.task.generator.bld.bldnode)] + + +class CShLibAdapter(CStLibAdapter): + task_type = "cshlib" # type: str + + def write_build(self, writer): # type: (Writer) -> None + output_file = self.task.outputs[0].path_from(self.task.generator.bld.bldnode) + input_files = [] + + cmd = " ".join(self.task.last_cmd) + + for node in self.task.inputs: + cmd = cmd.replace(node.path_from(self.task.get_cwd()), node.abspath()) + input_files.append(node.abspath()) + + cmd = cmd.replace(self.task.outputs[0].abspath(), output_file) + + for lib in self.task.env.STLIBPATH: + cwd = self.task.get_cwd().abspath() + path = os.path.normpath(os.path.join(cwd, lib)) + cmd = cmd.replace(self.task.env.STLIBPATH_ST % lib, + self.task.env.STLIBPATH_ST % path) + + for lib in self.task.env.LIBPATH: + cwd = self.task.get_cwd().abspath() + path = os.path.normpath(os.path.join(cwd, lib)) + cmd = cmd.replace(self.task.env.LIBPATH_ST % lib, + self.task.env.LIBPATH_ST % path) + + writer.build( + rule=self.task_type, + outputs=output_file, + inputs=input_files, + variables={ + "cmd": cmd, + }) + writer.newline() + + +class CxxStLibAdapter(CStLibAdapter): + task_type = "cxxstlib" # type: str + + +class CxxShLibAdapter(CShLibAdapter): + task_type = "cxxshlib" # type: str + + +def get_subclasses(cls): + subs = set() + for sub in cls.__subclasses__(): + subs.add(sub) + subs.update(get_subclasses(sub)) + return list(subs) + + +Adapters = get_subclasses(TaskAdapter) # type: List[Type[TaskAdapter]] + + +class NinjaContext(Build.BuildContext): + cmd = "ninja" + + def execute(self): + self.restore() + + tasks = [] # type: List[TaskAdapter] + + if not self.all_envs: + self.load_envs() + + self.recurse([self.run_dir]) + self.pre_build() + + def exec_command(self, *k, **kw): + return 0 + + for group in self.groups: + for task_gen in group: + try: + if hasattr(task_gen, "post"): + task_gen.post() + except AttributeError: + pass + + if isinstance(task_gen, Task.Task): + current_tasks = [task_gen] + else: + current_tasks = task_gen.tasks + + for task in current_tasks: + try: + adapter = next(a for a in Adapters if a.task_type == task.__class__.__name__) + except StopIteration: + continue + + if adapter: + try: + tasks.append(adapter(task)) + except ValueError: + continue + + task.nocache = True + + old_exec = task.exec_command + task.exec_command = exec_command + try: + task.run() + except Exception as e: + Logs.error("Error running task {}: {}".format(task, e)) + finally: + task.exec_command = old_exec + + ninja_file_node = self.bldnode.make_node("build.ninja") + + Logs.info("Ninja build commands will be stored in %s", ninja_file_node.abspath()) + + string_buffer = StringIO() + writer = Writer(string_buffer) + + writer.variable(key="ninja_required_version", value="1.5") + writer.newline() + + for a in Adapters: + a.write_rule(writer) + + writer.rule( + "waf_build", + command="python {} build {} {} {} --targets=$tgt".format( + os.path.join(self.top_dir, "scripts", "build-ninja.py"), + self.top_dir, os.path.dirname(self.out_dir), Options.lockfile) + ) + writer.newline() + + writer.rule( + "waf_build_all", + command="python {} build {} {} {}".format(os.path.join(self.top_dir, "scripts", "build-ninja.py"), + self.top_dir, os.path.dirname(self.out_dir), Options.lockfile) + ) + writer.newline() + + writer.rule( + "waf_clean", + command="python {} clean {} {} {}".format(os.path.join(self.top_dir, "scripts", "build-ninja.py"), + self.top_dir, os.path.dirname(self.out_dir), Options.lockfile) + ) + writer.newline() + + for task in tasks: + task.write_build(writer) + + for task in tasks: + task.write_target(writer) + + outputs = [] # type: List[str] + for task in tasks: + outputs += task.get_outputs() + + writer.build( + outputs="all", + rule="phony", + inputs=outputs + ) + writer.newline() + + inputs = [] # type: List[str] + for task in tasks: + inputs += task.get_inputs() + + writer.build( + outputs="all.passthrough", + rule="waf_build_all", + inputs=inputs + ) + writer.newline() + + writer.build(outputs="clean", rule="waf_clean") + writer.newline() + + writer.default("all") + + file_content = string_buffer.getvalue() + + with io.open(ninja_file_node.abspath(), "w", encoding="utf-8") as f: + f.write(file_content) diff --git a/scripts/waifulib/ninja_syntax.py b/scripts/waifulib/ninja_syntax.py new file mode 100644 index 00000000..39300af2 --- /dev/null +++ b/scripts/waifulib/ninja_syntax.py @@ -0,0 +1,235 @@ +#!/usr/bin/python + +# Copyright 2011 Google Inc. All Rights Reserved. +# Copyright 2025 Velaron (edited for Python 2.7 compatibility) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Python module for generating .ninja files. + +Note that this is emphatically not a required piece of Ninja; it's +just a helpful utility for build-file-generation systems that already +use Python. +""" + +import re +import textwrap +from io import TextIOWrapper + +try: + from typing import Dict, List, Optional, Tuple, Union +except ImportError: + pass + + +def escape_path(word): # type: (str) -> str + return word.replace('$ ', '$$ ').replace(' ', '$ ').replace(':', '$:') + + +class Writer(object): + def __init__(self, output, width=78): # type: (TextIOWrapper, int) -> None + self.output = output + self.width = width + + def newline(self): + self.output.write('\n') + + def comment(self, text): # type: (str) -> None + for line in textwrap.wrap(text, self.width - 2, break_long_words=False, + break_on_hyphens=False): + self.output.write('# ' + line + '\n') + + def variable( + self, + key, + value, + indent=0, + ): # type: (str, Optional[Union[bool, int, float, str, List[str]]], int) -> None + if value is None: + return + if isinstance(value, list): + value = ' '.join(filter(None, value)) # Filter out empty strings. + self._line('%s = %s' % (key, value), indent) + + def pool(self, name, depth): # type: (str, int) -> None + self._line('pool %s' % name) + self.variable('depth', depth, indent=1) + + def rule( + self, + name, + command, + description=None, + depfile=None, + generator=False, + pool=None, + restat=False, + rspfile=None, + rspfile_content=None, + deps=None, + ): # type: (str, str, Optional[str], Optional[str], bool, Optional[str], bool, Optional[str], Optional[str], Optional[Union[str, List[str]]]) -> None + self._line('rule %s' % name) + self.variable('command', command, indent=1) + if description: + self.variable('description', description, indent=1) + if depfile: + self.variable('depfile', depfile, indent=1) + if generator: + self.variable('generator', '1', indent=1) + if pool: + self.variable('pool', pool, indent=1) + if restat: + self.variable('restat', '1', indent=1) + if rspfile: + self.variable('rspfile', rspfile, indent=1) + if rspfile_content: + self.variable('rspfile_content', rspfile_content, indent=1) + if deps: + self.variable('deps', deps, indent=1) + + def build( + self, + outputs, + rule, + inputs=None, + implicit=None, + order_only=None, + variables=None, + implicit_outputs=None, + pool=None, + dyndep=None, + ): # type: (Union[str, List[str]], str, Optional[Union[str, List[str]]], Optional[Union[str, List[str]]], Optional[Union[str, List[str]]], Optional[Union[List[Tuple[str, Optional[Union[str, List[str]]]]],Dict[str, Optional[Union[str, List[str]]]],]], Optional[Union[str, List[str]]], Optional[str], Optional[str]) -> List[str] + outputs = as_list(outputs) + out_outputs = [escape_path(x) for x in outputs] + all_inputs = [escape_path(x) for x in as_list(inputs)] + + if implicit: + implicit = [escape_path(x) for x in as_list(implicit)] + all_inputs.append('|') + all_inputs.extend(implicit) + if order_only: + order_only = [escape_path(x) for x in as_list(order_only)] + all_inputs.append('||') + all_inputs.extend(order_only) + if implicit_outputs: + implicit_outputs = [escape_path(x) + for x in as_list(implicit_outputs)] + out_outputs.append('|') + out_outputs.extend(implicit_outputs) + + self._line('build %s: %s' % (' '.join(out_outputs), + ' '.join([rule] + all_inputs))) + if pool is not None: + self._line(' pool = %s' % pool) + if dyndep is not None: + self._line(' dyndep = %s' % dyndep) + + if variables: + if isinstance(variables, dict): + iterator = iter(variables.items()) + else: + iterator = iter(variables) + + for key, val in iterator: + self.variable(key, val, indent=1) + + return outputs + + def include(self, path): # type: (str) -> None + self._line('include %s' % path) + + def subninja(self, path): # type: (str) -> None + self._line('subninja %s' % path) + + def default(self, paths): # type: (Union[str, List[str]]) -> None + self._line('default %s' % ' '.join(as_list(paths))) + + def _count_dollars_before_index(self, s, i): # type: (str, int) -> int + """Returns the number of '$' characters right in front of s[i].""" + dollar_count = 0 + dollar_index = i - 1 + while dollar_index > 0 and s[dollar_index] == '$': + dollar_count += 1 + dollar_index -= 1 + return dollar_count + + def _line(self, text, indent=0): # type: (str, int) -> None + """Write 'text' word-wrapped at self.width characters.""" + leading_space = ' ' * indent + while len(leading_space) + len(text) > self.width: + # The text is too wide; wrap if possible. + + # Find the rightmost space that would obey our width constraint and + # that's not an escaped space. + available_space = self.width - len(leading_space) - len(' $') + space = available_space + while True: + space = text.rfind(' ', 0, space) + if (space < 0 or + self._count_dollars_before_index(text, space) % 2 == 0): + break + + if space < 0: + # No such space; just use the first unescaped space we can find. + space = available_space - 1 + while True: + space = text.find(' ', space + 1) + if (space < 0 or + self._count_dollars_before_index(text, space) % 2 == 0): + break + if space < 0: + # Give up on breaking. + break + + self.output.write(leading_space + text[0:space] + ' $\n') + text = text[space + 1:] + + # Subsequent lines are continuations, so indent them. + leading_space = ' ' * (indent + 2) + + self.output.write(leading_space + text + '\n') + + def close(self): + self.output.close() + + +def as_list(input): # type: (Optional[Union[str, List[str]]]) -> List[str] + if input is None: + return [] + if isinstance(input, list): + return input + return [input] + + +def escape(string): # type: (str) -> str + """Escape a string such that it can be embedded into a Ninja file without + further interpretation.""" + assert '\n' not in string, 'Ninja syntax does not allow newlines' + # We only have one special metacharacter: '$'. + return string.replace('$', '$$') + + +def expand(string, vars, local_vars={}): # type: (str, Dict[str, str], Dict[str, str]) -> str + """Expand a string containing $vars as Ninja would. + + Note: doesn't handle the full Ninja variable syntax, but it's enough + to make configure.py's use of it work. + """ + + def exp(m): # type (Match[str]) -> str: + var = m.group(1) + if var == '$': + return '$' + return local_vars.get(var, vars.get(var, '')) + + return re.sub(r'\$(\$|\w*)', exp, string) diff --git a/scripts/waifulib/xcompile.py b/scripts/waifulib/xcompile.py index 548a64dc..0f5dd4d1 100644 --- a/scripts/waifulib/xcompile.py +++ b/scripts/waifulib/xcompile.py @@ -25,6 +25,7 @@ ANDROID_NDK_HARDFP_MAX = 11 # latest version that supports hardfp ANDROID_NDK_GCC_MAX = 17 # latest NDK that ships with GCC ANDROID_NDK_UNIFIED_SYSROOT_MIN = 15 ANDROID_NDK_SYSROOT_FLAG_MAX = 19 # latest NDK that need --sysroot flag +ANDROID_NDK_BUGGED_LINKER_MAX = 22 ANDROID_NDK_API_MIN = { 10: 3, 19: 16, @@ -354,6 +355,12 @@ class Android: else: linkflags += ['-no-canonical-prefixes'] linkflags += ['-Wl,--hash-style=sysv', '-Wl,--no-undefined'] + + linkflags += ["-Wl,-z,max-page-size=16384"] + + if self.ndk_rev <= ANDROID_NDK_BUGGED_LINKER_MAX: + linkflags += ["-Wl,-z,common-page-size=16384"] + return linkflags def ldflags(self): diff --git a/wscript b/wscript index cb8e2552..8b4be6f5 100644 --- a/wscript +++ b/wscript @@ -128,7 +128,7 @@ REFDLLS = [ ] def options(opt): - opt.load('reconfigure compiler_optimizations xshlib xcompile compiler_cxx compiler_c sdl2 clang_compilation_database strip_on_install waf_unit_test msvs subproject') + opt.load('reconfigure compiler_optimizations xshlib xcompile compiler_cxx compiler_c sdl2 clang_compilation_database strip_on_install waf_unit_test msvs subproject ninja') grp = opt.add_option_group('Common options') @@ -226,7 +226,7 @@ def configure(conf): if conf.env.COMPILER_CC == 'msvc': conf.load('msvc_pdb') - conf.load('msvs subproject clang_compilation_database strip_on_install waf_unit_test enforce_pic force_32bit') + conf.load('msvs subproject clang_compilation_database strip_on_install waf_unit_test enforce_pic force_32bit ninja') conf.env.MSVC_SUBSYSTEM = 'WINDOWS' conf.env.CONSOLE_SUBSYSTEM = 'CONSOLE'