android: experimental ninja build

This commit is contained in:
Bohdan Shulyar
2025-07-02 14:43:36 +03:00
committed by a1batross
parent bb811310d2
commit ca6de641a1
14 changed files with 815 additions and 110 deletions

79
scripts/build-ninja.py Normal file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env python
# encoding: utf-8
# Copyright (C) 2025 Velaron
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
import argparse
import os
import shutil
import subprocess
import sys
def run_cmake(path, libs, out):
cmake_exec = ["cmake", "--build", path]
cmake_process = subprocess.Popen(cmake_exec)
cmake_process.communicate()
for lib in libs:
src = os.path.join(path, *lib.split("/"))
dest = os.path.join(out, lib.split("/")[-1])
dest_dir = os.path.dirname(dest)
if not os.path.exists(dest_dir):
os.makedirs(dest_dir)
shutil.copyfile(src, dest)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("cmd")
parser.add_argument("top_dir")
parser.add_argument("out_dir")
parser.add_argument("waflock")
parser.add_argument("--targets", type=str, default="")
args = parser.parse_args()
waf_path = os.path.join(args.top_dir, "waf")
env = os.environ.copy()
env["WAFLOCK"] = args.waflock
waf_exec = [sys.executable, waf_path, args.cmd, "-t", args.top_dir]
if args.targets:
waf_exec += ["--targets={}".format(args.targets)]
else:
# build SDL2 and hlsdk-portable with cmake
sdl_out_path = os.path.join(args.out_dir, "SDL")
hlsdk_out_path = os.path.join(args.out_dir, "hlsdk-portable")
abi = args.waflock.replace(".lock-waf_android_", "").replace("_build", "")
dest_dir = os.path.join(args.top_dir, "android", "app", "src", "main", "jniLibs", abi)
if not os.path.exists(dest_dir):
os.makedirs(dest_dir)
run_cmake(sdl_out_path, ["libSDL2.so"], dest_dir)
run_cmake(hlsdk_out_path, ["cl_dll/libclient.so", "dlls/libserver.so"], dest_dir)
process = subprocess.Popen(waf_exec, env=env)
process.communicate()
sys.exit(0)
if __name__ == "__main__":
main()

108
scripts/configure-ninja.py Executable file
View File

@@ -0,0 +1,108 @@
#!/usr/bin/env python
# encoding: utf-8
# Copyright (C) 2025 Velaron
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
from __future__ import print_function
import argparse
import os
import subprocess
import sys
import io
def check_repo(name, branch, url, path):
if not os.path.exists(path):
print("{} not found. Cloning...".format(name))
git_exec = ["git", "clone", "--branch", branch, url, path]
git_process = subprocess.Popen(git_exec)
git_process.communicate()
def run_cmake(root, out, toolchain, abi, build_type, ndk_root, min_sdk, *args):
cmake_exec = ["cmake", "-H{}".format(root), "-DCMAKE_BUILD_TYPE={}".format(build_type),
"-DCMAKE_TOOLCHAIN_FILE={}".format(toolchain), "-DANDROID_ABI={}".format(abi),
"-DANDROID_NDK={}".format(ndk_root),
"-DANDROID_PLATFORM=android-{}".format(min_sdk),
"-DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
"-DCMAKE_SYSTEM_NAME=Android", "-DCMAKE_SYSTEM_VERSION={}".format(min_sdk),
"-B{}".format(out), "-GNinja"]
cmake_exec.extend(args)
cmake_process = subprocess.Popen(cmake_exec)
cmake_process.communicate()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("wscript_path")
parser.add_argument("--variant")
parser.add_argument("--abi")
parser.add_argument("--configuration-dir")
parser.add_argument("--ndk-version")
parser.add_argument("--min-sdk-version")
parser.add_argument("--ndk-root")
args, unknown = parser.parse_known_args()
abi = args.abi[8:]
cmake_build_type = "Debug" if args.variant in ["debug", "asan"] else "Release"
cmake_toolchain_path = os.path.join(args.ndk_root, "build", "cmake", "android.toolchain.cmake")
# configure SDL2
sdl_path = os.path.join(args.wscript_path, "3rdparty", "SDL")
check_repo("SDL", "release-2.32.8", "https://github.com/libsdl-org/SDL", sdl_path)
sdl_out_path = os.path.join(args.configuration_dir, "SDL")
run_cmake(sdl_path, sdl_out_path, cmake_toolchain_path, abi, cmake_build_type, args.ndk_root, args.min_sdk_version,
"-DSDL_RENDER=OFF", "-DSDL_POWER=OFF", "-DSDL_VULKAN=OFF", "-DSDL_DISKAUDIO=OFF",
"-DSDL_DUMMYAUDIO=OFF", "-DSDL_DUMMYVIDEO=OFF",
"-DSDL_VULKAN=OFF", "-DSDL_OFFSCREEN=OFF", "-DSDL_STATIC=OFF")
# configure hlsdk-portable
hlsdk_path = os.path.join(args.wscript_path, "3rdparty", "hlsdk-portable")
check_repo("hlsdk-portable", "mobile_hacks", "https://github.com/FWGS/hlsdk-portable", hlsdk_path)
hlsdk_out_path = os.path.join(args.configuration_dir, "hlsdk-portable")
run_cmake(hlsdk_path, hlsdk_out_path, cmake_toolchain_path, abi, cmake_build_type, args.ndk_root,
args.min_sdk_version)
# waf configure
waf_path = os.path.join(args.wscript_path, "waf")
out_path = os.path.join(args.configuration_dir, "xash3d-fwgs")
waf_build_type = "debug" if args.variant in ["debug", "asan"] else "release"
env = os.environ.copy()
env["WAFLOCK"] = ".lock-waf_android_{}_build".format(abi)
env["ANDROID_NDK"] = args.ndk_root
env["BUILD_CMAKE_LIBRARY_OUTPUT_DIRECTORY"] = sdl_out_path
waf_exec = [sys.executable, waf_path, "configure", "-t", args.wscript_path, "-o", out_path,
"-T", waf_build_type, "--android={},,{}".format(abi, args.min_sdk_version), "-s",
sdl_path, "--skip-sdl2-sanity-check", "--enable-bundled-deps", "ninja"]
process = subprocess.Popen(waf_exec, env=env)
process.communicate()
with io.open(os.path.join(args.configuration_dir, "build.ninja.txt"), "w", encoding="utf-8") as f:
f.write(os.path.join(out_path, "build.ninja"))
# required for Android Studio
sys.exit(0)
if __name__ == "__main__":
main()

340
scripts/waifulib/ninja.py Normal file
View File

@@ -0,0 +1,340 @@
#!/usr/bin/env python
# encoding: utf-8
# Copyright (C) 2025 Velaron
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
import io
import os
import abc
try:
from cStringIO import StringIO
except ImportError:
from io import StringIO
from abc import abstractmethod
try:
from typing import List, Type, Union
except ImportError:
pass
from waflib import Logs, Task, Build, Options, Node
from ninja_syntax import Writer, escape_path
try:
ABC = abc.ABC
except AttributeError:
class ABC(object):
__metaclass__ = abc.ABCMeta
Task.Task.keep_last_cmd = True
def get_node_path(node): # type: (Union[Node, str]) -> 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)

View File

@@ -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)

View File

@@ -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):