build-windows.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. #!/usr/bin/env python3
  2. """
  3. Standard-of-Iron — Windows Build Script
  4. Verifies dependencies, sets up MSVC environment, and builds the project.
  5. This script automates the Windows build process by:
  6. 1. Checking for required tools (CMake, Ninja, MSVC, Qt)
  7. 2. Guiding installation of missing dependencies
  8. 3. Configuring and building the project with proper MSVC setup
  9. 4. Deploying Qt dependencies with runtime libraries
  10. 5. Writing qt.conf and diagnostic scripts (run_debug.cmd, run_debug_softwaregl.cmd)
  11. 6. Copying GL/ANGLE fallback DLLs for graphics compatibility
  12. 7. Copying assets and creating a distributable package
  13. Usage:
  14. python scripts/build-windows.py # Full build with checks
  15. python scripts/build-windows.py --skip-checks # Skip dependency checks
  16. python scripts/build-windows.py --build-type Debug # Build in Debug mode
  17. python scripts/build-windows.py --clean # Clean build directory first
  18. python scripts/build-windows.py --deploy-only # Only deploy Qt (assumes built)
  19. python scripts/build-windows.py --help # Show this help
  20. Requirements:
  21. - Python 3.7+
  22. - CMake 3.21+
  23. - Ninja build system
  24. - Visual Studio 2019/2022 with C++ tools
  25. - Qt 6.6.3 or compatible (with msvc2019_64 or msvc2022_64)
  26. """
  27. import argparse
  28. import os
  29. import platform
  30. import re
  31. import shutil
  32. import subprocess
  33. import sys
  34. from pathlib import Path
  35. from typing import Optional, Tuple
  36. # ANSI color codes for better output
  37. class Color:
  38. BLUE = '\033[1;34m'
  39. GREEN = '\033[1;32m'
  40. YELLOW = '\033[1;33m'
  41. RED = '\033[1;31m'
  42. RESET = '\033[0m'
  43. BOLD = '\033[1m'
  44. def info(msg: str) -> None:
  45. """Print info message."""
  46. print(f"{Color.BLUE}[i]{Color.RESET} {msg}")
  47. def success(msg: str) -> None:
  48. """Print success message."""
  49. print(f"{Color.GREEN}[✓]{Color.RESET} {msg}")
  50. def warning(msg: str) -> None:
  51. """Print warning message."""
  52. print(f"{Color.YELLOW}[!]{Color.RESET} {msg}")
  53. def error(msg: str) -> None:
  54. """Print error message."""
  55. print(f"{Color.RED}[✗]{Color.RESET} {msg}")
  56. def run_command(cmd: list, capture_output: bool = False, check: bool = True,
  57. env: Optional[dict] = None) -> subprocess.CompletedProcess:
  58. """Run a command and optionally capture output."""
  59. try:
  60. result = subprocess.run(
  61. cmd,
  62. capture_output=capture_output,
  63. text=True,
  64. check=check,
  65. env=env or os.environ.copy()
  66. )
  67. return result
  68. except subprocess.CalledProcessError as e:
  69. if check:
  70. error(f"Command failed: {' '.join(cmd)}")
  71. if e.stdout:
  72. print(e.stdout)
  73. if e.stderr:
  74. print(e.stderr, file=sys.stderr)
  75. raise
  76. return e
  77. def check_windows() -> None:
  78. """Verify we're running on Windows."""
  79. if platform.system() != 'Windows':
  80. error("This script is designed for Windows only.")
  81. error(f"Detected OS: {platform.system()}")
  82. sys.exit(1)
  83. success("Running on Windows")
  84. def check_cmake() -> Tuple[bool, Optional[str]]:
  85. """Check if CMake is installed and get version."""
  86. try:
  87. result = run_command(['cmake', '--version'], capture_output=True)
  88. version_match = re.search(r'cmake version (\d+\.\d+\.\d+)', result.stdout)
  89. if version_match:
  90. version = version_match.group(1)
  91. major, minor, _ = map(int, version.split('.'))
  92. if major > 3 or (major == 3 and minor >= 21):
  93. success(f"CMake {version} found")
  94. return True, version
  95. else:
  96. warning(f"CMake {version} found but version 3.21+ required")
  97. return False, version
  98. except (subprocess.CalledProcessError, FileNotFoundError):
  99. pass
  100. return False, None
  101. def check_ninja() -> bool:
  102. """Check if Ninja is installed."""
  103. try:
  104. result = run_command(['ninja', '--version'], capture_output=True)
  105. version = result.stdout.strip()
  106. success(f"Ninja {version} found")
  107. return True
  108. except (subprocess.CalledProcessError, FileNotFoundError):
  109. return False
  110. def find_vswhere() -> Optional[Path]:
  111. """Find vswhere.exe to locate Visual Studio."""
  112. vswhere_path = Path(r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe")
  113. if vswhere_path.exists():
  114. return vswhere_path
  115. return None
  116. def check_msvc() -> Tuple[bool, Optional[str]]:
  117. """Check if MSVC is installed."""
  118. vswhere = find_vswhere()
  119. if not vswhere:
  120. return False, None
  121. try:
  122. result = run_command([
  123. str(vswhere),
  124. '-latest',
  125. '-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64',
  126. '-property', 'installationVersion'
  127. ], capture_output=True)
  128. version = result.stdout.strip()
  129. if version:
  130. success(f"Visual Studio {version} with C++ tools found")
  131. return True, version
  132. except (subprocess.CalledProcessError, FileNotFoundError):
  133. pass
  134. return False, None
  135. def find_qt() -> Optional[Path]:
  136. """Try to find Qt installation."""
  137. # Common Qt installation locations
  138. possible_paths = [
  139. Path(r"C:\Qt"),
  140. Path.home() / "Qt",
  141. Path(os.environ.get('QT_ROOT', '')) if os.environ.get('QT_ROOT') else None,
  142. ]
  143. for base_path in possible_paths:
  144. if base_path and base_path.exists():
  145. # Look for Qt 6.x with msvc2019_64 or msvc2022_64
  146. for qt_version_dir in base_path.glob("6.*"):
  147. for arch_dir in qt_version_dir.glob("msvc*_64"):
  148. qmake = arch_dir / "bin" / "qmake.exe"
  149. if qmake.exists():
  150. return arch_dir
  151. return None
  152. def check_qt() -> Tuple[bool, Optional[Path], Optional[str]]:
  153. """Check if Qt is installed."""
  154. qt_path = find_qt()
  155. if qt_path:
  156. qmake = qt_path / "bin" / "qmake.exe"
  157. try:
  158. result = run_command([str(qmake), '-query', 'QT_VERSION'], capture_output=True)
  159. version = result.stdout.strip()
  160. success(f"Qt {version} found at {qt_path}")
  161. return True, qt_path, version
  162. except (subprocess.CalledProcessError, FileNotFoundError):
  163. pass
  164. return False, None, None
  165. def setup_msvc_environment() -> dict:
  166. """Set up MSVC environment variables."""
  167. info("Setting up MSVC environment...")
  168. vswhere = find_vswhere()
  169. if not vswhere:
  170. error("Cannot find vswhere.exe")
  171. sys.exit(1)
  172. # Find Visual Studio installation path
  173. result = run_command([
  174. str(vswhere),
  175. '-latest',
  176. '-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64',
  177. '-property', 'installationPath'
  178. ], capture_output=True)
  179. vs_path = Path(result.stdout.strip())
  180. if not vs_path.exists():
  181. error("Cannot find Visual Studio installation")
  182. sys.exit(1)
  183. # Find vcvarsall.bat
  184. vcvarsall = vs_path / "VC" / "Auxiliary" / "Build" / "vcvarsall.bat"
  185. if not vcvarsall.exists():
  186. error(f"Cannot find vcvarsall.bat at {vcvarsall}")
  187. sys.exit(1)
  188. # Run vcvarsall and capture environment
  189. info(f"Running vcvarsall.bat from {vs_path}")
  190. cmd = f'"{vcvarsall}" x64 && set'
  191. result = subprocess.run(
  192. cmd,
  193. shell=True,
  194. capture_output=True,
  195. text=True
  196. )
  197. # Parse environment variables
  198. env = os.environ.copy()
  199. for line in result.stdout.split('\n'):
  200. if '=' in line:
  201. key, _, value = line.partition('=')
  202. env[key] = value.strip()
  203. success("MSVC environment configured")
  204. return env
  205. def print_installation_guide() -> None:
  206. """Print guide for installing missing dependencies."""
  207. print(f"\n{Color.BOLD}Installation Guide:{Color.RESET}\n")
  208. print(f"{Color.BOLD}1. CMake:{Color.RESET}")
  209. print(" Download from: https://cmake.org/download/")
  210. print(" Or use: winget install Kitware.CMake\n")
  211. print(f"{Color.BOLD}2. Ninja:{Color.RESET}")
  212. print(" Download from: https://github.com/ninja-build/ninja/releases")
  213. print(" Or use: winget install Ninja-build.Ninja")
  214. print(" Or use: choco install ninja\n")
  215. print(f"{Color.BOLD}3. Visual Studio:{Color.RESET}")
  216. print(" Download: https://visualstudio.microsoft.com/downloads/")
  217. print(" Install 'Desktop development with C++' workload")
  218. print(" Or use: winget install Microsoft.VisualStudio.2022.Community")
  219. print(" --override '--add Microsoft.VisualStudio.Workload.NativeDesktop'\n")
  220. print(f"{Color.BOLD}4. Qt 6.6.3:{Color.RESET}")
  221. print(" Download: https://www.qt.io/download-open-source")
  222. print(" Install Qt 6.6.3 with MSVC 2019 64-bit component")
  223. print(" Required modules: qt5compat, qtmultimedia")
  224. print(" Or set QT_ROOT environment variable to your Qt installation\n")
  225. def check_dependencies() -> Tuple[bool, Optional[Path]]:
  226. """Check all required dependencies."""
  227. info("Checking dependencies...")
  228. print()
  229. all_ok = True
  230. qt_path = None
  231. cmake_ok, _ = check_cmake()
  232. if not cmake_ok:
  233. error("CMake 3.21+ not found")
  234. all_ok = False
  235. ninja_ok = check_ninja()
  236. if not ninja_ok:
  237. error("Ninja not found")
  238. all_ok = False
  239. msvc_ok, _ = check_msvc()
  240. if not msvc_ok:
  241. error("Visual Studio with C++ tools not found")
  242. all_ok = False
  243. qt_ok, qt_path, _ = check_qt()
  244. if not qt_ok:
  245. error("Qt 6.x with MSVC not found")
  246. all_ok = False
  247. print()
  248. if not all_ok:
  249. error("Some dependencies are missing!")
  250. print_installation_guide()
  251. return False, None
  252. success("All dependencies found!")
  253. return True, qt_path
  254. def configure_project(build_dir: Path, build_type: str, qt_path: Optional[Path],
  255. msvc_env: dict) -> None:
  256. """Configure the project with CMake."""
  257. info(f"Configuring project (Build type: {build_type})...")
  258. cmake_args = [
  259. 'cmake',
  260. '-S', '.',
  261. '-B', str(build_dir),
  262. '-G', 'Ninja',
  263. f'-DCMAKE_BUILD_TYPE={build_type}',
  264. '-DDEFAULT_LANG=en'
  265. ]
  266. if qt_path:
  267. cmake_args.append(f'-DCMAKE_PREFIX_PATH={qt_path}')
  268. run_command(cmake_args, env=msvc_env)
  269. success("Project configured")
  270. def build_project(build_dir: Path, msvc_env: dict) -> None:
  271. """Build the project."""
  272. info("Building project...")
  273. run_command(['cmake', '--build', str(build_dir)], env=msvc_env)
  274. success("Project built successfully")
  275. def deploy_qt(build_dir: Path, qt_path: Path, app_name: str, build_type: str) -> None:
  276. """Deploy Qt dependencies."""
  277. info("Deploying Qt dependencies...")
  278. app_dir = build_dir / "bin"
  279. exe_path = app_dir / f"{app_name}.exe"
  280. if not exe_path.exists():
  281. error(f"Executable not found: {exe_path}")
  282. sys.exit(1)
  283. windeployqt = qt_path / "bin" / "windeployqt.exe"
  284. if not windeployqt.exists():
  285. error(f"windeployqt not found at {windeployqt}")
  286. sys.exit(1)
  287. qml_dir = Path("ui/qml")
  288. # Map build type to windeployqt mode flag
  289. mode_flag = {
  290. "Debug": "--debug",
  291. "Release": "--release",
  292. "RelWithDebInfo": "--release", # release DLLs + PDBs
  293. }[build_type]
  294. run_command([
  295. str(windeployqt),
  296. mode_flag,
  297. '--compiler-runtime', # ship VC++ runtime DLLs
  298. '--qmldir', str(qml_dir),
  299. str(exe_path)
  300. ])
  301. success("Qt dependencies deployed")
  302. def write_qt_conf(app_dir: Path) -> None:
  303. """Write qt.conf to configure Qt plugin paths."""
  304. info("Writing qt.conf...")
  305. qt_conf_content = """[Paths]
  306. Plugins = .
  307. Imports = qml
  308. Qml2Imports = qml
  309. Translations = translations
  310. """
  311. qt_conf_path = app_dir / "qt.conf"
  312. qt_conf_path.write_text(qt_conf_content, encoding='ascii')
  313. success("qt.conf written")
  314. def write_debug_scripts(app_dir: Path, app_name: str) -> None:
  315. """Write diagnostic scripts for troubleshooting."""
  316. info("Writing diagnostic scripts...")
  317. # run_debug.cmd
  318. run_debug_content = """@echo off
  319. setlocal
  320. cd /d "%~dp0"
  321. set QT_DEBUG_PLUGINS=1
  322. set QT_LOGGING_RULES=qt.*=true;qt.qml=true;qqml.*=true
  323. set QT_QPA_PLATFORM=windows
  324. "%~dp0{app_name}.exe" 1> "%~dp0runlog.txt" 2>&1
  325. echo ExitCode: %ERRORLEVEL%>> "%~dp0runlog.txt"
  326. pause
  327. """.format(app_name=app_name)
  328. run_debug_path = app_dir / "run_debug.cmd"
  329. run_debug_path.write_text(run_debug_content, encoding='ascii')
  330. # run_debug_softwaregl.cmd
  331. run_debug_softwaregl_content = """@echo off
  332. setlocal
  333. cd /d "%~dp0"
  334. set QT_DEBUG_PLUGINS=1
  335. set QT_LOGGING_RULES=qt.*=true;qt.qml=true;qqml.*=true;qt.quick.*=true
  336. set QT_OPENGL=software
  337. set QT_QPA_PLATFORM=windows
  338. "%~dp0{app_name}.exe" 1> "%~dp0runlog.txt" 2>&1
  339. echo ExitCode: %ERRORLEVEL%>> "%~dp0runlog.txt"
  340. pause
  341. """.format(app_name=app_name)
  342. run_debug_softwaregl_path = app_dir / "run_debug_softwaregl.cmd"
  343. run_debug_softwaregl_path.write_text(run_debug_softwaregl_content, encoding='ascii')
  344. success(f"Diagnostic scripts written: run_debug.cmd, run_debug_softwaregl.cmd")
  345. def copy_gl_angle_fallbacks(app_dir: Path, qt_path: Path) -> None:
  346. """Copy GL/ANGLE fallback DLLs for graphics compatibility."""
  347. info("Copying GL/ANGLE fallback DLLs...")
  348. qt_bin = qt_path / "bin"
  349. fallback_dlls = [
  350. "d3dcompiler_47.dll",
  351. "opengl32sw.dll",
  352. "libEGL.dll",
  353. "libGLESv2.dll"
  354. ]
  355. copied_count = 0
  356. for dll_name in fallback_dlls:
  357. src = qt_bin / dll_name
  358. if src.exists():
  359. dst = app_dir / dll_name
  360. shutil.copy2(src, dst)
  361. info(f" Copied {dll_name}")
  362. copied_count += 1
  363. else:
  364. warning(f" {dll_name} not found in Qt bin directory")
  365. if copied_count > 0:
  366. success(f"Copied {copied_count} GL/ANGLE fallback DLL(s)")
  367. else:
  368. warning("No GL/ANGLE fallback DLLs found")
  369. def copy_assets(build_dir: Path) -> None:
  370. """Copy assets to build directory."""
  371. info("Copying assets...")
  372. app_dir = build_dir / "bin"
  373. assets_src = Path("assets")
  374. assets_dst = app_dir / "assets"
  375. if assets_dst.exists():
  376. shutil.rmtree(assets_dst)
  377. shutil.copytree(assets_src, assets_dst)
  378. success("Assets copied")
  379. def create_package(build_dir: Path, build_type: str) -> Path:
  380. """Create distributable package."""
  381. info("Creating distributable package...")
  382. app_dir = build_dir / "bin"
  383. package_name = f"standard_of_iron-win-x64-{build_type}.zip"
  384. package_path = Path(package_name)
  385. if package_path.exists():
  386. package_path.unlink()
  387. shutil.make_archive(
  388. package_path.stem,
  389. 'zip',
  390. app_dir
  391. )
  392. success(f"Package created: {package_path}")
  393. return package_path
  394. def main() -> int:
  395. """Main entry point."""
  396. parser = argparse.ArgumentParser(
  397. description="Build Standard-of-Iron on Windows",
  398. formatter_class=argparse.RawDescriptionHelpFormatter,
  399. epilog=__doc__
  400. )
  401. parser.add_argument(
  402. '--skip-checks',
  403. action='store_true',
  404. help='Skip dependency checks'
  405. )
  406. parser.add_argument(
  407. '--build-type',
  408. choices=['Debug', 'Release', 'RelWithDebInfo'],
  409. default='Release',
  410. help='CMake build type (default: Release)'
  411. )
  412. parser.add_argument(
  413. '--clean',
  414. action='store_true',
  415. help='Clean build directory before building'
  416. )
  417. parser.add_argument(
  418. '--deploy-only',
  419. action='store_true',
  420. help='Only deploy Qt dependencies (skip build)'
  421. )
  422. parser.add_argument(
  423. '--no-package',
  424. action='store_true',
  425. help='Skip creating distributable package'
  426. )
  427. args = parser.parse_args()
  428. # Check we're on Windows
  429. check_windows()
  430. # Repository root
  431. repo_root = Path(__file__).parent.parent
  432. os.chdir(repo_root)
  433. build_dir = Path("build")
  434. qt_path = None
  435. # Check dependencies unless skipped
  436. if not args.skip_checks and not args.deploy_only:
  437. deps_ok, qt_path = check_dependencies()
  438. if not deps_ok:
  439. return 1
  440. else:
  441. # Try to find Qt even if checks are skipped
  442. _, qt_path, _ = check_qt()
  443. # Clean build directory if requested
  444. if args.clean and build_dir.exists():
  445. info("Cleaning build directory...")
  446. shutil.rmtree(build_dir)
  447. success("Build directory cleaned")
  448. # Set up MSVC environment
  449. if not args.deploy_only:
  450. msvc_env = setup_msvc_environment()
  451. # Configure
  452. configure_project(build_dir, args.build_type, qt_path, msvc_env)
  453. # Build
  454. build_project(build_dir, msvc_env)
  455. # Deploy Qt
  456. if qt_path:
  457. deploy_qt(build_dir, qt_path, "standard_of_iron", args.build_type)
  458. # Write qt.conf
  459. app_dir = build_dir / "bin"
  460. write_qt_conf(app_dir)
  461. # Write diagnostic scripts
  462. write_debug_scripts(app_dir, "standard_of_iron")
  463. # Copy GL/ANGLE fallbacks
  464. copy_gl_angle_fallbacks(app_dir, qt_path)
  465. else:
  466. warning("Qt path not found, skipping windeployqt")
  467. # Copy assets
  468. copy_assets(build_dir)
  469. # Create package
  470. if not args.no_package:
  471. package_path = create_package(build_dir, args.build_type)
  472. print()
  473. info(f"Build complete! Package available at: {package_path}")
  474. info(f"Executable location: {build_dir / 'bin' / 'standard_of_iron.exe'}")
  475. else:
  476. print()
  477. info(f"Build complete!")
  478. info(f"Executable location: {build_dir / 'bin' / 'standard_of_iron.exe'}")
  479. return 0
  480. if __name__ == '__main__':
  481. try:
  482. sys.exit(main())
  483. except KeyboardInterrupt:
  484. print()
  485. warning("Build interrupted by user")
  486. sys.exit(130)
  487. except Exception as e:
  488. error(f"Unexpected error: {e}")
  489. import traceback
  490. traceback.print_exc()
  491. sys.exit(1)