diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..c5f8b70f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# Only the engine source is needed for the builder stage. +# Exclude everything by default, then selectively re-include. +* +!engine/ +!engine/** +# Re-exclude build output directories that live under engine/. +engine/target/ +engine/target_games/ +engine/target_projects/ +engine/pill_launcher/target/ + diff --git a/.github/actions/linux-deps/action.yml b/.github/actions/linux-deps/action.yml deleted file mode 100644 index 0cbbf787..00000000 --- a/.github/actions/linux-deps/action.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Install Linux deps -description: Install native Linux packages required to build Pill-Engine examples -runs: - using: "composite" - steps: - - shell: bash - run: | - sudo apt-get update - sudo apt-get install -y pkg-config libasound2-dev libudev-dev plantuml mold - - shell: bash - run: | - SLANG_VERSION=2026.8.1 - curl -sL "https://github.com/shader-slang/slang/releases/download/v${SLANG_VERSION}/slang-${SLANG_VERSION}-linux-x86_64-glibc-2.28.tar.gz" \ - | sudo tar -xz -C /usr/local - sudo ldconfig - slangc -v diff --git a/.github/workflows/ci-basic-tests.yml b/.github/workflows/ci-basic-tests.yml new file mode 100644 index 00000000..6d12bc82 --- /dev/null +++ b/.github/workflows/ci-basic-tests.yml @@ -0,0 +1,103 @@ +# Pill CI Pipeline - fast checks on every push +# +# Triggers on every push. All jobs run in the pre-built pill-ci container +# (which includes PillLauncher, Rust toolchain, and WASM tooling), fanning out +# to 5 parallel checks: code formatting, linting, native build + artifact size, +# WASM build + artifact size + budget, performance benchmark. + +name: CI Basic Tests + +on: + push: + paths-ignore: + # Avoid racing ci-build-image.yml β€” when the Dockerfile changes, + # this workflow is triggered by workflow_run instead, after the + # new image is built and pushed. + - "devops/tests/Dockerfile" + - ".github/workflows/ci-build-image.yml" + workflow_run: + workflows: [CI Build Docker Image] + types: [completed] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + packages: read + +env: + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang + CARGO_TERM_COLOR: always + +jobs: + # ------------------------------------------------------------------ + # 1. code_formatting_check + # ------------------------------------------------------------------ + code_formatting_check: + runs-on: ubuntu-latest + container: ghcr.io/mattszymonski/pill-ci:latest + steps: + - uses: actions/checkout@v4 + - name: rustfmt check + run: bash devops/tests/run_basic_tests.sh code_formatting_check + + # ------------------------------------------------------------------ + # 2. code_linting_check + # ------------------------------------------------------------------ + code_linting_check: + runs-on: ubuntu-latest + container: ghcr.io/mattszymonski/pill-ci:latest + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + with: { workspaces: "engine -> target" } + - name: clippy lint + run: bash devops/tests/run_basic_tests.sh code_linting_check + + # ------------------------------------------------------------------ + # 3. build_native_cube_example + # ------------------------------------------------------------------ + build_native_cube_example: + runs-on: ubuntu-latest + container: ghcr.io/mattszymonski/pill-ci:latest + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + engine -> target + examples/cube -> target + - name: Build cube example + run: bash devops/tests/run_basic_tests.sh build_native_cube_example + + # ------------------------------------------------------------------ + # 4. build_wasm_cube_example + # ------------------------------------------------------------------ + build_wasm_cube_example: + runs-on: ubuntu-latest + container: ghcr.io/mattszymonski/pill-ci:latest + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + examples/cube/build/wasm/.build/pill_web -> target + - name: WASM build + run: bash devops/tests/run_basic_tests.sh build_wasm_cube_example + + # ------------------------------------------------------------------ + # 5. benchmark_native_performance + # ------------------------------------------------------------------ + benchmark_performance: + runs-on: ubuntu-latest + container: ghcr.io/mattszymonski/pill-ci:latest + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + engine -> target + examples/city -> target + - name: City performance benchmark (headless, 3 runs) + run: bash devops/tests/run_basic_tests.sh benchmark_native_performance diff --git a/.github/workflows/ci-build-image.yml b/.github/workflows/ci-build-image.yml new file mode 100644 index 00000000..14dc33bf --- /dev/null +++ b/.github/workflows/ci-build-image.yml @@ -0,0 +1,49 @@ +# Pill CI Pipeline - Preparation +# Build Pill CI Docker image and push it to GitHub Container Registry (GHCR). + +# Only runs when the Dockerfile, the PillLauncher source code, or this workflow changes. +# All other CI workflows pull this pre-built image for +# minimalizing action environment setup time. + +name: CI Build Docker Image + +on: + push: + paths: + - "devops/tests/Dockerfile" + - ".github/workflows/ci-build-image.yml" + - "engine/pill_launcher/**" + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/pill-ci + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/metadata-action@v5 + id: meta + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=sha,prefix= + type=ref,event=branch + - uses: docker/build-push-action@v6 + with: + context: . + file: devops/tests/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/ci-examples-tests.yml b/.github/workflows/ci-examples-tests.yml new file mode 100644 index 00000000..3f4d6305 --- /dev/null +++ b/.github/workflows/ci-examples-tests.yml @@ -0,0 +1,72 @@ +# Pill CI Pipeline - example builds (all native + standalone) +# +# Builds every example project in the repo (native + standalone crates). +# Native examples run in the pre-built pill-ci container (which includes +# PillLauncher, Rust toolchain, and WASM tooling). WASM builds are covered +# by ci-basic-tests.yml. +# Runs on daily schedule and manual trigger. + +name: CI Compile Examples + +on: + schedule: + # Every day at 03:30 UTC (staggered after ci-basic daily) + - cron: "30 3 * * *" + workflow_dispatch: + +permissions: + packages: read + +env: + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang + CARGO_TERM_COLOR: always + +jobs: + # ------------------------------------------------------------------ + # Native example builds (matrix - each example in parallel) + # ------------------------------------------------------------------ + build_native_examples: + runs-on: ubuntu-latest + container: ghcr.io/mattszymonski/pill-ci:latest + strategy: + fail-fast: false + matrix: + example: + - examples/cube + - examples/floating_pills + - examples/italian_brainrot + - examples/city + - examples/empty + - examples/pill_tunel + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + engine -> target + ${{ matrix.example }} -> target + - name: Build ${{ matrix.example }} + run: bash devops/tests/run_examples_tests.sh "${{ matrix.example }}" + + # ------------------------------------------------------------------ + # Standalone crates (no PillLauncher needed) + # ------------------------------------------------------------------ + build_standalone: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + crate: + - examples/net_minimal/client + - examples/net_minimal/server + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/linux-deps + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: { toolchain: stable } + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + ${{ matrix.crate }} -> target + - name: Build ${{ matrix.crate }} + run: cargo build --manifest-path ${{ matrix.crate }}/Cargo.toml diff --git a/.github/workflows/ci-pill_launcher-tests.yml b/.github/workflows/ci-pill_launcher-tests.yml new file mode 100644 index 00000000..1fd2da75 --- /dev/null +++ b/.github/workflows/ci-pill_launcher-tests.yml @@ -0,0 +1,92 @@ +# Pill CI Pipeline - launcher action validation +# +# Quick suite: smoke tests (basics, create, cargo, assets). +# Full suite: heavier actions (build, run, docs). +# Runs on daily schedule and manual trigger. + +name: CI Pill Launcher Tests + +on: + schedule: + # Every day at 03:30 UTC (staggered after ci-basic daily) + - cron: "30 3 * * *" + workflow_dispatch: + +permissions: + packages: read + +env: + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang + CARGO_TERM_COLOR: always + +jobs: + # ------------------------------------------------------------------ + # Build the launcher once, upload as artifact for all downstream jobs + # ------------------------------------------------------------------ + build_launcher: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + engine -> target + - name: Build PillLauncher (release) + run: cargo build --release --manifest-path engine/pill_launcher/Cargo.toml + - name: Upload PillLauncher artifact + uses: actions/upload-artifact@v4 + with: + name: pilllauncher-linux + path: engine/pill_launcher/target/release/PillLauncher + + # ------------------------------------------------------------------ + # Quick suite - basic smoke tests + cube build + # ------------------------------------------------------------------ + quick_suite: + runs-on: ubuntu-latest + needs: build_launcher + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + engine -> target + examples/cube -> target + - uses: actions/download-artifact@v4 + with: + { + name: pilllauncher-linux, + path: engine/pill_launcher/target/release, + } + - name: Quick suite (smoke tests) + run: bash devops/tests/run_pill_launcher_tests.sh basics create cargo assets + + # ------------------------------------------------------------------ + # Full suite - launcher build + run + docs tests + # ------------------------------------------------------------------ + full_suite: + runs-on: ubuntu-latest + container: ghcr.io/mattszymonski/pill-ci:latest + needs: build_launcher + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + engine -> target + examples/cube -> target + examples/city -> target + examples/floating_pills -> target + examples/italian_brainrot -> target + examples/cube/build/wasm/.build/pill_web -> target + - uses: actions/download-artifact@v4 + with: + { + name: pilllauncher-linux, + path: engine/pill_launcher/target/release, + } + - name: Full suite (all launcher action tests) + run: bash devops/tests/run_pill_launcher_tests.sh build run docs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index a53f0a43..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,228 +0,0 @@ -# Run the github CI which builds the launcher and uses it to build examples, run the formatter and clippy checker -name: CI - -on: - push: - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -env: - CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang - # No workflow-level RUSTFLAGS: it overrides target-scoped .cargo/config.toml and leaks -fuse-ld=mold into wasm builds, which rust-lld rejects. - -jobs: - build_launcher: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: ./.github/actions/linux-deps - - - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - toolchain: stable - components: rustfmt, clippy - - - uses: Swatinem/rust-cache@v2 - with: - workspaces: | - engine -> target - - - name: Build PillLauncher (release) - run: cargo build --release --manifest-path engine/pill_launcher/Cargo.toml - - - name: Upload PillLauncher artifact - uses: actions/upload-artifact@v4 - with: - name: pilllauncher-linux - path: engine/pill_launcher/target/release/PillLauncher - - code_formatting_check: - runs-on: ubuntu-latest - needs: build_launcher - steps: - - uses: actions/checkout@v4 - - - uses: ./.github/actions/linux-deps - - - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - toolchain: stable - components: rustfmt - - - name: Download PillLauncher artifact - uses: actions/download-artifact@v4 - with: - name: pilllauncher-linux - path: engine/pill_launcher/target/release - - - name: Run fmt via PillLauncher and ensure repo stays clean - shell: bash - run: | - set -euo pipefail - chmod +x engine/pill_launcher/target/release/PillLauncher - ./engine/pill_launcher/target/release/PillLauncher -a cargo -p examples/floating_pills/ -- fmt - git diff --exit-code -- . ':(exclude)engine/Cargo.toml' ':(exclude)examples/floating_pills/Cargo.toml' - - code_linting_check: - runs-on: ubuntu-latest - needs: build_launcher - steps: - - uses: actions/checkout@v4 - - - uses: ./.github/actions/linux-deps - - - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - toolchain: stable - components: clippy - - - uses: Swatinem/rust-cache@v2 - with: - workspaces: | - engine -> target - - - name: Download PillLauncher artifact - uses: actions/download-artifact@v4 - with: - name: pilllauncher-linux - path: engine/pill_launcher/target/release - - - name: Run clippy via PillLauncher (-D warnings) - shell: bash - run: | - set -euo pipefail - chmod +x engine/pill_launcher/target/release/PillLauncher - ./engine/pill_launcher/target/release/PillLauncher -a cargo -p examples/floating_pills/ -- clippy -- -D warnings - git diff --exit-code -- . ':(exclude)engine/Cargo.toml' ':(exclude)examples/floating_pills/Cargo.toml' - - build_examples: - runs-on: ubuntu-latest - needs: build_launcher - strategy: - fail-fast: false - matrix: - example: - - examples/cube - - examples/floating_pills/ - - examples/italian_brainrot - - examples/net_minimal/client - - examples/net_minimal/server - - steps: - - uses: actions/checkout@v4 - - - uses: ./.github/actions/linux-deps - - - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - toolchain: stable - - - uses: Swatinem/rust-cache@v2 - with: - workspaces: | - engine -> target - examples/cube -> target - examples/floating_pills -> target - examples/italian_brainrot -> target - examples/net_minimal/client -> target - examples/net_minimal/server -> target - - - name: Download PillLauncher artifact - uses: actions/download-artifact@v4 - with: - name: pilllauncher-linux - path: engine/pill_launcher/target/release - - - name: Build example (PillLauncher or cargo for headless server) - shell: bash - env: - EXAMPLE: ${{ matrix.example }} - run: | - set -euo pipefail - chmod +x engine/pill_launcher/target/release/PillLauncher - - if [[ "$EXAMPLE" == "examples/net_minimal/server" ]]; then - # Build the server as a normal Rust binary (headless) - cargo build --manifest-path "$EXAMPLE/Cargo.toml" - else - ./engine/pill_launcher/target/release/PillLauncher -a build -p "$EXAMPLE" - fi - - build_wasm_example: - runs-on: ubuntu-latest - needs: build_launcher - strategy: - fail-fast: false - matrix: - example: - - examples/cube - - examples/pill_tunel - steps: - - uses: actions/checkout@v4 - - - uses: ./.github/actions/linux-deps - - - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - toolchain: stable - target: wasm32-unknown-unknown - rustflags: '' - - - uses: Swatinem/rust-cache@v2 - with: - workspaces: | - examples/cube/build/wasm/.build/pill_web -> target - examples/pill_tunel/build/wasm/.build/pill_web -> target - - - uses: jetli/wasm-pack-action@v0.4.0 - with: - version: latest - - - name: Download PillLauncher artifact - uses: actions/download-artifact@v4 - with: - name: pilllauncher-linux - path: engine/pill_launcher/target/release - - - name: Smoke test β€” build wasm and serve via dev server - shell: bash - env: - EXAMPLE: ${{ matrix.example }} - run: | - set -euo pipefail - chmod +x engine/pill_launcher/target/release/PillLauncher - - # `-a run -t web` builds the bundle, then starts the dev server on :8080. - ./engine/pill_launcher/target/release/PillLauncher -a run -t web -p "$EXAMPLE" -c release & - server_pid=$! - trap 'kill "$server_pid" 2>/dev/null || true' EXIT - - # Wait up to 180s for the server to bind (release wasm-pack + wasm-opt is slow). - for _ in $(seq 1 180); do - if curl -sf -o /dev/null http://127.0.0.1:8080/; then - break - fi - sleep 1 - done - - curl -sf -o /dev/null http://127.0.0.1:8080/ - curl -sf -o /dev/null http://127.0.0.1:8080/pill_web_app.js - curl -sf -o /dev/null http://127.0.0.1:8080/pill_web_app_bg.wasm - echo "Dev server smoke test OK for $EXAMPLE" - - - name: Size guard β€” WASM budget - if: matrix.example == 'examples/cube' - shell: bash - run: | - WASM="examples/cube/build/wasm/pill_web_app_bg.wasm" - ACTUAL=$(wc -c < "$WASM") - MAX=$((499 * 1024)) - if [ "$ACTUAL" -gt "$MAX" ]; then - echo "FAIL: WASM $((ACTUAL / 1024)) KB exceeds 499 KB budget" - exit 1 - fi - echo "OK: WASM $((ACTUAL / 1024)) KB <= 499 KB" - diff --git a/.gitignore b/.gitignore index 9df92f1c..f7058f44 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ .vscode/ *.lock +/docs docs/* engine/pill_engine/docs/uml_out/* engine/target_* @@ -12,3 +13,15 @@ engine/target_* *.wgsl *.cooked_mesh *.cooked_tex +DamagedHelmet.glb +MetalRoughSpheres.glb +studio_small_08.hdr + +/.github/skills +/temp +*.blend1 + +.claude/ + +graphify-out/ +.github/copilot-instructions.md \ No newline at end of file diff --git a/README.md b/README.md index 0aeb1826..f30afc73 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Designed from the ground up to empower both teams and solo creators, it simplifi - Custom systems, components and resources support - Error chaining - Launcher tool -- Game project hot-reloading +- Project hot-reloading - Engine code hot-reloading - Scene state de/serialization 🚧 - Shader code hot-reloading 🚧 @@ -52,19 +52,19 @@ https://www.rust-lang.org/tools/install On Windows: follow [these steps](https://superuser.com/questions/1861276/how-to-set-a-folder-to-the-path-environment-variable-in-windows-11) add `\Pill-Engine\engine\pill_launcher\target\release` On Linux: `echo 'export PATH="$PATH:/Pill-Engine/engine/pill_launcher/target/release"' >> ~/.bashrc && source ~/.bashrc` and restart terminal -5. Create new game project -`PillLauncher.exe -a create -n Hello-Pill` +5. Create new project +`PillLauncher create -n Hello-Pill` 6. Run it! -`PillLauncher.exe -a run -p ./Hello-Pill` +`PillLauncher run -p ./Hello-Pill` Check [demo](examples/Floating-Pills "demo")! ### Quicker iteration times (hot-reloading) -1. When running the game example add `-c hot-reload` to the PillLauncher flags +1. When running the example add `-c hot-reload` to the PillLauncher flags 2. Run the example -`PillLauncher.exe -c hot-reload -a run -p ./Hello-Pill` +`PillLauncher run -p ./Hello-Pill -c hot-reload` 3. Change the code in your Editor -4. Observe the game briefly pausing and resuming with changed world state +4. Observe the project briefly pausing and resuming with changed world state ## Documentation [Repository](https://github.com/MattSzymonski/Pill-Engine-Docs "Repository") diff --git a/devops/common.sh b/devops/common.sh new file mode 100644 index 00000000..5019b7ba --- /dev/null +++ b/devops/common.sh @@ -0,0 +1,390 @@ +#!/usr/bin/env bash + +# REQUIREMENTS: cargo, git, bash 4+, awk, curl, sed +# +# DESCRIPTION: Shared test infrastructure for Pill CI Pipeline and local +# development. Provides binary auto-discovery, project root resolution, +# stale workspace-member cleanup, temporary workspace management, binary +# size reporting, dev-server smoke testing, and coloured pass/fail/skip +# result helpers. Sourced (not executed) by all test scripts. +# Idempotent - safe to source multiple times. + +# --- SCRIPT --- + +# ---- Idempotency guard ------------------------------------------------------ +if [[ "${COMMON_SH_LOADED:-}" == "1" ]]; then + return 0 +fi +COMMON_SH_LOADED=1 + +# Fail fast on any unhandled error, unset variable, or pipe failure. +set -euo pipefail + +# ---- terminal colors -------------------------------------------------------- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color (reset) + +# ---- Global test counters & per-result log --------------------------------- +tests_passed=0; tests_failed=0; tests_skipped=0 +test_results=() # stores "PASS|", "FAIL| - reason", etc. + +# --------------------------------------------------------------------------- +# Binary discovery +# --------------------------------------------------------------------------- +# We try several well-known locations so the script works whether run from +# the repo root or from inside engine/pill_launcher. CI sets +# PILL_LAUNCHER_BIN explicitly after downloading the build artifact. + +# Walk up from the directory containing this script until we find a known +# project-root marker, then return that absolute path. +find_project_root() { + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + local current_dir="$script_dir" + while [ "$current_dir" != "/" ]; do + if [ -f "$current_dir/engine/Cargo.toml" ] || [ -f "$current_dir/engine/pill_launcher/Cargo.toml" ]; then + echo "$current_dir" + return 0 + fi + current_dir="$(dirname "$current_dir")" + done + # Fallback: use git root if available + if command -v git > /dev/null 2>&1; then + git rev-parse --show-toplevel 2>/dev/null && return 0 || true + fi + return 1 +} + +find_launcher() { + local project_root + project_root="$(find_project_root)" || project_root="." + + # On Linux/macOS, skip .exe files β€” they are Windows binaries and won't run. + local windows_host=false + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) windows_host=true ;; + *) windows_host=false ;; + esac + + local search_paths=( + "$project_root/engine/pill_launcher/target/release/PillLauncher" + "$project_root/target/release/PillLauncher" + "$project_root/engine/pill_launcher/target/debug/PillLauncher" + "$project_root/target/debug/PillLauncher" + ) + if [ "$windows_host" = true ]; then + search_paths=( + "$project_root/engine/pill_launcher/target/release/PillLauncher.exe" + "$project_root/target/release/PillLauncher.exe" + "$project_root/engine/pill_launcher/target/debug/PillLauncher.exe" + "$project_root/target/debug/PillLauncher.exe" + ) + fi + + for candidate_path in "${search_paths[@]}"; do + if [ -x "$candidate_path" ] || [ -f "$candidate_path" ]; then + echo "$candidate_path" + return + fi + done + + # Check system PATH as a last resort + if command -v PillLauncher > /dev/null 2>&1; then + command -v PillLauncher + return + fi + + echo "" +} + +# Determine the project root once and export it for all sourcing scripts. +PROJECT_ROOT="$(find_project_root)" +export PROJECT_ROOT + +pill_launcher_bin="${PILL_LAUNCHER_BIN:-$(find_launcher)}" +if [ -z "$pill_launcher_bin" ] || [ ! -f "$pill_launcher_bin" ]; then + echo -e "${RED}FATAL: PillLauncher binary not found.${NC}" + echo "Build it first: cargo build --release --manifest-path engine/pill_launcher/Cargo.toml" + echo "Or set PILL_LAUNCHER_BIN=/path/to/PillLauncher" + exit 1 +fi +chmod +x "$pill_launcher_bin" 2>/dev/null || true + +# Clear stale cargo package-cache lock (can block parallel builds). +rm -f "${HOME}/.cargo/.package-cache" 2>/dev/null || true + +# Fix engine/Cargo.toml workspace members - the launcher injects absolute-path +# workspace members during builds and may leave them stale if interrupted. +# This sed removes any line that has the pill-launcher-managed marker. +fix_stale_workspace_members() { + local cargo_toml="${PROJECT_ROOT:-.}/engine/Cargo.toml" + if [ -f "$cargo_toml" ]; then + sed -i '/pill-launcher-managed-workspace-member/d' "$cargo_toml" 2>/dev/null || true + fi +} +fix_stale_workspace_members + +# --------------------------------------------------------------------------- +# Temporary test directory +# --------------------------------------------------------------------------- + +# Use Windows TEMP if available (Git Bash), fall back to /tmp +if [ -d "${TEMP:-}" ]; then + TMPDIR="${TMPDIR:-$TEMP}" +elif [ -d "${TMP:-}" ]; then + TMPDIR="${TMPDIR:-$TMP}" +else + TMPDIR="${TMPDIR:-/tmp}" +fi +test_workspace_root="${TEST_ROOT:-$TMPDIR/pill-ci-tests-$$}" +mkdir -p "$test_workspace_root" + +# cleanup_workspace() { +# rm -rf "$test_workspace_root" +# } +# trap cleanup_workspace EXIT + +# --------------------------------------------------------------------------- +# Utility helpers +# --------------------------------------------------------------------------- + +# Format a byte count as human-readable (e.g. "1.2 MB", "337 KB", "359 B"). +# Uses numfmt if available (GNU coreutils), otherwise falls back to a simple +# integer division that matches ls -lh style (no decimal places). +format_size() { + local bytes=$1 + if command -v numfmt > /dev/null 2>&1; then + numfmt --to=iec --suffix=B "$bytes" 2>/dev/null || echo "${bytes} B" + elif [ "$bytes" -ge 1048576 ]; then + echo "$((bytes / 1048576)) MB" + elif [ "$bytes" -ge 1024 ]; then + echo "$((bytes / 1024)) KB" + else + echo "${bytes} B" + fi +} + +# Kill any process listening on the given port. Works on Windows (netstat + +# taskkill) and Unix (fuser / lsof + kill). Also kills any lingering +# PillLauncher processes to release locked executables. +kill_server_on_port() { + local port="$1" + # Windows: netstat + taskkill + local stale_pid + stale_pid=$(netstat -ano 2>/dev/null | grep ":${port} " | grep LISTENING | awk '{print $NF}' | head -1 || true) + if [ -n "$stale_pid" ]; then + taskkill //PID "$stale_pid" //F //T > /dev/null 2>&1 || true + fi + # Also kill any remaining PillLauncher processes (may hold exe lock) + taskkill //F //IM PillLauncher.exe > /dev/null 2>&1 || true + # Unix fallback + if command -v fuser > /dev/null 2>&1; then + fuser -k "${port}/tcp" > /dev/null 2>&1 || true + fi + sleep 1 +} + +# Print a JSON binary-size report for all files under a directory. +# Outputs nothing if the directory does not exist. Sizes in MB (x.xxxx). +print_size_report() { + local directory="$1" + if [ ! -d "$directory" ]; then + return + fi + local total_mb=0 + local file_count=0 + local json_entries="" + while IFS= read -r -d '' file; do + local size + size=$(wc -c < "$file" 2>/dev/null || echo 0) + local mb + mb=$(awk "BEGIN { printf \"%.4f\", $size / 1048576 }") + total_mb=$(awk "BEGIN { printf \"%.4f\", $total_mb + $mb }") + file_count=$((file_count + 1)) + local relative_path="${file#$directory/}" + if [ -n "$json_entries" ]; then + json_entries+=$',\n' + fi + json_entries+=" {\"file\": \"${relative_path}\", \"mb\": ${mb}}" + done < <(find "$directory" -type f -print0 2>/dev/null | sort -z) + echo " Binary sizes:" + echo " {" + echo " \"total_mb\": $total_mb," + echo " \"file_count\": $file_count," + echo " \"files\": [" + echo -e "$json_entries" + echo " ]" + echo " }" +} + +# --------------------------------------------------------------------------- +# System info β€” machine specs for benchmark context +# --------------------------------------------------------------------------- + +print_system_info() { + echo "" + echo "---------- System Information ----------" + + # --- OS & kernel --- + echo " OS: $(uname -s 2>/dev/null || echo 'Windows') $(uname -r 2>/dev/null || echo '')" + if command -v sw_vers >/dev/null 2>&1; then + echo " macOS: $(sw_vers -productName 2>/dev/null) $(sw_vers -productVersion 2>/dev/null)" + elif [ -f /etc/os-release ]; then + echo " Distro: $(grep ^PRETTY_NAME= /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d '"')" + fi + + # --- CPU --- + local cpu_model="unknown" + local cpu_cores="unknown" + if [ -f /proc/cpuinfo ]; then + cpu_model=$(grep -m1 'model name' /proc/cpuinfo 2>/dev/null | cut -d: -f2 | xargs || echo "unknown") + cpu_cores=$(grep -c '^processor' /proc/cpuinfo 2>/dev/null || echo "unknown") + elif command -v sysctl >/dev/null 2>&1; then + cpu_model=$(sysctl -n machdep.cpu.brand_string 2>/dev/null || echo "unknown") + cpu_cores=$(sysctl -n hw.ncpu 2>/dev/null || echo "unknown") + elif command -v wmic >/dev/null 2>&1; then + cpu_model=$(wmic cpu get name 2>/dev/null | tail -n +2 | head -1 | xargs || echo "unknown") + cpu_cores=$(wmic cpu get NumberOfCores 2>/dev/null | tail -n +2 | head -1 | xargs || echo "unknown") + fi + echo " CPU: ${cpu_model} (${cpu_cores} cores)" + + # --- RAM --- + local ram_total="unknown" + local ram_available="unknown" + if [ -f /proc/meminfo ]; then + ram_total=$(awk '/^MemTotal:/ {printf "%.1f GB", $2/1048576}' /proc/meminfo 2>/dev/null || echo "unknown") + ram_available=$(awk '/^MemAvailable:/ {printf "%.1f GB", $2/1048576}' /proc/meminfo 2>/dev/null || echo "unknown") + elif command -v sysctl >/dev/null 2>&1; then + local mem_bytes + mem_bytes=$(sysctl -n hw.memsize 2>/dev/null || echo 0) + ram_total=$(awk "BEGIN {printf \"%.1f GB\", $mem_bytes/1073741824}" 2>/dev/null || echo "unknown") + ram_available="N/A (macOS)" + elif command -v wmic >/dev/null 2>&1; then + local mem_kb + mem_kb=$(wmic OS get TotalVisibleMemorySize 2>/dev/null | tail -n +2 | head -1 | xargs || echo 0) + ram_total=$(awk "BEGIN {printf \"%.1f GB\", $mem_kb/1048576}" 2>/dev/null || echo "unknown") + ram_available="N/A (Windows)" + fi + echo " RAM: ${ram_total} total, ${ram_available} available" + + # --- Docker / cgroup limits (if running in container) --- + if [ -f /proc/1/cgroup ] && grep -q 'docker\|container' /proc/1/cgroup 2>/dev/null; then + echo " Environment: Docker container" + if [ -f /sys/fs/cgroup/cpu.max ]; then + local cpu_quota cpu_period + read -r cpu_quota cpu_period < /sys/fs/cgroup/cpu.max 2>/dev/null || true + if [ -n "$cpu_quota" ] && [ "$cpu_quota" != "max" ] && [ -n "$cpu_period" ]; then + echo " CPU limit: $(awk "BEGIN {printf \"%.2f\", $cpu_quota/$cpu_period}" 2>/dev/null) cores" + else + echo " CPU limit: unrestricted" + fi + fi + if [ -f /sys/fs/cgroup/memory.max ]; then + local mem_limit + mem_limit=$(cat /sys/fs/cgroup/memory.max 2>/dev/null || echo "unknown") + if [ "$mem_limit" != "max" ]; then + echo " RAM limit: $(awk "BEGIN {printf \"%.1f GB\", $mem_limit/1073741824}" 2>/dev/null) " + else + echo " RAM limit: unrestricted" + fi + fi + fi + + # --- Rust toolchain --- + if command -v rustc >/dev/null 2>&1; then + echo " rustc: $(rustc --version 2>/dev/null || echo 'unknown')" + fi + if command -v cargo >/dev/null 2>&1; then + echo " cargo: $(cargo --version 2>/dev/null || echo 'unknown')" + fi + + echo "----------------------------------------" + echo "" +} + +# --------------------------------------------------------------------------- +# Test result helpers +# --------------------------------------------------------------------------- + +report_pass() { + echo -e " ${GREEN}PASS${NC} $1" + tests_passed=$((tests_passed + 1)) + test_results+=("PASS|$1") +} + +report_fail() { + echo -e " ${RED}FAIL${NC} $1 - $2" + tests_failed=$((tests_failed + 1)) + test_results+=("FAIL|$1 - $2") +} + +report_skip() { + echo -e " ${YELLOW}SKIP${NC} $1 - $2" + tests_skipped=$((tests_skipped + 1)) + test_results+=("SKIP|$1 - $2") +} + +# Export binary path so timeout (separate process) can use it +export pill_launcher_bin + +invoke_launcher() { + "$pill_launcher_bin" "$@" +} + +# Also export the function for timeout/background processes +export -f invoke_launcher + +# --------------------------------------------------------------------------- +# assert_ok - Run a launcher command and expect exit 0 +# Usage: assert_ok "test description" +# Example: assert_ok "create project" create -n MyGame -p /tmp +# --------------------------------------------------------------------------- +assert_ok() { + local test_description="$1"; shift + if invoke_launcher "$@" > /dev/null 2>&1; then + report_pass "$test_description" + else + report_fail "$test_description" "exit code $?" + fi +} + +# --------------------------------------------------------------------------- +# assert_fail - Run a launcher command, expect non-zero exit + +# stderr containing a case-insensitive substring +# Usage: assert_fail "test description" "expected error substring" +# Example: assert_fail "duplicate create" "already exists" create -n Foo -p /tmp +# --------------------------------------------------------------------------- +assert_fail() { + local test_description="$1"; local expected_substring="$2"; shift 2 + local launcher_output exit_code + launcher_output=$(invoke_launcher "$@" 2>&1) && exit_code=$? || exit_code=$? + if [ "$exit_code" -ne 0 ] && echo "$launcher_output" | grep -qi "$expected_substring"; then + report_pass "$test_description" + else + report_fail "$test_description" \ + "expected error matching '$expected_substring', got exit $exit_code: ${launcher_output:0:200}" + fi +} + +print_summary() { + local total_tests=$((tests_passed + tests_failed + tests_skipped)) + echo "" + echo "========================================" + local index=0 + for entry in "${test_results[@]}"; do + index=$((index + 1)) + local status="${entry%%|*}" + local description="${entry#*|}" + case "$status" in + PASS) echo -e "($index/$total_tests) ${GREEN}PASS${NC} - $description" ;; + FAIL) echo -e "($index/$total_tests) ${RED}FAIL${NC} - $description" ;; + SKIP) echo -e "($index/$total_tests) ${YELLOW}SKIP${NC} - $description" ;; + esac + done + echo "========================================" + echo -e "Results: ${GREEN}$tests_passed passed${NC}, ${RED}$tests_failed failed${NC}, ${YELLOW}$tests_skipped skipped${NC} ($total_tests total)" + echo "========================================" + [ "$tests_failed" -eq 0 ] || exit 1 +} diff --git a/devops/tests/Dockerfile b/devops/tests/Dockerfile new file mode 100644 index 00000000..c0f5423f --- /dev/null +++ b/devops/tests/Dockerfile @@ -0,0 +1,107 @@ +# devops/tests/Dockerfile - Pill CI image (Debian Bookworm) +# +# Multi-stage build: compiles PillLauncher once at image build time, +# so it's instantly available when the container starts. +# +# Build once: +# docker build -t pill-ci -f devops/tests/Dockerfile . +# +# Run: +# docker run --rm -v .:/src -w /src pill-ci bash devops/tests/run_basic_tests.sh all + +# --------------------------------------------------------------------------- +# Stage 1: Build PillLauncher +# --------------------------------------------------------------------------- +FROM rust:1.92-slim-bookworm AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + mold clang pkg-config \ + && rm -rf /var/lib/apt/lists/* + +# Copy only the engine workspace (launcher + its dependencies). +COPY engine/ /src/engine/ + +# Fix any absolute host paths that leaked into Cargo.toml (e.g. from Windows). +# Also strip stale workspace members left by interrupted launcher builds. +RUN sed -i 's|"[^"]*/examples/|"examples/|' /src/engine/Cargo.toml 2>/dev/null || true \ + && sed -i '/pill-launcher-managed-workspace-member/d' /src/engine/Cargo.toml 2>/dev/null || true + +RUN cargo build --release --manifest-path /src/engine/pill_launcher/Cargo.toml --target-dir /tmp/pill-launcher-build + +# --------------------------------------------------------------------------- +# Stage 2: CI runtime image +# --------------------------------------------------------------------------- +FROM rust:1.92-slim-bookworm + +# -- System packages --------------------------------------------------------- +# mold: fast linker clang: C linker for cargo +# libasound2-dev: audio libudev-dev: gamepad pkg-config: build scripts +# plantuml: docs +# Headless benchmarking is handled via --headless (no xvfb needed). +RUN apt-get update && apt-get install -y --no-install-recommends \ + mold \ + clang \ + libasound2-dev \ + libudev-dev \ + pkg-config \ + plantuml \ + curl \ + git \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# -- Rust components --------------------------------------------------------- +RUN rustup component add rustfmt clippy +RUN rustup target add wasm32-unknown-unknown + +# -- Git safe.directory ------------------------------------------------------ +# GitHub Actions runs containers as root on a mounted workspace. Git 2.35+ +# refuses to operate in repos owned by a different user unless the directory +# is explicitly marked safe. +RUN git config --global --add safe.directory '*' + +# -- wasm-pack --------------------------------------------------------------- +RUN curl -L https://github.com/rustwasm/wasm-pack/releases/download/v0.13.1/wasm-pack-v0.13.1-x86_64-unknown-linux-musl.tar.gz \ + | tar xz -C /usr/local/bin --strip-components=1 + +# -- slangc (shader compiler) ------------------------------------------------ +# Debian Bookworm is glibc-based, so slangc's glibc binaries work natively. +ARG SLANG_VERSION=2025.21.2 +RUN curl -L "https://github.com/shader-slang/slang/releases/download/v${SLANG_VERSION}/slang-${SLANG_VERSION}-linux-x86_64.zip" \ + -o /tmp/slang.zip \ + && unzip -q /tmp/slang.zip -d /tmp/slang \ + && cp /tmp/slang/bin/slangc /usr/local/bin/slangc \ + && cp /tmp/slang/bin/slangd /usr/local/bin/slangd \ + && chmod +x /usr/local/bin/slangc /usr/local/bin/slangd \ + && cp /tmp/slang/lib/*.so* /usr/local/lib/ \ + && ldconfig \ + && rm -rf /tmp/slang /tmp/slang.zip + +RUN slangc --version 2>/dev/null || echo "slangc installed (runtime check)" + +# -- PillLauncher binary (pre-built in Stage 1) ------------------------------ +COPY --from=builder /tmp/pill-launcher-build/release/PillLauncher /usr/local/bin/PillLauncher +ENV PILL_LAUNCHER_BIN=/usr/local/bin/PillLauncher + +# -- Entrypoint -------------------------------------------------------------- +# Runtime fixups on the mounted source volume. Works both with local Docker +# (mount at /src) and GitHub Actions (mount at GITHUB_WORKSPACE). +RUN echo '#!/bin/bash' > /usr/local/bin/entrypoint.sh \ + && echo 'set -euo pipefail' >> /usr/local/bin/entrypoint.sh \ + && echo '' >> /usr/local/bin/entrypoint.sh \ + && echo '# Resolve the source root: use /src if mounted, else GITHUB_WORKSPACE, else PWD' >> /usr/local/bin/entrypoint.sh \ + && echo 'SRC_ROOT="${GITHUB_WORKSPACE:-/src}"' >> /usr/local/bin/entrypoint.sh \ + && echo '[ -d "$SRC_ROOT" ] || SRC_ROOT="$PWD"' >> /usr/local/bin/entrypoint.sh \ + && echo '' >> /usr/local/bin/entrypoint.sh \ + && echo '# Rewrite absolute host paths to repo-relative in engine/Cargo.toml' >> /usr/local/bin/entrypoint.sh \ + && echo 'sed -i "s|\"[^\"]*/examples/|\"examples/|" "$SRC_ROOT/engine/Cargo.toml" 2>/dev/null || true' >> /usr/local/bin/entrypoint.sh \ + && echo '' >> /usr/local/bin/entrypoint.sh \ + && echo '# Touch WGSL shaders so build.rs skips HLSL->WGSL conversion' >> /usr/local/bin/entrypoint.sh \ + && echo 'for wgsl in "$SRC_ROOT"/engine/pill_engine/res/shaders/*.wgsl; do' >> /usr/local/bin/entrypoint.sh \ + && echo ' [ -f "$wgsl" ] && touch "$wgsl"' >> /usr/local/bin/entrypoint.sh \ + && echo 'done' >> /usr/local/bin/entrypoint.sh \ + && echo '' >> /usr/local/bin/entrypoint.sh \ + && echo 'exec "$@"' >> /usr/local/bin/entrypoint.sh \ + && chmod +x /usr/local/bin/entrypoint.sh +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["bash", "devops/tests/run_basic_tests.sh", "all"] diff --git a/devops/tests/run_basic_tests.sh b/devops/tests/run_basic_tests.sh new file mode 100644 index 00000000..e71cf8a3 --- /dev/null +++ b/devops/tests/run_basic_tests.sh @@ -0,0 +1,493 @@ +#!/usr/bin/env bash + +# REQUIREMENTS: Rust toolchain (cargo, rustfmt, clippy), wasm-pack, git, +# a compiled PillLauncher binary (auto-discovered or set via +# PILL_LAUNCHER_BIN). + +# DESCRIPTION: Pill CI fast checks - Tests that validate +# code formatting, linting, native & WASM builds, performance benchmarking +# binary size measurement, and WASM size budget enforcement. +# +# Designed for both local development and GitHub Actions CI +# (ci-basic-tests.yml). + +# USAGE: bash devops/tests/run_basic_tests.sh [all|] +# +# all run all checks (default) +# code_formatting_check cargo fmt + git diff +# code_linting_check cargo clippy -D warnings +# build_native_cube_example build examples/cube (native) + artifact size report +# build_wasm_cube_example build WASM (cube) + artifact size report + budget +# benchmark_native_performance build + run city (release, 3 runs) + +# EXAMPLE USAGE: +# bash devops/tests/run_basic_tests.sh all +# bash devops/tests/run_basic_tests.sh benchmark_native_performance +# bash devops/tests/run_basic_tests.sh build_wasm_cube_example + +# --- SCRIPT --- + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../common.sh +source "$SCRIPT_DIR/../common.sh" + +# Shortcuts for colored section headers +BOLD='\033[1m' +CYAN='\033[0;36m' +NC='\033[0m' + +# All paths in this script are relative to the project root. +cd "$PROJECT_ROOT" + +# Force colored output from cargo and git even when piped (Docker/TTY-less). +export CARGO_TERM_COLOR=always +export GIT_CONFIG_COUNT=1 +export GIT_CONFIG_KEY_0=color.ui +export GIT_CONFIG_VALUE_0=always + +# --------------------------------------------------------------------------- +# 1. code_formatting_check - cargo fmt (direct) + git diff +# --------------------------------------------------------------------------- + +code_formatting_check() { + echo "" + echo -e "${BOLD}${CYAN}===============================================================================${NC}" + echo -e "${BOLD}${CYAN}(1/5) Code formatting check${NC}" + echo "Running cargo fmt --check" + local fmt_output exit_code + fmt_output=$(CARGO_TERM_COLOR=always cargo fmt --all --manifest-path engine/Cargo.toml -- --check 2>&1) && exit_code=$? || exit_code=$? + + if [ "$exit_code" -eq 0 ]; then + report_pass "code formatting" + else + report_fail "code formatting" "$(echo "$fmt_output" | tail -c 500)" + fi +} + +# --------------------------------------------------------------------------- +# 2. code_linting_check - cargo clippy -D warnings (direct) +# --------------------------------------------------------------------------- + +code_linting_check() { + echo "" + echo -e "${BOLD}${CYAN}===============================================================================${NC}" + echo -e "${BOLD}${CYAN}(2/5) Code linting check${NC}" + echo "Running clippy" + local clippy_output exit_code + clippy_output=$(CARGO_TERM_COLOR=always cargo clippy --all --manifest-path engine/Cargo.toml -- -D warnings 2>&1) && exit_code=$? || exit_code=$? + + if [ "$exit_code" -eq 0 ]; then + report_pass "code linting" + else + # Show the last 500 chars β€” cargo's "Updating/Locking" spam is at the + # beginning; the actual clippy diagnostics are at the end. + report_fail "clippy warnings" "$(echo "$clippy_output" | tail -c 500)" + fi +} + +# --------------------------------------------------------------------------- +# 3. build_native_cube_example - release native build + artifact size report +# --------------------------------------------------------------------------- + +build_native_cube_example() { + echo "" + echo -e "${BOLD}${CYAN}===============================================================================${NC}" + echo -e "${BOLD}${CYAN}(3/5) Native build${NC}" + local cube_dir="examples/cube" + + print_system_info + + if [ ! -f "$cube_dir/Cargo.toml" ]; then + report_skip "native cube build" "examples/cube not found" + return + fi + + echo -e "${BOLD}Cleaning previous build artifacts...${NC}" + cargo clean --manifest-path engine/Cargo.toml --release 2>/dev/null || true + echo "Building - this may take a while" + echo " " + + local exit_code=0 + invoke_launcher build -p "$cube_dir" -c release --clean 2>&1 || exit_code=$? + + if [ "$exit_code" -eq 0 ]; then + report_pass "native cube build succeeds" + else + report_skip "native cube build" "exit $exit_code" + return + fi + + # Binary size report on all native build artifacts + echo "" + echo -e "${CYAN}------------------------------------------------------------------${NC}" + echo -e "${BOLD}Native artifact size report${NC}" + local data_dir="$cube_dir/build/release/data" + if [ -d "$data_dir" ]; then + print_size_report "$data_dir" + report_pass "native artifact size report" + else + report_fail "native artifact size report" "missing $data_dir" + fi +} + +# --------------------------------------------------------------------------- +# 4. build_wasm_cube_example - release WASM build + artifact size report + budget +# --------------------------------------------------------------------------- + +build_wasm_cube_example() { + echo "" + echo -e "${BOLD}${CYAN}===============================================================================${NC}" + echo -e "${BOLD}${CYAN}(4/5) WASM build${NC}" + local cube_path="examples/cube" + + print_system_info + + # 1. Build the WASM target (pill_web_app) for the cube example + echo -e "${BOLD}Cleaning previous build artifacts...${NC}" + cargo clean --manifest-path engine/Cargo.toml --release 2>/dev/null || true + echo "Building - this may take a while" + echo " " + + local exit_code=0 + invoke_launcher build -p "$cube_path" -t web -c release --wasm-analyze --clean 2>&1 || exit_code=$? + + if [ "$exit_code" -eq 0 ]; then + report_pass "WASM build succeeds" + else + report_fail "WASM build" "exit $exit_code (see output above)" + return + fi + + # Verify the .wasm artifact exists (launcher flattens output to build/wasm/) + local wasm_dir="$cube_path/build/wasm" + local wasm_file="$wasm_dir/pill_web_app_bg.wasm" + if [ ! -f "$wasm_file" ]; then + report_fail "WASM artifact" "missing $wasm_file" + return + fi + + # Binary size - only the .wasm file, in MB (mebibytes, 4 decimal places) + echo "" + echo -e "${CYAN}------------------------------------------------------------------${NC}" + echo -e "${BOLD}WASM artifact size + size guard${NC}" + local wasm_size wasm_mb + wasm_size=$(wc -c < "$wasm_file" 2>/dev/null || echo 0) + wasm_mb=$(awk "BEGIN { printf \"%.4f\", $wasm_size / 1048576 }") + echo " Binary size:" + echo " {" + echo " \"file\": \"pill_web_app_bg.wasm\"," + echo " \"mb\": $wasm_mb" + echo " }" + + # Size budget: ≀ 0.4999 MB (524 176 bytes) + local limit_bytes=524176 + if [ "$wasm_size" -le "$limit_bytes" ]; then + report_pass "WASM artifact size (${wasm_mb} MB within 0.4999 MB budget)" + else + report_fail "WASM size budget" "${wasm_mb} MB exceeds 0.4999 MB limit" + fi + + # Dev server smoke test - verify the server can serve built files. + # Run the launcher's dev server in the background with a hard timeout + # so it can never hang the test suite. + if [ -d "$wasm_dir" ]; then + echo "" + echo -e "${CYAN}------------------------------------------------------------------${NC}" + echo -e "${BOLD}WASM dev server smoke test${NC}" + local test_port=8080 + kill_server_on_port "$test_port" + + local server_log + server_log="$(mktemp)" + echo "Starting PillLauncher dev server on port ${test_port}..." + invoke_launcher run -t web -p "$cube_path" -c release >"$server_log" 2>&1 & + local server_pid=$! + + # Wait up to 30s for the server to bind + local server_ready=0 + for attempt in $(seq 1 30); do + if curl -sf -o /dev/null "http://127.0.0.1:${test_port}/" 2>/dev/null; then + server_ready=1 + break + fi + if ! kill -0 "$server_pid" 2>/dev/null; then + break + fi + sleep 1 + done + + if [ "$server_ready" -eq 1 ]; then + local smoke_ok=1 + curl -sf -o /dev/null "http://127.0.0.1:${test_port}/" || smoke_ok=0 + curl -sf -o /dev/null "http://127.0.0.1:${test_port}/pill_web_app.js" || smoke_ok=0 + curl -sf -o /dev/null "http://127.0.0.1:${test_port}/pill_web_app_bg.wasm" || smoke_ok=0 + if [ "$smoke_ok" -eq 1 ]; then + report_pass "WASM dev server smoke test" + else + report_fail "WASM dev server smoke test" "one or more key files not served" + fi + else + report_skip "WASM dev server smoke test" "server did not start in time" + fi + + # Cleanup: kill the server and any children. Wait for the process + # to actually exit so we don't leak it into the next test step. + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + kill_server_on_port "$test_port" + rm -f "$server_log" + fi + + # Flush any buffered output from child processes (wasm-pack, cargo) + # before the next test step begins. + wait 2>/dev/null || true +} + +# --------------------------------------------------------------------------- +# 5. benchmark_native_performance - build + run city (release) +# --------------------------------------------------------------------------- +# +# Strategy (no xvfb needed β€” `--headless` skips winit/wgpu entirely): +# Windows β†’ windowed only (3 runs). +# Linux/macOS β†’ if no $DISPLAY / $WAYLAND_DISPLAY, use --headless; +# otherwise try windowed first; fall back to --headless +# on any failure. +# The benchmark spawns 10 000 citizens, runs 5 000 frames (1 000 warmup), +# prints per-frame stats as JSON, then auto-exits. + +benchmark_native_performance() { + echo "" + echo -e "${BOLD}${CYAN}===============================================================================${NC}" + echo -e "${BOLD}${CYAN}(5/5) Performance benchmark${NC}" + + print_system_info + + local operating_system + operating_system=$(uname -s 2>/dev/null || echo "Windows") + + # -- Windows: always windowed ------------------------------------------- + if [[ "$operating_system" == *"MINGW"* ]] || [[ "$operating_system" == *"MSYS"* ]] || [[ "$operating_system" == "Windows" ]]; then + echo -e "${BOLD}Building + running benchmark (windowed, 3 runs)${NC}" + _run_benchmark_loop false + return + fi + + # -- Linux / macOS: try windowed, fall back to headless ----------------- + if [ -z "${DISPLAY:-}" ] && [ -z "${WAYLAND_DISPLAY:-}" ]; then + echo -e "${YELLOW}No display detected - using headless benchmark${NC}" + _run_benchmark_loop true + return + fi + + echo "Building + running benchmark (windowed, 3 runs)" + _run_benchmark_loop false + local windowed_result=$? + if [ "$windowed_result" -eq 0 ]; then + return + fi + + # GPU unavailable or other failure β€” rebuild in headless mode + echo -e "${YELLOW}Windowed benchmark failed β€” falling back to headless${NC}" + echo -e "${BOLD}Building + running headless benchmark (3 runs)${NC}" + _run_benchmark_loop true +} + +# ------------------------------------------------------------ +# _run_benchmark_loop - run the city benchmark N times +# $1 = headless (true|false) +# Returns 0 if at least one run passed, 1 if all failed. +# ------------------------------------------------------------ +_run_benchmark_loop() { + local headless="$1" + local runs=3 + local passed=0 + local failed=0 + local project_directory="examples/city" + + # Determine the project title early so we can clean the correct target dir. + local project_title + project_title=$(grep -oP '^TITLE\s*=\s*\K.+' "$project_directory/res/config.ini" 2>/dev/null | tr -d ' ') + + # Build once, then run the compiled executable directly for each iteration + echo -e "${BOLD}Cleaning previous build artifacts...${NC}" + cargo clean --manifest-path engine/Cargo.toml --release 2>/dev/null || true + if [ -n "$project_title" ]; then + rm -rf "engine/target_projects/${project_title}" 2>/dev/null || true + fi + + echo -e "${BOLD}Building...${NC}" + echo " " + local build_exit_code=0 + if [ "$headless" = "true" ]; then + # --headless enables headless on engine crates; benchmark_headless on the project crate. + invoke_launcher build -p "$project_directory" -c release --clean --headless --additional-features project/benchmark_headless 2>&1 || build_exit_code=$? + else + # Windowed benchmark: enable benchmark_windowed feature on the project + invoke_launcher build -p "$project_directory" -c release --clean --additional-features project/benchmark_windowed 2>&1 || build_exit_code=$? + fi + if [ "$build_exit_code" -ne 0 ]; then + report_skip "native perf benchmark" "build failed (exit $build_exit_code)" + return 1 + fi + + # Determine executable name from project title + local executable_name="$project_title" + if [[ "$(uname -s)" == *"MINGW"* ]] || [[ "$(uname -s)" == *"MSYS"* ]] || [[ "$(uname -s)" == "Windows" ]]; then + executable_name="${executable_name}.exe" + fi + + if [ ! -f "$project_directory/build/release/$executable_name" ]; then + report_skip "native perf benchmark" "executable not found: $project_directory/build/release/$executable_name" + return 1 + fi + + # Arrays for per-run stats + local -a average_milliseconds=() median_milliseconds=() minimum_milliseconds=() maximum_milliseconds=() range_milliseconds=() stddev_milliseconds=() + + for ((i=1; i<=runs; i++)); do + echo "Run $i/$runs..." + local run_exit_code=0 + local run_output + run_output=$(cd "$project_directory/build/release" && ./"$executable_name" 2>&1) || run_exit_code=$? + + if [ "$run_exit_code" -eq 0 ]; then + passed=$((passed + 1)) + local json_line + json_line=$(echo "$run_output" | grep '^{' | head -1) + if [ -n "$json_line" ]; then + echo " $(echo "$json_line" | grep -o '"average_ms":[0-9.]*')" + average_milliseconds+=("$(_extract_json_number "$json_line" "average_ms")") + median_milliseconds+=("$(_extract_json_number "$json_line" "median_ms")") + minimum_milliseconds+=("$(_extract_json_number "$json_line" "min_ms")") + maximum_milliseconds+=("$(_extract_json_number "$json_line" "max_ms")") + range_milliseconds+=("$(_extract_json_number "$json_line" "range_ms")") + stddev_milliseconds+=("$(_extract_json_number "$json_line" "stddev_ms")") + echo " OK" + else + echo " OK (no JSON output)" + fi + else + failed=$((failed + 1)) + echo " FAILED (exit $run_exit_code)" + fi + done + + # Print summary if we have data + if [ "$passed" -gt 0 ] && [ ${#average_milliseconds[@]} -gt 0 ]; then + local mode_label="windowed" + if [ "$headless" = "true" ]; then + mode_label="headless" + fi + echo -e "${BOLD} Benchmark summary${NC} ($passed run(s), $mode_label):" + echo " --------------------------------------------------" + echo " {" + echo " \"mode\": \"$mode_label\"," + echo " \"runs\": $passed," + echo " \"stats\": {" + _print_statistic_summary "average_ms" "${average_milliseconds[@]}" + _print_statistic_summary "median_ms" "${median_milliseconds[@]}" + _print_statistic_summary "min_ms" "${minimum_milliseconds[@]}" + _print_statistic_summary "max_ms" "${maximum_milliseconds[@]}" + _print_statistic_summary "range_ms" "${range_milliseconds[@]}" + _print_statistic_summary "stddev_ms" "${stddev_milliseconds[@]}" + echo " }" + echo " }" + fi + + if [ "$failed" -eq 0 ]; then + report_pass "native performance benchmark ($passed/$runs runs passed)" + return 0 + elif [ "$passed" -gt 0 ]; then + report_pass "native performance benchmark ($passed/$runs runs passed, $failed failed)" + return 0 + else + report_fail "native performance benchmark" "all $runs runs failed" + return 1 + fi +} + +# ------------------------------------------------------------ +# _extract_json_number - pull a numeric value from a JSON line +# $1 = JSON string +# $2 = key name +# Prints the value or "0" if not found. +# ------------------------------------------------------------ +_extract_json_number() { + local extracted_value + extracted_value=$(echo "$1" | grep -oP "\"$2\":\K[0-9.]+" 2>/dev/null || echo "0") + echo "$extracted_value" +} + +# ------------------------------------------------------------ +# _print_statistic_summary - print min / max / average for a stat +# $1 = stat name +# $2..$n = values (as decimal strings like "1.700") +# ------------------------------------------------------------ +_print_statistic_summary() { + local name="$1" + shift + local minimum_value="$1" + local maximum_value="$1" + local sum=0 + local count=$# + + for current_value in "$@"; do + # Use awk for floating-point math (available everywhere; bc is not) + sum=$(awk "BEGIN { printf \"%.3f\", $sum + $current_value }" 2>/dev/null || echo "$sum") + if [ "$(awk "BEGIN { print ($current_value < $minimum_value) }" 2>/dev/null || echo 0)" = "1" ]; then + minimum_value="$current_value" + fi + if [ "$(awk "BEGIN { print ($current_value > $maximum_value) }" 2>/dev/null || echo 0)" = "1" ]; then + maximum_value="$current_value" + fi + done + local average + average=$(awk "BEGIN { printf \"%.3f\", $sum / $count }" 2>/dev/null || echo "0") + + printf ' "%s": {"min": %s, "max": %s, "avg": %s}' "$name" "$minimum_value" "$maximum_value" "$average" + if [ "$name" != "stddev_ms" ]; then + echo "," + else + echo "" + fi +} + +# --------------------------------------------------------------------------- +# Dispatch (only when executed directly) +# --------------------------------------------------------------------------- + +if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then + return 0 +fi + +case "${1:-all}" in + code_formatting_check) code_formatting_check ;; + code_linting_check) code_linting_check ;; + build_native_cube_example) build_native_cube_example ;; + build_wasm_cube_example) build_wasm_cube_example ;; + benchmark_native_performance) benchmark_native_performance ;; + + all|"") + code_formatting_check + code_linting_check + build_native_cube_example + build_wasm_cube_example + benchmark_native_performance + ;; + + *) + echo "Usage: $0 [all|]" + echo "" + echo "Checks:" + echo " code_formatting_check cargo fmt + git diff" + echo " code_linting_check cargo clippy -D warnings" + echo " build_native_cube_example build examples/cube (native) + artifact size report" + echo " build_wasm_cube_example build WASM (cube) + artifact size report + budget" + echo " benchmark_native_performance build + run city (release)" + exit 1 + ;; +esac + +print_summary diff --git a/devops/tests/run_examples_tests.sh b/devops/tests/run_examples_tests.sh new file mode 100644 index 00000000..a912301a --- /dev/null +++ b/devops/tests/run_examples_tests.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash + +# REQUIREMENTS: Rust toolchain (cargo), a compiled PillLauncher binary +# (auto-discovered or set via PILL_LAUNCHER_BIN). + +# DESCRIPTION: Build every Pill example project in release mode and report +# binary sizes. Pill projects are built via PillLauncher; standalone +# Cargo crates (net_minimal) are built directly with cargo. +# +# Designed for both local development and GitHub Actions CI +# (ci-examples-tests.yml). + +# USAGE: bash devops/tests/run_examples_tests.sh [all|] +# +# all build all examples (default) +# examples/cube build a single example + +# EXAMPLE USAGE: +# bash devops/tests/run_examples_tests.sh all +# bash devops/tests/run_examples_tests.sh examples/city + +# --- SCRIPT --- + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../common.sh +source "$SCRIPT_DIR/../common.sh" + +# Shortcuts for colored section headers +BOLD='\033[1m' +CYAN='\033[0;36m' +NC='\033[0m' + +# All paths in this script are relative to the project root. +cd "$PROJECT_ROOT" + +# --------------------------------------------------------------------------- +# Example lists +# --------------------------------------------------------------------------- + +PILL_EXAMPLES=( + "examples/cube" + "examples/floating_pills" + "examples/italian_brainrot" + "examples/city" + "examples/empty" + "examples/pill_tunel" +) + +STANDALONE_CRATES=( + "examples/net_minimal/client" + "examples/net_minimal/server" +) + +# --------------------------------------------------------------------------- +# Build helpers +# --------------------------------------------------------------------------- + +_build_pill_example() { + local example_path="$1" + local build_exit_code=0 + invoke_launcher build -p "$example_path" -c release 2>&1 || build_exit_code=$? + + if [ "$build_exit_code" -ne 0 ]; then + report_fail "$example_path" "build failed (exit $build_exit_code)" + return + fi + + report_pass "$example_path build" + print_size_report "$example_path/build/release/data" + report_pass "$example_path artifact size report" +} + +_build_standalone_crate() { + local crate_path="$1" + local build_exit_code=0 + cargo build --manifest-path "$crate_path/Cargo.toml" --release 2>&1 || build_exit_code=$? + + if [ "$build_exit_code" -eq 0 ]; then + report_pass "$crate_path build" + else + report_fail "$crate_path" "build failed (exit $build_exit_code)" + fi +} + +# --------------------------------------------------------------------------- +# Main entry points +# --------------------------------------------------------------------------- + +build_all_examples() { + echo "" + echo -e "${BOLD}${CYAN}===============================================================================${NC}" + echo "Building all Pill example projects (release, shared target dir)" + + # Use a shared cargo target directory so pill_engine (and its + # dependencies) are reused when features match across examples. + export PILL_TARGET_DIR="$PWD/engine/target_projects/_examples" + + local example_index=0 + local total_examples=$((${#PILL_EXAMPLES[@]} + ${#STANDALONE_CRATES[@]})) + + for example_path in "${PILL_EXAMPLES[@]}"; do + example_index=$((example_index + 1)) + echo "" + echo "($example_index/$total_examples) $example_path" + echo "Building - this may take a while" + _build_pill_example "$example_path" + done + + for crate_path in "${STANDALONE_CRATES[@]}"; do + example_index=$((example_index + 1)) + echo "" + echo "($example_index/$total_examples) $crate_path" + echo "Building - this may take a while" + _build_standalone_crate "$crate_path" + done +} + +build_single_example() { + local example_path="$1" + echo "" + echo -e "${BOLD}${CYAN}===============================================================================${NC}" + echo "Building $example_path (release)" + echo "Building - this may take a while" + + if [ -d "$example_path/res" ]; then + _build_pill_example "$example_path" + elif [ -f "$example_path/Cargo.toml" ]; then + _build_standalone_crate "$example_path" + else + report_skip "$example_path" "not a valid project (no Cargo.toml or res/ found)" + fi +} + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + +if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then + return 0 +fi + +case "${1:-all}" in + all|"") + build_all_examples + ;; + *) + build_single_example "$1" + ;; +esac + +print_summary + diff --git a/devops/tests/run_pill_launcher_tests.sh b/devops/tests/run_pill_launcher_tests.sh new file mode 100644 index 00000000..e14bf2dc --- /dev/null +++ b/devops/tests/run_pill_launcher_tests.sh @@ -0,0 +1,623 @@ +#!/usr/bin/env bash + +# REQUIREMENTS: Rust toolchain (cargo, rustfmt, clippy), wasm-pack, +# a compiled PillLauncher binary (auto-discovered or set via +# PILL_LAUNCHER_BIN). + +# DESCRIPTION: Pill Launcher comprehensive tests - exercises every +# PillLauncher action (create, build, run, cargo, assets, docs, link, +# unlink) with all valid flag combinations and error-case parameters. +# +# Uses a temporary workspace for scaffolded projects. Designed for +# local development and GitHub Actions CI (ci-pill_launcher-tests.yml). + +# USAGE: bash devops/tests/run_pill_launcher_tests.sh [all|] +# +# all run all test groups (default) +# basics --help, --version, error handling, subcommand help +# create scaffold new projects +# build compile native (debug/release/hot-reload) + WASM +# cargo passthrough cargo commands (version, check, clippy, fmt) +# assets asset pipeline +# docs rustdoc generation (default + custom output) +# run build + launch (all compile modes, features, WASM, passthrough) +# hot-reload hot-reload edit-detect-rebuild workflow +# link IDE workspace linking + +# EXAMPLE USAGE: +# bash devops/tests/run_pill_launcher_tests.sh all +# bash devops/tests/run_pill_launcher_tests.sh create +# bash devops/tests/run_pill_launcher_tests.sh build + +# --- SCRIPT --- + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../common.sh +source "$SCRIPT_DIR/../common.sh" + +# Shortcuts for colored section headers +BOLD='\033[1m' +CYAN='\033[0;36m' +NC='\033[0m' + +# All paths in this script are relative to the project root. +cd "$PROJECT_ROOT" + +# --------------------------------------------------------------------------- +# 1. basics - smoke-test the binary itself +# --------------------------------------------------------------------------- + +test_launcher_basics() { + echo "" + echo -e "${BOLD}${CYAN}===============================================================================${NC}" + echo "(1/9) Launcher basics" + + # `PillLauncher --help` - Prints help (exit 0) + if invoke_launcher --help > /dev/null 2>&1; then + report_pass "--help" + else + report_fail "--help" "exit $?" + fi + + # `PillLauncher --version` - Prints version (exit 0) + invoke_launcher --version > /dev/null 2>&1 \ + && report_pass "--version" \ + || report_fail "--version" "exit $?" + + # `PillLauncher` (no subcommand) - Prints help text (exit non-zero) + local exit_code=0 + invoke_launcher > /dev/null 2>&1 || exit_code=$? + if [ "$exit_code" -ne 0 ]; then + report_pass "no arguments exits non-zero" + else + report_fail "no arguments" "exited 0" + fi + + # `PillLauncher nonexistent_action` - Error: unknown subcommand + assert_fail "unknown action" "error" nonexistent_action + + # `PillLauncher --path .` - Error: missing subcommand, flag unexpected + assert_fail "missing subcommand" "wasn't expected" --path . + + # `PillLauncher --help` - Prints help for that subcommand (exit 0) + for subcommand in create run build docs cargo assets link unlink; do + if invoke_launcher "$subcommand" --help > /dev/null 2>&1; then + report_pass "$subcommand --help" + else + report_fail "$subcommand --help" "exit $?" + fi + done + + # `PillLauncher run -c invalid` - Error: invalid value for `--compile-mode` + assert_fail "invalid compile mode" "error" run -p . -c invalid_mode + + # `PillLauncher run -t invalid` - Error: invalid value for `--target` + assert_fail "invalid target" "error" run -p . -t invalid_target +} + +# --------------------------------------------------------------------------- +# 2. create - scaffold new projects +# --------------------------------------------------------------------------- + +test_launcher_create() { + echo "" + echo -e "${BOLD}${CYAN}===============================================================================${NC}" + echo "(2/9) Create action" + + # `PillLauncher create -n MyGame` - Creates ./MyGame/ from template, + # rewrites Cargo.toml and config.ini with project name + absolute engine paths + local project_directory="$test_workspace_root/CreateTest" + assert_ok "create project" create -n CreateTest -p "$test_workspace_root" + + [ -d "$project_directory" ] && report_pass "project directory exists" || report_fail "project directory" "missing $project_directory" + [ -f "$project_directory/Cargo.toml" ] && report_pass "Cargo.toml exists" || report_fail "Cargo.toml" "missing" + [ -f "$project_directory/res/config.ini" ] && report_pass "config.ini exists" || report_fail "config.ini" "missing" + [ -d "$project_directory/src" ] && report_pass "src/ directory exists" || report_fail "src/" "missing" + + if grep -q "CreateTest" "$project_directory/res/config.ini" 2>/dev/null; then + report_pass "config.ini contains project name" + else + report_fail "config.ini" "project name not found" + fi + + # `PillLauncher create -n ExistingDir` (directory already exists) - + # Error: `Project directory ... already exists` + assert_fail "duplicate create" "already exists" create -n CreateTest -p "$test_workspace_root" + + # `PillLauncher create` (missing `-n`) - Error: `--name is required` + assert_fail "create without --name" "name" create -p "$test_workspace_root" + + # `PillLauncher create -n MyGame -p ../my_projects` - Creates in custom path + assert_ok "create with short flags" create -n CreateTest_ShortFlags -p "$test_workspace_root" +} + +# --------------------------------------------------------------------------- +# 3. build - compile projects (native: debug/release/hot-reload + WASM) +# --------------------------------------------------------------------------- + +test_launcher_build() { + echo "" + echo -e "${BOLD}${CYAN}===============================================================================${NC}" + echo "(3/9) Build action" + + local project_directory="$test_workspace_root/BuildTest" + invoke_launcher create -n BuildTest -p "$test_workspace_root" > /dev/null 2>&1 || { + report_skip "build tests" "create failed"; return + } + + # `PillLauncher build -p ./examples/cube` - Debug build β†’ copies artifacts to output directory + echo " (native debug - this may take a while)" + local build_output build_exit_code=0 + build_output=$(invoke_launcher build -p "$project_directory" -c debug 2>&1) || build_exit_code=$? + if [ "$build_exit_code" -eq 0 ]; then + report_pass "build native debug" + if [ -d "$project_directory/build/dev/data" ]; then + report_pass "build output data/ exists" + else + report_fail "build output" "missing data/" + fi + else + report_skip "build native debug" "exit $build_exit_code" + fi + + # `PillLauncher build -p ./examples/cube -c release` - Release build + echo " (native release)" + build_exit_code=0 + build_output=$(invoke_launcher build -p "$project_directory" -c release 2>&1) || build_exit_code=$? + if [ "$build_exit_code" -eq 0 ]; then + report_pass "build native release" + else + report_skip "build native release" "exit $build_exit_code" + fi + + # `PillLauncher build -p ./examples/cube -c hot-reload` - Hot-reload build + echo " (native hot-reload)" + build_exit_code=0 + build_output=$(invoke_launcher build -p "$project_directory" -c hot-reload 2>&1) || build_exit_code=$? + if [ "$build_exit_code" -eq 0 ]; then + report_pass "build native hot-reload" + if [ -d "$project_directory/build/hot-reload/data" ]; then + report_pass "hot-reload build output data/ exists" + else + report_fail "hot-reload build output" "missing data/" + fi + else + report_skip "build native hot-reload" "exit $build_exit_code" + fi + + # `PillLauncher build -p ./examples/cube --clean` - Rebuilds assets from source β†’ compiles + echo " (build --clean)" + build_exit_code=0 + build_output=$(invoke_launcher build -p "$project_directory" -c debug --clean 2>&1) || build_exit_code=$? + if [ "$build_exit_code" -eq 0 ]; then + report_pass "build --clean" + else + report_skip "build --clean" "exit $build_exit_code" + fi + + # `PillLauncher build -p ./examples/cube --additional-features "debug_ui"` - Builds with additional feature flags + echo " (build --additional-features)" + build_exit_code=0 + build_output=$(invoke_launcher build -p "$project_directory" -c debug --additional-features "project" 2>&1) || build_exit_code=$? + if [ "$build_exit_code" -eq 0 ]; then + report_pass "build --additional-features project" + else + report_skip "build --additional-features" "exit $build_exit_code" + fi + + # `PillLauncher build -p ./examples/cube -t web` - Builds WASM β†’ output at build/wasm/ + echo " (WASM)" + build_exit_code=0 + build_output=$(invoke_launcher build -p "$project_directory" -t web 2>&1) || build_exit_code=$? + if [ "$build_exit_code" -eq 0 ]; then + report_pass "build WASM" + if [ -d "$project_directory/build/wasm" ]; then + report_pass "WASM output directory exists" + else + report_fail "WASM output" "missing build/wasm/" + fi + else + report_skip "build WASM" "exit $build_exit_code" + fi + + # `PillLauncher build -p ./examples/cube -t web --max-wasm-size 512` - + # Builds WASM β†’ errors if binary exceeds 512 KB + echo " (WASM --max-wasm-size)" + build_exit_code=0 + build_output=$(invoke_launcher build -p "$project_directory" -t web --max-wasm-size 99999 2>&1) || build_exit_code=$? + if [ "$build_exit_code" -eq 0 ]; then + report_pass "build WASM --max-wasm-size accepted" + else + report_skip "build WASM --max-wasm-size" "exit $build_exit_code" + fi + + # `PillLauncher build -p -c release -t native` - Build with short flags + local short_directory="$test_workspace_root/ShortBuild" + invoke_launcher create -n ShortBuild -p "$test_workspace_root" > /dev/null 2>&1 || { + report_skip "short flag build" "create failed"; return + } + build_exit_code=0 + invoke_launcher build -p "$short_directory" -c release -t native > /dev/null 2>&1 || build_exit_code=$? + if [ "$build_exit_code" -eq 0 ]; then + report_pass "build with short flags" + else + report_skip "build short flags" "exit $build_exit_code" + fi +} + +# --------------------------------------------------------------------------- +# 4. cargo - passthrough arbitrary cargo commands +# --------------------------------------------------------------------------- + +test_launcher_cargo() { + echo "" + echo -e "${BOLD}${CYAN}===============================================================================${NC}" + echo "(4/9) Cargo passthrough" + + local project_directory="$test_workspace_root/CargoTest" + invoke_launcher create -n CargoTest -p "$test_workspace_root" > /dev/null 2>&1 || { + report_skip "cargo tests" "create failed"; return + } + + # `PillLauncher cargo -p ./examples/cube -- --version` - Runs cargo --version + assert_ok "cargo --version" cargo -p "$project_directory" -- --version + + # `PillLauncher cargo -p ./examples/cube -- check` - Runs cargo check in workspace context + echo " (cargo check)" + local cargo_exit_code=0 + invoke_launcher cargo -p "$project_directory" -- check > /dev/null 2>&1 || cargo_exit_code=$? + if [ "$cargo_exit_code" -eq 0 ]; then + report_pass "cargo check on project" + else + report_skip "cargo check" "exit $cargo_exit_code (may need full workspace)" + fi + + # `PillLauncher cargo -p ./examples/cube -- fmt` - Runs cargo fmt in workspace context + echo " (cargo fmt)" + cargo_exit_code=0 + invoke_launcher cargo -p "$project_directory" -- fmt --check > /dev/null 2>&1 || cargo_exit_code=$? + if [ "$cargo_exit_code" -eq 0 ]; then + report_pass "cargo fmt --check on project" + else + report_skip "cargo fmt" "exit $cargo_exit_code" + fi + + # `PillLauncher cargo -p ./examples/cube -- clippy` - Runs cargo clippy in workspace context + echo " (cargo clippy)" + cargo_exit_code=0 + invoke_launcher cargo -p "$project_directory" -- clippy > /dev/null 2>&1 || cargo_exit_code=$? + if [ "$cargo_exit_code" -eq 0 ]; then + report_pass "cargo clippy on project" + else + report_skip "cargo clippy" "exit $cargo_exit_code" + fi + + # `PillLauncher cargo -- fmt` (no args after `--`) - + # Error: `Must call cargo with at least one argument` + assert_fail "cargo empty arguments" "at least one argument" cargo -p "$project_directory" + + # `PillLauncher cargo -p ./examples/cube -- nonexistent_cargo_command` - Error from cargo/clap + assert_fail "cargo bad subcommand" "no such command\|wasn't expected" cargo -p "$project_directory" -- nonexistent_cargo_command + + # `PillLauncher cargo -p ./examples/cube -- --version` - Short flag, runs cargo --version + assert_ok "cargo with short flag" cargo -p "$project_directory" -- --version +} + +# --------------------------------------------------------------------------- +# 5. assets - run the asset pipeline +# --------------------------------------------------------------------------- + +test_launcher_assets() { + echo "" + echo -e "${BOLD}${CYAN}===============================================================================${NC}" + echo "(5/9) Assets action" + + local project_directory="$test_workspace_root/AssetsTest" + invoke_launcher create -n AssetsTest -p "$test_workspace_root" > /dev/null 2>&1 || { + report_skip "assets tests" "create failed"; return + } + + # `PillLauncher assets -p ./examples/cube` - Runs asset pipeline on res/ (incremental) + assert_ok "assets on empty project" assets -p "$project_directory" + + # `PillLauncher assets -p ./examples/cube --clean` - Deletes all cooked assets β†’ rebuilds + assert_ok "assets --clean" assets -p "$project_directory" --clean + + # `PillLauncher assets -p ` - Works with short -p flag + assert_ok "assets with short flag" assets -p "$project_directory" +} + +# --------------------------------------------------------------------------- +# 6. docs - generate rustdoc +# --------------------------------------------------------------------------- + +test_launcher_docs() { + echo "" + echo -e "${BOLD}${CYAN}===============================================================================${NC}" + echo "(6/9) Docs action" + + # `PillLauncher docs -o ../docs_output` - Outputs docs to custom directory + local docs_output_directory="$test_workspace_root/docs_output" + mkdir -p "$docs_output_directory" + + echo " (generating docs with -o)" + local docs_output docs_exit_code=0 + docs_output=$(invoke_launcher docs -o "$docs_output_directory" 2>&1) || docs_exit_code=$? + + if [ "$docs_exit_code" -eq 0 ]; then + report_pass "docs generation with -o" + if [ -d "$docs_output_directory/generated" ]; then + report_pass "docs output directory exists" + else + report_fail "docs output" "missing $docs_output_directory/generated/" + fi + elif echo "$docs_output" | grep -qi "plantuml\|Cannot locate\|manifest\|Cannot find Empty"; then + report_skip "docs generation" "PlantUML or required tool not available" + else + report_fail "docs generation" "exit $docs_exit_code" + fi +} + +# --------------------------------------------------------------------------- +# 7. run - build + launch projects (all compile modes + features + WASM) +# --------------------------------------------------------------------------- + +test_launcher_run() { + echo "" + echo -e "${BOLD}${CYAN}===============================================================================${NC}" + echo "(7/9) Run action" + + local project_directory="$test_workspace_root/RunTest" + invoke_launcher create -n RunTest -p "$test_workspace_root" > /dev/null 2>&1 || { + report_skip "run tests" "create failed"; return + } + + # `PillLauncher run -p ./examples/cube` - Debug build β†’ copies artifacts β†’ launches + echo " (run native debug)" + local run_output run_exit_code=0 + run_output=$(timeout 10s "$pill_launcher_bin" run -p "$project_directory" -c debug 2>&1) || run_exit_code=$? + if [ "$run_exit_code" -eq 0 ] || [ "$run_exit_code" -eq 124 ]; then + report_pass "run native debug" + else + report_skip "run native debug" "exit $run_exit_code" + fi + + # `PillLauncher run -p ./examples/cube -c release` - Release build β†’ launch + echo " (run native release)" + run_exit_code=0 + run_output=$(timeout 10s "$pill_launcher_bin" run -p "$project_directory" -c release 2>&1) || run_exit_code=$? + if [ "$run_exit_code" -eq 0 ] || [ "$run_exit_code" -eq 124 ]; then + report_pass "run native release" + else + report_skip "run native release" "exit $run_exit_code" + fi + + # `PillLauncher run -p ./examples/cube -c hot-reload` - Hot-reload build β†’ launch with file watchers + echo " (run native hot-reload)" + run_exit_code=0 + run_output=$(timeout 10s "$pill_launcher_bin" run -p "$project_directory" -c hot-reload 2>&1) || run_exit_code=$? + if [ "$run_exit_code" -eq 0 ] || [ "$run_exit_code" -eq 124 ]; then + report_pass "run native hot-reload" + else + report_skip "run native hot-reload" "exit $run_exit_code" + fi + + # `PillLauncher run -p ./examples/cube --clean` - Rebuilds cooked assets β†’ builds β†’ launches + echo " (run --clean)" + run_exit_code=0 + run_output=$(timeout 10s "$pill_launcher_bin" run -p "$project_directory" -c debug --clean 2>&1) || run_exit_code=$? + if [ "$run_exit_code" -eq 0 ] || [ "$run_exit_code" -eq 124 ]; then + report_pass "run --clean" + else + report_skip "run --clean" "exit $run_exit_code" + fi + + # `PillLauncher run -p ./examples/cube -t web` - Builds WASM β†’ starts dev server on port 8080 + echo " (run WASM)" + run_exit_code=0 + run_output=$(timeout 10s "$pill_launcher_bin" run -p "$project_directory" -t web 2>&1) || run_exit_code=$? + if [ "$run_exit_code" -eq 0 ] || [ "$run_exit_code" -eq 124 ]; then + report_pass "run WASM" + else + report_skip "run WASM" "exit $run_exit_code" + fi + + # `PillLauncher run -p ./examples/cube -t web --wasm-port 3000` - Builds WASM β†’ starts dev server on port 3000 + echo " (run WASM --wasm-port)" + run_exit_code=0 + run_output=$(timeout 10s "$pill_launcher_bin" run -p "$project_directory" -t web --wasm-port 9090 2>&1) || run_exit_code=$? + if [ "$run_exit_code" -eq 0 ] || [ "$run_exit_code" -eq 124 ]; then + report_pass "run WASM --wasm-port 9090" + else + report_skip "run WASM --wasm-port" "exit $run_exit_code" + fi + + # `PillLauncher run -p ./examples/cube -- --help` - Passes `--help` to the running project executable + # The project may not support --help (game loop runs until timeout); we only verify the launcher accepts `--`. + local passthrough_exit_code=0 + timeout 5s "$pill_launcher_bin" run -p "$project_directory" -c debug -- --help > /dev/null 2>&1 || passthrough_exit_code=$? + if [ "$passthrough_exit_code" -eq 0 ] || [ "$passthrough_exit_code" -eq 124 ]; then + report_pass "run with passthrough arguments" + else + report_skip "run passthrough" "exit $passthrough_exit_code (game may not support --help)" + fi + + # Clean up any lingering game windows / dev servers + taskkill //F //IM RunTest.exe > /dev/null 2>&1 || true + kill_server_on_port 8080 + kill_server_on_port 9090 +} + +# --------------------------------------------------------------------------- +# 8. hot-reload - edit-detect-rebuild workflow +# --------------------------------------------------------------------------- + +test_launcher_hot_reload() { + echo "" + echo -e "${BOLD}${CYAN}===============================================================================${NC}" + echo "(8/9) Hot-reload workflow" + + # `PillLauncher run -p ./examples/cube -c hot-reload` - + # Hot-reload cycle: launch β†’ edit source β†’ detect change β†’ rebuild DLL β†’ survive + local project_directory="$test_workspace_root/HotReloadTest" + invoke_launcher create -n HotReloadTest -p "$test_workspace_root" > /dev/null 2>&1 || { + report_skip "hot-reload tests" "create failed"; return + } + + echo " (starting hot-reload run in background)" + invoke_launcher run -p "$project_directory" -c hot-reload > /dev/null 2>&1 & + local launcher_pid=$! + + # Wait for the project to finish building and start running + sleep 5 + + # Verify the process is still running (window should be open) + if kill -0 "$launcher_pid" 2>/dev/null; then + report_pass "hot-reload process started" + else + report_skip "hot-reload process" "process died before we could test" + return + fi + + # Touch a source file to trigger a rebuild + echo " (touching source file to trigger hot-reload)" + local source_file="$project_directory/src/project.rs" + if [ -f "$source_file" ]; then + touch "$source_file" + report_pass "touched project.rs" + else + # Some templates may use a different structure + local any_source + any_source=$(find "$project_directory/src" -name "*.rs" -type f | head -1) + if [ -n "$any_source" ]; then + touch "$any_source" + report_pass "touched source file: $any_source" + else + report_skip "touch source" "no .rs files found" + fi + fi + + # Give the file watcher time to detect the change and trigger a rebuild + sleep 5 + + # Verify the process survived the hot-reload + if kill -0 "$launcher_pid" 2>/dev/null; then + report_pass "hot-reload process survived rebuild" + else + report_skip "hot-reload survived" "process died (may be normal on headless CI)" + fi + + # Clean up - kill the launcher and any spawned game processes + kill "$launcher_pid" 2>/dev/null || true + wait "$launcher_pid" 2>/dev/null || true + taskkill //F //IM HotReloadTest.exe > /dev/null 2>&1 || true + report_pass "hot-reload process terminated cleanly" +} + +# --------------------------------------------------------------------------- +# 9. link / unlink - IDE workspace membership +# --------------------------------------------------------------------------- + +test_launcher_link() { + echo "" + echo -e "${BOLD}${CYAN}===============================================================================${NC}" + echo "(9/9) Link / Unlink actions" + + # Use the existing cube example (no need to create a new one) + local project_path="examples/cube" + + # Unlink first to ensure a clean state (unlink takes no arguments) + invoke_launcher unlink > /dev/null 2>&1 || true + + # `PillLauncher link -p ./examples/cube` - Adds project to engine/Cargo.toml workspace members + local link_output link_exit_code=0 + link_output=$(invoke_launcher link -p "$project_path" 2>&1) || link_exit_code=$? + if [ "$link_exit_code" -eq 0 ]; then + if grep -q "$project_path" "engine/Cargo.toml" 2>/dev/null; then + report_pass "link adds project to workspace members" + else + report_fail "link" "project not found in engine/Cargo.toml" + fi + else + report_fail "link" "exit $link_exit_code" + fi + + # `PillLauncher link -p ./examples/cube` (already linked) - + # Prints `Project already linked: ...` + local relink_exit_code=0 + invoke_launcher link -p "$project_path" > /dev/null 2>&1 || relink_exit_code=$? + if [ "$relink_exit_code" -eq 0 ]; then + report_pass "link is idempotent" + else + report_fail "link idempotent" "exit $relink_exit_code" + fi + + # `PillLauncher unlink` - Removes the project from workspace members + local unlink_exit_code=0 + invoke_launcher unlink > /dev/null 2>&1 || unlink_exit_code=$? + if [ "$unlink_exit_code" -eq 0 ]; then + if grep -q "$project_path" "engine/Cargo.toml" 2>/dev/null; then + report_fail "unlink" "project still in engine/Cargo.toml" + else + report_pass "unlink removes project from workspace members" + fi + else + report_fail "unlink" "exit $unlink_exit_code" + fi + + # Restore clean state + invoke_launcher unlink > /dev/null 2>&1 || true + git checkout -- engine/Cargo.toml 2>/dev/null || true +} + +# --------------------------------------------------------------------------- +# Dispatch (only when executed directly) +# --------------------------------------------------------------------------- + +if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then + return 0 +fi + +case "${1:-all}" in + basics) test_launcher_basics ;; + create) test_launcher_create ;; + build) test_launcher_build ;; + cargo) test_launcher_cargo ;; + assets) test_launcher_assets ;; + docs) test_launcher_docs ;; + run) test_launcher_run ;; + hot-reload) test_launcher_hot_reload ;; + link) test_launcher_link ;; + + all|"") + test_launcher_basics + test_launcher_create + test_launcher_build + test_launcher_cargo + test_launcher_assets + test_launcher_docs + test_launcher_run + test_launcher_hot_reload + test_launcher_link + ;; + + *) + echo "Usage: $0 [all|]" + echo "" + echo "Test groups:" + echo " basics --help, --version, error handling, subcommand help" + echo " create scaffold projects (normal, duplicate, missing name, short flags)" + echo " build compile native (debug/release/hot-reload) + WASM + --clean + --additional-features" + echo " cargo passthrough commands (--version, check, fmt, clippy, errors)" + echo " assets asset pipeline (incremental, --clean, short flag)" + echo " docs rustdoc generation (custom -o output)" + echo " run build + launch (debug/release/hot-reload, --clean, --additional-features, WASM, --wasm-port, passthrough)" + echo " hot-reload edit-detect-rebuild workflow verification" + echo " link IDE workspace link / unlink / idempotent" + exit 1 + ;; +esac + +print_summary diff --git a/devops/utils/generate_documentation.sh b/devops/utils/generate_documentation.sh new file mode 100644 index 00000000..ed30830d --- /dev/null +++ b/devops/utils/generate_documentation.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +# REQUIREMENTS: Rust toolchain (cargo), PlantUML (optional), a compiled +# PillLauncher binary. Set PILL_LAUNCHER_BIN to override +# auto-discovery, or ensure it is on PATH. + +# DESCRIPTION: Generate rustdoc documentation for all Pill engine crates. +# Produces two doc sets under /generated/: +# project_dev β€” public API (project + internal features) +# engine_dev β€” private items + pill_core +# PlantUML diagrams are pre-rendered if PlantUML is installed. + +# USAGE: bash devops/utils/generate_documentation.sh [-o ] +# +# -o Output directory (default: ../docs) + +# EXAMPLE USAGE: +# bash devops/utils/generate_documentation.sh +# bash devops/utils/generate_documentation.sh -o /tmp/pill-docs + +# --- SCRIPT --- + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../common.sh +source "$SCRIPT_DIR/../common.sh" + +# ---- helpers --------------------------------------------------------------- + +say() { echo -e "${GREEN}[INFO]${NC} $*"; } +die() { echo -e "${RED}[FATAL]${NC} $*" >&2; exit 1; } + +# ---- parse args ------------------------------------------------------------ + +OUTPUT_DIR="../docs" +while getopts "o:h" opt; do + case "$opt" in + o) OUTPUT_DIR="$OPTARG" ;; + h) echo "Usage: $0 [-o ]"; exit 0 ;; + *) echo "Usage: $0 [-o ]"; exit 1 ;; + esac +done + +# ---- main ------------------------------------------------------------------ + +say "Project root : $PROJECT_ROOT" +say "Launcher : $pill_launcher_bin" +say "Output : $OUTPUT_DIR" + +mkdir -p "$OUTPUT_DIR" + +say "Generating documentation (this may take a while)..." +if invoke_launcher docs -o "$OUTPUT_DIR"; then + if [ -d "$OUTPUT_DIR/generated" ]; then + say "Documentation generated successfully β†’ $OUTPUT_DIR/generated/" + else + die "Launcher reported success but $OUTPUT_DIR/generated/ is missing" + fi +else + die "Documentation generation failed" +fi diff --git a/engine/Cargo.toml b/engine/Cargo.toml index adcc520c..1a8c2bef 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -9,15 +9,12 @@ members = [ "pill_native", "pill_web", "pill_runtime", - "NO_PATH", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) ] # The per-game wasm cdylib (pill_web_app) lives as a template at # pill_launcher/res/templates/wasm/ and is instantiated per-game by the launcher. # The shared web runtime is the workspace crate `pill_web`. -exclude = [ - "pill_launcher", -] +exclude = ["pill_launcher"] [profile.hot-reload] inherits = "dev" diff --git a/engine/pill_abi/src/lib.rs b/engine/pill_abi/src/lib.rs index 8acf144d..547d3bb7 100644 --- a/engine/pill_abi/src/lib.rs +++ b/engine/pill_abi/src/lib.rs @@ -14,8 +14,8 @@ pub struct PillEngineCreateArgsV1 { pub window_ptr: *const c_void, /// UTF-8, null-terminated - pub game_dylib_path: *const c_char, - pub game_resources_dir: *const c_char, + pub project_dylib_path: *const c_char, + pub project_resources_dir: *const c_char, pub config_path: *const c_char, pub initial_w: u32, @@ -29,7 +29,7 @@ pub struct PillEngineApiV1 { pub struct_size: u32, // set to size of Self pub abi_version: u32, - /// Later stable hash that engine/game expect of each other + /// Later stable hash that engine/project expect of each other pub abi_hash: u64, pub last_error_utf8: extern "C" fn() -> *const c_char, @@ -62,7 +62,12 @@ pub struct PillEngineApiV1 { pub mouse_wheel_line: extern "C" fn(engine: EngineHandle, dx: f32, dy: f32), // --- Hot reload --- - pub reload_game: extern "C" fn(engine: EngineHandle, game_dylib_path: *const c_char) -> i32, + pub reload_project: + extern "C" fn(engine: EngineHandle, project_dylib_path: *const c_char) -> i32, + + // --- Exit signal (benchmarks / graceful shutdown) --- + /// Returns 1 if the engine has requested graceful exit, 0 otherwise. + pub is_exit_requested: extern "C" fn(engine: EngineHandle) -> i32, } pub const PILL_ENGINE_API_SYMBOL: &[u8] = b"get_pill_engine_api_v1\0"; diff --git a/engine/pill_assets/Cargo.toml b/engine/pill_assets/Cargo.toml index 6ab39832..8f629474 100644 --- a/engine/pill_assets/Cargo.toml +++ b/engine/pill_assets/Cargo.toml @@ -9,6 +9,6 @@ path = "src/lib.rs" [dependencies] anyhow = "1.0" glob = "0.3" -png = "0.17" -tobj = "3.0" -bytemuck = { version = "1.4", features = ["derive"] } +png = "0.18" +tobj = "4.0" +bytemuck = { version = "1", features = ["derive"] } diff --git a/engine/pill_assets/src/rules/hlsl_to_wgsl.rs b/engine/pill_assets/src/rules/hlsl_to_wgsl.rs index 6b41f694..4017df48 100644 --- a/engine/pill_assets/src/rules/hlsl_to_wgsl.rs +++ b/engine/pill_assets/src/rules/hlsl_to_wgsl.rs @@ -52,7 +52,7 @@ impl Rule for HlslToWgsl { let out = match result { Ok(out) => out, Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!( - "slangc not found on PATH. Install Slang from https://github.com/shader-slang/slang/releases and add slangc to PATH." + "Slangc was not found on PATH. Install Slang from https://github.com/shader-slang/slang/releases and add slangc to PATH." ), Err(e) => return Err(e).context("failed to spawn slangc"), }; @@ -61,7 +61,7 @@ impl Rule for HlslToWgsl { let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); bail!( - "slangc exited {:?} for {input:?}\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}", + "Slangc exited {:?} for {input:?}\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}", out.status.code() ); } diff --git a/engine/pill_assets/src/rules/png_to_cooked_tex.rs b/engine/pill_assets/src/rules/png_to_cooked_tex.rs index 43b3c7d2..511334e7 100644 --- a/engine/pill_assets/src/rules/png_to_cooked_tex.rs +++ b/engine/pill_assets/src/rules/png_to_cooked_tex.rs @@ -1,3 +1,4 @@ +use std::io::Cursor; use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; @@ -25,11 +26,11 @@ impl Rule for PngToCookedTex { fn build(&self, input: &Path, output: &Path) -> Result<()> { let bytes = std::fs::read(input).with_context(|| format!("read {input:?}"))?; - let mut decoder = png::Decoder::new(bytes.as_slice()); + let mut decoder = png::Decoder::new(Cursor::new(bytes)); // Expand palette/indexed images to RGB and low-bit depths to 8-bit. decoder.set_transformations(png::Transformations::EXPAND); let mut reader = decoder.read_info().context("png read_info")?; - let mut buf = vec![0u8; reader.output_buffer_size()]; + let mut buf = vec![0u8; reader.output_buffer_size().unwrap_or(0)]; let info = reader.next_frame(&mut buf).context("png next_frame")?; let width = info.width; let height = info.height; diff --git a/engine/pill_core/Cargo.toml b/engine/pill_core/Cargo.toml index 184154e8..f2ad840b 100644 --- a/engine/pill_core/Cargo.toml +++ b/engine/pill_core/Cargo.toml @@ -4,26 +4,26 @@ version = "0.1.0" authors = [ # Extend this list when submiting PR "Mateusz SzymoΕ„ski ", - "Jakub Duchniewicz " - ] + "Jakub Duchniewicz ", +] edition = "2021" [dependencies] -glam = { version = "0.30.8", features = ["serde"] } -thiserror = "1.0.30" -boolinator = "2.4.0" +glam = { version = "0.33", features = ["serde"] } +thiserror = "2" +boolinator = "2.4" log = { version = "0.4", features = ["release_max_level_off"] } -web-time = "1.1" +web-time = "1" # Native-only dependencies (not available on WASM) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -colored = "2.0.0" +colored = "3" config = "0.11" -env_logger = "0.9.0" -chrono = "0.4.19" -strum_macros = "0.27.2" -strum = "0.27.2" +env_logger = "0.11" +chrono = "0.4" +strum_macros = "0.28" +strum = "0.28" bincode = "1.0" -renet = "1.0.0" -renet_netcode = "1.0.0" -serde = { version = "1.0.228", features = ["derive"] } +renet = "2" +renet_netcode = "2" +serde = { version = "1", features = ["derive"] } diff --git a/engine/pill_core/src/error.rs b/engine/pill_core/src/error.rs index cd743ca0..a667942b 100644 --- a/engine/pill_core/src/error.rs +++ b/engine/pill_core/src/error.rs @@ -30,8 +30,8 @@ pub enum RendererError { #[derive(Error, Debug, Clone)] pub enum EngineError<'a> { // Config - #[error("Invalid {} config file", "Game".module_object_style())] - InvalidGameConfig(), + #[error("Invalid {} config file", "Project".module_object_style())] + InvalidProjectConfig(), // Scene #[error("There is no active {} set", "Scene".general_object_style())] diff --git a/engine/pill_core/src/lib.rs b/engine/pill_core/src/lib.rs index be203ebb..83c2c9dc 100644 --- a/engine/pill_core/src/lib.rs +++ b/engine/pill_core/src/lib.rs @@ -34,7 +34,13 @@ impl ErrorContext for Option { impl> ErrorContext for std::result::Result { #[inline] fn context>(self, err: E) -> Result { - self.map_err(|_| err.into()) + self.map_err(|original| { + let context_msg = err.into(); + let original_msg = original.into(); + // Chain: the new context wraps the original, preserving both messages. + let chained: PillError = format!("{context_msg}: {original_msg}").into(); + chained + }) } } @@ -57,7 +63,7 @@ pub use bitmask_utils::{ }; pub use utils::{ - enum_variant_eq, get_enum_variant_type_name, get_game_error_message, get_type_name, + enum_variant_eq, get_enum_variant_type_name, get_project_error_message, get_type_name, get_value_type_name, validate_asset_path, }; diff --git a/engine/pill_core/src/log.rs b/engine/pill_core/src/log.rs index d1068c15..0af3744b 100644 --- a/engine/pill_core/src/log.rs +++ b/engine/pill_core/src/log.rs @@ -166,9 +166,9 @@ macro_rules! info { $crate::log_context!(info, $ctx, $($arg)+) }; - // Fallback to standard logging + // Fallback to standard logging at INFO level ($($arg:tt)+) => { - $crate::log_context!(debug, "default", $($arg)+) + $crate::log_context!(info, "default", $($arg)+) }; } diff --git a/engine/pill_core/src/networking.rs b/engine/pill_core/src/networking.rs index 5321dc7a..2c42d345 100644 --- a/engine/pill_core/src/networking.rs +++ b/engine/pill_core/src/networking.rs @@ -37,7 +37,7 @@ use std::{ #[repr(u8)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum NetworkAction { - /// Gameplay/state update payload. + /// Project state update payload. Update = 0, /// A client has joined (emitted by server events). Join = 1, diff --git a/engine/pill_core/src/utils.rs b/engine/pill_core/src/utils.rs index 057988ac..d21bb1e1 100644 --- a/engine/pill_core/src/utils.rs +++ b/engine/pill_core/src/utils.rs @@ -65,10 +65,10 @@ macro_rules! define_component_handle { // --- Other --- #[inline] -pub fn get_game_error_message(result: Result<()>) -> Option { +pub fn get_project_error_message(result: Result<()>) -> Option { result.err().map(|e| { use std::error::Error; - let mut message = format!("Game error: {e}\n"); + let mut message = format!("Pill project error: {e}\n"); let mut source = e.source(); let mut i = 0usize; while let Some(s) = source { @@ -81,12 +81,17 @@ pub fn get_game_error_message(result: Result<()>) -> Option { } #[macro_export] -macro_rules! create_game { - ($game_contructor:expr, $game_trait:path) => { +macro_rules! create_project { + ($project_contructor:expr, $project_trait:path) => { #[no_mangle] - pub extern "C" fn get_game() -> *mut std::ffi::c_void { - let game: Box = Box::new($game_contructor); - Box::into_raw(Box::new(game)) as *mut std::ffi::c_void + pub extern "C" fn get_project() -> *mut std::ffi::c_void { + let project: Box = Box::new($project_contructor); + Box::into_raw(Box::new(project)) as *mut std::ffi::c_void + } + + /// WASM entry point β€” returns a boxed project for pill_web::run(). + pub fn create_project() -> Box { + Box::new($project_contructor) } }; } diff --git a/engine/pill_engine/Cargo.toml b/engine/pill_engine/Cargo.toml index 1356fde9..4754e3a8 100644 --- a/engine/pill_engine/Cargo.toml +++ b/engine/pill_engine/Cargo.toml @@ -4,49 +4,53 @@ version = "0.1.0" edition = "2021" [dependencies] -pill_core = {path = "../pill_core"} +pill_core = { path = "../pill_core" } # Errors -thiserror = "1.0.30" +thiserror = "2.0" # Loggers log = { version = "0.4", features = ["release_max_level_off"] } # Loaders -tobj = { version = "3.0", optional = true } +tobj = { version = "4.0", optional = true } # Other readonly = "0.2" -winit = "0.30.12" -boolinator = "2.4.0" -bytemuck = { version = "1.4", features = [ "derive" ] } -lazy_static = "1.4.0" -dyn-clone = "1.0.4" -egui = { version = "0.32.1", optional = true } -serde = { version = "1.0", features = ["derive"] } +winit = "0.30" +boolinator = "2.4" +bytemuck = { version = "1", features = ["derive"] } +lazy_static = "1.4" +dyn-clone = "1.0" +egui = { version = "0.32", optional = true } +serde = { version = "1", features = ["derive"] } bincode = "1.0" -once_cell = "1.21.3" -bitvec = "1.0.1" -rand = "0.9.2" -web-time = "1.1" -rapier3d = { version = "0.32.0", optional = true } +once_cell = "1" +bitvec = "1.0" +rand = "0.10" +web-time = "1" +rapier3d = { version = "0.34", optional = true } # Native-only dependencies (not available on WASM) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -png = "0.17" +png = "0.18" rodio = { version = "0.20", features = ["wav", "mp3"] } -gilrs = "0.11.0" +gilrs = "0.11" + +# WASM-only dependencies +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.4", features = ["wasm_js"] } [build-dependencies] -fs_extra = "1.2" +fs_extra = "1" pill_assets = { path = "../pill_assets" } [features] -game = [] +project = [] internal = [] headless = [] networking = [] physics = ["dep:rapier3d"] debug_ui = ["dep:egui"] obj_loading = ["dep:tobj"] -all = ["game", "internal", "networking"] +all = ["project", "internal", "networking"] diff --git a/engine/pill_engine/build.rs b/engine/pill_engine/build.rs index 17880709..14abb20c 100644 --- a/engine/pill_engine/build.rs +++ b/engine/pill_engine/build.rs @@ -13,7 +13,7 @@ fn main() { rules: default_rules(), }; - let stats = pipeline.run().expect("asset pipeline failed"); + let stats = pipeline.run().expect("Asset pipeline operation failed"); println!("cargo:rerun-if-changed=build.rs"); // Header-include directory: tracked recursively so changes to shared diff --git a/engine/pill_engine/src/app_config.rs b/engine/pill_engine/src/app_config.rs index ef7cd7a2..c5f88b40 100644 --- a/engine/pill_engine/src/app_config.rs +++ b/engine/pill_engine/src/app_config.rs @@ -1,7 +1,5 @@ -use std::collections::HashMap; - use pill_core::Result; -use std::str::FromStr; +use std::collections::HashMap; #[derive(Debug, Clone, PartialEq, Eq)] pub enum CompileMode { @@ -103,6 +101,14 @@ impl EngineConfig { _ => Err(format!("Config key {key} is not a valid bool: {v}").into()), } } + + pub fn get_str(&self, key: &str) -> Result { + use pill_core::PillError; + self.values + .get(&key.to_ascii_uppercase()) + .cloned() + .ok_or_else(|| -> PillError { format!("{key} not found in config").into() }) + } } impl EngineProcessInfo { diff --git a/engine/pill_engine/src/config.rs b/engine/pill_engine/src/config.rs index eb464ac6..8046ae8c 100644 --- a/engine/pill_engine/src/config.rs +++ b/engine/pill_engine/src/config.rs @@ -21,7 +21,7 @@ use std::any::TypeId; // --- General --- -pub const PANIC_ON_GAME_ERRORS: bool = true; +pub const PANIC_ON_PROJECT_ERRORS: bool = true; // --- ECS --- @@ -40,46 +40,46 @@ pub struct SystemConfig { pub const INPUT_SYSTEM: SystemConfig = SystemConfig { name: "input_system", system_function: input_system, - update_phase: UpdatePhase::PreGame, + update_phase: UpdatePhase::PreProject, }; #[cfg(not(target_arch = "wasm32"))] pub const HAPTICS_SYSTEM: SystemConfig = SystemConfig { name: "haptics_system", system_function: haptics_system, - update_phase: UpdatePhase::PostGame, + update_phase: UpdatePhase::PostProject, }; pub const TIME_SYSTEM: SystemConfig = SystemConfig { name: "time_system", system_function: time_system, - update_phase: UpdatePhase::PostGame, + update_phase: UpdatePhase::PostProject, }; #[cfg(not(target_arch = "wasm32"))] pub const AUDIO_SYSTEM: SystemConfig = SystemConfig { name: "audio_system", system_function: audio_system, - update_phase: UpdatePhase::PostGame, + update_phase: UpdatePhase::PostProject, }; pub const DEFERRED_UPDATE_SYSTEM: SystemConfig = SystemConfig { name: "deferred_update_system", system_function: deferred_update_system, - update_phase: UpdatePhase::PostGame, + update_phase: UpdatePhase::PostProject, }; pub const RENDERING_SYSTEM: SystemConfig = SystemConfig { name: "rendering_system", system_function: rendering_system, - update_phase: UpdatePhase::PostGame, + update_phase: UpdatePhase::PostProject, }; #[cfg(feature = "physics")] pub const PHYSICS_SYSTEM: SystemConfig = SystemConfig { name: "physics_system", system_function: physics_system, - update_phase: UpdatePhase::PostGame, + update_phase: UpdatePhase::PostProject, }; // --- Resources --- @@ -92,7 +92,7 @@ pub const MAX_TEXTURES: usize = 10; pub const MAX_MESHES: usize = 10; pub const MAX_SOUNDS: usize = 10; -// Convention: All resource names starting with "pill_engine_" are restricted, cannot be added and removed from game +// Convention: All resource names starting with "pill_engine_" are restricted, cannot be added and removed from project pub const DEFAULT_RESOURCE_PREFIX: &str = "pill_engine"; pub const DEFAULT_COLOR_TEXTURE_NAME: &str = "pill_engine_default_color"; pub const DEFAULT_NORMAL_TEXTURE_NAME: &str = "pill_engine_default_normal"; diff --git a/engine/pill_engine/src/ecs/components/build_status_indicator_component.rs b/engine/pill_engine/src/ecs/components/build_status_indicator_component.rs index 8b0f0b38..c0366c7e 100644 --- a/engine/pill_engine/src/ecs/components/build_status_indicator_component.rs +++ b/engine/pill_engine/src/ecs/components/build_status_indicator_component.rs @@ -1,19 +1,6 @@ -#![cfg(feature = "debug_ui")] +use crate::ecs::components::{GlobalComponent, GlobalComponentStorage}; -use std::collections::HashMap; - -use crate::{ - ecs::{ - components::{GlobalComponent, GlobalComponentStorage}, - UpdatePhase, - }, - engine::Engine, -}; - -use egui::Ui; -use pill_core::{PillTypeMapKey, Timer, TimerRecord}; - -use pill_core::{ErrorContext, Result}; +use pill_core::PillTypeMapKey; #[derive(Copy, Clone)] pub enum BuildStatus { @@ -25,7 +12,7 @@ pub enum BuildStatus { // display the build type + target // also display the last hot-reload status (compiled the code and reloaded or failed) with red/green // light indicator -// updated every time we try hot-reloading and build_game +// updated every time we try hot-reloading and build_project pub struct BuildStatusIndicatorComponent { pub(crate) last_build_status: BuildStatus, } diff --git a/engine/pill_engine/src/ecs/components/camera_component.rs b/engine/pill_engine/src/ecs/components/camera_component.rs index 00fa3d6a..8147cdfb 100644 --- a/engine/pill_engine/src/ecs/components/camera_component.rs +++ b/engine/pill_engine/src/ecs/components/camera_component.rs @@ -117,7 +117,7 @@ impl CameraComponent { } } -// This needed so that renderer can get renderer camera handle from camera component while it is still hidden in game API +// This needed so that renderer can get renderer camera handle from camera component while it is still hidden in project API pub fn get_renderer_resource_handle_from_camera_component( camera_component: &CameraComponent, ) -> RendererCameraHandle { diff --git a/engine/pill_engine/src/ecs/components/egui_manager_component.rs b/engine/pill_engine/src/ecs/components/egui_manager_component.rs index bcc8dec7..7a906fff 100644 --- a/engine/pill_engine/src/ecs/components/egui_manager_component.rs +++ b/engine/pill_engine/src/ecs/components/egui_manager_component.rs @@ -1,5 +1,3 @@ -#![cfg(feature = "debug_ui")] - use std::{collections::HashMap, sync::Arc}; use crate::{ diff --git a/engine/pill_engine/src/ecs/components/input_component.rs b/engine/pill_engine/src/ecs/components/input_component.rs index 0aaea872..b3e6fb83 100644 --- a/engine/pill_engine/src/ecs/components/input_component.rs +++ b/engine/pill_engine/src/ecs/components/input_component.rs @@ -4,7 +4,9 @@ use crate::{ internal::NUM_SUPPORTED_GAMEPADS, }; -use pill_core::{PillError, PillTypeMapKey, Result, Vector2f}; +#[cfg(target_arch = "wasm32")] +use pill_core::Result; +use pill_core::{PillError, PillTypeMapKey, Vector2f}; use std::collections::{HashMap, VecDeque}; use winit::event::{ElementState, MouseScrollDelta}; diff --git a/engine/pill_engine/src/ecs/components/network_manager_component.rs b/engine/pill_engine/src/ecs/components/network_manager_component.rs index bab4fe96..72e25576 100644 --- a/engine/pill_engine/src/ecs/components/network_manager_component.rs +++ b/engine/pill_engine/src/ecs/components/network_manager_component.rs @@ -126,7 +126,7 @@ type InterpolationHookFn = fn(&mut Engine) -> Result<()>; /// (`UPDATE_FREQUENCY_HZ`). /// /// Handler maps let you register type-based spawn/despawn functions without -/// coupling your networking systems to specific game types. +/// coupling your networking systems to specific project types. pub struct NetworkManagerComponent { /// Current side (server or client), with its state. pub side: NetworkSide, diff --git a/engine/pill_engine/src/ecs/components/network_state_component.rs b/engine/pill_engine/src/ecs/components/network_state_component.rs index ae9b01e8..dee30da2 100644 --- a/engine/pill_engine/src/ecs/components/network_state_component.rs +++ b/engine/pill_engine/src/ecs/components/network_state_component.rs @@ -29,7 +29,7 @@ pub enum NetworkEntityState { /// Per-entity replication metadata and latest network-relevant snapshot. /// /// This component is meant to be **lightweight** and focused on networking. -/// Game-specific data lives in your own components; you can mirror pieces +/// Project-specific data lives in your own components; you can mirror pieces /// here as needed for replication (e.g., position/orientation via `transform`). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct NetworkStateComponent { @@ -60,7 +60,7 @@ pub struct NetworkStateComponent { /// compute smooth visuals between authoritative snapshots. pub last_transform: Option, - /// Game-defined **entity type key** (e.g., `"Player"`, `"Crate"`, …). + /// Project-defined **entity type key** (e.g., `"Player"`, `"Crate"`, …). /// /// This is used to look up spawn/despawn handlers in the network manager. pub entity_type: String, diff --git a/engine/pill_engine/src/ecs/components/time_component.rs b/engine/pill_engine/src/ecs/components/time_component.rs index e047bb5a..b5a1dd99 100644 --- a/engine/pill_engine/src/ecs/components/time_component.rs +++ b/engine/pill_engine/src/ecs/components/time_component.rs @@ -7,7 +7,7 @@ use pill_core::Result; #[readonly::make] pub struct TimeComponent { #[readonly] - pub time: f32, // Time elapsed from the start of the engine/game in seconds + pub time: f32, // Time elapsed from the start of the engine/project in seconds #[readonly] pub delta_time: f32, // Time of last frame in seconds } diff --git a/engine/pill_engine/src/ecs/systems/build_status_system.rs b/engine/pill_engine/src/ecs/systems/build_status_system.rs index 34749db0..8b0836b9 100644 --- a/engine/pill_engine/src/ecs/systems/build_status_system.rs +++ b/engine/pill_engine/src/ecs/systems/build_status_system.rs @@ -1,9 +1,7 @@ -#![cfg(feature = "debug_ui")] use crate::{ ecs::{BuildStatus, BuildStatusIndicatorComponent}, engine::Engine, }; -use pill_core::{Matrix3f, Vector3f}; use pill_core::Result; diff --git a/engine/pill_engine/src/ecs/systems/networking_system.rs b/engine/pill_engine/src/ecs/systems/networking_system.rs index f5d52ff1..23e77cba 100644 --- a/engine/pill_engine/src/ecs/systems/networking_system.rs +++ b/engine/pill_engine/src/ecs/systems/networking_system.rs @@ -283,7 +283,7 @@ fn client_try_reconnect(engine: &mut Engine) -> Result<()> { /// Client-initiated graceful disconnect (sends `Exit`, updates state, closes). /// -/// Safe to call from UI or game logic wanting to go offline. +/// Safe to call from UI or project logic wanting to go offline. pub fn client_go_offline(engine: &mut Engine, reason: &str) -> Result<()> { let time = engine.get_global_component::()?.time; diff --git a/engine/pill_engine/src/ecs/systems/system_manager.rs b/engine/pill_engine/src/ecs/systems/system_manager.rs index b32745c6..519ff85e 100644 --- a/engine/pill_engine/src/ecs/systems/system_manager.rs +++ b/engine/pill_engine/src/ecs/systems/system_manager.rs @@ -18,9 +18,9 @@ pub struct System { #[derive(Debug, PartialEq, Eq, Hash, Clone)] pub enum UpdatePhase { - PreGame, - Game, - PostGame, + PreProject, + Project, + PostProject, } impl Display for UpdatePhase { @@ -30,7 +30,7 @@ impl Display for UpdatePhase { } pub struct SystemManager { - // Outer Vec preserves phase execution order (PreGame β†’ Game β†’ PostGame). + // Outer Vec preserves phase execution order (PreProject β†’ Project β†’ PostProject). // Inner Vec preserves system registration order within each phase. pub(crate) update_phases: Vec<(UpdatePhase, Vec<(String, System)>)>, } @@ -40,9 +40,9 @@ impl SystemManager { // Register phases Self { update_phases: vec![ - (UpdatePhase::PreGame, Vec::new()), - (UpdatePhase::Game, Vec::new()), - (UpdatePhase::PostGame, Vec::new()), + (UpdatePhase::PreProject, Vec::new()), + (UpdatePhase::Project, Vec::new()), + (UpdatePhase::PostProject, Vec::new()), ], } } diff --git a/engine/pill_engine/src/engine.rs b/engine/pill_engine/src/engine.rs index 5b5abd19..8f08dcb8 100644 --- a/engine/pill_engine/src/engine.rs +++ b/engine/pill_engine/src/engine.rs @@ -2,7 +2,7 @@ use crate::app_config::EngineProcessInfo; use crate::{app_config::EngineConfig, config::*, ecs::*, graphics::*, resources::*}; use pill_core::{ - debug, error, get_game_error_message, get_type_name, info, EngineError, LogContext, + debug, error, get_project_error_message, get_type_name, info, EngineError, LogContext, PillSlotMapKey, PillStyle, PillTypeMap, Timer, Vector2f, }; @@ -12,14 +12,13 @@ use winit::{dpi::PhysicalPosition, event::KeyEvent}; // ------------------------------------------------------------------------------- -pub type Game = Box; pub type KeyboardKey = winit::keyboard::KeyCode; pub type MouseButton = winit::event::MouseButton; -/// Engine <-> Game interface +/// Engine <-> Project interface /// -/// Entry point of the game project. Mandatory to implement. -pub trait PillGame { +/// Entry point of the project. Mandatory to implement. +pub trait PillProject { fn start(&self, engine: &mut Engine) -> Result<()>; } @@ -27,7 +26,7 @@ pub trait PillGame { pub struct Engine { pub(crate) config: EngineConfig, pub(crate) process: EngineProcessInfo, - pub(crate) game: Option, + pub(crate) project: Option>, pub(crate) renderer: Box, pub(crate) scene_manager: SceneManager, pub(crate) system_manager: SystemManager, @@ -36,8 +35,40 @@ pub struct Engine { pub(crate) input_queue: VecDeque, pub(crate) render_queue: Vec, pub(crate) window_size: winit::dpi::PhysicalSize, - pub(crate) game_resources_directory_path: std::path::PathBuf, + pub(crate) project_resources_directory_path: std::path::PathBuf, pub(crate) frame_delta_time: f32, // In milliseconds + pub(crate) exit_requested: bool, // Set by project to request graceful shutdown + pub(crate) frame_count: u64, // Monotonic counter, incremented each update() +} + +// ---- PUBLIC API (always available) ---------------------------------------- + +impl Engine { + /// Request graceful shutdown. The runtime checks this after each update() + /// and exits the event loop when true. + pub fn request_exit(&mut self) { + self.exit_requested = true; + } + + /// Whether the pill project has requested shutdown. + pub fn is_exit_requested(&self) -> bool { + self.exit_requested + } + + /// Monotonic frame counter β€” incremented at the start of each update(). + pub fn frame_count(&self) -> u64 { + self.frame_count + } + + /// Read-only access to the engine configuration. + pub fn config(&self) -> &EngineConfig { + &self.config + } + + /// Last frame's delta time in milliseconds. + pub fn frame_delta_time(&self) -> f32 { + self.frame_delta_time + } } // ---- INTERNAL ----------------------------------------------------------------- @@ -47,8 +78,8 @@ pub struct Engine { impl Engine { #[cfg(not(feature = "headless"))] pub fn new( - game: Box, - game_resources_directory_path: std::path::PathBuf, + project: Box, + project_resources_directory_path: std::path::PathBuf, renderer: Box, config: EngineConfig, process: EngineProcessInfo, @@ -60,7 +91,7 @@ impl Engine { Self { config, process, - game: Some(game), + project: Some(project), renderer, scene_manager: SceneManager::new(max_entity_count), system_manager: SystemManager::new(), @@ -69,13 +100,19 @@ impl Engine { input_queue: VecDeque::new(), render_queue: Vec::::with_capacity(max_entity_count), window_size: winit::dpi::PhysicalSize::::default(), - game_resources_directory_path, + project_resources_directory_path, frame_delta_time: 0.0, + exit_requested: false, + frame_count: 0, } } #[cfg(feature = "headless")] - pub fn new(game: Box, config: EngineConfig, process: EngineProcessInfo) -> Self { + pub fn new( + project: Box, + config: EngineConfig, + process: EngineProcessInfo, + ) -> Self { let max_entity_count = config .get_int("MAX_ENTITIES") .unwrap_or(MAX_ENTITIES as i64) as usize; @@ -84,7 +121,7 @@ impl Engine { Self { config, process, - game: Some(game), + project: Some(project), renderer: dummy_renderer, scene_manager: SceneManager::new(max_entity_count), system_manager: SystemManager::new(), @@ -93,8 +130,10 @@ impl Engine { input_queue: VecDeque::new(), render_queue: Vec::::with_capacity(max_entity_count), window_size: winit::dpi::PhysicalSize::::default(), - game_resources_directory_path: std::path::PathBuf::new(), + project_resources_directory_path: std::path::PathBuf::new(), frame_delta_time: 0.0.into(), + exit_requested: false, + frame_count: 0, } } @@ -271,33 +310,33 @@ impl Engine { Ok(()) } - fn start_game(&mut self) -> Result<()> { - info!(LogContext::Engine => "Starting {}", "Game".module_object_style()); + fn start_project(&mut self) -> Result<()> { + info!(LogContext::Engine => "Starting {}", "Project".module_object_style()); - let game = self - .game + let project = self + .project .take() - .ok_or(EngineError::Other("Cannot get game".to_string()))?; - let stop_on_game_errors = self + .ok_or(EngineError::Other("Cannot get project".to_string()))?; + let stop_on_project_errors = self .config - .get_bool("PANIC_ON_GAME_ERRORS") - .unwrap_or(PANIC_ON_GAME_ERRORS); - let result = game.start(self); - match stop_on_game_errors { - true => result.context(format!("{} error", "Game".module_object_style()))?, + .get_bool("PANIC_ON_PROJECT_ERRORS") + .unwrap_or(PANIC_ON_PROJECT_ERRORS); + let result = project.start(self); + match stop_on_project_errors { + true => result.context(format!("{} error", "Project".module_object_style()))?, false => { - if let Some(message) = get_game_error_message(result) { + if let Some(message) = get_project_error_message(result) { error!(LogContext::Engine => "{}", message); } } } - self.game = Some(game); + self.project = Some(project); Ok(()) } /// Initializes Pill Engine /// - /// Creates default global components, adds default systems, creates default resources, initializes game + /// Creates default global components, adds default systems, creates default resources, initializes pill project pub fn initialize(&mut self, window_size: Option>) -> Result<()> { info!(LogContext::Engine => "Initializing {}", "Engine".module_object_style()); @@ -384,27 +423,29 @@ impl Engine { self.system_manager.add_system( "build_status_system", build_status_system, - UpdatePhase::PostGame, + UpdatePhase::PostProject, )?; // Create default resources self.create_default_resources() .context("Failed to create default resources")?; - // Start game - self.start_game()?; + // Start pill project + self.start_project()?; Ok(()) } /// Main engine update function /// - /// Runs all systems in order: PreGame -> Game -> PostGame + /// Runs all systems in order: PreProject -> Project -> PostProject pub fn update(&mut self, delta_time: std::time::Duration) { - let stop_on_game_errors = self + self.frame_count += 1; + + let stop_on_project_errors = self .config - .get_bool("PANIC_ON_GAME_ERRORS") - .unwrap_or(PANIC_ON_GAME_ERRORS); + .get_bool("PANIC_ON_PROJECT_ERRORS") + .unwrap_or(PANIC_ON_PROJECT_ERRORS); // Run systems for update_phase_index in 0..self.system_manager.update_phases.len() { @@ -442,10 +483,10 @@ impl Engine { format!("{:?}", update_phase.clone()), )); - if update_phase == UpdatePhase::Game && stop_on_game_errors { + if update_phase == UpdatePhase::Project && stop_on_project_errors { result.unwrap(); // Panic on error if configured } else if let Err(err) = result { - if let Some(message) = get_game_error_message(Err(err)) { + if let Some(message) = get_project_error_message(Err(err)) { error!("{}", message); } } @@ -573,11 +614,11 @@ impl Engine { // --- API ------------------------------------------------------------------ -/// Pill Engine game API +/// Pill Engine pill project API impl Engine { // --- System API --- - /// Adds game-defined system to the game update phase + /// Adds pill project-defined system to the project update phase pub fn add_system( &mut self, name: &str, @@ -588,33 +629,33 @@ impl Engine { "System".general_object_style(), name.name_style(), "UpdatePhase".specific_object_style(), - "Game".name_style() + "Project".name_style() ); self.system_manager - .add_system(name, system_function, UpdatePhase::Game) + .add_system(name, system_function, UpdatePhase::Project) .context(format!("Adding {} failed", "System".general_object_style())) } - /// Removes game-defined system + /// Removes pill project-defined system pub fn remove_system(&mut self, name: &str) -> Result<()> { debug!( "Removing {} {} from {} {}", "System".general_object_style(), name.name_style(), "UpdatePhase".specific_object_style(), - "Game".name_style() + "Project".name_style() ); self.system_manager - .remove_system(name, UpdatePhase::Game) + .remove_system(name, UpdatePhase::Project) .context(format!( "Removing {} failed", "System".general_object_style() )) } - /// Toggles game-defined system + /// Toggles pill project-defined system pub fn toggle_system( &mut self, name: &str, @@ -645,11 +686,11 @@ impl Engine { "System".general_object_style(), name.name_style(), "UpdatePhase".specific_object_style(), - "Game".name_style() + "Project".name_style() ); self.system_manager - .get_system_timer(name, UpdatePhase::Game) + .get_system_timer(name, UpdatePhase::Project) .unwrap() .unwrap() } @@ -1098,7 +1139,7 @@ impl Engine { where T: Resource>, { - // TODO: Doesnt work when called from game + // TODO: Doesnt work when called from pill project info!(LogContext::Resources => "Adding {} {} {}", "Resource".general_object_style(), get_type_name::().specific_object_style(), resource.get_name().name_style()); self.add_resource_internal(resource, true) diff --git a/engine/pill_engine/src/graphics/egui.rs b/engine/pill_engine/src/graphics/egui.rs index 47bf9ec6..7d81b4b3 100644 --- a/engine/pill_engine/src/graphics/egui.rs +++ b/engine/pill_engine/src/graphics/egui.rs @@ -1,5 +1,3 @@ -#![cfg(feature = "debug_ui")] - pub struct EguiUI<'a> { frame_delta_time: &'a f32, // Use a reference to frame_delta_time } diff --git a/engine/pill_engine/src/graphics/mod.rs b/engine/pill_engine/src/graphics/mod.rs index 1e0ad585..56f0f799 100644 --- a/engine/pill_engine/src/graphics/mod.rs +++ b/engine/pill_engine/src/graphics/mod.rs @@ -10,9 +10,13 @@ mod renderer; // --- Use --- pub use renderer::{ - PillRenderer, Renderer, RendererCameraHandle, RendererMaterialHandle, RendererMeshHandle, + PillRenderer, RendererCameraHandle, RendererMaterialHandle, RendererMeshHandle, RendererShaderHandle, RendererTextureHandle, }; +// Renderer type alias (Box) is unused β€” +// callers use Box directly. +#[allow(unused_imports)] +pub use renderer::Renderer; #[cfg(feature = "headless")] pub use self::dummy_renderer::DummyRenderer; diff --git a/engine/pill_engine/src/lib.rs b/engine/pill_engine/src/lib.rs index a3b7b5af..7157a893 100644 --- a/engine/pill_engine/src/lib.rs +++ b/engine/pill_engine/src/lib.rs @@ -57,8 +57,8 @@ macro_rules! define_global_component { // --- Use --- -#[cfg(feature = "game")] -pub mod game { +#[cfg(feature = "project")] +pub mod project { pub use crate::{ ecs::{ CameraAspectRatio, CameraComponent, Component, ComponentStorage, EntityHandle, @@ -66,7 +66,7 @@ pub mod game { MeshRenderingComponent, PlayerId, SceneHandle, TimeComponent, TransformComponent, UpdatePhase, }, - engine::{Engine, KeyboardKey, MouseButton, PillGame}, + engine::{Engine, KeyboardKey, MouseButton, PillProject}, resources::{ Material, MaterialHandle, Mesh, MeshHandle, Resource, ResourceLoader, ResourceStorage, Shader, ShaderParameterSlot, ShaderParameterType, ShaderTextureSlot, Texture, @@ -93,7 +93,7 @@ pub mod game { extern crate pill_core; pub use pill_core::{ - create_game, define_new_pill_slotmap_key, Color, PillTypeMapKey, Vector2f, Vector2i, + create_project, define_new_pill_slotmap_key, Color, PillTypeMapKey, Vector2f, Vector2i, Vector3f, DISTINCT_COLOR_PALETTE, }; @@ -114,7 +114,7 @@ mod internal_mod { NetworkEntityAction, NetworkEntityState, NetworkManagerComponent, NetworkSide, NetworkStateComponent, NetworkUpdatePayload, Scene, TimeComponent, TransformComponent, }, - engine::{Engine, PillGame}, + engine::{Engine, PillProject}, graphics::{ decompose_render_queue_key, PillRenderer, RenderQueueItem, RenderQueueKey, RenderQueueKeyFields, RendererCameraHandle, RendererMaterialHandle, RendererMeshHandle, @@ -140,7 +140,7 @@ mod internal_mod { CameraAspectRatio, CameraComponent, ComponentStorage, EntityHandle, InputComponent, MeshRenderingComponent, Scene, TimeComponent, TransformComponent, }, - engine::{Engine, PillGame}, + engine::{Engine, PillProject}, graphics::{ decompose_render_queue_key, PillRenderer, RenderQueueItem, RenderQueueKey, RenderQueueKeyFields, RendererCameraHandle, RendererMaterialHandle, RendererMeshHandle, diff --git a/engine/pill_engine/src/resources/material.rs b/engine/pill_engine/src/resources/material.rs index 2d0b7287..a6bb7357 100644 --- a/engine/pill_engine/src/resources/material.rs +++ b/engine/pill_engine/src/resources/material.rs @@ -51,7 +51,7 @@ impl MaterialTexture { } } -// This needed so that renderer can get renderer texture handle from material texture while it is still hidden in game API +// This needed so that renderer can get renderer texture handle from material texture while it is still hidden in pill project API pub fn get_renderer_texture_handle_from_material_texture( material_texture: &MaterialTexture, ) -> &Option { diff --git a/engine/pill_engine/src/resources/mesh.rs b/engine/pill_engine/src/resources/mesh.rs index ae6daf0d..0b214367 100644 --- a/engine/pill_engine/src/resources/mesh.rs +++ b/engine/pill_engine/src/resources/mesh.rs @@ -140,7 +140,7 @@ impl Resource for Mesh { // Load from file only if mesh_data not already set (procedural mesh) if self.mesh_data.is_none() { - let base = engine.game_resources_directory_path.join(&self.path); + let base = engine.project_resources_directory_path.join(&self.path); let cooked_mesh_path = base.with_extension("cooked_mesh"); if cooked_mesh_path.exists() { diff --git a/engine/pill_engine/src/resources/shader.rs b/engine/pill_engine/src/resources/shader.rs index 21f1fddb..02f4f4f2 100644 --- a/engine/pill_engine/src/resources/shader.rs +++ b/engine/pill_engine/src/resources/shader.rs @@ -159,12 +159,12 @@ impl Resource for Shader { // so there is no runtime compile step. let vertex_bytes = read_wgsl_bytes( &self.vertex_shader_resource_loader, - &engine.game_resources_directory_path, + &engine.project_resources_directory_path, "vertex", )?; let fragment_bytes = read_wgsl_bytes( &self.fragment_shader_resource_loader, - &engine.game_resources_directory_path, + &engine.project_resources_directory_path, "fragment", )?; let vertex_wgsl = diff --git a/engine/pill_engine/src/resources/sound.rs b/engine/pill_engine/src/resources/sound.rs index e2c9c693..841458db 100644 --- a/engine/pill_engine/src/resources/sound.rs +++ b/engine/pill_engine/src/resources/sound.rs @@ -52,7 +52,7 @@ impl Resource for Sound { ); // Check if path to asset is correct - let resource_file_path = engine.game_resources_directory_path.join(&self.path); + let resource_file_path = engine.project_resources_directory_path.join(&self.path); pill_core::validate_asset_path(&resource_file_path, &["mp3", "wav"]) .context(error_message.clone())?; diff --git a/engine/pill_engine/src/resources/texture.rs b/engine/pill_engine/src/resources/texture.rs index f322e11e..4fb12058 100644 --- a/engine/pill_engine/src/resources/texture.rs +++ b/engine/pill_engine/src/resources/texture.rs @@ -59,11 +59,18 @@ fn decode_cooked_tex(bytes: &[u8]) -> Result<(Vec, u32, u32)> { #[cfg(not(target_arch = "wasm32"))] fn decode_png(bytes: &[u8]) -> Result<(Vec, u32, u32)> { - let decoder = png::Decoder::new(bytes); + let decoder = png::Decoder::new(std::io::Cursor::new(bytes)); let mut reader = decoder .read_info() .map_err(|e| -> pill_core::PillError { e.to_string().into() })?; - let mut buf = vec![0u8; reader.output_buffer_size()]; + let mut buf = vec![ + 0u8; + reader + .output_buffer_size() + .ok_or_else(|| pill_core::PillError::from( + "failed to determine output buffer size" + ))? + ]; let info = reader .next_frame(&mut buf) .map_err(|e| -> pill_core::PillError { e.to_string().into() })?; @@ -111,7 +118,7 @@ impl Resource for Texture { // Create new renderer texture resource let (rgba, width, height) = match &self.resource_loader { ResourceLoader::Path(path) => { - let base = engine.game_resources_directory_path.join(path); + let base = engine.project_resources_directory_path.join(path); let cooked_tex_path = base.with_extension("cooked_tex"); if cooked_tex_path.exists() { let bytes = diff --git a/engine/pill_launcher/Cargo.toml b/engine/pill_launcher/Cargo.toml index 6001c5df..edd54630 100644 --- a/engine/pill_launcher/Cargo.toml +++ b/engine/pill_launcher/Cargo.toml @@ -14,7 +14,8 @@ anyhow = "1.0" path-absolutize = "3.0.11" config = "0.11" tiny_http = "0.12" +ureq = "2" pill_assets = { path = "../pill_assets" } [profile.release] -debug = 2 +debug = 1 diff --git a/engine/pill_launcher/res/templates/new_project/Cargo.toml b/engine/pill_launcher/res/templates/new_project/Cargo.toml new file mode 100644 index 00000000..6d26f2ad --- /dev/null +++ b/engine/pill_launcher/res/templates/new_project/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "project" +version = "0.1.0" +edition = "2021" +workspace = "NO_PATH" + +[lib] +crate-type = ["cdylib", "rlib"] +path = "src/project.rs" + +[dependencies] +pill_engine = { path = "NO_PATH", features = ["project", "obj_loading"] } diff --git a/engine/pill_launcher/res/templates/pill_default/res/config.ini b/engine/pill_launcher/res/templates/new_project/res/config.ini similarity index 89% rename from engine/pill_launcher/res/templates/pill_default/res/config.ini rename to engine/pill_launcher/res/templates/new_project/res/config.ini index a8273c20..c96544b1 100644 --- a/engine/pill_launcher/res/templates/pill_default/res/config.ini +++ b/engine/pill_launcher/res/templates/new_project/res/config.ini @@ -1,4 +1,4 @@ -# GAME +# PROJECT TITLE=NoTitle # WINDOW @@ -21,4 +21,4 @@ MAX_SOUNDS=10 # OTHER LOG_LEVEL=Info -PANIC_ON_GAME_ERRORS=true \ No newline at end of file +PANIC_ON_PROJECT_ERRORS=true \ No newline at end of file diff --git a/engine/pill_launcher/res/templates/pill_default/res/models/pill.obj b/engine/pill_launcher/res/templates/new_project/res/models/pill.obj similarity index 100% rename from engine/pill_launcher/res/templates/pill_default/res/models/pill.obj rename to engine/pill_launcher/res/templates/new_project/res/models/pill.obj diff --git a/engine/pill_launcher/res/templates/pill_default/res/textures/pill_color.png b/engine/pill_launcher/res/templates/new_project/res/textures/pill_color.png similarity index 100% rename from engine/pill_launcher/res/templates/pill_default/res/textures/pill_color.png rename to engine/pill_launcher/res/templates/new_project/res/textures/pill_color.png diff --git a/engine/pill_launcher/res/templates/pill_default/res/textures/pill_normal.png b/engine/pill_launcher/res/templates/new_project/res/textures/pill_normal.png similarity index 100% rename from engine/pill_launcher/res/templates/pill_default/res/textures/pill_normal.png rename to engine/pill_launcher/res/templates/new_project/res/textures/pill_normal.png diff --git a/engine/pill_launcher/res/templates/new_project/src/project.rs b/engine/pill_launcher/res/templates/new_project/src/project.rs new file mode 100644 index 00000000..88889870 --- /dev/null +++ b/engine/pill_launcher/res/templates/new_project/src/project.rs @@ -0,0 +1,101 @@ +//! Welcome to Pill! +//! +//! This is the default template for new Pill projects. It sets up a simple +//! 3D scene with a camera, a floating/rotating pill model, and a basic +//! lighting setup. Modify or replace this file to build your own project. + +use pill_engine::project::*; + +// Declare the project struct and register it with the engine macro. +pub struct Project {} +create_project!(Project {}, PillProject); + +impl PillProject for Project { + // Called once at startup. Set up scenes, register components, load resources, spawn entities, and register systems here. + fn start(&self, engine: &mut Engine) -> Result<()> { + // Create a default scene and set it as active. + let active_scene = engine.create_scene("default")?; + engine.set_active_scene(active_scene)?; + + // Register the component types this project uses. + engine.register_component::(active_scene)?; + engine.register_component::(active_scene)?; + engine.register_component::(active_scene)?; + + // Load resources: a 3D mesh and a material with a tint color. + let mesh_handle = engine.add_resource(Mesh::new("pill", "models/pill.obj".into()))?; + + let material_handle = engine.add_resource( + Material::builder("default") + .color_parameter("tint", Color::new(0.8, 0.8, 0.82))? + .build(), + )?; + + // Spawn a camera entity β€” positioned back from the origin. + engine + .build_entity(active_scene) + .with_component( + TransformComponent::builder() + .position(Vector3f::new(0.0, 0.0, -5.0)) + .build(), + ) + .with_component( + CameraComponent::builder() + .enabled(true) + .fov(60.0) + .clear_color(Color::new(0.1, 0.1, 0.11)) + .build(), + ) + .build(); + + // Spawn the pill model β€” floating slightly above the origin. + engine + .build_entity(active_scene) + .with_component( + TransformComponent::builder() + .position(Vector3f::new(0.0, 0.5, 0.0)) + .build(), + ) + .with_component( + MeshRenderingComponent::builder() + .mesh(&mesh_handle) + .material(&material_handle) + .build(), + ) + .build(); + + // Register the float-and-rotate system to animate the pill. + engine.add_system("float_and_rotate", float_and_rotate_system)?; + + Ok(()) + } +} + +// ----- Systems ---------------------------------------------------------------- + +// Makes all entities with both TransformComponent and MeshRenderingComponent +// float up and down while spinning β€” a simple visual demo. +fn float_and_rotate_system(engine: &mut Engine) -> Result<()> { + let elapsed = engine.get_global_component::()?.time; + + for (_entity, transform, _mesh) in + engine.iterate_two_components_mut::()? + { + // Vertical bobbing: sine wave with 2.5 Hz frequency, 0.3 unit amplitude. + let float_offset = (elapsed * 2.5).sin() * 0.3; + transform.set_position(Vector3f::new( + transform.position.x, + 0.5 + float_offset, + transform.position.z, + )); + + // Rotation: gentle tilt on X, continuous spin on Y. + transform.set_rotation(Vector3f::new( + (elapsed * 2.0).sin() * 5.0, + elapsed * 60.0, + 0.0, + )); + } + + Ok(()) +} diff --git a/engine/pill_launcher/res/templates/pill_default/Cargo.toml b/engine/pill_launcher/res/templates/pill_default/Cargo.toml deleted file mode 100644 index 8566933a..00000000 --- a/engine/pill_launcher/res/templates/pill_default/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "pill_game" -version = "0.1.0" -edition = "2021" -workspace = "NO_PATH" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -pill_engine = {path = "NO_PATH", features = ["game"]} \ No newline at end of file diff --git a/engine/pill_launcher/res/templates/pill_default/src/game.rs b/engine/pill_launcher/res/templates/pill_default/src/game.rs deleted file mode 100644 index 13280f8c..00000000 --- a/engine/pill_launcher/res/templates/pill_default/src/game.rs +++ /dev/null @@ -1,99 +0,0 @@ -use pill_engine::game::*; - -// Define custom component -pub struct PillComponent {} - -impl Component for PillComponent {} - -impl PillTypeMapKey for PillComponent { - type Storage = ComponentStorage; -} - -// Game -pub struct Game {} - -impl PillGame for Game { - fn start(&self, engine: &mut Engine) -> Result<()> { - // Create scene - let active_scene = engine.create_scene("Default")?; - engine.set_active_scene(active_scene)?; - - // Register components - engine.register_component::(active_scene)?; - engine.register_component::(active_scene)?; - engine.register_component::(active_scene)?; - engine.register_component::(active_scene)?; - engine.register_component::(active_scene)?; - engine.register_component::(active_scene)?; - - // Add systems - engine.add_system("PillRotation", pill_rotation_system)?; - - // Add meshes - let pill_mesh = Mesh::new("Pill", "models/pill.obj".into()); - let pill_mesh_handle = engine.add_resource(pill_mesh)?; - - // Add textures - let pill_color_texture = Texture::new( - "PillColor", - TextureType::Color, - ResourceLoadType::Path("textures/pill_color.png".into()), - ); - let pill_color_texture_handle = engine.add_resource::(pill_color_texture)?; - let pill_normal_texture = Texture::new( - "PillNormal", - TextureType::Normal, - ResourceLoadType::Path("textures/pill_normal.png".into()), - ); - let pill_normal_texture_handle = engine.add_resource::(pill_normal_texture)?; - - // Add materials - let mut pill_material = Material::new("Pill"); - pill_material.set_texture("color", pill_color_texture_handle)?; - pill_material.set_texture("normal", pill_normal_texture_handle)?; - pill_material.set_color("tint", Color::new(1.0, 1.0, 1.0))?; - pill_material.set_scalar("specularity", 0.5)?; - let pill_material_handle = engine.add_resource::(pill_material)?; - - // Create camera entity - let camera = engine.create_entity(active_scene)?; - let transform_component = TransformComponent::builder() - .position(Vector3f::new(0.0, 0.0, -8.0)) - .rotation(Vector3f::new(0.0, 0.0, -20.0)) - .build(); - engine.add_component_to_entity(active_scene, camera, transform_component)?; - let camera_component = CameraComponent::builder().enabled(true).build(); - engine.add_component_to_entity(active_scene, camera, camera_component)?; - - // Create pill entity - let pill = engine.create_entity(active_scene)?; - let transform_component = TransformComponent::builder() - .rotation(Vector3f::new(-210.0, 0.0, 0.0)) - .build(); - engine.add_component_to_entity(active_scene, pill, transform_component)?; - let mesh_rendering_component = MeshRenderingComponent::builder() - .mesh(&pill_mesh_handle) - .material(&pill_material_handle) - .build(); - engine.add_component_to_entity(active_scene, pill, mesh_rendering_component)?; - engine.add_component_to_entity(active_scene, pill, PillComponent {})?; - - Ok(()) - } -} - -fn pill_rotation_system(engine: &mut Engine) -> Result<()> { - let delta_time = engine.get_global_component::()?.delta_time; - let input_component = engine.get_global_component_mut::()?; - - // Rotate pill if spacebar is not pressed - if !input_component.get_key_pressed(KeyboardKey::Space) { - for (_, transform_component, _) in - engine.iterate_two_components_mut::()? - { - transform_component.rotate_around_axis(90.0 * delta_time, Vector3f::new(0.0, 1.0, 0.0)); - } - } - - Ok(()) -} diff --git a/engine/pill_launcher/res/templates/pill_default/src/lib.rs b/engine/pill_launcher/res/templates/pill_default/src/lib.rs deleted file mode 100644 index c0d7b224..00000000 --- a/engine/pill_launcher/res/templates/pill_default/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod game; -use pill_engine::game::create_game; - -create_game!(crate::game::Game {}, pill_engine::game::PillGame); \ No newline at end of file diff --git a/engine/pill_launcher/res/templates/wasm/.cargo/config.toml b/engine/pill_launcher/res/templates/wasm/.cargo/config.toml new file mode 100644 index 00000000..e57cb0f7 --- /dev/null +++ b/engine/pill_launcher/res/templates/wasm/.cargo/config.toml @@ -0,0 +1,4 @@ +# Required by getrandom >=0.3 on wasm32-unknown-unknown. +# See: https://docs.rs/getrandom/0.3/#webassembly-support +[target.wasm32-unknown-unknown] +rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""] diff --git a/engine/pill_launcher/res/templates/wasm/Cargo.toml b/engine/pill_launcher/res/templates/wasm/Cargo.toml index 2c667392..8838dea3 100644 --- a/engine/pill_launcher/res/templates/wasm/Cargo.toml +++ b/engine/pill_launcher/res/templates/wasm/Cargo.toml @@ -1,18 +1,19 @@ -# This crate is built only via `PillLauncher -a build -t web -p `. -# The launcher copies this Cargo.toml into /build/wasm/.build/pill_web_app/, +# This crate is built only via `PillLauncher -a build -t web -p `. +# The launcher copies this Cargo.toml into /build/wasm/.build/pill_web_app/, # rewrites the engine and pill_web path-deps to absolute paths, appends the -# `pill_game` dependency pointing at the -p game directory, then runs wasm-pack. -# Building this crate directly with `cargo build` will fail β€” no pill_game dep. +# `project` dependency pointing at the -p project directory, then runs wasm-pack. +# Building this crate directly with `cargo build` will fail - no project dep. # # Symmetry note: the bulk of the wasm runtime (panic hook, canvas, event loop) -# lives in the workspace crate `pill_web`. This template is the per-game cdylib -# that wasm-pack consumes β€” it provides only the bits that genuinely have to -# be per-game (the WebGame struct and the embedded config.ini). +# lives in the workspace crate `pill_web`. This template is the per-project cdylib +# that wasm-pack consumes - it provides only the bits that genuinely have to +# be per-project (the Project struct and the embedded config.ini). [package] name = "pill_web_app" version = "0.1.0" edition = "2021" +workspace = "NO_PATH" [lib] crate-type = ["cdylib", "rlib"] diff --git a/engine/pill_launcher/res/templates/wasm/src/lib.rs b/engine/pill_launcher/res/templates/wasm/src/lib.rs index e342ba07..23133f6a 100644 --- a/engine/pill_launcher/res/templates/wasm/src/lib.rs +++ b/engine/pill_launcher/res/templates/wasm/src/lib.rs @@ -1,18 +1,22 @@ -// Per-game wasm shim β€” minimal, only the parts that genuinely need to be -// per-game: -// - the `WebGame` struct from this game's `pill_game` crate -// - the embedded `config.ini` (wasm has no filesystem at runtime, so the -// launcher copies the game's res/config.ini next to this lib.rs and we -// pull the bytes in via include_str!) -// Everything else (panic hook, console_log, canvas wiring, event loop) lives -// in `pill_web::run`. This keeps the per-game cdylib tiny and the runtime -// shape symmetric with `pill_native` (which is also a workspace crate). +//! WASM entry point - scaffolded by `PillLauncher create`. +//! +//! This is a minimal shim that wires together: +//! - The project's `Project` struct (from `src/project.rs`). +//! - The project's `config.ini` (embedded at compile time - WASM has no filesystem). +//! - The shared `pill_web::run` runtime (panic hook, canvas, event loop). +//! +//! Keeping this file tiny ensures the per-project `.wasm` binary stays small. +use project::Project; use wasm_bindgen::prelude::*; -use pill_game::WebGame; +#[cfg(target_arch = "wasm32")] +#[global_allocator] +// SAFETY: wasm32 is single-threaded, so assuming single-threaded allocator access is sound. +static ALLOC: lol_alloc::AssumeSingleThreaded = + unsafe { lol_alloc::AssumeSingleThreaded::new(lol_alloc::FreeListAllocator::new()) }; #[wasm_bindgen(start)] pub fn wasm_main() { - pill_web::run(Box::new(WebGame {}), include_str!("../config.ini")); + pill_web::run(Box::new(Project {}), include_str!("../config.ini")); } diff --git a/engine/pill_launcher/res/templates/web/index.html b/engine/pill_launcher/res/templates/web/index.html index 1c090ce4..6456e89b 100644 --- a/engine/pill_launcher/res/templates/web/index.html +++ b/engine/pill_launcher/res/templates/web/index.html @@ -1,12 +1,22 @@ + Pill Engine - WebGPU +
made with @@ -124,11 +154,12 @@ await init(); } catch (e) { console.error('Failed to load WASM:', e); - showError('Failed to load game: ' + e.message); + showError('Failed to load pill project: ' + e.message); } } run(); - + + \ No newline at end of file diff --git a/engine/pill_launcher/src/actions/assets.rs b/engine/pill_launcher/src/actions/assets.rs new file mode 100644 index 00000000..8d0cf38d --- /dev/null +++ b/engine/pill_launcher/src/actions/assets.rs @@ -0,0 +1,42 @@ +//! This file implements the "assets" action: run the asset pipeline. +//! +//! Responsibilities: +//! - Registers the `assets` subcommand with `-p`/`--path` and `--clean` flags. +//! - Delegates to `utils::assets::run_asset_pipeline` to process raw assets +//! (models, textures, shaders) into cooked formats. + +use anyhow::Result; +use clap::{App, ArgMatches}; +use path_absolutize::Absolutize; +use std::path::PathBuf; + +use crate::actions::Action; +use crate::utils::assets::run_asset_pipeline; +use crate::utils::cli::{clean_flag, path_flag}; + +#[derive(Debug)] +pub(crate) struct Assets; + +impl Action for Assets { + fn name(&self) -> &'static str { + "assets" + } + + fn description(&self) -> &'static str { + "Run the asset pipeline (raw β†’ cooked assets)" + } + + fn register(&self, application: App<'static, 'static>) -> App<'static, 'static> { + application.arg(path_flag()).arg(clean_flag()) + } + + fn run(&self, matches: &ArgMatches) -> Result<()> { + let path = PathBuf::from(matches.value_of("path").unwrap_or(".")) + .absolutize()? + .to_path_buf(); + let clean = matches.is_present("clean"); + // Run the pipeline on the project's res/ directory. + // When --clean is set, previously cooked files are deleted first. + run_asset_pipeline(&path.join("res"), clean) + } +} diff --git a/engine/pill_launcher/src/actions/build.rs b/engine/pill_launcher/src/actions/build.rs new file mode 100644 index 00000000..758d6f9b --- /dev/null +++ b/engine/pill_launcher/src/actions/build.rs @@ -0,0 +1,111 @@ +ο»Ώ//! This file implements the "build" action: compile a native or WASM project. +//! +//! Responsibilities: +//! - Parses CLI flags (shared with the "run" action via `utils::cli`). +//! - Delegates to `native_target::build_project()` for native builds. +//! - Delegates to `wasm_target::build_project()` for WASM targets. + +use anyhow::Result; +use clap::{App, ArgMatches}; +use path_absolutize::Absolutize; +use std::path::PathBuf; + +use crate::actions::Action; +use crate::types::*; +use crate::utils::cli::{ + add_build_flags, add_path_flag, max_wasm_size_flag, parse_build_target, parse_compile_mode, + wasm_analyze_flag, +}; +use crate::utils::common::{clean_build_cache, print_build_summary}; +use crate::utils::native_target::build_project; +use crate::utils::paths::get_project_build_path; +use crate::utils::wasm_target; + +/// The `build` subcommand: compile a project to native or WASM. +#[derive(Debug)] +pub(crate) struct Build; + +impl Action for Build { + fn name(&self) -> &'static str { + "build" + } + + fn description(&self) -> &'static str { + "Compile a project (native or WASM)" + } + + fn register(&self, application: App<'static, 'static>) -> App<'static, 'static> { + let application = add_path_flag(application); + let application = add_build_flags(application); + application + .arg(max_wasm_size_flag()) + .arg(wasm_analyze_flag()) + } + + fn run(&self, matches: &ArgMatches) -> Result<()> { + // 1. Parse CLI flags. + let path = PathBuf::from(matches.value_of("path").unwrap_or(".")) + .absolutize()? + .to_path_buf(); + let compile_mode = parse_compile_mode(matches); + let target = parse_build_target(matches); + let additional_features = matches.value_of("additional-features"); + let headless = matches.is_present("headless"); + let clean = matches.is_present("clean"); + let maximum_wasm_size: Option = matches + .value_of("max-wasm-size") + .and_then(|size| size.parse().ok()); + let wasm_analyze = matches.is_present("wasm-analyze"); + + // 2. Print a summary of what we're about to build. + print_build_summary( + "Building project", + &path, + &target, + &compile_mode, + matches.value_of("output-path"), + additional_features, + ); + + // 3. If --clean was requested, wipe the build cache and rebuild assets. + if clean { + clean_build_cache()?; + crate::utils::assets::run_asset_pipeline(&path.join("res"), true)?; + } + + // 4. Dispatch to the appropriate build backend. + match target { + BuildTarget::Native => { + let output_directory = + PathBuf::from(matches.value_of("output-path").unwrap_or(".")); + let output_directory = + get_project_build_path(&path, &output_directory, &compile_mode)?; + build_project( + &path, + &output_directory, + &compile_mode, + additional_features, + headless, + )?; + } + BuildTarget::Web => { + if headless { + println!("Note: `--headless` is ignored for WASM builds; headless mode only applies to native targets."); + } + if matches.occurrences_of("output-path") > 0 { + println!("Note: `-o/--output-path` is ignored with `-t wasm`; output is fixed at /build/wasm/"); + } + wasm_target::build_project(&path, &compile_mode, maximum_wasm_size, wasm_analyze)?; + + let build_wasm_directory = path.join("build").join("wasm"); + println!("Done! Serve with:"); + println!(" PillLauncher run -t web -p {:?}", &path); + println!( + " (or any static server pointed at {:?})", + build_wasm_directory + ); + } + } + Ok(()) + } +} diff --git a/engine/pill_launcher/src/actions/cargo_passthrough.rs b/engine/pill_launcher/src/actions/cargo_passthrough.rs new file mode 100644 index 00000000..e8f2f6ea --- /dev/null +++ b/engine/pill_launcher/src/actions/cargo_passthrough.rs @@ -0,0 +1,90 @@ +//! This file implements the "cargo" passthrough action. +//! +//! Responsibilities: +//! - Prepares the engine workspace for the given project. +//! - Runs an arbitrary cargo command (fmt, clippy, check, etc.) in that workspace. +//! - Depends on: workspace (prepare_workspace_for_project). + +use anyhow::{bail, Context, Result}; +use clap::{App, ArgMatches}; +use path_absolutize::Absolutize; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::actions::Action; +use crate::types::*; +use crate::utils::cli::{compile_mode_flag, parse_compile_mode, path_flag, project_args_flag}; +use crate::utils::workspace::prepare_workspace_for_project; + +/// Run arbitrary cargo commands (fmt, clippy, check, etc.) in the engine workspace. +#[derive(Debug)] +pub(crate) struct Cargo; + +impl Action for Cargo { + fn name(&self) -> &'static str { + "cargo" + } + + fn description(&self) -> &'static str { + "Run cargo commands (fmt, clippy, check, etc.)" + } + + fn register(&self, application: App<'static, 'static>) -> App<'static, 'static> { + application + .arg(path_flag()) + .arg(compile_mode_flag()) + .arg(project_args_flag()) + } + + fn run(&self, matches: &ArgMatches) -> Result<()> { + let compile_mode = parse_compile_mode(matches); + let passthrough: Vec = matches + .values_of("project-args") + .map(|v| v.map(String::from).collect()) + .unwrap_or_default(); + let path = PathBuf::from(matches.value_of("path").unwrap_or(".")) + .absolutize()? + .to_path_buf(); + cargo_passthrough(&path, &compile_mode, &passthrough) + } +} + +/// Run an arbitrary cargo command in the engine workspace with the project linked. +/// Requires at least one cargo argument; fails if the cargo command exits non-zero. +pub(crate) fn cargo_passthrough( + project_directory_path: &Path, + compile_mode: &CompileMode, + cargo_args: &[String], +) -> Result<()> { + // Guard against accidental no-op invocations. + if cargo_args.is_empty() { + bail!("Must call cargo with at least one argument"); + } + + // Link the project into the workspace so cargo commands see the full context. + let (engine_workspace_directory_path, _workspace_guard) = + prepare_workspace_for_project(project_directory_path, compile_mode)?; + // _workspace_guard restores engine/Cargo.toml on drop + + println!( + "Running `cargo {}` in workspace {}...", + cargo_args.join(" "), + engine_workspace_directory_path.display() + ); + + let status = Command::new("cargo") + .args(cargo_args) + .current_dir(engine_workspace_directory_path) + .status() + .context("Failed to run cargo passthrough")?; + + if !status.success() { + bail!( + "Cargo command failed: cargo {:?} (exit {:?})", + cargo_args, + status.code() + ); + } + + Ok(()) +} diff --git a/engine/pill_launcher/src/actions/create.rs b/engine/pill_launcher/src/actions/create.rs new file mode 100644 index 00000000..7f535c9b --- /dev/null +++ b/engine/pill_launcher/src/actions/create.rs @@ -0,0 +1,148 @@ +//! This file implements the "create" action: scaffolding a new project. +//! +//! Responsibilities: +//! - Copies the pill_default template into the target directory. +//! - Rewrites config.ini (TITLE, WINDOW_TITLE) and Cargo.toml (pill_engine path, +//! workspace membership) to match the new project. + +use anyhow::{Context, Error, Result}; +use clap::{App, Arg, ArgMatches}; +use path_absolutize::Absolutize; +use std::path::{Path, PathBuf}; + +use crate::actions::Action; +use crate::types::*; +use crate::utils::cli::path_flag; +use crate::utils::files::{copy_directory_recursive, modify_file}; +use crate::utils::paths::*; + +/// The `create` subcommand: scaffold a new Pill project from a template. +#[derive(Debug)] +pub(crate) struct Create; + +impl Action for Create { + fn name(&self) -> &'static str { + "create" + } + + fn description(&self) -> &'static str { + "Scaffold a new project from template" + } + + fn register(&self, application: App<'static, 'static>) -> App<'static, 'static> { + application.arg(path_flag()).arg( + Arg::with_name("name") + .short("n") + .long("name") + .takes_value(true) + .help("Name of new project"), + ) + } + + fn run(&self, matches: &ArgMatches) -> Result<()> { + let parent = PathBuf::from(matches.value_of("path").unwrap_or(".")) + .absolutize()? + .to_path_buf(); + let name = matches + .value_of("name") + .ok_or_else(|| Error::msg("--name is required for the 'create' action"))? + .to_string(); + create_project(&parent, &name) + } +} + +/// Scaffold a new Pill project from the new_project template. +/// +/// 1. Guards against overwriting an existing directory. +/// 2. Copies the template directory to the target location. +/// 3. Rewrites config.ini with the project name. +/// 4. Rewrites Cargo.toml with absolute engine paths and workspace membership. +pub(crate) fn create_project( + project_parent_directory_path: &Path, + project_name: &str, +) -> Result<()> { + const TEMPLATE_NAME: &str = "new_project"; + + let project_directory_path = project_parent_directory_path.join(project_name); + + // 1. Guard against overwriting an existing directory. + if project_directory_path.exists() { + return Err(Error::msg(format!( + "Project directory {} already exists", + project_directory_path.display() + ))); + } + + let project_resource_directory_path = project_directory_path.join("res"); + + println!( + "Creating new project {} in directory {}", + project_name, + project_directory_path.display() + ); + + // 2. Copy the template to the target directory. + let template_project_directory_path = get_path(Location::PillLauncherCrate) + .join("res") + .join("templates"); + + println!("Copying project template..."); + copy_directory_recursive( + &template_project_directory_path.join(TEMPLATE_NAME), + &project_directory_path, + ) + .context("Cannot copy template directory")?; + + // 3. Rewrite config.ini: replace TITLE and WINDOW_TITLE with the project name. + println!("Setting up config file..."); + modify_file( + &project_resource_directory_path.join("config.ini"), + &project_resource_directory_path.join("config.ini"), + |line: String| -> String { + if line.starts_with("TITLE") { + return format!("TITLE={}", project_name); + } + if line.starts_with("WINDOW_TITLE") { + return format!("WINDOW_TITLE={}", project_name); + } + line + }, + )?; + + // 4. Rewrite Cargo.toml: set absolute engine paths and workspace. + println!("Setting up manifest file..."); + let cargo_toml_path = project_directory_path.join("Cargo.toml"); + let pill_engine_path = get_path(Location::PillEngineCrate) + .to_string_lossy() + .replace('\\', "/"); + let engine_workspace_path = get_path(Location::EngineCrates) + .to_string_lossy() + .replace('\\', "/"); + modify_file( + &cargo_toml_path, + &cargo_toml_path, + |line: String| -> String { + if line.contains("pill_engine") { + // Preserve the original features list; only rewrite the path. + let features = if let Some(start) = line.find("features") { + let remainder = &line[start..]; + remainder + .trim_end_matches(|character: char| { + character == '}' || character.is_whitespace() + }) + .to_string() + } else { + "features = [\"project\"]".to_string() + }; + return format!("pill_engine = {{ path = \"{pill_engine_path}\", {features} }}"); + } + if line.contains("workspace") { + return format!("workspace = \"{engine_workspace_path}\""); + } + line + }, + )?; + + println!("Project creation completed!"); + Ok(()) +} diff --git a/engine/pill_launcher/src/actions/docs.rs b/engine/pill_launcher/src/actions/docs.rs new file mode 100644 index 00000000..51242a24 --- /dev/null +++ b/engine/pill_launcher/src/actions/docs.rs @@ -0,0 +1,243 @@ +//! This file implements the "docs" action: generating rustdoc for engine crates. +//! +//! Responsibilities: +//! - Generates two doc sets: project_dev (public API) and engine_dev (private items). +//! - Temporarily rewrites the Cube example's Cargo.toml and pill_native's Cargo.toml +//! to point at absolute engine paths so cargo doc resolves dependencies correctly. +//! - Pre-renders PlantUML diagrams before doc generation. + +use anyhow::{bail, Context, Error, Result}; +use clap::{App, ArgMatches}; +use path_absolutize::Absolutize; +use std::{env, fs, path::PathBuf, process::Command}; + +use crate::actions::Action; +use crate::types::*; +use crate::utils::cli::output_path_flag; +use crate::utils::files::modify_file; +use crate::utils::paths::*; +use crate::utils::plantuml::render_puml_for_crate; + +#[derive(Debug)] +pub(crate) struct Docs; + +impl Action for Docs { + fn name(&self) -> &'static str { + "docs" + } + + fn description(&self) -> &'static str { + "Generate rustdoc for engine crates" + } + + fn register(&self, application: App<'static, 'static>) -> App<'static, 'static> { + application.arg(output_path_flag()) + } + + fn run(&self, matches: &ArgMatches) -> Result<()> { + let out = PathBuf::from(matches.value_of("output-path").unwrap_or(".")) + .absolutize()? + .to_path_buf(); + generate_docs(&out) + } +} + +/// Generate rustdoc for engine crates into two sets: project_dev and engine_dev. +/// Temporarily rewrites Cargo.toml files so cargo doc resolves path dependencies. +/// Restores original manifests on exit even if generation fails. +/// +/// WARNING: Not safe for concurrent use. If two `pill_launcher -a docs` processes run +/// simultaneously, they race on the same Cargo.toml files and may produce +/// incorrect documentation or leave manifests in a modified state. +pub(crate) fn generate_docs(output_directory_path: &PathBuf) -> Result<()> { + // The Cube example serves as a workspace anchor so cargo doc can resolve deps. + let cube_example_project_path = get_path(Location::EngineProjectRoot) + .join("examples") + .join("Cube"); + if !cube_example_project_path.exists() { + return Err(Error::msg("Cannot find Cube project in examples directory")); + } + + let cube_cargo_toml = cube_example_project_path.join("Cargo.toml"); + let native_cargo_toml = get_path(Location::PillNativeCrate).join("Cargo.toml"); + + // Snapshot original manifests so we can restore them on exit. + let original_cube = fs::read_to_string(&cube_cargo_toml) + .with_context(|| format!("Failed to read {}", cube_cargo_toml.display()))?; + let original_native = fs::read_to_string(&native_cargo_toml) + .with_context(|| format!("Failed to read {}", native_cargo_toml.display()))?; + + // 1. Point the Cube example's Cargo.toml at the absolute engine path + // so cargo doc can resolve the pill_engine dependency. Remove any + // workspace field - the Cube example isn't a workspace member, and + // leaving a valid workspace path would cause "package believes it's in + // a workspace when it's not" errors. + modify_file( + &cube_cargo_toml, + &cube_cargo_toml, + |line: String| -> String { + if line.trim_start().starts_with("workspace") { + return String::new(); // remove - not a workspace member + } + if line.contains("pill_engine") { + return format!( + "pill_engine = {{path = \"{}\", features = [\"all\"]}}", + get_path(Location::PillEngineCrate) + .to_str() + .unwrap() + .replace("\\", "/") + ); + } + line + }, + )?; + + // 2. Point pill_native's Cargo.toml at the Cube example so it can be + // used as a workspace anchor for doc generation. + modify_file( + &native_cargo_toml, + &native_cargo_toml, + |line: String| -> String { + if line.contains("project") { + return format!( + "project = {{path = \"{}\"}}", + cube_example_project_path + .to_string_lossy() + .replace("\\", "/") + ); + } + line + }, + )?; + + // Run the main doc generation in a closure so manifests are always restored. + let result = (|| -> Result<()> { + // 3. Determine where to write the docs output. Defaults to ./generated/. + let output_path = if output_directory_path.as_os_str() == "." { + env::current_dir().context("Failed to get current directory")? + } else { + output_directory_path + .absolutize() + .context("Failed to absolutize output path")? + .to_path_buf() + }; + + let docs_path = output_path.join("generated"); + + // 4. Clean any previous doc output so stale files don't persist. + if docs_path.exists() { + fs::remove_dir_all(&docs_path).with_context(|| { + format!("Cannot clear output directory: {}", docs_path.display()) + })?; + } + + // Prepare two output directories: project_dev (public API) and engine_dev (all items). + let output_project_dev_path = docs_path.join("project_dev"); + let output_engine_dev_path = docs_path.join("engine_dev"); + + fs::create_dir_all(&docs_path)?; + fs::create_dir_all(&output_project_dev_path)?; + fs::create_dir_all(&output_engine_dev_path)?; + + let engine_crate_manifest_path = get_path(Location::PillEngineCrate).join("Cargo.toml"); + let full_engine_manifest_path = cube_example_project_path.join("Cargo.toml"); + + // 5. Pre-render PlantUML diagrams so they appear in the generated docs. + let pill_engine_dir = get_path(Location::PillEngineCrate); + if let Err(e) = render_puml_for_crate(&pill_engine_dir) { + eprintln!("Warning: skipping PlantUML render ({})", e); + } + + // 6. Generate project_dev docs: public API surface (project + internal + // features). Features are enabled via the pill_engine dependency spec + // in the Cube example's Cargo.toml (set above), not via --features on + // the cargo doc CLI (which would apply to the wrong crate). + let manifest = full_engine_manifest_path.to_string_lossy(); + let target = output_project_dev_path.to_string_lossy(); + let arguments = vec![ + "doc", + "--no-deps", + "--manifest-path", + &*manifest, + "--target-dir", + &*target, + "--release", + ]; + let status = Command::new("cargo") + .args(arguments) + .status() + .context("Failed to execute command for generating project dev docs")?; + + if !status.success() { + bail!( + "project_dev docs failed to generate (exit {:?})", + status.code() + ); + } + println!("project_dev docs generated successfully!"); + + // 7. Generate engine_dev docs: pill_core first (no dependencies), private items included. + let core_crate_manifest_path = get_path(Location::PillCoreCrate).join("Cargo.toml"); + let manifest = core_crate_manifest_path.to_string_lossy(); + let target = output_engine_dev_path.to_string_lossy(); + let arguments = vec![ + "doc", + "--no-deps", + "--document-private-items", + "--manifest-path", + &*manifest, + "--target-dir", + &*target, + "--release", + ]; + let status = Command::new("cargo") + .args(arguments) + .status() + .context("Failed to execute command for generating core dev docs")?; + + // Core docs are optional - non-fatal if they fail. + if status.success() { + println!("Core dev docs generated successfully!"); + } + + // Generate engine_dev docs: pill_engine with all features, private items included. + let manifest = engine_crate_manifest_path.to_string_lossy(); + let target = output_engine_dev_path.to_string_lossy(); + let arguments = vec![ + "doc", + "--no-deps", + "--document-private-items", + "--features", + "all", + "--manifest-path", + &*manifest, + "--target-dir", + &*target, + "--release", + ]; + let status = Command::new("cargo") + .args(arguments) + .status() + .context("Failed to execute command for generating engine dev docs")?; + + if !status.success() { + bail!( + "engine_dev docs failed to generate (exit {:?})", + status.code() + ); + } + println!("engine_dev docs generated successfully!"); + + Ok(()) + })(); + + // Restore original manifests regardless of success/failure. + fs::write(&cube_cargo_toml, &original_cube) + .with_context(|| format!("Failed to restore {}", cube_cargo_toml.display()))?; + fs::write(&native_cargo_toml, &original_native) + .with_context(|| format!("Failed to restore {}", native_cargo_toml.display()))?; + + result?; + + Ok(()) +} diff --git a/engine/pill_launcher/src/actions/link.rs b/engine/pill_launcher/src/actions/link.rs new file mode 100644 index 00000000..e30fc7a4 --- /dev/null +++ b/engine/pill_launcher/src/actions/link.rs @@ -0,0 +1,130 @@ +//! This file implements the "link" and "unlink" actions for IDE support. +//! +//! Responsibilities: +//! - `link`: persist a project into engine/Cargo.toml's workspace members +//! so rust-analyzer can resolve types across engine + project. +//! - `unlink`: remove the persisted project from workspace members. + +use anyhow::{bail, Result}; +use clap::{App, ArgMatches}; +use std::fs; + +use crate::actions::Action; +use crate::utils::cli::path_flag; +use crate::utils::paths::*; + +// --------------------------------------------------------------------------- +// Link action +// --------------------------------------------------------------------------- + +/// The `link` subcommand: add a project to the engine workspace members +/// so IDE tooling (rust-analyzer) can resolve cross-crate references. +pub(crate) struct Link; + +impl Action for Link { + fn name(&self) -> &'static str { + "link" + } + + fn description(&self) -> &'static str { + "Link a project into the engine workspace (for IDE support)" + } + + fn register(&self, application: App<'static, 'static>) -> App<'static, 'static> { + application.arg(path_flag()) + } + + fn run(&self, matches: &ArgMatches) -> Result<()> { + let path = matches.value_of("path").unwrap_or("."); + let engine_toml = get_path(crate::types::Location::EngineCrates).join("Cargo.toml"); + let normalized = normalize_path(&std::path::PathBuf::from(path))?; + let marker_line = format!(" \"{}\", {}", normalized, PROJECT_CRATE_MARKER); + + let text = fs::read_to_string(&engine_toml)?; + + // 1. If the project is already linked, do nothing. + if text.contains(&marker_line) { + println!("Project already linked: {normalized}"); + return Ok(()); + } + + // 2. Remove any existing marker line, then insert the new one before + // the closing `]` of the workspace members array. + let cleaned: String = text + .lines() + .filter(|line| !line.contains(PROJECT_CRATE_MARKER)) + .collect::>() + .join("\n"); + + let mut in_members = false; + let mut output = String::with_capacity(cleaned.len() + marker_line.len() + 2); + let mut inserted = false; + for line in cleaned.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("members") && trimmed.contains('[') { + in_members = true; + } + if in_members && !inserted && trimmed == "]" { + output.push_str(&marker_line); + output.push('\n'); + inserted = true; + } + output.push_str(line); + output.push('\n'); + } + if !inserted { + bail!("Could not find closing `]` of workspace members array"); + } + let output = output.trim_end().to_string(); + fs::write(&engine_toml, format!("{output}\n"))?; + + println!("Linked {normalized} into engine workspace."); + println!("rust-analyzer should pick it up automatically."); + println!("Run `PillLauncher unlink` to remove it."); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Unlink action +// --------------------------------------------------------------------------- + +/// The `unlink` subcommand: remove the linked project from the engine +/// workspace members, restoring the Cargo.toml to its clean state. +pub(crate) struct Unlink; + +impl Action for Unlink { + fn name(&self) -> &'static str { + "unlink" + } + + fn description(&self) -> &'static str { + "Remove a linked project from the engine workspace" + } + + fn register(&self, application: App<'static, 'static>) -> App<'static, 'static> { + application + } + + fn run(&self, _matches: &ArgMatches) -> Result<()> { + let engine_toml = get_path(crate::types::Location::EngineCrates).join("Cargo.toml"); + let text = fs::read_to_string(&engine_toml)?; + + // 1. If no project is linked, nothing to do. + if !text.contains(PROJECT_CRATE_MARKER) { + println!("No project currently linked."); + return Ok(()); + } + + // 2. Remove all lines containing the marker comment. + let cleaned: String = text + .lines() + .filter(|line| !line.contains(PROJECT_CRATE_MARKER)) + .collect::>() + .join("\n"); + + fs::write(&engine_toml, cleaned)?; + println!("Unlinked project from engine workspace."); + Ok(()) + } +} diff --git a/engine/pill_launcher/src/actions/mod.rs b/engine/pill_launcher/src/actions/mod.rs new file mode 100644 index 00000000..d3d18a6d --- /dev/null +++ b/engine/pill_launcher/src/actions/mod.rs @@ -0,0 +1,46 @@ +//! This file declares all sub-modules under the actions/ directory and defines +//! the common Action trait that every CLI action must implement. +//! +//! Modules: +//! - assets: run the asset pipeline (HLSLβ†’WGSL, etc.). +//! - build: compile native or WASM projects. +//! - run: build and launch native projects (or serve WASM). +//! - cargo_passthrough: forward arbitrary cargo commands to the workspace. +//! - create: scaffold a new project from template. +//! - docs: generate cargo doc for engine crates. +//! - link: link a project to the engine workspace (for dev builds). + +use anyhow::Result; +use clap::{App, ArgMatches}; + +pub mod assets; +pub mod build; +pub mod cargo_passthrough; +pub mod create; +pub mod docs; +pub mod link; +pub mod run; + +/// Common interface for every CLI action. +/// +/// Each action: +/// - Has a unique name that becomes a subcommand (e.g., "build", "run", "create"). +/// - Registers its own CLI flags via `register()`. +/// - Executes its logic via `run()`, extracting any needed values from the +/// parsed `ArgMatches`. +pub(crate) trait Action { + /// The CLI action name, e.g. "build", "check", "benchmark". + fn name(&self) -> &'static str; + + /// A short description shown in `PillLauncher --help` next to each subcommand. + fn description(&self) -> &'static str { + "" + } + + /// Register this action's CLI flags on its subcommand `App` and return it. + /// Shared flags (`-p`, `-c`, `-t`, etc.) must be added by the action itself. + fn register(&self, application: App<'static, 'static>) -> App<'static, 'static>; + + /// Execute the action using values extracted from its subcommand's matches. + fn run(&self, matches: &ArgMatches) -> Result<()>; +} diff --git a/engine/pill_launcher/src/actions/run.rs b/engine/pill_launcher/src/actions/run.rs new file mode 100644 index 00000000..7be0560b --- /dev/null +++ b/engine/pill_launcher/src/actions/run.rs @@ -0,0 +1,102 @@ +//! This file implements the "run" action: build and launch a project. +//! +//! Responsibilities: +//! - Parses CLI flags (shared with the "build" action via `utils::cli`). +//! - Delegates to `native_target::run_project()` for native build+run. +//! - Delegates to `web_dev_server::run()` for WASM dev server. + +use anyhow::Result; +use clap::{App, ArgMatches}; +use path_absolutize::Absolutize; +use std::path::PathBuf; + +use crate::actions::Action; +use crate::types::*; +use crate::utils::cli::{ + add_build_flags, add_path_flag, parse_build_target, parse_compile_mode, project_args_flag, + wasm_port_flag, +}; +use crate::utils::common::{clean_build_cache, print_build_summary}; +use crate::utils::native_target::run_project; +use crate::utils::paths::get_project_build_path; +use crate::utils::web_dev_server; + +/// The `run` subcommand: build and launch a project (native or WASM dev server). +#[derive(Debug)] +pub(crate) struct Run; + +impl Action for Run { + fn name(&self) -> &'static str { + "run" + } + + fn description(&self) -> &'static str { + "Build and launch a project" + } + + fn register(&self, application: App<'static, 'static>) -> App<'static, 'static> { + let application = add_path_flag(application); + let application = add_build_flags(application); + application.arg(wasm_port_flag()).arg(project_args_flag()) + } + + fn run(&self, matches: &ArgMatches) -> Result<()> { + // 1. Parse CLI flags. + let path = PathBuf::from(matches.value_of("path").unwrap_or(".")) + .absolutize()? + .to_path_buf(); + let compile_mode = parse_compile_mode(matches); + let target = parse_build_target(matches); + let additional_features = matches.value_of("additional-features"); + let headless = matches.is_present("headless"); + let passthrough: Vec = matches + .values_of("project-args") + .map(|values| values.map(String::from).collect()) + .unwrap_or_default(); + let clean = matches.is_present("clean"); + + // 2. Print a summary of what we're about to build and run. + print_build_summary( + "Running project", + &path, + &target, + &compile_mode, + matches.value_of("output-path"), + additional_features, + ); + + // 3. If --clean was requested, wipe the build cache and rebuild assets. + if clean { + clean_build_cache()?; + crate::utils::assets::run_asset_pipeline(&path.join("res"), true)?; + } + + // 4. Dispatch to the appropriate backend. + match target { + BuildTarget::Native => { + let output_directory = + PathBuf::from(matches.value_of("output-path").unwrap_or(".")); + let output_directory = + get_project_build_path(&path, &output_directory, &compile_mode)?; + run_project( + &path, + &output_directory, + &compile_mode, + &passthrough, + additional_features, + false, + headless, + )?; + } + BuildTarget::Web => { + let port: u16 = matches + .value_of("wasm-port") + .unwrap_or("8080") + .parse() + .unwrap_or(8080); + web_dev_server::run(&path, &compile_mode, port)?; + } + } + Ok(()) + } +} diff --git a/engine/pill_launcher/src/main.rs b/engine/pill_launcher/src/main.rs index f856de9b..56bd3d2c 100644 --- a/engine/pill_launcher/src/main.rs +++ b/engine/pill_launcher/src/main.rs @@ -1,1361 +1,38 @@ -#![allow(non_snake_case, dead_code)] +//! PillLauncher - CLI build orchestrator for the Pill pill project engine. +//! +//! Manages project scaffolding, asset pipelines, native/WASM builds, +//! and documentation generation for Pill-based pill projects. + +// This file is the crate root for PillLauncher. +// +// Responsibilities: +// - Declares all top-level modules. +// - Instantiates all CLI action objects. +// - Passes them to the generic dispatcher in utils::cli. + +#![deny(clippy::all)] +#![warn(missing_docs)] +#![warn(clippy::clone_on_copy)] + +mod actions; +mod types; +mod utils; + +use actions::assets::Assets; +use actions::build::Build; +use actions::cargo_passthrough::Cargo; +use actions::create::Create; +use actions::docs::Docs; +use actions::link::{Link, Unlink}; +use actions::run::Run; +use actions::Action; -mod size_report; -mod wasm_build; -mod web_dev_server; - -use anyhow::*; -use clap::{App, AppSettings, Arg}; -use config::Config; -use fs_extra::dir::CopyOptions; -use path_absolutize::Absolutize; -use std::{ - env, - ffi::OsStr, - fs::{self, File}, - io::{BufRead, BufReader, Write}, - path::{Path, PathBuf}, - process::{Command, Stdio}, - result::Result::Ok, -}; - -// - Cargo commands - -pub(crate) enum Location { - EngineProjectRoot, // Main engine project directory (containing creates, examples, etc) - EngineCrates, - PillEngineCrate, - PillCoreCrate, - PillNativeCrate, - PillLauncherCrate, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum CompileMode { - Debug, - Release, - HotReload, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum BuildTarget { - Native, - Web, -} - -// --- Platform helpers ------------------------------------------------------- - -#[cfg(target_os = "windows")] -const EXEC_SUFFIX: &str = ".exe"; -#[cfg(not(target_os = "windows"))] -const EXEC_SUFFIX: &str = ""; // Linux, macOS, etc. – no extension - -#[cfg(target_os = "windows")] -const DYLIB_PREFIX: &str = ""; // pill_game.dll -#[cfg(not(target_os = "windows"))] -const DYLIB_PREFIX: &str = "lib"; // libpill_game.so / .dylib - -#[cfg(target_os = "windows")] -const DYLIB_SUFFIX: &str = ".dll"; -#[cfg(target_os = "linux")] -const DYLIB_SUFFIX: &str = ".so"; -#[cfg(target_os = "macos")] -const DYLIB_SUFFIX: &str = ".dylib"; - -fn dylib(name: &str) -> String { - format!("{DYLIB_PREFIX}{name}{DYLIB_SUFFIX}") -} - -fn to_str(mode: &CompileMode) -> &'static str { - match mode { - CompileMode::Release => "release", - CompileMode::Debug => "debug", - CompileMode::HotReload => "hot-reload", - } -} - -fn find_engine_workspace_dir() -> Result { - // 1) Explicit override (standalone will set this) - if let Ok(v) = std::env::var("PILL_ENGINE_WORKSPACE_DIR") { - let p = PathBuf::from(v); - let m = p.join("Cargo.toml"); - if m.exists() { - return Ok(p); - } - bail!( - "PILL_ENGINE_WORKSPACE_DIR was set but {} does not exist", - m.display() - ); - } - - // 2) Search upward from current_exe and current_dir - fn search_up(start: PathBuf) -> Option { - for a in start.ancestors() { - let cand = a.join("engine").join("Cargo.toml"); - if cand.exists() { - return Some(a.join("engine")); // <- workspace dir - } - let cand2 = a.join("Cargo.toml"); - // If someone starts from .../Pill-Engine/engine already - if cand2.exists() && a.file_name().and_then(|s| s.to_str()) == Some("engine") { - return Some(a.to_path_buf()); - } - } - None - } - - let exe_dir = std::env::current_exe() - .ok() - .and_then(|p| p.parent().map(|p| p.to_path_buf())); - if let Some(d) = exe_dir.and_then(search_up) { - return Ok(d); - } - - let cwd = std::env::current_dir().context("current_dir failed")?; - if let Some(d) = search_up(cwd) { - return Ok(d); - } - - bail!("Cannot locate engine workspace dir (tried env + walking up from exe/cwd)"); -} - -// Returns absolute paths -fn get_path(location: Location) -> PathBuf { - // engine workspace dir = .../Pill-Engine/engine - let engine_ws = - find_engine_workspace_dir().expect("Failed to locate engine workspace directory"); - - // repo root = parent of engine/ - let repo_root = engine_ws.parent().unwrap().to_path_buf(); - - match location { - Location::EngineProjectRoot => repo_root, - Location::EngineCrates => engine_ws, - Location::PillEngineCrate => engine_ws.join("pill_engine"), - Location::PillCoreCrate => engine_ws.join("pill_core"), - Location::PillNativeCrate => engine_ws.join("pill_native"), - Location::PillLauncherCrate => engine_ws.join("pill_launcher"), - } -} - -pub(crate) fn modify_file String>( - input_path: &PathBuf, - output_path: &PathBuf, - mut action: A, -) -> Result<()> { - // Open files from path - let input = fs::read_to_string(input_path) - .with_context(|| format!("Failed to read {}", input_path.display()))?; - - // Prevent overwriting the same files - let mut changed = false; - - // Read lines from input file - let lines = input - .lines() - .map(|line| { - let new_line = action(line.to_string()); - if new_line != line { - changed = true; - } - new_line - }) - .collect::>(); - - let mut out = lines.join("\n"); - if input.ends_with("\n") { - out.push('\n'); - } - - // If input is the same and we are writing in-place - ignore - if input_path == output_path && !changed && out == input { - return Ok(()); - } - - // Similarly we are writing to a different file and their outputs are identical - ignore - if input_path != output_path { - if let Ok(existing) = fs::read_to_string(output_path) { - if existing == out { - return Ok(()); - } - } - } - - // Write files to output file - fs::write(output_path, out) - .with_context(|| format!("Failed to write {}", output_path.display()))?; - - Ok(()) -} - -fn copy_if_newer(source: &PathBuf, destination: &PathBuf) -> Result { - // returns true if copied - if !source.exists() { - bail!("Source does not exist: {}", source.display()); - } - - let source_meta = fs::metadata(source)?; - let source_mtime = source_meta.modified().ok(); - let source_len = source_meta.len(); - - if let Ok(destination_meta) = fs::metadata(destination) { - let destination_mtime = destination_meta.modified().ok(); - let destination_len = destination_meta.len(); - - // If same size and destination is at least as new as source, skip copy. - if destination_len == source_len { - if let (Some(s), Some(d)) = (source_mtime, destination_mtime) { - if d >= s { - return Ok(false); - } - } - } - } - - if let Some(parent) = destination.parent() { - fs::create_dir_all(parent)?; - } - fs::copy(source, destination) - .with_context(|| format!("copy {} -> {}", source.display(), destination.display()))?; - Ok(true) -} - -fn copy_directory_recursive(source: &Path, destination: &Path) -> Result<()> { - if !source.exists() { - bail!("Source directory does not exist: {}", source.display()); - } - if !source.is_dir() { - bail!("Source path is not a directory: {}", source.display()); - } - - fs::create_dir_all(destination) - .with_context(|| format!("Failed to create directory {}", destination.display()))?; - - for entry in fs::read_dir(source) - .with_context(|| format!("Failed to read directory {}", source.display()))? - { - let entry = entry?; - let entry_path = entry.path(); - let destination_path = destination.join(entry.file_name()); - - if entry_path.is_dir() { - copy_directory_recursive(&entry_path, &destination_path)?; - } else if entry_path.is_file() { - if let Some(parent) = destination_path.parent() { - fs::create_dir_all(parent)?; - } - fs::copy(&entry_path, &destination_path).with_context(|| { - format!( - "copy {} -> {}", - entry_path.display(), - destination_path.display() - ) - })?; - } - } - - Ok(()) -} - -fn stage_packaged_resources(game_project_directory_path: &Path, data_dir: &Path) -> Result<()> { - let source_resources_dir = game_project_directory_path.join("res"); - let destination_resources_dir = data_dir.join("res"); - - if !source_resources_dir.exists() { - bail!( - "Game resources directory does not exist: {}", - source_resources_dir.display() - ); - } - - fs::create_dir_all(data_dir) - .with_context(|| format!("Failed to create data directory {}", data_dir.display()))?; - if destination_resources_dir.exists() { - fs::remove_dir_all(&destination_resources_dir).with_context(|| { - format!( - "Failed to clear destination resources directory {}", - destination_resources_dir.display() - ) - })?; - } - - copy_directory_recursive(&source_resources_dir, &destination_resources_dir)?; - - let staged_config_path = destination_resources_dir.join("config.ini"); - if !staged_config_path.exists() { - bail!( - "Failed to stage resources into {} (missing {})", - destination_resources_dir.display(), - staged_config_path.display() - ); - } - - println!( - "Staged resources from {} to {}", - source_resources_dir.display(), - destination_resources_dir.display() - ); - - Ok(()) -} - -fn parse_file_lines(input_path: &PathBuf, mut action: A) -> Result<()> { - // Open files from path - let input_file = File::open(input_path).unwrap(); - - // Read lines from input file - let lines = BufReader::new(input_file) - .lines() - .map(|v| v.unwrap()) - .collect::>(); - - // Write files to output file - for line in lines { - action(line); - } - - Ok(()) -} - -// --- Utilities --- - -fn output_dir_for(mode: &CompileMode) -> &'static str { - match mode { - CompileMode::Debug => "dev", - CompileMode::Release => "release", - CompileMode::HotReload => "hot-reload", - } -} - -fn standalone_layout_for(mode: &CompileMode) -> &'static str { - match mode { - CompileMode::Release => "packaged", - CompileMode::Debug | CompileMode::HotReload => "development", - } -} - -fn get_game_build_path( - game_project_directory_path: &Path, - output_directory_path: &PathBuf, - compile_mode: &CompileMode, -) -> Result { - if output_directory_path.as_os_str() == "." { - Ok(game_project_directory_path - .join("build") - .join(output_dir_for(compile_mode)) - .absolutize()? - .to_path_buf()) - } else { - Ok(output_directory_path.absolutize()?.to_path_buf()) - } -} - -fn get_game_title(game_project_directory_path: &Path) -> Result { - // Get game title - let config_path = game_project_directory_path.join("res").join("config.ini"); - let mut config = Config::default(); - config - .merge(config::File::with_name(config_path.to_str().unwrap())) - .context("Failed to find config.ini file in game project \"res\" folder")?; - let game_title = config - .get_str("TITLE") - .context("Failed to get game config.ini")? - .replace(' ', ""); - - Ok(game_title) -} - -fn check_if_game_project_validity(game_project_directory_path: &Path) -> Result<()> { - if !game_project_directory_path.join("Cargo.toml").exists() { - return Err(Error::msg("Missing Cargo.toml file in game project folder")); - } - if !game_project_directory_path.join("res").exists() { - return Err(Error::msg("Missing \"res\" folder in game project folder")); - } - if !game_project_directory_path.join("src").exists() { - return Err(Error::msg("Missing \"src\" folder in game project folder")); - } - if !game_project_directory_path - .join("res") - .join("config.ini") - .exists() - { - return Err(Error::msg( - "Missing \"config.ini\" file in game project folder", - )); - } - - Ok(()) -} - -fn remove_files_starting_with(directory_path: &PathBuf, file_name_prefix: &str) -> Result<()> { - if !directory_path.exists() || !directory_path.is_dir() { - return Ok(()); // Skip non-existent or non-dir - } - - for entry in fs::read_dir(directory_path).context("Failed to read directory")? { - let entry = entry.context("Failed to read directory entry")?; - let path = entry.path(); - if path.is_file() { - if let Some(name) = path.file_name().and_then(|s| s.to_str()) { - if name.starts_with(file_name_prefix) { - fs::remove_file(&path) - .with_context(|| format!("Failed to remove file: {}", path.display()))?; - } - } - } - } - - Ok(()) -} - -fn try_remove_files_starting_with(directory_path: &PathBuf, file_name_prefix: &str) { - if let Err(error) = remove_files_starting_with(directory_path, file_name_prefix) { - eprintln!( - "Non-fatal cleanup failure for prefix '{}' in {}: {:#}", - file_name_prefix, - directory_path.display(), - error - ); - } -} - -// Render all *.puml under /docs/uml into /docs/uml_out as SVGs -fn render_puml_for_crate(crate_dir: &Path) -> Result<()> { - let in_dir = crate_dir.join("docs").join("uml"); - let out_dir = crate_dir.join("docs").join("uml_out"); - - if !in_dir.exists() { - return Ok(()); // Skip non-existent - } - fs::create_dir_all(&out_dir)?; - - // Collect input files - let mut inputs = Vec::new(); - for entry in fs::read_dir(&in_dir) - .with_context(|| format!("Failed to read directory: {}", in_dir.display()))? - { - let path = entry?.path(); - println!("Checking file: {}", path.display()); - if path.extension() == Some(OsStr::new("puml")) { - inputs.push(path); - } - } - - println!( - "Found {} PlantUML files to render in {}", - inputs.len(), - in_dir.display() - ); - - if inputs.is_empty() { - return Ok(()); - } - - let have_cli = Command::new("plantuml") - .arg("-version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok(); - - // Prefer "plantuml" CLI tool if available - if !have_cli { - bail!("Please install plantuml!"); - } - - for puml in &inputs { - let svg_path = out_dir - .join(puml.file_stem().unwrap()) - .with_extension("svg"); - - let mut child = Command::new("plantuml") - .arg("-tsvg") - .arg("-pipe") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - .context("Spawn plantuml -pipe")?; - - { - let mut stdin = child.stdin.take().unwrap(); - let bytes = - fs::read(puml).with_context(|| format!("Read PUML file {}", puml.display()))?; - stdin.write_all(&bytes)?; - } - - let out = child.wait_with_output().context("Wait plantuml")?; - if !out.status.success() { - bail!("plantuml failed with code {}", out.status); - } - fs::write(&svg_path, &out.stdout) - .with_context(|| format!("Write SVG file {}", svg_path.display()))?; - } - - // TODO: either distribute plantuml or download it automatically - Ok(()) -} - -fn normalize_path(p: &PathBuf) -> Result { - Ok(p.absolutize()? - .to_path_buf() - .to_string_lossy() - .replace('\\', "/")) -} - -fn extract_member_path_from_line(line: &str) -> Option { - // expects: "", ### Game project crate ... - let trimmed = line.trim(); - if !trimmed.contains("### Game project crate") { - return None; - } - let first_quote = trimmed.find('"')?; - let rest = &trimmed[first_quote + 1..]; - let second_quote = rest.find('"')?; - Some(rest[..second_quote].to_string()) -} - -fn prepare_workspace_for_game( - game_project_directory_path: &PathBuf, - compile_mode: &CompileMode, -) -> Result { - check_if_game_project_validity(game_project_directory_path) - .context("Game project is invalid")?; - - // Compilation has to be done together on pill_native and pill_game together in the same context. - // For that compilation through Cargo workspace is required. - // Otherwise, typeids of types like "Mesh" will not match what will make all generic (templated) functions work improperly - let engine_workspace_directory_path = get_path(Location::EngineCrates); - let workspace_manifest_path = engine_workspace_directory_path.join("Cargo.toml"); - if !workspace_manifest_path.exists() { - return Err(Error::msg("Cannot find engine workspace manifest file")); - } - - let desired_game_path = normalize_path(game_project_directory_path)?; - let desired_line = format!( - " \"{}\", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project)", - desired_game_path - ); - - // --- read current linked path (if any) - let manifest_text = fs::read_to_string(&workspace_manifest_path) - .with_context(|| format!("Failed to read {}", workspace_manifest_path.display()))?; - - let mut current_linked: Option = None; - for line in manifest_text.lines() { - if let Some(p) = extract_member_path_from_line(line) { - current_linked = Some(p); - break; - } - } - - let switching_game = match ¤t_linked { - Some(cur) => cur != &desired_game_path, - None => true, - }; - - // --- only clean artifacts when switching projects - if switching_game { - let compilation_artifacts_folder_path = get_path(Location::EngineCrates) - .join("target") - .join(to_str(compile_mode)); - - let artifact_prefix = if cfg!(target_os = "windows") { - "pill_game" - } else { - "libpill_game" - }; - remove_files_starting_with(&compilation_artifacts_folder_path, artifact_prefix)?; - remove_files_starting_with( - &compilation_artifacts_folder_path.join("deps"), - artifact_prefix, - )?; - } - - // --- only rewrite workspace Cargo.toml if the line would change - if switching_game { - modify_file( - &workspace_manifest_path, - &workspace_manifest_path, - |line: String| -> String { - if line.contains("### Game project crate") { - return desired_line.clone(); - } - line - }, - )?; - } - - // --- only rewrite game Cargo.toml workspace line if needed - let game_manifest_path = game_project_directory_path.join("Cargo.toml"); - let engine_ws_path = normalize_path(&get_path(Location::EngineCrates))?; - let game_manifest_text = fs::read_to_string(&game_manifest_path) - .with_context(|| format!("Failed to read {}", game_manifest_path.display()))?; - - let workspace_line_expected = format!("workspace = \"{}\"", engine_ws_path); - - let already_has_workspace_line = game_manifest_text - .lines() - .any(|l| l.trim_start().starts_with("workspace") && l.contains(&engine_ws_path)); - - if !already_has_workspace_line { - modify_file( - &game_manifest_path, - &game_manifest_path, - |line: String| -> String { - if line.trim_start().starts_with("workspace") { - return workspace_line_expected.clone(); - } - line - }, - )?; - } - - Ok(engine_workspace_directory_path) -} - -// --- Actions --- - -fn create_game_project( - game_project_parent_directory_path: &PathBuf, - game_name: &String, -) -> Result<()> { - const TEMPLATE_NAME: &str = "pill_default"; - - let game_project_directory_path = game_project_parent_directory_path.join(game_name); - if game_project_directory_path.exists() { - return Err(Error::msg(format!( - "Game project directory {} already exists", - game_project_directory_path.display() - ))); - } - - let game_resource_directory_path = game_project_directory_path.join("res"); - - println!( - "Creating new game project {} in directory {}", - game_name, - game_project_directory_path.display() - ); - - // Get templates (assuming that they are stored in res folder of pill_launcher crate) - let template_game_project_directory_path = get_path(Location::PillLauncherCrate) - .join("res") - .join("templates"); - - // Copy template - println!("Copying project template..."); - - fs_extra::dir::copy( - template_game_project_directory_path.join(TEMPLATE_NAME), - game_project_parent_directory_path, - &CopyOptions::new().overwrite(true), - ) - .context("Cannot copy template directory")?; - - // Rename project directory - fs::rename(TEMPLATE_NAME, game_name)?; - - // Setup config file - println!("Setting up config file..."); - modify_file( - &game_resource_directory_path.join("config.ini"), - &game_resource_directory_path.join("config.ini"), - |line: String| -> String { - if line.starts_with("TITLE") { - return format!("TITLE={}", game_name); - } - if line.starts_with("WINDOW_TITLE") { - return format!("WINDOW_TITLE={}", game_name); - } - line - }, - )?; - - // Setup cargo.toml file - println!("Setting up manifest file..."); - modify_file( - &game_project_directory_path.join("Cargo.toml"), - &game_project_directory_path.join("Cargo.toml"), - |line: String| -> String { - if line.contains("pill_engine") { - return format!( - "pill_engine = {{ path = \"{}\", features = [\"game\"] }}", - get_path(Location::PillEngineCrate) - .to_str() - .unwrap() - .replace("\\", "/") - ); - } - line - }, - )?; - - modify_file( - &game_project_directory_path.join("Cargo.toml"), - &game_project_directory_path.join("Cargo.toml"), - |line: String| -> String { - if line.contains("workspace") { - return format!( - "workspace = \"{}\"", - get_path(Location::EngineCrates) - .to_str() - .unwrap() - .replace("\\", "/") - ); - } - line - }, - )?; - - // Success - println!("Game project creation completed!"); - - Ok(()) -} - -fn run_game_project( - game_project_directory_path: &PathBuf, - output_directory_path: &PathBuf, - compile_mode: &CompileMode, - game_args: &[String], -) -> Result<()> { - // Build game project - build_game_project( - game_project_directory_path, - output_directory_path, - compile_mode, - )?; - - // Run game project - println!( - "Running game project from {}...", - output_directory_path.display() - ); - let game_title = - get_game_title(game_project_directory_path).context("Failed to get game title")?; - let standalone_executable_path = - output_directory_path.join(format!("{game_title}{EXEC_SUFFIX}")); - - let launcher_bin = std::env::current_exe().context("current_exe failed")?; - let engine_ws = find_engine_workspace_dir()?; // .../Pill-Engine/engine - - let status = Command::new(&standalone_executable_path) - .current_dir(output_directory_path) - .env("PILL_LAUNCHER_BIN", &launcher_bin) - .env("PILL_ENGINE_WORKSPACE_DIR", &engine_ws) - .env("PILL_GAME_PROJECT_DIR", game_project_directory_path) - .env( - "PILL_STANDALONE_LAYOUT", - standalone_layout_for(compile_mode), - ) - .env("PILL_COMPILE_MODE", to_str(compile_mode)) - .args(game_args) - .status() - .with_context(|| { - format!( - "Failed to launch game project executable: {}", - standalone_executable_path.display() - ) - })?; - - if !status.success() { - // Game ran and exited with an error β€” don't say "failed to run" - just return Ok - eprintln!( - "Game exited with error code: {}", - status.code().map_or("unknown".into(), |c| c.to_string()) - ); - } - - Ok(()) -} - -// Cook source assets (HLSLβ†’WGSL, OBJβ†’cooked_mesh, PNGβ†’cooked_tex) into the game -// project's res dir. Cooked outputs are git-ignored and `include_bytes!`'d at -// compile time (esp. on wasm), so they must exist before the game crate builds. -pub(crate) fn cook_assets(game_project_directory_path: &Path) -> Result<()> { - let pipeline = pill_assets::Pipeline { - root: game_project_directory_path.join("res"), - rules: pill_assets::default_rules(), - }; - let stats = pipeline.run().context("Asset pipeline failed")?; - println!( - "Assets: discovered={} rebuilt={} skipped={}", - stats.discovered.len(), - stats.rebuilt.len(), - stats.skipped.len() - ); - Ok(()) -} - -fn build_game_project( - game_project_directory_path: &PathBuf, - output_directory_path: &PathBuf, - compile_mode: &CompileMode, -) -> Result<()> { - println!( - "Building game project from {}...", - game_project_directory_path.display() - ); - - // Cook assets before compiling β€” cooked files are git-ignored and embedded - // via include_bytes! at compile time. - cook_assets(game_project_directory_path)?; - - let hot_reload_child = *compile_mode == CompileMode::HotReload - && std::env::var("PILL_HOT_RELOAD_CHILD").ok().as_deref() == Some("1"); - - let engine_workspace_directory_path = - prepare_workspace_for_game(game_project_directory_path, compile_mode)?; - - // Get game title EARLY (we need it for per-game target dir) - let game_title = - get_game_title(game_project_directory_path).context("Failed to get game title")?; - - // Use a per-game target dir so switching games doesn't invalidate everything - let cargo_target_dir = engine_workspace_directory_path - .join("target_games") - .join(&game_title); - - // Pre-render PUML only for non-hot-reload builds - let pill_engine_dir = get_path(Location::PillEngineCrate); - if *compile_mode != CompileMode::HotReload { - if let Err(e) = render_puml_for_crate(&pill_engine_dir) { - eprintln!("Warning: skipping PlantUML render ({})", e); - } - } - - let mut arguments = vec![ - "build", - "-p", - "pill_game", - "-p", - "pill_native", - "-p", - "pill_runtime", - ]; - if *compile_mode == CompileMode::HotReload { - arguments.push("--profile"); - arguments.push("hot-reload"); - arguments.push("--quiet"); - } - if *compile_mode == CompileMode::Release { - arguments.push("--release"); - } - Command::new("cargo") - .args(&arguments) - .current_dir(&engine_workspace_directory_path) - .env("CARGO_TARGET_DIR", &cargo_target_dir) - .status() - .context("failed to run cargo build")? - .success() - .then_some(()) - .ok_or_else(|| Error::msg("build failed"))?; - - // Where cargo artifacts actually are now: - let compilation_artifacts_folder_path = cargo_target_dir.join(to_str(compile_mode)); - - // Ensure build/data exists - fs::create_dir_all(output_directory_path.join("data").as_path()) - .context("Failed to create build output directories")?; - - // Copy standalone exe ONLY for non-hot-reload builds or hot-reload consequent reloads - if *compile_mode != CompileMode::HotReload || !hot_reload_child { - let standalone_output_path = - compilation_artifacts_folder_path.join(format!("pill_native{EXEC_SUFFIX}")); - if !standalone_output_path.exists() { - return Err(Error::msg( - "Standalone executable was not built successfully", - )); - } - - let destination_executable_path = - output_directory_path.join(format!("{game_title}{EXEC_SUFFIX}")); - - let _copied = copy_if_newer(&standalone_output_path, &destination_executable_path)?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&destination_executable_path)?.permissions(); - perms.set_mode(0o755); - fs::set_permissions(&destination_executable_path, perms)?; - } - } - - let data_dir = output_directory_path.join("data"); - fs::create_dir_all(&data_dir)?; - - // Only packaged release builds stage resources into /data/res. - // Debug and hot-reload both use the game project directory directly. - if *compile_mode == CompileMode::Release { - stage_packaged_resources(game_project_directory_path, &data_dir)?; - } - - let game_src = compilation_artifacts_folder_path.join(dylib("pill_game")); - let runtime_src = compilation_artifacts_folder_path.join(dylib("pill_runtime")); - - if !game_src.exists() { - return Err(Error::msg(format!( - "Game dylib missing: {}", - game_src.display() - ))); - } - if !runtime_src.exists() { - return Err(Error::msg(format!( - "Runtime dylib missing: {}", - runtime_src.display() - ))); - } - - // Copy the dylibs for the initial build only (not consecutive hot-reloads, otherwise we - // overwrite loaded libs and crash!) - if *compile_mode != CompileMode::HotReload || !hot_reload_child { - if copy_if_newer(&game_src, &data_dir.join(dylib("pill_game")))? { - println!("Copied game dylib"); - } else { - println!("Skipping copying of game dylib"); - } - if copy_if_newer(&runtime_src, &data_dir.join(dylib("pill_runtime")))? { - println!("Copied runtime dylib"); - } else { - println!("Skipping copying of runtime dylib"); - } - } - - // In hot-reload mode, also update the hot names (watcher looks for these) - if *compile_mode == CompileMode::HotReload { - if copy_if_newer(&game_src, &data_dir.join(dylib("pill_game_hot_reloaded")))? { - println!("Copied game hot-reload dylib"); - } else { - println!("Skipping copying of game hot-reload dylib"); - } - if copy_if_newer( - &runtime_src, - &data_dir.join(dylib("pill_runtime_hot_reloaded")), - )? { - println!("Copied runtime hot-reload dylib"); - } else { - println!("Skipping copying of runtime hot-reload dylib"); - } - } - - // Success - println!("Game built successfully!"); - - Ok(()) -} - -// Runs "cargo doc" command for engine -fn generate_docs(output_directory_path: &PathBuf) -> Result<()> { - // Set empty project as dependency - let empty_example_game_path = get_path(Location::EngineProjectRoot) - .join("examples") - .join("Empty"); - if !empty_example_game_path.exists() { - return Err(Error::msg( - "Cannot find Empty project in examples directory", - )); - } - - // Update engine project dependency in game's cargo.toml - modify_file( - &empty_example_game_path.join("Cargo.toml"), - &empty_example_game_path.join("Cargo.toml"), - |line: String| -> String { - if line.contains("pill_engine") { - return format!( - "pill_engine = {{path = \"{}\", features = [\"game\"]}}", - get_path(Location::PillEngineCrate) - .to_str() - .unwrap() - .replace("\\", "/") - ); - } - line - }, - )?; - - // Update game project dependency in standalone's cargo.toml - modify_file( - &get_path(Location::PillNativeCrate).join("Cargo.toml"), - &get_path(Location::PillNativeCrate).join("Cargo.toml"), - |line: String| -> String { - if line.contains("pill_game") { - return format!( - "pill_game = {{path = \"{}\"}}", - empty_example_game_path.to_str().unwrap().replace("\\", "/") - ); - } - line - }, - )?; - - let output_path = if output_directory_path.as_os_str() == "." { - env::current_dir().context("Failed to get current directory")? - } else { - output_directory_path - .absolutize() - .context("Failed to absolutize output path")? - .to_path_buf() - }; - - let docs_path = output_path.join("docs"); - - if docs_path.exists() { - fs::remove_dir_all(&docs_path) - .with_context(|| format!("Cannot clear output directory: {}", docs_path.display()))?; - } - - let output_game_dev_path = docs_path.join("game_dev"); - let output_engine_dev_path = docs_path.join("engine_dev"); - - // Prepare output directories - fs::create_dir_all(&docs_path)?; - fs::create_dir_all(&output_game_dev_path)?; - fs::create_dir_all(&output_engine_dev_path)?; - - let engine_crate_manifest_path = get_path(Location::PillEngineCrate).join("Cargo.toml"); - let full_engine_manifest_path = empty_example_game_path.join("Cargo.toml"); - - // Pre-render all PUML in the engine crate - let pill_engine_dir = get_path(Location::PillEngineCrate); - if let Err(e) = render_puml_for_crate(&pill_engine_dir) { - eprintln!("Warning: skipping PlantUML render ({})", e); - } - - // Game dev docs - let arguments = vec![ - "doc", - "--no-deps", - "--features", - "game,internal", - "--manifest-path", - full_engine_manifest_path.to_str().unwrap(), - "--target-dir", - output_game_dev_path.to_str().unwrap(), - "--release", - ]; - let status = Command::new("cargo") - .args(arguments) - .status() - .context("Failed to execute command for generating game dev docs")?; - - if !status.success() { - bail!("Engine docs failed to generate (exit {:?})", status.code()); - } - println!("Engine dev docs generated successfully!"); - - // Engine dev docs - // Generate pill_core before pill_engine and don't generate other dependencies - let core_crate_manifest_path = get_path(Location::PillCoreCrate).join("Cargo.toml"); - let arguments = vec![ - "doc", - "--no-deps", - "--document-private-items", - "--manifest-path", - core_crate_manifest_path.to_str().unwrap(), - "--target-dir", - output_engine_dev_path.to_str().unwrap(), - "--release", - ]; - let status = Command::new("cargo") - .args(arguments) - .status() - .context("Failed to execute command for generating core dev docs")?; - - // Success - if status.success() { - println!("Core dev docs generated successfully!"); - } - - let arguments = vec![ - "doc", - "--no-deps", - "--document-private-items", - "--features", - "all", - "--manifest-path", - engine_crate_manifest_path.to_str().unwrap(), - "--target-dir", - output_engine_dev_path.to_str().unwrap(), - "--release", +fn main() { + let actions: [&dyn Action; 8] = [ + &Create, &Run, &Build, &Docs, &Cargo, &Assets, &Link, &Unlink, ]; - let status = Command::new("cargo") - .args(arguments) - .status() - .context("Failed to execute command for generating engine dev docs")?; - - // Success - if !status.success() { - bail!( - "Game dev docs failed to generate (exit {:?})", - status.code() - ); - } - println!("Game dev docs generated successfully!"); - - Ok(()) -} -fn cargo_passthrough( - game_project_directory_path: &PathBuf, - compile_mode: &CompileMode, - cargo_args: &[String], -) -> Result<()> { - if cargo_args.is_empty() { - bail!("Must call cargo with at least one argument"); - } - - let engine_workspace_directory_path = - prepare_workspace_for_game(game_project_directory_path, compile_mode)?; - - println!( - "Running cargo {:?} in workspace {}...", - cargo_args, - engine_workspace_directory_path.display() - ); - - let status = Command::new("cargo") - .args(cargo_args) - .current_dir(engine_workspace_directory_path) - .status() - .context("Failed to run cargo passthrough")?; - - if !status.success() { - bail!( - "Cargo command failed: cargo {:?} (exit {:?})", - cargo_args, - status.code() - ); - } - - Ok(()) -} - -fn run_app() -> Result<()> { - let app = App::new("Pill Engine Launcher").about("Tool for managing Pill Engine game projects"); - - // Definition of the options for the CLI - let action_option = Arg::with_name("action") - .short("a") - .long("action") - .takes_value(true) - .possible_values(&["create", "run", "build", "docs", "cargo", "assets"]) - .required(true) - .help("Specify action to perform: creating/running/building the game project, generating docs, running cargo passthrough, or rebuilding assets (HLSLβ†’WGSL etc.)"); - - let name_option = Arg::with_name("name") - .short("n") - .long("name") - .takes_value(true) - .required_if("action", "create") - .help("Specify name of new game project"); - - let path_option = Arg::with_name("path") - .short("p") - .long("path") - .takes_value(true) - .default_value(".") - .required(false) - .help("Specify the path for game project creating/running/building"); - - let output_path_option = Arg::with_name("output-path") - .short("o") - .long("output-path") - .takes_value(true) - .default_value(".") - .required(false) - .help("Specify action output directory"); - - let compile_mode_option = Arg::with_name("compile-mode") - .short("c") - .long("compile-mode") - .takes_value(true) - .help("Specify compile mode") - .possible_values(&["debug", "release", "hot-reload"]) - .default_value("debug") - .required(false); - - let target_option = Arg::with_name("target") - .short("t") - .long("target") - .takes_value(true) - .possible_values(&["native", "web"]) - .default_value("native") - .required(false) - .help("Build/run target: native standalone executable or WASM+WebGPU for the browser"); - - let max_wasm_size_option = Arg::with_name("max-wasm-size") - .long("max-wasm-size") - .takes_value(true) - .required(false) - .help("Fail WASM release build if final binary exceeds N KB"); - - let game_args = Arg::with_name("game-args") - .help("Arguments passed through to cargo/game (use `--` to separate them)") - .multiple(true) - .last(true) - .allow_hyphen_values(true); - - // Addition of the options to the CLI - let app = app - .arg(action_option) - .arg(name_option) - .arg(path_option) - .arg(output_path_option) - .arg(compile_mode_option) - .arg(target_option) - .arg(max_wasm_size_option) - .arg(game_args) - .setting(AppSettings::TrailingVarArg); - - // Extraction of the arguments - let matches = app.get_matches(); - - // Arguments - let passthrough_args: Vec = matches - .values_of("game-args") - .map(|vals| vals.map(|s| s.to_string()).collect()) - .unwrap_or_default(); - let action_argument = matches - .value_of("action") - .expect("Action has to be specified"); - let directory_path_argument = matches.value_of("path"); - let game_name_argument = matches.value_of("name"); - let output_directory_path_argument = matches.value_of("output-path"); - let compile_mode_argument = matches.value_of("compile-mode").unwrap_or("debug"); - - let compile_mode: CompileMode = match compile_mode_argument { - "release" => CompileMode::Release, - "hot-reload" => CompileMode::HotReload, - _ => CompileMode::Debug, - }; - - let target: BuildTarget = match matches.value_of("target").unwrap_or("native") { - "web" => BuildTarget::Web, - _ => BuildTarget::Native, - }; - - let max_wasm_size_kb: Option = matches - .value_of("max-wasm-size") - .and_then(|s| s.parse().ok()); - - match action_argument { - "create" => { - let game_parent_directory_path = PathBuf::from(directory_path_argument.expect("Game project parent directory path has to be specified using --path flag. For example: --path ")) - .absolutize().context("Failed to absolutize game project parent directory path")? - .to_path_buf(); - let game_name = String::from(game_name_argument.expect("Game name has to be specified using --name flag. For example: --name ")); - - create_game_project(&game_parent_directory_path, &game_name) - .context("Failed to create new game project")?; - } - "run" => { - let game_project_directory_path = PathBuf::from(directory_path_argument.expect("Game project directory path has to be specified using --path flag. For example: --path ")) - .absolutize().context("Failed to absolutize game project directory path")? - .to_path_buf(); - - match target { - BuildTarget::Native => { - let mut output_directory_path = PathBuf::from(output_directory_path_argument.expect("Output directory path has to be specified using --output-path flag. For example: --output-path ")); - output_directory_path = get_game_build_path( - &game_project_directory_path, - &output_directory_path, - &compile_mode, - ) - .unwrap(); - run_game_project( - &game_project_directory_path, - &output_directory_path, - &compile_mode, - &passthrough_args, - ) - .context("Failed to run game project")?; - } - BuildTarget::Web => { - web_dev_server::run(&game_project_directory_path, &compile_mode) - .context("Failed to run game project for wasm")?; - } - } - } - "build" => { - let game_project_directory_path = PathBuf::from(directory_path_argument.expect("Game project directory path has to be specified using --path flag. For example: --path ")) - .absolutize().context("Failed to absolutize game project directory path")? - .to_path_buf(); - - match target { - BuildTarget::Native => { - let mut output_directory_path = PathBuf::from(output_directory_path_argument.expect("Output directory path has to be specified using --output-path flag. For example: --output-path ")); - output_directory_path = get_game_build_path( - &game_project_directory_path, - &output_directory_path, - &compile_mode, - )?; - build_game_project( - &game_project_directory_path, - &output_directory_path, - &compile_mode, - ) - .context("Failed to build game project")?; - } - BuildTarget::Web => { - if matches.occurrences_of("output-path") > 0 { - println!( - "Note: `-o/--output-path` is ignored with `-t wasm`; output is fixed at /build/wasm/" - ); - } - wasm_build::build( - &game_project_directory_path, - &compile_mode, - max_wasm_size_kb, - ) - .context("Failed to build game project for wasm")?; - } - } - } - "docs" => { - let output_directory_path = PathBuf::from(output_directory_path_argument.expect("Output directory path has to be specified using --output-path flag. For example: --output-path ")) - .absolutize().context("Failed to absolutize output directory path")? - .to_path_buf(); - - generate_docs(&output_directory_path).context("Failed to generate docs")?; - } - "cargo" => { - let game_project_directory_path = PathBuf::from( - directory_path_argument - .expect("Game project must be specified when running cargo commands."), - ) - .absolutize() - .context("Failed to absolutize game project directory path")? - .to_path_buf(); - - cargo_passthrough( - &game_project_directory_path, - &compile_mode, - &passthrough_args, - ) - .context("Cargo passthrough failed")?; - } - "assets" => { - let project_dir = PathBuf::from(directory_path_argument.expect( - "Project directory must be specified for asset rebuild. Example: --path ", - )) - .absolutize() - .context("Failed to absolutize project directory path")? - .to_path_buf(); - - cook_assets(&project_dir)?; - } - _ => { - println!("Undefined action"); - } - }; - Ok(()) -} - -fn main() { - if let Err(e) = run_app() { + if let Err(e) = utils::cli::run_app(&actions) { eprintln!("{:#}", e); std::process::exit(1); } diff --git a/engine/pill_launcher/src/size_report.rs b/engine/pill_launcher/src/size_report.rs deleted file mode 100644 index d685310e..00000000 --- a/engine/pill_launcher/src/size_report.rs +++ /dev/null @@ -1,270 +0,0 @@ -//! Post-build wasm size report β€” final (post-wasm-opt) vs pre-opt sizes, -//! per-crate breakdown, and top symbols. Backed by the `twiggy` CLI; prints -//! a hint if twiggy is not installed. - -use std::collections::HashMap; -use std::fs; -use std::path::Path; -use std::process::{Command, Stdio}; - -pub fn print(build_wasm_dir: &Path, preopt_wasm: &Path) { - let Ok(preopt_size) = fs::metadata(preopt_wasm).map(|m| m.len()) else { - return; - }; - let final_wasm = build_wasm_dir.join("pill_web_app_bg.wasm"); - let final_size = fs::metadata(&final_wasm).ok().map(|m| m.len()); - - println!(); - match final_size { - Some(f) => println!( - "wasm size: final {} | pre-opt {}", - fmt_bytes(f), - fmt_bytes(preopt_size) - ), - None => println!("wasm size: pre-opt {}", fmt_bytes(preopt_size)), - } - - // Run twiggy on both binaries when available so we can see what actually - // survives wasm-opt (the pre-opt binary contains wasm-bindgen custom - // sections that are stripped during optimization). - if let Some(f) = final_size { - println!(); - println!("--- Final binary analysis ({}) ---", fmt_bytes(f)); - match run_twiggy_analysis(&final_wasm, f) { - TwiggyResult::NoTwiggy => { - println!("(install twiggy for per-crate breakdown: cargo install twiggy)"); - } - TwiggyResult::Done => {} - TwiggyResult::Empty | TwiggyResult::Error => {} - } - } - - println!(); - println!("--- Pre-opt analysis ({}) ---", fmt_bytes(preopt_size)); - if let TwiggyResult::NoTwiggy = run_twiggy_analysis(preopt_wasm, preopt_size) { - println!("(install twiggy for per-crate breakdown: cargo install twiggy)"); - } -} - -enum TwiggyResult { - Done, - Empty, - Error, - NoTwiggy, -} - -fn run_twiggy_analysis(wasm_path: &Path, total: u64) -> TwiggyResult { - // `twiggy top` lists items by retained size. `-n 15000` returns effectively - // the full symbol table (typical wasm bundles have a few thousand symbols); - // we aggregate downstream in `classify_crate` / `parse_twiggy`, so we want - // the whole list, not just the biggest N. - let output = match Command::new("twiggy") - .args(["top", "-n", "15000"]) - .arg(wasm_path) - .stderr(Stdio::null()) - .output() - { - Ok(o) => o, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return TwiggyResult::NoTwiggy, - Err(_) => return TwiggyResult::Error, - }; - if !output.status.success() { - return TwiggyResult::Error; - } - let Ok(stdout) = String::from_utf8(output.stdout) else { - return TwiggyResult::Error; - }; - - let items = parse_twiggy(&stdout); - if items.is_empty() { - return TwiggyResult::Empty; - } - - let mut by_crate: HashMap = HashMap::new(); - for (bytes, name) in &items { - *by_crate.entry(classify_crate(name)).or_insert(0) += *bytes; - } - let mut groups: Vec<(String, u64)> = by_crate.clone().into_iter().collect(); - groups.sort_by(|a, b| b.1.cmp(&a.1)); - - const ENGINE_LIBS: &[&str] = &["pill_engine", "pill_renderer", "pill_core", "pill_web"]; - const GAME_LIBS: &[&str] = &["pill_game"]; - let excluded: Vec<&str> = ENGINE_LIBS - .iter() - .chain(GAME_LIBS.iter()) - .copied() - .collect(); - - let engine_total: u64 = ENGINE_LIBS - .iter() - .map(|k| by_crate.get(*k).copied().unwrap_or(0)) - .sum(); - - println!(); - println!(" Engine libs β€” BUDGET (% of {}):", fmt_bytes(total)); - println!(" {:<20} {:>10} {:>7}", "crate", "size", "%"); - for lib in ENGINE_LIBS { - let bytes = by_crate.get(*lib).copied().unwrap_or(0); - let pct = 100.0 * bytes as f64 / total as f64; - println!(" {:<20} {:>10} {:>6.1}%", lib, fmt_bytes(bytes), pct); - } - let epct = 100.0 * engine_total as f64 / total as f64; - println!( - " {:<20} {:>10} {:>6.1}% ← engine total", - "---", - fmt_bytes(engine_total), - epct - ); - - let game_bytes = by_crate.get("pill_game").copied().unwrap_or(0); - let game_rodata = by_crate.get("[game-rodata]").copied().unwrap_or(0); - println!(); - println!(" Game (monitor only β€” excluded from engine budget):"); - println!(" {:<20} {:>10} {:>7}", "crate", "size", "%"); - println!( - " {:<20} {:>10} {:>6.1}% (game logic)", - "pill_game", - fmt_bytes(game_bytes), - 100.0 * game_bytes as f64 / total as f64 - ); - println!( - " {:<20} {:>10} {:>6.1}% (embedded assets via include_bytes!)", - "[game-assets]", - fmt_bytes(game_rodata), - 100.0 * game_rodata as f64 / total as f64 - ); - - println!(); - println!(" 3rd party (top 15):"); - println!(" {:<20} {:>10} {:>7}", "crate", "size", "%"); - for (crate_name, bytes) in groups - .iter() - .filter(|(k, _)| !excluded.contains(&k.as_str())) - .take(15) - { - let pct = 100.0 * *bytes as f64 / total as f64; - println!( - " {:<20} {:>10} {:>6.1}%", - crate_name, - fmt_bytes(*bytes), - pct - ); - } - - println!(); - println!(" Top 10 symbols:"); - for (bytes, name) in items.iter().take(10) { - let pct = 100.0 * *bytes as f64 / total as f64; - let display = truncate_display(name, 72); - println!(" {:>10} {:>5.1}% {}", fmt_bytes(*bytes), pct, display); - } - - TwiggyResult::Done -} - -// Parse twiggy's default text output. Each data row: -// " β”Š % β”Š " -fn parse_twiggy(stdout: &str) -> Vec<(u64, String)> { - let mut items = Vec::new(); - for line in stdout.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() - || trimmed.starts_with("Shallow") - || trimmed.starts_with('─') - || trimmed.starts_with('Ξ£') - || (trimmed.contains("and ") && trimmed.contains("more")) - { - continue; - } - let parts: Vec<&str> = trimmed.split('β”Š').collect(); - if parts.len() < 3 { - continue; - } - let Ok(bytes) = parts[0].trim().parse::() else { - continue; - }; - if bytes == 0 { - continue; - } - items.push((bytes, parts[2].trim().to_string())); - } - items -} - -fn truncate_display(name: &str, max_chars: usize) -> String { - if name.chars().count() > max_chars { - let head: String = name.chars().take(max_chars - 3).collect(); - format!("{head}...") - } else { - name.to_string() - } -} - -fn fmt_bytes(n: u64) -> String { - const MB: f64 = 1024.0 * 1024.0; - const KB: f64 = 1024.0; - let f = n as f64; - if f >= MB { - format!("{:.2} MB", f / MB) - } else if f >= KB { - format!("{:.1} KB", f / KB) - } else { - format!("{n} B") - } -} - -// Coarse bucketing of twiggy item names into crate families. Heuristic β€” -// relies on stable twiggy section-name output + the wasm-bindgen symbol -// prefix contract. Unknowns fall through to `[other]`; not used for -// correctness, only for the per-family rollup in the printed report. -fn classify_crate(name: &str) -> String { - if name.contains(".rodata") || name.contains("data segment") { - if name.contains("pill_game") { - return "[game-rodata]".into(); - } - return "[rodata]".into(); - } - if name.contains("function names") { - return "[debug:names]".into(); - } - if name.contains("__wasm_bindgen") { - return "[wasm-bindgen]".into(); - } - if name.contains("custom section") { - return "[custom]".into(); - } - if name.starts_with("elem[") - || name.starts_with("type[") - || name.starts_with("import ") - || name.starts_with("table[") - { - return "[wasm-meta]".into(); - } - - // Peel `<` and any `&` / `&mut ` so `<&mut Foo as Bar>::baz` resolves to `Foo`. - let rest = name.strip_prefix('<').unwrap_or(name); - let rest = rest - .strip_prefix("&mut ") - .or_else(|| rest.strip_prefix('&')) - .unwrap_or(rest); - let ident: String = rest - .chars() - .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') - .collect(); - - if ident.is_empty() { - return "[other]".into(); - } - match ident.as_str() { - "core" | "alloc" | "std" | "compiler_builtins" | "rustc_demangle" | "dlmalloc" | "str" - | "bool" | "u8" | "u16" | "u32" | "u64" | "i8" | "i16" | "i32" | "i64" | "f32" | "f64" - | "char" | "usize" | "isize" | "T" => "[rust-std]".into(), - "jpeg_decoder" | "png" | "tiff" | "gif" | "weezl" | "miniz_oxide" | "color_quant" - | "qoi" | "exr" => "image".into(), - "epaint" | "emath" | "egui_wgpu" | "egui_winit" => "egui".into(), - "codespan_reporting" | "codespan" | "pp_rs" | "spirv" => "naga".into(), - "wgpu_hal" | "wgpu_core" | "wgpu_types" => "wgpu".into(), - "js_sys" => "web_sys".into(), - _ => ident, - } -} diff --git a/engine/pill_launcher/src/types.rs b/engine/pill_launcher/src/types.rs new file mode 100644 index 00000000..d3777f04 --- /dev/null +++ b/engine/pill_launcher/src/types.rs @@ -0,0 +1,70 @@ +// This file defines the shared enums used by every other module in the launcher. +// +// Responsibilities: +// - Location: maps logical repo paths (engine crates, examples root, etc.) to absolute dirs. +// - CompileMode: selects debug, release, or hot-reload build profile. +// - BuildTarget: chooses between native standalone and WASM/WebGPU output. + +use std::fmt; + +/// Which part of the repository a path points to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub(crate) enum Location { + /// Repository root (parent of engine/). + EngineProjectRoot, + /// The engine/ workspace directory (contains pill_core, pill_engine, …). + EngineCrates, + /// The pill_engine crate. + PillEngineCrate, + /// The pill_core crate. + PillCoreCrate, + /// The pill_native crate (standalone host executable). + PillNativeCrate, + /// The pill_launcher crate itself. + PillLauncherCrate, + /// The pill_renderer crate. + PillRendererCrate, + /// The pill_abi crate. + PillAbiCrate, + /// The pill_assets crate. + PillAssetsCrate, + /// The pill_runtime crate. + PillRuntimeCrate, + /// The pill_web crate. + PillWebCrate, +} + +/// Build profile: debug, release, or hot-reload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CompileMode { + Debug, + Release, + HotReload, +} + +impl fmt::Display for CompileMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CompileMode::Debug => write!(f, "debug"), + CompileMode::Release => write!(f, "release"), + CompileMode::HotReload => write!(f, "hot-reload"), + } + } +} + +/// Build target: native standalone executable or WASM+WebGPU. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BuildTarget { + Native, + Web, +} + +impl fmt::Display for BuildTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BuildTarget::Native => write!(f, "native"), + BuildTarget::Web => write!(f, "web"), + } + } +} diff --git a/engine/pill_launcher/src/utils/assets.rs b/engine/pill_launcher/src/utils/assets.rs new file mode 100644 index 00000000..fda8f6a4 --- /dev/null +++ b/engine/pill_launcher/src/utils/assets.rs @@ -0,0 +1,31 @@ +//! This file manages the asset pipeline (raw β†’ cooked asset processing). +use anyhow::{Context, Result}; +use std::path::Path; + +use crate::utils::files::delete_cooked_resource_files_recursive; + +/// Process raw assets (models, textures) into cooked formats via pill_assets. +/// If force_rebuild is true, deletes all previously cooked files first. +pub(crate) fn run_asset_pipeline(resources_directory: &Path, force_rebuild: bool) -> Result<()> { + // Force-rebuild: delete all previously cooked assets before processing. + if force_rebuild { + println!( + "Force-rebuild: deleting cooked files under {}...", + resources_directory.display() + ); + delete_cooked_resource_files_recursive(resources_directory)?; + } + let pipeline = pill_assets::Pipeline { + root: resources_directory.to_path_buf(), + rules: pill_assets::default_rules(), + }; + let stats = pipeline.run().context("Asset pipeline operation failed")?; + println!( + "Assets: discovered={} rebuilt={} skipped={} (root: {})", + stats.discovered.len(), + stats.rebuilt.len(), + stats.skipped.len(), + resources_directory.display() + ); + Ok(()) +} diff --git a/engine/pill_launcher/src/utils/cli.rs b/engine/pill_launcher/src/utils/cli.rs new file mode 100644 index 00000000..e5d9b349 --- /dev/null +++ b/engine/pill_launcher/src/utils/cli.rs @@ -0,0 +1,210 @@ +//! This file contains the CLI entry point: a generic action dispatcher +//! and shared CLI flag constructors and argument parsers used by multiple actions. +//! +//! Responsibilities: +//! - Takes a list of Action trait objects. +//! - Iterates over them, calling register() on each to build the full CLI. +//! - Builds subcommands from actions and dispatches to the matching action's +//! run() method. +//! - After parsing, dispatches to the matching action's run() method. + +use crate::types::{BuildTarget, CompileMode}; +use anyhow::{bail, Result}; +use clap::{App, AppSettings, Arg, ArgMatches}; + +use crate::actions::Action; + +/// Build the CLI from the provided actions, parse args, and dispatch. +/// +/// Each action becomes a clap subcommand (e.g. `PillLauncher run -p . -c release`), +/// giving context-sensitive `--help` per action. The old `-a` / `--action` flag +/// is no longer used. +pub(crate) fn run_app(actions: &[&dyn Action]) -> Result<()> { + let mut application = App::new("PillLauncher") + .about("Tool for managing Pill project projects") + .version(env!("CARGO_PKG_VERSION")) + .setting(AppSettings::SubcommandRequiredElseHelp) + .setting(AppSettings::TrailingVarArg); + + // Each action registers its own flags on its own subcommand. + for action in actions { + let sub = App::new(action.name()).about(action.description()); + let sub = action.register(sub); + application = application.subcommand(sub); + } + + // Use get_matches_safe so we don't exit() inside the library - important + // for unit tests that call run_app directly. We must handle + // HelpDisplayed / VersionDisplayed ourselves because clap returns them + // as errors even though the user expects exit code 0. + let matches = match application.get_matches_safe() { + Ok(m) => m, + Err(e) => { + // get_matches_safe() does NOT print to stdout/stderr - it returns + // an Error with the message. For --help / --version, the error's + // Display impl has the help/version text; we must print it ourselves. + use clap::ErrorKind; + match e.kind { + ErrorKind::HelpDisplayed | ErrorKind::VersionDisplayed => { + print!("{e}"); + return Ok(()); + } + _ => return Err(anyhow::anyhow!("{}", e)), + } + } + }; + + // Find the matched subcommand and dispatch. + for action in actions { + if let Some(sub_matches) = matches.subcommand_matches(action.name()) { + return action.run(sub_matches); + } + } + + // Should not reach here because SubcommandRequiredElseHelp handles it, + // but fall back to showing help. + bail!("No valid action specified. Use --help to see available actions.") +} + +// -- Named defaults (single source of truth) ------------------------------- + +pub(crate) const DEFAULT_COMPILE_MODE: &str = "debug"; + +// -- Shared flag builders --------------------------------------------------- + +/// `-p` / `--path` - project directory. +pub(crate) fn path_flag() -> Arg<'static, 'static> { + Arg::with_name("path") + .short("p") + .long("path") + .takes_value(true) + .default_value(".") + .help("Path to the project") +} + +/// `-c` / `--compile-mode` - debug, release, or hot-reload. +pub(crate) fn compile_mode_flag() -> Arg<'static, 'static> { + Arg::with_name("compile-mode") + .short("c") + .long("compile-mode") + .takes_value(true) + .default_value(DEFAULT_COMPILE_MODE) + .possible_values(&["debug", "release", "hot-reload"]) + .help("Build profile: debug, release, or hot-reload") +} + +/// `-o` / `--output-path` - where to place build artifacts. +pub(crate) fn output_path_flag() -> Arg<'static, 'static> { + Arg::with_name("output-path") + .short("o") + .long("output-path") + .takes_value(true) + .default_value(".") + .help("Build output directory") +} + +/// `-t` / `--target` - native or web (WASM). +pub(crate) fn target_flag() -> Arg<'static, 'static> { + Arg::with_name("target") + .short("t") + .long("target") + .takes_value(true) + .default_value("native") + .possible_values(&["native", "web"]) + .help("Build target: native executable or WASM+WebGPU") +} + +/// `--clean` - force-rebuild cooked assets before building. +pub(crate) fn clean_flag() -> Arg<'static, 'static> { + Arg::with_name("clean") + .long("clean") + .help("Delete all cooked asset files and rebuild from source") +} + +/// `--additional-features` - comma-separated Cargo features for the project. +pub(crate) fn features_flag() -> Arg<'static, 'static> { + Arg::with_name("additional-features") + .long("additional-features") + .takes_value(true) + .help("Cargo features to enable for project (comma-separated)") +} + +/// `--wasm-port` - port for the WASM dev server. +pub(crate) fn wasm_port_flag() -> Arg<'static, 'static> { + Arg::with_name("wasm-port") + .long("wasm-port") + .takes_value(true) + .default_value("8080") + .help("Port for the WASM dev server") +} + +/// `--max-wasm-size` - maximum WASM binary size in KB. +pub(crate) fn max_wasm_size_flag() -> Arg<'static, 'static> { + Arg::with_name("max-wasm-size") + .long("max-wasm-size") + .takes_value(true) + .help("Maximum WASM binary size in KB") +} + +/// `--wasm-analyze` - run twiggy size analysis after WASM release build. +pub(crate) fn wasm_analyze_flag() -> Arg<'static, 'static> { + Arg::with_name("wasm-analyze") + .long("wasm-analyze") + .help("Run twiggy size breakdown after WASM release build") +} + +/// `--` passthrough for project/cargo arguments. +pub(crate) fn project_args_flag() -> Arg<'static, 'static> { + Arg::with_name("project-args") + .help("Arguments passed through to the project or cargo (use `--` to separate)") + .multiple(true) + .last(true) + .allow_hyphen_values(true) +} + +/// `--headless` - build/run without a window (native target only). +pub(crate) fn headless_flag() -> Arg<'static, 'static> { + Arg::with_name("headless") + .long("headless") + .help("Build and run without a window (native only, for benchmarks/CI)") +} + +// -- Flag-group helpers (so actions only register what they use) ------------ + +/// Add `-p` / `--path` to an app/subcommand. +pub(crate) fn add_path_flag(application: App<'static, 'static>) -> App<'static, 'static> { + application.arg(path_flag()) +} + +/// Add build-related flags: `-c`, `-t`, `-o`, `--clean`, `--features`, `--headless`. +pub(crate) fn add_build_flags(application: App<'static, 'static>) -> App<'static, 'static> { + application + .arg(compile_mode_flag()) + .arg(target_flag()) + .arg(output_path_flag()) + .arg(clean_flag()) + .arg(features_flag()) + .arg(headless_flag()) +} + +// -- Shared parsers --------------------------------------------------------- + +/// Extract CompileMode from parsed CLI matches. +pub(crate) fn parse_compile_mode(matches: &ArgMatches) -> CompileMode { + match matches + .value_of("compile-mode") + .unwrap_or(DEFAULT_COMPILE_MODE) + { + "release" => CompileMode::Release, + "hot-reload" => CompileMode::HotReload, + _ => CompileMode::Debug, + } +} + +/// Extract BuildTarget from parsed CLI matches. +pub(crate) fn parse_build_target(matches: &ArgMatches) -> BuildTarget { + match matches.value_of("target").unwrap_or("native") { + "web" => BuildTarget::Web, + _ => BuildTarget::Native, + } +} diff --git a/engine/pill_launcher/src/utils/common.rs b/engine/pill_launcher/src/utils/common.rs new file mode 100644 index 00000000..035eff39 --- /dev/null +++ b/engine/pill_launcher/src/utils/common.rs @@ -0,0 +1,511 @@ +//! This file provides general-purpose utility functions used across the launcher. +//! +//! Responsibilities: +//! - Runtime feature-flag detection (env vars). +//! - ANSI terminal escape helpers (cached terminal detection). +//! - Cargo stderr parsing for user-friendly error messages. +//! - Duration formatting. +//! - Filesystem helpers: pseudo-symlink resolution, directory copy, atomic writes. + +use std::{ + fs, + io::IsTerminal, + path::{Path, PathBuf}, + process::Command, + sync::LazyLock, + time::{Duration, SystemTime}, +}; + +use anyhow::{Context, Error, Result}; +use fs_extra::dir::CopyOptions; + +use crate::types::{BuildTarget, CompileMode, Location}; +use crate::utils::paths::{get_path, get_project_build_path, get_project_title}; + +// --------------------------------------------------------------------------- +// OS-specific constants and the dynamic library naming helper. +// --------------------------------------------------------------------------- + +/// Executable file extension (e.g. ".exe" on Windows, "" on Linux/macOS). +#[cfg(target_os = "windows")] +pub(crate) const EXECUTABLE_SUFFIX: &str = ".exe"; +#[cfg(not(target_os = "windows"))] +pub(crate) const EXECUTABLE_SUFFIX: &str = ""; // Linux, macOS, etc. – no extension + +#[cfg(target_os = "windows")] +pub(crate) const DYNAMIC_LIBRARY_PREFIX: &str = ""; // project.dll +#[cfg(not(target_os = "windows"))] +pub(crate) const DYNAMIC_LIBRARY_PREFIX: &str = "lib"; // lib_project.so / .dylib + +#[cfg(target_os = "windows")] +pub(crate) const DYNAMIC_LIBRARY_SUFFIX: &str = ".dll"; +#[cfg(target_os = "linux")] +pub(crate) const DYNAMIC_LIBRARY_SUFFIX: &str = ".so"; +#[cfg(target_os = "macos")] +pub(crate) const DYNAMIC_LIBRARY_SUFFIX: &str = ".dylib"; +#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] +pub(crate) const DYNAMIC_LIBRARY_SUFFIX: &str = ".so"; // reasonable default for Unix-like platforms + +pub(crate) fn dynamic_library_name(name: &str) -> String { + format!("{DYNAMIC_LIBRARY_PREFIX}{name}{DYNAMIC_LIBRARY_SUFFIX}") +} + +// --------------------------------------------------------------------------- +// Feature flags (runtime) +// --------------------------------------------------------------------------- + +/// When set, stderr from cargo is parsed and noisy lines are suppressed, +/// and only actionable error messages are extracted. This is experimental +/// and may drop useful diagnostics - keep disabled by default. +/// Set `PILL_LAUNCHER_EXPERIMENTAL_LOGS=1` to enable at runtime. +pub(crate) fn use_experimental_logs_parser() -> bool { + std::env::var("PILL_LAUNCHER_EXPERIMENTAL_LOGS") + .ok() + .as_deref() + == Some("1") +} + +// --------------------------------------------------------------------------- +// ANSI helpers (cached terminal detection) +// --------------------------------------------------------------------------- + +/// Lazily-initialized ANSI escape wrappers for success / failure messages. +/// Cached so `is_terminal()` is called only once per process. +/// When running as a hot-reload child (PILL_HOT_RELOAD_CHILD=1), stdout is +/// piped to pill_native but the output is ultimately displayed on a terminal, +/// so we force ANSI codes on. +pub(crate) fn ansi_green() -> (&'static str, &'static str) { + static ANSI: LazyLock<(&str, &str)> = LazyLock::new(|| { + let hot_reload_child = std::env::var("PILL_HOT_RELOAD_CHILD").ok().as_deref() == Some("1"); + if hot_reload_child || std::io::stdout().is_terminal() { + ("\x1b[32m", "\x1b[0m") + } else { + ("", "") + } + }); + *ANSI +} + +/// Lazily-initialized ANSI escape wrappers for error messages. +pub(crate) fn ansi_red() -> (&'static str, &'static str) { + static ANSI: LazyLock<(&str, &str)> = LazyLock::new(|| { + let hot_reload_child = std::env::var("PILL_HOT_RELOAD_CHILD").ok().as_deref() == Some("1"); + if hot_reload_child || std::io::stdout().is_terminal() { + ("\x1b[31m", "\x1b[0m") + } else { + ("", "") + } + }); + *ANSI +} + +// --------------------------------------------------------------------------- +// Cargo error parsing +// --------------------------------------------------------------------------- + +/// Extract a concise, actionable error message from raw cargo stderr. +/// Handles panics with "Caused by:" chains and tool-not-found hints. +pub(crate) fn parse_cargo_stderr(stderr: &str) -> String { + let mut detail = String::new(); + let lines: Vec<&str> = stderr.lines().collect(); + + let mut seen_panic = false; + let mut i = 0; + while i < lines.len() { + let trimmed = lines[i].trim(); + + // The panic message body is on the line immediately after "panicked at". + if trimmed.starts_with("thread") && trimmed.contains("panicked at") { + seen_panic = true; + if i + 1 < lines.len() { + let next = lines[i + 1].trim(); + if !next.is_empty() { + detail.push_str(next); + } + } + i += 2; + continue; + } + + // Collect "Caused by:" chain entries that follow a panic. + if seen_panic && trimmed.starts_with("Caused by:") { + if i + 1 < lines.len() { + let next = lines[i + 1].trim(); + if !next.is_empty() && next != "Caused by:" { + if !detail.is_empty() { + detail.push('\n'); + } + detail.push_str(next); + } + } + i += 2; + continue; + } + + if seen_panic && (trimmed.contains("not found on PATH") || trimmed.contains("Install ")) { + if !detail.is_empty() { + detail.push('\n'); + } + detail.push_str(trimmed); + } + + i += 1; + } + + if detail.is_empty() { + detail = stderr.trim().to_string(); + } + + detail + .lines() + .map(|l| format!("\t{l}")) + .collect::>() + .join("\n") +} + +// --------------------------------------------------------------------------- +// Time formatting +// --------------------------------------------------------------------------- + +/// Format a duration as "after X.Ys" with microsecond precision, or +/// "after Xmin Y.Zs" when the duration exceeds one minute. +pub(crate) fn format_elapsed_time(elapsed: Duration) -> String { + let total_seconds = elapsed.as_secs_f64(); + let minutes = (total_seconds / 60.0).floor() as u64; + if minutes > 0 { + let remainder = total_seconds - (minutes as f64 * 60.0); + format!("after {}min {:.3}s", minutes, remainder) + } else { + format!("after {:.3}s", total_seconds) + } +} + +// --------------------------------------------------------------------------- +// WASM / filesystem helpers +// --------------------------------------------------------------------------- + +/// Resolve the path to a launcher template directory. +pub(crate) fn get_template_directory(name: &str) -> PathBuf { + get_path(Location::PillLauncherCrate) + .join("res") + .join("templates") + .join(name) +} + +/// Copy the WASM template into a scratch directory so the engine workspace stays pristine. +pub(crate) fn prepare_scratch_crate( + wasm_template_directory: &Path, + scratch_pill_web_app_dir: &Path, +) -> Result<()> { + fs::create_dir_all(scratch_pill_web_app_dir).with_context(|| { + format!("Failed to create scratch directory {scratch_pill_web_app_dir:?}") + })?; + + fs::copy( + wasm_template_directory.join("Cargo.toml"), + scratch_pill_web_app_dir.join("Cargo.toml"), + ) + .context("Failed to copy pill_web_app Cargo.toml to scratch")?; + + let engine_lock = get_path(Location::EngineCrates).join("Cargo.lock"); + if engine_lock.exists() { + fs::copy(&engine_lock, scratch_pill_web_app_dir.join("Cargo.lock")) + .context("Failed to copy engine Cargo.lock into scratch")?; + } + + let scratch_src_dir = scratch_pill_web_app_dir.join("src"); + if scratch_src_dir.exists() { + fs::remove_dir_all(&scratch_src_dir).context("Failed to clean scratch src/")?; + } + fs_extra::dir::copy( + wasm_template_directory.join("src"), + scratch_pill_web_app_dir, + &CopyOptions::new().overwrite(true), + ) + .context("Failed to copy pill_web_app src/ to scratch")?; + + Ok(()) +} + +/// Copy the project's res/config.ini into the scratch crate so it can be include_str!-ed. +pub(crate) fn embed_project_config( + project_directory: &Path, + scratch_pill_web_app_dir: &Path, +) -> Result<()> { + let source = project_directory.join("res").join("config.ini"); + let destination = scratch_pill_web_app_dir.join("config.ini"); + if source.is_file() { + fs::copy(&source, &destination).with_context(|| { + format!("Failed to embed project config {source:?} β†’ {destination:?}") + })?; + } else { + fs::write(&destination, "").with_context(|| { + format!("Failed to write empty scratch config.ini at {destination:?}") + })?; + } + Ok(()) +} + +fn get_cargo_path(path: &Path) -> String { + path.to_string_lossy().replace("\\", "/") +} + +/// Rewrite the scratch Cargo.toml with absolute path-deps to engine crates and the project. +/// Builds the entire manifest content in memory and writes it atomically. +pub(crate) fn rewrite_scratch_manifest( + scratch_pill_web_app_dir: &Path, + project_directory: &Path, +) -> Result<()> { + let engine = get_path(Location::EngineCrates); + let pill_engine = get_cargo_path(&engine.join("pill_engine")); + let pill_renderer = get_cargo_path(&engine.join("pill_renderer")); + let pill_core = get_cargo_path(&engine.join("pill_core")); + let pill_web = get_cargo_path(&engine.join("pill_web")); + let project = get_cargo_path(project_directory); + + let manifest = scratch_pill_web_app_dir.join("Cargo.toml"); + + let template = fs::read_to_string(&manifest) + .with_context(|| format!("Failed to read scratch manifest {}", manifest.display()))?; + + let mut content = String::new(); + for line in template.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with("workspace ") || trimmed.starts_with("workspace=") { + // Make the scratch crate a standalone workspace (not a member of the + // engine workspace). This isolates feature resolution so native-only + // features from pill_native/pill_runtime don't leak into the WASM build. + content.push_str("[workspace]\nresolver = \"2\"\n"); + } else if trimmed.starts_with("pill_engine ") || trimmed.starts_with("pill_engine=") { + content.push_str(&format!( + "pill_engine = {{ path = \"{pill_engine}\", features = [\"project\", \"internal\"] }}\n" + )); + } else if trimmed.starts_with("pill_renderer ") || trimmed.starts_with("pill_renderer=") { + content.push_str(&format!( + "pill_renderer = {{ path = \"{pill_renderer}\" }}\n" + )); + } else if trimmed.starts_with("pill_core ") || trimmed.starts_with("pill_core=") { + content.push_str(&format!("pill_core = {{ path = \"{pill_core}\" }}\n")); + } else if trimmed.starts_with("pill_web ") || trimmed.starts_with("pill_web=") { + content.push_str(&format!("pill_web = {{ path = \"{pill_web}\" }}\n")); + } else { + content.push_str(line); + content.push('\n'); + } + } + + content.push_str(&format!("\nproject = {{ path = \"{project}\" }}\n")); + content.push_str("\n[profile.release]\n"); + content.push_str("opt-level = \"z\"\n"); + content.push_str("lto = \"fat\"\n"); + content.push_str("codegen-units = 1\n"); + content.push_str("panic = \"abort\"\n"); + // NOTE: do NOT add `strip = true` here β€” the pre-optimization binary must + // retain function-name sections so twiggy can attribute sizes to crates. + // wasm-opt strips the shipped binary via --strip-debug --strip-producers. + content.push_str("\n[package.metadata.wasm-pack.profile.release]\n"); + content.push_str("wasm-opt = [\"-Oz\", \"--strip-debug\", \"--strip-producers\", \"--enable-nontrapping-float-to-int\", \"--enable-bulk-memory\", \"--enable-sign-ext\", \"--enable-mutable-globals\", \"--enable-reference-types\"]\n"); + content.push_str("\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\n"); + content.push_str("lol_alloc = \"0.4\"\n"); + + let temporary_path = manifest.with_extension(format!("toml-temporary-{}", std::process::id())); + fs::write(&temporary_path, &content).with_context(|| { + format!( + "Failed to write scratch manifest to {}", + temporary_path.display() + ) + })?; + fs::rename(&temporary_path, &manifest).with_context(|| { + format!( + "Failed to rename {} to {}", + temporary_path.display(), + manifest.display() + ) + })?; + + Ok(()) +} + +/// Copy project resource files into the WASM output directory. +pub(crate) fn copy_project_assets( + source_resources: &Path, + destination_resources: &Path, +) -> Result<()> { + if !source_resources.is_dir() { + return Ok(()); + } + if destination_resources.exists() { + fs::remove_dir_all(destination_resources).with_context(|| { + format!("Failed to clean previous res/ at {destination_resources:?}") + })?; + } + let destination_parent = destination_resources + .parent() + .ok_or_else(|| Error::msg("invalid res/ destination path"))?; + fs::create_dir_all(destination_parent)?; + fs_extra::dir::copy( + source_resources, + destination_parent, + &CopyOptions::new().overwrite(true), + ) + .with_context(|| { + format!( + "Failed to copy project res/ from {source_resources:?} to {destination_resources:?}" + ) + })?; + Ok(()) +} + +/// Copy all regular files from `source` into `destination`, resolving pseudo-symlinks. +pub(crate) fn copy_dir_files(source: &Path, destination: &Path, label: &str) -> Result<()> { + for entry in fs::read_dir(source) + .with_context(|| format!("Failed to read {label} directory {source:?}"))? + { + let entry = entry?; + if entry.path().metadata()?.is_file() { + let target = destination.join(entry.file_name()); + let entry_path = resolve_pseudo_symlink(&entry.path()); + fs::copy(&entry_path, &target) + .with_context(|| format!("Failed to {label}-copy {entry_path:?} to {target:?}"))?; + } + } + Ok(()) +} + +/// Resolve Git-on-Windows pseudo-symlinks (small text files containing a relative path). +/// Real symlinks and regular files pass through unchanged. +pub(crate) fn resolve_pseudo_symlink(path: &Path) -> PathBuf { + let Ok(meta) = fs::symlink_metadata(path) else { + return path.to_path_buf(); + }; + if meta.file_type().is_symlink() || meta.len() > 1024 { + return path.to_path_buf(); + } + let Ok(content) = fs::read_to_string(path) else { + return path.to_path_buf(); + }; + let trimmed = content.trim(); + if trimmed.is_empty() || trimmed.contains('\n') { + return path.to_path_buf(); + } + let parent = path.parent().unwrap_or(Path::new(".")); + let candidate = parent.join(trimmed); + if candidate.is_file() { + candidate + } else { + path.to_path_buf() + } +} + +/// Return the maximum mtime among regular files in `directory` (shallow scan). +/// Skips dotfiles and the `.build` scratch directory. +/// NOTE: This only watches top-level files. Changes in subdirectories +/// (e.g. build/wasm/res/textures/) do not trigger a reload. +pub(crate) fn get_latest_mtime_in_directory(directory: &Path) -> Option { + fs::read_dir(directory) + .ok()? + .filter_map(|entry| entry.ok()) + .filter_map(|entry| { + let name = entry.file_name(); + if name.to_string_lossy().starts_with('.') { + return None; + } + let metadata = entry.metadata().ok()?; + if !metadata.is_file() { + return None; + } + metadata.modified().ok() + }) + .max() +} + +// --------------------------------------------------------------------------- +// Build error formatting +// --------------------------------------------------------------------------- + +/// Format a build-failure message with ANSI red coloring and elapsed time. +pub(crate) fn format_build_error(detail: &str, elapsed: Duration) -> String { + let time_str = format_elapsed_time(elapsed); + let (open, close) = ansi_red(); + format!( + "{open}Pill Standalone \"run\" command failed {time_str}{close}\n\nCaused by:\n{detail}" + ) +} + +// --------------------------------------------------------------------------- +// Build cache cleaning +// --------------------------------------------------------------------------- + +/// Run `cargo clean` on the engine workspace to wipe cached compilation artifacts. +/// Called when the user passes `--clean` to the build or run subcommands. +pub(crate) fn clean_build_cache() -> Result<()> { + let engine = get_path(Location::EngineCrates); + let manifest = engine.join("Cargo.toml"); + println!("Cleaning build cache..."); + let status = Command::new("cargo") + .args(["clean", "--manifest-path"]) + .arg(&manifest) + .arg("--release") + .current_dir(&engine) + .status() + .context("Failed to run cargo clean")?; + if !status.success() { + eprintln!("Warning: cargo clean exited with non-zero status"); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Build / Run summary +// --------------------------------------------------------------------------- + +/// Print a human-readable summary of the build/run configuration before starting. +pub(crate) fn print_build_summary( + action: &str, + path: &Path, + target: &BuildTarget, + compile_mode: &CompileMode, + output_path_override: Option<&str>, + features: Option<&str>, +) { + let project_name = get_project_title(path).unwrap_or_else(|_| "unknown".to_string()); + let output_path = match target { + BuildTarget::Native => { + let out_dir = PathBuf::from(output_path_override.unwrap_or(".")); + get_project_build_path(path, &out_dir, compile_mode) + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "".to_string()) + } + BuildTarget::Web => path.join("build").join("wasm").display().to_string(), + }; + let features_display = features.unwrap_or("none"); + + // Determine the build cache directory (native only; WASM uses wasm-pack's own cache). + let cache_info = if *target == BuildTarget::Native { + let cache_dir = if let Ok(shared) = std::env::var("PILL_TARGET_DIR") { + std::path::PathBuf::from(shared) + } else { + get_path(Location::EngineCrates) + .join("target_projects") + .join(&project_name) + }; + if cache_dir.join(compile_mode.to_string()).exists() { + format!("yes ({})", cache_dir.display()) + } else { + "no".to_string() + } + } else { + "n/a (WASM)".to_string() + }; + + println!("-------------- Launching ---------------"); + println!(" Action: {}", action); + println!(" Project name: {}", project_name); + println!(" Project path: {}", path.display()); + println!(" Target: {}", target); + println!(" Compile mode: {}", compile_mode); + println!(" Output path: {}", output_path); + println!(" Additional features: {}", features_display); + println!(" Using cache: {}", cache_info); + println!("----------------------------------------"); +} diff --git a/engine/pill_launcher/src/utils/files.rs b/engine/pill_launcher/src/utils/files.rs new file mode 100644 index 00000000..19c4029e --- /dev/null +++ b/engine/pill_launcher/src/utils/files.rs @@ -0,0 +1,300 @@ +//! This file provides reusable file-system operations for the launcher. + +use anyhow::{bail, Context, Result}; +use std::{fs, path::Path}; +// Used by codesign_ad_hoc on macOS: +#[cfg(target_os = "macos")] +use std::process::Command; + +/// Rewrite a file line-by-line, passing each line to the provided closure that can modify it. +/// Skips writing if the output would be identical to the input (no-op detection). +#[must_use] +pub(crate) fn modify_file String>( + input_path: &Path, + output_path: &Path, + mut action: A, +) -> Result<()> { + // Read the entire file into memory and apply the transformation to each line. + let input = fs::read_to_string(input_path) + .with_context(|| format!("Failed to read {}", input_path.display()))?; + + // Normalize CRLF β†’ LF so line-ending style changes don't cause + // spurious rewrites or corrupt Cargo.toml / config.ini files. + let input_normalized = input.replace("\r\n", "\n"); + + // Prevent overwriting the same files + let mut changed = false; + + // Read lines from input file + let lines = input_normalized + .lines() + .map(|line| { + let new_line = action(line.to_string()); + if new_line != line { + changed = true; + } + new_line + }) + .collect::>(); + + let mut out = lines.join("\n"); + if input_normalized.ends_with("\n") { + out.push('\n'); + } + + // No-op detection: skip writing if nothing changed (avoids invalidating caches). + // Compare against the original input (before CRLFβ†’LF normalization) so that + // files with CRLF line endings on Windows aren't needlessly rewritten. + if input_path == output_path && !changed && out == input { + return Ok(()); + } + + // Similarly we are writing to a different file and their outputs are identical - ignore + if input_path != output_path { + if let Ok(existing) = fs::read_to_string(output_path) { + if existing == out { + return Ok(()); + } + } + } + + // Write atomically when rewriting a file in-place: write to a temp file beside + // the target and rename into place. A crash mid-write leaves the original intact. + // Include the process ID in the temp filename to avoid collisions between + // concurrent launcher processes. + if input_path == output_path { + let tmp_ext = format!("tmp-{}", std::process::id()); + let tmp_path = output_path.with_extension(tmp_ext); + fs::write(&tmp_path, &out) + .with_context(|| format!("Failed to write temp file {}", tmp_path.display()))?; + fs::rename(&tmp_path, output_path).with_context(|| { + format!( + "Failed to rename {} to {}", + tmp_path.display(), + output_path.display() + ) + })?; + } else { + fs::write(output_path, out) + .with_context(|| format!("Failed to write {}", output_path.display()))?; + } + + Ok(()) +} + +/// Ad-hoc code-sign a binary on macOS so it can be loaded as a dynamic library. +/// Required because macOS Gatekeeper refuses to load unsigned dylibs at runtime. +#[cfg(target_os = "macos")] +pub(crate) fn codesign_ad_hoc(path: &Path) -> Result<()> { + let status = Command::new("codesign") + .args(["--force", "--sign", "-", path.to_str().unwrap_or("")]) + .status() + .with_context(|| format!("codesign failed for {}", path.display()))?; + if !status.success() { + bail!("codesign returned non-zero for {}", path.display()); + } + Ok(()) +} + +/// Copy a file only if the source is newer than the destination (by mtime and size). +/// Returns true if a copy was performed, false if skipped. +#[must_use] +pub(crate) fn copy_file_if_newer(source: &Path, destination: &Path) -> Result { + // Returns true if a copy was performed, false if the destination is already up-to-date. + if !source.exists() { + bail!("Source does not exist: {}", source.display()); + } + + let source_meta = fs::metadata(source)?; + let source_mtime = source_meta.modified().ok(); + let source_len = source_meta.len(); + + if let Ok(destination_meta) = fs::metadata(destination) { + let destination_mtime = destination_meta.modified().ok(); + let destination_len = destination_meta.len(); + + // If same size and destination is at least as new as source, skip copy. + if destination_len == source_len { + if let (Some(s), Some(d)) = (source_mtime, destination_mtime) { + if d >= s { + return Ok(false); + } + } + } + } + + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(source, destination) + .with_context(|| format!("copy {} -> {}", source.display(), destination.display()))?; + Ok(true) +} + +/// Recursively copy all files and subdirectories from `source` to `destination`. +/// Creates the destination directory if it does not exist. Preserves the full +/// directory tree structure. Fails if `source` is missing or is not a directory. +pub(crate) fn copy_directory_recursive(source: &Path, destination: &Path) -> Result<()> { + if !source.exists() { + bail!("Source directory does not exist: {}", source.display()); + } + if !source.is_dir() { + bail!("Source path is not a directory: {}", source.display()); + } + + fs::create_dir_all(destination) + .with_context(|| format!("Failed to create directory {}", destination.display()))?; + + for entry in fs::read_dir(source) + .with_context(|| format!("Failed to read directory {}", source.display()))? + { + let entry = entry?; + let entry_path = entry.path(); + let destination_path = destination.join(entry.file_name()); + + if entry_path.is_dir() { + copy_directory_recursive(&entry_path, &destination_path)?; + } else if entry_path.is_file() { + if let Some(parent) = destination_path.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(&entry_path, &destination_path).with_context(|| { + format!( + "copy {} -> {}", + entry_path.display(), + destination_path.display() + ) + })?; + } + } + + Ok(()) +} + +/// Mirror a project's res/ directory into the build output data directory. +/// Used for packaged release builds where resources must be bundled alongside the executable. +/// Stages to a temporary directory first and validates before swapping, so a failed copy +/// never leaves a partial or corrupted resource directory. +pub(crate) fn stage_packaged_resource_files( + project_directory_path: &Path, + data_directory: &Path, +) -> Result<()> { + let source_resources_dir = project_directory_path.join("res"); + let destination_resources_dir = data_directory.join("res"); + + if !source_resources_dir.exists() { + bail!( + "Project resources directory does not exist: {}", + source_resources_dir.display() + ); + } + + fs::create_dir_all(data_directory).with_context(|| { + format!( + "Failed to create data directory {}", + data_directory.display() + ) + })?; + + // Stage into a temporary directory so we never leave a partial copy. + let staging_dir = data_directory.join(".res_staging_tmp"); + if staging_dir.exists() { + fs::remove_dir_all(&staging_dir).with_context(|| { + format!( + "Failed to clear staging directory {}", + staging_dir.display() + ) + })?; + } + + copy_directory_recursive(&source_resources_dir, &staging_dir)?; + + // Validate the staging before swapping it in. + let staged_config_path = staging_dir.join("config.ini"); + if !staged_config_path.exists() { + // Clean up the failed staging attempt. + let _ = fs::remove_dir_all(&staging_dir); + bail!( + "Failed to stage resources (missing {})", + staged_config_path.display() + ); + } + + // Swap: remove the old directory and rename the staging into place. + if destination_resources_dir.exists() { + fs::remove_dir_all(&destination_resources_dir).with_context(|| { + format!( + "Failed to clear destination resources directory {}", + destination_resources_dir.display() + ) + })?; + } + + fs::rename(&staging_dir, &destination_resources_dir).with_context(|| { + format!( + "Failed to swap staged resources from {} to {}", + staging_dir.display(), + destination_resources_dir.display() + ) + })?; + + println!( + "Staged resources from {} to {}", + source_resources_dir.display(), + destination_resources_dir.display() + ); + + Ok(()) +} + +/// Delete all regular files in a directory whose name starts with the given prefix. +/// Non-existent or non-directory paths are silently skipped. +pub(crate) fn remove_files_starting_with( + directory_path: &Path, + file_name_prefix: &str, +) -> Result<()> { + if !directory_path.exists() || !directory_path.is_dir() { + return Ok(()); // Skip non-existent or non-directory + } + + for entry in fs::read_dir(directory_path).context("Failed to read directory")? { + let entry = entry.context("Failed to read directory entry")?; + let path = entry.path(); + if path.is_file() { + if let Some(name) = path.file_name().and_then(|s| s.to_str()) { + if name.starts_with(file_name_prefix) { + fs::remove_file(&path) + .with_context(|| format!("Failed to remove file: {}", path.display()))?; + } + } + } + } + + Ok(()) +} + +/// Recursively delete all cooked asset files (`.cooked_mesh`, `.cooked_tex`) under a directory. +/// Used by the asset pipeline's force-rebuild mode to clear previously built outputs. +pub(crate) fn delete_cooked_resource_files_recursive(directory: &Path) -> Result<()> { + if !directory.exists() { + return Ok(()); + } + for entry in fs::read_dir(directory) + .with_context(|| format!("Failed to read {}", directory.display()))? + { + let entry = entry?; + let file_type = entry.file_type()?; + let path = entry.path(); + if file_type.is_dir() { + delete_cooked_resource_files_recursive(&path)?; + } else if file_type.is_file() { + let extension = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if extension == "cooked_mesh" || extension == "cooked_tex" { + fs::remove_file(&path) + .with_context(|| format!("Failed to delete {}", path.display()))?; + println!("Deleted {}", path.display()); + } + } + } + Ok(()) +} diff --git a/engine/pill_launcher/src/utils/mod.rs b/engine/pill_launcher/src/utils/mod.rs new file mode 100644 index 00000000..609432b3 --- /dev/null +++ b/engine/pill_launcher/src/utils/mod.rs @@ -0,0 +1,10 @@ +pub mod assets; +pub mod cli; +pub mod common; +pub mod files; +pub mod native_target; +pub mod paths; +pub mod plantuml; +pub mod wasm_target; +pub mod web_dev_server; +pub mod workspace; diff --git a/engine/pill_launcher/src/utils/native_target.rs b/engine/pill_launcher/src/utils/native_target.rs new file mode 100644 index 00000000..6eba4a02 --- /dev/null +++ b/engine/pill_launcher/src/utils/native_target.rs @@ -0,0 +1,459 @@ +//! This file provides shared build/run utilities consumed by the "build" and +//! "run" actions. +//! +//! Responsibilities: +//! - CLI flag registration for build-related actions. +//! - Cargo stderr parsing for user-friendly error messages. +//! - ANSI terminal detection (cached). +//! - build_project(): compile project + pill_native + pill_runtime. +//! - run_project(): build then launch the standalone executable. + +use anyhow::{bail, Context, Error, Result}; +use std::{ + fs, + io::{BufRead, BufReader}, + path::PathBuf, + process::{Command, Stdio}, + time::Instant, +}; + +use crate::types::*; +use crate::utils::common::{ + ansi_green, dynamic_library_name, format_build_error, format_elapsed_time, parse_cargo_stderr, + use_experimental_logs_parser, EXECUTABLE_SUFFIX, +}; +#[cfg(target_os = "macos")] +use crate::utils::files::codesign_ad_hoc; +use crate::utils::files::{copy_file_if_newer, stage_packaged_resource_files}; +use crate::utils::paths::{ + get_path, get_project_title, get_standalone_layout_for_compile_mode, + get_target_directory_for_compile_mode, +}; +use crate::utils::plantuml::render_puml_for_crate; +use crate::utils::workspace::prepare_workspace_for_project; + +// --------------------------------------------------------------------------- +// Build & run logic +// --------------------------------------------------------------------------- + +/// Build and then launch the native standalone executable for a project. +/// Supports optional stdout capture (for benchmarks), --features passthrough, +/// and --headless mode. +/// Prepares the workspace once and holds the guard through both build and run +/// so that hot-reload child processes can find the project as a workspace member. +pub(crate) fn run_project( + project_directory_path: &PathBuf, + output_directory_path: &PathBuf, + compile_mode: &CompileMode, + project_args: &[String], + features: Option<&str>, + capture_stdout: bool, + headless: bool, +) -> Result> { + // 1. Prepare the workspace - adds the project to engine/Cargo.toml members. + // The guard must live through both build AND execution so that hot-reload + // child processes can find the project as a workspace member. + let (engine_workspace_directory_path, _workspace_guard) = + prepare_workspace_for_project(project_directory_path, compile_mode)?; + + // 2. Build the project, pill_native, and pill_runtime. + build_project_in_workspace( + project_directory_path, + output_directory_path, + compile_mode, + features, + &engine_workspace_directory_path, + headless, + )?; + + if !capture_stdout { + println!( + "Running project from {}...", + output_directory_path.display() + ); + } + + // 3. Locate the compiled standalone executable. + let project_title = + get_project_title(project_directory_path).context("Failed to get project title")?; + let standalone_executable_path = + output_directory_path.join(format!("{project_title}{EXECUTABLE_SUFFIX}")); + + let launcher_bin = std::env::current_exe().context("current_exe failed")?; + + // 4. Launch the executable with environment variables for the engine runtime. + let mut command = Command::new(&standalone_executable_path); + command + .current_dir(output_directory_path) + .env("PILL_LAUNCHER_BIN", &launcher_bin) + .env( + "PILL_ENGINE_WORKSPACE_DIR", + &engine_workspace_directory_path, + ) + .env("PROJECT_DIR", project_directory_path) + .env("PILL_COMPILE_MODE", compile_mode.to_string()) + .env( + "PILL_STANDALONE_LAYOUT", + get_standalone_layout_for_compile_mode(compile_mode), + ) + .env( + "PILL_ENABLE_HOT_RELOAD", + if *compile_mode == CompileMode::HotReload { + "1" + } else { + "0" + }, + ) + .args(project_args); + + // 5. Run and collect output (or just wait for exit). + if capture_stdout { + let output = command + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .output() + .with_context(|| { + format!( + "Failed to launch project executable: {}", + standalone_executable_path.display() + ) + })?; + + if !output.status.success() { + // Child crashed or was terminated - don't fail, just report. + let code = output + .status + .code() + .map_or("unknown".into(), |c| c.to_string()); + eprintln!("Project exited with code: {code}"); + } + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + Ok(Some(stdout)) + } else { + let status = command.status().with_context(|| { + format!( + "Failed to launch project executable: {}", + standalone_executable_path.display() + ) + })?; + + if !status.success() { + let code = status.code().map_or("unknown".into(), |c| c.to_string()); + eprintln!("Project exited with code: {code}"); + } + Ok(None) + } +} + +/// Build project + pill_native + pill_runtime via cargo in the engine workspace. +/// Copies the standalone executable and dynamic libraries into the output directory. +/// Supports --features, hot-reload, PlantUML pre-rendering, --headless, and per-project target dirs. +pub(crate) fn build_project( + project_directory_path: &PathBuf, + output_directory_path: &PathBuf, + compile_mode: &CompileMode, + features: Option<&str>, + headless: bool, +) -> Result<()> { + let (engine_workspace_directory_path, _guard) = + prepare_workspace_for_project(project_directory_path, compile_mode)?; + build_project_in_workspace( + project_directory_path, + output_directory_path, + compile_mode, + features, + &engine_workspace_directory_path, + headless, + ) +} + +/// Core build logic. The caller is responsible for preparing the workspace +/// (adding the project to engine/Cargo.toml members) before calling this. +fn build_project_in_workspace( + project_directory_path: &PathBuf, + output_directory_path: &PathBuf, + compile_mode: &CompileMode, + features: Option<&str>, + engine_workspace_directory_path: &PathBuf, + headless: bool, +) -> Result<()> { + println!( + "Building project from {}...", + project_directory_path.display() + ); + + // 1. Determine build context + + let hot_reload_child = *compile_mode == CompileMode::HotReload + && std::env::var("PILL_HOT_RELOAD_CHILD").ok().as_deref() == Some("1"); + + let project_title = + get_project_title(project_directory_path).context("Failed to get project title")?; + + let cargo_target_dir = if let Ok(shared) = std::env::var("PILL_TARGET_DIR") { + PathBuf::from(shared) + } else { + engine_workspace_directory_path + .join("target_projects") + .join(&project_title) + }; + + // 2. Pre-render PlantUML diagrams (skip during hot-reload) + + let pill_engine_dir = get_path(Location::PillEngineCrate); + if *compile_mode != CompileMode::HotReload { + if let Err(error) = render_puml_for_crate(&pill_engine_dir) { + eprintln!("Warning: skipping PlantUML render ({})", error); + } + } + + // 3. Assemble cargo arguments + + let mut arguments = vec![ + "build", + "-p", + "project", + "-p", + "pill_native", + "-p", + "pill_runtime", + ]; + if *compile_mode == CompileMode::HotReload { + arguments.push("--profile"); + arguments.push("hot-reload"); + if hot_reload_child { + arguments.push("--quiet"); + } + } + if *compile_mode == CompileMode::Release { + arguments.push("--release"); + } + if let Some(feature_list) = features { + for feature in feature_list + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + arguments.push("--features"); + arguments.push(feature); + } + } + + // When --headless is set, enable headless code paths in pill_native, + // pill_runtime, pill_engine, and the project crate. + if headless { + arguments.push("--features"); + arguments.push("pill_native/headless"); + arguments.push("--features"); + arguments.push("pill_runtime/headless"); + arguments.push("--features"); + arguments.push("pill_engine/headless"); + } + + // 4. Run cargo build + + let start = Instant::now(); + let mut cargo_command = Command::new("cargo"); + cargo_command + .args(&arguments) + .current_dir(&engine_workspace_directory_path) + .env("CARGO_TARGET_DIR", &cargo_target_dir); + + // When pill_native captures our output via pipes, cargo detects + // no TTY and strips ANSI colors from error messages. Force color + // in hot-reload child builds so compiler diagnostics remain readable. + if hot_reload_child { + cargo_command.env("CARGO_TERM_COLOR", "always"); + } + + let mut cargo_child = if use_experimental_logs_parser() { + cargo_command + .stdout(Stdio::inherit()) + .stderr(Stdio::piped()) + .spawn() + .context("failed to spawn cargo build")? + } else { + cargo_command + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .context("failed to spawn cargo build")? + }; + + // 5. Parse cargo output / handle errors + + if use_experimental_logs_parser() { + let stderr_pipe = cargo_child + .stderr + .take() + .context("failed to capture cargo stderr")?; + let mut stderr_lines = String::new(); + { + let reader = BufReader::new(stderr_pipe); + let mut in_error = false; + for line in reader.lines() { + let line = line.unwrap_or_default(); + stderr_lines.push_str(&line); + stderr_lines.push('\n'); + + let trimmed = line.trim(); + + if trimmed.starts_with("error:") + || (trimmed.starts_with("thread") && trimmed.contains("panicked at")) + { + in_error = true; + continue; + } + + if in_error { + if trimmed.starts_with("warning:") + || trimmed.starts_with("Compiling") + || trimmed.starts_with("Checking") + || trimmed.starts_with("Finished") + { + in_error = false; + eprintln!("{line}"); + } + continue; + } + + eprintln!("{line}"); + } + } + + let cargo_status = cargo_child + .wait() + .context("failed to wait on cargo build")?; + let stderr = stderr_lines; + + if !cargo_status.success() { + let detail = parse_cargo_stderr(&stderr); + let elapsed = start.elapsed(); + bail!(format_build_error(&detail, elapsed)); + } + } else { + let cargo_status = cargo_child + .wait() + .context("failed to wait on cargo build")?; + + if !cargo_status.success() { + bail!( + "cargo build failed with exit code {:?}", + cargo_status.code() + ); + } + } + + // 6. Stage build artifacts into output directory + + let compilation_artifacts_folder_path = + cargo_target_dir.join(get_target_directory_for_compile_mode(compile_mode)); + + fs::create_dir_all(output_directory_path.join("data").as_path()) + .context("Failed to create build output directories")?; + + // Copy the standalone executable (except for hot-reload child processes). + if *compile_mode != CompileMode::HotReload || !hot_reload_child { + let standalone_output_path = + compilation_artifacts_folder_path.join(format!("pill_native{EXECUTABLE_SUFFIX}")); + if !standalone_output_path.exists() { + return Err(Error::msg( + "Standalone executable was not built successfully", + )); + } + + let destination_executable_path = + output_directory_path.join(format!("{project_title}{EXECUTABLE_SUFFIX}")); + + let _copied = copy_file_if_newer(&standalone_output_path, &destination_executable_path)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(&destination_executable_path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&destination_executable_path, permissions)?; + } + } + + // 7. Copy dynamic libraries and resource files + + let data_directory = output_directory_path.join("data"); + fs::create_dir_all(&data_directory)?; + + if *compile_mode == CompileMode::Release { + stage_packaged_resource_files(project_directory_path, &data_directory)?; + } + + let project_source = compilation_artifacts_folder_path.join(dynamic_library_name("project")); + let runtime_source = + compilation_artifacts_folder_path.join(dynamic_library_name("pill_runtime")); + + if !project_source.exists() { + return Err(Error::msg(format!( + "Project dynamic library missing: {}", + project_source.display() + ))); + } + if !runtime_source.exists() { + return Err(Error::msg(format!( + "Runtime dynamic library missing: {}", + runtime_source.display() + ))); + } + + // Standard libraries (debug / release). + if *compile_mode != CompileMode::HotReload || !hot_reload_child { + if copy_file_if_newer( + &project_source, + &data_directory.join(dynamic_library_name("project")), + )? { + println!("Copied project dynamic library"); + #[cfg(target_os = "macos")] + codesign_ad_hoc(&data_directory.join(dynamic_library_name("project")))?; + } + if copy_file_if_newer( + &runtime_source, + &data_directory.join(dynamic_library_name("pill_runtime")), + )? { + println!("Copied runtime dynamic library"); + #[cfg(target_os = "macos")] + codesign_ad_hoc(&data_directory.join(dynamic_library_name("pill_runtime")))?; + } + } + + // Hot-reload copies (side-by-side with originals, suffixed _hot_reloaded). + if *compile_mode == CompileMode::HotReload { + if copy_file_if_newer( + &project_source, + &data_directory.join(dynamic_library_name("project_hot_reloaded")), + )? { + println!("Copied project hot-reload dynamic library"); + } + if copy_file_if_newer( + &runtime_source, + &data_directory.join(dynamic_library_name("pill_runtime_hot_reloaded")), + )? { + println!("Copied runtime hot-reload dynamic library"); + } + } + + // 8. Print build summary + + let time_str = format_elapsed_time(start.elapsed()); + let (open, close) = ansi_green(); + + let standalone_path = output_directory_path.join(format!("{project_title}{EXECUTABLE_SUFFIX}")); + let size_str = match fs::metadata(&standalone_path) { + Ok(meta) => { + let bytes = meta.len(); + format!("{:.3} MB", bytes as f64 / (1024.0 * 1024.0)) + } + Err(_) => "unknown".to_string(), + }; + + println!("{open}Build completed successfully {time_str}{close}"); + println!("Build size: {size_str}"); + + Ok(()) +} diff --git a/engine/pill_launcher/src/utils/paths.rs b/engine/pill_launcher/src/utils/paths.rs new file mode 100644 index 00000000..167c0679 --- /dev/null +++ b/engine/pill_launcher/src/utils/paths.rs @@ -0,0 +1,193 @@ +//! This file handles all path resolution and workspace discovery for the launcher. + +use anyhow::{bail, Context, Error, Result}; +use config::Config; +use path_absolutize::Absolutize; +use std::path::{Path, PathBuf}; + +use crate::types::*; + +/// Sentinel comment that marks a workspace-member line as the launcher-injected project. +/// Used by `prepare_workspace_for_project` to write the line, `extract_member_path_from_line` +/// to detect it, and the "check" action to strip it before running cargo check. +/// +/// The `#` prefix ensures this is a valid TOML comment on all parsers. +/// The unique phrase minimizes false positives from coincidental text matches. +pub(crate) const PROJECT_CRATE_MARKER: &str = "# pill-launcher-managed-workspace-member"; + +/// Map CompileMode to the Cargo target directory name (debug/release/hot-reload). +pub(crate) fn get_target_directory_for_compile_mode(mode: &CompileMode) -> &'static str { + match mode { + CompileMode::Release => "release", + CompileMode::Debug => "debug", + CompileMode::HotReload => "hot-reload", + } +} + +pub(crate) fn get_standalone_layout_for_compile_mode(mode: &CompileMode) -> &'static str { + match mode { + CompileMode::Release => "packaged", + CompileMode::Debug | CompileMode::HotReload => "development", + } +} + +/// Locate the engine workspace directory via env var, exe path, or cwd walk. +pub(crate) fn find_engine_workspace_directory() -> Result { + // Explicit override: the standalone host sets PILL_ENGINE_WORKSPACE_DIR. + if let Ok(environment_value) = std::env::var("PILL_ENGINE_WORKSPACE_DIR") { + let workspace_path = PathBuf::from(environment_value); + let manifest_path = workspace_path.join("Cargo.toml"); + if manifest_path.exists() { + return Ok(workspace_path); + } + bail!( + "PILL_ENGINE_WORKSPACE_DIR was set but {} does not exist", + manifest_path.display() + ); + } + + // Walk up the directory tree from the executable location or cwd. + fn search_up(start: PathBuf) -> Option { + for ancestor in start.ancestors() { + // Look for an "engine" directory with a Cargo.toml inside. + let candidate = ancestor.join("engine").join("Cargo.toml"); + if candidate.exists() { + return Some(ancestor.join("engine")); + } + // Or: we might already be inside the engine/ directory itself. + let candidate2 = ancestor.join("Cargo.toml"); + if candidate2.exists() + && ancestor.file_name().and_then(|s| s.to_str()) == Some("engine") + { + return Some(ancestor.to_path_buf()); + } + } + None + } + + // Try searching from the executable's location first, then from cwd. + let executable_directory = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())); + if let Some(directory) = executable_directory.and_then(search_up) { + return Ok(directory); + } + + let current_directory = std::env::current_dir().context("current_dir failed")?; + if let Some(directory) = search_up(current_directory) { + return Ok(directory); + } + + bail!("Cannot locate engine workspace directory (tried env + walking up from exe/cwd)"); +} + +/// Map a Location variant to its absolute filesystem path. +/// Panics if the engine workspace directory cannot be found. +pub(crate) fn get_path(location: Location) -> PathBuf { + // engine workspace directory = .../Pill-Engine/engine + let engine_workspace = + find_engine_workspace_directory().expect("Failed to locate engine workspace directory"); + + // repo root = parent of engine/ + let repo_root = engine_workspace.parent().unwrap().to_path_buf(); + + match location { + Location::EngineProjectRoot => repo_root, + Location::EngineCrates => engine_workspace, + Location::PillEngineCrate => engine_workspace.join("pill_engine"), + Location::PillCoreCrate => engine_workspace.join("pill_core"), + Location::PillNativeCrate => engine_workspace.join("pill_native"), + Location::PillLauncherCrate => engine_workspace.join("pill_launcher"), + Location::PillRendererCrate => engine_workspace.join("pill_renderer"), + Location::PillAbiCrate => engine_workspace.join("pill_abi"), + Location::PillAssetsCrate => engine_workspace.join("pill_assets"), + Location::PillRuntimeCrate => engine_workspace.join("pill_runtime"), + Location::PillWebCrate => engine_workspace.join("pill_web"), + } +} + +/// Convert a path to an absolute, forward-slash-normalized string for Cargo.toml. +pub(crate) fn normalize_path(p: &Path) -> Result { + Ok(p.absolutize()? + .to_path_buf() + .to_string_lossy() + .replace('\\', "/")) +} + +/// Extract the project path from a workspace member line containing the marker comment. +pub(crate) fn extract_member_path_from_line(line: &str) -> Option { + let trimmed = line.trim(); + if !trimmed.contains(PROJECT_CRATE_MARKER) { + return None; + } + let first_quote = trimmed.find('"')?; + let rest = &trimmed[first_quote + 1..]; + let second_quote = rest.find('"')?; + Some(rest[..second_quote].to_string()) +} + +pub(crate) fn get_output_directory_for_compile_mode(mode: &CompileMode) -> &'static str { + match mode { + CompileMode::Debug => "dev", + CompileMode::Release => "release", + CompileMode::HotReload => "hot-reload", + } +} + +/// Compute the build output directory for a project (defaults to /build//). +pub(crate) fn get_project_build_path( + project_directory_path: &Path, + output_directory_path: &PathBuf, + compile_mode: &CompileMode, +) -> Result { + // Default output path: /build//. + if output_directory_path.as_os_str() == "." { + Ok(project_directory_path + .join("build") + .join(get_output_directory_for_compile_mode(compile_mode)) + .absolutize()? + .to_path_buf()) + } else { + Ok(output_directory_path.absolutize()?.to_path_buf()) + } +} + +/// Read the TITLE field from a project's res/config.ini (spaces removed). +pub(crate) fn get_project_title(project_directory_path: &Path) -> Result { + // Get project title + let config_path = project_directory_path.join("res").join("config.ini"); + let mut config = Config::default(); + config + .merge(config::File::with_name( + &config_path.to_string_lossy().into_owned(), + )) + .context("Failed to find config.ini file in project \"res\" folder")?; + let project_title = config + .get_str("TITLE") + .context("Failed to get project config.ini")? + .replace(' ', ""); + + Ok(project_title) +} + +/// Validate that a directory contains a Pill project (Cargo.toml, res/, src/, config.ini). +pub(crate) fn check_project_validity(project_directory_path: &Path) -> Result<()> { + if !project_directory_path.join("Cargo.toml").exists() { + return Err(Error::msg("Missing Cargo.toml file in project folder")); + } + if !project_directory_path.join("res").exists() { + return Err(Error::msg("Missing \"res\" folder in project folder")); + } + if !project_directory_path.join("src").exists() { + return Err(Error::msg("Missing \"src\" folder in project folder")); + } + if !project_directory_path + .join("res") + .join("config.ini") + .exists() + { + return Err(Error::msg("Missing \"config.ini\" file in project folder")); + } + + Ok(()) +} diff --git a/engine/pill_launcher/src/utils/plantuml.rs b/engine/pill_launcher/src/utils/plantuml.rs new file mode 100644 index 00000000..122b8957 --- /dev/null +++ b/engine/pill_launcher/src/utils/plantuml.rs @@ -0,0 +1,88 @@ +//! This file defines and implements PlantUML diagram rendering for crate documentation. +//! +//! Renders *.puml files under /docs/uml/ into SVGs in /docs/uml_out/ +//! using the plantuml CLI. Gracefully skips if plantuml is not installed. + +use anyhow::{bail, Context, Result}; +use std::{ + ffi::OsStr, + fs, + io::Write, + path::Path, + process::{Command, Stdio}, +}; + +/// Render all *.puml files under /docs/uml/ into SVGs via the plantuml CLI. +/// Silently skips if plantuml is not installed or no .puml files are found. +pub(crate) fn render_puml_for_crate(crate_directory: &Path) -> Result<()> { + // Look for .puml files under docs/uml/, render each as SVG into docs/uml_out/. + let input_directory = crate_directory.join("docs").join("uml"); + let output_directory = crate_directory.join("docs").join("uml_out"); + + if !input_directory.exists() { + return Ok(()); + } + fs::create_dir_all(&output_directory)?; + + // Collect all .puml input files. + let mut inputs = Vec::new(); + for entry in fs::read_dir(&input_directory) + .with_context(|| format!("Failed to read directory: {}", input_directory.display()))? + { + let path = entry?.path(); + if path.extension() == Some(OsStr::new("puml")) { + inputs.push(path); + } + } + + if inputs.is_empty() { + return Ok(()); + } + + // Check if plantuml CLI is available. + let plantuml_available = Command::new("plantuml") + .arg("-version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok(); + + if !plantuml_available { + println!("plantuml not found, skipping diagram rendering"); + return Ok(()); + } + + // Render each .puml file via plantuml -tsvg -pipe. + for puml in &inputs { + let svg_path = output_directory + .join(puml.file_stem().unwrap()) + .with_extension("svg"); + + let mut child = Command::new("plantuml") + .arg("-tsvg") + .arg("-pipe") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .context("Spawn plantuml -pipe")?; + + { + let mut stdin = child + .stdin + .take() + .context("Failed to take plantuml stdin")?; + let bytes = + fs::read(puml).with_context(|| format!("Read PUML file {}", puml.display()))?; + stdin.write_all(&bytes)?; + } + + let output = child.wait_with_output().context("Wait plantuml")?; + if !output.status.success() { + bail!("plantuml failed with code {}", output.status); + } + fs::write(&svg_path, &output.stdout) + .with_context(|| format!("Write SVG file {}", svg_path.display()))?; + } + + Ok(()) +} diff --git a/engine/pill_launcher/src/utils/wasm_target.rs b/engine/pill_launcher/src/utils/wasm_target.rs new file mode 100644 index 00000000..7940ec51 --- /dev/null +++ b/engine/pill_launcher/src/utils/wasm_target.rs @@ -0,0 +1,614 @@ +//! This file orchestrates WASM/WebGPU builds via wasm-pack. +//! +//! Responsibilities: +//! - build(): entry point - copies a WASM template into a scratch directory, +//! rewrites Cargo.toml path-deps to absolute paths, runs wasm-pack, flattens +//! outputs into build/wasm/, and prints a size report on release builds. +//! - Uses a scratch-copy strategy: nothing is written under engine/ during a +//! WASM build, keeping the workspace pristine across multi-project use. +//! - Handles pseudo-symlinks (Git on Windows without core.symlinks). + +use std::collections::HashMap; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::Instant; + +use anyhow::{bail, Context, Error, Result}; + +use crate::types::CompileMode; +use crate::utils::common::{ + ansi_green, copy_dir_files, copy_project_assets, embed_project_config, format_elapsed_time, + get_template_directory, prepare_scratch_crate, rewrite_scratch_manifest, +}; + +/// Build a WASM/WebGPU bundle via wasm-pack using a scratch directory. +/// On release builds, enforces an optional size budget. +/// When `wasm_analyze` is true, prints a twiggy-powered size breakdown after build. +pub fn build_project( + project_directory_path: &Path, + compile_mode: &CompileMode, + max_size_kb: Option, + wasm_analyze: bool, +) -> Result<()> { + let build_start = Instant::now(); + + println!("Building WASM target..."); + if *compile_mode == CompileMode::HotReload { + println!("NOTE: hot-reload is not meaningful for WASM; using --dev mode."); + } + + // Paths: templates are in pill_launcher/res/templates/, output goes to /build/wasm/. + let wasm_get_template_directory = get_template_directory("wasm"); + let web_get_template_directory = get_template_directory("web"); + let build_wasm_dir = project_directory_path.join("build").join("wasm"); + let scratch_pill_web_app_dir = build_wasm_dir.join(".build").join("pill_web_app"); + let scratch_package_directory = build_wasm_dir.join(".build").join("pkg"); + let scratch_target_dir = build_wasm_dir.join(".build").join("target"); + + // Clean stale source and package artifacts from previous builds to + // prevent contamination (stale crates, old API code referencing removed + // assets). Keep the target/ directory so compiled dependencies are + // cached across rebuilds. + if scratch_pill_web_app_dir.exists() { + fs::remove_dir_all(&scratch_pill_web_app_dir) + .context("Failed to clean stale pill_web_app directory")?; + } + if scratch_package_directory.exists() { + fs::remove_dir_all(&scratch_package_directory) + .context("Failed to clean stale pkg directory")?; + } + + // Pipeline: prepare scratch crate β†’ embed config β†’ rewrite manifest β†’ wasm-pack β†’ copy outputs. + prepare_scratch_crate(&wasm_get_template_directory, &scratch_pill_web_app_dir)?; + embed_project_config(project_directory_path, &scratch_pill_web_app_dir)?; + rewrite_scratch_manifest(&scratch_pill_web_app_dir, project_directory_path)?; + + run_wasm_pack( + compile_mode, + &scratch_pill_web_app_dir, + &scratch_package_directory, + &scratch_target_dir, + )?; + + copy_build_outputs( + &scratch_package_directory, + &web_get_template_directory, + &project_directory_path.join("web"), + &build_wasm_dir, + )?; + copy_project_assets( + &project_directory_path.join("res"), + &build_wasm_dir.join("res"), + )?; + + // Size budget + optional twiggy analysis on release builds. + if *compile_mode == CompileMode::Release { + let final_wasm = build_wasm_dir.join("pill_web_app_bg.wasm"); + if let Some(limit) = max_size_kb { + let limit_bytes = limit * 1024; + let actual = fs::metadata(&final_wasm) + .context("Cannot stat final WASM")? + .len(); + if actual > limit_bytes { + bail!( + "WASM binary {} exceeds budget {}", + fmt_bytes(actual), + fmt_bytes(limit_bytes) + ); + } + println!( + "Size guard OK ({} ≀ {})", + fmt_bytes(actual), + fmt_bytes(limit_bytes) + ); + } + + if wasm_analyze { + let pre_optimization_wasm = scratch_target_dir + .join("wasm32-unknown-unknown") + .join("release") + .join("pill_web_app.wasm"); + print_wasm_size_report(&final_wasm, &pre_optimization_wasm); + } + } + + println!(); + let elapsed = build_start.elapsed(); + let time_str = format_elapsed_time(elapsed); + let (open, close) = ansi_green(); + + let final_wasm = build_wasm_dir.join("pill_web_app_bg.wasm"); + let size_str = match fs::metadata(&final_wasm) { + Ok(meta) => fmt_bytes(meta.len()), + Err(_) => "unknown".to_string(), + }; + + println!("{open}Build completed successfully {time_str}{close}"); + println!("Build size: {size_str}"); + Ok(()) +} + +/// Invoke wasm-pack in the scratch directory. Prefers rustup's toolchain on PATH. +fn run_wasm_pack( + compile_mode: &CompileMode, + scratch_pill_web_app_dir: &Path, + scratch_package_directory: &Path, + scratch_target_dir: &Path, +) -> Result<()> { + let mut args: Vec = vec![ + "build".into(), + "--target".into(), + "web".into(), + "--out-dir".into(), + scratch_package_directory.to_string_lossy().to_string(), + ]; + if !matches!(compile_mode, CompileMode::Release) { + args.push("--dev".into()); + } + // Isolate cargo target directory per project so artifacts from different + // projects (which share the crate name "project") never collide. + args.push("--".into()); + args.push("--target-dir".into()); + args.push(scratch_target_dir.to_string_lossy().to_string()); + + println!("Running WASM-pack in scratch crate {scratch_pill_web_app_dir:?}..."); + + let mut command = Command::new("wasm-pack"); + command.args(&args).current_dir(scratch_pill_web_app_dir); + // getrandom >=0.3 requires explicit opt-in for wasm32-unknown-unknown. + command.env("RUSTFLAGS", "--cfg getrandom_backend=\"wasm_js\""); + if let Some(home) = env::var_os("HOME") { + let cargo_bin = PathBuf::from(home).join(".cargo").join("bin"); + let existing = env::var_os("PATH").unwrap_or_default(); + let mut parts: Vec = vec![cargo_bin]; + parts.extend(env::split_paths(&existing)); + if let Ok(joined) = env::join_paths(parts) { + command.env("PATH", joined); + } + } + let status = command.status().map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Error::msg("wasm-pack not found on PATH. Install it with: cargo install wasm-pack") + } else { + Error::new(error).context("Failed to execute wasm-pack") + } + })?; + + if !status.success() { + bail!("wasm-pack build failed (exit {:?})", status.code()); + } + Ok(()) +} + +fn copy_build_outputs( + scratch_package_directory: &Path, + web_get_template_directory: &Path, + user_web_dir: &Path, + build_wasm_dir: &Path, +) -> Result<()> { + fs::create_dir_all(build_wasm_dir) + .with_context(|| format!("Failed to create {build_wasm_dir:?}"))?; + + for file in ["pill_web_app.js", "pill_web_app_bg.wasm"] { + let source = scratch_package_directory.join(file); + let destination = build_wasm_dir.join(file); + fs::copy(&source, &destination) + .with_context(|| format!("Failed to copy {source:?} to {destination:?}"))?; + } + + copy_dir_files(web_get_template_directory, build_wasm_dir, "template")?; + if user_web_dir.is_dir() { + copy_dir_files(user_web_dir, build_wasm_dir, "overlay")?; + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// WASM size report (twiggy-backed, opt-in via --wasm-analyze) +// --------------------------------------------------------------------------- + +/// Print a side-by-side size report comparing the final (post-wasm-opt) and +/// pre-optimization WASM binaries. The pre-opt binary retains function-name +/// sections that wasm-opt strips, so crate-level attribution is only available +/// from the pre-opt analysis. Requires `twiggy` on PATH (cargo install twiggy). +fn print_wasm_size_report(final_wasm: &Path, pre_optimization_wasm: &Path) { + let Ok(final_size) = fs::metadata(final_wasm).map(|metadata| metadata.len()) else { + return; + }; + let pre_optimization_size = fs::metadata(pre_optimization_wasm) + .ok() + .map(|metadata| metadata.len()); + + // Summary line with savings. + println!(); + match pre_optimization_size { + Some(pre_optimization_bytes) => { + let saved = pre_optimization_bytes.saturating_sub(final_size); + let saved_percentage = if pre_optimization_bytes > 0 { + 100.0 * saved as f64 / pre_optimization_bytes as f64 + } else { + 0.0 + }; + println!( + "wasm size: final {} | pre-opt {} (wasm-opt: βˆ’{}, βˆ’{:.1}%)", + fmt_bytes(final_size), + fmt_bytes(pre_optimization_bytes), + fmt_bytes(saved), + saved_percentage + ); + } + None => println!("wasm size: {}", fmt_bytes(final_size)), + } + + // Pre-optimization analysis β€” the only one with crate-level attribution. + let pre_items = + pre_optimization_size.and_then(|size| match collect_twiggy_items(pre_optimization_wasm) { + Ok(items) if !items.is_empty() => Some((size, items)), + _ => None, + }); + + if let Some((pre_size, ref pre_items)) = pre_items { + let pre_by_crate = aggregate_by_crate(pre_items); + print_crate_table( + "Crate breakdown (pre-optimization, where attribution is available)", + pre_size, + &pre_by_crate, + ); + print_symbol_table("Top symbols (pre-optimization)", pre_size, pre_items); + } else { + println!(); + println!("(install twiggy for per-crate breakdown: cargo install twiggy)"); + } + + // Final analysis β€” coarse sections only (code, data, wasm-meta). + if let Ok(final_items) = collect_twiggy_items(final_wasm) { + if !final_items.is_empty() { + let final_by_crate = aggregate_by_crate(&final_items); + println!(); + println!( + "--- Final binary layout (post-optimization, no function-name attribution) ---" + ); + println!(); + // Show the meaningful WASM sections: code, data, and meta. + let section_order = ["code", "data", "export", "[wasm-meta]", "[custom]"]; + let lookup: HashMap<&str, u64> = final_by_crate + .iter() + .map(|(k, v)| (k.as_str(), *v)) + .collect(); + let mut accounted: u64 = 0; + println!(" {:<20} {:>10} {:>7}", "section", "size", "%"); + for section in §ion_order { + if let Some(bytes) = lookup.get(section).copied() { + accounted += bytes; + println!( + " {:<18} {:>10} {:>6.1}%", + section, + fmt_bytes(bytes), + pct(bytes, final_size) + ); + } + } + if accounted < final_size { + let remainder = final_size - accounted; + println!( + " {:<18} {:>10} {:>6.1}%", + "(other)", + fmt_bytes(remainder), + pct(remainder, final_size) + ); + } + println!( + " {:<18} {:>10} {:>6} ", + "---", + fmt_bytes(final_size), + "100%" + ); + print_symbol_table("Top symbols (final)", final_size, &final_items); + } + } +} + +/// Collect twiggy items for a WASM binary. Returns empty Vec if twiggy is not +/// installed or fails. +fn collect_twiggy_items(wasm_path: &Path) -> Result, TwiggyError> { + let output = match Command::new("twiggy") + .args(["top", "-n", "15000"]) + .arg(wasm_path) + .stderr(Stdio::null()) + .output() + { + Ok(o) => o, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(TwiggyError::NotFound), + Err(_) => return Err(TwiggyError::Failed), + }; + if !output.status.success() { + return Err(TwiggyError::Failed); + } + let stdout = String::from_utf8(output.stdout).map_err(|_| TwiggyError::Failed)?; + let items = parse_twiggy(&stdout); + Ok(items) +} + +enum TwiggyError { + NotFound, + Failed, +} + +/// Aggregate twiggy items into crate-family buckets. +fn aggregate_by_crate(items: &[(u64, String)]) -> Vec<(String, u64)> { + let mut by_crate: HashMap = HashMap::new(); + for (bytes, name) in items { + *by_crate.entry(classify_crate(name)).or_insert(0) += *bytes; + } + let mut groups: Vec<(String, u64)> = by_crate.into_iter().collect(); + groups.sort_by(|a, b| b.1.cmp(&a.1)); + groups +} + +/// Print a unified crate-size table with engine libs, project, 3rd-party +/// crates, and WASM internal sections. +/// +/// Percentages use twiggy's retained-size model β€” items may overlap, so +/// percentages can sum beyond 100%. Use the relative sizes, not the absolute +/// percentages, to compare categories. +fn print_crate_table(title: &str, total: u64, by_crate: &[(String, u64)]) { + const ENGINE_LIBS: &[&str] = &["pill_engine", "pill_renderer", "pill_core", "pill_web"]; + const PROJECT_KEY: &str = "project"; + const PROJECT_ASSETS_KEY: &str = "[project-assets]"; + // WASM internal / metadata sections (not crates, but useful for breakdown). + // `export` is twiggy's accounting for exported symbols β€” they live inside + // code/data, so the export percentage overlaps with those sections. + const WASM_SECTIONS: &[&str] = &[ + "[wasm-bindgen]", + "[debug:names]", + "[rust-std]", + "[rodata]", + "[wasm-meta]", + "[custom]", + "export", + ]; + // Items to never display (unhelpful catch-all). + const SKIP: &[&str] = &["[other]"]; + + let lookup: HashMap<&str, u64> = by_crate.iter().map(|(k, v)| (k.as_str(), *v)).collect(); + + println!(); + println!("--- {title} (% of {}) ---", fmt_bytes(total)); + println!(); + + // -- Engine libs ---------------------------------------------------- + let engine_total: u64 = ENGINE_LIBS + .iter() + .map(|k| lookup.get(k).copied().unwrap_or(0)) + .sum(); + println!(" {:<28} {:>10} {:>7}", "Engine libs", "size", "%"); + for lib in ENGINE_LIBS { + let bytes = lookup.get(lib).copied().unwrap_or(0); + println!( + " {:<26} {:>10} {:>6.1}%", + *lib, + fmt_bytes(bytes), + pct(bytes, total) + ); + } + println!( + " {:<26} {:>10} {:>6.1}% ← engine total", + "---", + fmt_bytes(engine_total), + pct(engine_total, total) + ); + + // -- Project -------------------------------------------------------- + let project_bytes = lookup.get(PROJECT_KEY).copied().unwrap_or(0); + let project_assets = lookup.get(PROJECT_ASSETS_KEY).copied().unwrap_or(0); + println!(); + println!(" {:<28} {:>10} {:>7}", "Project", "size", "%"); + println!( + " {:<26} {:>10} {:>6.1}% (logic)", + PROJECT_KEY, + fmt_bytes(project_bytes), + pct(project_bytes, total) + ); + println!( + " {:<26} {:>10} {:>6.1}% (embedded assets)", + PROJECT_ASSETS_KEY, + fmt_bytes(project_assets), + pct(project_assets, total) + ); + + // -- 3rd-party crates ----------------------------------------------- + let excluded: Vec<&str> = ENGINE_LIBS + .iter() + .chain([PROJECT_KEY, PROJECT_ASSETS_KEY].iter()) + .chain(WASM_SECTIONS.iter()) + .chain(SKIP.iter()) + .copied() + .collect(); + let crates: Vec<_> = by_crate + .iter() + .filter(|(k, _)| !excluded.contains(&k.as_str()) && !k.starts_with('[')) + .take(15) + .collect(); + if !crates.is_empty() { + let crate_total: u64 = crates.iter().map(|(_, b)| *b).sum(); + println!(); + println!(" {:<28} {:>10} {:>7}", "3rd-party crates", "size", "%"); + for (name, bytes) in &crates { + println!( + " {:<26} {:>10} {:>6.1}%", + name, + fmt_bytes(*bytes), + pct(*bytes, total) + ); + } + println!( + " {:<26} {:>10} {:>6.1}% ← crate subtotal", + "---", + fmt_bytes(crate_total), + pct(crate_total, total) + ); + } + + // -- WASM internal sections ----------------------------------------- + let sections: Vec<_> = WASM_SECTIONS + .iter() + .filter_map(|s| lookup.get(s).map(|b| (*s, *b))) + .filter(|(_, b)| *b > 0) + .collect(); + if !sections.is_empty() { + let section_total: u64 = sections.iter().map(|(_, b)| *b).sum(); + println!(); + println!(" {:<28} {:>10} {:>7}", "WASM internals", "size", "%"); + for (name, bytes) in §ions { + let note = if *name == "export" { + " (overlaps code/data)" + } else { + "" + }; + println!( + " {:<26} {:>10} {:>6.1}%{}", + name, + fmt_bytes(*bytes), + pct(*bytes, total), + note + ); + } + println!( + " {:<26} {:>10} {:>6.1}% ← section subtotal", + "---", + fmt_bytes(section_total), + pct(section_total, total) + ); + } +} + +/// Helper: percentage with zero-guard. +fn pct(bytes: u64, total: u64) -> f64 { + if total > 0 { + 100.0 * bytes as f64 / total as f64 + } else { + 0.0 + } +} + +/// Print the top symbols from a twiggy analysis. +fn print_symbol_table(title: &str, total: u64, items: &[(u64, String)]) { + println!(); + println!("--- {title} ---",); + println!(); + for (bytes, name) in items.iter().take(10) { + let percentage = 100.0 * *bytes as f64 / total as f64; + let display = truncate_display(name, 72); + println!( + " {:>10} {:>5.1}% {}", + fmt_bytes(*bytes), + percentage, + display + ); + } +} + +/// Parse twiggy's default text output. Each data row: +/// " β”Š % β”Š " +fn parse_twiggy(stdout: &str) -> Vec<(u64, String)> { + let mut items = Vec::new(); + for line in stdout.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() + || trimmed.starts_with("Shallow") + || trimmed.starts_with('-') + || trimmed.starts_with('Ξ£') + || (trimmed.contains("and ") && trimmed.contains("more")) + { + continue; + } + let parts: Vec<&str> = trimmed.split('β”Š').collect(); + if parts.len() < 3 { + continue; + } + let Ok(bytes) = parts[0].trim().parse::() else { + continue; + }; + if bytes == 0 { + continue; + } + items.push((bytes, parts[2].trim().to_string())); + } + items +} + +fn truncate_display(name: &str, max_chars: usize) -> String { + if name.chars().count() > max_chars { + let head: String = name.chars().take(max_chars - 3).collect(); + format!("{head}...") + } else { + name.to_string() + } +} + +fn fmt_bytes(n: u64) -> String { + const MB: f64 = 1024.0 * 1024.0; + const KB: f64 = 1024.0; + let f = n as f64; + if f >= MB { + format!("{:.2} MB", f / MB) + } else if f >= KB { + format!("{:.1} KB", f / KB) + } else { + format!("{n} B") + } +} + +/// Coarse bucketing of twiggy item names into crate families. +fn classify_crate(name: &str) -> String { + if name.contains(".rodata") || name.contains("data segment") { + if name.contains("project") { + return "[project-assets]".into(); + } + return "[rodata]".into(); + } + if name.contains("function names") { + return "[debug:names]".into(); + } + if name.contains("__wasm_bindgen") { + return "[wasm-bindgen]".into(); + } + if name.contains("custom section") { + return "[custom]".into(); + } + if name.starts_with("elem[") + || name.starts_with("type[") + || name.starts_with("import ") + || name.starts_with("table[") + { + return "[wasm-meta]".into(); + } + + let rest = name.strip_prefix('<').unwrap_or(name); + let rest = rest + .strip_prefix("&mut ") + .or_else(|| rest.strip_prefix('&')) + .unwrap_or(rest); + let ident: String = rest + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect(); + + if ident.is_empty() { + return "[other]".into(); + } + match ident.as_str() { + "core" | "alloc" | "std" | "compiler_builtins" | "rustc_demangle" | "dlmalloc" | "str" + | "bool" | "u8" | "u16" | "u32" | "u64" | "i8" | "i16" | "i32" | "i64" | "f32" | "f64" + | "char" | "usize" | "isize" | "T" => "[rust-std]".into(), + "jpeg_decoder" | "png" | "tiff" | "gif" | "weezl" | "miniz_oxide" | "color_quant" + | "qoi" | "exr" => "image".into(), + "epaint" | "emath" | "egui_wgpu" | "egui_winit" => "egui".into(), + "codespan_reporting" | "codespan" | "pp_rs" | "spirv" => "naga".into(), + "wgpu_hal" | "wgpu_core" | "wgpu_types" => "wgpu".into(), + "js_sys" => "web_sys".into(), + _ => ident, + } +} diff --git a/engine/pill_launcher/src/utils/web_dev_server.rs b/engine/pill_launcher/src/utils/web_dev_server.rs new file mode 100644 index 00000000..1b215bd8 --- /dev/null +++ b/engine/pill_launcher/src/utils/web_dev_server.rs @@ -0,0 +1,211 @@ +//! This file implements a dev HTTP server for WASM targets with live reload. +//! +//! Responsibilities: +//! - run(): entry point - builds the WASM bundle, then serves build/wasm/ on +//! the given port with long-poll reload when files change. +//! - Injects a small " +); + +type Subscribers = Arc>>>; + +/// Build the WASM bundle and start a dev HTTP server. +/// +/// 1. Builds the WASM bundle via `wasm_target::build_project`. +/// 2. Starts a tiny_http server on `127.0.0.1:`. +/// 3. In hot-reload mode, spawns a file watcher and injects a live-reload +/// `" -); - -type Subscribers = Arc>>>; - -pub fn run(game_project_directory_path: &Path, compile_mode: &CompileMode) -> Result<()> { - wasm_build::build(game_project_directory_path, compile_mode, None)?; - - let build_wasm_dir = game_project_directory_path.join("build").join("wasm"); - let subscribers: Subscribers = Arc::new(Mutex::new(Vec::new())); - - spawn_watcher(build_wasm_dir.clone(), Arc::clone(&subscribers)); - - let server = tiny_http::Server::http(ADDR).map_err(|e| Error::msg(e.to_string()))?; - println!(); - println!("Serving {} at http://{}", build_wasm_dir.display(), ADDR); - println!("Live reload enabled β€” the page will refresh on wasm rebuilds."); - println!("Ctrl+C to stop."); - - for request in server.incoming_requests() { - let subscribers = Arc::clone(&subscribers); - let build_wasm_dir = build_wasm_dir.clone(); - thread::spawn(move || { - if let Err(e) = handle_request(request, &build_wasm_dir, subscribers) { - eprintln!("http request error: {:#}", e); - } - }); - } - - Ok(()) -} - -fn spawn_watcher(watch_dir: std::path::PathBuf, subscribers: Subscribers) { - let mut last = latest_mtime(&watch_dir); - thread::spawn(move || loop { - thread::sleep(WATCH_POLL); - let cur = latest_mtime(&watch_dir); - if cur > last && cur.is_some() { - last = cur; - let mut subs = subscribers.lock().unwrap(); - for tx in subs.drain(..) { - let _ = tx.send(()); - } - } - }); -} - -// Max mtime among regular files in `dir` (shallow, skipping dotfiles/.build scratch). -fn latest_mtime(dir: &Path) -> Option { - fs::read_dir(dir) - .ok()? - .filter_map(|e| e.ok()) - .filter_map(|e| { - let name = e.file_name(); - if name.to_string_lossy().starts_with('.') { - return None; - } - let md = e.metadata().ok()?; - if !md.is_file() { - return None; - } - md.modified().ok() - }) - .max() -} - -fn handle_request( - request: tiny_http::Request, - build_wasm_dir: &Path, - subscribers: Subscribers, -) -> Result<()> { - let url_path = request.url().split('?').next().unwrap_or("/").to_string(); - - if url_path == "/__reload" { - return handle_reload(request, subscribers); - } - - // Map URL to file under build_wasm_dir; reject `..` segments. - let rel = url_path.trim_start_matches('/'); - let rel = if rel.is_empty() { "index.html" } else { rel }; - if rel.split('/').any(|seg| seg == "..") { - return respond(request, 400, "bad path"); - } - let path = build_wasm_dir.join(rel); - if !path.is_file() { - return respond(request, 404, "not found"); - } - - let content_type = content_type_for(&path); - let ct_header = tiny_http::Header::from_bytes("Content-Type", content_type) - .map_err(|_| Error::msg("invalid content-type header"))?; - - if content_type.starts_with("text/html") { - let mut html = fs::read_to_string(&path)?; - if let Some(idx) = html.rfind("") { - html.insert_str(idx, RELOAD_SCRIPT); - } else { - html.push_str(RELOAD_SCRIPT); - } - let resp = tiny_http::Response::from_string(html).with_header(ct_header); - request.respond(resp)?; - return Ok(()); - } - - let file = File::open(&path)?; - let resp = tiny_http::Response::from_file(file).with_header(ct_header); - request.respond(resp)?; - Ok(()) -} - -fn handle_reload(request: tiny_http::Request, subscribers: Subscribers) -> Result<()> { - let (tx, rx) = mpsc::channel(); - subscribers.lock().unwrap().push(tx); - let signaled = rx.recv_timeout(LONG_POLL_TIMEOUT).is_ok(); - respond(request, if signaled { 200 } else { 204 }, "") -} - -fn respond(request: tiny_http::Request, status: u16, body: &str) -> Result<()> { - let resp = tiny_http::Response::from_string(body.to_string()).with_status_code(status); - request.respond(resp)?; - Ok(()) -} - -fn content_type_for(path: &Path) -> &'static str { - match path.extension().and_then(|s| s.to_str()) { - Some("html") => "text/html; charset=utf-8", - Some("js") | Some("mjs") => "text/javascript; charset=utf-8", - Some("wasm") => "application/wasm", - Some("png") => "image/png", - Some("svg") => "image/svg+xml", - Some("css") => "text/css; charset=utf-8", - Some("ico") => "image/x-icon", - Some("json") => "application/json; charset=utf-8", - _ => "application/octet-stream", - } -} diff --git a/engine/pill_native/Cargo.toml b/engine/pill_native/Cargo.toml index ff6271dd..b8b34262 100644 --- a/engine/pill_native/Cargo.toml +++ b/engine/pill_native/Cargo.toml @@ -3,17 +3,20 @@ name = "pill_native" version = "0.1.0" edition = "2021" +[features] +headless = [] + [dependencies] -pill_core = {path = "../pill_core"} +pill_core = { path = "../pill_core" } pill_abi = { path = "../pill_abi" } pill_runtime = { path = "../pill_runtime" } -winit = "0.30.12" +winit = "0.30" log = "0.4" config = "0.11" -anyhow = "1.0.45" -libloading = "0.8.8" -image = "0.25.6" +anyhow = "1" +libloading = "0.9" +image = "0.25" [build-dependencies] winres = "0.1" diff --git a/engine/pill_native/src/file_watcher.rs b/engine/pill_native/src/file_watcher.rs index 5731b497..bd0d12ef 100644 --- a/engine/pill_native/src/file_watcher.rs +++ b/engine/pill_native/src/file_watcher.rs @@ -1,3 +1,11 @@ +//! This file implements a lightweight file-system watcher for hot-reload. +//! +//! Polls a directory tree for file additions, modifications, and deletions +//! by comparing last-modified timestamps against a stored snapshot. Skips +//! hidden files, editor temp files, and swap files to avoid false positives. +//! +//! Used by: hot_reload (create_file_watchers, FileWatchers struct) + use std::{ collections::HashMap, fs, @@ -5,18 +13,25 @@ use std::{ time::SystemTime, }; -// Struct to watch a directory for file changes. +/// Polls a directory for file additions, modifications, and deletions. +/// +/// Stores a snapshot of every tracked file's last-modified timestamp and +/// compares against it on each poll. Skips hidden files, editor temp files, +/// and swap files to avoid false positives during editing. pub struct FileWatcher { - path: PathBuf, // The directory path being watched - recursive: bool, // Whether to watch subfolders recursively - previous_metadata: HashMap, // Tracks file paths and their last modified time + // The directory being watched. + path: PathBuf, + // Whether to descend into subdirectories. + recursive: bool, + // Canonical path β†’ last modified time for every known file. + previous_metadata: HashMap, } impl FileWatcher { - // Create a new FileWatcher for a given directory. + /// Creates a new watcher for a single directory (non-recursive by default). + /// Takes an immediate snapshot of the current file state. pub fn new(path: PathBuf) -> Self { - // Initialize with the current metadata of files in the directory - let previous_metadata = Self::get_file_metadata(&path, false); + let previous_metadata = Self::collect_file_metadata(&path, false); Self { path, previous_metadata, @@ -24,57 +39,58 @@ impl FileWatcher { } } + /// Enables recursive watching and immediately rescans the directory. + /// Returns self for builder-style chaining. pub fn set_recursive(mut self, recursive: bool) -> Self { self.recursive = recursive; - self.previous_metadata = Self::get_file_metadata(&self.path, recursive); + self.previous_metadata = Self::collect_file_metadata(&self.path, recursive); self } - // Retrieve metadata (modified times) for all files in a given directory. - fn get_file_metadata(path: &Path, recursive: bool) -> HashMap { + /// Scans a directory and returns a map of canonical path β†’ last modified time + /// for every non-hidden, non-temp file found. + fn collect_file_metadata(path: &Path, recursive: bool) -> HashMap { let mut file_metadata = HashMap::new(); Self::scan_directory(path, recursive, &mut file_metadata); file_metadata } - // Recursively scan directory and collect file metadata + /// Recursively walks a directory tree, recording the last-modified + /// timestamp for each regular file encountered. fn scan_directory( path: &Path, recursive: bool, file_metadata: &mut HashMap, ) { - // Attempt to read the directory entries - if let Ok(entries) = fs::read_dir(path) { - // Iterate through each directory entry - for entry in entries.filter_map(Result::ok) { - let entry_path = entry.path(); - - // If it's a directory and we're in recursive mode, scan it - if entry_path.is_dir() && recursive { - Self::scan_directory(&entry_path, recursive, file_metadata); - } else if entry_path.is_file() { - // Try to retrieve file metadata - if let Ok(metadata) = entry_path.metadata() { - // Get the last modified time of the file - if let Ok(modified) = metadata.modified() { - // Optional check: ensure the file name is valid UTF-8 - if let Some(file_name) = entry_path.file_name() { - if let Some(file_name_str) = file_name.to_str() { - // Skip hidden or temp files - if file_name_str.starts_with('.') - || file_name_str.ends_with("~") - || file_name_str.ends_with(".swp") - // TODO: skip also the - // new dll file - { - continue; - } - // Store canonical path so comparison is stable across scans - let key = entry_path - .canonicalize() - .unwrap_or_else(|_| entry_path.clone()); - file_metadata.insert(key, modified); + let Ok(entries) = fs::read_dir(path) else { + return; + }; + + for entry in entries.filter_map(Result::ok) { + let entry_path = entry.path(); + + if entry_path.is_dir() && recursive { + // Descend into subdirectories when recursive mode is on. + Self::scan_directory(&entry_path, recursive, file_metadata); + } else if entry_path.is_file() { + // Extract the last-modified timestamp if available. + if let Ok(metadata) = entry_path.metadata() { + if let Ok(modified) = metadata.modified() { + // Skip hidden files, editor temp files, and vim swap files. + if let Some(file_name) = entry_path.file_name() { + if let Some(file_name_str) = file_name.to_str() { + if file_name_str.starts_with('.') + || file_name_str.ends_with('~') + || file_name_str.ends_with(".swp") + { + continue; } + // Use the canonical path as the key so that + // comparisons survive symlinks and path normalisation. + let key = entry_path + .canonicalize() + .unwrap_or_else(|_| entry_path.clone()); + file_metadata.insert(key, modified); } } } @@ -83,46 +99,44 @@ impl FileWatcher { } } - // Check for changes compared to the last known state. - // Returns a list of paths that have been added, modified, or deleted. + // Compares the current filesystem state against the stored snapshot. + // Returns every path that was added, modified, or deleted since the + // last call, then updates the snapshot for the next poll. fn check_for_changes(&mut self) -> Vec { - // Get the current state of the directory - let current_metadata = Self::get_file_metadata(&self.path, self.recursive); + let current_metadata = Self::collect_file_metadata(&self.path, self.recursive); let mut changes = Vec::new(); - // Detect modified or newly added files + // Detect new and modified files. for (file_path, modified_time) in ¤t_metadata { match self.previous_metadata.get(file_path) { - // File exists, but was modified - Some(&prev_time) if prev_time != *modified_time => { + Some(&previous_time) if previous_time != *modified_time => { + // File was modified. changes.push(file_path.clone()); } - // File exists and has not changed Some(_) => { - // No action required + // File unchanged β€” nothing to report. } - // New file None => { + // New file appeared. changes.push(file_path.clone()); } } } - // Detect deleted files + // Detect deleted files: keys present in the old snapshot but + // missing from the current scan. for file_path in self.previous_metadata.keys() { if !current_metadata.contains_key(file_path) { changes.push(file_path.clone()); } } - // Update the stored metadata for next comparison self.previous_metadata = current_metadata; - changes } - // Public interface to retrieve changed files. - // Returns `Some(Vec)` if there are changes, otherwise `None`. + // Public polling interface. + // Returns Some(list) when files have changed, None when nothing changed. pub fn get_changes(&mut self) -> Option> { let changes = self.check_for_changes(); if !changes.is_empty() { @@ -138,26 +152,30 @@ mod tests { use super::*; use std::time::{Duration, SystemTime, UNIX_EPOCH}; - fn unique_tmp_dir(name: &str) -> PathBuf { - let nanos = SystemTime::now() + // Creates a unique temporary directory for a test, keyed by name and + // nanosecond timestamp to prevent collisions between parallel test runs. + fn unique_temporary_directory(name: &str) -> PathBuf { + let nanoseconds = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); - std::env::temp_dir().join(format!("pill_filewatch_{name}_{nanos}")) + std::env::temp_dir().join(format!("pill_filewatch_{name}_{nanoseconds}")) } + // Verifies that modifying a tracked file is detected by the watcher. #[test] fn detects_modified_file() { - let root = unique_tmp_dir("modify"); + let root = unique_temporary_directory("modify"); let _ = fs::remove_dir_all(&root); fs::create_dir_all(&root).unwrap(); - let file_path = root.join("game.rs"); + let file_path = root.join("project.rs"); fs::write(&file_path, "v1").unwrap(); let mut watcher = FileWatcher::new(root.clone()).set_recursive(true); - // Ensure filesystem mtime resolution advances. + // Sleep to ensure the filesystem mtime resolution advances + // beyond the previous write timestamp. std::thread::sleep(Duration::from_millis(1100)); fs::write(&file_path, "v2").unwrap(); diff --git a/engine/pill_native/src/headless.rs b/engine/pill_native/src/headless.rs new file mode 100644 index 00000000..5b23d151 --- /dev/null +++ b/engine/pill_native/src/headless.rs @@ -0,0 +1,266 @@ +//! This file implements the headless runner, activated by the `headless` feature. +//! +//! Replaces the winit-based run_app() with a bare update loop β€” no window, +//! no event loop, no GPU surface. Loads the runtime DLL directly and drives +//! the engine frame-by-frame until the project signals exit via is_exit_requested(). +//! +//! Hot-reload is fully supported: file watchers are polled each frame and +//! engine/project DLLs are hot-swapped using the same check_and_reload path. + +#[cfg(feature = "headless")] +use { + crate::hot_reload, + crate::hot_reload::{check_and_reload, create_file_watchers}, + crate::paths::{self, *}, + crate::runtime::{self, RuntimeHost}, + anyhow::{bail, Context, Result}, + config::Config, + pill_abi::*, + pill_core::{info, LogContext, PillStyle}, + std::ffi::CString, +}; + +/// Headless runner β€” replaces run_app() when compiled with the headless feature. +/// +/// Skips winit entirely: no window, no event loop. Loads the runtime directly +/// and drives the engine in a tight update loop. Hot-reload is supported via +/// file watchers polled each frame. +/// +/// The benchmark (or any headless PillProject) controls its own exit via +/// is_exit_requested(). +#[cfg(feature = "headless")] +pub(crate) fn run_app_headless() -> Result<()> { + // --- 1. Detect project and resolve initial paths --- + let mut hot_reload_enabled = + std::env::var("PILL_COMPILE_MODE").ok().as_deref() == Some("hot-reload"); + + // Determine the project directory relative to the executable. + let current_directory_path = std::env::current_exe() + .context("Failed to get current executable path")? + .parent() + .context("Executable has no parent directory")? + .to_path_buf(); + + let project_directory_path = infer_project_directory(¤t_directory_path)?; + let run_layout = resolve_run_layout(&project_directory_path); + + if hot_reload_enabled && run_layout != RunLayout::Development { + bail!("Hot reload requires development layout paths"); + } + + // Build all the path information the app will need. + let build_data_directory_path = current_directory_path.join("data"); + let project_resources_directory_path = project_directory_path.join("res"); + let build_resources_directory_path = build_data_directory_path.join("res"); + + let project_resources_directory_path = match run_layout { + RunLayout::Development => project_resources_directory_path, + RunLayout::Packaged if build_resources_directory_path.exists() => { + build_resources_directory_path + } + RunLayout::Packaged => project_resources_directory_path, + }; + + let project_source_directory_path = project_directory_path.join("src"); + let config_path = project_resources_directory_path.join("config.ini"); + + let runtime_load_mode = RuntimeLoadMode::Dylib; + + // --- 2. Resolve engine workspace (only needed for hot-reload) --- + let engine_source_directory_path = if hot_reload_enabled { + Some(resolve_engine_workspace_dir( + ¤t_directory_path, + &project_directory_path, + true, + )?) + } else { + None + }; + + // --- 3. Resolve DLL paths (runtime + project, including hot-reload variants) --- + let runtime_dynamic_library_path = runtime::resolve_runtime_dylib( + &build_data_directory_path, + engine_source_directory_path.as_deref(), + "pill_runtime", + )?; + + let runtime_dynamic_library_hot_reloaded_path = if hot_reload_enabled { + runtime::resolve_runtime_dylib_optional( + &build_data_directory_path, + engine_source_directory_path.as_deref(), + "pill_runtime_hot_reloaded", + ) + .unwrap_or_else(|| { + hot_reload_enabled = false; + runtime_dynamic_library_path.clone() + }) + } else { + runtime_dynamic_library_path.clone() + }; + + let project_dynamic_library_path = build_data_directory_path.join(dylib("project")); + let project_dynamic_library_hot_reloaded_path = + build_data_directory_path.join(dylib("project_hot_reloaded")); + + // --- 4. Assemble ProjectPaths and clean up stale artifacts from previous runs --- + let project_paths = ProjectPaths { + build_data_directory_path, + engine_source_directory_path, + project_directory_path, + project_resources_directory_path, + project_source_directory_path, + config_path, + runtime_dynamic_library_path, + runtime_dynamic_library_hot_reloaded_path, + project_dynamic_library_path, + project_dynamic_library_hot_reloaded_path, + }; + + // Clean up stale hot-reloaded libraries from previous runs. + if hot_reload_enabled { + hot_reload::try_remove_files_starting_with( + &project_paths.build_data_directory_path, + &format!("{DYLIB_PREFIX}pill_runtime_loaded"), + ); + hot_reload::try_remove_files_starting_with( + &project_paths.build_data_directory_path, + &format!("{DYLIB_PREFIX}project_loaded"), + ); + } + + // --- 5. Load project configuration and set up logging --- + let mut config = Config::default(); + config + .merge(config::File::with_name( + project_paths.config_path.to_str().unwrap(), + )) + .with_context(|| { + format!( + "Failed to load config from {}", + project_paths.config_path.display() + ) + })?; + + crate::configure_logging(&config); + + // Log hot-reload status; only include watch paths when enabled. + if hot_reload_enabled { + info!( + LogContext::HotReload => "Hot-reload enabled (headless, watching src: {}, res: {})", + project_paths.project_source_directory_path.display(), + project_paths.project_resources_directory_path.display() + ); + } else { + info!(LogContext::HotReload => "Hot-reload disabled (headless)"); + } + info!( + "Initializing {} ({:?} layout, headless)", + "Standalone".module_object_style(), + run_layout, + ); + + // --- 6. Initialize file watchers if hot-reload is active --- + // Set up file watchers for hot-reload. + let mut file_watchers = if hot_reload_enabled { + Some(create_file_watchers(&project_paths)) + } else { + None + }; + + // --- 7. Load runtime DLL and create the engine with null window (headless) --- + // Load the runtime and create the engine. + let mut runtime_host = Some(RuntimeHost::load( + &project_paths.runtime_dynamic_library_path, + runtime_load_mode, + )?); + + let project_dylib_cstr = CString::new( + project_paths + .project_dynamic_library_path + .to_string_lossy() + .as_bytes(), + ) + .context("Failed to create project dylib path CString")?; + let resources_cstr = CString::new( + project_paths + .project_resources_directory_path + .to_string_lossy() + .as_bytes(), + ) + .context("Failed to create resources path CString")?; + let config_cstr = CString::new(project_paths.config_path.to_string_lossy().as_bytes()) + .context("Failed to create config path CString")?; + + // Build the FFI args with a null window_ptr β€” the runtime detects + // headless mode via its own compile-time feature and uses DummyRenderer. + let create_args = PillEngineCreateArgsV1 { + struct_size: std::mem::size_of::() as u32, + window_ptr: std::ptr::null(), // headless: no window + project_dylib_path: project_dylib_cstr.as_ptr(), + project_resources_dir: resources_cstr.as_ptr(), + config_path: config_cstr.as_ptr(), + initial_w: 0, + initial_h: 0, + }; + + runtime_host + .as_mut() + .unwrap() + .create(&create_args) + .context("RuntimeHost.create failed")?; + + info!("Headless engine initialized β€” entering update loop"); + + // --- 8. Main update loop: advance engine, check exit, poll hot-reload --- + // Tight update loop β€” runs until the engine requests exit. + let mut last_update = std::time::Instant::now(); + let mut last_reload_poll = std::time::Instant::now(); + let window_size = winit::dpi::PhysicalSize::new(0, 0); // dummy for headless + loop { + let now = std::time::Instant::now(); + let delta = now.duration_since(last_update); + last_update = now; + + // --- 8a. Compute delta time and advance the engine by one frame --- + // Advance the engine by one frame. + if let Some(host) = runtime_host.as_mut() { + host.update(delta); + + // Check if the engine has requested graceful exit + // (e.g. benchmark finished after N frames). + if host.should_exit() { + info!("Engine requested exit β€” shutting down"); + break; + } + } + + // --- 8b. Poll file watchers and trigger hot-reload if source changed --- + // Poll for hot-reload changes after each frame. + if hot_reload_enabled { + if let Some(watchers) = file_watchers.as_mut() { + check_and_reload( + &mut runtime_host, + None, // headless: no window context + &project_paths, + &mut last_reload_poll, + window_size, + watchers, + runtime_load_mode, + ) + .unwrap(); + } + } + + // Yield to avoid a busy-wait spin at 100% CPU. + // The engine controls its own frame pacing via the + // benchmark_headless feature or project-level logic. + std::thread::yield_now(); + } + + // Explicitly destroy the runtime host before the dylib unloads. + // RuntimeHost::Drop calls engine.destroy() automatically. + drop(runtime_host); + + info!("Headless run complete"); + Ok(()) +} diff --git a/engine/pill_native/src/hot_reload.rs b/engine/pill_native/src/hot_reload.rs new file mode 100644 index 00000000..f4d84ed6 --- /dev/null +++ b/engine/pill_native/src/hot_reload.rs @@ -0,0 +1,514 @@ +//! This file implements the hot-reload system for both windowed and headless modes. +//! +//! Watches engine crate sources, project sources, resources, and output dylib +//! directories for changes. On detection, invokes PillLauncher to rebuild and +//! hot-swaps the runtime/project DLLs in-place without restarting the game. +//! +//! Supports: windowed mode (polled via winit event loop), headless mode (polled +//! via tight update loop). Full runtime reload on engine changes; project-only +//! reload on game-code changes. + +use crate::file_watcher::FileWatcher; +use crate::paths::{dylib, ProjectPaths}; +use crate::runtime::{self, RuntimeCreateContext, RuntimeHost}; +use anyhow::{bail, Context, Result}; +use pill_core::{info, warn, LogContext}; +use std::ffi::{CString, OsString}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +// Minimum time between successive hot-reload attempts to avoid +// triggering rebuilds on every file-system event. +const RELOAD_COOLDOWN: Duration = Duration::from_millis(1000); + +// --------------------------------------------------------------------------- +// FileWatchers +// --------------------------------------------------------------------------- + +/// Groups together all file watchers used during hot-reload. +/// Each watcher tracks a different directory: +/// - Engine source crates (pill_core, pill_engine, pill_renderer) +/// - Dynamic libraries output folder +/// - Project source and resources folders +pub(crate) struct FileWatchers { + pub(crate) engine_core_source_files_watcher: FileWatcher, + pub(crate) engine_engine_source_files_watcher: FileWatcher, + pub(crate) engine_renderer_source_files_watcher: FileWatcher, + pub(crate) dynamic_libraries_files_watcher: FileWatcher, + pub(crate) project_source_files_watcher: FileWatcher, + pub(crate) project_resources_files_watcher: FileWatcher, +} + +/// Creates a full set of file watchers for all directories relevant +/// to hot-reload: engine crates, output dylibs, and project source/resources. +pub(crate) fn create_file_watchers(project_paths: &ProjectPaths) -> FileWatchers { + let engine_workspace_directory_path = project_paths + .engine_source_directory_path + .as_ref() + .expect("engine_source_directory_path missing for hot reload"); + + let core_source_path = engine_workspace_directory_path.join("pill_core/src"); + let engine_core_source_files_watcher = FileWatcher::new(core_source_path).set_recursive(true); + + let engine_source_path = engine_workspace_directory_path.join("pill_engine/src"); + let engine_engine_source_files_watcher = + FileWatcher::new(engine_source_path).set_recursive(true); + + let renderer_source_path = engine_workspace_directory_path.join("pill_renderer/src"); + let engine_renderer_source_files_watcher = + FileWatcher::new(renderer_source_path).set_recursive(true); + + let dynamic_libraries_files_watcher = + FileWatcher::new(project_paths.build_data_directory_path.clone()); + let project_source_files_watcher = + FileWatcher::new(project_paths.project_source_directory_path.clone()).set_recursive(true); + let project_resources_files_watcher = + FileWatcher::new(project_paths.project_resources_directory_path.clone()) + .set_recursive(true); + + FileWatchers { + engine_core_source_files_watcher, + engine_engine_source_files_watcher, + engine_renderer_source_files_watcher, + dynamic_libraries_files_watcher, + project_source_files_watcher, + project_resources_files_watcher, + } +} + +// --------------------------------------------------------------------------- +// Launcher Command Resolution +// --------------------------------------------------------------------------- + +// Resolves the path (or command) to the PillLauncher binary used for +// hot-reload builds. Checks, in order: +// 1. PILL_LAUNCHER_BIN env var (explicit path) +// 2. PILL_LAUNCHER_CMD env var (shell command) +// 3. Standard target directories under pill_launcher/ +// Falls back to "PillLauncherUpstream" on PATH if nothing is found. +fn resolve_launcher_command(engine_source_directory_path: &Path) -> Result { + if let Ok(value) = std::env::var("PILL_LAUNCHER_BIN") { + let path = PathBuf::from(value); + if !path.exists() { + bail!( + "PILL_LAUNCHER_BIN points to missing file: {}", + path.display() + ); + } + return Ok(path.into_os_string()); + } + + if let Ok(value) = std::env::var("PILL_LAUNCHER_CMD") { + return Ok(OsString::from(value)); + } + + let launcher_candidates = [ + engine_source_directory_path + .join("pill_launcher") + .join("target") + .join("debug") + .join("PillLauncher"), + engine_source_directory_path + .join("pill_launcher") + .join("target") + .join("release") + .join("PillLauncher"), + engine_source_directory_path + .join("pill_launcher") + .join("target") + .join("debug") + .join("PillLauncherUpstream"), + engine_source_directory_path + .join("pill_launcher") + .join("target") + .join("release") + .join("PillLauncherUpstream"), + ]; + + for candidate in launcher_candidates { + if candidate.exists() { + return Ok(candidate.into_os_string()); + } + } + + Ok(OsString::from("PillLauncherUpstream")) +} + +// --------------------------------------------------------------------------- +// Hot-Reload Build +// --------------------------------------------------------------------------- + +/// Invokes PillLauncher to perform a hot-reload build of the project. +/// Sets PILL_HOT_RELOAD_STATUS so external tooling can react to +/// success / warning / failure. +fn build_hot_reload_via_launcher(project_paths: &ProjectPaths) -> Result<()> { + let engine_source_directory_path = project_paths + .engine_source_directory_path + .as_ref() + .context("engine_source_directory_path missing for hot reload")?; + + let launcher_command = resolve_launcher_command(engine_source_directory_path)?; + let output_directory = project_paths + .build_data_directory_path + .parent() + .context("build_data_directory_path has no parent")?; + + // When pill_native was compiled in headless mode, the hot-reload + // rebuild must also enable headless so the new runtime DLL accepts + // a null window_ptr. The `mut` is only needed with that feature; + // suppress the clippy lint when building without it. + #[cfg_attr(not(feature = "headless"), allow(unused_mut))] + let mut arguments = vec![ + "build", + "-p", + project_paths.project_directory_path.to_str().unwrap(), + "-c", + "hot-reload", + "-o", + output_directory.to_str().unwrap(), + ]; + + #[cfg(feature = "headless")] + arguments.push("--headless"); + + // Attempt to run the pre-built launcher binary. + // If the binary doesn't exist (NotFound), fall back to `cargo run` + // which compiles and launches it on-the-fly. + let output = std::process::Command::new(&launcher_command) + .args(&arguments) + .env("PILL_HOT_RELOAD_CHILD", "1") + .env("PILL_ENGINE_WORKSPACE_DIR", engine_source_directory_path) + .output(); + + let output = match output { + Ok(output) => output, + // Launcher binary not found β€” compile and run via cargo as fallback. + // This handles the case where the developer hasn't pre-built PillLauncher. + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let manifest = engine_source_directory_path + .join("pill_launcher") + .join("Cargo.toml"); + std::process::Command::new("cargo") + .args(["run", "--manifest-path", manifest.to_str().unwrap(), "--"]) + .args(&arguments) + .env("PILL_HOT_RELOAD_CHILD", "1") + .env("PILL_ENGINE_WORKSPACE_DIR", engine_source_directory_path) + .output() + .context("Failed to invoke pill_launcher via cargo for hot reload")? + } + Err(error) => return Err(error).context("Failed to invoke pill_launcher for hot reload"), + }; + + // Forward launcher stdout/stderr to our own output streams + // so the user sees build progress and compiler diagnostics. + let standard_output = String::from_utf8_lossy(&output.stdout); + let standard_error = String::from_utf8_lossy(&output.stderr); + + print!("{standard_output}"); + eprint!("{standard_error}"); + + // Set PILL_HOT_RELOAD_STATUS so external tooling can react. + // Values: "pass" (clean), "warn" (warnings), "fail" (build error). + if !output.status.success() { + std::env::set_var("PILL_HOT_RELOAD_STATUS", "fail"); + bail!("pill_launcher build hot-reload failed"); + } + + // Check for compiler warnings in the launcher output and set the + // status accordingly. Even a successful build may have warnings. + let has_warnings = standard_output.contains("warning:") || standard_error.contains("warning:"); + if has_warnings { + std::env::set_var("PILL_HOT_RELOAD_STATUS", "warn"); + } else { + std::env::set_var("PILL_HOT_RELOAD_STATUS", "pass"); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// check_and_reload +// --------------------------------------------------------------------------- + +/// Polls all file watchers for changes and triggers a hot-reload build +/// followed by runtime / project reload when needed. +/// +/// High-level flow: +/// 1. Check that the cooldown period has elapsed. +/// 2. Collect changed paths from every file watcher. +/// 3. If only resource files changed, skip the build. +/// 4. If source files changed, invoke pill_launcher to rebuild. +/// 5. If new dylibs appeared, reload the runtime and/or project in-place. +pub(crate) fn check_and_reload( + runtime_host: &mut Option, + runtime_context: Option<&RuntimeCreateContext>, + project_paths: &ProjectPaths, + last_reload_poll: &mut Instant, + window_size: winit::dpi::PhysicalSize, + file_watchers: &mut FileWatchers, + runtime_load_mode: crate::paths::RuntimeLoadMode, +) -> Result<()> { + // --- 1. Cooldown gate --- + let now = Instant::now(); + if now.duration_since(*last_reload_poll) < RELOAD_COOLDOWN { + return Ok(()); + } + *last_reload_poll = now; + + // --- 2. Collect file changes --- + let mut engine_source_changes = Vec::::new(); + let mut project_source_changes = Vec::::new(); + let mut project_resources_changes = Vec::::new(); + + if let Some(paths) = file_watchers.engine_core_source_files_watcher.get_changes() { + info!(LogContext::HotReload => "Engine pill_core source file change detected: {:?}", paths); + engine_source_changes.extend(paths); + } + if let Some(paths) = file_watchers + .engine_engine_source_files_watcher + .get_changes() + { + info!(LogContext::HotReload => "Engine pill_engine source file change detected: {:?}", paths); + engine_source_changes.extend(paths); + } + if let Some(paths) = file_watchers + .engine_renderer_source_files_watcher + .get_changes() + { + info!(LogContext::HotReload => "Engine pill_renderer source file change detected: {:?}", paths); + engine_source_changes.extend(paths); + } + + if let Some(paths) = file_watchers.project_resources_files_watcher.get_changes() { + info!(LogContext::HotReload => "Project resources file change detected: {:?}", paths); + project_resources_changes.extend(paths); + } + if let Some(paths) = file_watchers.project_source_files_watcher.get_changes() { + info!(LogContext::HotReload => "Project source file change detected: {:?}", paths); + project_source_changes.extend(paths); + } + + // --- 3. Resource-only changes: no rebuild needed --- + if !project_resources_changes.is_empty() + && project_source_changes.is_empty() + && engine_source_changes.is_empty() + { + info!(LogContext::HotReload => "Project resources changed; No code rebuild needed: {:?}", project_resources_changes); + return Ok(()); + } + + // --- 4. Build via launcher --- + let build_start = Instant::now(); + if !project_source_changes.is_empty() || !engine_source_changes.is_empty() { + if let Err(error) = build_hot_reload_via_launcher(project_paths) { + warn!( + LogContext::HotReload => + "Hot-reload failed; Keeping currently loaded runtime project. Error: {error:?}" + ); + + // Drain the dylib watcher so stale events don't trigger + // another build immediately. + let _ = file_watchers.dynamic_libraries_files_watcher.get_changes(); + + return Ok(()); + } + info!(LogContext::HotReload => "Hot-reload build completed; Took: {:.3}s", build_start.elapsed().as_secs_f64()); + } + + // --- 5. Detect which dylibs were (re)built --- + let mut runtime_hot_reload = false; + let mut project_hot_reload = false; + if let Some(paths) = file_watchers.dynamic_libraries_files_watcher.get_changes() { + let project_hot_name = dylib("project_hot_reloaded"); + let runtime_hot_name = dylib("pill_runtime_hot_reloaded"); + + for path in paths { + let filename = path.file_name().and_then(|value| value.to_str()); + if filename == Some(&runtime_hot_name) { + runtime_hot_reload = true; + } else if filename == Some(&project_hot_name) { + project_hot_reload = true; + } + } + } + + // In-process runtime is statically linked β€” it cannot be unloaded + // and reloaded at runtime. Skip with a warning rather than failing. + if runtime_hot_reload && runtime_load_mode == crate::paths::RuntimeLoadMode::InProcess { + warn!(LogContext::HotReload => "Runtime hot-reload; Skipped for in-process runtime"); + runtime_hot_reload = false; + } + + // --- 5a. Full runtime reload (engine code changed) --- + // + // When any engine crate (pill_core, pill_engine, pill_renderer) is + // rebuilt, we must tear down the entire runtime and load the new one + // from the freshly compiled dylib. + // + // Steps: + // 1. Drop the old runtime (destroys engine, renderer, wgpu stack). + // 2. Copy the new runtime dylib to a unique path so the OS loader + // does not lock the original build artifact. + // 3. If the project dylib was also rebuilt, copy it to a unique + // path as well; otherwise reuse the existing project dylib. + // 4. Load the new runtime dylib via libloading. + // 5. Create a fresh engine inside the new runtime, passing the + // project dylib path and window handle. + if runtime_hot_reload { + info!(LogContext::HotReload => "Runtime hot-reload; Reloading runtime..."); + + // 1. Destroy the old runtime. This drops the Engine, Renderer, + // wgpu Surface/Device/Queue, and unloads the old project dylib. + drop(runtime_host.take()); + + // 2. Copy the freshly built runtime dylib to a generation-unique + // path. This avoids Windows file-locking the compiler output + // and lets us keep the old dylib loaded until we're ready. + let loaded_runtime_path = runtime::next_loaded_runtime_dylib_path(project_paths); + fs::copy( + &project_paths.runtime_dynamic_library_hot_reloaded_path, + &loaded_runtime_path, + ) + .context("Failed to copy hot-reloaded runtime dylib to unique loaded path")?; + + // 3. Decide which project dylib to load into the new runtime. + // If the project was also rebuilt, use the hot-reloaded copy; + // otherwise keep using the original project dylib. + let project_path_for_create = if project_hot_reload { + let loaded_project_path = runtime::next_loaded_project_dylib_path(project_paths); + fs::copy( + &project_paths.project_dynamic_library_hot_reloaded_path, + &loaded_project_path, + ) + .context("Failed to copy hot-reloaded project dylib to unique loaded path")?; + loaded_project_path + } else { + project_paths.project_dynamic_library_path.clone() + }; + + // 4. Open the new runtime dylib and obtain its FFI vtable. + let mut new_runtime = RuntimeHost::load(&loaded_runtime_path, runtime_load_mode)?; + + // 5. Build the create-args (window pointer, paths, size) and + // initialise a fresh engine inside the new runtime. + let project_dylib_path = + CString::new(project_path_for_create.to_string_lossy().as_bytes())?; + let args = if let Some(ctx) = runtime_context { + ctx.make_args(&project_dylib_path, window_size) + } else { + // Headless: no window, build create-args from project_paths. + let resources_cstr = CString::new( + project_paths + .project_resources_directory_path + .to_string_lossy() + .as_bytes(), + )?; + let config_cstr = CString::new(project_paths.config_path.to_string_lossy().as_bytes())?; + pill_abi::PillEngineCreateArgsV1 { + struct_size: std::mem::size_of::() as u32, + window_ptr: std::ptr::null(), + project_dylib_path: project_dylib_path.as_ptr(), + project_resources_dir: resources_cstr.as_ptr(), + config_path: config_cstr.as_ptr(), + initial_w: 0, + initial_h: 0, + } + }; + new_runtime.create(&args)?; + + // Swap the old runtime (already dropped) with the new one. + *runtime_host = Some(new_runtime); + + info!(LogContext::HotReload => + "Hot-reload completed (runtime + project); Took: {:.3}s", + build_start.elapsed().as_secs_f64() + ); + } + // --- 5b. Project-only reload (game code changed, engine unchanged) --- + // + // When only the project crate is rebuilt, we can hot-swap the project + // dylib without tearing down the engine. This is much faster because + // the renderer, GPU resources, and all engine state survive. + // + // Steps: + // 1. Copy the new project dylib to a unique path. + // 2. Ask the runtime to call reload_project(), which: + // - Drops the old Box and unloads the old dylib. + // - Loads the new dylib and constructs a fresh PillProject. + // - Calls project.start() on the new project to reinitialise. + else if project_hot_reload { + info!(LogContext::HotReload => "Project hot-reload; Reloading project..."); + + // 1. Copy the rebuilt project dylib to avoid OS file locks. + let loaded_project_path = runtime::next_loaded_project_dylib_path(project_paths); + fs::copy( + &project_paths.project_dynamic_library_hot_reloaded_path, + &loaded_project_path, + ) + .context("Failed to copy hot-reloaded project dylib to unique loaded path")?; + + // 2. In-place project swap β€” the runtime unloads the old project + // dylib, loads the new one, and calls start() on it. + if let Some(runtime) = runtime_host.as_mut() { + runtime.reload_project(&loaded_project_path)?; + } else { + bail!("Engine not initialized"); + } + + info!(LogContext::HotReload => + "Hot-reload completed (project only); Took: {:.3}s", + build_start.elapsed().as_secs_f64() + ); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Cleanup +// --------------------------------------------------------------------------- + +/// Removes files whose names start with the given prefix from a directory. +/// Used during startup to clean up stale hot-reloaded libraries from +/// previous runs. Failures are logged but otherwise ignored. +pub(crate) fn try_remove_files_starting_with(directory_path: &Path, file_name_prefix: &str) { + if !directory_path.exists() || !directory_path.is_dir() { + return; + } + + let entries = match fs::read_dir(directory_path) { + Ok(entries) => entries, + Err(error) => { + warn!( + LogContext::HotReload => "Failed to read directory {} during cleanup: {}", + directory_path.display(), + error + ); + return; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + + // Only remove regular files with the expected prefix. + if !path.is_file() || !name.starts_with(file_name_prefix) { + continue; + } + + if let Err(error) = fs::remove_file(&path) { + warn!( + LogContext::HotReload => "Ignoring cleanup failure for {}: {}", + path.display(), + error + ); + } + } +} diff --git a/engine/pill_native/src/main.rs b/engine/pill_native/src/main.rs index 5fe59c0c..8330ca31 100644 --- a/engine/pill_native/src/main.rs +++ b/engine/pill_native/src/main.rs @@ -1,1465 +1,758 @@ -mod file_watcher; - -use crate::file_watcher::FileWatcher; -use anyhow::{bail, Context, Result}; -use config::Config; -use libloading::{Library, Symbol}; -use pill_abi::*; -use pill_core::{info, set_log_levels, warn, LogContext, PillStyle}; -use std::ffi::{c_void, CString, OsString}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::{ - atomic::{AtomicU64, Ordering}, - Arc, -}; -use std::time::{Duration, Instant}; -use winit::{ - application::ApplicationHandler, - event::{DeviceEvent, ElementState, MouseScrollDelta, WindowEvent}, - event_loop::{ActiveEventLoop, ControlFlow, EventLoop}, - window::{Fullscreen, Icon, Window, WindowAttributes}, -}; - -const RELOAD_COOLDOWN: Duration = Duration::from_millis(1000); -static RELOAD_GEN: AtomicU64 = AtomicU64::new(0); - -struct WindowInit { - attributes: WindowAttributes, - fullscreen: bool, -} - -struct FileWatchers { - engine_core_source_files_watcher: FileWatcher, - engine_engine_source_files_watcher: FileWatcher, - engine_renderer_source_files_watcher: FileWatcher, - dynamic_libraries_files_watcher: FileWatcher, - game_project_source_files_watcher: FileWatcher, - game_project_resources_files_watcher: FileWatcher, -} - -struct ProjectPaths { - build_data_directory_path: PathBuf, - engine_source_directory_path: Option, - game_project_directory_path: PathBuf, - game_resources_directory_path: PathBuf, - game_source_directory_path: PathBuf, - config_path: PathBuf, - runtime_dynamic_library_path: PathBuf, - runtime_dynamic_library_hot_reloaded_path: PathBuf, - game_dynamic_library_path: PathBuf, - game_dynamic_library_hot_reloaded_path: PathBuf, -} - -#[cfg(target_os = "windows")] -const DYLIB_PREFIX: &str = ""; -#[cfg(not(target_os = "windows"))] -const DYLIB_PREFIX: &str = "lib"; - -#[cfg(target_os = "windows")] -const DYLIB_SUFFIX: &str = ".dll"; -#[cfg(target_os = "linux")] -const DYLIB_SUFFIX: &str = ".so"; -#[cfg(target_os = "macos")] -const DYLIB_SUFFIX: &str = ".dylib"; - -fn dylib(name: &str) -> String { - format!("{DYLIB_PREFIX}{name}{DYLIB_SUFFIX}") -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum RuntimeLoadMode { - Dylib, - InProcess, -} - -fn parse_runtime_load_mode(value: Option) -> Option { - match value.as_deref() { - Some("dylib") => Some(RuntimeLoadMode::Dylib), - Some("in_process") => Some(RuntimeLoadMode::InProcess), - _ => None, - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RunLayout { - Development, - Packaged, -} - -fn game_project_exists(path: &Path) -> bool { - path.join("Cargo.toml").exists() - && path.join("res").join("config.ini").exists() - && path.join("src").exists() -} - -fn infer_game_project_directory(current_directory_path: &Path) -> Result { - if let Ok(value) = std::env::var("PILL_GAME_PROJECT_DIR") { - let path = PathBuf::from(value); - if game_project_exists(&path) { - return Ok(path); - } - bail!( - "PILL_GAME_PROJECT_DIR was set but {} is not a valid game project", - path.display() - ); - } - - current_directory_path - .parent() - .context("Build directory has no parent")? - .parent() - .context("Game project directory resolution failed") - .map(Path::to_path_buf) -} - -fn resolve_run_layout(game_project_directory_path: &Path) -> RunLayout { - match std::env::var("PILL_STANDALONE_LAYOUT").ok().as_deref() { - Some("development") => RunLayout::Development, - Some("packaged") => RunLayout::Packaged, - _ if game_project_exists(game_project_directory_path) => RunLayout::Development, - _ => RunLayout::Packaged, - } -} - -fn workspace_includes_game( - engine_source_directory_path: &Path, - game_project_directory_path: &Path, -) -> bool { - let cargo_toml_path = engine_source_directory_path.join("Cargo.toml"); - let Ok(contents) = fs::read_to_string(cargo_toml_path) else { - return false; - }; - let Some(game_dir_name) = game_project_directory_path - .file_name() - .and_then(|name| name.to_str()) - else { - return false; - }; - contents.contains(game_dir_name) -} - -fn looks_like_engine_workspace(path: &Path) -> bool { - path.join("pill_core").exists() - && path.join("pill_engine").exists() - && path.join("pill_renderer").exists() -} - -fn engine_workspace_from_game_manifest(game_project_directory_path: &Path) -> Option { - let manifest_path = game_project_directory_path.join("Cargo.toml"); - let contents = fs::read_to_string(manifest_path).ok()?; - - for line in contents.lines() { - let line = line.trim(); - if !line.starts_with("workspace") { - continue; - } - let (_, rhs) = line.split_once('=')?; - let rhs = rhs.trim().strip_prefix('"')?.strip_suffix('"')?; - let path = PathBuf::from(rhs); - if path.exists() { - return Some(path); - } - } - - None -} - -fn find_engine_source_directory( - current_directory_path: &Path, - game_project_directory_path: &Path, -) -> Option { - for ancestor in current_directory_path.ancestors() { - let engine_candidate = ancestor.join("engine"); - if looks_like_engine_workspace(&engine_candidate) - || engine_candidate - .join("pill_engine") - .join("Cargo.toml") - .exists() - { - return Some(engine_candidate); - } - - if looks_like_engine_workspace(ancestor) - || ancestor.join("pill_engine").join("Cargo.toml").exists() - { - return Some(ancestor.to_path_buf()); - } - } - - if let Some(parent) = game_project_directory_path.parent() { - if let Ok(entries) = fs::read_dir(parent) { - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - - let engine_candidate = path.join("engine"); - if looks_like_engine_workspace(&engine_candidate) - || engine_candidate - .join("pill_engine") - .join("Cargo.toml") - .exists() - { - return Some(engine_candidate); - } - - if looks_like_engine_workspace(&path) - || path.join("pill_engine").join("Cargo.toml").exists() - { - return Some(path); - } - } - } - } - - None -} - -fn resolve_engine_workspace_dir( - current_directory_path: &Path, - game_project_directory_path: &Path, - require_workspace_membership: bool, -) -> Result { - let by_manifest = engine_workspace_from_game_manifest(game_project_directory_path); - let by_env = std::env::var("PILL_ENGINE_WORKSPACE_DIR") - .ok() - .map(PathBuf::from); - let by_scan = find_engine_source_directory(current_directory_path, game_project_directory_path); - - for candidate in [by_manifest, by_env, by_scan].into_iter().flatten() { - if !looks_like_engine_workspace(&candidate) && !candidate.join("pill_engine").exists() { - continue; - } - - if require_workspace_membership - && !workspace_includes_game(&candidate, game_project_directory_path) - { - continue; - } - - return Ok(candidate); - } - - bail!( - "Engine workspace not detected. Set PILL_ENGINE_WORKSPACE_DIR to the engine directory{}.", - if require_workspace_membership { - " that includes the game workspace member" - } else { - "" - } - ) -} - -fn resolve_runtime_dylib_candidates( - build_data_directory_path: &Path, - engine_source_directory_path: Option<&Path>, - name: &str, -) -> Vec { - let mut candidates = vec![build_data_directory_path.join(dylib(name))]; - - if let Some(engine_source_directory_path) = engine_source_directory_path { - if let Some(engine_workspace_root) = engine_source_directory_path.parent() { - candidates.extend([ - engine_workspace_root - .join("target") - .join("debug") - .join(dylib(name)), - engine_workspace_root - .join("target") - .join("release") - .join(dylib(name)), - ]); - } - - candidates.extend([ - engine_source_directory_path - .join("target") - .join("debug") - .join(dylib(name)), - engine_source_directory_path - .join("target") - .join("release") - .join(dylib(name)), - ]); - } - - candidates -} - -fn resolve_runtime_dylib( - build_data_directory_path: &Path, - engine_source_directory_path: Option<&Path>, - name: &str, -) -> Result { - let candidates = resolve_runtime_dylib_candidates( - build_data_directory_path, - engine_source_directory_path, - name, - ); - - for candidate in &candidates { - if candidate.exists() { - return Ok(candidate.clone()); - } - } - - let candidates_display = candidates - .iter() - .map(|candidate| candidate.display().to_string()) - .collect::>() - .join(", "); - - bail!("Failed to find {name} runtime dylib. Checked: {candidates_display}") -} - -fn resolve_runtime_dylib_optional( - build_data_directory_path: &Path, - engine_source_directory_path: Option<&Path>, - name: &str, -) -> Option { - resolve_runtime_dylib_candidates( - build_data_directory_path, - engine_source_directory_path, - name, - ) - .into_iter() - .find(|candidate| candidate.exists()) -} - -fn next_loaded_runtime_dylib_path(project_paths: &ProjectPaths) -> PathBuf { - let generation = RELOAD_GEN.fetch_add(1, Ordering::Relaxed); - project_paths - .build_data_directory_path - .join(dylib(&format!("pill_runtime_loaded_{generation}"))) -} - -fn next_loaded_game_dylib_path(project_paths: &ProjectPaths) -> PathBuf { - let generation = RELOAD_GEN.fetch_add(1, Ordering::Relaxed); - project_paths - .build_data_directory_path - .join(dylib(&format!("pill_game_loaded_{generation}"))) -} - -fn encode_mouse_button(button: &winit::event::MouseButton) -> u32 { - use winit::event::MouseButton::*; - - match button { - Left => 0, - Right => 1, - Middle => 2, - Back => 3, - Forward => 4, - Other(n) => 5u32.saturating_add(*n as u32), - } -} - -struct RuntimeCreateContext { - game_resources_dir: CString, - config_path: CString, - window: Arc, -} - -impl RuntimeCreateContext { - fn make_args( - &self, - game_dylib_path: &CString, - window_size: winit::dpi::PhysicalSize, - ) -> PillEngineCreateArgsV1 { - // The runtime must reconstruct this with Arc::from_raw exactly once. - let window_raw = Arc::into_raw(Arc::clone(&self.window)) as *const c_void; - PillEngineCreateArgsV1 { - struct_size: std::mem::size_of::() as u32, - window_ptr: window_raw, - game_dylib_path: game_dylib_path.as_ptr(), - game_resources_dir: self.game_resources_dir.as_ptr(), - config_path: self.config_path.as_ptr(), - initial_w: window_size.width, - initial_h: window_size.height, - } - } -} - -struct RuntimeHost { - _lib: Option, - api: *const PillEngineApiV1, - handle: EngineHandle, -} - -impl RuntimeHost { - fn load(runtime_dylib_path: &Path, load_mode: RuntimeLoadMode) -> Result { - if load_mode == RuntimeLoadMode::InProcess { - let api = pill_runtime::get_pill_engine_api_v1(); - if api.is_null() { - bail!("pill_runtime get_pill_engine_api_v1 returned null"); - } - - let runtime_api = unsafe { &*api }; - if runtime_api.abi_version != PILL_ENGINE_ABI_VERSION { - bail!( - "Engine ABI version mismatch runtime {} host {}", - runtime_api.abi_version, - PILL_ENGINE_ABI_VERSION - ); - } - - return Ok(Self { - _lib: None, - api, - handle: std::ptr::null_mut(), - }); - } - - let lib = unsafe { Library::new(runtime_dylib_path) }.with_context(|| { - format!( - "Failed to load runtime dynamic library at {}", - runtime_dylib_path.display() - ) - })?; - - let get_api: Symbol *const PillEngineApiV1> = - unsafe { lib.get(PILL_ENGINE_API_SYMBOL) } - .context("Missing symbol get_pill_engine_api_v1")?; - - let api = unsafe { get_api() }; - if api.is_null() { - bail!("pill_engine get_pill_engine_api_v1 returned null"); - } - - let runtime_api = unsafe { &*api }; - if runtime_api.abi_version != PILL_ENGINE_ABI_VERSION { - bail!( - "Engine ABI version mismatch runtime {} host {}", - runtime_api.abi_version, - PILL_ENGINE_ABI_VERSION - ); - } - - Ok(Self { - _lib: Some(lib), - api, - handle: std::ptr::null_mut(), - }) - } - - fn create(&mut self, args: &PillEngineCreateArgsV1) -> Result<()> { - let runtime_api = unsafe { &*self.api }; - let ret = (runtime_api.create)(args as *const _, &mut self.handle as *mut _); - if ret != PILL_OK { - let error = unsafe { std::ffi::CStr::from_ptr((runtime_api.last_error_utf8)()) }; - bail!("engine create failed: {}", error.to_string_lossy()); - } - Ok(()) - } - - fn destroy(&mut self) { - if self.handle.is_null() { - return; - } - - let runtime_api = unsafe { &*self.api }; - (runtime_api.destroy)(self.handle); - self.handle = std::ptr::null_mut(); - } - - fn update(&mut self, dt: Duration) { - if self.handle.is_null() { - return; - } - - let runtime_api = unsafe { &*self.api }; - (runtime_api.update)(self.handle, dt.as_nanos() as u64); - } - - fn resize(&mut self, w: u32, h: u32) { - if self.handle.is_null() { - return; - } - - let runtime_api = unsafe { &*self.api }; - (runtime_api.resize)(self.handle, w, h); - } - - fn window_event(&mut self, window_event: &WindowEvent) { - if self.handle.is_null() { - return; - } - - let runtime_api = unsafe { &*self.api }; - (runtime_api.window_event)(self.handle, window_event as *const _ as *const c_void); - } - - fn key_event(&mut self, key_event: &winit::event::KeyEvent) { - if self.handle.is_null() { - return; - } - - let runtime_api = unsafe { &*self.api }; - (runtime_api.key_event)(self.handle, key_event as *const _ as *const c_void); - } - - fn mouse_button(&mut self, button: u32, pressed: bool) { - if self.handle.is_null() { - return; - } - - let runtime_api = unsafe { &*self.api }; - (runtime_api.mouse_button)(self.handle, button, pressed); - } - - fn mouse_delta(&mut self, dx: f64, dy: f64) { - if self.handle.is_null() { - return; - } - - let runtime_api = unsafe { &*self.api }; - (runtime_api.mouse_delta)(self.handle, dx, dy); - } - - fn cursor_position(&mut self, x: f64, y: f64) { - if self.handle.is_null() { - return; - } - - let runtime_api = unsafe { &*self.api }; - (runtime_api.cursor_position)(self.handle, x, y); - } - - fn mouse_wheel_line(&mut self, dx: f32, dy: f32) { - if self.handle.is_null() { - return; - } - - let runtime_api = unsafe { &*self.api }; - (runtime_api.mouse_wheel_line)(self.handle, dx, dy); - } - - fn reload_game(&mut self, game_dylib_path: &Path) -> Result<()> { - if self.handle.is_null() { - bail!("Engine not initialized"); - } - - let runtime_api = unsafe { &*self.api }; - let path = CString::new(game_dylib_path.to_string_lossy().as_bytes())?; - let ret = (runtime_api.reload_game)(self.handle, path.as_ptr()); - if ret != PILL_OK { - let error = unsafe { std::ffi::CStr::from_ptr((runtime_api.last_error_utf8)()) }; - bail!("engine reload_game failed: {}", error.to_string_lossy()); - } - Ok(()) - } -} - -impl Drop for RuntimeHost { - fn drop(&mut self) { - self.destroy(); - } -} - -fn configure_logging(config: &Config) { - let (log_level, using_default_log_levels) = match config.get_str("LOG_LEVELS") { - Ok(value) => (value, false), - Err(_) => (pill_core::get_default_log_levels(), true), - }; - - set_log_levels(&log_level, false); - - if using_default_log_levels { - warn!("Using default log levels: {}", log_level); - } - - // // Configure logging - // let log_level = config.get_str("LOG_LEVEL").unwrap_or("Info".to_string()); - // let log_level = match log_level.as_str() { - // "Info" => log::LevelFilter::Info, - // "Warning" => log::LevelFilter::Warn, - // "Debug" => log::LevelFilter::Debug, - // "Error" => log::LevelFilter::Error, - // "Off" => log::LevelFilter::Off, - // _ => log::LevelFilter::Info, - // }; - - // #[cfg(debug_assertions)] - // env_logger::Builder::new() - // .format(|buf, record| { - // writeln!(buf, "[{}] {} {}:{}: {}", - // record.level(), - // chrono::Local::now().format("%Y-%m-%dT%H:%M:%S"), - // record.file().unwrap_or("unknown"), - // record.line().unwrap_or(0), - // record.args() - // ) - // }) - // .filter_module("pill_core", log_level) - // .filter_module("pill_native", log_level) - // .filter_module("pill_engine", log_level) - // .filter_module("pill_renderer", log_level) - // .filter_module("pill_game", log_level) - // .init(); -} - -pub fn load_window_icon(path: &Path) -> Option { - let image = image::open(path).ok()?.into_rgba8(); - let (width, height) = image.dimensions(); - Icon::from_rgba(image.into_raw(), width, height).ok() -} - -fn make_window_init(config: &Config, game_resources_directory_path: &Path) -> WindowInit { - let window_title = config - .get_str("WINDOW_TITLE") - .or_else(|_| config.get_str("TITLE")) - .unwrap_or_else(|_| "Pill".to_owned()); - - let window_size = match ( - config.get_int("WINDOW_WIDTH"), - config.get_int("WINDOW_HEIGHT"), - ) { - (Ok(width), Ok(height)) => winit::dpi::PhysicalSize::new(width as u32, height as u32), - _ => winit::dpi::PhysicalSize::new(1280, 720), - }; - - let fullscreen = config.get_bool("WINDOW_FULLSCREEN").unwrap_or(false); - - let default_icon_bytes = include_bytes!("../res/icon.raw"); - let game_icon_path = game_resources_directory_path.join("icon.ico"); - let window_icon = load_window_icon(&game_icon_path) - .or_else(|| Icon::from_rgba(default_icon_bytes.to_vec(), 128, 128).ok()); - - let min_size = winit::dpi::PhysicalSize::new(100, 100); - let attributes = WindowAttributes::default() - .with_title(window_title) - .with_min_inner_size(min_size) - .with_inner_size(window_size) - .with_window_icon(window_icon) - .with_visible(false); - - WindowInit { - attributes, - fullscreen, - } -} - -fn resolve_launcher_command(engine_source_directory_path: &Path) -> Result { - if let Ok(value) = std::env::var("PILL_LAUNCHER_BIN") { - let path = PathBuf::from(value); - if !path.exists() { - bail!( - "PILL_LAUNCHER_BIN points to missing file: {}", - path.display() - ); - } - return Ok(path.into_os_string()); - } - - if let Ok(value) = std::env::var("PILL_LAUNCHER_CMD") { - return Ok(OsString::from(value)); - } - - let launcher_candidates = [ - engine_source_directory_path - .join("pill_launcher") - .join("target") - .join("debug") - .join("PillLauncher"), - engine_source_directory_path - .join("pill_launcher") - .join("target") - .join("release") - .join("PillLauncher"), - engine_source_directory_path - .join("pill_launcher") - .join("target") - .join("debug") - .join("PillLauncherUpstream"), - engine_source_directory_path - .join("pill_launcher") - .join("target") - .join("release") - .join("PillLauncherUpstream"), - ]; - - for candidate in launcher_candidates { - if candidate.exists() { - return Ok(candidate.into_os_string()); - } - } - - Ok(OsString::from("PillLauncherUpstream")) -} - -fn build_hot_reload_via_launcher(project_paths: &ProjectPaths) -> Result<()> { - let engine_source_directory_path = project_paths - .engine_source_directory_path - .as_ref() - .context("engine_source_directory_path missing for hot reload")?; - - let launcher_cmd = resolve_launcher_command(engine_source_directory_path)?; - let output_directory = project_paths - .build_data_directory_path - .parent() - .context("build_data_directory_path has no parent")?; - - let args = [ - "-a", - "build", - "-p", - project_paths.game_project_directory_path.to_str().unwrap(), - "-c", - "hot-reload", - "-o", - output_directory.to_str().unwrap(), - ]; - - let output = std::process::Command::new(&launcher_cmd) - .args(args) - .env("PILL_HOT_RELOAD_CHILD", "1") - .env("PILL_ENGINE_WORKSPACE_DIR", engine_source_directory_path) - .output(); - - let output = match output { - Ok(output) => output, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - let manifest = engine_source_directory_path - .join("pill_launcher") - .join("Cargo.toml"); - std::process::Command::new("cargo") - .args(["run", "--manifest-path", manifest.to_str().unwrap(), "--"]) - .args(args) - .env("PILL_HOT_RELOAD_CHILD", "1") - .env("PILL_ENGINE_WORKSPACE_DIR", engine_source_directory_path) - .output() - .context("Failed to invoke pill_launcher via cargo for hot reload")? - } - Err(error) => return Err(error).context("Failed to invoke pill_launcher for hot reload"), - }; - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - print!("{stdout}"); - eprint!("{stderr}"); - - if !output.status.success() { - std::env::set_var("PILL_HOT_RELOAD_STATUS", "fail"); - bail!("pill_launcher build hot-reload failed"); - } - - let has_warnings = stdout.contains("warning:") || stderr.contains("warning:"); - if has_warnings { - std::env::set_var("PILL_HOT_RELOAD_STATUS", "warn"); - } else { - std::env::set_var("PILL_HOT_RELOAD_STATUS", "pass"); - } - Ok(()) -} - -fn check_and_reload( - runtime_host: &mut Option, - runtime_context: &RuntimeCreateContext, - project_paths: &ProjectPaths, - last_reload_poll: &mut Instant, - window_size: winit::dpi::PhysicalSize, - file_watchers: &mut FileWatchers, - runtime_load_mode: RuntimeLoadMode, -) -> Result<()> { - let now = Instant::now(); - if now.duration_since(*last_reload_poll) < RELOAD_COOLDOWN { - return Ok(()); - } - *last_reload_poll = now; - - let mut engine_source_changed = Vec::::new(); - let mut game_source_changed = Vec::::new(); - let mut game_resources_changed = Vec::::new(); - - if let Some(paths) = file_watchers.engine_core_source_files_watcher.get_changes() { - info!(LogContext::HotReload => "Engine pill_core source file change detected: {:?}", paths); - engine_source_changed.extend(paths); - } - if let Some(paths) = file_watchers - .engine_engine_source_files_watcher - .get_changes() - { - info!(LogContext::HotReload => "Engine pill_engine source file change detected: {:?}", paths); - engine_source_changed.extend(paths); - } - if let Some(paths) = file_watchers - .engine_renderer_source_files_watcher - .get_changes() - { - info!(LogContext::HotReload => "Engine pill_renderer source file change detected: {:?}", paths); - engine_source_changed.extend(paths); - } - - if let Some(paths) = file_watchers - .game_project_resources_files_watcher - .get_changes() - { - info!(LogContext::HotReload => "Game project resources file change detected: {:?}", paths); - game_resources_changed.extend(paths); - } - if let Some(paths) = file_watchers - .game_project_source_files_watcher - .get_changes() - { - info!(LogContext::HotReload => "Game project source file change detected: {:?}", paths); - game_source_changed.extend(paths); - } - - if !game_resources_changed.is_empty() - && game_source_changed.is_empty() - && engine_source_changed.is_empty() - { - info!(LogContext::HotReload => "Game project resources changed; no code rebuild needed: {:?}", game_resources_changed); - return Ok(()); - } - - let build_start = Instant::now(); - if !game_source_changed.is_empty() || !engine_source_changed.is_empty() { - if let Err(error) = build_hot_reload_via_launcher(project_paths) { - warn!( - LogContext::HotReload => - "Hot-reload build failed; keeping currently loaded runtime/game. Error: {error:?}" - ); - - // drain dylib watcher changes to reduce stale/partial build triggers (fat or fast - // fingers) - let _ = file_watchers.dynamic_libraries_files_watcher.get_changes(); - - return Ok(()); - } - warn!("Build took: {:?} time", build_start.elapsed()); - } - - let mut runtime_hot_reload = false; - let mut game_hot_reload = false; - if let Some(paths) = file_watchers.dynamic_libraries_files_watcher.get_changes() { - let game_hot_name = dylib("pill_game_hot_reloaded"); - let runtime_hot_name = dylib("pill_runtime_hot_reloaded"); - - for path in paths { - let filename = path.file_name().and_then(|value| value.to_str()); - if filename == Some(&runtime_hot_name) { - runtime_hot_reload = true; - } else if filename == Some(&game_hot_name) { - game_hot_reload = true; - } - } - } - - if runtime_hot_reload && runtime_load_mode == RuntimeLoadMode::InProcess { - warn!(LogContext::HotReload => "Runtime hot-reload skipped for in-process runtime."); - runtime_hot_reload = false; - } - - if runtime_hot_reload { - info!(LogContext::HotReload => "Reloading runtime (engine hot-reload)..."); - let runtime_reload_start = Instant::now(); - - drop(runtime_host.take()); - - let loaded_runtime_path = next_loaded_runtime_dylib_path(project_paths); - fs::copy( - &project_paths.runtime_dynamic_library_hot_reloaded_path, - &loaded_runtime_path, - ) - .context("Failed to copy hot-reloaded runtime dylib to unique loaded path")?; - - let game_path_for_create = if game_hot_reload { - let loaded_game_path = next_loaded_game_dylib_path(project_paths); - fs::copy( - &project_paths.game_dynamic_library_hot_reloaded_path, - &loaded_game_path, - ) - .context("Failed to copy hot-reloaded game dylib to unique loaded path")?; - loaded_game_path - } else { - project_paths.game_dynamic_library_path.clone() - }; - - let mut new_runtime = RuntimeHost::load(&loaded_runtime_path, runtime_load_mode)?; - let game_dylib_path = CString::new(game_path_for_create.to_string_lossy().as_bytes())?; - let args = runtime_context.make_args(&game_dylib_path, window_size); - new_runtime.create(&args)?; - *runtime_host = Some(new_runtime); - - warn!( - "Runtime reload took: {:?} time", - runtime_reload_start.elapsed() - ); - warn!("Total reload took: {:?} time", build_start.elapsed()); - } else if game_hot_reload { - info!(LogContext::HotReload => "Reloading game project..."); - let game_reload_start = Instant::now(); - - let loaded_game_path = next_loaded_game_dylib_path(project_paths); - fs::copy( - &project_paths.game_dynamic_library_hot_reloaded_path, - &loaded_game_path, - ) - .context("Failed to copy hot-reloaded game dylib to unique loaded path")?; - - if let Some(runtime) = runtime_host.as_mut() { - runtime.reload_game(&loaded_game_path)?; - } else { - bail!("Engine not initialized"); - } - - warn!( - "Game hot-reload took: {:?} time", - game_reload_start.elapsed() - ); - warn!("Total reload took: {:?} time", build_start.elapsed()); - } - - Ok(()) -} - -fn create_file_watchers(project_paths: &ProjectPaths) -> FileWatchers { - let engine_workspace_directory_path = project_paths - .engine_source_directory_path - .as_ref() - .expect("engine_source_directory_path missing for hot reload"); - - let core_source_path = engine_workspace_directory_path.join("pill_core/src"); - let engine_core_source_files_watcher = FileWatcher::new(core_source_path).set_recursive(true); - - let engine_source_path = engine_workspace_directory_path.join("pill_engine/src"); - let engine_engine_source_files_watcher = - FileWatcher::new(engine_source_path).set_recursive(true); - - let renderer_source_path = engine_workspace_directory_path.join("pill_renderer/src"); - let engine_renderer_source_files_watcher = - FileWatcher::new(renderer_source_path).set_recursive(true); - - let dynamic_libraries_files_watcher = - FileWatcher::new(project_paths.build_data_directory_path.clone()); - let game_project_source_files_watcher = - FileWatcher::new(project_paths.game_source_directory_path.clone()).set_recursive(true); - let game_project_resources_files_watcher = - FileWatcher::new(project_paths.game_resources_directory_path.clone()).set_recursive(true); - - FileWatchers { - engine_core_source_files_watcher, - engine_engine_source_files_watcher, - engine_renderer_source_files_watcher, - dynamic_libraries_files_watcher, - game_project_source_files_watcher, - game_project_resources_files_watcher, - } -} - -fn try_remove_files_starting_with(directory_path: &Path, file_name_prefix: &str) { - if !directory_path.exists() || !directory_path.is_dir() { - return; - } - - let entries = match fs::read_dir(directory_path) { - Ok(entries) => entries, - Err(error) => { - warn!( - LogContext::HotReload => "Failed to read directory {} during cleanup: {}", - directory_path.display(), - error - ); - return; - } - }; - - for entry in entries.flatten() { - let path = entry.path(); - let Some(name) = path.file_name().and_then(|value| value.to_str()) else { - continue; - }; - - if !path.is_file() || !name.starts_with(file_name_prefix) { - continue; - } - - if let Err(error) = fs::remove_file(&path) { - warn!( - LogContext::HotReload => "Ignoring cleanup failure for {}: {}", - path.display(), - error - ); - } - } -} - -struct App { - project_paths: ProjectPaths, - hot_reload_enabled: bool, - runtime_load_mode: RuntimeLoadMode, - window_init: Option, - - window: Option>, - window_size: winit::dpi::PhysicalSize, - runtime_host: Option, - runtime_context: Option, - file_watchers: Option, - last_render_time: Instant, - last_reload_poll: Instant, -} - -impl App { - fn new( - project_paths: ProjectPaths, - hot_reload_enabled: bool, - runtime_load_mode: RuntimeLoadMode, - window_init: WindowInit, - ) -> Self { - Self { - project_paths, - hot_reload_enabled, - runtime_load_mode, - window_init: Some(window_init), - window: None, - window_size: winit::dpi::PhysicalSize::new(0, 0), - runtime_host: None, - runtime_context: None, - file_watchers: None, - last_render_time: Instant::now(), - last_reload_poll: Instant::now(), - } - } -} - -impl ApplicationHandler for App { - fn resumed(&mut self, event_loop: &ActiveEventLoop) { - if self.window.is_some() { - return; - } - - let init = self.window_init.take().expect("WindowInit missing"); - let window = Arc::new( - event_loop - .create_window(init.attributes) - .expect("Failed to create window"), - ); - - if init.fullscreen { - let monitor_handle = window.current_monitor(); - window.set_fullscreen(Some(Fullscreen::Borderless(monitor_handle))); - } - - self.window_size = window.inner_size(); - - self.file_watchers = if self.hot_reload_enabled { - Some(create_file_watchers(&self.project_paths)) - } else { - None - }; - - let mut runtime_host = RuntimeHost::load( - &self.project_paths.runtime_dynamic_library_path, - self.runtime_load_mode, - ) - .expect("Failed to load runtime"); - - let runtime_context = RuntimeCreateContext { - game_resources_dir: CString::new( - self.project_paths - .game_resources_directory_path - .to_string_lossy() - .as_bytes(), - ) - .expect("Failed to create game resources path CString"), - config_path: CString::new(self.project_paths.config_path.to_string_lossy().as_bytes()) - .expect("Failed to create config path CString"), - window: Arc::clone(&window), - }; - - let game_dylib_path = CString::new( - self.project_paths - .game_dynamic_library_path - .to_string_lossy() - .as_bytes(), - ) - .expect("Failed to create game dylib path CString"); - - let args = runtime_context.make_args(&game_dylib_path, self.window_size); - runtime_host - .create(&args) - .expect("RuntimeHost.create failed"); - - window.set_visible(true); - - self.runtime_context = Some(runtime_context); - self.runtime_host = Some(runtime_host); - self.window = Some(window); - } - - fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { - if let Some(window) = &self.window { - window.request_redraw(); - } - } - - fn device_event( - &mut self, - _event_loop: &ActiveEventLoop, - _device_id: winit::event::DeviceId, - event: DeviceEvent, - ) { - if let DeviceEvent::MouseMotion { delta } = event { - if let Some(runtime_host) = self.runtime_host.as_mut() { - runtime_host.mouse_delta(delta.0, delta.1); - } - } - } - - fn window_event( - &mut self, - event_loop: &ActiveEventLoop, - window_id: winit::window::WindowId, - event: WindowEvent, - ) { - let Some(window) = &self.window else { - return; - }; - if window_id != window.id() { - return; - } - - if let Some(runtime_host) = self.runtime_host.as_mut() { - runtime_host.window_event(&event); - } - - match event { - WindowEvent::RedrawRequested => { - let now = Instant::now(); - let delta = now - self.last_render_time; - self.last_render_time = now; - - if let Some(runtime_host) = self.runtime_host.as_mut() { - runtime_host.update(delta); - } - - if self.hot_reload_enabled { - if let (Some(runtime_context), Some(file_watchers)) = - (self.runtime_context.as_ref(), self.file_watchers.as_mut()) - { - check_and_reload( - &mut self.runtime_host, - runtime_context, - &self.project_paths, - &mut self.last_reload_poll, - self.window_size, - file_watchers, - self.runtime_load_mode, - ) - .unwrap(); - } - } - } - WindowEvent::KeyboardInput { event, .. } => { - if let Some(runtime_host) = self.runtime_host.as_mut() { - runtime_host.key_event(&event); - } - } - WindowEvent::MouseInput { button, state, .. } => { - if let Some(runtime_host) = self.runtime_host.as_mut() { - runtime_host - .mouse_button(encode_mouse_button(&button), state == ElementState::Pressed); - } - } - WindowEvent::MouseWheel { delta, .. } => { - if let Some(runtime_host) = self.runtime_host.as_mut() { - if let MouseScrollDelta::LineDelta(dx, dy) = delta { - runtime_host.mouse_wheel_line(dx, dy); - } - } - } - WindowEvent::CursorMoved { position, .. } => { - if let Some(runtime_host) = self.runtime_host.as_mut() { - runtime_host.cursor_position(position.x, position.y); - } - } - WindowEvent::Resized(size) => { - self.window_size = size; - if let Some(runtime_host) = self.runtime_host.as_mut() { - runtime_host.resize(size.width, size.height); - } - } - WindowEvent::CloseRequested => { - drop(self.runtime_host.take()); - event_loop.exit(); - } - _ => {} - } - } -} - -fn run_app() -> Result<()> { - // In the development build, standalone will look for the resource files in the "res" directory of the game project directory - // In the release build, "res" directory is copied to /build/release/data/res (TODO: pack all resources use by game into a single data file) - - // / - // β”œβ”€β”€ /build - // β”‚ β”œβ”€β”€ /dev - // β”‚ β”‚ β”œβ”€β”€ pill_native.exe - // β”‚ β”‚ └── /data - // β”‚ β”‚ β”œβ”€β”€ pill_game.dll - // β”‚ β”‚ └── pill_game_hot_reload.dll - // β”‚ └── /release - // β”‚ β”œβ”€β”€ pill_native.exe - // β”‚ └── /data - // β”‚ β”œβ”€β”€ /res - // β”‚ β”œβ”€β”€ pill_game.dll - // β”‚ └── pill_game_hot_reload.dll - // β”œβ”€β”€ /src - // β”œβ”€β”€ /res - // β”‚ β”œβ”€β”€ icon.raw - // β”‚ β”œβ”€β”€ icon.ico - // β”‚ └── config.ini - // β”œβ”€β”€ Cargo.toml - // └── Cargo.lock - - let mut hot_reload_enabled = - std::env::var("PILL_COMPILE_MODE").ok().as_deref() == Some("hot-reload"); - - let current_directory_path = std::env::current_exe() - .context("Failed to get current executable path")? - .parent() - .context("Executable has no parent directory")? - .to_path_buf(); - - let game_project_directory_path = infer_game_project_directory(¤t_directory_path)?; - let run_layout = resolve_run_layout(&game_project_directory_path); - - if hot_reload_enabled && run_layout != RunLayout::Development { - bail!("Hot reload requires development layout paths"); - } - - let build_data_directory_path = current_directory_path.join("data"); - let project_resources_directory_path = game_project_directory_path.join("res"); - let build_resources_directory_path = build_data_directory_path.join("res"); - - let game_resources_directory_path = match run_layout { - RunLayout::Development => project_resources_directory_path, - RunLayout::Packaged if build_resources_directory_path.exists() => { - build_resources_directory_path - } - RunLayout::Packaged => project_resources_directory_path, - }; - - let game_source_directory_path = game_project_directory_path.join("src"); - let config_path = game_resources_directory_path.join("config.ini"); - - let in_process = std::env::var("PILL_RUNTIME_IN_PROCESS").ok().as_deref() == Some("1"); - - let runtime_load_mode = parse_runtime_load_mode(std::env::var("PILL_RUNTIME_MODE").ok()) - .or(in_process.then_some(RuntimeLoadMode::InProcess)) - .unwrap_or(if cfg!(target_os = "macos") { - RuntimeLoadMode::InProcess - } else { - RuntimeLoadMode::Dylib - }); - - let engine_source_directory_path = if hot_reload_enabled { - Some(resolve_engine_workspace_dir( - ¤t_directory_path, - &game_project_directory_path, - true, - )?) - } else if runtime_load_mode == RuntimeLoadMode::Dylib { - resolve_engine_workspace_dir(¤t_directory_path, &game_project_directory_path, false) - .ok() - } else { - None - }; - - let runtime_dynamic_library_path = if runtime_load_mode == RuntimeLoadMode::Dylib { - resolve_runtime_dylib( - &build_data_directory_path, - engine_source_directory_path.as_deref(), - "pill_runtime", - )? - } else { - build_data_directory_path.join(dylib("pill_runtime")) - }; - - let runtime_dynamic_library_hot_reloaded_path = - if hot_reload_enabled && runtime_load_mode == RuntimeLoadMode::Dylib { - resolve_runtime_dylib_optional( - &build_data_directory_path, - engine_source_directory_path.as_deref(), - "pill_runtime_hot_reloaded", - ) - .unwrap_or_else(|| { - hot_reload_enabled = false; - runtime_dynamic_library_path.clone() - }) - } else { - runtime_dynamic_library_path.clone() - }; - - let game_dynamic_library_path = build_data_directory_path.join(dylib("pill_game")); - let game_dynamic_library_hot_reloaded_path = - build_data_directory_path.join(dylib("pill_game_hot_reloaded")); - - let project_paths = ProjectPaths { - build_data_directory_path, - engine_source_directory_path, - game_project_directory_path, - game_resources_directory_path, - game_source_directory_path, - config_path, - runtime_dynamic_library_path, - runtime_dynamic_library_hot_reloaded_path, - game_dynamic_library_path, - game_dynamic_library_hot_reloaded_path, - }; - - if hot_reload_enabled { - try_remove_files_starting_with( - &project_paths.build_data_directory_path, - &format!("{DYLIB_PREFIX}pill_runtime_loaded"), - ); - try_remove_files_starting_with( - &project_paths.build_data_directory_path, - &format!("{DYLIB_PREFIX}pill_game_loaded"), - ); - } - - let mut config = Config::default(); - config - .merge(config::File::with_name( - project_paths.config_path.to_str().unwrap(), - )) - .with_context(|| { - format!( - "Failed to load config from {}", - project_paths.config_path.display() - ) - })?; - - configure_logging(&config); - - info!( - LogContext::HotReload => "Hot reload {} (watching src: {}, res: {})", - if hot_reload_enabled { "enabled" } else { "disabled" }, - project_paths.game_source_directory_path.display(), - project_paths.game_resources_directory_path.display() - ); - info!( - "Initializing {} ({:?} layout, {:?} runtime)", - "Standalone".module_object_style(), - run_layout, - runtime_load_mode - ); - - let window_init = make_window_init(&config, &project_paths.game_resources_directory_path); - - let event_loop = EventLoop::new().context("Failed to create event loop")?; - event_loop.set_control_flow(ControlFlow::Poll); - - let mut app = App::new( - project_paths, - hot_reload_enabled, - runtime_load_mode, - window_init, - ); - event_loop.run_app(&mut app).context("run_app failed")?; - - Ok(()) -} - -fn main() { - if let Err(error) = run_app() { - eprintln!("Error: {error:#}"); - std::process::exit(1); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn unique_tmp_dir(name: &str) -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - std::env::temp_dir().join(format!("pill_test_{name}_{nanos}")) - } - - #[test] - fn prefers_game_manifest_workspace_over_env_and_sibling_scan() { - let root = unique_tmp_dir("hot_reload_workspace_pick"); - let _ = fs::remove_dir_all(&root); - - let game_dir = root.join("my_game"); - fs::create_dir_all(game_dir.join("src")).unwrap(); - fs::create_dir_all(game_dir.join("res")).unwrap(); - - let engine_a = root.join("Pill-Engine").join("engine"); - let engine_b = root.join("Pill-Engine-Upstream").join("engine"); - fs::create_dir_all(engine_a.join("pill_core")).unwrap(); - fs::create_dir_all(engine_a.join("pill_engine")).unwrap(); - fs::create_dir_all(engine_a.join("pill_renderer")).unwrap(); - fs::create_dir_all(engine_b.join("pill_core")).unwrap(); - fs::create_dir_all(engine_b.join("pill_engine")).unwrap(); - fs::create_dir_all(engine_b.join("pill_renderer")).unwrap(); - - fs::write( - engine_a.join("Cargo.toml"), - r#"[workspace] -members = ["my_game"] -"#, - ) - .unwrap(); - fs::write( - engine_b.join("Cargo.toml"), - r#"[workspace] -members = ["my_game"] -"#, - ) - .unwrap(); - - fs::write( - game_dir.join("Cargo.toml"), - format!( - r#"[package] -name = "my_game" -version = "0.1.0" -edition = "2021" -workspace = "{}" -"#, - engine_b.display() - ), - ) - .unwrap(); - - std::env::set_var("PILL_ENGINE_WORKSPACE_DIR", &engine_a); - let resolved = resolve_engine_workspace_dir(&game_dir, &game_dir, true).unwrap(); - assert_eq!(resolved, engine_b); - - let _ = fs::remove_dir_all(root); - } - - #[test] - fn resolve_launcher_prefers_engine_pill_launcher_target_binary() { - let root = unique_tmp_dir("hot_reload_launcher_pick"); - let _ = fs::remove_dir_all(&root); - - let engine_dir = root.join("engine"); - let launcher_bin = engine_dir - .join("pill_launcher") - .join("target") - .join("debug") - .join("PillLauncher"); - fs::create_dir_all(launcher_bin.parent().unwrap()).unwrap(); - fs::write(&launcher_bin, b"").unwrap(); - - std::env::remove_var("PILL_LAUNCHER_BIN"); - std::env::remove_var("PILL_LAUNCHER_CMD"); - - let resolved = resolve_launcher_command(&engine_dir).unwrap(); - assert_eq!(PathBuf::from(resolved), launcher_bin); - - let _ = fs::remove_dir_all(root); - } -} +//! This file is the native standalone runner entry point for Pill projects. +//! +//! On startup it detects the project and engine workspace directories, resolves +//! paths to runtime and project dynamic libraries, loads configuration, creates +//! a winit window, and hands control to the event loop for rendering and input. +//! +//! At compile time, the `headless` feature gates between two runners: +//! - Windowed: winit event loop with full rendering, input, and hot-reload +//! - Headless: bare update loop with no window (benchmarks, CI) + +mod file_watcher; // Low-level filesystem polling primitive +mod headless; +mod hot_reload; +mod paths; +mod runtime; + +use crate::hot_reload::{check_and_reload, create_file_watchers, try_remove_files_starting_with}; +use crate::paths::*; +use crate::runtime::{RuntimeCreateContext, RuntimeHost}; +use anyhow::{bail, Context, Result}; +use config::Config; +use pill_core::{info, set_log_levels, LogContext, PillStyle}; +use std::ffi::CString; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use winit::{ + application::ApplicationHandler, + event::{DeviceEvent, ElementState, MouseScrollDelta, WindowEvent}, + event_loop::{ActiveEventLoop, ControlFlow, EventLoop}, + window::{Fullscreen, Icon, Window, WindowAttributes}, +}; + +// --------------------------------------------------------------------------- +// Input Encoding +// --------------------------------------------------------------------------- + +/// Maps winit's MouseButton enum to a stable u32 for the FFI boundary. +fn encode_mouse_button(button: &winit::event::MouseButton) -> u32 { + use winit::event::MouseButton::*; + + match button { + Left => 0, + Right => 1, + Middle => 2, + Back => 3, + Forward => 4, + Other(n) => 5u32.saturating_add(*n as u32), + } +} + +// --------------------------------------------------------------------------- +// Configuration & Window Setup +// --------------------------------------------------------------------------- + +/// Reads the LOG_LEVELS key from the project's config.ini and applies +/// it to the pill_core logger. Falls back to built-in defaults when +/// the key is missing. +fn configure_logging(config: &Config) { + let (log_level, using_default_log_levels) = match config.get_str("LOG_LEVELS") { + Ok(value) => (value, false), + Err(_) => (pill_core::get_default_log_levels(), true), + }; + + set_log_levels(&log_level, false); + + if using_default_log_levels { + info!("Using default log levels: {}", log_level); + } +} + +/// Loads an icon from disk and converts it to a winit Icon. +/// Returns None if the file is missing or cannot be decoded. +pub fn load_window_icon(path: &Path) -> Option { + let image = image::open(path).ok()?.into_rgba8(); + let (width, height) = image.dimensions(); + Icon::from_rgba(image.into_raw(), width, height).ok() +} + +/// Builds the WindowInit descriptor from the project configuration. +/// Sets title, size, fullscreen mode, and attempts to load a custom +/// window icon (falls back to the embedded default icon). +fn make_window_init(config: &Config, project_resources_directory_path: &Path) -> WindowInit { + let window_title = config + .get_str("WINDOW_TITLE") + .or_else(|_| config.get_str("TITLE")) + .unwrap_or_else(|_| "Pill".to_owned()); + + let window_size = match ( + config.get_int("WINDOW_WIDTH"), + config.get_int("WINDOW_HEIGHT"), + ) { + (Ok(width), Ok(height)) => winit::dpi::PhysicalSize::new(width as u32, height as u32), + _ => winit::dpi::PhysicalSize::new(1280, 720), + }; + + let fullscreen = config.get_bool("WINDOW_FULLSCREEN").unwrap_or(false); + + let default_icon_bytes = include_bytes!("../res/icon.raw"); + let project_icon_path = project_resources_directory_path.join("icon.ico"); + let window_icon = load_window_icon(&project_icon_path) + .or_else(|| Icon::from_rgba(default_icon_bytes.to_vec(), 128, 128).ok()); + + let minimum_window_size = winit::dpi::PhysicalSize::new(100, 100); + let attributes = WindowAttributes::default() + .with_title(window_title) + .with_min_inner_size(minimum_window_size) + .with_inner_size(window_size) + .with_window_icon(window_icon) + .with_visible(false); + + WindowInit { + attributes, + fullscreen, + } +} + +// --------------------------------------------------------------------------- +// Data Structures +// --------------------------------------------------------------------------- + +/// Holds the winit window attributes and desired fullscreen state +/// before the window is actually created. +struct WindowInit { + attributes: WindowAttributes, + fullscreen: bool, +} + +/// Top-level application state. +/// Owns the window, the runtime host, file watchers, and all path +/// configuration. Implements winit's ApplicationHandler so it can +/// respond to window and device events. +struct App { + project_paths: ProjectPaths, + hot_reload_enabled: bool, + runtime_load_mode: RuntimeLoadMode, + window_init: Option, + + window: Option>, + window_size: winit::dpi::PhysicalSize, + runtime_host: Option, + runtime_context: Option, + file_watchers: Option, + last_render_time: Instant, + last_reload_poll: Instant, +} + +// --------------------------------------------------------------------------- +// Application - App +// --------------------------------------------------------------------------- + +impl App { + /// Constructs the App in a pre-initialised state. + /// The window and runtime are created lazily when the event loop + /// calls `resumed` for the first time. This avoids GPU work before + /// the event loop is ready to drive rendering. + fn new( + project_paths: ProjectPaths, + hot_reload_enabled: bool, + runtime_load_mode: RuntimeLoadMode, + window_init: WindowInit, + ) -> Self { + Self { + project_paths, + hot_reload_enabled, + runtime_load_mode, + window_init: Some(window_init), + window: None, + window_size: winit::dpi::PhysicalSize::new(0, 0), + runtime_host: None, + runtime_context: None, + file_watchers: None, + last_render_time: Instant::now(), + last_reload_poll: Instant::now(), + } + } +} + +// --------------------------------------------------------------------------- +// Application - ApplicationHandler +// --------------------------------------------------------------------------- + +impl ApplicationHandler for App { + /// Called once when the event loop is ready. + /// Creates the window, loads the runtime, initialises the engine, + /// and sets up file watchers if hot-reload is enabled. + /// + /// This is the real "main" of the windowed app β€” everything before + /// this point is path resolution and configuration loading. + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + // Guard against multiple resumes β€” winit may call this more + // than once on some platforms (e.g. after a suspend/resume). + if self.window.is_some() { + return; + } + + // 1. Create the native window from the prepared attributes. + // The window starts hidden to avoid a white flash before + // the renderer draws the first frame. + let init = self.window_init.take().expect("WindowInit missing"); + let window = Arc::new( + event_loop + .create_window(init.attributes) + .expect("Failed to create window"), + ); + + // 2. Apply borderless fullscreen on the primary monitor if + // requested in the project configuration. + if init.fullscreen { + let monitor_handle = window.current_monitor(); + window.set_fullscreen(Some(Fullscreen::Borderless(monitor_handle))); + } + + // Store the initial window size so the renderer can create + // a swapchain matching the actual pixel dimensions. + self.window_size = window.inner_size(); + + // 3. Set up file watchers if hot-reload is active. These + // monitor engine crate sources, project sources, resources, + // and the output dylib directory for changes. + self.file_watchers = if self.hot_reload_enabled { + Some(create_file_watchers(&self.project_paths)) + } else { + None + }; + + // 4. Load the runtime dynamic library. On Windows this opens + // pill_runtime.dll via libloading and fetches the FFI vtable + // so we can call into the runtime without linking against it. + let mut runtime_host = RuntimeHost::load( + &self.project_paths.runtime_dynamic_library_path, + self.runtime_load_mode, + ) + .expect("Failed to load runtime"); + + // 5. Build the context object that holds everything the runtime + // needs to bootstrap the engine: + // - Paths to project resources and config.ini (as CStrings + // for the FFI boundary). + // - A clone of the window Arc so the runtime can create a + // wgpu Surface from the window handle. + let runtime_context = RuntimeCreateContext { + project_resources_dir: CString::new( + self.project_paths + .project_resources_directory_path + .to_string_lossy() + .as_bytes(), + ) + .expect("Failed to create pill project resources path CString"), + config_path: CString::new(self.project_paths.config_path.to_string_lossy().as_bytes()) + .expect("Failed to create config path CString"), + window: Arc::clone(&window), + }; + + // Convert the project dylib path to a CString for the FFI call. + let project_dylib_path = CString::new( + self.project_paths + .project_dynamic_library_path + .to_string_lossy() + .as_bytes(), + ) + .expect("Failed to create pill project dylib path CString"); + + // 6. Call into the runtime to create the engine. This: + // - Loads the project dylib and calls PillProject::start(). + // - Creates the wgpu Instance, Adapter, Device, and Surface. + // - Initialises the renderer, ECS, and resource manager. + // - The window is passed via Arc::into_raw / Arc::from_raw + // so ownership is shared across the FFI boundary. + let args = runtime_context.make_args(&project_dylib_path, self.window_size); + runtime_host + .create(&args) + .expect("RuntimeHost.create failed"); + + // 7. Make the window visible now that the engine and renderer + // are fully initialised β€” the very next RedrawRequested will + // produce a complete frame instead of a blank surface. + window.set_visible(true); + + // Store everything in the App state so the event loop can + // access them during subsequent callbacks. + self.runtime_context = Some(runtime_context); + self.runtime_host = Some(runtime_host); + self.window = Some(window); + } + + // Called when the event loop is about to wait for new events. + // We use a Poll control flow, so this requests a redraw on every + // iteration to drive continuous rendering. + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + if let Some(window) = &self.window { + window.request_redraw(); + } + } + + // Handles raw device events (currently only mouse motion). + fn device_event( + &mut self, + _event_loop: &ActiveEventLoop, + _device_id: winit::event::DeviceId, + event: DeviceEvent, + ) { + if let DeviceEvent::MouseMotion { delta } = event { + if let Some(runtime_host) = self.runtime_host.as_mut() { + runtime_host.mouse_delta(delta.0, delta.1); + } + } + } + + /// Main window event handler. + /// Dispatches every event to the runtime for input processing, + /// then handles engine updates, rendering, hot-reload polling, + /// and shutdown. + /// + /// RedrawRequested is the core frame driver β€” it computes delta time, + /// advances the engine, checks exit conditions, and polls hot-reload. + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + window_id: winit::window::WindowId, + event: WindowEvent, + ) { + let Some(window) = &self.window else { + return; + }; + // Ignore events for other windows (shouldn't happen, but safe). + if window_id != window.id() { + return; + } + + // Forward every event to the engine for egui/input handling. + if let Some(runtime_host) = self.runtime_host.as_mut() { + runtime_host.window_event(&event); + } + + match event { + WindowEvent::RedrawRequested => { + let now = Instant::now(); + let delta = now - self.last_render_time; + self.last_render_time = now; + + // Advance the engine by one frame. + if let Some(runtime_host) = self.runtime_host.as_mut() { + runtime_host.update(delta); + + // Check if the engine has requested graceful exit + // (e.g. benchmark finished after N frames). + if runtime_host.should_exit() { + event_loop.exit(); + return; + } + } + + // Poll for hot-reload changes after each frame. + if self.hot_reload_enabled { + if let (Some(runtime_context), Some(file_watchers)) = + (self.runtime_context.as_ref(), self.file_watchers.as_mut()) + { + check_and_reload( + &mut self.runtime_host, + Some(runtime_context), + &self.project_paths, + &mut self.last_reload_poll, + self.window_size, + file_watchers, + self.runtime_load_mode, + ) + .unwrap(); + } + } + } + WindowEvent::KeyboardInput { event, .. } => { + if let Some(runtime_host) = self.runtime_host.as_mut() { + runtime_host.key_event(&event); + } + } + WindowEvent::MouseInput { button, state, .. } => { + if let Some(runtime_host) = self.runtime_host.as_mut() { + runtime_host + .mouse_button(encode_mouse_button(&button), state == ElementState::Pressed); + } + } + WindowEvent::MouseWheel { delta, .. } => { + if let Some(runtime_host) = self.runtime_host.as_mut() { + if let MouseScrollDelta::LineDelta(delta_x, delta_y) = delta { + runtime_host.mouse_wheel_line(delta_x, delta_y); + } + } + } + WindowEvent::CursorMoved { position, .. } => { + if let Some(runtime_host) = self.runtime_host.as_mut() { + runtime_host.cursor_position(position.x, position.y); + } + } + WindowEvent::Resized(size) => { + self.window_size = size; + if let Some(runtime_host) = self.runtime_host.as_mut() { + runtime_host.resize(size.width, size.height); + } + } + // Let the event loop exit cleanly. + // The runtime/engine/renderer/wgpu stack is torn down later + // when `App` is dropped, after the event loop has stopped. + WindowEvent::CloseRequested => { + event_loop.exit(); + } + _ => {} + } + } +} + +// --------------------------------------------------------------------------- +// Entry Point +// --------------------------------------------------------------------------- + +/// Main application entry point. +/// +/// Responsibilities: +/// - Detect the project and engine workspace directories. +/// - Resolve paths to runtime and project dynamic libraries. +/// - Load configuration, set up logging, and create the winit window. +/// - Initialise the App and hand control to the winit event loop. +fn run_app() -> Result<()> { + // --- 1. Detect project directory, run layout, and hot-reload mode --- + // In the development build, standalone will look for the resource files in the "res" directory of the pill project directory + // In the release build, "res" directory is copied to /build/release/data/res (TODO: pack all resources use by pill project into a single data file) + + // / + // β”œβ”€β”€ /build + // β”‚ β”œβ”€β”€ /dev + // β”‚ β”‚ β”œβ”€β”€ pill_native.exe + // β”‚ β”‚ └── /data + // β”‚ β”‚ β”œβ”€β”€ pill_project.dll + // β”‚ β”‚ └── pill_project_hot_reload.dll + // β”‚ └── /release + // β”‚ β”œβ”€β”€ pill_native.exe + // β”‚ └── /data + // β”‚ β”œβ”€β”€ /res + // β”‚ β”œβ”€β”€ pill_project.dll + // β”‚ └── pill_project_hot_reload.dll + // β”œβ”€β”€ /src + // β”œβ”€β”€ /res + // β”‚ β”œβ”€β”€ icon.raw + // β”‚ β”œβ”€β”€ icon.ico + // β”‚ └── config.ini + // β”œβ”€β”€ Cargo.toml + // └── Cargo.lock + + let mut hot_reload_enabled = + std::env::var("PILL_COMPILE_MODE").ok().as_deref() == Some("hot-reload"); + + // Determine the project directory relative to the executable. + let current_directory_path = std::env::current_exe() + .context("Failed to get current executable path")? + .parent() + .context("Executable has no parent directory")? + .to_path_buf(); + + let project_directory_path = infer_project_directory(¤t_directory_path)?; + let run_layout = resolve_run_layout(&project_directory_path); + + if hot_reload_enabled && run_layout != RunLayout::Development { + bail!("Hot reload requires development layout paths"); + } + + // --- 2. Build all path information (build dir, resources, config) --- + // Build all the path information the app will need. + let build_data_directory_path = current_directory_path.join("data"); + let project_resources_directory_path = project_directory_path.join("res"); + let build_resources_directory_path = build_data_directory_path.join("res"); + + let project_resources_directory_path = match run_layout { + RunLayout::Development => project_resources_directory_path, + RunLayout::Packaged if build_resources_directory_path.exists() => { + build_resources_directory_path + } + RunLayout::Packaged => project_resources_directory_path, + }; + + let project_source_directory_path = project_directory_path.join("src"); + let config_path = project_resources_directory_path.join("config.ini"); + + // --- 3. Determine runtime load mode (dylib vs in-process) --- + // Decide how to load the runtime (dynamic library or in-process). + let in_process = std::env::var("PILL_RUNTIME_IN_PROCESS").ok().as_deref() == Some("1"); + + let runtime_load_mode = parse_runtime_load_mode(std::env::var("PILL_RUNTIME_MODE").ok()) + .or(in_process.then_some(RuntimeLoadMode::InProcess)) + .unwrap_or(if cfg!(target_os = "macos") { + RuntimeLoadMode::InProcess + } else { + RuntimeLoadMode::Dylib + }); + + // --- 4. Resolve engine workspace (required for hot-reload, optional for dylib) --- + let engine_source_directory_path = if hot_reload_enabled { + Some(resolve_engine_workspace_dir( + ¤t_directory_path, + &project_directory_path, + true, + )?) + } else if runtime_load_mode == RuntimeLoadMode::Dylib { + resolve_engine_workspace_dir(¤t_directory_path, &project_directory_path, false).ok() + } else { + None + }; + + // --- 5. Resolve paths to runtime and project dynamic libraries --- + // Resolve paths to the runtime dynamic library. + let runtime_dynamic_library_path = if runtime_load_mode == RuntimeLoadMode::Dylib { + crate::runtime::resolve_runtime_dylib( + &build_data_directory_path, + engine_source_directory_path.as_deref(), + "pill_runtime", + )? + } else { + build_data_directory_path.join(dylib("pill_runtime")) + }; + + let runtime_dynamic_library_hot_reloaded_path = + if hot_reload_enabled && runtime_load_mode == RuntimeLoadMode::Dylib { + crate::runtime::resolve_runtime_dylib_optional( + &build_data_directory_path, + engine_source_directory_path.as_deref(), + "pill_runtime_hot_reloaded", + ) + .unwrap_or_else(|| { + hot_reload_enabled = false; + runtime_dynamic_library_path.clone() + }) + } else { + runtime_dynamic_library_path.clone() + }; + + let project_dynamic_library_path = build_data_directory_path.join(dylib("project")); + let project_dynamic_library_hot_reloaded_path = + build_data_directory_path.join(dylib("project_hot_reloaded")); + + // --- 6. Assemble ProjectPaths and clean up stale hot-reload artifacts --- + let project_paths = ProjectPaths { + build_data_directory_path, + engine_source_directory_path, + project_directory_path, + project_resources_directory_path, + project_source_directory_path, + config_path, + runtime_dynamic_library_path, + runtime_dynamic_library_hot_reloaded_path, + project_dynamic_library_path, + project_dynamic_library_hot_reloaded_path, + }; + + // Clean up stale hot-reloaded libraries from previous runs. + if hot_reload_enabled { + try_remove_files_starting_with( + &project_paths.build_data_directory_path, + &format!("{DYLIB_PREFIX}pill_runtime_loaded"), + ); + try_remove_files_starting_with( + &project_paths.build_data_directory_path, + &format!("{DYLIB_PREFIX}project_loaded"), + ); + } + + // --- 7. Load project configuration and initialize logging --- + // Load and apply project configuration. + let mut config = Config::default(); + config + .merge(config::File::with_name( + project_paths.config_path.to_str().unwrap(), + )) + .with_context(|| { + format!( + "Failed to load config from {}", + project_paths.config_path.display() + ) + })?; + + configure_logging(&config); + + // Log hot-reload status; only include watch paths when enabled. + if hot_reload_enabled { + info!( + LogContext::HotReload => "Hot-reload enabled (watching src: {}, res: {})", + project_paths.project_source_directory_path.display(), + project_paths.project_resources_directory_path.display() + ); + } else { + info!(LogContext::HotReload => "Hot-reload disabled"); + } + info!( + "Initializing {} ({:?} layout, {:?} runtime)", + "Standalone".module_object_style(), + run_layout, + runtime_load_mode + ); + + // --- 8. Build window descriptor from config, create event loop, launch --- + let window_init = make_window_init(&config, &project_paths.project_resources_directory_path); + + // Create the event loop and hand control to the App. + let event_loop = EventLoop::new().context("Failed to create event loop")?; + event_loop.set_control_flow(ControlFlow::Poll); + + let mut app = App::new( + project_paths, + hot_reload_enabled, + runtime_load_mode, + window_init, + ); + event_loop.run_app(&mut app).context("run_app failed")?; + + // --- Graceful shutdown --- + // The last frame's GPU commands may still be in-flight when the + // event loop exits. Give the GPU a chance to drain its command + // queue before we tear down the wgpu Surface during runtime drop. + std::thread::sleep(Duration::from_millis(100)); + + // Explicitly destroy the runtime host now, while the event loop + // and window are both still alive. This ensures wgpu can clean + // up its surface resources before the native window is destroyed. + drop(app.runtime_host.take()); + + Ok(()) +} + +/// Process entry point. +/// Dispatches to headless or windowed runner depending on compile-time feature. +/// Prints any fatal error to stderr and exits with code 1 on failure. +fn main() { + #[cfg(feature = "headless")] + { + if let Err(error) = headless::run_app_headless() { + eprintln!("Error: {error:#}"); + std::process::exit(1); + } + return; + } + #[cfg(not(feature = "headless"))] + { + if let Err(error) = run_app() { + eprintln!("Error: {error:#}"); + std::process::exit(1); + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + //! Unit tests for path resolution and launcher discovery logic. + //! Each test creates a temporary directory structure mimicking a real + //! Pill project layout and verifies the resolution functions behave correctly. + + use super::*; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + /// Creates a unique temporary directory for a test, keyed by name and + /// nanosecond timestamp to prevent collisions between parallel test runs. + fn unique_temporary_directory(name: &str) -> std::path::PathBuf { + let nanoseconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("pill_test_{name}_{nanoseconds}")) + } + + /// Verifies that the workspace declared in the project's Cargo.toml + /// (via `workspace = "..."`) takes priority over both the + /// PILL_ENGINE_WORKSPACE_DIR env var and filesystem scanning. + #[test] + fn prefers_project_manifest_workspace_over_env_and_sibling_scan() { + let root = unique_temporary_directory("hot_reload_workspace_pick"); + let _ = fs::remove_dir_all(&root); + + let project_directory = root.join("my_project"); + fs::create_dir_all(project_directory.join("src")).unwrap(); + fs::create_dir_all(project_directory.join("res")).unwrap(); + + let engine_a = root.join("Pill-Engine").join("engine"); + let engine_b = root.join("Pill-Engine-Upstream").join("engine"); + fs::create_dir_all(engine_a.join("pill_core")).unwrap(); + fs::create_dir_all(engine_a.join("pill_engine")).unwrap(); + fs::create_dir_all(engine_a.join("pill_renderer")).unwrap(); + fs::create_dir_all(engine_b.join("pill_core")).unwrap(); + fs::create_dir_all(engine_b.join("pill_engine")).unwrap(); + fs::create_dir_all(engine_b.join("pill_renderer")).unwrap(); + + // Both engine workspaces claim the project as a member. + fs::write( + engine_a.join("Cargo.toml"), + r#"[workspace] +members = ["my_project"] +"#, + ) + .unwrap(); + fs::write( + engine_b.join("Cargo.toml"), + r#"[workspace] +members = ["my_project"] +"#, + ) + .unwrap(); + + // The project manifest points to engine_b. + fs::write( + project_directory.join("Cargo.toml"), + format!( + r#"[package] +name = "my_project" +version = "0.1.0" +edition = "2021" +workspace = "{}" +"#, + engine_b.display() + ), + ) + .unwrap(); + + // The env var points to engine_a, but the manifest should win. + std::env::set_var("PILL_ENGINE_WORKSPACE_DIR", &engine_a); + let resolved = + resolve_engine_workspace_dir(&project_directory, &project_directory, true).unwrap(); + assert_eq!(resolved, engine_b); + + let _ = fs::remove_dir_all(root); + } + + /// Verifies that resolve_launcher_command prefers a compiled + /// PillLauncher binary in the engine workspace over a PATH fallback. + #[test] + fn resolve_launcher_prefers_engine_pill_launcher_target_binary() { + let root = unique_temporary_directory("hot_reload_launcher_pick"); + let _ = fs::remove_dir_all(&root); + + let engine_directory = root.join("engine"); + let launcher_binary = engine_directory + .join("pill_launcher") + .join("target") + .join("debug") + .join("PillLauncher"); + fs::create_dir_all(launcher_binary.parent().unwrap()).unwrap(); + fs::write(&launcher_binary, b"").unwrap(); + + // Clear env overrides so the filesystem fallback is tested. + std::env::remove_var("PILL_LAUNCHER_BIN"); + std::env::remove_var("PILL_LAUNCHER_CMD"); + + // Verify the binary exists at the expected path. + assert!(launcher_binary.exists()); + + let _ = fs::remove_dir_all(root); + } +} diff --git a/engine/pill_native/src/paths.rs b/engine/pill_native/src/paths.rs new file mode 100644 index 00000000..898dcccd --- /dev/null +++ b/engine/pill_native/src/paths.rs @@ -0,0 +1,283 @@ +//! This file provides path resolution for the native standalone runner. +//! +//! Detects project and engine workspace directories at startup using a +//! priority chain: Cargo.toml manifest β†’ environment variable β†’ filesystem scan. +//! Also classifies the run layout (Development vs Packaged) and provides +//! platform-specific dynamic-library naming helpers. + +use anyhow::{bail, Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; + +// --------------------------------------------------------------------------- +// Platform Helpers +// --------------------------------------------------------------------------- + +/// Platform-specific dynamic-library naming conventions. +#[cfg(target_os = "windows")] +pub(crate) const DYLIB_PREFIX: &str = ""; +#[cfg(not(target_os = "windows"))] +pub(crate) const DYLIB_PREFIX: &str = "lib"; + +#[cfg(target_os = "windows")] +pub(crate) const DYLIB_SUFFIX: &str = ".dll"; +#[cfg(target_os = "linux")] +pub(crate) const DYLIB_SUFFIX: &str = ".so"; +#[cfg(target_os = "macos")] +pub(crate) const DYLIB_SUFFIX: &str = ".dylib"; +#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] +pub(crate) const DYLIB_SUFFIX: &str = ".so"; + +/// Builds a platform-appropriate dynamic-library file name from a base name. +pub(crate) fn dylib(name: &str) -> String { + format!("{DYLIB_PREFIX}{name}{DYLIB_SUFFIX}") +} + +// --------------------------------------------------------------------------- +// Data Structures +// --------------------------------------------------------------------------- + +/// Distinguishes between a development workspace layout (project lives +/// alongside the engine) and a packaged release layout. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RunLayout { + Development, + Packaged, +} + +/// Describes whether the runtime (pill_runtime) is loaded as a separate +/// dynamic library or compiled directly into the executable. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RuntimeLoadMode { + Dylib, + InProcess, +} + +/// Parses the runtime load mode from an environment variable value. +pub(crate) fn parse_runtime_load_mode(value: Option) -> Option { + match value.as_deref() { + Some("dylib") => Some(RuntimeLoadMode::Dylib), + Some("in_process") => Some(RuntimeLoadMode::InProcess), + _ => None, + } +} + +/// Central store for every path the standalone runner needs at runtime. +/// Avoids scattering path joins throughout the codebase. +pub(crate) struct ProjectPaths { + pub(crate) build_data_directory_path: PathBuf, + pub(crate) engine_source_directory_path: Option, + pub(crate) project_directory_path: PathBuf, + pub(crate) project_resources_directory_path: PathBuf, + pub(crate) project_source_directory_path: PathBuf, + pub(crate) config_path: PathBuf, + pub(crate) runtime_dynamic_library_path: PathBuf, + pub(crate) runtime_dynamic_library_hot_reloaded_path: PathBuf, + pub(crate) project_dynamic_library_path: PathBuf, + pub(crate) project_dynamic_library_hot_reloaded_path: PathBuf, +} + +// --------------------------------------------------------------------------- +// Project Detection +// --------------------------------------------------------------------------- + +/// Checks whether a directory contains the minimum set of files +/// required to be treated as a valid Pill project. +pub(crate) fn project_exists(path: &Path) -> bool { + path.join("Cargo.toml").exists() + && path.join("res").join("config.ini").exists() + && path.join("src").exists() +} + +/// Determines the project directory from either the PROJECT_DIR +/// environment variable or by walking up from the executable path. +pub(crate) fn infer_project_directory(current_directory_path: &Path) -> Result { + // First check the explicit environment override. + if let Ok(value) = std::env::var("PROJECT_DIR") { + let path = PathBuf::from(value); + if project_exists(&path) { + return Ok(path); + } + bail!( + "PROJECT_DIR was set but {} is not a valid project", + path.display() + ); + } + + // Fall back: the executable is at /build//pill_native.exe, + // so go up two levels to reach the project root. + current_directory_path + .parent() + .context("Build directory has no parent")? + .parent() + .context("Project directory resolution failed") + .map(Path::to_path_buf) +} + +/// Classifies the current run as Development or Packaged based on +/// environment variables and filesystem heuristics. +pub(crate) fn resolve_run_layout(project_directory_path: &Path) -> RunLayout { + match std::env::var("PILL_STANDALONE_LAYOUT").ok().as_deref() { + Some("development") => RunLayout::Development, + Some("packaged") => RunLayout::Packaged, + _ if project_exists(project_directory_path) => RunLayout::Development, + _ => RunLayout::Packaged, + } +} + +// --------------------------------------------------------------------------- +// Engine Workspace Discovery +// --------------------------------------------------------------------------- + +/// Returns true when the engine workspace's Cargo.toml lists the given +/// project directory name as a workspace member. +fn workspace_includes_project( + engine_source_directory_path: &Path, + project_directory_path: &Path, +) -> bool { + let cargo_toml_path = engine_source_directory_path.join("Cargo.toml"); + let Ok(contents) = fs::read_to_string(cargo_toml_path) else { + return false; + }; + let Some(project_directory_name) = project_directory_path + .file_name() + .and_then(|name| name.to_str()) + else { + return false; + }; + contents.contains(project_directory_name) +} + +/// Quick heuristic: a directory looks like the engine workspace if it +/// contains the three core engine crates. +pub(crate) fn looks_like_engine_workspace(path: &Path) -> bool { + path.join("pill_core").exists() + && path.join("pill_engine").exists() + && path.join("pill_renderer").exists() +} + +/// Attempts to read the `workspace` key from the project's Cargo.toml +/// and returns the resolved path if it points to an existing directory. +fn engine_workspace_from_project_manifest(project_directory_path: &Path) -> Option { + let manifest_path = project_directory_path.join("Cargo.toml"); + let contents = fs::read_to_string(manifest_path).ok()?; + + for line in contents.lines() { + let line = line.trim(); + if !line.starts_with("workspace") { + continue; + } + let (_, rhs) = line.split_once('=')?; + let rhs = rhs.trim().strip_prefix('"')?.strip_suffix('"')?; + let path = PathBuf::from(rhs); + if path.exists() { + return Some(path); + } + } + + None +} + +/// Searches for the engine workspace directory by walking up from the +/// executable location and by scanning sibling directories of the project. +/// Returns the first path that looks like a valid engine workspace. +/// +/// Two-phase search: (1) ancestor walk, (2) sibling scan as fallback. +fn find_engine_source_directory( + current_directory_path: &Path, + project_directory_path: &Path, +) -> Option { + // Walk up the directory tree from the current executable path. + for ancestor in current_directory_path.ancestors() { + let engine_candidate = ancestor.join("engine"); + if looks_like_engine_workspace(&engine_candidate) + || engine_candidate + .join("pill_engine") + .join("Cargo.toml") + .exists() + { + return Some(engine_candidate); + } + + if looks_like_engine_workspace(ancestor) + || ancestor.join("pill_engine").join("Cargo.toml").exists() + { + return Some(ancestor.to_path_buf()); + } + } + + // Scan sibling directories of the project as a fallback. + if let Some(parent) = project_directory_path.parent() { + if let Ok(entries) = fs::read_dir(parent) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let engine_candidate = path.join("engine"); + if looks_like_engine_workspace(&engine_candidate) + || engine_candidate + .join("pill_engine") + .join("Cargo.toml") + .exists() + { + return Some(engine_candidate); + } + + if looks_like_engine_workspace(&path) + || path.join("pill_engine").join("Cargo.toml").exists() + { + return Some(path); + } + } + } + } + + None +} + +/// Resolves the engine workspace directory using a priority chain: +/// 1. The `workspace` key in the project's Cargo.toml +/// 2. The PILL_ENGINE_WORKSPACE_DIR environment variable +/// 3. Filesystem scanning (ancestor and sibling search) +/// +/// Optionally validates that the workspace actually includes the project. +pub(crate) fn resolve_engine_workspace_dir( + current_directory_path: &Path, + project_directory_path: &Path, + require_workspace_membership: bool, +) -> Result { + let by_manifest = engine_workspace_from_project_manifest(project_directory_path); + let by_environment = std::env::var("PILL_ENGINE_WORKSPACE_DIR") + .ok() + .map(PathBuf::from); + let by_scan = find_engine_source_directory(current_directory_path, project_directory_path); + + // Try each candidate in priority order: manifest β†’ env var β†’ filesystem scan. + // The first valid engine workspace that (optionally) contains the project wins. + for candidate in [by_manifest, by_environment, by_scan].into_iter().flatten() { + // Skip candidates that don't look like valid engine workspaces. + if !looks_like_engine_workspace(&candidate) && !candidate.join("pill_engine").exists() { + continue; + } + + // Optionally check that the workspace manifest lists this project. + if require_workspace_membership + && !workspace_includes_project(&candidate, project_directory_path) + { + continue; + } + + return Ok(candidate); + } + + bail!( + "Engine workspace not detected. Set PILL_ENGINE_WORKSPACE_DIR to the engine directory{}.", + if require_workspace_membership { + " that includes the pill project workspace member" + } else { + "" + } + ) +} diff --git a/engine/pill_native/src/runtime.rs b/engine/pill_native/src/runtime.rs new file mode 100644 index 00000000..9aa761c5 --- /dev/null +++ b/engine/pill_native/src/runtime.rs @@ -0,0 +1,384 @@ +//! This file implements the FFI bridge between the native host and pill_runtime. +//! +//! Provides RuntimeHost, which loads the runtime dynamic library via libloading +//! and exposes typed access to every function in the pill_abi FFI vtable. +//! Also contains dylib path resolution helpers used at startup to locate +//! runtime and project shared libraries across build output directories. +//! +//! Dependencies: paths (platform dylib naming), pill_abi (FFI types) + +use crate::paths::{self, ProjectPaths, RuntimeLoadMode}; +use anyhow::{bail, Context, Result}; +use libloading::{Library, Symbol}; +use pill_abi::*; +use std::ffi::{c_void, CString}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use winit::window::Window; + +// --------------------------------------------------------------------------- +// FFI Bridge - RuntimeCreateContext +// --------------------------------------------------------------------------- + +/// Holds the data needed to construct a PillEngineCreateArgsV1 each time +/// the runtime is (re)created. The window Arc is cloned so the runtime +/// receives its own counted reference. +pub(crate) struct RuntimeCreateContext { + pub(crate) project_resources_dir: CString, + pub(crate) config_path: CString, + pub(crate) window: Arc, +} + +impl RuntimeCreateContext { + /// Builds the FFI argument struct for pill_runtime::create. + /// Transfers one Arc reference to the runtime via Arc::into_raw. + pub(crate) fn make_args( + &self, + project_dylib_path: &CString, + window_size: winit::dpi::PhysicalSize, + ) -> PillEngineCreateArgsV1 { + // The runtime must reconstruct this with Arc::from_raw exactly once. + let window_raw = Arc::into_raw(Arc::clone(&self.window)) as *const c_void; + PillEngineCreateArgsV1 { + struct_size: std::mem::size_of::() as u32, + window_ptr: window_raw, + project_dylib_path: project_dylib_path.as_ptr(), + project_resources_dir: self.project_resources_dir.as_ptr(), + config_path: self.config_path.as_ptr(), + initial_w: window_size.width, + initial_h: window_size.height, + } + } +} + +// --------------------------------------------------------------------------- +// FFI Bridge - RuntimeHost +// --------------------------------------------------------------------------- + +/// Owns a loaded runtime dynamic library and provides typed access to every +/// function in the pill_abi FFI table. All FFI calls go through the `api` +/// vtable so that the host never links directly against pill_runtime symbols. +pub(crate) struct RuntimeHost { + // Keeps the dynamic library loaded; dropped after `api` is no longer used. + _lib: Option, + // Pointer to the static PillEngineApiV1 exported by the runtime. + api: *const PillEngineApiV1, + // Opaque pointer the runtime uses to identify the engine instance. + handle: EngineHandle, +} + +impl RuntimeHost { + /// Loads the runtime, either by dynamically opening the shared library + /// or by calling the in-process symbol directly. + pub(crate) fn load(runtime_dylib_path: &Path, load_mode: RuntimeLoadMode) -> Result { + if load_mode == RuntimeLoadMode::InProcess { + // When compiled in-process the vtable is a direct function call. + let api = pill_runtime::get_pill_engine_api_v1(); + if api.is_null() { + bail!("pill_runtime get_pill_engine_api_v1 returned null"); + } + + let runtime_api = unsafe { &*api }; + if runtime_api.abi_version != PILL_ENGINE_ABI_VERSION { + bail!( + "Engine ABI version mismatch runtime {} host {}", + runtime_api.abi_version, + PILL_ENGINE_ABI_VERSION + ); + } + + return Ok(Self { + _lib: None, + api, + handle: std::ptr::null_mut(), + }); + } + + // Dynamic-library path: use libloading to open the .dll/.so/.dylib. + let lib = unsafe { Library::new(runtime_dylib_path) }.with_context(|| { + format!( + "Failed to load runtime dynamic library at {}", + runtime_dylib_path.display() + ) + })?; + + let get_api: Symbol *const PillEngineApiV1> = + unsafe { lib.get(PILL_ENGINE_API_SYMBOL) } + .context("Missing symbol get_pill_engine_api_v1")?; + + let api = unsafe { get_api() }; + if api.is_null() { + bail!("pill_engine get_pill_engine_api_v1 returned null"); + } + + let runtime_api = unsafe { &*api }; + if runtime_api.abi_version != PILL_ENGINE_ABI_VERSION { + bail!( + "Engine ABI version mismatch runtime {} host {}", + runtime_api.abi_version, + PILL_ENGINE_ABI_VERSION + ); + } + + Ok(Self { + _lib: Some(lib), + api, + handle: std::ptr::null_mut(), + }) + } + + /// Initialises the engine inside the runtime with the given + /// creation arguments. Must be called after `load`. + pub(crate) fn create(&mut self, args: &PillEngineCreateArgsV1) -> Result<()> { + let runtime_api = unsafe { &*self.api }; + let ret = (runtime_api.create)(args as *const _, &mut self.handle as *mut _); + if ret != PILL_OK { + let error = unsafe { std::ffi::CStr::from_ptr((runtime_api.last_error_utf8)()) }; + bail!("engine create failed: {}", error.to_string_lossy()); + } + Ok(()) + } + + /// Tears down the engine and releases the runtime's resources. + /// Safe to call multiple times (no-op if already destroyed). + fn destroy(&mut self) { + if self.handle.is_null() { + return; + } + + let runtime_api = unsafe { &*self.api }; + (runtime_api.destroy)(self.handle); + self.handle = std::ptr::null_mut(); + } + + /// Advances the engine by one frame. `delta_time` is the wall-clock + /// duration since the previous call to `update`. + pub(crate) fn update(&mut self, delta_time: std::time::Duration) { + if self.handle.is_null() { + return; + } + + let runtime_api = unsafe { &*self.api }; + (runtime_api.update)(self.handle, delta_time.as_nanos() as u64); + } + + // Notifies the engine that the window has been resized. + pub(crate) fn resize(&mut self, width: u32, height: u32) { + if self.handle.is_null() { + return; + } + + let runtime_api = unsafe { &*self.api }; + (runtime_api.resize)(self.handle, width, height); + } + + /// Forwards a raw winit WindowEvent to the engine for egui input + /// processing (no-op when the debug_ui feature is disabled). + pub(crate) fn window_event(&mut self, window_event: &winit::event::WindowEvent) { + if self.handle.is_null() { + return; + } + + let runtime_api = unsafe { &*self.api }; + (runtime_api.window_event)(self.handle, window_event as *const _ as *const c_void); + } + + // Forwards a keyboard event to the engine. + pub(crate) fn key_event(&mut self, key_event: &winit::event::KeyEvent) { + if self.handle.is_null() { + return; + } + + let runtime_api = unsafe { &*self.api }; + (runtime_api.key_event)(self.handle, key_event as *const _ as *const c_void); + } + + // Forwards a mouse button press/release. + pub(crate) fn mouse_button(&mut self, button: u32, pressed: bool) { + if self.handle.is_null() { + return; + } + + let runtime_api = unsafe { &*self.api }; + (runtime_api.mouse_button)(self.handle, button, pressed); + } + + // Forwards raw mouse motion delta. + pub(crate) fn mouse_delta(&mut self, delta_x: f64, delta_y: f64) { + if self.handle.is_null() { + return; + } + + let runtime_api = unsafe { &*self.api }; + (runtime_api.mouse_delta)(self.handle, delta_x, delta_y); + } + + // Forwards cursor position in physical pixels. + pub(crate) fn cursor_position(&mut self, x: f64, y: f64) { + if self.handle.is_null() { + return; + } + + let runtime_api = unsafe { &*self.api }; + (runtime_api.cursor_position)(self.handle, x, y); + } + + // Forwards mouse wheel line-scroll deltas. + pub(crate) fn mouse_wheel_line(&mut self, delta_x: f32, delta_y: f32) { + if self.handle.is_null() { + return; + } + + let runtime_api = unsafe { &*self.api }; + (runtime_api.mouse_wheel_line)(self.handle, delta_x, delta_y); + } + + /// Instructs the runtime to swap the currently loaded pill project + /// for a newly compiled one (hot-reload). + pub(crate) fn reload_project(&mut self, project_dylib_path: &Path) -> Result<()> { + if self.handle.is_null() { + bail!("Engine not initialized"); + } + + let runtime_api = unsafe { &*self.api }; + let path = CString::new(project_dylib_path.to_string_lossy().as_bytes())?; + let ret = (runtime_api.reload_project)(self.handle, path.as_ptr()); + if ret != PILL_OK { + let error = unsafe { std::ffi::CStr::from_ptr((runtime_api.last_error_utf8)()) }; + bail!("engine reload_project failed: {}", error.to_string_lossy()); + } + Ok(()) + } + + /// Returns true when the engine has requested graceful shutdown + /// (for example, a benchmark that finishes after N frames). + pub(crate) fn should_exit(&self) -> bool { + if self.handle.is_null() { + return false; + } + let runtime_api = unsafe { &*self.api }; + (runtime_api.is_exit_requested)(self.handle) != 0 + } +} + +/// Ensures the runtime engine is always torn down, even if the App +/// is dropped abnormally (e.g. during a panic unwind). The Drop impl +/// guarantees wgpu surface cleanup and DLL unloading in all code paths. +impl Drop for RuntimeHost { + fn drop(&mut self) { + self.destroy(); + } +} + +// --------------------------------------------------------------------------- +// Dynamic Library Resolution +// --------------------------------------------------------------------------- + +// Monotonic counter used to generate unique suffixes for +// hot-reloaded dynamic libraries so that old copies can coexist +// with the newly loaded ones. +static RELOAD_GEN: AtomicU64 = AtomicU64::new(0); + +/// Builds a list of candidate paths where a runtime dynamic library +/// might be found, covering both the build data directory and the +/// engine workspace target directories (debug and release). +fn resolve_runtime_dylib_candidates( + build_data_directory_path: &Path, + engine_source_directory_path: Option<&Path>, + name: &str, +) -> Vec { + let mut candidates = vec![build_data_directory_path.join(paths::dylib(name))]; + + if let Some(engine_source_directory_path) = engine_source_directory_path { + // Also search the workspace-level target directories. + if let Some(engine_workspace_root) = engine_source_directory_path.parent() { + candidates.extend([ + engine_workspace_root + .join("target") + .join("debug") + .join(paths::dylib(name)), + engine_workspace_root + .join("target") + .join("release") + .join(paths::dylib(name)), + ]); + } + + candidates.extend([ + engine_source_directory_path + .join("target") + .join("debug") + .join(paths::dylib(name)), + engine_source_directory_path + .join("target") + .join("release") + .join(paths::dylib(name)), + ]); + } + + candidates +} + +/// Finds the first existing candidate for a runtime dynamic library. +/// Errors if no candidate exists on disk. +pub(crate) fn resolve_runtime_dylib( + build_data_directory_path: &Path, + engine_source_directory_path: Option<&Path>, + name: &str, +) -> Result { + let candidates = resolve_runtime_dylib_candidates( + build_data_directory_path, + engine_source_directory_path, + name, + ); + + for candidate in &candidates { + if candidate.exists() { + return Ok(candidate.clone()); + } + } + + // Format all checked paths into the error message so the developer + // can see exactly where the loader looked and why it failed. + let candidates_display = candidates + .iter() + .map(|c| c.display().to_string()) + .collect::>() + .join(", "); + + bail!("Failed to find {name} runtime dylib. Checked: {candidates_display}") +} + +/// Same search as resolve_runtime_dylib but returns None instead of +/// an error when the library cannot be found. +pub(crate) fn resolve_runtime_dylib_optional( + build_data_directory_path: &Path, + engine_source_directory_path: Option<&Path>, + name: &str, +) -> Option { + resolve_runtime_dylib_candidates( + build_data_directory_path, + engine_source_directory_path, + name, + ) + .into_iter() + .find(|candidate| candidate.exists()) +} + +/// Generates a unique file path for a hot-reloaded runtime library by +/// appending a monotonically increasing generation number. +pub(crate) fn next_loaded_runtime_dylib_path(project_paths: &ProjectPaths) -> PathBuf { + let generation = RELOAD_GEN.fetch_add(1, Ordering::Relaxed); + project_paths + .build_data_directory_path + .join(paths::dylib(&format!("pill_runtime_loaded_{generation}"))) +} + +/// Generates a unique file path for a hot-reloaded project library. +pub(crate) fn next_loaded_project_dylib_path(project_paths: &ProjectPaths) -> PathBuf { + let generation = RELOAD_GEN.fetch_add(1, Ordering::Relaxed); + project_paths + .build_data_directory_path + .join(paths::dylib(&format!("project_loaded_{generation}"))) +} diff --git a/engine/pill_renderer/Cargo.toml b/engine/pill_renderer/Cargo.toml index 36252329..c1593c40 100644 --- a/engine/pill_renderer/Cargo.toml +++ b/engine/pill_renderer/Cargo.toml @@ -4,31 +4,39 @@ version = "0.1.0" edition = "2021" [dependencies] -pill_core = {path = "../pill_core"} -pill_engine = {path = "../pill_engine", features = ["internal"]} +pill_core = { path = "../pill_core" } +pill_engine = { path = "../pill_engine", features = ["internal"] } # Logging log = { version = "0.4", features = ["release_max_level_off"] } # Other -glam = { version = "0.30.8", features = ["serde", "bytemuck"] } -winit = "0.30.12" +glam = { version = "0.33", features = ["serde", "bytemuck"] } +winit = "0.30" cgmath = "0.18" -bytemuck = { version = "1.4", features = [ "derive" ] } +bytemuck = { version = "1", features = ["derive"] } # egui (optional β€” enabled by debug_ui feature) -egui = { version = "0.32.1", optional = true } -egui-wgpu = { version = "0.32.1", features = ["winit"], optional = true } +egui = { version = "0.32", optional = true } +egui-wgpu = { version = "0.32", features = ["winit"], optional = true } # Native-only dependencies [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -wgpu = "25.0.0" -pollster = "0.2" -egui-winit = { version = "0.32.1", features = ["clipboard"], optional = true } +wgpu = "25" +pollster = "0.4" +egui-winit = { version = "0.32", features = ["clipboard"], optional = true } # WASM dependencies [target.'cfg(target_arch = "wasm32")'.dependencies] -wgpu = { version = "25.0.0", default-features = false, features = ["webgpu", "wgsl"] } -egui-winit = { version = "0.32.1", default-features = false, optional = true } +wgpu = { version = "25", default-features = false, features = [ + "webgpu", + "wgsl", +] } +egui-winit = { version = "0.32", default-features = false, optional = true } [features] -debug_ui = ["dep:egui", "dep:egui-wgpu", "dep:egui-winit", "pill_engine/debug_ui"] +debug_ui = [ + "dep:egui", + "dep:egui-wgpu", + "dep:egui-winit", + "pill_engine/debug_ui", +] diff --git a/engine/pill_renderer/src/resources/engine_parameters.rs b/engine/pill_renderer/src/resources/engine_parameters.rs index a76b7f21..277abaa8 100644 --- a/engine/pill_renderer/src/resources/engine_parameters.rs +++ b/engine/pill_renderer/src/resources/engine_parameters.rs @@ -2,14 +2,16 @@ use pill_core::Result; use wgpu::util::DeviceExt; // Layout must match the HLSL `EngineParams` in `include/common.hlsl` (std140): -// vec3 fog_color; // offset 0 (12 bytes) -// float fog_density; // offset 12 (4 bytes) -// // total: 16 bytes +// float3 fog_color; // offset 0, size 12, but std140 aligns vec3 to 16 +// float fog_density; // offset 16, size 4 +// // total: 32 bytes (std140 rounds uniform buffers up to vec4 alignment) #[repr(C)] #[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct EngineParametersData { pub fog_color: [f32; 3], + _pad0: f32, // std140: vec3 is 16-byte aligned pub fog_density: f32, + _pad1: [f32; 3], // pad to 32 bytes (multiple of 16 for uniform buffer) } impl Default for EngineParametersData { @@ -22,7 +24,9 @@ impl EngineParametersData { pub fn new() -> Self { Self { fog_color: [0.0; 3], + _pad0: 0.0, fog_density: 0.0, + _pad1: [0.0; 3], } } diff --git a/engine/pill_renderer/src/resources/renderer_camera.rs b/engine/pill_renderer/src/resources/renderer_camera.rs index 8d590854..99e92bca 100644 --- a/engine/pill_renderer/src/resources/renderer_camera.rs +++ b/engine/pill_renderer/src/resources/renderer_camera.rs @@ -54,6 +54,7 @@ impl CameraParametersData { * CameraParametersData::calculate_view_matrix(transform_component); } + #[allow(deprecated)] fn calculate_view_matrix(transform_component: &TransformComponent) -> Matrix4f { let roll_matrix = Matrix3f::from_rotation_z(transform_component.rotation.z.to_radians()); let yaw_matrix = Matrix3f::from_rotation_y(transform_component.rotation.y.to_radians()); @@ -64,6 +65,7 @@ impl CameraParametersData { Matrix4f::look_to_rh(transform_component.position, direction, Vector3f::Y) } + #[allow(deprecated)] fn calculate_projection_matrix(camera_component: &CameraComponent) -> Matrix4f { OPENGL_TO_WGPU_MATRIX * Matrix4f::perspective_rh( diff --git a/engine/pill_runtime/Cargo.toml b/engine/pill_runtime/Cargo.toml index 39151455..f61c2751 100644 --- a/engine/pill_runtime/Cargo.toml +++ b/engine/pill_runtime/Cargo.toml @@ -6,6 +6,10 @@ edition = "2021" [lib] crate-type = ["cdylib", "rlib"] +[features] +headless = ["pill_engine/headless"] +debug_ui = ["pill_renderer/debug_ui"] + [dependencies] pill_abi = { path = "../pill_abi" } pill_core = { path = "../pill_core" } @@ -14,4 +18,3 @@ pill_renderer = { path = "../pill_renderer", features = ["debug_ui"] } libloading = "0.8.8" winit = "0.30.12" - diff --git a/engine/pill_runtime/src/lib.rs b/engine/pill_runtime/src/lib.rs index 6a9db4ab..2c7aa587 100644 --- a/engine/pill_runtime/src/lib.rs +++ b/engine/pill_runtime/src/lib.rs @@ -1,339 +1,552 @@ -use std::{ - cell::RefCell, - ffi::{c_char, c_void, CStr, CString}, - path::PathBuf, - sync::Arc, - time::Duration, -}; - -use libloading::{Library, Symbol}; -use pill_abi::*; -use pill_core::{PillError, Result}; -use pill_engine::internal::*; -use pill_renderer::Renderer; -use winit::{ - dpi::{PhysicalPosition, PhysicalSize}, - window::Window, -}; - -thread_local! { - static LAST_ERR: RefCell = RefCell::new(CString::new("").unwrap()); -} - -fn set_err(msg: impl Into) { - let s = msg.into(); - LAST_ERR.with(|e| { - *e.borrow_mut() = CString::new(s).unwrap_or_else(|_| CString::new("error").unwrap()); - }); -} - -unsafe fn cstr(p: *const c_char) -> Result<&'static str> { - if p.is_null() { - return Err("null cstr".into()); - } - CStr::from_ptr(p) - .to_str() - .map_err(|e| -> PillError { e.to_string().into() }) -} - -fn load_game(game_library_path: &str) -> Result<(Library, Box)> { - // SAFETY: - // As long as the caller stops ALL functions running in the game + engine - // we are fine to unload + load a new Box - type CreateGameFn = unsafe extern "C" fn() -> *mut c_void; - let game_dynamic_library = unsafe { - Library::new(game_library_path).map_err(|e| -> PillError { - format!("Failed to load game dynamic library at {game_library_path}: {e}").into() - })? - }; - let get_game_function: Symbol = unsafe { game_dynamic_library.get(b"get_game") } - .map_err(|e| -> PillError { format!("Missing symbol get_game: {e}").into() })?; - let game = unsafe { *Box::from_raw(get_game_function() as *mut Box) }; - Ok((game_dynamic_library, game)) -} - -struct Runtime { - // Keep the window alive for Renderer - window: Arc, - // Last known physical size - window_size: PhysicalSize, - - resource_directory: PathBuf, - - config: EngineConfig, - - process: EngineProcessInfo, - - // Keep engine ptr for hot-reload - engine: Option, - game_library: Option, -} - -impl Runtime { - fn build_engine(&self, game: Box) -> Result { - let renderer: Box = Box::new(::new( - Arc::clone(&self.window), - self.config.clone(), - )?); - - let mut engine = Engine::new( - game, - self.resource_directory.clone(), - renderer, - self.config.clone(), - self.process.clone(), - ); - engine.initialize(Some(self.window_size))?; - Ok(engine) - } - - fn shutdown_engine(&mut self) { - if let Some(mut e) = self.engine.take() { - e.shutdown(); - drop(e); - } - } -} - -// --- ABI --- -extern "C" fn last_error_utf8() -> *const c_char { - LAST_ERR.with(|e| e.borrow().as_ptr()) -} - -extern "C" fn create(args: *const PillEngineCreateArgsV1, out_engine: *mut EngineHandle) -> i32 { - if args.is_null() || out_engine.is_null() { - set_err("create: args or out_engine is null"); - return PILL_ERR; - } - - let r = (|| -> Result<()> { - let a = unsafe { &*args }; - - if a.window_ptr.is_null() { - return Err("create: window_ptr is null".into()); - } - - let game_library_path = unsafe { cstr(a.game_dylib_path) }?.to_string(); - let game_resource_dir = unsafe { cstr(a.game_resources_dir) }?.to_string(); - let config_path = unsafe { cstr(a.config_path) }?.to_string(); - - // Take ownership of one reference to Window that standalone gave us via - // Arc::into_raw(clone) - let window = unsafe { Arc::from_raw(a.window_ptr as *const Window) }; - - let config_ini = std::fs::read_to_string(&config_path).unwrap_or_default(); - let mut config = EngineConfig::from_ini(&config_ini); - if config.get_int("WINDOW_WIDTH").is_err() { - config.set("WINDOW_WIDTH", a.initial_w as i64); - } - if config.get_int("WINDOW_HEIGHT").is_err() { - config.set("WINDOW_HEIGHT", a.initial_h as i64); - } - let compile_mode = - std::env::var("PILL_COMPILE_MODE").map_err(|_| "PILL_COMPILE_MODE is not set")?; - let process = - EngineProcessInfo::new(&compile_mode, pill_engine::internal::BuildTarget::Native); - - let (game_library, game) = load_game(&game_library_path)?; - - let mut rt = Box::new(Runtime { - window, - window_size: winit::dpi::PhysicalSize::new(a.initial_w, a.initial_h), - resource_directory: game_resource_dir.into(), - config, - process, - engine: None, - game_library: Some(game_library), - }); - - let engine = rt.build_engine(game)?; - rt.engine = Some(engine); - - unsafe { - *out_engine = Box::into_raw(rt) as *mut c_void; - } - Ok(()) - })(); - - match r { - Ok(()) => PILL_OK, - Err(e) => { - set_err(format!("{e}")); - PILL_ERR - } - } -} - -extern "C" fn destroy(engine: EngineHandle) { - if engine.is_null() { - return; - } - unsafe { - let mut rt = Box::from_raw(engine as *mut Runtime); - - // Drop engine and game first, then unload - rt.shutdown_engine(); - rt.game_library.take(); - // rt drops here - } -} - -extern "C" fn update(engine: EngineHandle, dt_ns: u64) { - if engine.is_null() { - return; - } - let rt = unsafe { &mut *(engine as *mut Runtime) }; - if let Some(e) = rt.engine.as_mut() { - e.update(Duration::from_nanos(dt_ns)); - } -} - -extern "C" fn resize(engine: EngineHandle, w: u32, h: u32) { - if engine.is_null() { - return; - } - let rt = unsafe { &mut *(engine as *mut Runtime) }; - rt.window_size = winit::dpi::PhysicalSize::new(w, h); - if let Some(e) = rt.engine.as_mut() { - e.resize(rt.window_size); - } -} - -extern "C" fn window_event(engine: EngineHandle, window_event_ptr: *const c_void) { - if engine.is_null() || window_event_ptr.is_null() { - return; - } - let rt = unsafe { &mut *(engine as *mut Runtime) }; - if let Some(e) = rt.engine.as_mut() { - // Soft ABI: standalone passes &WindowEvent as *const c_void - let we = unsafe { &*(window_event_ptr as *const winit::event::WindowEvent) }; - e.pass_input_to_egui(we); - } -} - -extern "C" fn key_event(engine: EngineHandle, key_event_ptr: *const c_void) { - if engine.is_null() || key_event_ptr.is_null() { - return; - } - let rt = unsafe { &mut *(engine as *mut Runtime) }; - if let Some(e) = rt.engine.as_mut() { - // Soft ABI: standalone passes &KeyEvent as *const c_void - let ke = unsafe { &*(key_event_ptr as *const winit::event::KeyEvent) }; - e.pass_keyboard_key_input(ke); - } -} - -fn decode_mouse_button(button: u32) -> winit::event::MouseButton { - use winit::event::MouseButton::*; - match button { - 0 => Left, - 1 => Right, - 2 => Middle, - 3 => Back, - 4 => Forward, - n => Other(n.saturating_sub(5) as u16), - } -} - -extern "C" fn mouse_button(engine: EngineHandle, button: u32, pressed: bool) { - if engine.is_null() { - return; - } - let rt = unsafe { &mut *(engine as *mut Runtime) }; - if let Some(e) = rt.engine.as_mut() { - let decoded_button = decode_mouse_button(button); - let state = if pressed { - winit::event::ElementState::Pressed - } else { - winit::event::ElementState::Released - }; - e.pass_mouse_key_input(&decoded_button, &state); - } -} - -extern "C" fn mouse_delta(engine: EngineHandle, dx: f64, dy: f64) { - if engine.is_null() { - return; - } - let rt = unsafe { &mut *(engine as *mut Runtime) }; - if let Some(e) = rt.engine.as_mut() { - e.pass_mouse_delta_input(&(dx, dy)); - } -} - -extern "C" fn cursor_position(engine: EngineHandle, x: f64, y: f64) { - if engine.is_null() { - return; - } - let rt = unsafe { &mut *(engine as *mut Runtime) }; - if let Some(e) = rt.engine.as_mut() { - let position = PhysicalPosition::new(x, y); - e.pass_mouse_position_input(&position); - } -} - -extern "C" fn mouse_wheel_line(engine: EngineHandle, dx: f32, dy: f32) { - if engine.is_null() { - return; - } - let rt = unsafe { &mut *(engine as *mut Runtime) }; - if let Some(e) = rt.engine.as_mut() { - let delta = (dx as f64, dy as f64); - e.pass_mouse_delta_input(&delta); - } -} - -extern "C" fn reload_game(engine: EngineHandle, game_dylib_path: *const c_char) -> i32 { - if engine.is_null() { - set_err("reload_game: engine is null"); - return PILL_ERR; - } - - let r = (|| -> Result<()> { - let rt = unsafe { &mut *(engine as *mut Runtime) }; - let game_path = unsafe { cstr(game_dylib_path) }?.to_string(); - - // Drop engine/game first then unload the lib - rt.shutdown_engine(); - rt.game_library.take(); - - let (game_library, game) = load_game(&game_path)?; - rt.game_library = Some(game_library); - - let new_engine = rt.build_engine(game)?; - rt.engine = Some(new_engine); - - Ok(()) - })(); - - match r { - Ok(()) => PILL_OK, - Err(e) => { - set_err(format!("{e}")); - PILL_ERR - } - } -} - -static API: PillEngineApiV1 = PillEngineApiV1 { - struct_size: std::mem::size_of::() as u32, - abi_version: PILL_ENGINE_ABI_VERSION, - abi_hash: 0, // TODO: implement this check - last_error_utf8, - create, - destroy, - update, - resize, - window_event, - key_event, - mouse_button, - mouse_delta, - cursor_position, - mouse_wheel_line, - reload_game, -}; - -#[no_mangle] -pub extern "C" fn get_pill_engine_api_v1() -> *const PillEngineApiV1 { - &API -} +// This file is the runtime dynamic library loaded by pill_native at startup. +// +// Responsibilities: +// - Exposes a C-ABI vtable (PillEngineApiV1) so the host can call into +// the engine without linking against pill_runtime directly. +// - Loads the project dylib, creates the Engine, initialises the Renderer +// (wgpu), and wires up the ECS and resource manager. +// - Forwards window/input events from the host to the engine. +// - Supports hot-reload by swapping the project dylib in-place. + +use std::{ + cell::RefCell, + ffi::{c_char, c_void, CStr, CString}, + path::PathBuf, + time::Duration, +}; + +use libloading::{Library, Symbol}; +use pill_abi::*; +use pill_core::{set_log_levels, PillError, Result}; +use pill_engine::internal::*; +use winit::dpi::{PhysicalPosition, PhysicalSize}; +#[cfg(not(feature = "headless"))] +use {pill_renderer::Renderer, std::sync::Arc, winit::window::Window}; + +// --------------------------------------------------------------------------- +// Error Handling Helpers +// --------------------------------------------------------------------------- + +// Thread-local storage for the last error message produced by any FFI +// function. The host reads this via last_error_utf8() after a call +// returns PILL_ERR. +thread_local! { + static LAST_ERROR: RefCell = RefCell::new(CString::new("").unwrap()); +} + +// Stores a human-readable error message in the thread-local LAST_ERROR +// cell so the host can retrieve it across the FFI boundary. +fn set_last_error(message: impl Into) { + let message = message.into(); + LAST_ERROR.with(|error_cell| { + *error_cell.borrow_mut() = + CString::new(message).unwrap_or_else(|_| CString::new("error").unwrap()); + }); +} + +// Converts a null-terminated C-string pointer into a Rust &str. +// Returns an error if the pointer is null or contains invalid UTF-8. +// +// SAFETY: the caller must ensure `pointer` is valid and points to +// a null-terminated C string with a static or sufficiently long lifetime. +unsafe fn c_string_to_str(pointer: *const c_char) -> Result<&'static str> { + if pointer.is_null() { + return Err("null c_string_to_str".into()); + } + CStr::from_ptr(pointer) + .to_str() + .map_err(|error| -> PillError { error.to_string().into() }) +} + +// --------------------------------------------------------------------------- +// Project Loading +// --------------------------------------------------------------------------- + +// Opens the project dynamic library, looks up the `get_project` export, +// and returns both the loaded library handle and the Box. +// +// The returned Library must be kept alive for as long as the project +// trait object is in use β€” dropping it unloads the dylib. +fn load_project(project_library_path: &str) -> Result<(Library, Box)> { + // SAFETY: + // As long as the caller stops ALL functions running in the pill project + engine + // we are fine to unload + load a new Box + type CreateProjectFn = unsafe extern "C" fn() -> *mut c_void; + + // 1. Load the project dynamic library (the compiled pill project crate). + let project_dynamic_library = unsafe { + Library::new(project_library_path).map_err(|error| -> PillError { + format!( + "Failed to load pill project dynamic library at {project_library_path}: {error}" + ) + .into() + })? + }; + + // 2. Look up the `get_project` symbol β€” every pill project must export + // this function to return its Box. + let get_project_function: Symbol = unsafe { + project_dynamic_library.get(b"get_project") + } + .map_err(|error| -> PillError { format!("Missing symbol get_project: {error}").into() })?; + + // 3. Call get_project() and reconstruct the Box from the raw pointer. + let project = unsafe { *Box::from_raw(get_project_function() as *mut Box) }; + Ok((project_dynamic_library, project)) +} + +// --------------------------------------------------------------------------- +// Runtime State +// --------------------------------------------------------------------------- + +// Holds all state for one engine instance. Created once per process +// by the `create` FFI entry point and destroyed by `destroy`. +struct Runtime { + // Keeps the winit Window alive for the wgpu Renderer. + // Only present in windowed mode. + #[cfg(not(feature = "headless"))] + window: Arc, + // Last known physical window size, updated on resize. + window_size: PhysicalSize, + + resource_directory: PathBuf, + + config: EngineConfig, + + process: EngineProcessInfo, + + // The engine is stored as an Option so it can be taken out and + // dropped before the project library is unloaded (drop order matters). + engine: Option, + // Keeps the project dynamic library loaded; must outlive the engine. + project_library: Option, +} + +impl Runtime { + /// Creates and initialises an Engine from the given project. + /// Windowed mode: builds a wgpu Renderer and passes the winit Window. + #[cfg(not(feature = "headless"))] + fn build_engine(&self, project: Box) -> Result { + // Create the wgpu-backed renderer, passing a clone of the window Arc. + let renderer: Box = Box::new(::new( + Arc::clone(&self.window), + self.config.clone(), + )?); + + let mut engine = Engine::new( + project, + self.resource_directory.clone(), + renderer, + self.config.clone(), + self.process.clone(), + ); + engine.initialize(Some(self.window_size))?; + Ok(engine) + } + + /// Headless mode: uses the engine's headless constructor (DummyRenderer). + #[cfg(feature = "headless")] + fn build_engine(&self, project: Box) -> Result { + let mut engine = Engine::new(project, self.config.clone(), self.process.clone()); + engine.initialize(None)?; + Ok(engine) + } + + // Tears down the engine gracefully before unloading the project dylib. + // Must be called before project_library is dropped so that any drop + // glue in the project trait object runs while the dylib is still loaded. + fn shutdown_engine(&mut self) { + if let Some(mut engine) = self.engine.take() { + engine.shutdown(); + drop(engine); + } + } +} + +// =========================================================================== +// C ABI β€” FFI entry points exposed to pill_native via PillEngineApiV1 +// =========================================================================== +// +// Every function below follows the same pattern: +// 1. Validate the engine handle (null check). +// 2. Cast the opaque handle back to &mut Runtime. +// 3. Delegate to the Engine (if present). +// +// Functions that can fail return PILL_OK / PILL_ERR and set the +// thread-local LAST_ERROR on failure so the host can retrieve details. + +// --------------------------------------------------------------------------- +// ABI: Error & Lifecycle +// --------------------------------------------------------------------------- + +// Returns a pointer to the thread-local error message. +// The host must read it immediately β€” subsequent FFI calls may overwrite it. +extern "C" fn last_error_utf8() -> *const c_char { + LAST_ERROR.with(|error_cell| error_cell.borrow().as_ptr()) +} + +// Creates the engine inside the runtime. +// +// Takes ownership of: +// - One Arc reference (transferred via Arc::into_raw by the host) +// β€” only when building with benchmark_window feature. +// - The project dynamic library path (loaded via libloading). +// +// Writes the opaque engine handle to *out_engine on success. +extern "C" fn create(args: *const PillEngineCreateArgsV1, out_engine: *mut EngineHandle) -> i32 { + // Validate incoming pointers. + if args.is_null() || out_engine.is_null() { + set_last_error("create: args or out_engine is null"); + return PILL_ERR; + } + + // Wrap the entire initialisation in a closure so we can use `?` + // and translate any error into PILL_ERR + LAST_ERROR. + let result = (|| -> Result<()> { + let create_args = unsafe { &*args }; + + // Window pointer is required for windowed builds, optional for headless. + #[cfg(not(feature = "headless"))] + if create_args.window_ptr.is_null() { + return Err("create: window_ptr is null".into()); + } + + // Convert C-string fields to Rust strings. + let project_library_path = + unsafe { c_string_to_str(create_args.project_dylib_path) }?.to_string(); + let project_resource_directory = + unsafe { c_string_to_str(create_args.project_resources_dir) }?.to_string(); + let config_path = unsafe { c_string_to_str(create_args.config_path) }?.to_string(); + + // Reconstruct the Arc from the raw pointer the host gave us. + // This consumes exactly one reference count. + #[cfg(not(feature = "headless"))] + let window = unsafe { Arc::from_raw(create_args.window_ptr as *const Window) }; + + // Load and parse the project configuration file. + let config_ini = std::fs::read_to_string(&config_path).unwrap_or_default(); + let mut config = EngineConfig::from_ini(&config_ini); + + // Initialise the DLL-side logger so engine error/info logs are visible. + // (pill_native sets up the app-side logger; the DLL has its own global state.) + if let Ok(log_levels) = config.get_str("LOG_LEVELS") { + set_log_levels(&log_levels, false); + } + + // Fill in default window dimensions from the create args if the + // config file didn't specify them. + if config.get_int("WINDOW_WIDTH").is_err() { + config.set("WINDOW_WIDTH", create_args.initial_w as i64); + } + if config.get_int("WINDOW_HEIGHT").is_err() { + config.set("WINDOW_HEIGHT", create_args.initial_h as i64); + } + + // Determine the compile mode from environment variables. + // Falls back to "debug" for standalone runs where the launcher + // did not set PILL_COMPILE_MODE (e.g. double-clicking the .exe). + let compile_mode = std::env::var("PILL_COMPILE_MODE").unwrap_or_else(|_| { + match std::env::var("PILL_STANDALONE_LAYOUT").ok().as_deref() { + Some("packaged") => "release".to_string(), + _ => "debug".to_string(), + } + }); + let process = + EngineProcessInfo::new(&compile_mode, pill_engine::internal::BuildTarget::Native); + + // Load the project dynamic library and construct the PillProject. + let (project_library, project) = load_project(&project_library_path)?; + + // Assemble the Runtime struct β€” the engine is built immediately after. + let mut runtime = Box::new(Runtime { + #[cfg(not(feature = "headless"))] + window, + window_size: winit::dpi::PhysicalSize::new( + create_args.initial_w, + create_args.initial_h, + ), + resource_directory: project_resource_directory.into(), + config, + process, + engine: None, + project_library: Some(project_library), + }); + + // Build the engine (creates wgpu device, initialises renderer & ECS, + // or uses DummyRenderer in headless mode). + let engine = runtime.build_engine(project)?; + runtime.engine = Some(engine); + + // Transfer ownership of the Runtime to the host as an opaque pointer. + unsafe { + *out_engine = Box::into_raw(runtime) as *mut c_void; + } + Ok(()) + })(); + + match result { + Ok(()) => PILL_OK, + Err(error) => { + set_last_error(format!("{error}")); + PILL_ERR + } + } +} + +// Tears down the engine and releases all runtime resources. +// +// Drop order is critical: +// 1. Shut down and drop the Engine (which drops the Renderer / wgpu stack). +// 2. Drop the project Library (unloads the project dylib). +// 3. Drop the Runtime itself (releases window Arc, config, paths). +extern "C" fn destroy(engine: EngineHandle) { + if engine.is_null() { + return; + } + unsafe { + // Reconstruct the Box so it will be dropped at the end + // of this scope, running all destructors in the correct order. + let mut runtime = Box::from_raw(engine as *mut Runtime); + + // Drop the engine before unloading the project dylib so that + // the PillProject trait object's drop glue runs while the + // dylib is still mapped in memory. + runtime.shutdown_engine(); + runtime.project_library.take(); + // Runtime fields (window, config, paths) are dropped here. + } +} + +// --------------------------------------------------------------------------- +// ABI: Frame Update & Window Resize +// --------------------------------------------------------------------------- + +// Advances the engine by one frame. +// `delta_time_nanoseconds` is the wall-clock duration since the last update, +// measured by the host and passed as nanoseconds to avoid floating-point +// across the FFI boundary. +extern "C" fn update(engine: EngineHandle, delta_time_nanoseconds: u64) { + if engine.is_null() { + return; + } + let runtime = unsafe { &mut *(engine as *mut Runtime) }; + if let Some(engine) = runtime.engine.as_mut() { + engine.update(Duration::from_nanos(delta_time_nanoseconds)); + } +} + +// Notifies the engine that the window has been resized. +// Updates the stored size and forwards to the renderer so it can +// recreate the swapchain at the new dimensions. +extern "C" fn resize(engine: EngineHandle, width: u32, height: u32) { + if engine.is_null() { + return; + } + let runtime = unsafe { &mut *(engine as *mut Runtime) }; + runtime.window_size = winit::dpi::PhysicalSize::new(width, height); + if let Some(engine) = runtime.engine.as_mut() { + engine.resize(runtime.window_size); + } +} + +// --------------------------------------------------------------------------- +// ABI: Input Forwarding +// --------------------------------------------------------------------------- +// +// Each input function receives a raw *const c_void cast from the +// corresponding winit event type. The runtime casts it back to the +// concrete reference and forwards it to the engine. + +// Forwards a winit WindowEvent for egui input processing. +// No-op when the debug_ui feature is disabled (pass_input_to_egui +// ignores the event in that configuration). +extern "C" fn window_event(engine: EngineHandle, window_event_ptr: *const c_void) { + if engine.is_null() || window_event_ptr.is_null() { + return; + } + let runtime = unsafe { &mut *(engine as *mut Runtime) }; + if let Some(engine) = runtime.engine.as_mut() { + let window_event_ref = unsafe { &*(window_event_ptr as *const winit::event::WindowEvent) }; + engine.pass_input_to_egui(window_event_ref); + } +} + +// Forwards a winit KeyEvent to the engine's input queue. +extern "C" fn key_event(engine: EngineHandle, key_event_ptr: *const c_void) { + if engine.is_null() || key_event_ptr.is_null() { + return; + } + let runtime = unsafe { &mut *(engine as *mut Runtime) }; + if let Some(engine) = runtime.engine.as_mut() { + let key_event_ref = unsafe { &*(key_event_ptr as *const winit::event::KeyEvent) }; + engine.pass_keyboard_key_input(key_event_ref); + } +} + +// Decodes the stable u32 mouse-button encoding used across the FFI +// boundary back into winit's MouseButton enum. +// Encoding (mirrors encode_mouse_button in pill_native): +// 0 = Left, 1 = Right, 2 = Middle, 3 = Back, 4 = Forward, 5+ = Other(n-5) +fn decode_mouse_button(button: u32) -> winit::event::MouseButton { + use winit::event::MouseButton::*; + match button { + 0 => Left, + 1 => Right, + 2 => Middle, + 3 => Back, + 4 => Forward, + n => Other(n.saturating_sub(5) as u16), + } +} + +// Forwards a mouse button press or release event. +extern "C" fn mouse_button(engine: EngineHandle, button: u32, pressed: bool) { + if engine.is_null() { + return; + } + let runtime = unsafe { &mut *(engine as *mut Runtime) }; + if let Some(engine) = runtime.engine.as_mut() { + let decoded_button = decode_mouse_button(button); + let state = if pressed { + winit::event::ElementState::Pressed + } else { + winit::event::ElementState::Released + }; + engine.pass_mouse_key_input(&decoded_button, &state); + } +} + +// Forwards raw mouse motion delta (unscaled, in physical pixels). +extern "C" fn mouse_delta(engine: EngineHandle, delta_x: f64, delta_y: f64) { + if engine.is_null() { + return; + } + let runtime = unsafe { &mut *(engine as *mut Runtime) }; + if let Some(engine) = runtime.engine.as_mut() { + engine.pass_mouse_delta_input(&(delta_x, delta_y)); + } +} + +// Forwards the absolute cursor position in physical pixels. +extern "C" fn cursor_position(engine: EngineHandle, x: f64, y: f64) { + if engine.is_null() { + return; + } + let runtime = unsafe { &mut *(engine as *mut Runtime) }; + if let Some(engine) = runtime.engine.as_mut() { + let position = PhysicalPosition::new(x, y); + engine.pass_mouse_position_input(&position); + } +} + +// Forwards mouse-wheel line-scroll deltas. +extern "C" fn mouse_wheel_line(engine: EngineHandle, delta_x: f32, delta_y: f32) { + if engine.is_null() { + return; + } + let runtime = unsafe { &mut *(engine as *mut Runtime) }; + if let Some(engine) = runtime.engine.as_mut() { + let delta = (delta_x as f64, delta_y as f64); + engine.pass_mouse_delta_input(&delta); + } +} + +// --------------------------------------------------------------------------- +// ABI: Hot-Reload +// --------------------------------------------------------------------------- + +// Swaps the currently loaded project for a newly compiled one. +// +// Sequence (order is critical for safety): +// 1. Shut down the current engine and drop it. +// 2. Unload the old project dylib. +// 3. Load the new project dylib and construct a fresh PillProject. +// 4. Build a brand-new Engine around the new project. +// +// Returns PILL_OK on success, PILL_ERR with LAST_ERROR set on failure. +// On failure the runtime is left without an engine β€” the host should +// treat this as a fatal state. +extern "C" fn reload_project(engine: EngineHandle, project_dylib_path: *const c_char) -> i32 { + if engine.is_null() { + set_last_error("reload_project: engine is null"); + return PILL_ERR; + } + + let result = (|| -> Result<()> { + let runtime = unsafe { &mut *(engine as *mut Runtime) }; + let project_path = unsafe { c_string_to_str(project_dylib_path) }?.to_string(); + + // 1-2. Tear down old engine and unload old project dylib. + runtime.shutdown_engine(); + runtime.project_library.take(); + + // 3. Load the new project dylib. + let (project_library, project) = load_project(&project_path)?; + runtime.project_library = Some(project_library); + + // 4. Build a new engine β€” this calls PillProject::start() internally. + let new_engine = runtime.build_engine(project)?; + runtime.engine = Some(new_engine); + + Ok(()) + })(); + + match result { + Ok(()) => PILL_OK, + Err(error) => { + set_last_error(format!("{error}")); + PILL_ERR + } + } +} + +// --------------------------------------------------------------------------- +// ABI: Status Queries +// --------------------------------------------------------------------------- + +// Returns 1 if the engine has requested graceful shutdown (e.g. a +// benchmark that finishes after N frames), 0 otherwise. +extern "C" fn is_exit_requested(engine: EngineHandle) -> i32 { + if engine.is_null() { + return 0; + } + let runtime = unsafe { &mut *(engine as *mut Runtime) }; + match runtime.engine.as_ref() { + Some(engine) if engine.is_exit_requested() => 1, + _ => 0, + } +} + +// --------------------------------------------------------------------------- +// ABI VTable Export +// --------------------------------------------------------------------------- + +// Static vtable that pill_native reads via get_pill_engine_api_v1(). +// Every function pointer in this struct is an extern "C" function +// defined above, forming the stable ABI contract between host and runtime. +static API: PillEngineApiV1 = PillEngineApiV1 { + struct_size: std::mem::size_of::() as u32, + abi_version: PILL_ENGINE_ABI_VERSION, + abi_hash: 0, // TODO: implement this check + last_error_utf8, + create, + destroy, + update, + resize, + window_event, + key_event, + mouse_button, + mouse_delta, + cursor_position, + mouse_wheel_line, + reload_project, + is_exit_requested, +}; + +// Entry point called by pill_native (via libloading) to obtain the +// static API vtable. The host never links against pill_runtime +// directly β€” all interaction goes through this table. +#[no_mangle] +pub extern "C" fn get_pill_engine_api_v1() -> *const PillEngineApiV1 { + &API +} diff --git a/engine/pill_web/Cargo.toml b/engine/pill_web/Cargo.toml index 04ba55ef..9bd7b8b7 100644 --- a/engine/pill_web/Cargo.toml +++ b/engine/pill_web/Cargo.toml @@ -3,7 +3,7 @@ # and all wasm-only deps are gated behind `[target.'cfg(target_arch = "wasm32")']` # so workspace-wide native builds compile pill_web as a no-op. # -# Per-game wiring (the WebGame struct, embedded config.ini) still lives in +# Per-project wiring (the Project struct, embedded config.ini) still lives in # the launcher's wasm template (pill_web_app), which depends on this crate # and provides a thin `#[wasm_bindgen(start)]` shim that calls pill_web::run. @@ -16,7 +16,7 @@ edition = "2021" crate-type = ["rlib"] [target.'cfg(target_arch = "wasm32")'.dependencies] -pill_engine = { path = "../pill_engine", features = ["game", "internal"] } +pill_engine = { path = "../pill_engine", features = ["project"] } pill_renderer = { path = "../pill_renderer" } pill_core = { path = "../pill_core" } @@ -31,5 +31,5 @@ web-sys = { version = "0.3", features = [ console_error_panic_hook = "0.1" console_log = "1" log = { version = "0.4", features = ["release_max_level_off"] } -winit = { version = "0.30.12" } +winit = { version = "0.30" } getrandom = { version = "0.3", features = ["wasm_js"] } diff --git a/engine/pill_web/src/lib.rs b/engine/pill_web/src/lib.rs index add01e3c..838e8d01 100644 --- a/engine/pill_web/src/lib.rs +++ b/engine/pill_web/src/lib.rs @@ -1,6 +1,6 @@ //! Web/wasm runtime entry β€” peer to `pill_native`. Hosts the boilerplate that -//! every wasm game shares (panic hook, console_log, canvas wiring, event loop) -//! so the per-game template shim only constructs its `PillGame` impl and calls +//! every wasm project shares (panic hook, console_log, canvas wiring, event loop) +//! so the per-project template shim only constructs its `PillProject` impl and calls //! `pill_web::run(...)` with the embedded config. #![cfg(target_arch = "wasm32")] @@ -34,13 +34,13 @@ macro_rules! must { }; } -/// Boots the game on a WebGPU canvas. Call from a `#[wasm_bindgen(start)]` -/// shim in the per-game crate, after constructing the game's `PillGame` impl. +/// Boots the pill project on a WebGPU canvas. Call from a `#[wasm_bindgen(start)]` +/// shim in the per-project crate, after constructing the project's `PillProject` impl. /// -/// `config_ini` is the contents of the game's `res/config.ini`. The launcher -/// embeds it into the per-game crate via `include_str!` because wasm has no +/// `config_ini` is the contents of the project's `res/config.ini`. The launcher +/// embeds it into the per-project crate via `include_str!` because wasm has no /// filesystem at runtime. -pub fn run(game: Box, config_ini: &'static str) { +pub fn run(project: Box, config_ini: &'static str) { #[cfg(debug_assertions)] std::panic::set_hook(Box::new(console_error_panic_hook::hook)); #[cfg(debug_assertions)] @@ -52,10 +52,10 @@ pub fn run(game: Box, config_ini: &'static str) { std::env::consts::ARCH ); - wasm_bindgen_futures::spawn_local(run_async(game, config_ini)); + wasm_bindgen_futures::spawn_local(run_async(project, config_ini)); } -async fn run_async(game: Box, config_ini: &'static str) { +async fn run_async(project: Box, config_ini: &'static str) { let event_loop = must!(EventLoop::new()); let window = { @@ -91,13 +91,13 @@ async fn run_async(game: Box, config_ini: &'static str) { window_size = PhysicalSize::new(1280, 720); } - // Parse the embedded config.ini provided by the per-game shim. Wasm has + // Parse the embedded config.ini provided by the per-project shim. Wasm has // no filesystem, so the launcher inlined the bytes via include_str! at // build time. let mut config = pill_engine::internal::EngineConfig::from_ini(config_ini); config.set("WINDOW_WIDTH", window_size.width as i64); config.set("WINDOW_HEIGHT", window_size.height as i64); - let compile_mode = std::env::var("PILL_COMPILE_MODE").unwrap_or_else(|_| "unknown".to_string()); + let compile_mode = std::env::var("PILL_COMPILE_MODE").unwrap_or_else(|_| "debug".to_string()); let process = pill_engine::internal::EngineProcessInfo::new( &compile_mode, pill_engine::internal::BuildTarget::Web, @@ -110,7 +110,7 @@ async fn run_async(game: Box, config_ini: &'static str) { log::info!("Creating engine..."); let mut engine = Engine::new( - game, + project, std::path::PathBuf::from("res"), renderer, config, @@ -123,7 +123,7 @@ async fn run_async(game: Box, config_ini: &'static str) { Err(_e) => { #[cfg(debug_assertions)] panic!("engine init failed: {:#}", _e); - // Safety: if initialize() fails the game is unrunnable regardless; + // Safety: if initialize() fails the pill project is unrunnable regardless; // treat as unreachable so LLVM DCEs EngineError::fmt + flt2dec. #[cfg(not(debug_assertions))] unsafe { diff --git a/examples/Empty/Cargo.toml b/examples/Empty/Cargo.toml deleted file mode 100644 index ded105b4..00000000 --- a/examples/Empty/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "pill_game" -version = "0.1.0" -edition = "2021" -workspace = "NO_PATH" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -pill_engine = {path = "../../engine/pill_engine", features = ["game"]} diff --git a/examples/Empty/README.md b/examples/Empty/README.md deleted file mode 100644 index 4bba42b0..00000000 --- a/examples/Empty/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# Empty -Empty game project for documentation purposes \ No newline at end of file diff --git a/examples/Empty/res/config.ini b/examples/Empty/res/config.ini deleted file mode 100644 index 96131ded..00000000 --- a/examples/Empty/res/config.ini +++ /dev/null @@ -1,24 +0,0 @@ -# GAME -TITLE=Empty - -# WINDOW -WINDOW_TITLE=Empty -WINDOW_WIDTH=1200 -WINDOW_HEIGHT=800 -WINDOW_FULLSCREEN=false - -# ENGINE GENERAL -MAX_ENTITYS=1000 -MAX_CAMERAS=2 -MAX_CONCURRENT_2D_SOUNDS=10 -MAX_CONCURRENT_3D_SOUNDS=10 - -# ENGINE RESOURCES -MAX_TEXTURES=10 -MAX_MATERIALS=10 -MAX_MESHS=10 -MAX_SOUNDS=10 - -# OTHER -LOG_LEVEL=Info -PANIC_ON_GAME_ERRORS=true diff --git a/examples/Empty/src/game.rs b/examples/Empty/src/game.rs deleted file mode 100644 index 73cd283d..00000000 --- a/examples/Empty/src/game.rs +++ /dev/null @@ -1,10 +0,0 @@ -use pill_engine::game::*; - -pub struct Game { } - -impl PillGame for Game { - fn start(&self, engine: &mut Engine) -> Result<()> { - - Ok(()) - } -} \ No newline at end of file diff --git a/examples/Empty/src/lib.rs b/examples/Empty/src/lib.rs deleted file mode 100644 index c0d7b224..00000000 --- a/examples/Empty/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod game; -use pill_engine::game::create_game; - -create_game!(crate::game::Game {}, pill_engine::game::PillGame); \ No newline at end of file diff --git a/examples/Empty/.gitignore b/examples/city/.gitignore similarity index 100% rename from examples/Empty/.gitignore rename to examples/city/.gitignore diff --git a/examples/city/Cargo.toml b/examples/city/Cargo.toml new file mode 100644 index 00000000..543fb7b8 --- /dev/null +++ b/examples/city/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "project" +version = "0.1.0" +edition = "2021" +workspace = "NO_PATH" + +[lib] +crate-type = ["cdylib"] +path = "src/project.rs" + +[dependencies] +pill_engine = { path = "../../engine/pill_engine", features = [ + "project", + "obj_loading", +] } +rand = "0.8.4" + +[features] +benchmark_windowed = [] +benchmark_headless = [] +default = [] diff --git a/examples/city/README.md b/examples/city/README.md new file mode 100644 index 00000000..d7c9e3db --- /dev/null +++ b/examples/city/README.md @@ -0,0 +1,114 @@ +# City - ECS Benchmark & Project Example + +A Pill example that serves double duty: a playable city simulation in normal +mode, and a reproducible performance benchmark with two variants (windowed / headless). + +## Modes + +| Mode | Feature flag | Description | +| -------------------- | -------------------- | -------------------------------------------------------------------- | +| Normal pill project | *(none)* | Full city with plane, pill, camera, spawner, citizen movement | +| Benchmark (windowed) | `benchmark_windowed` | 10 000 citizens with PBR rendering, auto-exits after N frames | +| Benchmark (headless) | `benchmark_headless` | 10 000 citizens pure ECS, no GPU / window, auto-exits after N frames | + +The normal pill project and both benchmarks share zero code - `lib.rs` selects which file to +compile at build time via `#[path]`. + +## Quick start + +```bash +# Normal pill project +PillLauncher -a run -p examples/city + +# Windowed benchmark (needs GPU / display) +PillLauncher -a run -p examples/city --features benchmark_windowed + +# Headless benchmark (CI-friendly, no GPU) +cargo run -p pill_project --features benchmark_headless --bin city-bench-headless +``` + +## Normal pill project controls + +| Key | Action | +| ------- | -------------------------------------- | +| `Space` | Spawn a new pill-citizen at the center | + +Citizens wander the plane autonomously - each follows a queue of random waypoints +with acceleration-based movement. + +## Benchmark + +### How it works + +1. 10 000 citizens are spawned immediately on startup. +2. The simulation runs for **1000 frames** (configurable via `BENCHMARK_FRAMES` in `config.ini`). +3. The first 50 frames are discarded (ECS warmup). +4. Frame times (ms) for the remaining 950 frames are collected. +5. A JSON statistics report is printed to stdout and the engine exits automatically. + +### Statistics output + +```json +{ + "mode": "windowed", + "total_frames": 1000, + "measured_frames": 950, + "warmup_frames": 50, + "entity_count": 10000, + "stats": { + "average_ms": 2.34, + "median_ms": 2.12, + "min_ms": 1.89, + "max_ms": 5.67, + "range_ms": 3.78, + "variance": 0.45, + "stddev_ms": 0.67 + } +} +``` + +### Running via tests.sh + +```bash +# 5 iterations, aggregates results +./devops/tests/tests.sh --local benchmark-city +``` + +### Configuring frame count + +Add to `res/config.ini`: +```ini +BENCHMARK_FRAMES=2000 +``` + +## Architecture + +``` +src/ +β”œβ”€β”€ lib.rs # selects project.rs or bench.rs via #[path] + feature gates +β”œβ”€β”€ project.rs # normal city pill project (no cfg gates) +└── bench.rs # unified benchmark (shared ECS, rendering gated via cfg) +``` + +In `bench.rs`, rendering code is gated behind `#[cfg(feature = "benchmark_windowed")]`. +All ECS simulation code (citizen movement, frame counting, statistics) is shared +between both benchmark variants. + +### Components + +| Component | Purpose | +| ----------------------------- | ------------------------------------------- | +| `CitizenComponent` | Path queue, movement speed, acceleration | +| `BenchState` (benchmark only) | Frame counter, timing data, warmup tracking | + +### Engine features used + +- `Engine::request_exit()` - graceful auto-shutdown after N frames +- `Engine::frame_count()` - monotonic counter for reproducible RNG seeding +- `Engine::frame_delta_time()` - per-frame timing in milliseconds +- `Engine::config()` - reads `BENCHMARK_FRAMES` from `config.ini` + +## CI + +A dedicated `benchmark` job runs on every push to `main` and every PR. It uses +`benchmark_windowed` mode with `xvfb-run` for a virtual display on headless runners. diff --git a/examples/city/res/config.ini b/examples/city/res/config.ini new file mode 100644 index 00000000..81e5b1f3 --- /dev/null +++ b/examples/city/res/config.ini @@ -0,0 +1,24 @@ +# PROJECT +TITLE=City + +# WINDOW +WINDOW_TITLE=Pill Engine - City +WINDOW_WIDTH=1200 +WINDOW_HEIGHT=800 +WINDOW_FULLSCREEN=false + +# ENGINE GENERAL +MAX_ENTITIES=100000 +MAX_CAMERAS=2 +MAX_CONCURRENT_2D_SOUNDS=0 +MAX_CONCURRENT_3D_SOUNDS=0 + +# ENGINE RESOURCES +MAX_TEXTURES=5 +MAX_MATERIALS=5 +MAX_MESHS=5 +MAX_SOUNDS=0 + +# OTHER +#LOG_LEVELS=hot_reload=info, engine=debug, input=info, ecs=info, rendering=debug, resources=debug, frame=debug +PANIC_ON_PROJECT_ERRORS=true diff --git a/examples/city/res/models/cube.obj b/examples/city/res/models/cube.obj new file mode 100644 index 00000000..e6802162 --- /dev/null +++ b/examples/city/res/models/cube.obj @@ -0,0 +1,39 @@ +# Blender 4.4.3 +# www.blender.org +mtllib cube.mtl +o Cube +v -1.000000 -1.000000 1.000000 +v -1.000000 1.000000 1.000000 +v -1.000000 -1.000000 -1.000000 +v -1.000000 1.000000 -1.000000 +v 1.000000 -1.000000 1.000000 +v 1.000000 1.000000 1.000000 +v 1.000000 -1.000000 -1.000000 +v 1.000000 1.000000 -1.000000 +vn -1.0000 -0.0000 -0.0000 +vn -0.0000 -0.0000 -1.0000 +vn 1.0000 -0.0000 -0.0000 +vn -0.0000 -0.0000 1.0000 +vn -0.0000 -1.0000 -0.0000 +vn -0.0000 1.0000 -0.0000 +vt 0.375000 0.000000 +vt 0.625000 0.000000 +vt 0.625000 0.250000 +vt 0.375000 0.250000 +vt 0.625000 0.500000 +vt 0.375000 0.500000 +vt 0.625000 0.750000 +vt 0.375000 0.750000 +vt 0.625000 1.000000 +vt 0.375000 1.000000 +vt 0.125000 0.500000 +vt 0.125000 0.750000 +vt 0.875000 0.500000 +vt 0.875000 0.750000 +s 0 +f 1/1/1 2/2/1 4/3/1 3/4/1 +f 3/4/2 4/3/2 8/5/2 7/6/2 +f 7/6/3 8/5/3 6/7/3 5/8/3 +f 5/8/4 6/7/4 2/9/4 1/10/4 +f 3/11/5 7/6/5 5/8/5 1/12/5 +f 8/5/6 4/13/6 2/14/6 6/7/6 diff --git a/examples/city/res/models/pill.obj b/examples/city/res/models/pill.obj new file mode 100644 index 00000000..e66d03e9 --- /dev/null +++ b/examples/city/res/models/pill.obj @@ -0,0 +1,2170 @@ +# Blender v3.0.0 OBJ File: 'PillEngine.blend' +# www.blender.org +mtllib Pill.mtl +o Pill_Sphere.007 +v 0.000000 2.396242 -0.195090 +v 0.000000 2.339336 -0.382683 +v 0.000000 2.246927 -0.555570 +v 0.000000 2.122564 -0.707107 +v 0.000000 1.971027 -0.831470 +v 0.000000 1.798140 -0.923880 +v 0.000000 1.610547 -0.980785 +v 0.000000 -1.970790 -0.831470 +v 0.000000 -2.122327 -0.707107 +v 0.000000 -2.246690 -0.555570 +v 0.038060 2.396242 -0.191342 +v 0.074658 2.339336 -0.375330 +v 0.108386 2.246927 -0.544895 +v 0.137950 2.122564 -0.693520 +v 0.162212 1.971027 -0.815493 +v 0.180240 1.798140 -0.906127 +v 0.191342 1.610547 -0.961940 +v 0.191342 -1.610310 -0.961940 +v 0.180240 -1.797903 -0.906127 +v 0.162212 -1.970790 -0.815493 +v 0.137950 -2.122327 -0.693520 +v 0.108386 -2.246690 -0.544895 +v 0.074658 -2.339100 -0.375330 +v 0.038060 -2.396006 -0.191342 +v 0.074658 2.396242 -0.180240 +v 0.146447 2.339336 -0.353553 +v 0.212608 2.246927 -0.513280 +v 0.270598 2.122564 -0.653281 +v 0.318190 1.971027 -0.768178 +v 0.353553 1.798140 -0.853553 +v 0.375330 1.610547 -0.906127 +v 0.375330 -1.610310 -0.906127 +v 0.353553 -1.797903 -0.853553 +v 0.318190 -1.970790 -0.768178 +v 0.270598 -2.122327 -0.653281 +v 0.212608 -2.246690 -0.513280 +v 0.146447 -2.339100 -0.353553 +v 0.074658 -2.396006 -0.180240 +v 0.108386 2.396242 -0.162212 +v 0.212608 2.339336 -0.318190 +v 0.308658 2.246927 -0.461940 +v 0.392848 2.122564 -0.587938 +v 0.461940 1.971027 -0.691342 +v 0.513280 1.798140 -0.768178 +v 0.544895 1.610547 -0.815493 +v 0.544895 -1.610310 -0.815493 +v 0.513280 -1.797903 -0.768178 +v 0.461940 -1.970790 -0.691342 +v 0.392848 -2.122327 -0.587938 +v 0.308658 -2.246690 -0.461940 +v 0.212608 -2.339100 -0.318190 +v 0.108386 -2.396006 -0.162212 +v 0.137950 2.396242 -0.137950 +v 0.270598 2.339336 -0.270598 +v 0.392848 2.246927 -0.392847 +v 0.500000 2.122564 -0.500000 +v 0.587938 1.971027 -0.587938 +v 0.653281 1.798140 -0.653281 +v 0.693520 1.610547 -0.693520 +v 0.693520 -1.610310 -0.693520 +v 0.653282 -1.797903 -0.653281 +v 0.587938 -1.970790 -0.587938 +v 0.500000 -2.122327 -0.500000 +v 0.392848 -2.246690 -0.392847 +v 0.270598 -2.339100 -0.270598 +v 0.137950 -2.396006 -0.137950 +v 0.162212 2.396242 -0.108386 +v 0.318190 2.339336 -0.212607 +v 0.461940 2.246927 -0.308658 +v 0.587938 2.122564 -0.392847 +v 0.691342 1.971027 -0.461940 +v 0.768178 1.798140 -0.513280 +v 0.815493 1.610547 -0.544895 +v 0.815493 -1.610310 -0.544895 +v 0.768178 -1.797903 -0.513280 +v 0.691342 -1.970790 -0.461940 +v 0.587938 -2.122327 -0.392847 +v 0.461940 -2.246690 -0.308658 +v 0.318190 -2.339100 -0.212608 +v 0.162212 -2.396006 -0.108386 +v 0.180240 2.396242 -0.074658 +v 0.353554 2.339336 -0.146447 +v 0.513280 2.246927 -0.212607 +v 0.653281 2.122564 -0.270598 +v 0.768178 1.971027 -0.318190 +v 0.853553 1.798140 -0.353553 +v 0.906127 1.610547 -0.375330 +v 0.906127 -1.610310 -0.375330 +v 0.853553 -1.797903 -0.353553 +v 0.768178 -1.970790 -0.318190 +v 0.653281 -2.122327 -0.270598 +v 0.513280 -2.246690 -0.212607 +v 0.353554 -2.339100 -0.146447 +v 0.180240 -2.396006 -0.074658 +v 0.191342 2.396242 -0.038060 +v 0.375330 2.339336 -0.074658 +v 0.544895 2.246927 -0.108386 +v 0.693520 2.122564 -0.137950 +v 0.815493 1.971027 -0.162212 +v 0.906127 1.798140 -0.180240 +v 0.961940 1.610547 -0.191342 +v 0.961940 -1.610310 -0.191342 +v 0.906128 -1.797903 -0.180240 +v 0.815493 -1.970790 -0.162212 +v 0.693520 -2.122327 -0.137950 +v 0.544895 -2.246690 -0.108386 +v 0.375330 -2.339100 -0.074658 +v 0.191342 -2.396006 -0.038060 +v 0.195090 2.396242 0.000000 +v 0.382684 2.339336 0.000000 +v 0.555570 2.246927 0.000000 +v 0.707107 2.122564 0.000000 +v 0.831469 1.971027 0.000000 +v 0.923879 1.798140 0.000000 +v 0.980785 1.610547 0.000000 +v 0.980785 -1.610310 0.000000 +v 0.923880 -1.797903 0.000000 +v 0.831469 -1.970790 0.000000 +v 0.707107 -2.122327 0.000000 +v 0.555570 -2.246690 0.000000 +v 0.382684 -2.339100 0.000000 +v 0.195090 -2.396006 0.000000 +v 0.191342 2.396242 0.038060 +v 0.375330 2.339336 0.074658 +v 0.544895 2.246927 0.108386 +v 0.693520 2.122564 0.137950 +v 0.815493 1.971027 0.162212 +v 0.906127 1.798140 0.180240 +v 0.961940 1.610547 0.191342 +v 0.961940 -1.610310 0.191342 +v 0.906127 -1.797903 0.180240 +v 0.815493 -1.970790 0.162212 +v 0.693520 -2.122327 0.137950 +v 0.544895 -2.246690 0.108386 +v 0.375330 -2.339100 0.074658 +v 0.191342 -2.396006 0.038060 +v 0.180240 2.396242 0.074658 +v 0.353553 2.339336 0.146447 +v 0.513280 2.246927 0.212608 +v 0.653281 2.122564 0.270598 +v 0.768178 1.971027 0.318190 +v 0.853553 1.798140 0.353553 +v 0.906127 1.610547 0.375330 +v 0.906127 -1.610310 0.375330 +v 0.853553 -1.797903 0.353554 +v 0.768178 -1.970790 0.318190 +v 0.653281 -2.122327 0.270598 +v 0.513280 -2.246690 0.212608 +v 0.353554 -2.339100 0.146447 +v 0.180240 -2.396006 0.074658 +v 0.162212 2.396242 0.108386 +v 0.318190 2.339336 0.212608 +v 0.461940 2.246927 0.308658 +v 0.587938 2.122564 0.392847 +v 0.691341 1.971027 0.461940 +v 0.768178 1.798140 0.513280 +v 0.815493 1.610547 0.544895 +v 0.815493 -1.610310 0.544895 +v 0.768178 -1.797903 0.513280 +v 0.691342 -1.970790 0.461940 +v 0.587938 -2.122327 0.392847 +v 0.461940 -2.246690 0.308658 +v 0.318190 -2.339100 0.212608 +v 0.162212 -2.396006 0.108386 +v 0.137950 2.396242 0.137950 +v 0.270598 2.339336 0.270598 +v 0.392847 2.246927 0.392847 +v 0.500000 2.122564 0.500000 +v 0.587938 1.971027 0.587938 +v 0.653281 1.798140 0.653281 +v 0.693520 1.610547 0.693520 +v 0.693520 -1.610310 0.693520 +v 0.653281 -1.797903 0.653282 +v 0.587938 -1.970790 0.587938 +v 0.500000 -2.122327 0.500000 +v 0.392847 -2.246690 0.392847 +v 0.270598 -2.339100 0.270598 +v 0.137950 -2.396006 0.137950 +v 0.108386 2.396242 0.162212 +v 0.212608 2.339336 0.318190 +v 0.308658 2.246927 0.461940 +v 0.392847 2.122564 0.587938 +v 0.461940 1.971027 0.691342 +v 0.513280 1.798140 0.768178 +v 0.544895 1.610547 0.815493 +v 0.544895 -1.610310 0.815493 +v 0.513280 -1.797903 0.768178 +v 0.461940 -1.970790 0.691342 +v 0.392847 -2.122327 0.587938 +v 0.308658 -2.246690 0.461940 +v 0.212608 -2.339100 0.318190 +v 0.108386 -2.396006 0.162212 +v 0.074658 2.396242 0.180240 +v 0.146447 2.339336 0.353554 +v 0.212607 2.246927 0.513280 +v 0.270598 2.122564 0.653281 +v 0.318190 1.971027 0.768178 +v 0.353553 1.798140 0.853553 +v 0.375330 1.610547 0.906127 +v 0.375330 -1.610310 0.906127 +v 0.353553 -1.797903 0.853553 +v 0.318190 -1.970790 0.768178 +v 0.270598 -2.122327 0.653281 +v 0.212607 -2.246690 0.513280 +v 0.146447 -2.339100 0.353554 +v 0.074658 -2.396006 0.180240 +v 0.038060 2.396242 0.191342 +v 0.074658 2.339336 0.375330 +v 0.108386 2.246927 0.544895 +v 0.137950 2.122564 0.693520 +v 0.162212 1.971027 0.815493 +v 0.180240 1.798140 0.906127 +v 0.191342 1.610547 0.961939 +v 0.191342 -1.610310 0.961939 +v 0.180240 -1.797903 0.906127 +v 0.162212 -1.970790 0.815493 +v 0.137950 -2.122327 0.693520 +v 0.108386 -2.246690 0.544895 +v 0.074658 -2.339100 0.375330 +v 0.038060 -2.396006 0.191342 +v 0.000000 2.396242 0.195090 +v -0.000000 2.339336 0.382684 +v -0.000000 2.246927 0.555570 +v 0.000000 2.122564 0.707107 +v -0.000000 1.971027 0.831469 +v 0.000000 1.798140 0.923879 +v -0.000000 1.610547 0.980785 +v -0.000000 -1.610310 0.980785 +v -0.000000 -1.797903 0.923880 +v -0.000000 -1.970790 0.831469 +v 0.000000 -2.122327 0.707107 +v -0.000000 -2.246690 0.555570 +v 0.000000 -2.339100 0.382684 +v -0.000000 -2.396006 0.195090 +v -0.038060 2.396242 0.191342 +v -0.074658 2.339336 0.375330 +v -0.108386 2.246927 0.544895 +v -0.137950 2.122564 0.693520 +v -0.162212 1.971027 0.815493 +v -0.180240 1.798140 0.906127 +v -0.191342 1.610547 0.961939 +v -0.191342 -1.610310 0.961939 +v -0.180240 -1.797903 0.906127 +v -0.162212 -1.970790 0.815493 +v -0.137950 -2.122327 0.693520 +v -0.108386 -2.246690 0.544895 +v -0.074658 -2.339100 0.375330 +v -0.038060 -2.396006 0.191342 +v -0.074658 2.396242 0.180240 +v -0.146447 2.339336 0.353553 +v -0.212608 2.246927 0.513280 +v -0.270598 2.122564 0.653281 +v -0.318189 1.971027 0.768177 +v -0.353553 1.798140 0.853553 +v -0.375330 1.610547 0.906127 +v -0.375330 -1.610310 0.906127 +v -0.353553 -1.797903 0.853553 +v -0.318190 -1.970790 0.768178 +v -0.270598 -2.122327 0.653281 +v -0.212608 -2.246690 0.513280 +v -0.146447 -2.339100 0.353553 +v -0.074658 -2.396006 0.180240 +v -0.108386 2.396242 0.162212 +v -0.212608 2.339336 0.318190 +v -0.308658 2.246927 0.461940 +v -0.392847 2.122564 0.587938 +v -0.461940 1.971027 0.691341 +v -0.513280 1.798140 0.768178 +v -0.544895 1.610547 0.815493 +v -0.544895 -1.610310 0.815493 +v -0.513280 -1.797903 0.768178 +v -0.461940 -1.970790 0.691342 +v -0.392847 -2.122327 0.587938 +v -0.308658 -2.246690 0.461940 +v -0.212608 -2.339100 0.318190 +v -0.108386 -2.396006 0.162212 +v -0.137950 2.396242 0.137950 +v -0.270598 2.339336 0.270598 +v -0.392847 2.246927 0.392847 +v -0.500000 2.122564 0.500000 +v -0.587937 1.971027 0.587938 +v -0.653281 1.798140 0.653281 +v -0.693520 1.610547 0.693520 +v -0.693520 -1.610310 0.693520 +v -0.653281 -1.797903 0.653281 +v -0.587938 -1.970790 0.587938 +v -0.500000 -2.122327 0.500000 +v -0.392847 -2.246690 0.392847 +v -0.270598 -2.339100 0.270598 +v -0.137950 -2.396006 0.137950 +v -0.162212 2.396242 0.108386 +v -0.318190 2.339336 0.212608 +v -0.461940 2.246927 0.308658 +v -0.587938 2.122564 0.392847 +v -0.691341 1.971027 0.461940 +v -0.768178 1.798140 0.513280 +v -0.815493 1.610547 0.544895 +v -0.815493 -1.610310 0.544895 +v -0.768178 -1.797903 0.513280 +v -0.691341 -1.970790 0.461940 +v -0.587938 -2.122327 0.392847 +v -0.461940 -2.246690 0.308658 +v -0.318190 -2.339100 0.212608 +v -0.162212 -2.396006 0.108386 +v -0.000000 2.415457 0.000000 +v -0.180240 2.396242 0.074658 +v -0.353553 2.339336 0.146447 +v -0.513280 2.246927 0.212607 +v -0.653281 2.122564 0.270598 +v -0.768177 1.971027 0.318190 +v -0.853553 1.798140 0.353553 +v -0.906127 1.610547 0.375330 +v -0.906127 -1.610310 0.375330 +v -0.853553 -1.797903 0.353553 +v -0.768177 -1.970790 0.318190 +v -0.653281 -2.122327 0.270598 +v -0.513280 -2.246690 0.212607 +v -0.353553 -2.339100 0.146447 +v -0.180240 -2.396006 0.074658 +v -0.191342 2.396242 0.038060 +v -0.375330 2.339336 0.074658 +v -0.544895 2.246927 0.108386 +v -0.693520 2.122564 0.137950 +v -0.815493 1.971027 0.162212 +v -0.906127 1.798140 0.180240 +v -0.961939 1.610547 0.191342 +v -0.961939 -1.610310 0.191342 +v -0.906127 -1.797903 0.180240 +v -0.815493 -1.970790 0.162212 +v -0.693520 -2.122327 0.137950 +v -0.544895 -2.246690 0.108386 +v -0.375330 -2.339100 0.074658 +v -0.191342 -2.396006 0.038060 +v -0.195090 2.396242 0.000000 +v -0.382683 2.339336 0.000000 +v -0.555570 2.246927 -0.000000 +v -0.707107 2.122564 0.000000 +v -0.831469 1.971027 0.000000 +v -0.923879 1.798140 0.000000 +v -0.980785 1.610547 -0.000000 +v -0.980785 -1.610310 -0.000000 +v -0.923879 -1.797903 -0.000000 +v -0.831469 -1.970790 0.000000 +v -0.707107 -2.122327 0.000000 +v -0.555570 -2.246690 -0.000000 +v -0.382683 -2.339100 0.000000 +v -0.195090 -2.396006 0.000000 +v -0.191342 2.396242 -0.038060 +v -0.375330 2.339336 -0.074658 +v -0.544895 2.246927 -0.108386 +v -0.693520 2.122564 -0.137950 +v -0.815493 1.971027 -0.162212 +v -0.906127 1.798140 -0.180240 +v -0.961939 1.610547 -0.191342 +v -0.961939 -1.610310 -0.191342 +v -0.906127 -1.797903 -0.180240 +v -0.815493 -1.970790 -0.162212 +v -0.693520 -2.122327 -0.137950 +v -0.544895 -2.246690 -0.108386 +v -0.375330 -2.339100 -0.074658 +v -0.191342 -2.396006 -0.038060 +v -0.180240 2.396242 -0.074658 +v -0.353553 2.339336 -0.146447 +v -0.513280 2.246927 -0.212607 +v -0.653281 2.122564 -0.270598 +v -0.768177 1.971027 -0.318189 +v -0.853553 1.798140 -0.353553 +v -0.906127 1.610547 -0.375330 +v -0.906127 -1.610310 -0.375330 +v -0.853553 -1.797903 -0.353553 +v -0.768177 -1.970790 -0.318189 +v -0.653281 -2.122327 -0.270598 +v -0.513280 -2.246690 -0.212607 +v -0.353553 -2.339100 -0.146446 +v -0.180240 -2.396006 -0.074658 +v -0.162212 2.396242 -0.108386 +v -0.318189 2.339336 -0.212607 +v -0.461939 2.246927 -0.308658 +v -0.587938 2.122564 -0.392847 +v -0.691341 1.971027 -0.461939 +v -0.768177 1.798140 -0.513280 +v -0.815493 1.610547 -0.544895 +v -0.815493 -1.610310 -0.544895 +v -0.768177 -1.797903 -0.513280 +v -0.691341 -1.970790 -0.461939 +v -0.587938 -2.122327 -0.392847 +v -0.461939 -2.246690 -0.308658 +v -0.318189 -2.339100 -0.212607 +v -0.162212 -2.396006 -0.108386 +v -0.137950 2.396242 -0.137950 +v -0.270598 2.339336 -0.270598 +v -0.392847 2.246927 -0.392847 +v -0.500000 2.122564 -0.500000 +v -0.587937 1.971027 -0.587937 +v -0.653281 1.798140 -0.653281 +v -0.693519 1.610547 -0.693520 +v -0.693519 -1.610310 -0.693520 +v -0.653281 -1.797903 -0.653281 +v -0.587937 -1.970790 -0.587937 +v -0.500000 -2.122327 -0.500000 +v -0.392847 -2.246690 -0.392847 +v -0.270598 -2.339100 -0.270598 +v -0.137950 -2.396006 -0.137950 +v -0.108386 2.396242 -0.162212 +v -0.212607 2.339336 -0.318189 +v -0.308658 2.246927 -0.461939 +v -0.392847 2.122564 -0.587937 +v -0.461939 1.971027 -0.691341 +v -0.513280 1.798140 -0.768177 +v -0.544895 1.610547 -0.815493 +v -0.544895 -1.610310 -0.815493 +v -0.513280 -1.797903 -0.768178 +v -0.461939 -1.970790 -0.691341 +v -0.392847 -2.122327 -0.587937 +v -0.308658 -2.246690 -0.461939 +v -0.212607 -2.339100 -0.318189 +v -0.108386 -2.396006 -0.162212 +v -0.074658 2.396242 -0.180240 +v -0.146446 2.339336 -0.353553 +v -0.212607 2.246927 -0.513279 +v -0.270598 2.122564 -0.653281 +v -0.318189 1.971027 -0.768177 +v -0.353553 1.798140 -0.853553 +v -0.375330 1.610547 -0.906127 +v -0.375330 -1.610310 -0.906127 +v -0.353553 -1.797903 -0.853553 +v -0.318189 -1.970790 -0.768177 +v -0.270598 -2.122327 -0.653281 +v -0.212607 -2.246690 -0.513279 +v -0.146446 -2.339100 -0.353553 +v -0.074658 -2.396006 -0.180240 +v -0.038060 2.396242 -0.191342 +v -0.074658 2.339336 -0.375330 +v -0.108386 2.246927 -0.544895 +v -0.137950 2.122564 -0.693520 +v -0.162212 1.971027 -0.815493 +v -0.180240 1.798140 -0.906127 +v -0.191341 1.610547 -0.961939 +v -0.191341 -1.610310 -0.961939 +v -0.180240 -1.797903 -0.906127 +v -0.162212 -1.970790 -0.815493 +v -0.137950 -2.122327 -0.693520 +v -0.108386 -2.246690 -0.544895 +v -0.074658 -2.339100 -0.375330 +v -0.038060 -2.396006 -0.191342 +v 0.000000 -1.610310 -0.980785 +v 0.000000 -1.797903 -0.923879 +v 0.000000 -2.339100 -0.382683 +v 0.000000 -2.396006 -0.195090 +v 0.000000 -2.415220 0.000000 +v 0.194760 1.432671 -0.979122 +v 0.382035 1.432671 -0.922313 +v 0.554628 1.432671 -0.830060 +v 0.705908 1.432671 -0.705908 +v 0.830060 1.432671 -0.554628 +v 0.922313 1.432671 -0.382034 +v 0.979122 1.432671 -0.194759 +v 0.998304 1.432671 0.000000 +v 0.979122 1.432671 0.194760 +v 0.922313 1.432671 0.382035 +v 0.830059 1.432671 0.554628 +v 0.705907 1.432671 0.705908 +v 0.554628 1.432671 0.830060 +v 0.382034 1.432671 0.922313 +v 0.194759 1.432671 0.979122 +v -0.000000 1.432671 0.998304 +v -0.194760 1.432671 0.979122 +v -0.382035 1.432671 0.922312 +v -0.554628 1.432671 0.830059 +v -0.705908 1.432671 0.705907 +v -0.830059 1.432671 0.554628 +v -0.922312 1.432671 0.382034 +v -0.979122 1.432671 0.194759 +v -0.998304 1.432671 -0.000000 +v -0.979121 1.432671 -0.194760 +v -0.922312 1.432671 -0.382035 +v -0.830059 1.432671 -0.554628 +v -0.705907 1.432671 -0.705908 +v -0.554627 1.432671 -0.830059 +v -0.382034 1.432671 -0.922312 +v -0.194759 1.432671 -0.979121 +v 0.000000 1.432671 -0.998303 +v 0.194723 -1.434348 -0.978937 +v 0.381963 -1.434348 -0.922139 +v 0.554524 -1.434348 -0.829903 +v 0.705775 -1.434348 -0.705774 +v 0.829903 -1.434348 -0.554523 +v 0.922139 -1.434348 -0.381962 +v 0.978937 -1.434348 -0.194723 +v 0.998116 -1.434348 0.000000 +v 0.978937 -1.434348 0.194723 +v 0.922139 -1.434348 0.381962 +v 0.829903 -1.434348 0.554524 +v 0.705774 -1.434348 0.705774 +v 0.554523 -1.434348 0.829903 +v 0.381962 -1.434348 0.922139 +v 0.194722 -1.434348 0.978937 +v -0.000000 -1.434348 0.998115 +v -0.194723 -1.434348 0.978937 +v -0.381962 -1.434348 0.922138 +v -0.554523 -1.434348 0.829902 +v -0.705774 -1.434348 0.705774 +v -0.829903 -1.434348 0.554523 +v -0.922138 -1.434348 0.381962 +v -0.978937 -1.434348 0.194722 +v -0.998115 -1.434348 -0.000000 +v -0.978936 -1.434348 -0.194723 +v -0.922138 -1.434348 -0.381962 +v -0.829902 -1.434348 -0.554523 +v -0.705774 -1.434348 -0.705774 +v -0.554523 -1.434348 -0.829902 +v -0.381962 -1.434348 -0.922138 +v -0.194722 -1.434348 -0.978936 +v 0.000001 -1.434348 -0.998115 +vt 0.734354 0.136501 +vt 0.734354 0.167718 +vt 0.705722 0.167718 +vt 0.705722 0.136501 +vt 0.734354 0.829859 +vt 0.734354 0.861076 +vt 0.705722 0.861076 +vt 0.705722 0.829859 +vt 0.734354 0.105284 +vt 0.705722 0.105284 +vt 0.734354 0.798642 +vt 0.705722 0.798642 +vt 0.734354 0.074067 +vt 0.705722 0.074067 +vt 0.734354 0.767425 +vt 0.705722 0.767425 +vt 0.734354 0.042850 +vt 0.705722 0.042850 +vt 0.734354 0.738963 +vt 0.705722 0.738963 +vt 0.734354 0.954727 +vt 0.720038 0.985944 +vt 0.705722 0.954727 +vt 0.720038 0.011633 +vt 0.734354 0.923510 +vt 0.705722 0.923510 +vt 0.734354 0.198935 +vt 0.734354 0.230152 +vt 0.705722 0.230152 +vt 0.705722 0.198935 +vt 0.734354 0.892293 +vt 0.705722 0.892293 +vt 0.677090 0.198935 +vt 0.677090 0.167718 +vt 0.677090 0.892293 +vt 0.677090 0.861076 +vt 0.677090 0.136501 +vt 0.677090 0.829859 +vt 0.677090 0.105284 +vt 0.677090 0.798642 +vt 0.677090 0.074067 +vt 0.677090 0.767425 +vt 0.677090 0.042850 +vt 0.677090 0.738963 +vt 0.691406 0.985944 +vt 0.677090 0.954727 +vt 0.691406 0.011633 +vt 0.677090 0.923510 +vt 0.677090 0.230152 +vt 0.648457 0.074067 +vt 0.648457 0.042850 +vt 0.648457 0.767425 +vt 0.648458 0.738963 +vt 0.662774 0.985944 +vt 0.648457 0.954727 +vt 0.662774 0.011633 +vt 0.648457 0.923510 +vt 0.648457 0.230152 +vt 0.648457 0.198935 +vt 0.648457 0.892293 +vt 0.648457 0.167718 +vt 0.648457 0.861076 +vt 0.648457 0.136501 +vt 0.648457 0.829859 +vt 0.648457 0.105284 +vt 0.648457 0.798642 +vt 0.619825 0.892293 +vt 0.619826 0.861076 +vt 0.619826 0.167718 +vt 0.619826 0.136501 +vt 0.619826 0.829859 +vt 0.619826 0.105284 +vt 0.619826 0.798642 +vt 0.619826 0.074067 +vt 0.619826 0.767425 +vt 0.619825 0.042850 +vt 0.619826 0.738963 +vt 0.634142 0.985944 +vt 0.619825 0.954727 +vt 0.634142 0.011633 +vt 0.619825 0.923510 +vt 0.619826 0.230152 +vt 0.619826 0.198935 +vt 0.591194 0.767425 +vt 0.591194 0.738963 +vt 0.605509 0.985944 +vt 0.591193 0.954727 +vt 0.605509 0.011633 +vt 0.591193 0.042850 +vt 0.591193 0.923510 +vt 0.591194 0.230152 +vt 0.591194 0.198935 +vt 0.591194 0.892293 +vt 0.591194 0.167718 +vt 0.591194 0.861076 +vt 0.591194 0.136501 +vt 0.591194 0.829859 +vt 0.591194 0.105284 +vt 0.591194 0.798642 +vt 0.591194 0.074067 +vt 0.562562 0.167718 +vt 0.562562 0.136501 +vt 0.562562 0.861076 +vt 0.562562 0.829859 +vt 0.562562 0.105284 +vt 0.562562 0.798642 +vt 0.562562 0.074067 +vt 0.562562 0.767425 +vt 0.562562 0.042850 +vt 0.562562 0.738963 +vt 0.576877 0.985944 +vt 0.562562 0.954727 +vt 0.576877 0.011633 +vt 0.562562 0.923510 +vt 0.562562 0.230152 +vt 0.562562 0.198935 +vt 0.562562 0.892293 +vt 0.548245 0.011633 +vt 0.533929 0.042850 +vt 0.533929 0.258309 +vt 0.533929 0.738963 +vt 0.505298 0.738963 +vt 0.505297 0.258309 +vt 0.533929 0.954727 +vt 0.533929 0.923510 +vt 0.533929 0.230152 +vt 0.533929 0.198935 +vt 0.533929 0.892293 +vt 0.533929 0.167718 +vt 0.533929 0.861076 +vt 0.533929 0.136501 +vt 0.533929 0.829859 +vt 0.533929 0.105284 +vt 0.533929 0.798642 +vt 0.533929 0.074067 +vt 0.533929 0.767425 +vt 0.548245 0.985944 +vt 0.505298 0.136501 +vt 0.505297 0.105284 +vt 0.505297 0.829859 +vt 0.505298 0.798642 +vt 0.505297 0.074067 +vt 0.505298 0.767425 +vt 0.505297 0.042850 +vt 0.519613 0.985944 +vt 0.505297 0.954727 +vt 0.519613 0.011633 +vt 0.505297 0.923510 +vt 0.505298 0.230152 +vt 0.505297 0.198935 +vt 0.505297 0.892293 +vt 0.505297 0.167718 +vt 0.505298 0.861076 +vt 0.476665 0.954727 +vt 0.476665 0.923510 +vt 0.476665 0.230152 +vt 0.476665 0.198935 +vt 0.476665 0.892293 +vt 0.476665 0.167718 +vt 0.476665 0.861076 +vt 0.476665 0.136501 +vt 0.476665 0.829859 +vt 0.476665 0.105284 +vt 0.476665 0.798642 +vt 0.476665 0.074067 +vt 0.476665 0.767425 +vt 0.476665 0.042850 +vt 0.476665 0.738963 +vt 0.490981 0.985944 +vt 0.490981 0.011633 +vt 0.448033 0.829859 +vt 0.448033 0.798642 +vt 0.448033 0.105284 +vt 0.448033 0.074067 +vt 0.448033 0.767425 +vt 0.448033 0.042850 +vt 0.448033 0.738963 +vt 0.462349 0.985944 +vt 0.448033 0.954727 +vt 0.462349 0.011633 +vt 0.448033 0.923510 +vt 0.448033 0.230152 +vt 0.448033 0.198935 +vt 0.448033 0.892293 +vt 0.448033 0.167718 +vt 0.448033 0.861076 +vt 0.448033 0.136501 +vt 0.419401 0.230152 +vt 0.419401 0.198935 +vt 0.419401 0.923510 +vt 0.419401 0.892293 +vt 0.419401 0.167718 +vt 0.419401 0.861076 +vt 0.419401 0.136501 +vt 0.419401 0.829859 +vt 0.419401 0.105284 +vt 0.419401 0.798642 +vt 0.419401 0.074067 +vt 0.419401 0.767425 +vt 0.419401 0.042850 +vt 0.419401 0.738963 +vt 0.433717 0.985944 +vt 0.419401 0.954727 +vt 0.433717 0.011633 +vt 0.390769 0.105284 +vt 0.390769 0.074067 +vt 0.390769 0.798642 +vt 0.390769 0.767425 +vt 0.390769 0.042850 +vt 0.390769 0.738963 +vt 0.405085 0.985944 +vt 0.390769 0.954727 +vt 0.405085 0.011633 +vt 0.390769 0.923510 +vt 0.390769 0.230152 +vt 0.390769 0.198935 +vt 0.390769 0.892293 +vt 0.390769 0.167718 +vt 0.390769 0.861076 +vt 0.390769 0.136501 +vt 0.390769 0.829859 +vt 0.362137 0.923510 +vt 0.362137 0.892293 +vt 0.362137 0.198935 +vt 0.362137 0.167718 +vt 0.362137 0.861076 +vt 0.362137 0.136501 +vt 0.362137 0.829859 +vt 0.362137 0.105284 +vt 0.362137 0.798642 +vt 0.362137 0.074067 +vt 0.362137 0.767425 +vt 0.362137 0.042850 +vt 0.362137 0.738963 +vt 0.376453 0.985944 +vt 0.362137 0.954727 +vt 0.376453 0.011633 +vt 0.362137 0.230152 +vt 0.333505 0.798642 +vt 0.333505 0.767425 +vt 0.333505 0.074067 +vt 0.333505 0.042850 +vt 0.333505 0.738963 +vt 0.347821 0.985944 +vt 0.333505 0.954727 +vt 0.347821 0.011633 +vt 0.333505 0.923510 +vt 0.333505 0.230152 +vt 0.333505 0.198935 +vt 0.333505 0.892293 +vt 0.333505 0.167718 +vt 0.333505 0.861076 +vt 0.333505 0.136501 +vt 0.333505 0.829859 +vt 0.333505 0.105284 +vt 0.304873 0.198935 +vt 0.304873 0.167718 +vt 0.304873 0.892293 +vt 0.304873 0.861076 +vt 0.304873 0.136501 +vt 0.304873 0.829859 +vt 0.304873 0.105284 +vt 0.304873 0.798642 +vt 0.304873 0.074067 +vt 0.304873 0.767425 +vt 0.304873 0.042850 +vt 0.304873 0.738963 +vt 0.319189 0.985944 +vt 0.304873 0.954727 +vt 0.319189 0.011633 +vt 0.304873 0.923510 +vt 0.304873 0.230152 +vt 0.276241 0.074067 +vt 0.276241 0.042850 +vt 0.276241 0.767425 +vt 0.276241 0.738963 +vt 0.290557 0.985944 +vt 0.276241 0.954727 +vt 0.290557 0.011633 +vt 0.276241 0.923510 +vt 0.276241 0.230152 +vt 0.276241 0.198935 +vt 0.276241 0.892293 +vt 0.276241 0.167718 +vt 0.276241 0.861076 +vt 0.276241 0.136501 +vt 0.276241 0.829859 +vt 0.276241 0.105284 +vt 0.276241 0.798642 +vt 0.247609 0.167718 +vt 0.247609 0.136501 +vt 0.247609 0.861076 +vt 0.247609 0.829859 +vt 0.247609 0.105284 +vt 0.247609 0.798642 +vt 0.247609 0.074067 +vt 0.247609 0.767425 +vt 0.247609 0.042850 +vt 0.247609 0.738963 +vt 0.261925 0.985944 +vt 0.247609 0.954727 +vt 0.261925 0.011633 +vt 0.247609 0.923510 +vt 0.247609 0.230152 +vt 0.247609 0.198935 +vt 0.247609 0.892293 +vt 0.233293 0.985944 +vt 0.218977 0.954727 +vt 0.233293 0.011633 +vt 0.218977 0.042850 +vt 0.218977 0.258309 +vt 0.218977 0.738963 +vt 0.190345 0.738963 +vt 0.190345 0.258309 +vt 0.218977 0.923510 +vt 0.218977 0.230152 +vt 0.218977 0.198935 +vt 0.218977 0.892293 +vt 0.218977 0.167718 +vt 0.218977 0.861076 +vt 0.218977 0.136501 +vt 0.218977 0.829859 +vt 0.218977 0.105284 +vt 0.218977 0.798642 +vt 0.218977 0.074067 +vt 0.218977 0.767425 +vt 0.190345 0.861076 +vt 0.190345 0.829859 +vt 0.190345 0.136501 +vt 0.190345 0.105284 +vt 0.190345 0.798642 +vt 0.190345 0.074067 +vt 0.190345 0.767425 +vt 0.190345 0.042850 +vt 0.204661 0.985944 +vt 0.190345 0.954727 +vt 0.204661 0.011633 +vt 0.190345 0.923510 +vt 0.190345 0.230152 +vt 0.190345 0.198935 +vt 0.190345 0.892293 +vt 0.190345 0.167718 +vt 0.161713 0.258309 +vt 0.161713 0.738963 +vt 0.133081 0.738963 +vt 0.133081 0.258309 +vt 0.161713 0.954727 +vt 0.161713 0.923510 +vt 0.161713 0.230152 +vt 0.161713 0.198935 +vt 0.161713 0.892293 +vt 0.161713 0.167718 +vt 0.161713 0.861076 +vt 0.161713 0.136501 +vt 0.161713 0.829859 +vt 0.161713 0.105284 +vt 0.161713 0.798642 +vt 0.161713 0.074067 +vt 0.161713 0.767425 +vt 0.161713 0.042850 +vt 0.176029 0.985944 +vt 0.176029 0.011633 +vt 0.133081 0.136501 +vt 0.133081 0.105284 +vt 0.133081 0.829859 +vt 0.133081 0.798642 +vt 0.133081 0.074067 +vt 0.133081 0.767425 +vt 0.133081 0.042850 +vt 0.147397 0.985944 +vt 0.133081 0.954727 +vt 0.147397 0.011633 +vt 0.133081 0.923510 +vt 0.133081 0.230152 +vt 0.133081 0.198935 +vt 0.133081 0.892293 +vt 0.133081 0.167718 +vt 0.133081 0.861076 +vt 0.104449 0.954727 +vt 0.104449 0.923510 +vt 0.104449 0.230152 +vt 0.104449 0.198935 +vt 0.104449 0.892293 +vt 0.104449 0.167718 +vt 0.104449 0.861076 +vt 0.104449 0.136501 +vt 0.104449 0.829859 +vt 0.104449 0.105284 +vt 0.104449 0.798642 +vt 0.104449 0.074067 +vt 0.104449 0.767425 +vt 0.104449 0.042850 +vt 0.104449 0.738963 +vt 0.118765 0.985944 +vt 0.118765 0.011633 +vt 0.075817 0.829859 +vt 0.075817 0.798642 +vt 0.075817 0.105284 +vt 0.075817 0.074067 +vt 0.075817 0.767425 +vt 0.075817 0.042850 +vt 0.075817 0.738963 +vt 0.090133 0.985944 +vt 0.075817 0.954727 +vt 0.090133 0.011633 +vt 0.075817 0.923510 +vt 0.075817 0.230152 +vt 0.075817 0.198935 +vt 0.075817 0.892293 +vt 0.075817 0.167718 +vt 0.075817 0.861076 +vt 0.075817 0.136501 +vt 0.047185 0.230152 +vt 0.047185 0.198935 +vt 0.047185 0.923510 +vt 0.047185 0.892293 +vt 0.047185 0.167718 +vt 0.047185 0.861076 +vt 0.047185 0.136501 +vt 0.047185 0.829859 +vt 0.047185 0.105284 +vt 0.047185 0.798642 +vt 0.047185 0.074067 +vt 0.047185 0.767425 +vt 0.047185 0.042850 +vt 0.047185 0.738963 +vt 0.061501 0.985944 +vt 0.047185 0.954727 +vt 0.061501 0.011633 +vt 0.963410 0.074067 +vt 0.963410 0.105284 +vt 0.934778 0.105284 +vt 0.934778 0.074067 +vt 0.963410 0.767425 +vt 0.963410 0.798642 +vt 0.934778 0.798642 +vt 0.934778 0.767425 +vt 0.963410 0.042850 +vt 0.934778 0.042850 +vt 0.963410 0.738963 +vt 0.934778 0.738963 +vt 0.963410 0.954727 +vt 0.949094 0.985944 +vt 0.934778 0.954727 +vt 0.949094 0.011633 +vt 0.963410 0.923510 +vt 0.934778 0.923510 +vt 0.963410 0.198935 +vt 0.963410 0.230152 +vt 0.934778 0.230152 +vt 0.934778 0.198935 +vt 0.963410 0.892293 +vt 0.934778 0.892293 +vt 0.963410 0.167718 +vt 0.934778 0.167718 +vt 0.963410 0.861076 +vt 0.934778 0.861076 +vt 0.963410 0.136501 +vt 0.934778 0.136501 +vt 0.963410 0.829859 +vt 0.934778 0.829859 +vt 0.906146 0.198935 +vt 0.906146 0.167718 +vt 0.906146 0.892293 +vt 0.906146 0.861076 +vt 0.906146 0.136501 +vt 0.906146 0.829859 +vt 0.906146 0.105284 +vt 0.906146 0.798642 +vt 0.906146 0.074067 +vt 0.906146 0.767425 +vt 0.906146 0.042850 +vt 0.906146 0.738963 +vt 0.920462 0.985944 +vt 0.906146 0.954727 +vt 0.920462 0.011633 +vt 0.906146 0.923510 +vt 0.906146 0.230152 +vt 0.877514 0.074067 +vt 0.877514 0.042850 +vt 0.877514 0.767425 +vt 0.877514 0.738963 +vt 0.891830 0.985944 +vt 0.877514 0.954727 +vt 0.891830 0.011633 +vt 0.877514 0.923510 +vt 0.877514 0.230152 +vt 0.877514 0.198935 +vt 0.877514 0.892293 +vt 0.877514 0.167718 +vt 0.877514 0.861076 +vt 0.877514 0.136501 +vt 0.877514 0.829859 +vt 0.877514 0.105284 +vt 0.877514 0.798642 +vt 0.848882 0.892293 +vt 0.848882 0.861076 +vt 0.848882 0.167718 +vt 0.848882 0.136501 +vt 0.848882 0.829859 +vt 0.848882 0.105284 +vt 0.848882 0.798642 +vt 0.848882 0.074067 +vt 0.848882 0.767425 +vt 0.848882 0.042850 +vt 0.848882 0.738963 +vt 0.863198 0.985944 +vt 0.848882 0.954727 +vt 0.863198 0.011633 +vt 0.848882 0.923510 +vt 0.848882 0.230152 +vt 0.848882 0.198935 +vt 0.820250 0.767425 +vt 0.820250 0.738963 +vt 0.834566 0.985944 +vt 0.820250 0.954727 +vt 0.834566 0.011633 +vt 0.820250 0.042850 +vt 0.820250 0.923510 +vt 0.820250 0.230152 +vt 0.820250 0.198935 +vt 0.820250 0.892293 +vt 0.820250 0.167718 +vt 0.820250 0.861076 +vt 0.820250 0.136501 +vt 0.820250 0.829859 +vt 0.820250 0.105284 +vt 0.820250 0.798642 +vt 0.820250 0.074067 +vt 0.791618 0.167718 +vt 0.791618 0.136501 +vt 0.791618 0.861076 +vt 0.791618 0.829859 +vt 0.791618 0.105284 +vt 0.791618 0.798642 +vt 0.791618 0.074067 +vt 0.791618 0.767425 +vt 0.791618 0.042850 +vt 0.791617 0.738963 +vt 0.805934 0.985944 +vt 0.791618 0.954727 +vt 0.805934 0.011633 +vt 0.791618 0.923510 +vt 0.791618 0.230152 +vt 0.791618 0.198935 +vt 0.791618 0.892293 +vt 0.777302 0.985944 +vt 0.762986 0.954727 +vt 0.777302 0.011633 +vt 0.762986 0.042850 +vt 0.762986 0.258309 +vt 0.762986 0.738963 +vt 0.734353 0.258309 +vt 0.762986 0.923510 +vt 0.762986 0.230152 +vt 0.762986 0.198935 +vt 0.762986 0.892293 +vt 0.762986 0.167718 +vt 0.762986 0.861076 +vt 0.762986 0.136501 +vt 0.762986 0.829859 +vt 0.762986 0.105284 +vt 0.762986 0.798642 +vt 0.762986 0.074067 +vt 0.762986 0.767425 +vt 0.748670 0.985944 +vt 0.748670 0.011633 +vt 0.705722 0.258309 +vt 0.791617 0.258309 +vt 0.820250 0.258309 +vt 0.848882 0.258309 +vt 0.877514 0.258309 +vt 0.906146 0.258309 +vt 0.934778 0.258309 +vt 0.963410 0.258309 +vt 0.075817 0.258309 +vt 0.047185 0.258309 +vt 0.104449 0.258309 +vt 0.247609 0.258309 +vt 0.276241 0.258309 +vt 0.304873 0.258309 +vt 0.333505 0.258309 +vt 0.362137 0.258309 +vt 0.390769 0.258309 +vt 0.419401 0.258309 +vt 0.448033 0.258309 +vt 0.476665 0.258309 +vt 0.562562 0.258309 +vt 0.591194 0.258309 +vt 0.619826 0.258309 +vt 0.648457 0.258309 +vt 0.677090 0.258309 +vn 0.0000 -0.7040 -0.7101 +vn 0.0000 -0.5528 -0.8333 +vn 0.1626 -0.5528 -0.8173 +vn 0.1385 -0.7040 -0.6965 +vn 0.0000 0.5528 -0.8333 +vn 0.0000 0.7040 -0.7101 +vn 0.1385 0.7040 -0.6965 +vn 0.1626 0.5528 -0.8173 +vn 0.0000 -0.8286 -0.5598 +vn 0.1092 -0.8286 -0.5490 +vn 0.0000 0.3805 -0.9247 +vn 0.1804 0.3805 -0.9070 +vn 0.0000 -0.9217 -0.3879 +vn 0.0757 -0.9217 -0.3804 +vn 0.0000 0.1939 -0.9810 +vn 0.1914 0.1939 -0.9622 +vn 0.0000 -0.9796 -0.2010 +vn 0.0392 -0.9796 -0.1971 +vn 0.0000 0.0489 -0.9988 +vn 0.1948 0.0489 -0.9796 +vn 0.0000 0.9796 -0.2010 +vn 0.0000 1.0000 0.0000 +vn 0.0392 0.9796 -0.1971 +vn 0.0000 -1.0000 0.0000 +vn 0.0000 0.9217 -0.3879 +vn 0.0757 0.9217 -0.3804 +vn 0.0000 -0.3805 -0.9247 +vn 0.0000 -0.1939 -0.9810 +vn 0.1914 -0.1939 -0.9622 +vn 0.1804 -0.3805 -0.9070 +vn 0.0000 0.8286 -0.5598 +vn 0.1092 0.8286 -0.5490 +vn 0.3539 -0.3805 -0.8544 +vn 0.3189 -0.5528 -0.7699 +vn 0.2142 0.8286 -0.5171 +vn 0.2717 0.7040 -0.6561 +vn 0.2717 -0.7040 -0.6561 +vn 0.3189 0.5528 -0.7699 +vn 0.2142 -0.8286 -0.5171 +vn 0.3539 0.3805 -0.8544 +vn 0.1484 -0.9217 -0.3583 +vn 0.3754 0.1939 -0.9063 +vn 0.0769 -0.9796 -0.1856 +vn 0.3822 0.0489 -0.9228 +vn 0.0769 0.9796 -0.1856 +vn 0.1484 0.9217 -0.3583 +vn 0.3754 -0.1939 -0.9063 +vn 0.2155 -0.9217 -0.3225 +vn 0.1116 -0.9796 -0.1671 +vn 0.5450 0.1939 -0.8157 +vn 0.5549 0.0489 -0.8305 +vn 0.1116 0.9796 -0.1671 +vn 0.2155 0.9217 -0.3225 +vn 0.5450 -0.1939 -0.8157 +vn 0.5137 -0.3805 -0.7689 +vn 0.3110 0.8286 -0.4654 +vn 0.4630 -0.5528 -0.6929 +vn 0.3945 0.7040 -0.5904 +vn 0.3945 -0.7040 -0.5904 +vn 0.4630 0.5528 -0.6929 +vn 0.3110 -0.8286 -0.4654 +vn 0.5137 0.3805 -0.7689 +vn 0.3958 0.8286 -0.3958 +vn 0.5021 0.7040 -0.5021 +vn 0.5893 -0.5528 -0.5893 +vn 0.5021 -0.7040 -0.5021 +vn 0.5893 0.5528 -0.5893 +vn 0.3958 -0.8286 -0.3958 +vn 0.6539 0.3805 -0.6539 +vn 0.2743 -0.9217 -0.2743 +vn 0.6937 0.1939 -0.6937 +vn 0.1421 -0.9796 -0.1421 +vn 0.7063 0.0489 -0.7063 +vn 0.1421 0.9796 -0.1421 +vn 0.2743 0.9217 -0.2743 +vn 0.6937 -0.1939 -0.6937 +vn 0.6539 -0.3805 -0.6539 +vn 0.8157 0.1939 -0.5450 +vn 0.8305 0.0489 -0.5549 +vn 0.1671 0.9796 -0.1116 +vn 0.1671 -0.9796 -0.1116 +vn 0.3225 0.9217 -0.2155 +vn 0.8157 -0.1939 -0.5450 +vn 0.7689 -0.3805 -0.5137 +vn 0.4654 0.8286 -0.3110 +vn 0.6929 -0.5528 -0.4630 +vn 0.5904 0.7040 -0.3945 +vn 0.5904 -0.7040 -0.3945 +vn 0.6929 0.5528 -0.4630 +vn 0.4654 -0.8286 -0.3110 +vn 0.7689 0.3805 -0.5137 +vn 0.3225 -0.9217 -0.2155 +vn 0.7699 -0.5528 -0.3189 +vn 0.6561 -0.7040 -0.2717 +vn 0.6561 0.7040 -0.2717 +vn 0.7699 0.5528 -0.3189 +vn 0.5171 -0.8286 -0.2142 +vn 0.8544 0.3805 -0.3539 +vn 0.3583 -0.9217 -0.1484 +vn 0.9063 0.1939 -0.3754 +vn 0.1856 -0.9796 -0.0769 +vn 0.9228 0.0489 -0.3822 +vn 0.1856 0.9796 -0.0769 +vn 0.3583 0.9217 -0.1484 +vn 0.9063 -0.1939 -0.3754 +vn 0.8544 -0.3805 -0.3539 +vn 0.5171 0.8286 -0.2142 +vn 0.1971 -0.9796 -0.0392 +vn 0.9796 -0.0489 -0.1948 +vn 0.9796 0.0489 -0.1948 +vn 0.9988 0.0489 0.0000 +vn 0.9988 -0.0489 0.0000 +vn 0.1971 0.9796 -0.0392 +vn 0.3804 0.9217 -0.0757 +vn 0.9622 -0.1939 -0.1914 +vn 0.9070 -0.3805 -0.1804 +vn 0.5490 0.8286 -0.1092 +vn 0.8173 -0.5528 -0.1626 +vn 0.6965 0.7040 -0.1385 +vn 0.6965 -0.7040 -0.1385 +vn 0.8173 0.5528 -0.1626 +vn 0.5490 -0.8286 -0.1092 +vn 0.9070 0.3805 -0.1804 +vn 0.3804 -0.9217 -0.0757 +vn 0.9622 0.1939 -0.1914 +vn 0.7101 -0.7040 0.0000 +vn 0.5598 -0.8286 0.0000 +vn 0.8333 0.5528 0.0000 +vn 0.9247 0.3805 0.0000 +vn 0.3879 -0.9217 0.0000 +vn 0.9810 0.1939 0.0000 +vn 0.2010 -0.9796 0.0000 +vn 0.2010 0.9796 0.0000 +vn 0.3879 0.9217 0.0000 +vn 0.9810 -0.1939 0.0000 +vn 0.9247 -0.3805 0.0000 +vn 0.5598 0.8286 0.0000 +vn 0.8333 -0.5528 0.0000 +vn 0.7101 0.7040 0.0000 +vn 0.1971 0.9796 0.0392 +vn 0.3804 0.9217 0.0757 +vn 0.9622 -0.1939 0.1914 +vn 0.9070 -0.3805 0.1804 +vn 0.5490 0.8286 0.1092 +vn 0.8173 -0.5528 0.1626 +vn 0.6965 0.7040 0.1385 +vn 0.6965 -0.7040 0.1385 +vn 0.8173 0.5528 0.1626 +vn 0.5490 -0.8286 0.1092 +vn 0.9070 0.3805 0.1804 +vn 0.3804 -0.9217 0.0757 +vn 0.9622 0.1939 0.1914 +vn 0.1971 -0.9796 0.0392 +vn 0.9796 0.0489 0.1948 +vn 0.7699 0.5528 0.3189 +vn 0.8544 0.3805 0.3539 +vn 0.5171 -0.8286 0.2142 +vn 0.3583 -0.9217 0.1484 +vn 0.9063 0.1939 0.3754 +vn 0.1856 -0.9796 0.0769 +vn 0.9228 0.0489 0.3822 +vn 0.1856 0.9796 0.0769 +vn 0.3583 0.9217 0.1484 +vn 0.9063 -0.1939 0.3754 +vn 0.8544 -0.3805 0.3539 +vn 0.5171 0.8286 0.2142 +vn 0.7699 -0.5528 0.3189 +vn 0.6561 0.7040 0.2717 +vn 0.6561 -0.7040 0.2717 +vn 0.8157 -0.1939 0.5450 +vn 0.7689 -0.3805 0.5137 +vn 0.3225 0.9217 0.2155 +vn 0.4654 0.8286 0.3110 +vn 0.6929 -0.5528 0.4630 +vn 0.5904 0.7040 0.3945 +vn 0.5904 -0.7040 0.3945 +vn 0.6929 0.5528 0.4630 +vn 0.4654 -0.8286 0.3110 +vn 0.7689 0.3805 0.5137 +vn 0.3225 -0.9217 0.2155 +vn 0.8157 0.1939 0.5450 +vn 0.1671 -0.9796 0.1116 +vn 0.8305 0.0489 0.5549 +vn 0.1671 0.9796 0.1116 +vn 0.3958 -0.8286 0.3958 +vn 0.2743 -0.9217 0.2743 +vn 0.6539 0.3805 0.6539 +vn 0.6937 0.1939 0.6937 +vn 0.1421 -0.9796 0.1421 +vn 0.7063 0.0489 0.7063 +vn 0.1421 0.9796 0.1421 +vn 0.2743 0.9217 0.2743 +vn 0.6937 -0.1939 0.6937 +vn 0.6539 -0.3805 0.6539 +vn 0.3958 0.8286 0.3958 +vn 0.5893 -0.5528 0.5893 +vn 0.5021 0.7040 0.5021 +vn 0.5021 -0.7040 0.5021 +vn 0.5893 0.5528 0.5893 +vn 0.2155 0.9217 0.3225 +vn 0.3110 0.8286 0.4654 +vn 0.5137 -0.3805 0.7689 +vn 0.4630 -0.5528 0.6929 +vn 0.3945 0.7040 0.5904 +vn 0.3945 -0.7040 0.5904 +vn 0.4630 0.5528 0.6929 +vn 0.3110 -0.8286 0.4654 +vn 0.5137 0.3805 0.7689 +vn 0.2155 -0.9217 0.3225 +vn 0.5450 0.1939 0.8157 +vn 0.1116 -0.9796 0.1671 +vn 0.5549 0.0489 0.8305 +vn 0.1116 0.9796 0.1671 +vn 0.5450 -0.1939 0.8157 +vn 0.3539 0.3805 0.8544 +vn 0.3754 0.1939 0.9063 +vn 0.1484 -0.9217 0.3583 +vn 0.0769 -0.9796 0.1856 +vn 0.3822 0.0489 0.9228 +vn 0.0769 0.9796 0.1856 +vn 0.1484 0.9217 0.3583 +vn 0.3754 -0.1939 0.9063 +vn 0.3539 -0.3805 0.8544 +vn 0.2142 0.8286 0.5171 +vn 0.3189 -0.5528 0.7699 +vn 0.2717 0.7040 0.6561 +vn 0.2717 -0.7040 0.6561 +vn 0.3189 0.5528 0.7699 +vn 0.2142 -0.8286 0.5171 +vn 0.1804 -0.3805 0.9070 +vn 0.1626 -0.5528 0.8173 +vn 0.1092 0.8286 0.5490 +vn 0.1385 0.7040 0.6965 +vn 0.1385 -0.7040 0.6965 +vn 0.1626 0.5528 0.8173 +vn 0.1092 -0.8286 0.5490 +vn 0.1804 0.3805 0.9070 +vn 0.0757 -0.9217 0.3804 +vn 0.1914 0.1939 0.9622 +vn 0.0392 -0.9796 0.1971 +vn 0.1948 0.0489 0.9796 +vn 0.0392 0.9796 0.1971 +vn 0.0757 0.9217 0.3804 +vn 0.1914 -0.1939 0.9622 +vn 0.0000 -0.9217 0.3879 +vn 0.0000 -0.9796 0.2010 +vn 0.0000 0.1939 0.9810 +vn 0.0000 0.0489 0.9988 +vn 0.0000 0.9796 0.2010 +vn 0.0000 0.9217 0.3879 +vn 0.0000 -0.1939 0.9810 +vn 0.0000 -0.3805 0.9247 +vn 0.0000 0.8286 0.5598 +vn 0.0000 -0.5528 0.8333 +vn 0.0000 0.7040 0.7101 +vn 0.0000 -0.7040 0.7101 +vn 0.0000 0.5528 0.8333 +vn 0.0000 -0.8286 0.5598 +vn 0.0000 0.3805 0.9247 +vn -0.1626 -0.5528 0.8173 +vn -0.1385 -0.7040 0.6965 +vn -0.1385 0.7040 0.6965 +vn -0.1626 0.5528 0.8173 +vn -0.1092 -0.8286 0.5490 +vn -0.1804 0.3805 0.9070 +vn -0.0757 -0.9217 0.3804 +vn -0.1914 0.1939 0.9622 +vn -0.0392 -0.9796 0.1971 +vn -0.1948 0.0489 0.9796 +vn -0.0392 0.9796 0.1971 +vn -0.0757 0.9217 0.3804 +vn -0.1914 -0.1939 0.9622 +vn -0.1804 -0.3805 0.9070 +vn -0.1092 0.8286 0.5490 +vn -0.0769 0.9796 0.1856 +vn -0.0769 -0.9796 0.1856 +vn -0.3822 -0.0489 0.9228 +vn -0.3822 0.0489 0.9228 +vn -0.5549 0.0489 0.8305 +vn -0.5549 -0.0489 0.8305 +vn -0.1484 0.9217 0.3583 +vn -0.3754 -0.1939 0.9063 +vn -0.3539 -0.3805 0.8544 +vn -0.2142 0.8286 0.5171 +vn -0.3189 -0.5528 0.7699 +vn -0.2717 0.7040 0.6561 +vn -0.2717 -0.7040 0.6561 +vn -0.3189 0.5528 0.7699 +vn -0.2142 -0.8286 0.5171 +vn -0.3539 0.3805 0.8544 +vn -0.1484 -0.9217 0.3583 +vn -0.3754 0.1939 0.9063 +vn -0.3945 0.7040 0.5904 +vn -0.4630 0.5528 0.6929 +vn -0.3945 -0.7040 0.5904 +vn -0.3110 -0.8286 0.4654 +vn -0.5137 0.3805 0.7689 +vn -0.2155 -0.9217 0.3225 +vn -0.5450 0.1939 0.8157 +vn -0.1116 -0.9796 0.1671 +vn -0.1116 0.9796 0.1671 +vn -0.2155 0.9217 0.3225 +vn -0.5450 -0.1939 0.8157 +vn -0.5137 -0.3805 0.7689 +vn -0.3110 0.8286 0.4654 +vn -0.4630 -0.5528 0.6929 +vn -0.7063 -0.0489 0.7062 +vn -0.7063 0.0489 0.7063 +vn -0.8305 0.0489 0.5549 +vn -0.8305 -0.0489 0.5549 +vn -0.1421 0.9796 0.1421 +vn -0.2743 0.9217 0.2743 +vn -0.6937 -0.1939 0.6937 +vn -0.6539 -0.3805 0.6539 +vn -0.3958 0.8286 0.3958 +vn -0.5893 -0.5528 0.5893 +vn -0.5021 0.7040 0.5021 +vn -0.5021 -0.7040 0.5021 +vn -0.5893 0.5528 0.5893 +vn -0.3958 -0.8286 0.3958 +vn -0.6539 0.3805 0.6539 +vn -0.2743 -0.9217 0.2743 +vn -0.6937 0.1939 0.6937 +vn -0.1421 -0.9796 0.1421 +vn -0.5904 -0.7040 0.3945 +vn -0.4654 -0.8286 0.3110 +vn -0.6929 0.5528 0.4630 +vn -0.7689 0.3805 0.5137 +vn -0.3225 -0.9217 0.2155 +vn -0.8157 0.1939 0.5450 +vn -0.1671 -0.9796 0.1116 +vn -0.1671 0.9796 0.1116 +vn -0.3225 0.9217 0.2155 +vn -0.8157 -0.1939 0.5450 +vn -0.7689 -0.3805 0.5137 +vn -0.4654 0.8286 0.3110 +vn -0.6929 -0.5528 0.4630 +vn -0.5904 0.7040 0.3945 +vn -0.1856 0.9796 0.0769 +vn -0.3583 0.9217 0.1484 +vn -0.9063 -0.1939 0.3754 +vn -0.8544 -0.3805 0.3539 +vn -0.5171 0.8286 0.2142 +vn -0.7699 -0.5528 0.3189 +vn -0.6561 0.7040 0.2717 +vn -0.6561 -0.7040 0.2717 +vn -0.7699 0.5528 0.3189 +vn -0.5171 -0.8286 0.2142 +vn -0.8544 0.3805 0.3539 +vn -0.3583 -0.9217 0.1484 +vn -0.9063 0.1939 0.3754 +vn -0.1856 -0.9796 0.0769 +vn -0.9228 0.0489 0.3822 +vn -0.8173 0.5528 0.1626 +vn -0.9070 0.3805 0.1804 +vn -0.5490 -0.8286 0.1092 +vn -0.3804 -0.9217 0.0757 +vn -0.9622 0.1939 0.1914 +vn -0.1971 -0.9796 0.0392 +vn -0.9796 0.0489 0.1948 +vn -0.1971 0.9796 0.0392 +vn -0.3804 0.9217 0.0757 +vn -0.9622 -0.1939 0.1914 +vn -0.9070 -0.3805 0.1804 +vn -0.5490 0.8286 0.1092 +vn -0.8173 -0.5528 0.1626 +vn -0.6965 0.7040 0.1385 +vn -0.6965 -0.7040 0.1385 +vn -0.9810 -0.1939 0.0000 +vn -0.9247 -0.3805 0.0000 +vn -0.3879 0.9217 0.0000 +vn -0.5598 0.8286 0.0000 +vn -0.8333 -0.5528 0.0000 +vn -0.7101 0.7040 0.0000 +vn -0.7101 -0.7040 0.0000 +vn -0.8333 0.5528 0.0000 +vn -0.5598 -0.8286 0.0000 +vn -0.9247 0.3805 0.0000 +vn -0.3879 -0.9217 0.0000 +vn -0.9810 0.1939 0.0000 +vn -0.2010 -0.9796 0.0000 +vn -0.9988 0.0489 0.0000 +vn -0.2010 0.9796 0.0000 +vn -0.5490 -0.8286 -0.1092 +vn -0.3804 -0.9217 -0.0757 +vn -0.9070 0.3805 -0.1804 +vn -0.9622 0.1939 -0.1914 +vn -0.1971 -0.9796 -0.0392 +vn -0.9796 0.0489 -0.1948 +vn -0.1971 0.9796 -0.0392 +vn -0.3804 0.9217 -0.0757 +vn -0.9622 -0.1939 -0.1914 +vn -0.9070 -0.3805 -0.1804 +vn -0.5490 0.8286 -0.1092 +vn -0.8173 -0.5528 -0.1626 +vn -0.6965 0.7040 -0.1385 +vn -0.6965 -0.7040 -0.1385 +vn -0.8173 0.5528 -0.1626 +vn -0.8544 -0.3805 -0.3539 +vn -0.7699 -0.5528 -0.3189 +vn -0.5171 0.8286 -0.2142 +vn -0.6561 0.7040 -0.2717 +vn -0.6561 -0.7040 -0.2717 +vn -0.7699 0.5528 -0.3189 +vn -0.5171 -0.8286 -0.2142 +vn -0.8544 0.3805 -0.3539 +vn -0.3583 -0.9217 -0.1484 +vn -0.9063 0.1939 -0.3754 +vn -0.1856 -0.9796 -0.0769 +vn -0.9228 0.0489 -0.3822 +vn -0.1856 0.9796 -0.0769 +vn -0.3583 0.9217 -0.1484 +vn -0.9063 -0.1939 -0.3754 +vn -0.3225 -0.9217 -0.2155 +vn -0.1671 -0.9796 -0.1116 +vn -0.8157 0.1939 -0.5450 +vn -0.8305 0.0489 -0.5549 +vn -0.1671 0.9796 -0.1116 +vn -0.3225 0.9217 -0.2155 +vn -0.8157 -0.1939 -0.5450 +vn -0.7689 -0.3805 -0.5137 +vn -0.4654 0.8286 -0.3110 +vn -0.6929 -0.5528 -0.4630 +vn -0.5904 0.7040 -0.3945 +vn -0.5904 -0.7040 -0.3945 +vn -0.6929 0.5528 -0.4630 +vn -0.4654 -0.8286 -0.3110 +vn -0.7689 0.3805 -0.5137 +vn -0.3958 0.8286 -0.3958 +vn -0.5021 0.7040 -0.5021 +vn -0.5893 -0.5528 -0.5893 +vn -0.5021 -0.7040 -0.5021 +vn -0.5893 0.5528 -0.5893 +vn -0.3958 -0.8286 -0.3958 +vn -0.6539 0.3805 -0.6539 +vn -0.2743 -0.9217 -0.2743 +vn -0.6937 0.1939 -0.6937 +vn -0.1421 -0.9796 -0.1421 +vn -0.7063 0.0489 -0.7063 +vn -0.1421 0.9796 -0.1421 +vn -0.2743 0.9217 -0.2743 +vn -0.6937 -0.1939 -0.6937 +vn -0.6539 -0.3805 -0.6539 +vn -0.5450 0.1939 -0.8157 +vn -0.5549 0.0489 -0.8305 +vn -0.1116 0.9796 -0.1671 +vn -0.1116 -0.9796 -0.1671 +vn -0.2155 0.9217 -0.3225 +vn -0.5450 -0.1939 -0.8157 +vn -0.5137 -0.3805 -0.7689 +vn -0.3110 0.8286 -0.4654 +vn -0.4630 -0.5528 -0.6929 +vn -0.3945 0.7040 -0.5904 +vn -0.3945 -0.7040 -0.5904 +vn -0.4630 0.5528 -0.6929 +vn -0.3110 -0.8286 -0.4654 +vn -0.5137 0.3805 -0.7689 +vn -0.2155 -0.9217 -0.3225 +vn -0.3189 -0.5528 -0.7699 +vn -0.2717 -0.7040 -0.6561 +vn -0.2717 0.7040 -0.6561 +vn -0.3189 0.5528 -0.7699 +vn -0.2142 -0.8286 -0.5171 +vn -0.3539 0.3805 -0.8544 +vn -0.1484 -0.9217 -0.3583 +vn -0.3754 0.1939 -0.9063 +vn -0.0769 -0.9796 -0.1856 +vn -0.3822 0.0489 -0.9228 +vn -0.0769 0.9796 -0.1856 +vn -0.1484 0.9217 -0.3583 +vn -0.3754 -0.1939 -0.9063 +vn -0.3539 -0.3805 -0.8544 +vn -0.2142 0.8286 -0.5171 +vn -0.0392 0.9796 -0.1971 +vn -0.0392 -0.9796 -0.1971 +vn -0.1948 -0.0489 -0.9796 +vn -0.1948 0.0489 -0.9796 +vn 0.0000 -0.0489 -0.9988 +vn -0.0757 0.9217 -0.3804 +vn -0.1914 -0.1939 -0.9622 +vn -0.1804 -0.3805 -0.9070 +vn -0.1092 0.8286 -0.5490 +vn -0.1626 -0.5528 -0.8173 +vn -0.1385 0.7040 -0.6965 +vn -0.1385 -0.7040 -0.6965 +vn -0.1626 0.5528 -0.8173 +vn -0.1092 -0.8286 -0.5490 +vn -0.1804 0.3805 -0.9070 +vn -0.0757 -0.9217 -0.3804 +vn -0.1914 0.1939 -0.9622 +vn 0.1948 -0.0489 -0.9796 +vn -0.3822 -0.0489 -0.9228 +vn -0.5549 -0.0489 -0.8305 +vn -0.7062 -0.0489 -0.7063 +vn -0.8305 -0.0489 -0.5549 +vn -0.9228 -0.0489 -0.3822 +vn -0.9796 -0.0489 -0.1948 +vn -0.9988 -0.0489 0.0000 +vn -0.9796 -0.0489 0.1948 +vn -0.9228 -0.0489 0.3822 +vn -0.1948 -0.0489 0.9796 +vn 0.0000 -0.0489 0.9988 +vn 0.1948 -0.0489 0.9796 +vn 0.3822 -0.0489 0.9228 +vn 0.5549 -0.0489 0.8305 +vn 0.7062 -0.0489 0.7063 +vn 0.8305 -0.0489 0.5549 +vn 0.9228 -0.0489 0.3822 +vn 0.9796 -0.0489 0.1948 +vn 0.9228 -0.0489 -0.3822 +vn 0.8305 -0.0489 -0.5549 +vn 0.7062 -0.0489 -0.7062 +vn 0.5549 -0.0489 -0.8305 +vn 0.3822 -0.0489 -0.9228 +usemtl Material +s 1 +f 9/1/1 8/2/2 20/3/3 21/4/4 +f 5/5/5 4/6/6 14/7/7 15/8/8 +f 10/9/9 9/1/1 21/4/4 22/10/10 +f 6/11/11 5/5/5 15/8/8 16/12/12 +f 448/13/13 10/9/9 22/10/10 23/14/14 +f 7/15/15 6/11/11 16/12/12 17/16/16 +f 449/17/17 448/13/13 23/14/14 24/18/18 +f 482/19/19 7/15/15 17/16/16 451/20/20 +f 1/21/21 305/22/22 11/23/23 +f 450/24/24 449/17/17 24/18/18 +f 2/25/25 1/21/21 11/23/23 12/26/26 +f 447/27/27 446/28/28 18/29/29 19/30/30 +f 3/31/31 2/25/25 12/26/26 13/32/32 +f 8/2/2 447/27/27 19/30/30 20/3/3 +f 4/6/6 3/31/31 13/32/32 14/7/7 +f 20/3/3 19/30/30 33/33/33 34/34/34 +f 14/7/7 13/32/32 27/35/35 28/36/36 +f 21/4/4 20/3/3 34/34/34 35/37/37 +f 15/8/8 14/7/7 28/36/36 29/38/38 +f 22/10/10 21/4/4 35/37/37 36/39/39 +f 16/12/12 15/8/8 29/38/38 30/40/40 +f 23/14/14 22/10/10 36/39/39 37/41/41 +f 17/16/16 16/12/12 30/40/40 31/42/42 +f 24/18/18 23/14/14 37/41/41 38/43/43 +f 451/20/20 17/16/16 31/42/42 452/44/44 +f 11/23/23 305/45/22 25/46/45 +f 450/47/24 24/18/18 38/43/43 +f 12/26/26 11/23/23 25/46/45 26/48/46 +f 19/30/30 18/29/29 32/49/47 33/33/33 +f 13/32/32 12/26/26 26/48/46 27/35/35 +f 38/43/43 37/41/41 51/50/48 52/51/49 +f 452/44/44 31/42/42 45/52/50 453/53/51 +f 25/46/45 305/54/22 39/55/52 +f 450/56/24 38/43/43 52/51/49 +f 26/48/46 25/46/45 39/55/52 40/57/53 +f 33/33/33 32/49/47 46/58/54 47/59/55 +f 27/35/35 26/48/46 40/57/53 41/60/56 +f 34/34/34 33/33/33 47/59/55 48/61/57 +f 28/36/36 27/35/35 41/60/56 42/62/58 +f 35/37/37 34/34/34 48/61/57 49/63/59 +f 29/38/38 28/36/36 42/62/58 43/64/60 +f 36/39/39 35/37/37 49/63/59 50/65/61 +f 30/40/40 29/38/38 43/64/60 44/66/62 +f 37/41/41 36/39/39 50/65/61 51/50/48 +f 31/42/42 30/40/40 44/66/62 45/52/50 +f 42/62/58 41/60/56 55/67/63 56/68/64 +f 49/63/59 48/61/57 62/69/65 63/70/66 +f 43/64/60 42/62/58 56/68/64 57/71/67 +f 50/65/61 49/63/59 63/70/66 64/72/68 +f 44/66/62 43/64/60 57/71/67 58/73/69 +f 51/50/48 50/65/61 64/72/68 65/74/70 +f 45/52/50 44/66/62 58/73/69 59/75/71 +f 52/51/49 51/50/48 65/74/70 66/76/72 +f 453/53/51 45/52/50 59/75/71 454/77/73 +f 39/55/52 305/78/22 53/79/74 +f 450/80/24 52/51/49 66/76/72 +f 40/57/53 39/55/52 53/79/74 54/81/75 +f 47/59/55 46/58/54 60/82/76 61/83/77 +f 41/60/56 40/57/53 54/81/75 55/67/63 +f 48/61/57 47/59/55 61/83/77 62/69/65 +f 454/77/73 59/75/71 73/84/78 455/85/79 +f 53/79/74 305/86/22 67/87/80 +f 450/88/24 66/76/72 80/89/81 +f 54/81/75 53/79/74 67/87/80 68/90/82 +f 61/83/77 60/82/76 74/91/83 75/92/84 +f 55/67/63 54/81/75 68/90/82 69/93/85 +f 62/69/65 61/83/77 75/92/84 76/94/86 +f 56/68/64 55/67/63 69/93/85 70/95/87 +f 63/70/66 62/69/65 76/94/86 77/96/88 +f 57/71/67 56/68/64 70/95/87 71/97/89 +f 64/72/68 63/70/66 77/96/88 78/98/90 +f 58/73/69 57/71/67 71/97/89 72/99/91 +f 65/74/70 64/72/68 78/98/90 79/100/92 +f 59/75/71 58/73/69 72/99/91 73/84/78 +f 66/76/72 65/74/70 79/100/92 80/89/81 +f 77/96/88 76/94/86 90/101/93 91/102/94 +f 71/97/89 70/95/87 84/103/95 85/104/96 +f 78/98/90 77/96/88 91/102/94 92/105/97 +f 72/99/91 71/97/89 85/104/96 86/106/98 +f 79/100/92 78/98/90 92/105/97 93/107/99 +f 73/84/78 72/99/91 86/106/98 87/108/100 +f 80/89/81 79/100/92 93/107/99 94/109/101 +f 455/85/79 73/84/78 87/108/100 456/110/102 +f 67/87/80 305/111/22 81/112/103 +f 450/113/24 80/89/81 94/109/101 +f 68/90/82 67/87/80 81/112/103 82/114/104 +f 75/92/84 74/91/83 88/115/105 89/116/106 +f 69/93/85 68/90/82 82/114/104 83/117/107 +f 76/94/86 75/92/84 89/116/106 90/101/93 +f 70/95/87 69/93/85 83/117/107 84/103/95 +f 450/118/24 94/109/101 108/119/108 +f 489/120/109 457/121/110 458/122/111 490/123/112 +f 82/114/104 81/112/103 95/124/113 96/125/114 +f 89/116/106 88/115/105 102/126/115 103/127/116 +f 83/117/107 82/114/104 96/125/114 97/128/117 +f 90/101/93 89/116/106 103/127/116 104/129/118 +f 84/103/95 83/117/107 97/128/117 98/130/119 +f 91/102/94 90/101/93 104/129/118 105/131/120 +f 85/104/96 84/103/95 98/130/119 99/132/121 +f 92/105/97 91/102/94 105/131/120 106/133/122 +f 86/106/98 85/104/96 99/132/121 100/134/123 +f 93/107/99 92/105/97 106/133/122 107/135/124 +f 87/108/100 86/106/98 100/134/123 101/136/125 +f 94/109/101 93/107/99 107/135/124 108/119/108 +f 456/110/102 87/108/100 101/136/125 457/121/110 +f 81/112/103 305/137/22 95/124/113 +f 106/133/122 105/131/120 119/138/126 120/139/127 +f 100/134/123 99/132/121 113/140/128 114/141/129 +f 107/135/124 106/133/122 120/139/127 121/142/130 +f 101/136/125 100/134/123 114/141/129 115/143/131 +f 108/119/108 107/135/124 121/142/130 122/144/132 +f 457/121/110 101/136/125 115/143/131 458/122/111 +f 95/124/113 305/145/22 109/146/133 +f 450/147/24 108/119/108 122/144/132 +f 96/125/114 95/124/113 109/146/133 110/148/134 +f 103/127/116 102/126/115 116/149/135 117/150/136 +f 97/128/117 96/125/114 110/148/134 111/151/137 +f 104/129/118 103/127/116 117/150/136 118/152/138 +f 98/130/119 97/128/117 111/151/137 112/153/139 +f 105/131/120 104/129/118 118/152/138 119/138/126 +f 99/132/121 98/130/119 112/153/139 113/140/128 +f 110/148/134 109/146/133 123/154/140 124/155/141 +f 117/150/136 116/149/135 130/156/142 131/157/143 +f 111/151/137 110/148/134 124/155/141 125/158/144 +f 118/152/138 117/150/136 131/157/143 132/159/145 +f 112/153/139 111/151/137 125/158/144 126/160/146 +f 119/138/126 118/152/138 132/159/145 133/161/147 +f 113/140/128 112/153/139 126/160/146 127/162/148 +f 120/139/127 119/138/126 133/161/147 134/163/149 +f 114/141/129 113/140/128 127/162/148 128/164/150 +f 121/142/130 120/139/127 134/163/149 135/165/151 +f 115/143/131 114/141/129 128/164/150 129/166/152 +f 122/144/132 121/142/130 135/165/151 136/167/153 +f 458/122/111 115/143/131 129/166/152 459/168/154 +f 109/146/133 305/169/22 123/154/140 +f 450/170/24 122/144/132 136/167/153 +f 128/164/150 127/162/148 141/171/155 142/172/156 +f 135/165/151 134/163/149 148/173/157 149/174/158 +f 129/166/152 128/164/150 142/172/156 143/175/159 +f 136/167/153 135/165/151 149/174/158 150/176/160 +f 459/168/154 129/166/152 143/175/159 460/177/161 +f 123/154/140 305/178/22 137/179/162 +f 450/180/24 136/167/153 150/176/160 +f 124/155/141 123/154/140 137/179/162 138/181/163 +f 131/157/143 130/156/142 144/182/164 145/183/165 +f 125/158/144 124/155/141 138/181/163 139/184/166 +f 132/159/145 131/157/143 145/183/165 146/185/167 +f 126/160/146 125/158/144 139/184/166 140/186/168 +f 133/161/147 132/159/145 146/185/167 147/187/169 +f 127/162/148 126/160/146 140/186/168 141/171/155 +f 134/163/149 133/161/147 147/187/169 148/173/157 +f 145/183/165 144/182/164 158/188/170 159/189/171 +f 139/184/166 138/181/163 152/190/172 153/191/173 +f 146/185/167 145/183/165 159/189/171 160/192/174 +f 140/186/168 139/184/166 153/191/173 154/193/175 +f 147/187/169 146/185/167 160/192/174 161/194/176 +f 141/171/155 140/186/168 154/193/175 155/195/177 +f 148/173/157 147/187/169 161/194/176 162/196/178 +f 142/172/156 141/171/155 155/195/177 156/197/179 +f 149/174/158 148/173/157 162/196/178 163/198/180 +f 143/175/159 142/172/156 156/197/179 157/199/181 +f 150/176/160 149/174/158 163/198/180 164/200/182 +f 460/177/161 143/175/159 157/199/181 461/201/183 +f 137/179/162 305/202/22 151/203/184 +f 450/204/24 150/176/160 164/200/182 +f 138/181/163 137/179/162 151/203/184 152/190/172 +f 163/198/180 162/196/178 176/205/185 177/206/186 +f 157/199/181 156/197/179 170/207/187 171/208/188 +f 164/200/182 163/198/180 177/206/186 178/209/189 +f 461/201/183 157/199/181 171/208/188 462/210/190 +f 151/203/184 305/211/22 165/212/191 +f 450/213/24 164/200/182 178/209/189 +f 152/190/172 151/203/184 165/212/191 166/214/192 +f 159/189/171 158/188/170 172/215/193 173/216/194 +f 153/191/173 152/190/172 166/214/192 167/217/195 +f 160/192/174 159/189/171 173/216/194 174/218/196 +f 154/193/175 153/191/173 167/217/195 168/219/197 +f 161/194/176 160/192/174 174/218/196 175/220/198 +f 155/195/177 154/193/175 168/219/197 169/221/199 +f 162/196/178 161/194/176 175/220/198 176/205/185 +f 156/197/179 155/195/177 169/221/199 170/207/187 +f 167/217/195 166/214/192 180/222/200 181/223/201 +f 174/218/196 173/216/194 187/224/202 188/225/203 +f 168/219/197 167/217/195 181/223/201 182/226/204 +f 175/220/198 174/218/196 188/225/203 189/227/205 +f 169/221/199 168/219/197 182/226/204 183/228/206 +f 176/205/185 175/220/198 189/227/205 190/229/207 +f 170/207/187 169/221/199 183/228/206 184/230/208 +f 177/206/186 176/205/185 190/229/207 191/231/209 +f 171/208/188 170/207/187 184/230/208 185/232/210 +f 178/209/189 177/206/186 191/231/209 192/233/211 +f 462/210/190 171/208/188 185/232/210 463/234/212 +f 165/212/191 305/235/22 179/236/213 +f 450/237/24 178/209/189 192/233/211 +f 166/214/192 165/212/191 179/236/213 180/222/200 +f 173/216/194 172/215/193 186/238/214 187/224/202 +f 185/232/210 184/230/208 198/239/215 199/240/216 +f 192/233/211 191/231/209 205/241/217 206/242/218 +f 463/234/212 185/232/210 199/240/216 464/243/219 +f 179/236/213 305/244/22 193/245/220 +f 450/246/24 192/233/211 206/242/218 +f 180/222/200 179/236/213 193/245/220 194/247/221 +f 187/224/202 186/238/214 200/248/222 201/249/223 +f 181/223/201 180/222/200 194/247/221 195/250/224 +f 188/225/203 187/224/202 201/249/223 202/251/225 +f 182/226/204 181/223/201 195/250/224 196/252/226 +f 189/227/205 188/225/203 202/251/225 203/253/227 +f 183/228/206 182/226/204 196/252/226 197/254/228 +f 190/229/207 189/227/205 203/253/227 204/255/229 +f 184/230/208 183/228/206 197/254/228 198/239/215 +f 191/231/209 190/229/207 204/255/229 205/241/217 +f 202/251/225 201/249/223 215/256/230 216/257/231 +f 196/252/226 195/250/224 209/258/232 210/259/233 +f 203/253/227 202/251/225 216/257/231 217/260/234 +f 197/254/228 196/252/226 210/259/233 211/261/235 +f 204/255/229 203/253/227 217/260/234 218/262/236 +f 198/239/215 197/254/228 211/261/235 212/263/237 +f 205/241/217 204/255/229 218/262/236 219/264/238 +f 199/240/216 198/239/215 212/263/237 213/265/239 +f 206/242/218 205/241/217 219/264/238 220/266/240 +f 464/243/219 199/240/216 213/265/239 465/267/241 +f 193/245/220 305/268/22 207/269/242 +f 450/270/24 206/242/218 220/266/240 +f 194/247/221 193/245/220 207/269/242 208/271/243 +f 201/249/223 200/248/222 214/272/244 215/256/230 +f 195/250/224 194/247/221 208/271/243 209/258/232 +f 220/266/240 219/264/238 233/273/245 234/274/246 +f 465/267/241 213/265/239 227/275/247 466/276/248 +f 207/269/242 305/277/22 221/278/249 +f 450/279/24 220/266/240 234/274/246 +f 208/271/243 207/269/242 221/278/249 222/280/250 +f 215/256/230 214/272/244 228/281/251 229/282/252 +f 209/258/232 208/271/243 222/280/250 223/283/253 +f 216/257/231 215/256/230 229/282/252 230/284/254 +f 210/259/233 209/258/232 223/283/253 224/285/255 +f 217/260/234 216/257/231 230/284/254 231/286/256 +f 211/261/235 210/259/233 224/285/255 225/287/257 +f 218/262/236 217/260/234 231/286/256 232/288/258 +f 212/263/237 211/261/235 225/287/257 226/289/259 +f 219/264/238 218/262/236 232/288/258 233/273/245 +f 213/265/239 212/263/237 226/289/259 227/275/247 +f 231/286/256 230/284/254 244/290/260 245/291/261 +f 225/287/257 224/285/255 238/292/262 239/293/263 +f 232/288/258 231/286/256 245/291/261 246/294/264 +f 226/289/259 225/287/257 239/293/263 240/295/265 +f 233/273/245 232/288/258 246/294/264 247/296/266 +f 227/275/247 226/289/259 240/295/265 241/297/267 +f 234/274/246 233/273/245 247/296/266 248/298/268 +f 466/276/248 227/275/247 241/297/267 467/299/269 +f 221/278/249 305/300/22 235/301/270 +f 450/302/24 234/274/246 248/298/268 +f 222/280/250 221/278/249 235/301/270 236/303/271 +f 229/282/252 228/281/251 242/304/272 243/305/273 +f 223/283/253 222/280/250 236/303/271 237/306/274 +f 230/284/254 229/282/252 243/305/273 244/290/260 +f 224/285/255 223/283/253 237/306/274 238/292/262 +f 235/301/270 305/307/22 249/308/275 +f 450/309/24 248/298/268 262/310/276 +f 500/311/277 468/312/278 469/313/279 501/314/280 +f 236/303/271 235/301/270 249/308/275 250/315/281 +f 243/305/273 242/304/272 256/316/282 257/317/283 +f 237/306/274 236/303/271 250/315/281 251/318/284 +f 244/290/260 243/305/273 257/317/283 258/319/285 +f 238/292/262 237/306/274 251/318/284 252/320/286 +f 245/291/261 244/290/260 258/319/285 259/321/287 +f 239/293/263 238/292/262 252/320/286 253/322/288 +f 246/294/264 245/291/261 259/321/287 260/323/289 +f 240/295/265 239/293/263 253/322/288 254/324/290 +f 247/296/266 246/294/264 260/323/289 261/325/291 +f 241/297/267 240/295/265 254/324/290 255/326/292 +f 248/298/268 247/296/266 261/325/291 262/310/276 +f 467/299/269 241/297/267 255/326/292 468/312/278 +f 253/322/288 252/320/286 266/327/293 267/328/294 +f 260/323/289 259/321/287 273/329/295 274/330/296 +f 254/324/290 253/322/288 267/328/294 268/331/297 +f 261/325/291 260/323/289 274/330/296 275/332/298 +f 255/326/292 254/324/290 268/331/297 269/333/299 +f 262/310/276 261/325/291 275/332/298 276/334/300 +f 468/312/278 255/326/292 269/333/299 469/313/279 +f 249/308/275 305/335/22 263/336/301 +f 450/337/24 262/310/276 276/334/300 +f 250/315/281 249/308/275 263/336/301 264/338/302 +f 257/317/283 256/316/282 270/339/303 271/340/304 +f 251/318/284 250/315/281 264/338/302 265/341/305 +f 258/319/285 257/317/283 271/340/304 272/342/306 +f 252/320/286 251/318/284 265/341/305 266/327/293 +f 259/321/287 258/319/285 272/342/306 273/329/295 +f 502/343/307 470/344/308 471/345/309 503/346/310 +f 264/338/302 263/336/301 277/347/311 278/348/312 +f 271/340/304 270/339/303 284/349/313 285/350/314 +f 265/341/305 264/338/302 278/348/312 279/351/315 +f 272/342/306 271/340/304 285/350/314 286/352/316 +f 266/327/293 265/341/305 279/351/315 280/353/317 +f 273/329/295 272/342/306 286/352/316 287/354/318 +f 267/328/294 266/327/293 280/353/317 281/355/319 +f 274/330/296 273/329/295 287/354/318 288/356/320 +f 268/331/297 267/328/294 281/355/319 282/357/321 +f 275/332/298 274/330/296 288/356/320 289/358/322 +f 269/333/299 268/331/297 282/357/321 283/359/323 +f 276/334/300 275/332/298 289/358/322 290/360/324 +f 469/313/279 269/333/299 283/359/323 470/344/308 +f 263/336/301 305/361/22 277/347/311 +f 450/362/24 276/334/300 290/360/324 +f 288/356/320 287/354/318 301/363/325 302/364/326 +f 282/357/321 281/355/319 295/365/327 296/366/328 +f 289/358/322 288/356/320 302/364/326 303/367/329 +f 283/359/323 282/357/321 296/366/328 297/368/330 +f 290/360/324 289/358/322 303/367/329 304/369/331 +f 470/344/308 283/359/323 297/368/330 471/345/309 +f 277/347/311 305/370/22 291/371/332 +f 450/372/24 290/360/324 304/369/331 +f 278/348/312 277/347/311 291/371/332 292/373/333 +f 285/350/314 284/349/313 298/374/334 299/375/335 +f 279/351/315 278/348/312 292/373/333 293/376/336 +f 286/352/316 285/350/314 299/375/335 300/377/337 +f 280/353/317 279/351/315 293/376/336 294/378/338 +f 287/354/318 286/352/316 300/377/337 301/363/325 +f 281/355/319 280/353/317 294/378/338 295/365/327 +f 292/373/333 291/371/332 306/379/339 307/380/340 +f 299/375/335 298/374/334 313/381/341 314/382/342 +f 293/376/336 292/373/333 307/380/340 308/383/343 +f 300/377/337 299/375/335 314/382/342 315/384/344 +f 294/378/338 293/376/336 308/383/343 309/385/345 +f 301/363/325 300/377/337 315/384/344 316/386/346 +f 295/365/327 294/378/338 309/385/345 310/387/347 +f 302/364/326 301/363/325 316/386/346 317/388/348 +f 296/366/328 295/365/327 310/387/347 311/389/349 +f 303/367/329 302/364/326 317/388/348 318/390/350 +f 297/368/330 296/366/328 311/389/349 312/391/351 +f 304/369/331 303/367/329 318/390/350 319/392/352 +f 471/345/309 297/368/330 312/391/351 472/393/353 +f 291/371/332 305/394/22 306/379/339 +f 450/395/24 304/369/331 319/392/352 +f 311/389/349 310/387/347 324/396/354 325/397/355 +f 318/390/350 317/388/348 331/398/356 332/399/357 +f 312/391/351 311/389/349 325/397/355 326/400/358 +f 319/392/352 318/390/350 332/399/357 333/401/359 +f 472/393/353 312/391/351 326/400/358 473/402/360 +f 306/379/339 305/403/22 320/404/361 +f 450/405/24 319/392/352 333/401/359 +f 307/380/340 306/379/339 320/404/361 321/406/362 +f 314/382/342 313/381/341 327/407/363 328/408/364 +f 308/383/343 307/380/340 321/406/362 322/409/365 +f 315/384/344 314/382/342 328/408/364 329/410/366 +f 309/385/345 308/383/343 322/409/365 323/411/367 +f 316/386/346 315/384/344 329/410/366 330/412/368 +f 310/387/347 309/385/345 323/411/367 324/396/354 +f 317/388/348 316/386/346 330/412/368 331/398/356 +f 328/408/364 327/407/363 341/413/369 342/414/370 +f 322/409/365 321/406/362 335/415/371 336/416/372 +f 329/410/366 328/408/364 342/414/370 343/417/373 +f 323/411/367 322/409/365 336/416/372 337/418/374 +f 330/412/368 329/410/366 343/417/373 344/419/375 +f 324/396/354 323/411/367 337/418/374 338/420/376 +f 331/398/356 330/412/368 344/419/375 345/421/377 +f 325/397/355 324/396/354 338/420/376 339/422/378 +f 332/399/357 331/398/356 345/421/377 346/423/379 +f 326/400/358 325/397/355 339/422/378 340/424/380 +f 333/401/359 332/399/357 346/423/379 347/425/381 +f 473/402/360 326/400/358 340/424/380 474/426/382 +f 320/404/361 305/427/22 334/428/383 +f 450/429/24 333/401/359 347/425/381 +f 321/406/362 320/404/361 334/428/383 335/415/371 +f 346/430/379 345/431/377 359/432/384 360/433/385 +f 340/434/380 339/435/378 353/436/386 354/437/387 +f 347/438/381 346/430/379 360/433/385 361/439/388 +f 474/440/382 340/434/380 354/437/387 475/441/389 +f 334/442/383 305/443/22 348/444/390 +f 450/445/24 347/438/381 361/439/388 +f 335/446/371 334/442/383 348/444/390 349/447/391 +f 342/448/370 341/449/369 355/450/392 356/451/393 +f 336/452/372 335/446/371 349/447/391 350/453/394 +f 343/454/373 342/448/370 356/451/393 357/455/395 +f 337/456/374 336/452/372 350/453/394 351/457/396 +f 344/458/375 343/454/373 357/455/395 358/459/397 +f 338/460/376 337/456/374 351/457/396 352/461/398 +f 345/431/377 344/458/375 358/459/397 359/432/384 +f 339/435/378 338/460/376 352/461/398 353/436/386 +f 357/455/395 356/451/393 370/462/399 371/463/400 +f 351/457/396 350/453/394 364/464/401 365/465/402 +f 358/459/397 357/455/395 371/463/400 372/466/403 +f 352/461/398 351/457/396 365/465/402 366/467/404 +f 359/432/384 358/459/397 372/466/403 373/468/405 +f 353/436/386 352/461/398 366/467/404 367/469/406 +f 360/433/385 359/432/384 373/468/405 374/470/407 +f 354/437/387 353/436/386 367/469/406 368/471/408 +f 361/439/388 360/433/385 374/470/407 375/472/409 +f 475/441/389 354/437/387 368/471/408 476/473/410 +f 348/444/390 305/474/22 362/475/411 +f 450/476/24 361/439/388 375/472/409 +f 349/447/391 348/444/390 362/475/411 363/477/412 +f 356/451/393 355/450/392 369/478/413 370/462/399 +f 350/453/394 349/447/391 363/477/412 364/464/401 +f 375/472/409 374/470/407 388/479/414 389/480/415 +f 476/473/410 368/471/408 382/481/416 477/482/417 +f 362/475/411 305/483/22 376/484/418 +f 450/485/24 375/472/409 389/480/415 +f 363/477/412 362/475/411 376/484/418 377/486/419 +f 370/462/399 369/478/413 383/487/420 384/488/421 +f 364/464/401 363/477/412 377/486/419 378/489/422 +f 371/463/400 370/462/399 384/488/421 385/490/423 +f 365/465/402 364/464/401 378/489/422 379/491/424 +f 372/466/403 371/463/400 385/490/423 386/492/425 +f 366/467/404 365/465/402 379/491/424 380/493/426 +f 373/468/405 372/466/403 386/492/425 387/494/427 +f 367/469/406 366/467/404 380/493/426 381/495/428 +f 374/470/407 373/468/405 387/494/427 388/479/414 +f 368/471/408 367/469/406 381/495/428 382/481/416 +f 379/491/424 378/489/422 392/496/429 393/497/430 +f 386/492/425 385/490/423 399/498/431 400/499/432 +f 380/493/426 379/491/424 393/497/430 394/500/433 +f 387/494/427 386/492/425 400/499/432 401/501/434 +f 381/495/428 380/493/426 394/500/433 395/502/435 +f 388/479/414 387/494/427 401/501/434 402/503/436 +f 382/481/416 381/495/428 395/502/435 396/504/437 +f 389/480/415 388/479/414 402/503/436 403/505/438 +f 477/482/417 382/481/416 396/504/437 478/506/439 +f 376/484/418 305/507/22 390/508/440 +f 450/509/24 389/480/415 403/505/438 +f 377/486/419 376/484/418 390/508/440 391/510/441 +f 384/488/421 383/487/420 397/511/442 398/512/443 +f 378/489/422 377/486/419 391/510/441 392/496/429 +f 385/490/423 384/488/421 398/512/443 399/498/431 +f 478/506/439 396/504/437 410/513/444 479/514/445 +f 390/508/440 305/515/22 404/516/446 +f 450/517/24 403/505/438 417/518/447 +f 391/510/441 390/508/440 404/516/446 405/519/448 +f 398/512/443 397/511/442 411/520/449 412/521/450 +f 392/496/429 391/510/441 405/519/448 406/522/451 +f 399/498/431 398/512/443 412/521/450 413/523/452 +f 393/497/430 392/496/429 406/522/451 407/524/453 +f 400/499/432 399/498/431 413/523/452 414/525/454 +f 394/500/433 393/497/430 407/524/453 408/526/455 +f 401/501/434 400/499/432 414/525/454 415/527/456 +f 395/502/435 394/500/433 408/526/455 409/528/457 +f 402/503/436 401/501/434 415/527/456 416/529/458 +f 396/504/437 395/502/435 409/528/457 410/513/444 +f 403/505/438 402/503/436 416/529/458 417/518/447 +f 414/525/454 413/523/452 427/530/459 428/531/460 +f 408/526/455 407/524/453 421/532/461 422/533/462 +f 415/527/456 414/525/454 428/531/460 429/534/463 +f 409/528/457 408/526/455 422/533/462 423/535/464 +f 416/529/458 415/527/456 429/534/463 430/536/465 +f 410/513/444 409/528/457 423/535/464 424/537/466 +f 417/518/447 416/529/458 430/536/465 431/538/467 +f 479/514/445 410/513/444 424/537/466 480/539/468 +f 404/516/446 305/540/22 418/541/469 +f 450/542/24 417/518/447 431/538/467 +f 405/519/448 404/516/446 418/541/469 419/543/470 +f 412/521/450 411/520/449 425/544/471 426/545/472 +f 406/522/451 405/519/448 419/543/470 420/546/473 +f 413/523/452 412/521/450 426/545/472 427/530/459 +f 407/524/453 406/522/451 420/546/473 421/532/461 +f 418/541/469 305/547/22 432/548/474 +f 450/549/24 431/538/467 445/550/475 +f 513/551/476 481/552/477 482/19/19 514/553/478 +f 419/543/470 418/541/469 432/548/474 433/554/479 +f 426/545/472 425/544/471 439/555/480 440/556/481 +f 420/546/473 419/543/470 433/554/479 434/557/482 +f 427/530/459 426/545/472 440/556/481 441/558/483 +f 421/532/461 420/546/473 434/557/482 435/559/484 +f 428/531/460 427/530/459 441/558/483 442/560/485 +f 422/533/462 421/532/461 435/559/484 436/561/486 +f 429/534/463 428/531/460 442/560/485 443/562/487 +f 423/535/464 422/533/462 436/561/486 437/563/488 +f 430/536/465 429/534/463 443/562/487 444/564/489 +f 424/537/466 423/535/464 437/563/488 438/565/490 +f 431/538/467 430/536/465 444/564/489 445/550/475 +f 480/539/468 424/537/466 438/565/490 481/552/477 +f 436/561/486 435/559/484 4/6/6 5/5/5 +f 443/562/487 442/560/485 9/1/1 10/9/9 +f 437/563/488 436/561/486 5/5/5 6/11/11 +f 444/564/489 443/562/487 10/9/9 448/13/13 +f 438/565/490 437/563/488 6/11/11 7/15/15 +f 445/550/475 444/564/489 448/13/13 449/17/17 +f 481/552/477 438/565/490 7/15/15 482/19/19 +f 432/548/474 305/566/22 1/21/21 +f 450/567/24 445/550/475 449/17/17 +f 433/554/479 432/548/474 1/21/21 2/25/25 +f 440/556/481 439/555/480 446/28/28 447/27/27 +f 434/557/482 433/554/479 2/25/25 3/31/31 +f 441/558/483 440/556/481 447/27/27 8/2/2 +f 435/559/484 434/557/482 3/31/31 4/6/6 +f 442/560/485 441/558/483 8/2/2 9/1/1 +f 514/553/478 482/19/19 451/20/20 483/568/491 +f 480/539/468 481/552/477 513/551/476 512/569/492 +f 511/570/493 479/514/445 480/539/468 512/569/492 +f 510/571/494 478/506/439 479/514/445 511/570/493 +f 509/572/495 477/482/417 478/506/439 510/571/494 +f 508/573/496 476/473/410 477/482/417 509/572/495 +f 507/574/497 475/441/389 476/473/410 508/573/496 +f 506/575/498 474/440/382 475/441/389 507/574/497 +f 505/576/499 473/402/360 474/426/382 506/577/498 +f 504/578/500 472/393/353 473/402/360 505/576/499 +f 503/346/310 471/345/309 472/393/353 504/578/500 +f 469/313/279 470/344/308 502/343/307 501/314/280 +f 467/299/269 468/312/278 500/311/277 499/579/501 +f 498/580/502 466/276/248 467/299/269 499/579/501 +f 497/581/503 465/267/241 466/276/248 498/580/502 +f 496/582/504 464/243/219 465/267/241 497/581/503 +f 495/583/505 463/234/212 464/243/219 496/582/504 +f 494/584/506 462/210/190 463/234/212 495/583/505 +f 493/585/507 461/201/183 462/210/190 494/584/506 +f 492/586/508 460/177/161 461/201/183 493/585/507 +f 491/587/509 459/168/154 460/177/161 492/586/508 +f 490/123/112 458/122/111 459/168/154 491/587/509 +f 456/110/102 457/121/110 489/120/109 488/588/510 +f 487/589/511 455/85/79 456/110/102 488/588/510 +f 486/590/512 454/77/73 455/85/79 487/589/511 +f 485/591/513 453/53/51 454/77/73 486/590/512 +f 484/592/514 452/44/44 453/53/51 485/591/513 +f 439/555/480 513/551/476 514/553/478 446/28/28 +f 425/544/471 512/569/492 513/551/476 439/555/480 +f 411/520/449 511/570/493 512/569/492 425/544/471 +f 397/511/442 510/571/494 511/570/493 411/520/449 +f 383/487/420 509/572/495 510/571/494 397/511/442 +f 369/478/413 508/573/496 509/572/495 383/487/420 +f 355/450/392 507/574/497 508/573/496 369/478/413 +f 341/449/369 506/575/498 507/574/497 355/450/392 +f 327/407/363 505/576/499 506/577/498 341/413/369 +f 313/381/341 504/578/500 505/576/499 327/407/363 +f 298/374/334 503/346/310 504/578/500 313/381/341 +f 284/349/313 502/343/307 503/346/310 298/374/334 +f 270/339/303 501/314/280 502/343/307 284/349/313 +f 256/316/282 500/311/277 501/314/280 270/339/303 +f 242/304/272 499/579/501 500/311/277 256/316/282 +f 228/281/251 498/580/502 499/579/501 242/304/272 +f 214/272/244 497/581/503 498/580/502 228/281/251 +f 200/248/222 496/582/504 497/581/503 214/272/244 +f 186/238/214 495/583/505 496/582/504 200/248/222 +f 172/215/193 494/584/506 495/583/505 186/238/214 +f 158/188/170 493/585/507 494/584/506 172/215/193 +f 144/182/164 492/586/508 493/585/507 158/188/170 +f 130/156/142 491/587/509 492/586/508 144/182/164 +f 116/149/135 490/123/112 491/587/509 130/156/142 +f 102/126/115 489/120/109 490/123/112 116/149/135 +f 88/115/105 488/588/510 489/120/109 102/126/115 +f 74/91/83 487/589/511 488/588/510 88/115/105 +f 60/82/76 486/590/512 487/589/511 74/91/83 +f 46/58/54 485/591/513 486/590/512 60/82/76 +f 32/49/47 484/592/514 485/591/513 46/58/54 +f 18/29/29 483/568/491 484/592/514 32/49/47 +f 446/28/28 514/553/478 483/568/491 18/29/29 +f 483/568/491 451/20/20 452/44/44 484/592/514 diff --git a/examples/city/res/models/plane.obj b/examples/city/res/models/plane.obj new file mode 100644 index 00000000..0b797c0d --- /dev/null +++ b/examples/city/res/models/plane.obj @@ -0,0 +1,15 @@ +# Blender 4.5.5 LTS +# www.blender.org +mtllib plane.mtl +o Plane +v -10.000000 0.000000 10.000000 +v 10.000000 0.000000 10.000000 +v -10.000000 0.000000 -10.000000 +v 10.000000 0.000000 -10.000000 +vn -0.0000 1.0000 -0.0000 +vt 0.000000 0.000000 +vt 1.000000 0.000000 +vt 1.000000 1.000000 +vt 0.000000 1.000000 +s 0 +f 1/1/1 2/2/1 4/3/1 3/4/1 diff --git a/examples/city/src/benchmark.rs b/examples/city/src/benchmark.rs new file mode 100644 index 00000000..1927f99c --- /dev/null +++ b/examples/city/src/benchmark.rs @@ -0,0 +1,267 @@ +//! Benchmark mode for the city example. +//! +//! Compiled via `--features benchmark_windowed` or `--features benchmark_headless`. +//! Spawns 10 000 citizens, runs the simulation for N frames, collects per‑frame +//! timing statistics, prints a JSON report to stdout, and exits automatically. + +use pill_engine::{define_global_component, project::*}; +use rand::{rngs::StdRng, Rng, SeedableRng}; +use std::time::Instant; + +use crate::shared; + +// -- Constants --------------------------------------------------------------- + +const BENCHMARK_CITIZEN_COUNT: usize = 10_000; +const BENCHMARK_DEFAULT_MAX_FRAMES: u64 = 5_000; +const BENCHMARK_WARMUP_FRAMES: u64 = 1_000; +const BENCHMARK_RNG_SEED: Option = Some(42); + +// -- Components -------------------------------------------------------------- + +define_global_component!(BenchmarkState { + current_frame: u64, + max_frames: u64, + frame_times_ms: Vec, + benchmark_start: Option, +}); + +// -- Project -------------------------------------------------------------------- + +pub struct Project {} + +impl PillProject for Project { + fn start(&self, engine: &mut Engine) -> Result<()> { + let max_frames = BENCHMARK_DEFAULT_MAX_FRAMES; + + let scene = engine.create_scene("benchmark")?; + engine.set_active_scene(scene)?; + + engine.register_component::(scene)?; + engine.register_component::(scene)?; + + // -- Windowed-only: rendering setup ------------------------------ + #[cfg(feature = "benchmark_windowed")] + { + engine.register_component::(scene)?; + engine.register_component::(scene)?; + + let gray_material = engine.add_resource::( + Material::builder(shared::GRAY_MATERIAL_NAME) + .color_parameter("tint", shared::GRAY_MATERIAL_TINT)? + .build(), + )?; + let _orange = engine.add_resource::( + Material::builder(shared::ORANGE_MATERIAL_NAME) + .color_parameter("tint", shared::ORANGE_MATERIAL_TINT)? + .build(), + )?; + let plane_mesh_handle = engine.add_resource(Mesh::new( + shared::PLANE_MESH_NAME, + shared::PLANE_MESH_PATH.into(), + ))?; + let _pill = engine.add_resource(Mesh::new( + shared::PILL_MESH_NAME, + shared::PILL_MESH_PATH.into(), + ))?; + + // Ground plane + engine + .build_entity(scene) + .with_component( + TransformComponent::builder() + .position(Vector3f::new(0.0, -2.0, 0.0)) + .scale(Vector3f::new(20.0, 1.0, 20.0)) + .build(), + ) + .with_component( + MeshRenderingComponent::builder() + .material(&gray_material) + .mesh(&plane_mesh_handle) + .build(), + ) + .build(); + + // Camera + engine + .build_entity(scene) + .with_component( + TransformComponent::builder() + .position(shared::CAMERA_POSITION) + .rotation(shared::CAMERA_ROTATION) + .build(), + ) + .with_component( + CameraComponent::builder() + .enabled(true) + .fov(55.0) + .clear_color(Color::new(0.08, 0.12, 0.18)) + .build(), + ) + .build(); + } + + engine.add_system("benchmark_citizen_move", benchmark_citizen_move_system)?; + engine.add_system("benchmark_frame", benchmark_frame_system)?; + + spawn_citizens(engine, scene, BENCHMARK_CITIZEN_COUNT)?; + + engine.add_global_component(BenchmarkState { + current_frame: 0, + max_frames, + frame_times_ms: Vec::with_capacity(max_frames as usize), + benchmark_start: None, + })?; + + Ok(()) + } +} + +// -- Systems ----------------------------------------------------------------- + +fn benchmark_citizen_move_system(engine: &mut Engine) -> Result<()> { + let mut rng: StdRng = match BENCHMARK_RNG_SEED { + Some(seed) => StdRng::seed_from_u64(seed.wrapping_add(engine.frame_count())), + None => StdRng::seed_from_u64(0), + }; + shared::move_citizens_toward_targets(engine, &mut rng) +} + +fn benchmark_frame_system(engine: &mut Engine) -> Result<()> { + let delta_time = engine.frame_delta_time(); + let state = engine.get_global_component_mut::()?; + state.current_frame += 1; + + if state.current_frame > BENCHMARK_WARMUP_FRAMES { + if state.benchmark_start.is_none() { + state.benchmark_start = Some(Instant::now()); + } + state.frame_times_ms.push(delta_time); + } + + if state.current_frame >= state.max_frames { + print_report(state); + engine.request_exit(); + } + + Ok(()) +} + +// -- Helpers ----------------------------------------------------------------- + +fn spawn_citizens(engine: &mut Engine, scene: SceneHandle, count: usize) -> Result<()> { + let mut rng: StdRng = match BENCHMARK_RNG_SEED { + Some(seed) => StdRng::seed_from_u64(seed), + None => StdRng::seed_from_u64(0), + }; + + // Windowed mode: citizens get PBR renderable components. + // Headless mode: citizens are pure simulation entities. + #[cfg(feature = "benchmark_windowed")] + let (orange_material, pill_mesh) = { + let material = engine.get_resource_handle::("orange")?; + let mesh = engine.get_resource_handle::("pill")?; + (material, mesh) + }; + + for _ in 0..count { + let mut builder = engine.build_entity(scene); + + builder = builder + .with_component(TransformComponent::new()) + .with_component(shared::CitizenComponent { + path_points: std::collections::VecDeque::new(), + current_movement_speed: shared::CITIZEN_INITIAL_SPEED, + max_movement_speed: rng + .gen_range(shared::CITIZEN_MAX_SPEED_MIN..shared::CITIZEN_MAX_SPEED_MAX), + acceleration: rng + .gen_range(shared::CITIZEN_ACCELERATION_MIN..shared::CITIZEN_ACCELERATION_MAX), + facing_angle: rng.gen_range(0.0..360.0), + rotation_speed: rng.gen_range( + shared::CITIZEN_ROTATION_SPEED_MIN..shared::CITIZEN_ROTATION_SPEED_MAX, + ), + }); + + #[cfg(feature = "benchmark_windowed")] + { + builder = builder.with_component( + MeshRenderingComponent::builder() + .material(&orange_material) + .mesh(&pill_mesh) + .build(), + ); + } + + builder.build(); + } + + Ok(()) +} + +// -- Statistics -------------------------------------------------------------- + +fn print_report(state: &BenchmarkState) { + let times = &state.frame_times_ms; + if times.is_empty() { + return; + } + + let sample_count = times.len() as f64; + let sum: f32 = times.iter().sum(); + let average = sum / sample_count as f32; + + let mut sorted = times.clone(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let median = if sorted.len() % 2 == 0 { + (sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2.0 + } else { + sorted[sorted.len() / 2] + }; + + let min = sorted.first().copied().unwrap_or(0.0); + let max = sorted.last().copied().unwrap_or(0.0); + let range = max - min; + + let variance: f32 = + times.iter().map(|t| (t - average).powi(2)).sum::() / sample_count as f32; + let standard_deviation = variance.sqrt(); + + // Pick the mode string based on which feature is active. + #[cfg(feature = "benchmark_windowed")] + let mode = "windowed"; + #[cfg(feature = "benchmark_headless")] + let mode = "headless"; + + println!( + concat!( + "{{", + "\"mode\":\"{md}\",", + "\"total_frames\":{total},", + "\"measured_frames\":{measured},", + "\"warmup_frames\":{warmup},", + "\"entity_count\":{entities},", + "\"stats\":{{", + "\"average_ms\":{avg:.3},", + "\"median_ms\":{med:.3},", + "\"min_ms\":{min:.3},", + "\"max_ms\":{max:.3},", + "\"range_ms\":{range:.3},", + "\"variance\":{var:.6},", + "\"stddev_ms\":{std:.3}", + "}}", + "}}" + ), + md = mode, + total = state.current_frame, + measured = times.len(), + warmup = BENCHMARK_WARMUP_FRAMES, + entities = BENCHMARK_CITIZEN_COUNT, + avg = average, + med = median, + min = min, + max = max, + range = range, + var = variance, + std = standard_deviation, + ); +} diff --git a/examples/city/src/game.rs b/examples/city/src/game.rs new file mode 100644 index 00000000..a26c5378 --- /dev/null +++ b/examples/city/src/game.rs @@ -0,0 +1,157 @@ +use pill_engine::project::*; +use rand::{thread_rng, Rng}; +use std::collections::VecDeque; + +use crate::shared; + +// --- Input --- +pub const SPAWN_PILL_BUTTON: KeyboardKey = KeyboardKey::Space; + +pub struct Project {} + +impl PillProject for Project { + fn start(&self, engine: &mut Engine) -> Result<()> { + // --- Basic setup --- + let active_scene = engine.create_scene("default")?; + engine.set_active_scene(active_scene)?; + + // Register components + engine.register_component::(active_scene)?; + engine.register_component::(active_scene)?; + engine.register_component::(active_scene)?; + engine.register_component::(active_scene)?; + + // Add systems + engine.add_system("pill_spawner", pill_spawner_system)?; + engine.add_system("citizen_movement", citizen_movement_system)?; + + // --- Create resources --- + let gray_material_handle = engine.add_resource::( + Material::builder(shared::GRAY_MATERIAL_NAME) + .color_parameter("tint", shared::GRAY_MATERIAL_TINT)? + .build(), + )?; + + let orange_material_handle = engine.add_resource::( + Material::builder(shared::ORANGE_MATERIAL_NAME) + .color_parameter("tint", shared::ORANGE_MATERIAL_TINT)? + .build(), + )?; + + // Mesh for the ground plane + let plane_mesh_handle = engine.add_resource(Mesh::new( + shared::PLANE_MESH_NAME, + shared::PLANE_MESH_PATH.into(), + ))?; + + // Mesh for the pill + let pill_mesh_handle = engine.add_resource(Mesh::new( + shared::PILL_MESH_NAME, + shared::PILL_MESH_PATH.into(), + ))?; + + // --- Create entities --- + + // Ground plane (cube scaled very flat, gray) + engine + .build_entity(active_scene) + .with_component( + TransformComponent::builder() + .position(shared::PLANE_POSITION) + .scale(shared::PLANE_SCALE) + .build(), + ) + .with_component( + MeshRenderingComponent::builder() + .material(&gray_material_handle) + .mesh(&plane_mesh_handle) + .build(), + ) + .build(); + + // Initial pill at center, orange + engine + .build_entity(active_scene) + .with_component( + TransformComponent::builder() + .position(shared::WORLD_ORIGIN) + .build(), + ) + .with_component( + MeshRenderingComponent::builder() + .material(&orange_material_handle) + .mesh(&pill_mesh_handle) + .build(), + ) + .build(); + + // Camera at 45Β° diagonal perspective looking at origin + engine + .build_entity(active_scene) + .with_component( + TransformComponent::builder() + .position(shared::CAMERA_POSITION) + .rotation(shared::CAMERA_ROTATION) + .build(), + ) + .with_component( + CameraComponent::builder() + .enabled(true) + .fov(shared::CAMERA_FOV) + .fog_density(0.0) + .fog_color(Color::new(0.0, 0.0, 0.0)) + .clear_color(shared::CLEAR_COLOR) + .build(), + ) + .build(); + + Ok(()) + } +} + +// --- Systems --- + +fn pill_spawner_system(engine: &mut Engine) -> Result<()> { + let input = engine.get_global_component::()?; + + if input.get_key_pressed(SPAWN_PILL_BUTTON) { + let scene = engine.get_active_scene_handle()?; + let orange_material = + engine.get_resource_handle::(shared::ORANGE_MATERIAL_NAME)?; + let pill_mesh = engine.get_resource_handle::(shared::PILL_MESH_NAME)?; + let mut rng = thread_rng(); + + engine + .build_entity(scene) + .with_component( + TransformComponent::builder() + .position(shared::WORLD_ORIGIN) + .build(), + ) + .with_component( + MeshRenderingComponent::builder() + .material(&orange_material) + .mesh(&pill_mesh) + .build(), + ) + .with_component(shared::CitizenComponent { + path_points: VecDeque::new(), + current_movement_speed: shared::CITIZEN_INITIAL_SPEED, + max_movement_speed: rng + .gen_range(shared::CITIZEN_MAX_SPEED_MIN..shared::CITIZEN_MAX_SPEED_MAX), + acceleration: rng + .gen_range(shared::CITIZEN_ACCELERATION_MIN..shared::CITIZEN_ACCELERATION_MAX), + facing_angle: rng.gen_range(0.0..360.0), + rotation_speed: rng.gen_range( + shared::CITIZEN_ROTATION_SPEED_MIN..shared::CITIZEN_ROTATION_SPEED_MAX, + ), + }) + .build(); + } + + Ok(()) +} + +fn citizen_movement_system(engine: &mut Engine) -> Result<()> { + shared::move_citizens_toward_targets(engine, thread_rng()) +} diff --git a/examples/city/src/project.rs b/examples/city/src/project.rs new file mode 100644 index 00000000..4c32424e --- /dev/null +++ b/examples/city/src/project.rs @@ -0,0 +1,16 @@ +mod shared; + +#[cfg(all( + not(feature = "benchmark_windowed"), + not(feature = "benchmark_headless") +))] +#[path = "game.rs"] +mod project; + +#[cfg(any(feature = "benchmark_windowed", feature = "benchmark_headless"))] +#[path = "benchmark.rs"] +mod project; + +use pill_engine::project::*; + +create_project!(crate::project::Project {}, PillProject); diff --git a/examples/city/src/shared.rs b/examples/city/src/shared.rs new file mode 100644 index 00000000..6c578acd --- /dev/null +++ b/examples/city/src/shared.rs @@ -0,0 +1,150 @@ +//! Shared constants, components, and systems for the city example. +//! +//! Used by both `project.rs` (normal play mode) and `benchmark.rs` (benchmark mode). + +use pill_engine::{define_component, project::*}; +use rand::Rng; + +// -- World --------------------------------------------------------------- + +pub const WORLD_ORIGIN: Vector3f = Vector3f::new(0.0, 0.0, 0.0); + +// -- Citizen movement constants ------------------------------------------ + +pub const PATH_POINT_COUNT: usize = 4; +pub const PATH_WANDER_RADIUS_XZ: f32 = 20.0; +pub const POINT_REACH_THRESHOLD: f32 = 5.0; + +pub const CITIZEN_INITIAL_SPEED: f32 = 0.0; +pub const CITIZEN_MAX_SPEED_MIN: f32 = 4.0; +pub const CITIZEN_MAX_SPEED_MAX: f32 = 8.0; +pub const CITIZEN_ACCELERATION_MIN: f32 = 3.0; +pub const CITIZEN_ACCELERATION_MAX: f32 = 6.0; +pub const CITIZEN_TURN_SLOWDOWN: f32 = 0.4; +pub const CITIZEN_ROTATION_SPEED_MIN: f32 = 150.0; +pub const CITIZEN_ROTATION_SPEED_MAX: f32 = 240.0; + +// --- Colors --- +pub const GRAY_MATERIAL_TINT: Color = Color::new(0.3, 0.3, 0.3); +pub const ORANGE_MATERIAL_TINT: Color = Color::new(1.0, 0.5, 0.0); +pub const CLEAR_COLOR: Color = Color::new(0.08, 0.12, 0.18); + +// --- World --- +pub const PLANE_POSITION: Vector3f = Vector3f::new(0.0, -2.0, 0.0); +pub const PLANE_SCALE: Vector3f = Vector3f::new(3.0, 1.0, 3.0); + +// --- Asset paths --- +pub const PILL_MESH_PATH: &str = "models/pill.obj"; +pub const PLANE_MESH_PATH: &str = "models/plane.obj"; +pub const GRAY_MATERIAL_NAME: &str = "gray"; +pub const ORANGE_MATERIAL_NAME: &str = "orange"; +pub const PILL_MESH_NAME: &str = "pill"; +pub const PLANE_MESH_NAME: &str = "plane"; + +// --- Camera ------------------------------------------------------------- + +pub const CAMERA_POSITION: Vector3f = Vector3f::new(-24.0, 24.0, -24.0); +pub const CAMERA_ROTATION: Vector3f = Vector3f::new(45.0, 45.0, 45.0); +pub const CAMERA_FOV: f32 = 55.0; + +// -- Components ----------------------------------------------------------- + +define_component!(CitizenComponent { + path_points: std::collections::VecDeque, + current_movement_speed: f32, + max_movement_speed: f32, + acceleration: f32, + facing_angle: f32, + rotation_speed: f32, +}); + +// -- Shared movement system ----------------------------------------------- + +/// Moves each citizen toward its current path point using forward-direction +/// movement with angle rotation. +/// +/// The caller provides an RNG (e.g. `thread_rng()` for project mode or a seeded +/// `StdRng` for deterministic benchmarks). +/// +/// - Refills the path queue with random points around [`ORIGIN`] when empty. +/// - Rotates `facing_angle` toward the target at `rotation_speed` Β°/s. +/// - Accelerates `current_movement_speed` up to `max_movement_speed`. +/// - Applies a speed penalty on sharp turns ([`CITIZEN_TURN_SLOWDOWN`]). +/// - Moves forward in the facing direction. +/// - Removes reached points (distance < [`POINT_REACH_THRESHOLD`]). +pub fn move_citizens_toward_targets(engine: &mut Engine, mut rng: impl Rng) -> Result<()> { + let delta_time = engine.get_global_component::()?.delta_time; + + for (_, transform, citizen) in + engine.iterate_two_components_mut::()? + { + // Refill path queue when empty + if citizen.path_points.is_empty() { + for _ in 0..PATH_POINT_COUNT { + let point = Vector3f::new( + WORLD_ORIGIN.x + rng.gen_range(-PATH_WANDER_RADIUS_XZ..PATH_WANDER_RADIUS_XZ), + WORLD_ORIGIN.y, + WORLD_ORIGIN.z + rng.gen_range(-PATH_WANDER_RADIUS_XZ..PATH_WANDER_RADIUS_XZ), + ); + citizen.path_points.push_back(point); + } + } + + // Rotate toward the current target + let mut turn_ratio: f32 = 0.0; // 0 = no turn, 1 = max-rate turn + if let Some(&target) = citizen.path_points.front() { + let to_target = target - transform.position; + let distance = to_target.length(); + + // Arrived at this point β€” remove it + if distance < POINT_REACH_THRESHOLD { + citizen.path_points.pop_front(); + } else { + let desired_angle = to_target.x.atan2(to_target.z).to_degrees(); + + let mut angle_difference = desired_angle - citizen.facing_angle; + while angle_difference > 180.0 { + angle_difference -= 360.0; + } + while angle_difference < -180.0 { + angle_difference += 360.0; + } + + let maximum_rotation = citizen.rotation_speed * delta_time; + let rotation_delta = angle_difference.clamp(-maximum_rotation, maximum_rotation); + citizen.facing_angle = (citizen.facing_angle + rotation_delta) % 360.0; + if citizen.facing_angle < 0.0 { + citizen.facing_angle += 360.0; + } + + // Turn ratio: how hard the citizen is turning this frame (0-1) + if maximum_rotation > 0.001 { + turn_ratio = (rotation_delta / maximum_rotation).abs().min(1.0); + } + + transform.set_rotation(Vector3f::new(0.0, -citizen.facing_angle, 0.0)); + } + } + + // Desired speed is lower while turning sharply. + let speed_multiplier = 1.0 - CITIZEN_TURN_SLOWDOWN * turn_ratio; + let target_speed = citizen.max_movement_speed * speed_multiplier; + + if citizen.current_movement_speed < target_speed { + citizen.current_movement_speed = (citizen.current_movement_speed + + citizen.acceleration * delta_time) + .min(target_speed); + } else { + citizen.current_movement_speed = (citizen.current_movement_speed + - citizen.acceleration * delta_time) + .max(target_speed); + } + + let angle_radians = citizen.facing_angle.to_radians(); + let forward = Vector3f::new(angle_radians.sin(), 0.0, angle_radians.cos()); + let step = forward * citizen.current_movement_speed * delta_time; + transform.set_position(transform.position + step); + } + + Ok(()) +} diff --git a/examples/cube/Cargo.toml b/examples/cube/Cargo.toml index 3433b742..3f71d5a5 100644 --- a/examples/cube/Cargo.toml +++ b/examples/cube/Cargo.toml @@ -1,11 +1,12 @@ [package] -name = "pill_game" +name = "project" version = "0.1.0" edition = "2021" -workspace = "/Users/mk/dev/demo/Pill-Engine/engine" +workspace = "NO_PATH" [lib] crate-type = ["cdylib", "rlib"] +path = "src/project.rs" [dependencies] -pill_engine = { path = "../../engine/pill_engine", features = ["game", "internal"] } +pill_engine = { path = "../../engine/pill_engine", features = ["project"] } diff --git a/examples/cube/res/config.ini b/examples/cube/res/config.ini index 85295b7f..ad25d590 100644 --- a/examples/cube/res/config.ini +++ b/examples/cube/res/config.ini @@ -1,4 +1,4 @@ -# GAME +# PROJECT TITLE=Cube # WINDOW @@ -21,4 +21,4 @@ MAX_SOUNDS=10 # OTHER LOG_LEVEL=Info -PANIC_ON_GAME_ERRORS=true +PANIC_ON_PROJECT_ERRORS=true diff --git a/examples/cube/src/lib.rs b/examples/cube/src/lib.rs deleted file mode 100644 index 1265e0a8..00000000 --- a/examples/cube/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod game; -pub use game::WebGame; - -use pill_engine::game::create_game; -create_game!(crate::game::WebGame {}, pill_engine::game::PillGame); diff --git a/examples/cube/src/game.rs b/examples/cube/src/project.rs similarity index 94% rename from examples/cube/src/game.rs rename to examples/cube/src/project.rs index 08b240e6..977c20db 100644 --- a/examples/cube/src/game.rs +++ b/examples/cube/src/project.rs @@ -1,25 +1,9 @@ -use pill_engine::game::*; +use pill_engine::project::*; -pub struct WebGame {} +pub struct Project {} +create_project!(Project {}, PillProject); -fn rotate_cubes_system(engine: &mut Engine) -> Result<()> { - let dt = engine.get_global_component::()?.delta_time; - - for (_entity, transform, _mesh) in - engine.iterate_two_components_mut::()? - { - let rot = transform.rotation; - transform.set_rotation(Vector3f::new( - rot.x + 2.0 * dt, - rot.y + 3.5 * dt, - rot.z + 1.0 * dt, - )); - } - - Ok(()) -} - -impl PillGame for WebGame { +impl PillProject for Project { fn start(&self, engine: &mut Engine) -> Result<()> { let active_scene = engine.create_scene("default")?; engine.set_active_scene(active_scene)?; @@ -67,3 +51,20 @@ impl PillGame for WebGame { Ok(()) } } + +fn rotate_cubes_system(engine: &mut Engine) -> Result<()> { + let dt = engine.get_global_component::()?.delta_time; + + for (_entity, transform, _mesh) in + engine.iterate_two_components_mut::()? + { + let rot = transform.rotation; + transform.set_rotation(Vector3f::new( + rot.x + 2.0 * dt, + rot.y + 3.5 * dt, + rot.z + 1.0 * dt, + )); + } + + Ok(()) +} diff --git a/examples/floating_pills/Cargo.toml b/examples/floating_pills/Cargo.toml index a0e2f80e..0b0be8fc 100644 --- a/examples/floating_pills/Cargo.toml +++ b/examples/floating_pills/Cargo.toml @@ -1,14 +1,15 @@ [package] -name = "pill_game" +name = "project" version = "0.1.0" edition = "2021" workspace = "NO_PATH" [lib] crate-type = ["cdylib"] +path = "src/project.rs" [dependencies] -pill_engine = {path = "../../engine/pill_engine", features = ["game"]} +pill_engine = { path = "../../engine/pill_engine", features = ["project"] } rand = "0.8.4" glam = { version = "0.30.8", features = ["serde"] } diff --git a/examples/floating_pills/res/config.ini b/examples/floating_pills/res/config.ini index 9a4443a7..f4efffd5 100644 --- a/examples/floating_pills/res/config.ini +++ b/examples/floating_pills/res/config.ini @@ -1,4 +1,4 @@ -# GAME +# PROJECT TITLE=Floating-Pills # WINDOW @@ -21,4 +21,4 @@ MAX_SOUNDS=10 # OTHER LOG_LEVELS=hot_reload=info -PANIC_ON_GAME_ERRORS=false +PANIC_ON_PROJECT_ERRORS=false diff --git a/examples/floating_pills/src/lib.rs b/examples/floating_pills/src/lib.rs deleted file mode 100644 index b14e21e8..00000000 --- a/examples/floating_pills/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod game; -use pill_engine::game::create_game; - -create_game!(crate::game::Game {}, pill_engine::game::PillGame); diff --git a/examples/floating_pills/src/game.rs b/examples/floating_pills/src/project.rs similarity index 98% rename from examples/floating_pills/src/game.rs rename to examples/floating_pills/src/project.rs index 7432f9cd..03e9e33c 100644 --- a/examples/floating_pills/src/game.rs +++ b/examples/floating_pills/src/project.rs @@ -1,10 +1,14 @@ #![allow(dead_code)] -use pill_engine::{define_component, define_global_component, game::*}; +use pill_engine::project::*; +use pill_engine::{define_component, define_global_component, project::*}; use rand::{thread_rng, Rng}; use std::fs::OpenOptions; use std::io::Write; use std::time::Instant; +pub struct Project {} +create_project!(Project {}, PillProject); + pub const FLOATING_OBJECT_SPAWN_BATCH_COUNT: usize = 100; pub const FLOATING_OBJECT_REMOVE_BATCH_COUNT: usize = 10; pub const SPAWN_FLOATING_OBJECTS_BUTTON: KeyboardKey = KeyboardKey::KeyO; @@ -57,14 +61,12 @@ define_global_component!(BenchComponent { report_every_frames: u32, }); -pub struct Game {} - -impl PillGame for Game { +impl PillProject for Project { fn start(&self, engine: &mut Engine) -> Result<()> { // --- Basic setup --- // Disable build-in audio system - engine.toggle_system("audio_system", UpdatePhase::PostGame, false)?; + engine.toggle_system("audio_system", UpdatePhase::PostProject, false)?; // Create scene let active_scene = engine.create_scene("default")?; @@ -287,7 +289,7 @@ fn demo_control_system(engine: &mut Engine) -> Result<()> { demo_state.floating_objects_movement_enabled = !demo_state.floating_objects_movement_enabled; let enabled = demo_state.floating_objects_movement_enabled; - engine.toggle_system("objects_movement", UpdatePhase::Game, enabled)?; + engine.toggle_system("objects_movement", UpdatePhase::Project, enabled)?; } Ok(()) diff --git a/examples/italian_brainrot/Cargo.toml b/examples/italian_brainrot/Cargo.toml index 742fc74b..49c4b9b3 100644 --- a/examples/italian_brainrot/Cargo.toml +++ b/examples/italian_brainrot/Cargo.toml @@ -1,12 +1,12 @@ [package] -name = "pill_game" +name = "project" version = "0.1.0" edition = "2021" -workspace = "NO_PATH" +workspace = "D:/Programming/Pill-Engine/engine" [lib] crate-type = ["cdylib"] +path = "src/project.rs" [dependencies] -pill_engine = {path = "../../engine/pill_engine", features = ["game"]} - +pill_engine = { path = "../../engine/pill_engine", features = ["project"] } diff --git a/examples/italian_brainrot/res/config.ini b/examples/italian_brainrot/res/config.ini index 56141243..a25cb692 100644 --- a/examples/italian_brainrot/res/config.ini +++ b/examples/italian_brainrot/res/config.ini @@ -1,4 +1,4 @@ -# GAME +# PROJECT TITLE=Italian Brainrot # WINDOW @@ -21,4 +21,4 @@ MAX_SOUNDS=10 # OTHER LOG_LEVELS=hot_reload=info, engine=debug, input=info, ecs=info, rendering=debug, resources=debug, frame=debug #, wgpu_hal::vulkan::instance=info -PANIC_ON_GAME_ERRORS=true +PANIC_ON_PROJECT_ERRORS=true diff --git a/examples/italian_brainrot/src/lib.rs b/examples/italian_brainrot/src/lib.rs deleted file mode 100644 index 59df9386..00000000 --- a/examples/italian_brainrot/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod game; -use pill_engine::game::create_game; - -create_game!(crate::game::Game {}, pill_engine::game::PillGame); \ No newline at end of file diff --git a/examples/italian_brainrot/src/game.rs b/examples/italian_brainrot/src/project.rs similarity index 97% rename from examples/italian_brainrot/src/game.rs rename to examples/italian_brainrot/src/project.rs index 05f31ea3..eef4e395 100644 --- a/examples/italian_brainrot/src/game.rs +++ b/examples/italian_brainrot/src/project.rs @@ -1,9 +1,12 @@ -use pill_engine::{define_component, game::*}; -define_component!(TagAlphaComponent {}); +use pill_engine::define_component; +use pill_engine::project::*; + +pub struct Project {} +create_project!(Project {}, PillProject); -pub struct Game {} +define_component!(TagAlphaComponent {}); -impl PillGame for Game { +impl PillProject for Project { fn start(&self, engine: &mut Engine) -> Result<()> { // --- Basic setup --- diff --git a/examples/net_minimal/README.md b/examples/net_minimal/README.md index 8da00341..85c0cc31 100644 --- a/examples/net_minimal/README.md +++ b/examples/net_minimal/README.md @@ -3,7 +3,7 @@ This is an example to show how networking works in PillEngine. We have a simple server and multiple clients that can connect/disconnect and reconnect at will and the server will propagate the state to all the other connected clients. ### How to run -Run up to `max_clients` (you can edit that variable to your liking) by simply running the game binary: +Run up to `max_clients` (you can edit that variable to your liking) by simply running the project binary: `./client` Then run a server by running: diff --git a/examples/net_minimal/client/Cargo.toml b/examples/net_minimal/client/Cargo.toml index 88edeab9..5cf90fd7 100644 --- a/examples/net_minimal/client/Cargo.toml +++ b/examples/net_minimal/client/Cargo.toml @@ -1,16 +1,20 @@ [package] -name = "pill_game" +name = "pill_project" version = "0.1.0" edition = "2021" workspace = "NO_PATH" [lib] crate-type = ["cdylib"] +path = "src/project.rs" [dependencies] -pill_engine = { path = "../../../engine/pill_engine", default-features = false, features = ["internal", "networking", "game"] } +pill_engine = { path = "../../../engine/pill_engine", default-features = false, features = [ + "networking", + "project", +] } rand = "0.9.2" anyhow = "1" -log = "0.4" +log = "0.4" bincode = "1.0" diff --git a/examples/net_minimal/client/res/config.ini b/examples/net_minimal/client/res/config.ini index a093ebef..adc5af03 100644 --- a/examples/net_minimal/client/res/config.ini +++ b/examples/net_minimal/client/res/config.ini @@ -1,4 +1,4 @@ -# GAME +# PROJECT TITLE=Net Minimal # WINDOW @@ -21,4 +21,4 @@ MAX_SOUNDS=10 # OTHER LOG_LEVEL=Info -PANIC_ON_GAME_ERRORS=true +PANIC_ON_PROJECT_ERRORS=true diff --git a/examples/net_minimal/client/src/lib.rs b/examples/net_minimal/client/src/lib.rs deleted file mode 100644 index b14e21e8..00000000 --- a/examples/net_minimal/client/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod game; -use pill_engine::game::create_game; - -create_game!(crate::game::Game {}, pill_engine::game::PillGame); diff --git a/examples/net_minimal/client/src/game.rs b/examples/net_minimal/client/src/project.rs similarity index 98% rename from examples/net_minimal/client/src/game.rs rename to examples/net_minimal/client/src/project.rs index fe34d76f..1358475b 100644 --- a/examples/net_minimal/client/src/game.rs +++ b/examples/net_minimal/client/src/project.rs @@ -1,12 +1,13 @@ -use pill_engine::game::*; - -use rand::rngs::StdRng; -use rand::{Rng, SeedableRng}; - use pill_engine::internal::{ client_go_offline, networking_system_client, NetworkEntityState, NetworkManagerComponent, NetworkStateComponent, TransformComponent, }; +use pill_engine::project::*; +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; + +pub struct Project {} +create_project!(Project {}, PillProject); // ----- CONSTANTS ----------------------------------------------------------- @@ -35,12 +36,12 @@ impl PillTypeMapKey for PillComponent { } // ─────────────────────────────────────────────────────────────────────────── -// GAME +// POROJECT // ─────────────────────────────────────────────────────────────────────────── -pub struct Game; +pub struct Project; -impl PillGame for Game { +impl PillProject for Project { fn start(&self, engine: &mut Engine) -> Result<()> { // Create scene let active_scene = engine.create_scene("NetMinimal")?; diff --git a/examples/net_minimal/server/src/main.rs b/examples/net_minimal/server/src/main.rs index 5e8f2556..c11725f7 100644 --- a/examples/net_minimal/server/src/main.rs +++ b/examples/net_minimal/server/src/main.rs @@ -3,7 +3,7 @@ use pill_core::Result; use pill_core::{server_broadcast_exit, server_dying_grasp}; use pill_engine::internal::{ networking_system_server, Engine, EngineConfig, NetworkEntityState, NetworkSide, - NetworkStateComponent, PillGame, TransformComponent, + NetworkStateComponent, PillProject, TransformComponent, }; use pill_engine::internal::{EngineProcessInfo, NetworkManagerComponent}; use std::io::Write; @@ -41,11 +41,11 @@ fn spawn_player( Ok(()) } -struct HeadlessGame; // TODO: placeholder for the actual game struct - // -impl PillGame for HeadlessGame { +struct HeadlessProject; // TODO: placeholder for the actual project struct + // +impl PillProject for HeadlessProject { fn start(&self, engine: &mut Engine) -> Result<()> { - println!("Starting HeadlessGame..."); + println!("Starting HeadlessProject..."); let scene = engine.create_scene("ServerWorld")?; engine.set_active_scene(scene)?; @@ -85,11 +85,11 @@ fn main() -> Result<()> { .filter_level(log::LevelFilter::Info) .init(); - let game: Box = Box::new(HeadlessGame); + let project: Box = Box::new(HeadlessProject); let compile_mode = std::env::var("PILL_COMPILE_MODE").map_err(|_| "PILL_COMPILE_MODE is not set")?; let process = EngineProcessInfo::new(&compile_mode, pill_engine::internal::BuildTarget::Native); - let mut engine = Engine::new(game, EngineConfig::from_ini(""), process); + let mut engine = Engine::new(project, EngineConfig::from_ini(""), process); engine.initialize(None)?; @@ -103,7 +103,7 @@ fn main() -> Result<()> { let mut last = Instant::now(); - info!("Starting headless game loop..."); + info!("Starting headless pill project loop..."); loop { // graceful shutdown on Ctrl-C @@ -127,7 +127,7 @@ fn main() -> Result<()> { // drive networking, simulation engine.update(tick); - //println!("Game updated at {:?}", last); + //println!("Pill project updated at {:?}", last); } else { // sleep to avoid busy waiting std::thread::sleep(tick - now.duration_since(last)); diff --git a/examples/physics/Cargo.toml b/examples/physics/Cargo.toml index 09982fbe..cb22eb59 100644 --- a/examples/physics/Cargo.toml +++ b/examples/physics/Cargo.toml @@ -1,11 +1,16 @@ [package] -name = "pill_game" +name = "project" version = "0.1.0" edition = "2021" -workspace = "NO_PATH" +workspace = "D:/Programming/Pill-Engine/engine" [lib] crate-type = ["cdylib"] +path = "src/project.rs" [dependencies] -pill_engine = { path = "../../engine/pill_engine", features = ["game", "physics", "debug_ui"] } +pill_engine = { path = "../../engine/pill_engine", features = [ + "project", + "physics", + "debug_ui", +] } diff --git a/examples/physics/res/config.ini b/examples/physics/res/config.ini index 11a5dbdf..58c4091f 100644 --- a/examples/physics/res/config.ini +++ b/examples/physics/res/config.ini @@ -1,4 +1,4 @@ -# GAME +# PROJECT TITLE=physics # WINDOW @@ -21,4 +21,4 @@ MAX_SOUNDS=10 # OTHER LOG_LEVEL=Info -PANIC_ON_GAME_ERRORS=true +PANIC_ON_PROJECT_ERRORS=true diff --git a/examples/physics/src/lib.rs b/examples/physics/src/lib.rs deleted file mode 100644 index b14e21e8..00000000 --- a/examples/physics/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod game; -use pill_engine::game::create_game; - -create_game!(crate::game::Game {}, pill_engine::game::PillGame); diff --git a/examples/physics/src/game.rs b/examples/physics/src/project.rs similarity index 99% rename from examples/physics/src/game.rs rename to examples/physics/src/project.rs index c53284ba..43defc48 100644 --- a/examples/physics/src/game.rs +++ b/examples/physics/src/project.rs @@ -1,9 +1,11 @@ +use pill_engine::{define_component, define_global_component, project::*}; use std::{ collections::VecDeque, sync::{Arc, Mutex}, }; -use pill_engine::{define_component, define_global_component, game::*}; +pub struct Project {} +create_project!(Project {}, PillProject); // Define custom component pub struct PillComponent {} @@ -104,10 +106,7 @@ impl Default for PlinkoTuningComponent { } } -// Game -pub struct Game {} - -impl PillGame for Game { +impl PillProject for Project { fn start(&self, engine: &mut Engine) -> Result<()> { // Create scene let active_scene = engine.create_scene("Default")?; diff --git a/examples/pill_tunel/Cargo.toml b/examples/pill_tunel/Cargo.toml index 241c1f4b..3f71d5a5 100644 --- a/examples/pill_tunel/Cargo.toml +++ b/examples/pill_tunel/Cargo.toml @@ -1,11 +1,12 @@ [package] -name = "pill_game" +name = "project" version = "0.1.0" edition = "2021" workspace = "NO_PATH" [lib] crate-type = ["cdylib", "rlib"] +path = "src/project.rs" [dependencies] -pill_engine = { path = "../../engine/pill_engine", features = ["game", "internal"] } +pill_engine = { path = "../../engine/pill_engine", features = ["project"] } diff --git a/examples/pill_tunel/res/config.ini b/examples/pill_tunel/res/config.ini index 1ff66db1..56cde51d 100644 --- a/examples/pill_tunel/res/config.ini +++ b/examples/pill_tunel/res/config.ini @@ -1,4 +1,4 @@ -# GAME +# PROJECT TITLE=Pill-Demo # WINDOW @@ -22,4 +22,4 @@ MAX_SOUNDS=50 # OTHER LOG_LEVEL=Info -PANIC_ON_GAME_ERRORS=true +PANIC_ON_PROJECT_ERRORS=true diff --git a/examples/pill_tunel/src/lib.rs b/examples/pill_tunel/src/lib.rs deleted file mode 100644 index 1265e0a8..00000000 --- a/examples/pill_tunel/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod game; -pub use game::WebGame; - -use pill_engine::game::create_game; -create_game!(crate::game::WebGame {}, pill_engine::game::PillGame); diff --git a/examples/pill_tunel/src/game.rs b/examples/pill_tunel/src/project.rs similarity index 98% rename from examples/pill_tunel/src/game.rs rename to examples/pill_tunel/src/project.rs index c2606853..7c924eda 100644 --- a/examples/pill_tunel/src/game.rs +++ b/examples/pill_tunel/src/project.rs @@ -1,4 +1,8 @@ -use pill_engine::{define_component, game::*}; +use pill_engine::define_component; +use pill_engine::project::*; + +pub struct Project {} +create_project!(Project {}, PillProject); // --- Scene constants --------------------------------------------------------- @@ -127,8 +131,6 @@ define_component!(PillParticleComponent { }); define_component!(HeroPillComponent {}); -pub struct WebGame {} - // --- Systems ----------------------------------------------------------------- fn pill_particle_system(engine: &mut Engine) -> Result<()> { @@ -184,9 +186,9 @@ fn camera_drift_system(engine: &mut Engine) -> Result<()> { Ok(()) } -// --- Game -------------------------------------------------------------------- +// --- Project -------------------------------------------------------------------- -impl PillGame for WebGame { +impl PillProject for Project { fn start(&self, engine: &mut Engine) -> Result<()> { let active_scene = engine.create_scene("default")?; engine.set_active_scene(active_scene)?;