From f729faf24ad6662b7f9c3e87f6b8ae280e2eef81 Mon Sep 17 00:00:00 2001 From: MattSzymonski Date: Sun, 21 Jun 2026 22:59:36 +0200 Subject: [PATCH 01/49] Rework --- .github/workflows/ci.yml | 215 +- .gitignore | 7 + devops/tests/Dockerfile | 65 + engine/pill_abi/src/lib.rs | 4 + engine/pill_assets/src/rules/hlsl_to_wgsl.rs | 4 +- engine/pill_core/src/lib.rs | 17 +- engine/pill_engine/build.rs | 2 +- engine/pill_engine/src/app_config.rs | 8 + engine/pill_engine/src/engine.rs | 38 + engine/pill_launcher/Cargo.toml | 1 + .../res/templates/pill_default/res/config.ini | 2 +- .../res/templates/wasm/src/lib.rs | 8 +- engine/pill_launcher/src/actions/assets.rs | 27 + engine/pill_launcher/src/actions/build.rs | 560 +++++ .../src/actions/cargo_passthrough.rs | 82 + engine/pill_launcher/src/actions/check.rs | 99 + .../pill_launcher/src/actions/check_wasm.rs | 166 ++ engine/pill_launcher/src/actions/ci.rs | 81 + engine/pill_launcher/src/actions/create.rs | 147 ++ engine/pill_launcher/src/actions/docs.rs | 218 ++ engine/pill_launcher/src/actions/mod.rs | 45 + .../src/actions/performance_benchmark.rs | 357 +++ .../src/actions/size_benchmark.rs | 520 ++++ engine/pill_launcher/src/main.rs | 1394 +---------- engine/pill_launcher/src/size_report.rs | 246 -- engine/pill_launcher/src/types.rs | 59 + engine/pill_launcher/src/utils/assets.rs | 32 + engine/pill_launcher/src/utils/cli.rs | 148 ++ engine/pill_launcher/src/utils/files.rs | 273 +++ engine/pill_launcher/src/utils/mod.rs | 9 + engine/pill_launcher/src/utils/paths.rs | 188 ++ engine/pill_launcher/src/utils/plantuml.rs | 85 + engine/pill_launcher/src/utils/platform.rs | 23 + .../src/{wasm_build.rs => utils/wasm.rs} | 689 +++--- .../src/{ => utils}/web_dev_server.rs | 334 +-- engine/pill_launcher/src/utils/workspace.rs | 123 + engine/pill_native/src/main.rs | 16 + engine/pill_runtime/src/lib.rs | 20 +- examples/city/.gitignore | 4 + examples/city/Cargo.toml | 20 + examples/city/README.md | 114 + examples/city/res/config.ini | 24 + examples/city/res/models/cube.obj | 39 + examples/city/res/models/pill.obj | 2170 +++++++++++++++++ examples/city/res/models/plane.obj | 15 + examples/city/src/benchmark.rs | 262 ++ examples/city/src/game.rs | 210 ++ examples/city/src/lib.rs | 13 + examples/city/src/shared.rs | 127 + 49 files changed, 7125 insertions(+), 2185 deletions(-) create mode 100644 devops/tests/Dockerfile create mode 100644 engine/pill_launcher/src/actions/assets.rs create mode 100644 engine/pill_launcher/src/actions/build.rs create mode 100644 engine/pill_launcher/src/actions/cargo_passthrough.rs create mode 100644 engine/pill_launcher/src/actions/check.rs create mode 100644 engine/pill_launcher/src/actions/check_wasm.rs create mode 100644 engine/pill_launcher/src/actions/ci.rs create mode 100644 engine/pill_launcher/src/actions/create.rs create mode 100644 engine/pill_launcher/src/actions/docs.rs create mode 100644 engine/pill_launcher/src/actions/mod.rs create mode 100644 engine/pill_launcher/src/actions/performance_benchmark.rs create mode 100644 engine/pill_launcher/src/actions/size_benchmark.rs delete mode 100644 engine/pill_launcher/src/size_report.rs create mode 100644 engine/pill_launcher/src/types.rs create mode 100644 engine/pill_launcher/src/utils/assets.rs create mode 100644 engine/pill_launcher/src/utils/cli.rs create mode 100644 engine/pill_launcher/src/utils/files.rs create mode 100644 engine/pill_launcher/src/utils/mod.rs create mode 100644 engine/pill_launcher/src/utils/paths.rs create mode 100644 engine/pill_launcher/src/utils/plantuml.rs create mode 100644 engine/pill_launcher/src/utils/platform.rs rename engine/pill_launcher/src/{wasm_build.rs => utils/wasm.rs} (58%) rename engine/pill_launcher/src/{ => utils}/web_dev_server.rs (66%) create mode 100644 engine/pill_launcher/src/utils/workspace.rs create mode 100644 examples/city/.gitignore create mode 100644 examples/city/Cargo.toml create mode 100644 examples/city/README.md create mode 100644 examples/city/res/config.ini create mode 100644 examples/city/res/models/cube.obj create mode 100644 examples/city/res/models/pill.obj create mode 100644 examples/city/res/models/plane.obj create mode 100644 examples/city/src/benchmark.rs create mode 100644 examples/city/src/game.rs create mode 100644 examples/city/src/lib.rs create mode 100644 examples/city/src/shared.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a53f0a43..0e2577ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,18 @@ -# Run the github CI which builds the launcher and uses it to build examples, run the formatter and clippy checker +# Pill CI Pipeline +# +# Triggers on every push. Builds the launcher once, then fans out to parallel +# jobs that download the artifact: formatting, clippy, native builds, and WASM. +# +# All build/test logic is now built into PillLauncher itself: +# pill_launcher -a check|build|wasm-test|benchmark|ci +# +# Job dependency graph: +# build_launcher ──┬── code_check +# ├── code_formatting_check +# ├── code_linting_check +# ├── build_examples (matrix) +# ├── build_wasm_example (matrix) +# └── benchmark (main / PR only) name: CI on: @@ -10,60 +24,93 @@ concurrency: 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: + code_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 + - 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: Validate launcher artifact + shell: bash + run: | + set -euo pipefail + chmod +x engine/pill_launcher/target/release/PillLauncher + test -x engine/pill_launcher/target/release/PillLauncher || { + echo "ERROR: PillLauncher artifact missing or not executable" + exit 1 + } + echo "PillLauncher artifact OK" + - name: cargo check (engine crates) + shell: bash + run: | + set -euo pipefail + ./engine/pill_launcher/target/release/PillLauncher -a check + 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 + - name: Validate launcher artifact 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 + test -x engine/pill_launcher/target/release/PillLauncher || { + echo "ERROR: PillLauncher artifact missing or not executable" + exit 1 + } + echo "PillLauncher artifact OK" + - name: rustfmt check + shell: bash + run: | + set -euo pipefail + ./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: @@ -71,31 +118,35 @@ jobs: 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) + - name: Validate launcher artifact 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 + test -x engine/pill_launcher/target/release/PillLauncher || { + echo "ERROR: PillLauncher artifact missing or not executable" + exit 1 + } + echo "PillLauncher artifact OK" + - name: clippy lint (-D warnings) + shell: bash + run: | + set -euo pipefail + ./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: @@ -106,20 +157,17 @@ jobs: matrix: example: - examples/cube - - examples/floating_pills/ + - examples/floating_pills - examples/italian_brainrot + - examples/city - 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: | @@ -127,25 +175,31 @@ jobs: examples/cube -> target examples/floating_pills -> target examples/italian_brainrot -> target + examples/city -> 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) + - name: Validate launcher artifact + shell: bash + run: | + set -euo pipefail + chmod +x engine/pill_launcher/target/release/PillLauncher + test -x engine/pill_launcher/target/release/PillLauncher || { + echo "ERROR: PillLauncher artifact missing or not executable" + exit 1 + } + echo "PillLauncher artifact OK" + - name: Build example 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" @@ -160,69 +214,94 @@ jobs: example: - examples/cube - examples/pill_tunel + - examples/pbr_helmet + - examples/pbr_balls 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: '' - + 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 - + examples/pbr_helmet/build/wasm/.build/pill_web -> target + examples/pbr_balls/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 + - name: Validate launcher artifact + shell: bash + run: | + set -euo pipefail + chmod +x engine/pill_launcher/target/release/PillLauncher + test -x engine/pill_launcher/target/release/PillLauncher || { + echo "ERROR: PillLauncher artifact missing or not executable" + exit 1 + } + echo "PillLauncher artifact OK" + - name: WASM build + smoke test + size guard 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" + BUDGET_FLAG="" + if [[ "$EXAMPLE" == "examples/cube" ]]; then + BUDGET_FLAG="--wasm-budget-kb 499" + fi + ./engine/pill_launcher/target/release/PillLauncher -a wasm-test -p "$EXAMPLE" $BUDGET_FLAG - - name: Size guard — WASM budget - if: matrix.example == 'examples/cube' + benchmark: + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request' + needs: build_launcher + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/linux-deps + - name: Install xvfb (virtual display for windowed benchmarks) + run: sudo apt-get install -y xvfb + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + engine -> target + examples/city -> target + - name: Download PillLauncher artifact + uses: actions/download-artifact@v4 + with: + name: pilllauncher-linux + path: engine/pill_launcher/target/release + - name: Validate launcher artifact 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" + set -euo pipefail + chmod +x engine/pill_launcher/target/release/PillLauncher + test -x engine/pill_launcher/target/release/PillLauncher || { + echo "ERROR: PillLauncher artifact missing or not executable" exit 1 - fi - echo "OK: WASM $((ACTUAL / 1024)) KB <= 499 KB" - + } + echo "PillLauncher artifact OK" + - name: Run city benchmark + shell: bash + run: | + set -euo pipefail + xvfb-run --auto-servernum \ + ./engine/pill_launcher/target/release/PillLauncher \ + -a benchmark \ + -p examples/city \ + --bench-iterations 5 \ + --bench-frames 1000 \ + --bench-features benchmark_window \ + -c release diff --git a/.gitignore b/.gitignore index 9df92f1c..5dc7471e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,10 @@ engine/target_* *.wgsl *.cooked_mesh *.cooked_tex +DamagedHelmet.glb +MetalRoughSpheres.glb +studio_small_08.hdr + +/.github/skills +/temp +*.blend1 \ No newline at end of file diff --git a/devops/tests/Dockerfile b/devops/tests/Dockerfile new file mode 100644 index 00000000..e1a666f0 --- /dev/null +++ b/devops/tests/Dockerfile @@ -0,0 +1,65 @@ +# devops/tests/Dockerfile — Pill-Engine CI image (Alpine) +# +# Build once: +# docker build -t pill-ci -f devops/tests/Dockerfile . +# +# Run: +# docker run --rm -v .:/src -w /src pill-ci ./devops/tests/tests.sh all + +FROM rust:1.92-alpine + +# -- System packages --------------------------------------------------------- +# mold: fast linker gcompat: run glibc binaries on musl (slangc) +# alsa-lib-dev: audio eudev-dev: gamepad pkgconfig: build scripts +RUN apk add --no-cache \ + mold \ + gcompat \ + alsa-lib-dev \ + eudev-dev \ + pkgconfig \ + openjdk21-jre \ + plantuml \ + curl \ + git \ + bash \ + xorg-server-xvfb + +# -- Rust components --------------------------------------------------------- +RUN rustup component add rustfmt clippy +RUN rustup target add wasm32-unknown-unknown + +# -- 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) ------------------------------------------------ +# slangc ships glibc binaries. gcompat (installed above) provides the glibc +# compatibility layer needed to run them on musl. +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 \ + && rm -rf /tmp/slang /tmp/slang.zip + +# Quick smoke test: if gcompat fails, fall back gracefully. +RUN slangc --version 2>/dev/null || echo "slangc installed (gcompat runtime check at container start)" + +# -- Pre-seed cargo registry (optional, speeds up first build) ---------------- +# Uncomment the lines below and run a dummy build to cache crates in the image. +# COPY engine/Cargo.toml engine/Cargo.lock engine/ +# COPY engine/pill_core/Cargo.toml engine/pill_core/ +# RUN cargo check -p pill_core --manifest-path engine/Cargo.toml 2>/dev/null || true + +# -- Entrypoint -------------------------------------------------------------- +# 1. Touches WGSL files so build.rs skips HLSL→WGSL conversion (avoids slangc +# gcompat edge cases). +# 2. Rewrites absolute host paths in engine/Cargo.toml (e.g. +# "D:/.../examples/city" or "/home/.../examples/city") to repo-relative +# paths ("examples/city") because the repo is mounted at /src inside the +# container. Relative paths are left untouched. +# 3. Execs the user command. +ENTRYPOINT ["/bin/bash", "-c", "for wgsl in /src/engine/pill_engine/res/shaders/*.wgsl; do [[ -f \"$wgsl\" ]] && touch \"$wgsl\"; done; sed -i 's|\"[^\"]*/examples/|\"examples/|' /src/engine/Cargo.toml 2>/dev/null || true; exec \"$@\"", "--"] +CMD ["./devops/tests/tests.sh", "all"] diff --git a/engine/pill_abi/src/lib.rs b/engine/pill_abi/src/lib.rs index 8acf144d..efd52986 100644 --- a/engine/pill_abi/src/lib.rs +++ b/engine/pill_abi/src/lib.rs @@ -63,6 +63,10 @@ pub struct PillEngineApiV1 { // --- Hot reload --- pub reload_game: extern "C" fn(engine: EngineHandle, game_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/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_core/src/lib.rs b/engine/pill_core/src/lib.rs index be203ebb..44a3f76a 100644 --- a/engine/pill_core/src/lib.rs +++ b/engine/pill_core/src/lib.rs @@ -4,6 +4,7 @@ mod bitmask_utils; mod color; mod error; +mod handle; mod log; mod math; #[cfg(not(target_arch = "wasm32"))] @@ -12,6 +13,7 @@ mod pill_slotmap; mod pill_twinmap; mod pill_typemap; mod style; +mod tags; mod timer; mod utils; @@ -34,7 +36,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 + }) } } @@ -63,6 +71,13 @@ pub use utils::{ pub use color::{generate_color_palette, hsl_to_rgb, DISTINCT_COLOR_PALETTE}; +pub use handle::{Handle, ResourcePool}; + +pub use tags::{ + RendererBufferTag, RendererCameraTag, RendererMaterialTag, RendererMeshTag, + RendererPipelineTag, RendererPipelineV2Tag, RendererTextureTag, +}; + pub use timer::{Timer, TimerRecord}; #[cfg(not(target_arch = "wasm32"))] 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..ae67ec02 100644 --- a/engine/pill_engine/src/app_config.rs +++ b/engine/pill_engine/src/app_config.rs @@ -103,6 +103,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/engine.rs b/engine/pill_engine/src/engine.rs index 11326dfe..580b1100 100644 --- a/engine/pill_engine/src/engine.rs +++ b/engine/pill_engine/src/engine.rs @@ -38,6 +38,38 @@ pub struct Engine { pub(crate) window_size: winit::dpi::PhysicalSize, pub(crate) game_resources_directory_path: std::path::PathBuf, pub(crate) frame_delta_time: f32, // In milliseconds + pub(crate) exit_requested: bool, // Set by game 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 game 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 ----------------------------------------------------------------- @@ -71,6 +103,8 @@ impl Engine { window_size: winit::dpi::PhysicalSize::::default(), game_resources_directory_path, frame_delta_time: 0.0, + exit_requested: false, + frame_count: 0, } } @@ -95,6 +129,8 @@ impl Engine { window_size: winit::dpi::PhysicalSize::::default(), game_resources_directory_path: std::path::PathBuf::new(), frame_delta_time: 0.0.into(), + exit_requested: false, + frame_count: 0, } } @@ -387,6 +423,8 @@ impl Engine { /// /// Runs all systems in order: PreGame -> Game -> PostGame pub fn update(&mut self, delta_time: std::time::Duration) { + self.frame_count += 1; + let stop_on_game_errors = self .config .get_bool("PANIC_ON_GAME_ERRORS") diff --git a/engine/pill_launcher/Cargo.toml b/engine/pill_launcher/Cargo.toml index 6001c5df..feb27ed6 100644 --- a/engine/pill_launcher/Cargo.toml +++ b/engine/pill_launcher/Cargo.toml @@ -14,6 +14,7 @@ anyhow = "1.0" path-absolutize = "3.0.11" config = "0.11" tiny_http = "0.12" +ureq = "2" pill_assets = { path = "../pill_assets" } [profile.release] diff --git a/engine/pill_launcher/res/templates/pill_default/res/config.ini b/engine/pill_launcher/res/templates/pill_default/res/config.ini index a8273c20..7e485638 100644 --- a/engine/pill_launcher/res/templates/pill_default/res/config.ini +++ b/engine/pill_launcher/res/templates/pill_default/res/config.ini @@ -2,7 +2,7 @@ TITLE=NoTitle # WINDOW -WINDOW_TITLE=NoTitle +WINDOW_TITLE=Pill Engine - NoTitle WINDOW_WIDTH=600 WINDOW_HEIGHT=600 WINDOW_FULLSCREEN=false diff --git a/engine/pill_launcher/res/templates/wasm/src/lib.rs b/engine/pill_launcher/res/templates/wasm/src/lib.rs index e342ba07..ab55531f 100644 --- a/engine/pill_launcher/res/templates/wasm/src/lib.rs +++ b/engine/pill_launcher/res/templates/wasm/src/lib.rs @@ -10,9 +10,13 @@ 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(pill_game::create_pill_game(), include_str!("../config.ini")); } diff --git a/engine/pill_launcher/src/actions/assets.rs b/engine/pill_launcher/src/actions/assets.rs new file mode 100644 index 00000000..32acbc1d --- /dev/null +++ b/engine/pill_launcher/src/actions/assets.rs @@ -0,0 +1,27 @@ +// This file implements the "assets" action: run the asset pipeline. + +use anyhow::*; +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}; + +pub(crate) struct Assets; + +impl Action for Assets { + fn name(&self) -> &'static str { "assets" } + + fn register(&self, app: App<'static, 'static>) -> App<'static, 'static> { + app.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_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..a4ee03db --- /dev/null +++ b/engine/pill_launcher/src/actions/build.rs @@ -0,0 +1,560 @@ +// This file implements the "build" and "run" actions for native game projects. +// +// Responsibilities: +// - build_game_project(): invokes cargo build for pill_game + pill_native + pill_runtime +// in the engine workspace, copies artifacts into the build output directory. +// - run_game_project(): builds then launches the standalone executable with the +// appropriate environment variables (PILL_GAME_PROJECT_DIR, etc.). +// - Supports --features passthrough, hot-reload mode, and stdout capture (for benchmarks). +// - Depends on: workspace, utils::paths, utils::files, utils::platform, utils::assets. + +use anyhow::*; +use clap::{App, Arg, ArgMatches}; +use path_absolutize::Absolutize; +use std::{ + fs, + io::{BufRead, BufReader}, + path::PathBuf, + process::{Command, Stdio}, + time::Instant, +}; + +use crate::actions::Action; +use crate::types::*; +use crate::utils::cli::{ + clean_flag, compile_mode_flag, features_flag, output_path_flag, parse_build_target, + parse_compile_mode, path_flag, target_flag, +}; +use crate::utils::files::*; +use crate::utils::paths::*; +use crate::utils::plantuml::render_puml_for_crate; +use crate::utils::platform::*; +use crate::utils::wasm; +use crate::utils::web_dev_server; +use crate::utils::workspace::prepare_workspace_for_game; + +/// Shared CLI flag registration for both "run" and "build" actions. +fn register_build_flags(app: App<'static, 'static>) -> App<'static, 'static> { + app.arg(path_flag()) + .arg(output_path_flag()) + .arg(compile_mode_flag()) + .arg(target_flag()) + .arg(clean_flag()) + .arg(features_flag()) + .arg( + Arg::with_name("max-wasm-size") + .long("max-wasm-size") + .takes_value(true) + .help("Fail WASM build if binary exceeds N KB"), + ) +} + +/// Extract a concise, actionable error message from raw cargo stderr. +/// Handles panics with "Caused by:" chains and tool-not-found hints. +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") +} + +/// Format a duration as "after Xmin Ysec" or "after Ysec". +fn format_elapsed_time(elapsed: std::time::Duration) -> String { + let seconds = elapsed.as_secs(); + let minutes = seconds / 60; + let remainder = seconds % 60; + if minutes > 0 { + format!("after {}min {}sec", minutes, remainder) + } else { + format!("after {}sec", remainder) + } +} + +/// Format a build-failure message with ANSI red coloring and elapsed time. +fn format_build_error(detail: &str, elapsed: std::time::Duration) -> String { + let time_str = format_elapsed_time(elapsed); + format!( + "\x1b[31mPill Standalone \"run\" command failed {time_str}\x1b[0m\n\nCaused by:\n{detail}" + ) +} + +pub(crate) struct Run; + +impl Action for Run { + fn name(&self) -> &'static str { + "run" + } + + fn register(&self, app: App<'static, 'static>) -> App<'static, 'static> { + register_build_flags(app) + } + + fn run(&self, matches: &ArgMatches) -> Result<()> { + 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 features = matches.value_of("features"); + let passthrough: Vec = matches + .values_of("game-args") + .map(|v| v.map(String::from).collect()) + .unwrap_or_default(); + let clean = matches.is_present("clean"); + + if clean { + crate::utils::assets::run_asset_pipeline(&path.join("res"), true)?; + } + + match target { + BuildTarget::Native => { + let output_directory = + PathBuf::from(matches.value_of("output-path").unwrap_or(".")); + let output_directory = + get_game_build_path(&path, &output_directory, &compile_mode)?; + run_game_project( + &path, + &output_directory, + &compile_mode, + &passthrough, + features, + false, + )?; + } + BuildTarget::Web => { + web_dev_server::run(&path, &compile_mode)?; + } + } + Ok(()) + } +} + +pub(crate) struct Build; + +impl Action for Build { + fn name(&self) -> &'static str { + "build" + } + + fn register(&self, app: App<'static, 'static>) -> App<'static, 'static> { + register_build_flags(app) + } + + fn run(&self, matches: &ArgMatches) -> Result<()> { + 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 features = matches.value_of("features"); + let clean = matches.is_present("clean"); + let maximum_wasm_size: Option = matches + .value_of("max-wasm-size") + .and_then(|s| s.parse().ok()); + + if clean { + crate::utils::assets::run_asset_pipeline(&path.join("res"), true)?; + } + + match target { + BuildTarget::Native => { + let output_directory = + PathBuf::from(matches.value_of("output-path").unwrap_or(".")); + let output_directory = + get_game_build_path(&path, &output_directory, &compile_mode)?; + build_game_project(&path, &output_directory, &compile_mode, features)?; + } + 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(&path, &compile_mode, maximum_wasm_size)?; + } + } + Ok(()) + } +} + +/// Build and then launch the native standalone executable for a game project. +/// Supports optional stdout capture (for benchmarks) and --features passthrough. +/// Sets PILL_GAME_PROJECT_DIR, PILL_ENGINE_WORKSPACE_DIR, and other env vars. +pub(crate) fn run_game_project( + game_project_directory_path: &PathBuf, + output_directory_path: &PathBuf, + compile_mode: &CompileMode, + game_args: &[String], + features: Option<&str>, + capture_stdout: bool, +) -> Result> { + // Build game project + build_game_project( + game_project_directory_path, + output_directory_path, + compile_mode, + features, + )?; + + // Run game project + if !capture_stdout { + 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}{EXECUTABLE_SUFFIX}")); + + let launcher_bin = std::env::current_exe().context("current_exe failed")?; + let engine_workspace = find_engine_workspace_directory()?; // .../Pill-Engine/engine + + let mut cmd = Command::new(&standalone_executable_path); + cmd.current_dir(output_directory_path) + .env("PILL_LAUNCHER_BIN", &launcher_bin) + .env("PILL_ENGINE_WORKSPACE_DIR", &engine_workspace) + .env("PILL_GAME_PROJECT_DIR", game_project_directory_path) + .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(game_args); + + // Capture mode: pipe stdout into a String, inherit stderr to terminal. + if capture_stdout { + let output = cmd + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .output() + .with_context(|| { + format!( + "Failed to launch game project executable: {}", + standalone_executable_path.display() + ) + })?; + + if !output.status.success() { + eprintln!( + "Game exited with error code: {}", + output + .status + .code() + .map_or("unknown".into(), |c| c.to_string()) + ); + } + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + Ok(Some(stdout)) + } else { + let status = cmd.status().with_context(|| { + format!( + "Failed to launch game project executable: {}", + standalone_executable_path.display() + ) + })?; + + if !status.success() { + eprintln!( + "Game exited with error code: {}", + status.code().map_or("unknown".into(), |c| c.to_string()) + ); + } + Ok(None) + } +} + +/// Build pill_game + 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, and per-game target dirs. +pub(crate) fn build_game_project( + game_project_directory_path: &PathBuf, + output_directory_path: &PathBuf, + compile_mode: &CompileMode, + features: Option<&str>, +) -> Result<()> { + println!( + "Building game project from {}...", + game_project_directory_path.display() + ); + + 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); + } + } + + // Build all three workspace crates together so type IDs are consistent. + let mut arguments = vec![ + "build", + "-p", + "pill_game", + "-p", + "pill_native", + "-p", + "pill_runtime", + ]; + // Hot-reload uses a custom Cargo profile with fast incremental compilation. + if *compile_mode == CompileMode::HotReload { + arguments.push("--profile"); + arguments.push("hot-reload"); + arguments.push("--quiet"); + } + if *compile_mode == CompileMode::Release { + arguments.push("--release"); + } + // Append --features flags, splitting comma-separated values into individual args. + if let Some(feats) = features { + for feat in feats.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) { + arguments.push("--features"); + arguments.push(feat); + } + } + let start = Instant::now(); + let mut cargo_child = Command::new("cargo") + .args(&arguments) + .current_dir(&engine_workspace_directory_path) + .env("CARGO_TARGET_DIR", &cargo_target_dir) + .stdout(Stdio::inherit()) // real-time to terminal + .stderr(Stdio::piped()) // we'll read line-by-line + .spawn() + .context("failed to spawn cargo build")?; + + // Stream stderr in real time. When cargo hits an error, we suppress the + // noisy error-chain output and present it cleanly in the final error message. + 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(); + + // When we see an error header, start suppressing subsequent lines. + // The full error will be extracted and shown cleanly at the end. + if trimmed.starts_with("error:") + || (trimmed.starts_with("thread") && trimmed.contains("panicked at")) + { + in_error = true; + continue; + } + + if in_error { + // Stay in error mode until a clear "normal output" marker. + 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; + + // Build failed — extract only the actionable error message from the raw stderr. + if !cargo_status.success() { + let detail = parse_cargo_stderr(&stderr); + let elapsed = start.elapsed(); + bail!(format_build_error(&detail, elapsed)); + } + + // Cargo placed the compiled binaries in CARGO_TARGET_DIR//. + let compilation_artifacts_folder_path = + cargo_target_dir.join(get_target_directory_for_compile_mode(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 the standalone executable into the build output directory. + // Skip for initial hot-reload builds (only copy on subsequent reloads). + 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!("{game_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 perms = fs::metadata(&destination_executable_path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&destination_executable_path, perms)?; + } + } + + let data_directory = output_directory_path.join("data"); + fs::create_dir_all(&data_directory)?; + + // Release builds bundle game resources into data/res/ for standalone distribution. + if *compile_mode == CompileMode::Release { + stage_packaged_resource_files(game_project_directory_path, &data_directory)?; + } + + // Copy the game and runtime dynamic libraries into the build output. + let game_source = compilation_artifacts_folder_path.join(dynamic_library_name("pill_game")); + let runtime_source = + compilation_artifacts_folder_path.join(dynamic_library_name("pill_runtime")); + + if !game_source.exists() { + return Err(Error::msg(format!( + "Game dynamic library missing: {}", + game_source.display() + ))); + } + if !runtime_source.exists() { + return Err(Error::msg(format!( + "Runtime dynamic library missing: {}", + runtime_source.display() + ))); + } + + // Only copy dynamic libraries for initial builds — overwriting loaded libs + // in a running hot-reload process would crash it. + if *compile_mode != CompileMode::HotReload || !hot_reload_child { + if copy_file_if_newer( + &game_source, + &data_directory.join(dynamic_library_name("pill_game")), + )? { + println!("Copied game dynamic library"); + #[cfg(target_os = "macos")] + codesign_ad_hoc(&data_directory.join(dynamic_library_name("pill_game")))?; + } else { + println!("Skipping copying of game dynamic library"); + } + 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")))?; + } else { + println!("Skipping copying of runtime dynamic library"); + } + } + + // In hot-reload mode, also copy to the hot-reload names (file watcher looks for these). + if *compile_mode == CompileMode::HotReload { + if copy_file_if_newer( + &game_source, + &data_directory.join(dynamic_library_name("pill_game_hot_reloaded")), + )? { + println!("Copied game hot-reload dynamic library"); + } else { + println!("Skipping copying of game 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"); + } else { + println!("Skipping copying of runtime hot-reload dynamic library"); + } + } + + let time_str = format_elapsed_time(start.elapsed()); + println!("\x1b[32mGame built successfully {time_str}\x1b[0m"); + + 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..2ca26091 --- /dev/null +++ b/engine/pill_launcher/src/actions/cargo_passthrough.rs @@ -0,0 +1,82 @@ +// This file implements the "cargo" passthrough action. +// +// Responsibilities: +// - Prepares the engine workspace for the given game project. +// - Runs an arbitrary cargo command (fmt, clippy, etc.) in that workspace. +// - Used by the "ci" meta-action for fmt and clippy steps. +// - Depends on: workspace (prepare_workspace_for_game). + +use anyhow::*; +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::{parse_compile_mode, path_flag}; +use crate::utils::workspace::prepare_workspace_for_game; + +/// Registers `-p` / `--path` and collects trailing args as the cargo command. +pub(crate) struct Cargo; + +impl Action for Cargo { + fn name(&self) -> &'static str { + "cargo" + } + + fn register(&self, app: App<'static, 'static>) -> App<'static, 'static> { + app.arg(path_flag()) + } + + fn run(&self, matches: &ArgMatches) -> Result<()> { + let compile_mode = parse_compile_mode(matches); + let passthrough: Vec = matches + .values_of("game-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 game project linked. +/// Requires at least one cargo argument; fails if the cargo command exits non-zero. +pub(crate) fn cargo_passthrough( + game_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 game project into the workspace so cargo commands see the full context. + let engine_workspace_directory_path = + prepare_workspace_for_game(game_project_directory_path, compile_mode)?; + + 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/check.rs b/engine/pill_launcher/src/actions/check.rs new file mode 100644 index 00000000..d35d3d21 --- /dev/null +++ b/engine/pill_launcher/src/actions/check.rs @@ -0,0 +1,99 @@ +// This file implements the "check" action: fast compile-check of engine crates. +// +// Responsibilities: +// - Temporarily removes the game project from engine/Cargo.toml's workspace members +// so cargo check doesn't try to resolve a project with system-specific paths. +// - Runs `cargo check` on pill_core, pill_abi, pill_assets, pill_engine, pill_native, +// pill_runtime, and pill_web. +// - Restores the original Cargo.toml even on error (guard pattern). +// - Depends on: utils::paths (get_path, Location). + +use anyhow::*; +use clap::{App, ArgMatches}; +use std::fs; +use std::process::Command; + +use crate::actions::Action; +use crate::types::*; +use crate::utils::paths::*; + +/// No flags needed — just registers the action name. +pub(crate) struct Check; + +impl Action for Check { + fn name(&self) -> &'static str { "check" } + + fn register(&self, app: App<'static, 'static>) -> App<'static, 'static> { + app + } + + fn run(&self, _matches: &ArgMatches) -> Result<()> { + do_check() + } +} + +/// Run cargo check on all engine crates (no game code). +/// Temporarily strips the game project from workspace members, +/// runs the check, and restores the manifest even on error. +pub(crate) fn do_check() -> Result<()> { + println!("Running cargo check on engine crates..."); + + // Locate the engine workspace manifest and temporarily strip the game project. + let engine_dir = get_path(Location::EngineCrates); + let cargo_toml = engine_dir.join("Cargo.toml"); + + if !cargo_toml.exists() { + bail!("Engine Cargo.toml not found at {}", cargo_toml.display()); + } + + // Read the original manifest so we can restore it after the check. + let original = fs::read_to_string(&cargo_toml) + .with_context(|| format!("Failed to read {}", cargo_toml.display()))?; + + // Remove the game-project workspace member line so cargo check doesn't + // try to resolve a project that may have system-specific paths or missing deps. + let stripped: String = original + .lines() + .filter(|line| !line.contains(GAME_PROJECT_CRATE_MARKER)) + .collect::>() + .join("\n"); + + // Write the stripped version + fs::write(&cargo_toml, &stripped) + .with_context(|| format!("Failed to write {}", cargo_toml.display()))?; + + // Run cargo check in a closure so the manifest is always restored. + let result = (|| -> Result<()> { + let status = Command::new("cargo") + .args(&[ + "check", + "-p", "pill_core", + "-p", "pill_abi", + "-p", "pill_assets", + "-p", "pill_engine", + "-p", "pill_native", + "-p", "pill_runtime", + "-p", "pill_web", + ]) + .current_dir(&engine_dir) + .status() + .context("Failed to spawn cargo check")?; + + if !status.success() { + bail!( + "cargo check failed with exit code {}", + status.code().map_or("unknown".into(), |c| c.to_string()) + ); + } + Ok(()) + })(); + + // Restore the original manifest regardless of success/failure + fs::write(&cargo_toml, &original) + .with_context(|| format!("Failed to restore {}", cargo_toml.display()))?; + + result?; + + println!("cargo check passed."); + Ok(()) +} diff --git a/engine/pill_launcher/src/actions/check_wasm.rs b/engine/pill_launcher/src/actions/check_wasm.rs new file mode 100644 index 00000000..b02cbb66 --- /dev/null +++ b/engine/pill_launcher/src/actions/check_wasm.rs @@ -0,0 +1,166 @@ +// This file implements the "check-wasm" action: WASM build + smoke test + size check. +// +// Responsibilities: +// - Builds the WASM bundle via utils::wasm. +// - Optionally checks the final .wasm file size against a budget (--wasm-budget-kb). +// - Starts a tiny HTTP dev server on the given port, serves build/wasm/. +// - Smoke-tests that /, /pill_web_app.js, and /pill_web_app_bg.wasm return HTTP 200. +// - Stops the server and reports results. + +use anyhow::*; +use clap::{App, Arg, ArgMatches}; +use path_absolutize::Absolutize; +use std::{fs, path::PathBuf, result::Result::Ok}; + +use crate::actions::Action; +use crate::types::CompileMode; +use crate::utils::cli::path_flag; +use crate::utils::wasm; + +pub(crate) struct CheckWasm; + +impl Action for CheckWasm { + fn name(&self) -> &'static str { + "check-wasm" + } + + fn register(&self, app: App<'static, 'static>) -> App<'static, 'static> { + app.arg(path_flag()) + .arg( + Arg::with_name("wasm-port") + .long("wasm-port") + .takes_value(true) + .default_value("8080") + .help("Dev server port"), + ) + .arg( + Arg::with_name("wasm-budget-kb") + .long("wasm-budget-kb") + .takes_value(true) + .help("Fail if WASM exceeds N KB"), + ) + } + + fn run(&self, matches: &ArgMatches) -> Result<()> { + let path = PathBuf::from(matches.value_of("path").unwrap_or(".")) + .absolutize()? + .to_path_buf(); + let port: u16 = matches + .value_of("wasm-port") + .unwrap_or("8080") + .parse() + .unwrap_or(8080); + let budget: Option = matches + .value_of("wasm-budget-kb") + .and_then(|s| s.parse().ok()); + do_check_wasm(&path, port, budget) + } +} + +/// Build the WASM bundle (always debug — wasm-pack release is slow and +/// not needed for smoke testing), optionally check the binary size against a budget, +/// start a tiny HTTP dev server, smoke-test the three core files, and stop the server. +pub(crate) fn do_check_wasm( + game_project_directory_path: &PathBuf, + port: u16, + budget_kb: Option, +) -> Result<()> { + println!( + "WASM check: {} (port {})", + game_project_directory_path.display(), + port, + ); + + println!("Building WASM bundle (debug) ..."); + wasm::build(game_project_directory_path, &CompileMode::Debug, None)?; + + let wasm_directory = game_project_directory_path.join("build").join("wasm"); + + // Optional size check: fail early if the .wasm exceeds the budget. + if let Some(budget) = budget_kb { + let wasm_file_path = wasm_directory.join("pill_web_app_bg.wasm"); + if !wasm_file_path.exists() { + bail!("WASM file not found: {}", wasm_file_path.display()); + } + let size = fs::metadata(&wasm_file_path) + .with_context(|| format!("Failed to stat {}", wasm_file_path.display()))? + .len(); + let size_kb = size / 1024; + if size_kb > budget { + bail!("WASM size {} KB exceeds {} KB budget", size_kb, budget); + } + println!(" OK: WASM size {} KB <= {} KB", size_kb, budget); + } + + println!("Starting dev server on port {} ...", port); + let wasm_directory_clone = wasm_directory.clone(); + let server = std::sync::Arc::new( + tiny_http::Server::http(format!("127.0.0.1:{}", port)) + .map_err(|e| anyhow::anyhow!("Failed to bind dev server on port {}: {}", port, e))?, + ); + let server_for_thread = std::sync::Arc::clone(&server); + + let server_thread = std::thread::spawn(move || { + for request in server_for_thread.incoming_requests() { + let request_path = request.url().trim_start_matches('/'); + let file_path = if request_path.is_empty() { + wasm_directory_clone.join("index.html") + } else { + wasm_directory_clone.join(request_path) + }; + match fs::read(&file_path) { + Ok(data) => { + let _ = request.respond(tiny_http::Response::from_data(data)); + } + Err(_) => { + let _ = request.respond(tiny_http::Response::empty(404)); + } + } + } + }); + + // Retry the smoke test with brief back-off rather than a fixed sleep. + let mut server_ready = false; + for _ in 0..20 { + std::thread::sleep(std::time::Duration::from_millis(50)); + if ureq::get(&format!("http://127.0.0.1:{}/", port)) + .call() + .is_ok() + { + server_ready = true; + break; + } + } + if !server_ready { + server.unblock(); + let _ = server_thread.join(); + bail!("Dev server on port {} did not become ready in time", port); + } + + let result = (|| -> Result<()> { + let base = format!("http://127.0.0.1:{}", port); + + println!("Smoke testing HTTP endpoints ..."); + let files = &["/", "/pill_web_app.js", "/pill_web_app_bg.wasm"]; + for endpoint in files { + let url = format!("{}{}", base, endpoint); + let response = ureq::get(&url) + .call() + .with_context(|| format!("HTTP GET {} failed", url))?; + if response.status() != 200 { + bail!("HTTP {} for {} (expected 200)", response.status(), url); + } + println!(" OK: {} (200)", endpoint); + } + + Ok(()) + })(); + + // Shut down the server cleanly and join the thread before returning. + server.unblock(); + let _ = server_thread.join(); + + result?; + println!("WASM check passed."); + Ok(()) +} diff --git a/engine/pill_launcher/src/actions/ci.rs b/engine/pill_launcher/src/actions/ci.rs new file mode 100644 index 00000000..d366e7a3 --- /dev/null +++ b/engine/pill_launcher/src/actions/ci.rs @@ -0,0 +1,81 @@ +// This file implements the "ci" meta-action: run check → fmt → clippy → build. +// +// Responsibilities: +// - Executes four CI steps sequentially, stopping on the first failure. +// - Steps: cargo check (engine crates), rustfmt, clippy -D warnings, cargo build. +// - Uses actions::check, actions::cargo_passthrough (for fmt/clippy), and actions::build. +// - Prints a summary when all steps pass. + +use anyhow::*; +use clap::{App, ArgMatches}; +use path_absolutize::Absolutize; +use std::path::PathBuf; + +use crate::actions::build::build_game_project; +use crate::actions::cargo_passthrough::cargo_passthrough; +use crate::actions::check::do_check; +use crate::actions::Action; +use crate::types::*; +use crate::utils::cli::{compile_mode_flag, parse_compile_mode, path_flag}; +use crate::utils::paths::get_game_build_path; + +pub(crate) struct Ci; + +impl Action for Ci { + fn name(&self) -> &'static str { + "ci" + } + + fn register(&self, app: App<'static, 'static>) -> App<'static, 'static> { + app.arg(path_flag()).arg(compile_mode_flag()) + } + + fn run(&self, matches: &ArgMatches) -> Result<()> { + let compile_mode = parse_compile_mode(matches); + let path = PathBuf::from(matches.value_of("path").unwrap_or(".")) + .absolutize()? + .to_path_buf(); + do_ci(&path, &compile_mode) + } +} + +/// Run the CI pipeline: cargo check → rustfmt → clippy → build. +/// Stops on the first failing step. Prints a summary on success. +pub(crate) fn do_ci(game_project_path: &PathBuf, compile_mode: &CompileMode) -> Result<()> { + println!("=== CI Pipeline ==="); + println!(); + + // Step 1: fast compile-check of all engine crates (no game code). + println!("--- 1/4: cargo check ---"); + do_check().context("check step failed")?; + + // Step 2: code formatting via rustfmt. + println!("--- 2/4: rustfmt ---"); + cargo_passthrough(game_project_path, compile_mode, &["fmt".into()]) + .context("fmt step failed")?; + + // Step 3: clippy linting with deny-by-default warnings. + println!("--- 3/4: clippy (-D warnings) ---"); + cargo_passthrough( + game_project_path, + compile_mode, + &["clippy".into(), "--".into(), "-D".into(), "warnings".into()], + ) + .context("clippy step failed")?; + + // Step 4: full native build of the game project. + println!("--- 4/4: build ---"); + let output_path = PathBuf::from("."); + let output_directory_path = get_game_build_path(game_project_path, &output_path, compile_mode)?; + build_game_project( + game_project_path, + &output_directory_path, + compile_mode, + None, + ) + .context("build step failed")?; + + println!(); + println!("\x1b[32mAll CI checks passed.\x1b[0m"); + 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..f738ae5d --- /dev/null +++ b/engine/pill_launcher/src/actions/create.rs @@ -0,0 +1,147 @@ +// This file implements the "create" action: scaffolding a new game 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. +// - Depends on: utils::paths (get_path, Location), utils::files (modify_file). + +use anyhow::*; +use clap::{App, Arg, ArgMatches}; +use fs_extra::dir::CopyOptions; +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::modify_file; +use crate::utils::paths::*; + +pub(crate) struct Create; + +impl Action for Create { + fn name(&self) -> &'static str { + "create" + } + + fn register(&self, app: App<'static, 'static>) -> App<'static, 'static> { + app.arg( + Arg::with_name("name") + .short("n") + .long("name") + .takes_value(true) + .help("Name of new game project"), + ) + .arg(path_flag()) + } + + 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_game_project(&parent, &name) + } +} + +/// Scaffold a new Pill game project from the pill_default template. +/// Copies template files, renames the directory, and rewrites config.ini +/// and Cargo.toml with the new project name and absolute engine paths. +pub(crate) fn create_game_project( + game_project_parent_directory_path: &Path, + game_name: &str, +) -> Result<()> { + const TEMPLATE_NAME: &str = "pill_default"; + + let game_project_directory_path = game_project_parent_directory_path.join(game_name); + // Guard against overwriting an existing directory. + 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 the pill_default template into the target parent directory. + 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 the copied template directory to the new project name. + std::fs::rename( + game_project_parent_directory_path.join(TEMPLATE_NAME), + &game_project_directory_path, + ) + .context("Failed to rename template directory to game project 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 + }, + )?; + + // Rewrite Cargo.toml in a single pass — point pill_engine at the absolute path + // and set the workspace field to the engine workspace directory. + println!("Setting up manifest file..."); + let cargo_toml_path = game_project_directory_path.join("Cargo.toml"); + let pill_engine_path = get_path(Location::PillEngineCrate) + .to_str() + .unwrap() + .replace('\\', "/"); + let engine_workspace_path = get_path(Location::EngineCrates) + .to_str() + .unwrap() + .replace('\\', "/"); + modify_file( + &cargo_toml_path, + &cargo_toml_path, + |line: String| -> String { + if line.contains("pill_engine") { + return format!( + "pill_engine = {{ path = \"{pill_engine_path}\", features = [\"game\"] }}" + ); + } + if line.contains("workspace") { + return format!("workspace = \"{engine_workspace_path}\""); + } + line + }, + )?; + + // Success + println!("Game 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..76ee9e3d --- /dev/null +++ b/engine/pill_launcher/src/actions/docs.rs @@ -0,0 +1,218 @@ +// This file implements the "docs" action: generating rustdoc for engine crates. +// +// Responsibilities: +// - Generates two doc sets: game_dev (public API) and engine_dev (private items). +// - Temporarily rewrites the Empty 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. +// - Depends on: utils::paths, utils::files, utils::assets. + +use anyhow::*; +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::plantuml::render_puml_for_crate; +use crate::utils::files::modify_file; +use crate::utils::paths::*; + +pub(crate) struct Docs; + +impl Action for Docs { + fn name(&self) -> &'static str { + "docs" + } + + fn register(&self, app: App<'static, 'static>) -> App<'static, 'static> { + app.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: game_dev and engine_dev. +/// Temporarily rewrites Cargo.toml files so cargo doc resolves path dependencies. +/// Restores original manifests on exit even if generation fails. +pub(crate) fn generate_docs(output_directory_path: &PathBuf) -> Result<()> { + // The Empty example serves as a workspace anchor so cargo doc can resolve deps. + 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", + )); + } + + let empty_cargo_toml = empty_example_game_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_empty = fs::read_to_string(&empty_cargo_toml) + .with_context(|| format!("Failed to read {}", empty_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 Empty example's Cargo.toml at the absolute engine path + // so cargo doc can resolve the pill_engine dependency. + modify_file( + &empty_cargo_toml, + &empty_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 + }, + )?; + + // 2. Point pill_native's Cargo.toml at the Empty 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("pill_game") { + return format!( + "pill_game = {{path = \"{}\"}}", + empty_example_game_path.to_str().unwrap().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 ./docs/. + 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"); + + // 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: game_dev (public API) and engine_dev (all items). + let output_game_dev_path = docs_path.join("game_dev"); + let output_engine_dev_path = docs_path.join("engine_dev"); + + 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"); + + // 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 game_dev docs: public API surface (game + internal features). + 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!"); + + // 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 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")?; + + // 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 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", + ]; + let status = Command::new("cargo") + .args(arguments) + .status() + .context("Failed to execute command for generating engine dev docs")?; + + if !status.success() { + bail!( + "Game dev docs failed to generate (exit {:?})", + status.code() + ); + } + println!("Game dev docs generated successfully!"); + + Ok(()) + })(); + + // Restore original manifests regardless of success/failure. + fs::write(&empty_cargo_toml, &original_empty) + .with_context(|| format!("Failed to restore {}", empty_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/mod.rs b/engine/pill_launcher/src/actions/mod.rs new file mode 100644 index 00000000..1d77d376 --- /dev/null +++ b/engine/pill_launcher/src/actions/mod.rs @@ -0,0 +1,45 @@ +// This file declares all sub-modules under the actions/ directory and defines +// the common Action trait that every CLI action must implement. +// +// Modules: +// - create: scaffold a new game project from template. +// - build: compile and run native game projects (Build + Run structs). +// - docs: generate cargo doc for engine crates. +// - cargo_passthrough: forward arbitrary cargo commands to the workspace. +// - check: fast compile-check of engine crates (no game code). +// - performance_benchmark: build+run iterations, collect frame-time JSON, print stats. +// - size_benchmark: build + analyze artifact sizes (native folder / WASM binary). +// - check_wasm: build WASM, smoke-test dev server, check size budget. +// - ci: meta-action running check → fmt → clippy → build sequentially. + +use anyhow::Result; +use clap::{App, ArgMatches}; + +pub mod assets; +pub mod build; +pub mod cargo_passthrough; +pub mod check; +pub mod ci; +pub mod create; +pub mod docs; +pub mod performance_benchmark; +pub mod size_benchmark; +pub mod check_wasm; + +/// Common interface for every CLI action. +/// +/// Each action: +/// - Has a unique name (the value passed to `-a` / `--action`). +/// - 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; + + /// Register this action's CLI flags on the given `App` and return it. + fn register(&self, app: App<'static, 'static>) -> App<'static, 'static>; + + /// Execute the action using values extracted from the parsed CLI matches. + fn run(&self, matches: &ArgMatches) -> Result<()>; +} diff --git a/engine/pill_launcher/src/actions/performance_benchmark.rs b/engine/pill_launcher/src/actions/performance_benchmark.rs new file mode 100644 index 00000000..a7b7c203 --- /dev/null +++ b/engine/pill_launcher/src/actions/performance_benchmark.rs @@ -0,0 +1,357 @@ +// This file implements the "benchmark" action: build+run iterations with stats. +// +// Responsibilities: +// - Builds the game with benchmark features (e.g. benchmark_window). +// - Runs N iterations, capturing stdout to extract JSON frame-time reports. +// - Parses full per-iteration statistics and prints a markdown table + aggregate summary. +// - Depends on: actions::build (build_game_project, run_game_project), utils::paths. + +use anyhow::*; +use clap::{App, Arg, ArgMatches}; +use path_absolutize::Absolutize; +use std::path::PathBuf; + +use crate::actions::build::{build_game_project, run_game_project}; +use crate::actions::Action; +use crate::types::*; +use crate::utils::cli::{compile_mode_flag, parse_compile_mode, path_flag}; +use crate::utils::paths::get_game_build_path; + +pub(crate) struct Benchmark; + +impl Action for Benchmark { + fn name(&self) -> &'static str { + "benchmark" + } + + fn register(&self, app: App<'static, 'static>) -> App<'static, 'static> { + app.arg(path_flag()) + .arg(compile_mode_flag()) + .arg( + Arg::with_name("benchmark-iterations") + .long("benchmark-iterations") + .takes_value(true) + .default_value("5") + .help("Number of iterations"), + ) + .arg( + Arg::with_name("benchmark-features") + .long("benchmark-features") + .takes_value(true) + .default_value("benchmark_window") + .help("Cargo features for benchmark"), + ) + } + + fn run(&self, matches: &ArgMatches) -> Result<()> { + let compile_mode = parse_compile_mode(matches); + let path = PathBuf::from(matches.value_of("path").unwrap_or(".")) + .absolutize()? + .to_path_buf(); + let iterations: u32 = matches + .value_of("benchmark-iterations") + .unwrap_or("5") + .parse() + .unwrap_or(5); + let features = matches + .value_of("benchmark-features") + .unwrap_or("benchmark_window"); + do_benchmark(&path, &compile_mode, iterations, features) + } +} + +/// Per-iteration statistics parsed from the game's JSON report line. +#[derive(Debug, Clone)] +struct IterationStats { + /// Which run this was (1-based). + run: u32, + /// Benchmark mode ("windowed" or "headless"). + mode: String, + /// Total frames simulated (including warmup). + total_frames: u64, + /// Frames actually measured (after warmup). + measured_frames: u64, + /// Number of entities in the scene. + entity_count: u64, + /// Average frame time in milliseconds. + average_ms: f64, + /// Median frame time in milliseconds. + median_ms: f64, + /// Fastest frame time in milliseconds. + min_ms: f64, + /// Slowest frame time in milliseconds. + max_ms: f64, + /// Difference between max and min (milliseconds). + range_ms: f64, + /// Variance of frame times. + variance: f64, + /// Standard deviation of frame times in milliseconds. + standard_deviation_ms: f64, +} + +/// Build the game with benchmark features, run N iterations capturing stdout +/// JSON, parse full stats, and print a markdown table + aggregate summary. +pub(crate) fn do_benchmark( + game_project_directory_path: &PathBuf, + compile_mode: &CompileMode, + benchmark_iterations: u32, + benchmark_features: &str, +) -> Result<()> { + println!( + "Benchmark: {} ({} iterations, features: {})", + game_project_directory_path.display(), + benchmark_iterations, + benchmark_features, + ); + + let mut output_directory_path = PathBuf::from("."); + output_directory_path = get_game_build_path( + game_project_directory_path, + &output_directory_path, + compile_mode, + )?; + + println!("Building with features: {} ...", benchmark_features); + build_game_project( + game_project_directory_path, + &output_directory_path, + compile_mode, + Some(benchmark_features), + )?; + + // Run each iteration, capture stdout, and parse the JSON report. + let mut all_stats: Vec = Vec::new(); + for i in 1..=benchmark_iterations { + println!("Iteration {} / {} ...", i, benchmark_iterations); + let output = run_game_project( + game_project_directory_path, + &output_directory_path, + compile_mode, + &[], + Some(benchmark_features), + true, + )?; + + if let Some(stdout) = output { + // Find the first line starting with '{' — that's the JSON report. + for line in stdout.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('{') { + if let Some(stats) = parse_iteration_json(trimmed, i) { + all_stats.push(stats); + } + break; + } + } + } + + if all_stats.len() < i as usize { + eprintln!(" Warning: no JSON output captured for iteration {}", i); + } + } + + if all_stats.is_empty() { + bail!("No benchmark iterations produced valid JSON output"); + } + + print_markdown_report(&all_stats); + + Ok(()) +} + +// -- JSON parsing ------------------------------------------------------------ + +/// Parse a single iteration's JSON report line into an [`IterationStats`]. +fn parse_iteration_json(json: &str, run: u32) -> Option { + let mode = extract_json_string(json, "mode")?; + let total_frames = extract_json_u64(json, "total_frames")?; + let measured_frames = extract_json_u64(json, "measured_frames")?; + let entity_count = extract_json_u64(json, "entity_count")?; + let average_ms = extract_json_f64(json, "average_ms")?; + let median_ms = extract_json_f64(json, "median_ms")?; + let min_ms = extract_json_f64(json, "min_ms")?; + let max_ms = extract_json_f64(json, "max_ms")?; + let range_ms = extract_json_f64(json, "range_ms")?; + let variance = extract_json_f64(json, "variance")?; + let standard_deviation_ms = extract_json_f64(json, "stddev_ms")?; + + Some(IterationStats { + run, + mode, + total_frames, + measured_frames, + entity_count, + average_ms, + median_ms, + min_ms, + max_ms, + range_ms, + variance, + standard_deviation_ms, + }) +} + +/// Extract an `f64` value from a JSON object for the given key. +fn extract_json_f64(json: &str, key: &str) -> Option { + let search = format!("\"{}\":", key); + let start = json.find(&search)? + search.len(); + let rest = &json[start..]; + let end = rest + .find(|c: char| c == ',' || c == '}' || c.is_whitespace()) + .unwrap_or(rest.len()); + rest[..end].trim().parse::().ok() +} + +/// Extract a `u64` value from a JSON object for the given key. +fn extract_json_u64(json: &str, key: &str) -> Option { + let search = format!("\"{}\":", key); + let start = json.find(&search)? + search.len(); + let rest = &json[start..]; + let end = rest + .find(|c: char| c == ',' || c == '}' || c.is_whitespace()) + .unwrap_or(rest.len()); + rest[..end].trim().parse::().ok() +} + +/// Extract a quoted string value from a JSON object for the given key. +fn extract_json_string(json: &str, key: &str) -> Option { + let search = format!("\"{}\":\"", key); + let start = json.find(&search)? + search.len(); + let rest = &json[start..]; + let end = rest.find('"')?; + Some(rest[..end].to_string()) +} + +// -- Report formatting ------------------------------------------------------- + +/// Print a markdown table of per-iteration stats plus an aggregate summary. +fn print_markdown_report(all_stats: &[IterationStats]) { + let count = all_stats.len(); + let mode = &all_stats[0].mode; + let entities = all_stats[0].entity_count; + let total_frames = all_stats[0].total_frames; + let measured_frames = all_stats[0].measured_frames; + + println!(); + println!("=== Benchmark Report ==="); + println!(); + println!("| {:<18} | {:<16} |", "Parameter", "Value"); + println!("|{:-^20}|{:-^18}|", "", ""); + println!("| {:<18} | {:<16} |", "Mode", mode); + println!("| {:<18} | {:<16} |", "Entities", entities); + println!("| {:<18} | {:<16} |", "Total frames/run", total_frames); + println!("| {:<18} | {:<16} |", "Measured frames", measured_frames); + println!("| {:<18} | {:<16} |", "Iterations", count); + println!(); + + // Per-iteration table + println!( + "| {:<3} | {:<8} | {:<11} | {:<8} | {:<8} | {:<10} | {:<11} | {:<8} |", + "Run", + "Avg (ms)", + "Median (ms)", + "Min (ms)", + "Max (ms)", + "Range (ms)", + "StdDev (ms)", + "Variance" + ); + println!( + "|{:-^5}|{:-^10}|{:-^13}|{:-^10}|{:-^10}|{:-^12}|{:-^13}|{:-^10}|", + "", "", "", "", "", "", "", "" + ); + for stats in all_stats { + println!( + "| {:<3} | {:>8.3} | {:>11.3} | {:>8.3} | {:>8.3} | {:>10.3} | {:>11.3} | {:>8.4} |", + stats.run, + stats.average_ms, + stats.median_ms, + stats.min_ms, + stats.max_ms, + stats.range_ms, + stats.standard_deviation_ms, + stats.variance, + ); + } + println!(); + + // Aggregate statistics computed from the per-iteration averages. + let averages: Vec = all_stats.iter().map(|s| s.average_ms).collect(); + let aggregate_mean = mean(&averages); + let aggregate_median = median_of(&averages); + let aggregate_stddev = standard_deviation(&averages, aggregate_mean); + + // Best / worst iterations (by average_ms) + let best = all_stats + .iter() + .min_by(|a, b| a.average_ms.partial_cmp(&b.average_ms).unwrap()) + .unwrap(); + let worst = all_stats + .iter() + .max_by(|a, b| a.average_ms.partial_cmp(&b.average_ms).unwrap()) + .unwrap(); + + println!("### Aggregate (cross-iteration)"); + println!(); + println!("| {:<21} | {:<16} |", "Metric", "Value"); + println!("|{:-^23}|{:-^18}|", "", ""); + println!("| {:<21} | {:<16.3} ms |", "Mean avg_ms", aggregate_mean); + println!( + "| {:<21} | {:<16.3} ms |", + "Median avg_ms", aggregate_median + ); + println!( + "| {:<21} | {:<16.3} ms |", + "StdDev avg_ms", aggregate_stddev + ); + println!( + "| {:<21} | #{run} — {val:<16.3} ms |", + "Best run (avg_ms)", + run = best.run, + val = best.average_ms + ); + println!( + "| {:<21} | #{run} — {val:<16.3} ms |", + "Worst run (avg_ms)", + run = worst.run, + val = worst.average_ms + ); + println!( + "| {:<21} | {:<16.3} ms |", + "Spread (worst−best)", + worst.average_ms - best.average_ms + ); + println!(); +} + +// -- Statistics helpers ------------------------------------------------------ + +fn mean(values: &[f64]) -> f64 { + if values.is_empty() { + return 0.0; + } + values.iter().sum::() / values.len() as f64 +} + +fn median_of(values: &[f64]) -> f64 { + if values.is_empty() { + return 0.0; + } + let mut sorted: Vec = values.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let mid = sorted.len() / 2; + if sorted.len() % 2 == 0 { + (sorted[mid - 1] + sorted[mid]) / 2.0 + } else { + sorted[mid] + } +} + +fn standard_deviation(values: &[f64], mean: f64) -> f64 { + if values.len() < 2 { + return 0.0; + } + let variance: f64 = + values.iter().map(|v| (v - mean).powi(2)).sum::() / (values.len() - 1) as f64; + variance.sqrt() +} diff --git a/engine/pill_launcher/src/actions/size_benchmark.rs b/engine/pill_launcher/src/actions/size_benchmark.rs new file mode 100644 index 00000000..ffce52f9 --- /dev/null +++ b/engine/pill_launcher/src/actions/size_benchmark.rs @@ -0,0 +1,520 @@ +// This file implements the "size-benchmark" action: build + artifact size analysis. +// +// Responsibilities: +// - Builds the game project for the given target (native or WASM). +// - Analyzes the final build artifact sizes: +// - Native: executable, dynamic libraries, resource directories. +// - WASM: final and pre-optimization .wasm sizes, twiggy per-crate breakdown. +// - Prints a formatted size report to the console. + +use anyhow::*; +use clap::{App, ArgMatches}; +use path_absolutize::Absolutize; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::result::Result::Ok; + +use crate::actions::Action; +use crate::types::*; +use crate::utils::cli::{ + parse_build_target, path_flag, target_flag, +}; +use crate::utils::paths::get_game_build_path; + +pub(crate) struct SizeBenchmark; + +impl Action for SizeBenchmark { + fn name(&self) -> &'static str { + "size-benchmark" + } + + fn register(&self, app: App<'static, 'static>) -> App<'static, 'static> { + app.arg(path_flag()) + .arg(target_flag()) + } + + fn run(&self, matches: &ArgMatches) -> Result<()> { + let path = PathBuf::from(matches.value_of("path").unwrap_or(".")) + .absolutize()? + .to_path_buf(); + let target = parse_build_target(matches); + // Always release — debug sizes are dominated by debuginfo and meaningless. + let compile_mode = CompileMode::Release; + do_size_benchmark(&path, &compile_mode, &target) + } +} + +/// Build the game and print a size report for the final artifact. +pub(crate) fn do_size_benchmark( + game_project_directory_path: &PathBuf, + compile_mode: &CompileMode, + target: &BuildTarget, +) -> Result<()> { + println!( + "Size benchmark: {} (target: {}, compile mode: release)", + game_project_directory_path.display(), + target, + ); + + match target { + BuildTarget::Native => { + let mut output_directory_path = PathBuf::from("."); + output_directory_path = get_game_build_path( + game_project_directory_path, + &output_directory_path, + compile_mode, + )?; + + println!("Building native target..."); + crate::actions::build::build_game_project( + game_project_directory_path, + &output_directory_path, + compile_mode, + None, + )?; + + print_native_report(&output_directory_path); + } + BuildTarget::Web => { + println!("Building WASM target..."); + crate::utils::wasm::build(game_project_directory_path, compile_mode, None)?; + + let build_wasm_dir = game_project_directory_path.join("build").join("wasm"); + let pre_optimization_wasm = build_wasm_dir + .join(".build") + .join("pill_web_app") + .join("target") + .join("wasm32-unknown-unknown") + .join(match compile_mode { + CompileMode::Release => "release", + _ => "debug", + }) + .join("pill_web_app.wasm"); + + if pre_optimization_wasm.exists() { + print_wasm_report(&build_wasm_dir, &pre_optimization_wasm); + } else { + println!("Note: pre-optimization WASM not found, skipping twiggy analysis."); + println!( + "Final WASM: {}", + build_wasm_dir.join("pill_web_app_bg.wasm").display() + ); + } + } + } + + Ok(()) +} + +// ============================================================================ +// Native size report +// ============================================================================ + +/// Print a native build size report: executable, dynamic libraries, and +/// resource directory sizes with a breakdown. +fn print_native_report(build_output_dir: &Path) { + println!(); + println!("=== Native Build Size Report ==="); + println!(); + + let data_directory = build_output_dir.join("data"); + let executable_ext = if cfg!(target_os = "windows") { + ".exe" + } else { + "" + }; + + let mut executable_size: Option<(String, u64)> = None; + let mut dynamic_library_sizes: Vec<(String, u64)> = Vec::new(); + + if let Ok(entries) = fs::read_dir(build_output_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + let name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + if name.ends_with(executable_ext) && !name.starts_with("pill_") { + if let Ok(meta) = path.metadata() { + executable_size = Some((name, meta.len())); + } + } + } + } + } + + // Measure dynamic libraries in data/ + if data_directory.exists() { + if let Ok(entries) = fs::read_dir(&data_directory) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + let name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + if name.contains("pill_") + && (name.contains(".dll") + || name.contains(".so") + || name.contains(".dylib")) + { + if let Ok(meta) = path.metadata() { + dynamic_library_sizes.push((name, meta.len())); + } + } + } + } + } + } + + // Summary table + println!("| {:<30} | {:<12} |", "Component", "Size"); + println!("|{:-^32}|{:-^14}|", "", ""); + + let mut total: u64 = 0; + + if let Some((ref name, size)) = executable_size { + println!( + "| {:<30} | {:>10} |", + format!("Executable ({name})"), + format_bytes(size) + ); + total += size; + } + + for (ref name, size) in &dynamic_library_sizes { + println!( + "| {:<30} | {:>10} |", + format!("Library ({name})"), + format_bytes(*size) + ); + total += *size; + } + + // Measure data/res/ + let resources_directory = data_directory.join("res"); + if resources_directory.exists() { + let resources_size = dir_size(&resources_directory); + if resources_size > 0 { + println!( + "| {:<30} | {:>10} |", + "Resources (data/res/)", + format_bytes(resources_size) + ); + total += resources_size; + } + + // Breakdown of resource subdirectories + if let Ok(entries) = fs::read_dir(&resources_directory) { + for entry in entries.flatten() { + let path = entry.path(); + let name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + if path.is_dir() { + let sub_size = dir_size(&path); + if sub_size > 0 { + println!( + "| {:<30} | {:>10} |", + format!(" └─ {name}/"), + format_bytes(sub_size) + ); + } + } + } + } + } + + println!("|{:-^32}|{:-^14}|", "", ""); + println!( + "| {:<30} | {:>10} |", + "TOTAL", + format_bytes(total) + ); + println!(); +} + +/// Recursively compute the total byte size of a directory. +fn dir_size(path: &Path) -> u64 { + let mut total: u64 = 0; + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + total += dir_size(&path); + } else if let Ok(meta) = path.metadata() { + total += meta.len(); + } + } + } + total +} + +// ============================================================================ +// WASM size report (twiggy-based) +// ============================================================================ + +/// Print a WASM size report: final vs pre-optimization sizes, per-crate +/// breakdown, and top symbols. Uses twiggy if available. +fn print_wasm_report(build_wasm_dir: &Path, pre_optimization_wasm: &Path) { + let Ok(pre_optimization_size) = fs::metadata(pre_optimization_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-optimization {}", + format_bytes(f), + format_bytes(pre_optimization_size) + ), + None => println!( + "wasm size: pre-optimization {}", + format_bytes(pre_optimization_size) + ), + } + + if let Some(f) = final_size { + println!(); + println!("--- Final binary analysis ({}) ---", format_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-optimization analysis ({}) ---", + format_bytes(pre_optimization_size) + ); + if let TwiggyResult::NoTwiggy = + run_twiggy_analysis(pre_optimization_wasm, pre_optimization_size) + { + println!("(install twiggy for per-crate breakdown: cargo install twiggy)"); + } +} + +enum TwiggyResult { + Done, + Empty, + Error, + NoTwiggy, +} + +fn run_twiggy_analysis(wasm_file_path: &Path, total: u64) -> TwiggyResult { + let output = match Command::new("twiggy") + .args(["top", "-n", "15000"]) + .arg(wasm_file_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_output(&stdout); + if items.is_empty() { + return TwiggyResult::Empty; + } + + // Aggregate symbol bytes by crate family. + 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(); + + // Engine libs breakdown + println!(); + println!(" Engine libs — BUDGET (% of {}):", format_bytes(total)); + println!(" {:<20} {:>10} {:>7}", "crate", "size", "%"); + for lib in ENGINE_LIBS { + let bytes = by_crate.get(*lib).copied().unwrap_or(0); + let percent = 100.0 * bytes as f64 / total as f64; + println!(" {:<20} {:>10} {:>6.1}%", lib, format_bytes(bytes), percent); + } + let engine_percent = 100.0 * engine_total as f64 / total as f64; + println!( + " {:<20} {:>10} {:>6.1}% ← engine total", + "---", + format_bytes(engine_total), + engine_percent + ); + + // Game code + embedded assets (monitoring only) + 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", + format_bytes(game_bytes), + 100.0 * game_bytes as f64 / total as f64 + ); + println!( + " {:<20} {:>10} {:>6.1}% (embedded assets via include_bytes!)", + "[game-assets]", + format_bytes(game_rodata), + 100.0 * game_rodata as f64 / total as f64 + ); + + // Top 15 third-party dependencies + 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 percent = 100.0 * *bytes as f64 / total as f64; + println!( + " {:<20} {:>10} {:>6.1}%", + crate_name, + format_bytes(*bytes), + percent + ); + } + + // Top 10 individual symbols + println!(); + println!(" Top 10 symbols:"); + for (bytes, name) in items.iter().take(10) { + let percent = 100.0 * *bytes as f64 / total as f64; + let display = truncate_display(name, 72); + println!( + " {:>10} {:>5.1}% {}", + format_bytes(*bytes), + percent, + display + ); + } + + TwiggyResult::Done +} + +fn parse_twiggy_output(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 format_bytes(n: u64) -> String { + const MB: f64 = 1024.0 * 1024.0; + const KB: f64 = 1024.0; + let bytes_float = n as f64; + if bytes_float >= MB { + format!("{:.2} MB", bytes_float / MB) + } else if bytes_float >= KB { + format!("{:.1} KB", bytes_float / KB) + } else { + format!("{n} B") + } +} + +/// Coarse bucketing of twiggy item names into crate families. +/// Heuristic — relies on stable twiggy section names and wasm-bindgen symbol prefixes. +fn classify_crate(name: &str) -> String { + if name.contains(".rodata") || name.contains("data segment") { + if name.contains("pill_game") { + return "[game-rodata]".into(); + } + return "[other-rodata]".into(); + } + if name.contains("lol_alloc") { + return "lol_alloc".into(); + } + if name.starts_with("<") { + if let Some(idx) = name.find(" as ") { + let crate_name = &name[1..idx]; + return crate_name.to_string(); + } + return "[unknown]".into(); + } + if let Some(idx) = name.find("::") { + let crate_name = &name[..idx]; + if crate_name.eq_ignore_ascii_case("PillEngine") { + return "pill_engine".into(); + } + if crate_name.eq_ignore_ascii_case("PillRenderer") { + return "pill_renderer".into(); + } + if crate_name.eq_ignore_ascii_case("PillCore") { + return "pill_core".into(); + } + if crate_name.eq_ignore_ascii_case("PillWeb") { + return "pill_web".into(); + } + if crate_name.eq_ignore_ascii_case("PillGame") { + return "pill_game".into(); + } + return crate_name.to_string(); + } + "[other]".into() +} diff --git a/engine/pill_launcher/src/main.rs b/engine/pill_launcher/src/main.rs index f856de9b..9d3c0cd8 100644 --- a/engine/pill_launcher/src/main.rs +++ b/engine/pill_launcher/src/main.rs @@ -1,1361 +1,45 @@ -#![allow(non_snake_case, dead_code)] +// 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. + +// PillLauncher uses PascalCase for the binary name — allow it without global suppression. +#![allow(non_snake_case)] + +mod actions; +mod types; +mod utils; + +use actions::assets::Assets; +use actions::build::{Build, Run}; +use actions::cargo_passthrough::Cargo; +use actions::check::Check; +use actions::check_wasm::CheckWasm; +use actions::ci::Ci; +use actions::create::Create; +use actions::docs::Docs; +use actions::performance_benchmark::Benchmark; +use actions::size_benchmark::SizeBenchmark; +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: Vec> = vec![ + Box::new(Create), + Box::new(Run), + Box::new(Build), + Box::new(Docs), + Box::new(Cargo), + Box::new(Assets), + Box::new(Check), + Box::new(Benchmark), + Box::new(SizeBenchmark), + Box::new(CheckWasm), + Box::new(Ci), ]; - 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 44aa71e1..00000000 --- a/engine/pill_launcher/src/size_report.rs +++ /dev/null @@ -1,246 +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..132ec364 --- /dev/null +++ b/engine/pill_launcher/src/types.rs @@ -0,0 +1,59 @@ +// 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. +// - All enums are pub(crate) — consumed by utils/, actions/, and the CLI dispatcher. + +use std::fmt; + +/// Which part of the repository a path points to. +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, +} + +/// 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..b26d3dfa --- /dev/null +++ b/engine/pill_launcher/src/utils/assets.rs @@ -0,0 +1,32 @@ +// This file manages the asset pipeline (raw → cooked asset processing). + +use anyhow::*; +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..87363364 --- /dev/null +++ b/engine/pill_launcher/src/utils/cli.rs @@ -0,0 +1,148 @@ +// 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. +// - Adds the common --action flag and -- game-args passthrough. +// - After parsing, dispatches to the matching action's run() method. + +use crate::types::{BuildTarget, CompileMode}; +use anyhow::*; +use clap::{App, AppSettings, Arg, ArgMatches}; + +use crate::actions::Action; + +/// Build the CLI from the provided actions, parse args, and dispatch. +pub(crate) fn run_app(actions: &[Box]) -> Result<()> { + let mut app = App::new("Pill Launcher").about("Tool for managing Pill game projects"); + + // Collect all valid action names for the --action possible_values list. + let names: Vec<&str> = actions.iter().map(|a| a.name()).collect(); + + // The --action flag is the only flag defined here; everything else comes + // from each action's register() method. + app = app.arg( + Arg::with_name("action") + .short("a") + .long("action") + .takes_value(true) + .possible_values(&names) + .required(true) + .help("Specify the action to perform"), + ); + + // Common passthrough: trailing arguments after `--` are forwarded to the + // game or cargo command (used by "run", "build", "cargo" actions). + app = app.arg( + Arg::with_name("game-args") + .help("Arguments passed through to the game or cargo (use `--` to separate)") + .multiple(true) + .last(true) + .allow_hyphen_values(true), + ); + + // Let each action register its own flags. + for action in actions { + app = action.register(app); + } + + app = app.setting(AppSettings::TrailingVarArg); + + let matches = app.get_matches(); + + // Find the action named by --action and delegate. + let action_name = matches.value_of("action").expect("Action is required"); + for action in actions { + if action.name() == action_name { + return action.run(&matches); + } + } + + bail!("Unknown action: {}", action_name) +} + +// -- Named defaults (single source of truth) ------------------------------- + +pub(crate) const DEFAULT_COMPILE_MODE: &str = "debug"; + +// -- Shared flag builders --------------------------------------------------- + +/// `-p` / `--path` — game 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 game 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") +} + +/// `--features` — comma-separated Cargo features for pill_game. +pub(crate) fn features_flag() -> Arg<'static, 'static> { + Arg::with_name("features") + .long("features") + .takes_value(true) + .help("Cargo features to enable for pill_game (comma-separated)") +} + +// -- 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/files.rs b/engine/pill_launcher/src/utils/files.rs new file mode 100644 index 00000000..d959d8ea --- /dev/null +++ b/engine/pill_launcher/src/utils/files.rs @@ -0,0 +1,273 @@ +// This file provides reusable file-system operations for the launcher. + +use anyhow::*; +use std::{ + fs, + path::Path, + result::Result::Ok, +}; + +/// 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). +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()))?; + + // 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'); + } + + // No-op detection: skip writing if nothing changed (avoids invalidating caches). + 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. + if input_path == output_path { + let tmp_path = output_path.with_extension("tmp"); + 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. +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) +} + +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 game 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( + game_project_directory_path: &Path, + data_directory: &Path, +) -> Result<()> { + let source_resources_dir = game_project_directory_path.join("res"); + let destination_resources_dir = data_directory.join("res"); + + if !source_resources_dir.exists() { + bail!( + "Game 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-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(()) +} + +/// 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(dir: &Path) -> Result<()> { + if !dir.exists() { + return Ok(()); + } + for entry in fs::read_dir(dir).with_context(|| format!("Failed to read {}", dir.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 ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if ext == "cooked_mesh" || ext == "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..c86016b8 --- /dev/null +++ b/engine/pill_launcher/src/utils/mod.rs @@ -0,0 +1,9 @@ +pub mod assets; +pub mod cli; +pub mod files; +pub mod paths; +pub mod plantuml; +pub mod platform; +pub mod wasm; +pub mod web_dev_server; +pub mod workspace; diff --git a/engine/pill_launcher/src/utils/paths.rs b/engine/pill_launcher/src/utils/paths.rs new file mode 100644 index 00000000..8222ff72 --- /dev/null +++ b/engine/pill_launcher/src/utils/paths.rs @@ -0,0 +1,188 @@ +// This file handles all path resolution and workspace discovery for the launcher. + +use anyhow::*; +use config::Config; +use path_absolutize::Absolutize; +use std::{ + path::{Path, PathBuf}, + result::Result::Ok, +}; + +use crate::types::*; + +/// Sentinel comment that marks a workspace-member line as the launcher-injected game project. +/// Used by `prepare_workspace_for_game` to write the line, `extract_member_path_from_line` +/// to detect it, and the "check" action to strip it before running cargo check. +pub(crate) const GAME_PROJECT_CRATE_MARKER: &str = "### Game project crate"; + +/// 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 dir (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 dir = .../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"), + } +} + +/// 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 game 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(GAME_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 game project (defaults to /build//). +pub(crate) fn get_game_build_path( + game_project_directory_path: &Path, + output_directory_path: &PathBuf, + compile_mode: &CompileMode, +) -> Result { + // Default output path: /build//. + if output_directory_path.as_os_str() == "." { + Ok(game_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 game project's res/config.ini (spaces removed). +pub(crate) 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) +} + +/// Validate that a directory contains a Pill game project (Cargo.toml, res/, src/, config.ini). +pub(crate) fn check_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(()) +} diff --git a/engine/pill_launcher/src/utils/plantuml.rs b/engine/pill_launcher/src/utils/plantuml.rs new file mode 100644 index 00000000..68ace504 --- /dev/null +++ b/engine/pill_launcher/src/utils/plantuml.rs @@ -0,0 +1,85 @@ +// 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::*; +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().unwrap(); + 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/platform.rs b/engine/pill_launcher/src/utils/platform.rs new file mode 100644 index 00000000..7f9bef42 --- /dev/null +++ b/engine/pill_launcher/src/utils/platform.rs @@ -0,0 +1,23 @@ +// This file provides 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 = ""; // pill_game.dll +#[cfg(not(target_os = "windows"))] +pub(crate) const DYNAMIC_LIBRARY_PREFIX: &str = "lib"; // libpill_game.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"; + +pub(crate) fn dynamic_library_name(name: &str) -> String { + format!("{DYNAMIC_LIBRARY_PREFIX}{name}{DYNAMIC_LIBRARY_SUFFIX}") +} diff --git a/engine/pill_launcher/src/wasm_build.rs b/engine/pill_launcher/src/utils/wasm.rs similarity index 58% rename from engine/pill_launcher/src/wasm_build.rs rename to engine/pill_launcher/src/utils/wasm.rs index 4c1e8a94..0b0c4535 100644 --- a/engine/pill_launcher/src/wasm_build.rs +++ b/engine/pill_launcher/src/utils/wasm.rs @@ -1,349 +1,340 @@ -//! WASM/WebGPU build for `-a build -t wasm`. Copies the wasm crate template -//! into a scratch dir inside the game directory, rewrites path-deps to -//! absolute paths (engine crates + the game at -p), runs wasm-pack, flattens -//! outputs, and prints a size report on release builds. -//! -//! The scratch-copy strategy keeps the engine dir pristine across multi-game -//! use — nothing is written under engine/ during a wasm build. - -use std::env; -use std::fs::{self, OpenOptions}; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use anyhow::{bail, Context, Error, Result}; -use fs_extra::dir::CopyOptions; - -use crate::{get_path, modify_file, size_report, CompileMode, Location}; - -pub fn build(game_project_directory_path: &Path, compile_mode: &CompileMode, max_size_kb: Option) -> Result<()> { - println!("Building WASM/WebGPU target for game project at {game_project_directory_path:?}..."); - if *compile_mode == CompileMode::HotReload { - println!("Note: hot-reload is not meaningful for WASM; using --dev mode."); - } - - let wasm_template_dir = template_dir("wasm"); - let web_template_dir = template_dir("web"); - let build_wasm_dir = game_project_directory_path.join("build").join("wasm"); - let scratch_pill_web_app_dir = build_wasm_dir.join(".build").join("pill_web_app"); - let scratch_pkg_dir = build_wasm_dir.join(".build").join("pkg"); - - // Cook assets before wasm-pack — cooked files are git-ignored and embedded - // via include_bytes! at compile time, so they must exist for the game crate - // to compile on a fresh checkout. - crate::cook_assets(game_project_directory_path)?; - - prepare_scratch_crate(&wasm_template_dir, &scratch_pill_web_app_dir)?; - embed_game_config(game_project_directory_path, &scratch_pill_web_app_dir)?; - rewrite_scratch_manifest(&scratch_pill_web_app_dir, game_project_directory_path)?; - run_wasm_pack(compile_mode, &scratch_pill_web_app_dir, &scratch_pkg_dir)?; - copy_build_outputs( - &scratch_pkg_dir, - &web_template_dir, - &game_project_directory_path.join("web"), - &build_wasm_dir, - )?; - copy_game_assets( - &game_project_directory_path.join("res"), - &build_wasm_dir.join("res"), - )?; - - // Size report — only meaningful on release (debug wasm is dominated by debuginfo). - if *compile_mode == CompileMode::Release { - let preopt_wasm = scratch_pill_web_app_dir - .join("target") - .join("wasm32-unknown-unknown") - .join("release") - .join("pill_web_app.wasm"); - size_report::print(&build_wasm_dir, &preopt_wasm); - - if let Some(limit) = max_size_kb { - let final_wasm = build_wasm_dir.join("pill_web_app_bg.wasm"); - let actual = fs::metadata(&final_wasm) - .context("Cannot stat final WASM")?.len(); - if actual > limit * 1024 { - bail!( - "WASM binary {:.1} KB exceeds budget {} KB", - actual as f64 / 1024.0, - limit - ); - } - println!("Size guard OK ({:.1} KB ≤ {} KB)", actual as f64 / 1024.0, limit); - } - } - - println!(); - println!("Done! Serve with:"); - println!(" PillLauncher -a run -t wasm -p {game_project_directory_path:?}"); - println!(" (or any static server pointed at {build_wasm_dir:?})"); - Ok(()) -} - -fn template_dir(name: &str) -> PathBuf { - get_path(Location::PillLauncherCrate) - .join("res") - .join("templates") - .join(name) -} - -fn prepare_scratch_crate(wasm_template_dir: &Path, scratch_pill_web_app_dir: &Path) -> Result<()> { - fs::create_dir_all(scratch_pill_web_app_dir) - .with_context(|| format!("Failed to create scratch dir {scratch_pill_web_app_dir:?}"))?; - - fs::copy( - wasm_template_dir.join("Cargo.toml"), - scratch_pill_web_app_dir.join("Cargo.toml"), - ) - .context("Failed to copy pill_web_app Cargo.toml to scratch")?; - - // Share the engine workspace's Cargo.lock so the scratch build resolves - // identical crate versions to an in-place engine build. Without this, - // cargo picks newer wasm-bindgen/etc. which can break WebGPU rendering. - 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_dir.join("src"), - scratch_pill_web_app_dir, - &CopyOptions::new().overwrite(true), - ) - .context("Failed to copy pill_web_app src/ to scratch")?; - - Ok(()) -} - -// Copy the game's res/config.ini into the scratch crate at a known location -// so the template's lib.rs can include_str! it. Needed because wasm has no -// filesystem — the engine can't read config.ini at runtime. -fn embed_game_config(game_dir: &Path, scratch_pill_web_app_dir: &Path) -> Result<()> { - let src = game_dir.join("res").join("config.ini"); - let dst = scratch_pill_web_app_dir.join("config.ini"); - if src.is_file() { - fs::copy(&src, &dst) - .with_context(|| format!("Failed to embed game config {src:?} → {dst:?}"))?; - } else { - // Write an empty file so the template's include_str! compiles. - fs::write(&dst, "") - .with_context(|| format!("Failed to write empty scratch config.ini at {dst:?}"))?; - } - Ok(()) -} - -fn rewrite_scratch_manifest(scratch_pill_web_app_dir: &Path, game_dir: &Path) -> Result<()> { - let engine = get_path(Location::EngineCrates); - let pill_engine = cargo_path(&engine.join("pill_engine")); - let pill_renderer = cargo_path(&engine.join("pill_renderer")); - let pill_core = cargo_path(&engine.join("pill_core")); - let pill_web = cargo_path(&engine.join("pill_web")); - let pill_game = cargo_path(game_dir); - - let manifest = scratch_pill_web_app_dir.join("Cargo.toml"); - modify_file(&manifest, &manifest, |line: String| -> String { - let t = line.trim_start(); - if t.starts_with("pill_engine ") || t.starts_with("pill_engine=") { - format!( - "pill_engine = {{ path = \"{pill_engine}\", features = [\"game\", \"internal\"] }}" - ) - } else if t.starts_with("pill_renderer ") || t.starts_with("pill_renderer=") { - format!("pill_renderer = {{ path = \"{pill_renderer}\" }}") - } else if t.starts_with("pill_core ") || t.starts_with("pill_core=") { - format!("pill_core = {{ path = \"{pill_core}\" }}") - } else if t.starts_with("pill_web ") || t.starts_with("pill_web=") { - format!("pill_web = {{ path = \"{pill_web}\" }}") - } else { - line - } - })?; - - // Append three things the committed template omits: - // - pill_game dep (injected per-game, not in the template) - // - [workspace] + resolver = "2" so cargo doesn't walk up into the game's - // parent workspace; resolver matches engine/Cargo.toml (wgpu feature - // unification is sensitive to resolver version) - // - [profile.release] + wasm-pack's wasm-opt flags for Tier 1 size opt. - // `strip` is NOT set here: the size report analyzes the pre-opt wasm - // and needs the function-names subsection. wasm-opt strips the shipped - // binary via --strip-debug --strip-producers. - let mut f = OpenOptions::new() - .append(true) - .open(&manifest) - .context("Failed to open scratch Cargo.toml for append")?; - write!( - f, - concat!( - "\npill_game = {{ path = \"{pill_game}\" }}\n", - "\n[workspace]\nresolver = \"2\"\n", - "\n[profile.release]\n", - "opt-level = \"z\"\n", - "lto = \"fat\"\n", - "codegen-units = 1\n", - "panic = \"abort\"\n", - "\n[package.metadata.wasm-pack.profile.release]\n", - "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", - ), - pill_game = pill_game, - ) - .context("Failed to append to scratch Cargo.toml")?; - - Ok(()) -} - -fn cargo_path(p: &Path) -> String { - p.to_string_lossy().replace("\\", "/") -} - -fn run_wasm_pack( - compile_mode: &CompileMode, - scratch_pill_web_app_dir: &Path, - scratch_pkg_dir: &Path, -) -> Result<()> { - let mut args: Vec = vec![ - "build".into(), - "--target".into(), - "web".into(), - "--out-dir".into(), - scratch_pkg_dir.to_string_lossy().to_string(), - ]; - if !matches!(compile_mode, CompileMode::Release) { - args.push("--dev".into()); - } - - println!("Running wasm-pack in scratch crate {scratch_pill_web_app_dir:?}..."); - - // Prefer rustup's toolchain over other rustc installs on PATH — Homebrew, - // distro packages, etc. may ship a rustc without the wasm32-unknown-unknown - // target. Prepending ~/.cargo/bin is enough to shadow them. - let mut cmd = Command::new("wasm-pack"); - cmd.args(&args).current_dir(scratch_pill_web_app_dir); - 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) { - cmd.env("PATH", joined); - } - } - let status = cmd.status().map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - Error::msg("wasm-pack not found on PATH. Install it with: cargo install wasm-pack") - } else { - Error::new(e).context("Failed to execute wasm-pack") - } - })?; - - if !status.success() { - bail!("wasm-pack build failed (exit {:?})", status.code()); - } - Ok(()) -} - -fn copy_build_outputs( - scratch_pkg_dir: &Path, - web_template_dir: &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 src = scratch_pkg_dir.join(file); - let dst = build_wasm_dir.join(file); - fs::copy(&src, &dst) - .with_context(|| format!("Failed to copy {src:?} to {dst:?}"))?; - } - - // Default web shell from the engine template (index.html + logo + ...). - copy_dir_files(web_template_dir, build_wasm_dir, "template")?; - // Overlay per-game customizations; each file individually overrides the default. - if user_web_dir.is_dir() { - copy_dir_files(user_web_dir, build_wasm_dir, "overlay")?; - } - Ok(()) -} - -// The game's runtime res/ — meshes, textures, config.ini, etc. — must be -// served alongside the wasm for fetch()-based asset loading to succeed. Mirror -// /res/ into /res/ on every build (overwrite so deleted -// assets don't linger). -fn copy_game_assets(src_res: &Path, dst_res: &Path) -> Result<()> { - if !src_res.is_dir() { - return Ok(()); - } - if dst_res.exists() { - fs::remove_dir_all(dst_res) - .with_context(|| format!("Failed to clean previous res/ at {dst_res:?}"))?; - } - let dst_parent = dst_res - .parent() - .ok_or_else(|| Error::msg("invalid res/ destination path"))?; - fs::create_dir_all(dst_parent)?; - fs_extra::dir::copy(src_res, dst_parent, &CopyOptions::new().overwrite(true)) - .with_context(|| format!("Failed to copy game res/ from {src_res:?} to {dst_res:?}"))?; - Ok(()) -} - -// Flat-copy files from `src` into `dst`, following symlinks. `label` is used -// in error messages to distinguish template vs user-overlay copies. -fn copy_dir_files(src: &Path, dst: &Path, label: &str) -> Result<()> { - for entry in - fs::read_dir(src).with_context(|| format!("Failed to read {label} dir {src:?}"))? - { - let entry = entry?; - // path().metadata() follows symlinks so symlinked assets are copied as files. - if entry.path().metadata()?.is_file() { - let target = dst.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 a "pseudo-symlink" — a regular text file whose contents are a -// relative path to another file. Git on Windows checks out symlinks this way -// when `core.symlinks` is unset (the default without admin/dev mode), so the -// committed `templates/web/pill_logo.png` ends up as a 45-byte text file -// containing `../../../../../media/logo/pill_logo_black.png` instead of the -// PNG bytes. fs::copy on a real symlink follows it (reads through the -// target), but on a pseudo-symlink it copies the path-text bytes — which the -// browser then can't render. -// -// The check: small file (≤1KB), single-line content, target path resolves to -// an existing file relative to the symlink's parent. If all match, return the -// resolved path so the caller copies the actual asset bytes. This is purely -// additive — real symlinks (Mac/Linux) and ordinary files pass through -// unchanged via fs::copy. -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() - } -} +// 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-game use. +// - Handles pseudo-symlinks (Git on Windows without core.symlinks). + +use std::env; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{bail, Context, Error, Result}; +use fs_extra::dir::CopyOptions; + +use crate::types::{CompileMode, Location}; +use crate::utils::files::modify_file; +use crate::utils::paths::get_path; + +/// Build a WASM/WebGPU bundle via wasm-pack using a scratch directory. +/// On release builds, prints a size report and enforces an optional size budget. +pub fn build( + game_project_directory_path: &Path, + compile_mode: &CompileMode, + max_size_kb: Option, +) -> Result<()> { + println!("Building WASM/WebGPU target for game project at {game_project_directory_path:?}..."); + 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_template_dir = template_dir("wasm"); + let web_template_dir = template_dir("web"); + let build_wasm_dir = game_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"); + + // Pipeline: prepare scratch crate → embed config → rewrite manifest → wasm-pack → copy outputs. + prepare_scratch_crate(&wasm_template_dir, &scratch_pill_web_app_dir)?; + embed_game_config(game_project_directory_path, &scratch_pill_web_app_dir)?; + rewrite_scratch_manifest(&scratch_pill_web_app_dir, game_project_directory_path)?; + run_wasm_pack(compile_mode, &scratch_pill_web_app_dir, &scratch_package_directory)?; + copy_build_outputs( + &scratch_package_directory, + &web_template_dir, + &game_project_directory_path.join("web"), + &build_wasm_dir, + )?; + copy_game_assets( + &game_project_directory_path.join("res"), + &build_wasm_dir.join("res"), + )?; + + // Size budget check on release — fail if the binary exceeds the limit. + if *compile_mode == CompileMode::Release { + // Enforce optional size budget (analysis moved to size-benchmark action). + if let Some(limit) = max_size_kb { + let final_wasm = build_wasm_dir.join("pill_web_app_bg.wasm"); + let actual = fs::metadata(&final_wasm) + .context("Cannot stat final WASM")? + .len(); + if actual > limit * 1024 { + bail!( + "WASM binary {:.1} KB exceeds budget {} KB", + actual as f64 / 1024.0, + limit + ); + } + println!( + "Size guard OK ({:.1} KB ≤ {} KB)", + actual as f64 / 1024.0, + limit + ); + } + } + + println!(); + println!("Done! Serve with:"); + println!(" PillLauncher -a run -t wasm -p {game_project_directory_path:?}"); + println!(" (or any static server pointed at {build_wasm_dir:?})"); + Ok(()) +} + +fn template_dir(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. +fn prepare_scratch_crate(wasm_template_dir: &Path, scratch_pill_web_app_dir: &Path) -> Result<()> { + fs::create_dir_all(scratch_pill_web_app_dir) + .with_context(|| format!("Failed to create scratch dir {scratch_pill_web_app_dir:?}"))?; + + fs::copy( + wasm_template_dir.join("Cargo.toml"), + scratch_pill_web_app_dir.join("Cargo.toml"), + ) + .context("Failed to copy pill_web_app Cargo.toml to scratch")?; + + // Share the engine workspace's Cargo.lock so the scratch build resolves + // identical crate versions to an in-place engine build. Without this, + // cargo picks newer wasm-bindgen/etc. which can break WebGPU rendering. + 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_dir.join("src"), + scratch_pill_web_app_dir, + &CopyOptions::new().overwrite(true), + ) + .context("Failed to copy pill_web_app src/ to scratch")?; + + Ok(()) +} + +// Copy the game's res/config.ini into the scratch crate at a known location +// so the template's lib.rs can include_str! it. Needed because wasm has no +// filesystem — the engine can't read config.ini at runtime. +/// Copy the game's res/config.ini into the scratch crate so it can be include_str!-ed. +fn embed_game_config(game_directory: &Path, scratch_pill_web_app_dir: &Path) -> Result<()> { + let source = game_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 game config {source:?} → {destination:?}"))?; + } else { + // Write an empty file so the template's include_str! compiles. + fs::write(&destination, "") + .with_context(|| format!("Failed to write empty scratch config.ini at {destination:?}"))?; + } + Ok(()) +} + +/// Rewrite the scratch Cargo.toml with absolute path-deps to engine crates and the game. +fn rewrite_scratch_manifest(scratch_pill_web_app_dir: &Path, game_directory: &Path) -> Result<()> { + let engine = get_path(Location::EngineCrates); + let pill_engine = cargo_path(&engine.join("pill_engine")); + let pill_renderer = cargo_path(&engine.join("pill_renderer")); + let pill_core = cargo_path(&engine.join("pill_core")); + let pill_web = cargo_path(&engine.join("pill_web")); + let pill_game = cargo_path(game_directory); + + let manifest = scratch_pill_web_app_dir.join("Cargo.toml"); + modify_file(&manifest, &manifest, |line: String| -> String { + let trimmed = line.trim_start(); + if trimmed.starts_with("pill_engine ") || trimmed.starts_with("pill_engine=") { + format!( + "pill_engine = {{ path = \"{pill_engine}\", features = [\"game\", \"internal\"] }}" + ) + } else if trimmed.starts_with("pill_renderer ") || trimmed.starts_with("pill_renderer=") { + format!("pill_renderer = {{ path = \"{pill_renderer}\" }}") + } else if trimmed.starts_with("pill_core ") || trimmed.starts_with("pill_core=") { + format!("pill_core = {{ path = \"{pill_core}\" }}") + } else if trimmed.starts_with("pill_web ") || trimmed.starts_with("pill_web=") { + format!("pill_web = {{ path = \"{pill_web}\" }}") + } else { + line + } + })?; + + let mut file_handle = OpenOptions::new() + .append(true) + .open(&manifest) + .context("Failed to open scratch Cargo.toml for append")?; + write!(file_handle, + concat!( + "\npill_game = {{ path = \"{pill_game}\" }}\n", + "\n[workspace]\nresolver = \"2\"\n", + "\n[profile.release]\n", + "opt-level = \"z\"\n", + "lto = \"fat\"\n", + "codegen-units = 1\n", + "panic = \"abort\"\n", + "strip = true\n", + "\n[package.metadata.wasm-pack.profile.release]\n", + "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", + "\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\n", + "lol_alloc = \"0.4\"\n", + ), + pill_game = pill_game, + ) + .context("Failed to append to scratch Cargo.toml")?; + + Ok(()) +} + +fn cargo_path(path: &Path) -> String { + path.to_string_lossy().replace("\\", "/") +} + +/// 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, +) -> Result<()> { + // Build `wasm-pack build --target web --out-dir `. + let mut args: Vec = vec![ + "build".into(), + "--target".into(), + "web".into(), + "--out-dir".into(), + scratch_package_directory.to_string_lossy().to_string(), + ]; + // Non-release builds get --dev for faster iteration. + if !matches!(compile_mode, CompileMode::Release) { + args.push("--dev".into()); + } + + println!("Running wasm-pack in scratch crate {scratch_pill_web_app_dir:?}..."); + + let mut cmd = Command::new("wasm-pack"); + cmd.args(&args).current_dir(scratch_pill_web_app_dir); + // Prepend ~/.cargo/bin to PATH so rustup's toolchain shadows any + // system-installed rustc that may lack the wasm32 target. + 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) { + cmd.env("PATH", joined); + } + } + // Give a clear install hint if wasm-pack is not installed. + let status = cmd.status().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + Error::msg("wasm-pack not found on PATH. Install it with: cargo install wasm-pack") + } else { + Error::new(e).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_template_dir: &Path, + user_web_dir: &Path, + build_wasm_dir: &Path, +) -> Result<()> { + // Ensure the output directory exists. + fs::create_dir_all(build_wasm_dir) + .with_context(|| format!("Failed to create {build_wasm_dir:?}"))?; + + // Copy the two core wasm-pack outputs: JS glue + WASM binary. + 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:?}"))?; + } + + // Layer the engine's default web shell, then the game's customizations on top. + copy_dir_files(web_template_dir, build_wasm_dir, "template")?; + if user_web_dir.is_dir() { + copy_dir_files(user_web_dir, build_wasm_dir, "overlay")?; + } + Ok(()) +} + +fn copy_game_assets(source_resources: &Path, destination_resources: &Path) -> Result<()> { + // If the game has no res/ directory, there's nothing to copy. + if !source_resources.is_dir() { + return Ok(()); + } + // Remove previous assets so deleted files don't linger from a prior build. + 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 game res/ from {source_resources:?} to {destination_resources:?}"))?; + Ok(()) +} + +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} dir {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. +fn resolve_pseudo_symlink(path: &Path) -> PathBuf { + // Check file metadata: real symlinks and large files pass through. + let Ok(meta) = fs::symlink_metadata(path) else { + return path.to_path_buf(); + }; + // Real symlink or file > 1KB — not a pseudo-symlink. + if meta.file_type().is_symlink() || meta.len() > 1024 { + return path.to_path_buf(); + } + // Read the file content; must be a single relative path on one line. + let Ok(content) = fs::read_to_string(path) else { + return path.to_path_buf(); + }; + let trimmed = content.trim(); + // Empty or multi-line files are not pseudo-symlinks. + if trimmed.is_empty() || trimmed.contains('\n') { + return path.to_path_buf(); + } + // Resolve the relative path against the symlink's parent directory. + let parent = path.parent().unwrap_or(Path::new(".")); + let candidate = parent.join(trimmed); + // Only use the resolved path if it actually points to an existing file. + if candidate.is_file() { + candidate + } else { + path.to_path_buf() + } +} diff --git a/engine/pill_launcher/src/web_dev_server.rs b/engine/pill_launcher/src/utils/web_dev_server.rs similarity index 66% rename from engine/pill_launcher/src/web_dev_server.rs rename to engine/pill_launcher/src/utils/web_dev_server.rs index 0df73fd5..c92fcd5a 100644 --- a/engine/pill_launcher/src/web_dev_server.rs +++ b/engine/pill_launcher/src/utils/web_dev_server.rs @@ -1,159 +1,175 @@ -//! Dev HTTP server for `-a run -t wasm`. Serves /build/wasm/ on -//! localhost:8080 and pushes long-poll reloads whenever any file in the dir -//! changes (wasm rebuilds trigger browser refresh). - -use std::fs::{self, File}; -use std::path::Path; -use std::sync::{mpsc, Arc, Mutex}; -use std::thread; -use std::time::{Duration, SystemTime}; - -use anyhow::{Error, Result}; - -use crate::{wasm_build, CompileMode}; - -const ADDR: &str = "127.0.0.1:8080"; -const WATCH_POLL: Duration = Duration::from_millis(500); -const LONG_POLL_TIMEOUT: Duration = Duration::from_secs(30); - -// Long-poll client script injected into HTML responses. -const RELOAD_SCRIPT: &str = concat!( - "" -); - -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", - } -} +// 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 +// localhost:8080 with long-poll reload when files change. +// - Injects a small " +); + +type Subscribers = Arc>>>; + +/// Build the WASM bundle and start a dev HTTP server on localhost:8080. +/// Injects a live-reload script into HTML responses; watches for file changes. +pub fn run(game_project_directory_path: &Path, compile_mode: &CompileMode) -> Result<()> { + // Build the WASM bundle first, then serve it. + wasm::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())); + + // Start a background watcher that notifies long-poll clients on file changes. + spawn_watcher(build_wasm_dir.clone(), Arc::clone(&subscribers)); + + let server = tiny_http::Server::http(ADDRESS).map_err(|e| Error::msg(e.to_string()))?; + println!(); + println!("Serving {} at http://{}", build_wasm_dir.display(), ADDRESS); + 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(); + + // /__reload is the long-poll endpoint for live-reload clients. + if url_path == "/__reload" { + return handle_reload(request, subscribers); + } + + // Map URL path to a file under build_wasm_dir; reject directory traversal. + let relative_path = url_path.trim_start_matches('/'); + let relative_path = if relative_path.is_empty() { + "index.html" + } else { + relative_path + }; + if relative_path.split('/').any(|seg| seg == "..") { + return respond(request, 400, "bad path"); + } + let path = build_wasm_dir.join(relative_path); + if !path.is_file() { + return respond(request, 404, "not found"); + } + + let content_type = content_type_for(&path); + let content_type_header = tiny_http::Header::from_bytes("Content-Type", content_type) + .map_err(|_| Error::msg("invalid content-type header"))?; + + // Inject the live-reload - + + \ No newline at end of file diff --git a/engine/pill_launcher/src/actions/cargo_passthrough.rs b/engine/pill_launcher/src/actions/cargo_passthrough.rs index 3c8d138f..e8b59f87 100644 --- a/engine/pill_launcher/src/actions/cargo_passthrough.rs +++ b/engine/pill_launcher/src/actions/cargo_passthrough.rs @@ -56,8 +56,9 @@ pub(crate) fn cargo_passthrough( } // Link the project into the workspace so cargo commands see the full context. - let engine_workspace_directory_path = + 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 {}...", diff --git a/engine/pill_launcher/src/actions/create.rs b/engine/pill_launcher/src/actions/create.rs index 16516dae..be02939b 100644 --- a/engine/pill_launcher/src/actions/create.rs +++ b/engine/pill_launcher/src/actions/create.rs @@ -103,7 +103,7 @@ pub(crate) fn create_project( }, )?; - // Rewrite Cargo.toml in a single pass — point pill_engine at the absolute path + // Rewrite Cargo.toml in a single pass - point pill_engine at the absolute path // and set the workspace field to the engine workspace directory. println!("Setting up manifest file..."); let cargo_toml_path = project_directory_path.join("Cargo.toml"); diff --git a/engine/pill_launcher/src/actions/docs.rs b/engine/pill_launcher/src/actions/docs.rs index 8c6614b8..3532f88f 100644 --- a/engine/pill_launcher/src/actions/docs.rs +++ b/engine/pill_launcher/src/actions/docs.rs @@ -66,7 +66,7 @@ pub(crate) fn generate_docs(output_directory_path: &PathBuf) -> Result<()> { // 1. Point the Empty example's Cargo.toml at the absolute engine path // so cargo doc can resolve the pill_engine dependency. Strip the - // workspace = "NO_PATH" line entirely — the empty example isn't a + // workspace = "NO_PATH" line entirely - the empty 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( @@ -74,7 +74,7 @@ pub(crate) fn generate_docs(output_directory_path: &PathBuf) -> Result<()> { &empty_cargo_toml, |line: String| -> String { if line.trim_start().starts_with("workspace") { - return String::new(); // remove — not a workspace member + return String::new(); // remove - not a workspace member } if line.contains("pill_engine") { return format!( @@ -95,9 +95,9 @@ pub(crate) fn generate_docs(output_directory_path: &PathBuf) -> Result<()> { &native_cargo_toml, &native_cargo_toml, |line: String| -> String { - if line.contains("pill_project") { + if line.contains("project") { return format!( - "pill_project = {{path = \"{}\"}}", + "project = {{path = \"{}\"}}", empty_example_project_path .to_string_lossy() .replace("\\", "/") @@ -192,7 +192,7 @@ pub(crate) fn generate_docs(output_directory_path: &PathBuf) -> Result<()> { .status() .context("Failed to execute command for generating core dev docs")?; - // Core docs are optional — non-fatal if they fail. + // Core docs are optional - non-fatal if they fail. if status.success() { println!("Core dev docs generated successfully!"); } diff --git a/engine/pill_launcher/src/actions/link.rs b/engine/pill_launcher/src/actions/link.rs new file mode 100644 index 00000000..bec37998 --- /dev/null +++ b/engine/pill_launcher/src/actions/link.rs @@ -0,0 +1,114 @@ +// This file implements the "link" / "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 +// --------------------------------------------------------------------------- + +pub(crate) struct Link; + +impl Action for Link { + fn name(&self) -> &'static str { + "link" + } + + fn register(&self, app: App<'static, 'static>) -> App<'static, 'static> { + app.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)?; + + if text.contains(&marker_line) { + println!("Project already linked: {normalized}"); + return Ok(()); + } + + // Remove any existing marker, then insert the new one before the closing ] + let cleaned: String = text + .lines() + .filter(|l| !l.contains(PROJECT_CRATE_MARKER)) + .collect::>() + .join("\n"); + + let mut in_members = false; + let mut out = 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 == "]" { + out.push_str(&marker_line); + out.push('\n'); + inserted = true; + } + out.push_str(line); + out.push('\n'); + } + if !inserted { + bail!("Could not find closing `]` of workspace members array"); + } + let out = out.trim_end().to_string(); + fs::write(&engine_toml, format!("{out}\n"))?; + + println!("Linked {normalized} into engine workspace."); + println!("rust-analyzer should pick it up automatically."); + println!("Run `PillLauncher -a unlink` to remove it."); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Unlink +// --------------------------------------------------------------------------- + +pub(crate) struct Unlink; + +impl Action for Unlink { + fn name(&self) -> &'static str { + "unlink" + } + + fn register(&self, app: App<'static, 'static>) -> App<'static, 'static> { + app + } + + 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)?; + + if !text.contains(PROJECT_CRATE_MARKER) { + println!("No project currently linked."); + return Ok(()); + } + + let cleaned: String = text + .lines() + .filter(|l| !l.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 index 5b6236e5..d0ed951c 100644 --- a/engine/pill_launcher/src/actions/mod.rs +++ b/engine/pill_launcher/src/actions/mod.rs @@ -6,7 +6,7 @@ // - 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 game project from template. +// - create: scaffold a new project from template. // - docs: generate cargo doc for engine crates. use anyhow::Result; @@ -17,6 +17,7 @@ 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. diff --git a/engine/pill_launcher/src/main.rs b/engine/pill_launcher/src/main.rs index 93d162b9..56bd3d2c 100644 --- a/engine/pill_launcher/src/main.rs +++ b/engine/pill_launcher/src/main.rs @@ -1,7 +1,7 @@ -//! PillLauncher — CLI build orchestrator for the Pill game engine. +//! PillLauncher - CLI build orchestrator for the Pill pill project engine. //! //! Manages project scaffolding, asset pipelines, native/WASM builds, -//! and documentation generation for Pill-based game projects. +//! and documentation generation for Pill-based pill projects. // This file is the crate root for PillLauncher. // @@ -23,11 +23,14 @@ 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; fn main() { - let actions: [&dyn Action; 6] = [&Create, &Run, &Build, &Docs, &Cargo, &Assets]; + let actions: [&dyn Action; 8] = [ + &Create, &Run, &Build, &Docs, &Cargo, &Assets, &Link, &Unlink, + ]; if let Err(e) = utils::cli::run_app(&actions) { eprintln!("{:#}", e); diff --git a/engine/pill_launcher/src/utils/cli.rs b/engine/pill_launcher/src/utils/cli.rs index 945042ca..7cdbb583 100644 --- a/engine/pill_launcher/src/utils/cli.rs +++ b/engine/pill_launcher/src/utils/cli.rs @@ -44,7 +44,7 @@ pub(crate) fn run_app(actions: &[&dyn Action]) -> Result<()> { .allow_hyphen_values(true), ); - // Register shared flags once — individual actions must NOT re-register + // Register shared flags once - individual actions must NOT re-register // these (clap v2 panics on duplicate arg names). app = app .arg(path_flag()) @@ -74,7 +74,7 @@ pub(crate) fn run_app(actions: &[&dyn Action]) -> Result<()> { app = app.setting(AppSettings::TrailingVarArg); - // Use get_matches_safe so we don't exit() inside the library — important + // 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. @@ -83,7 +83,7 @@ pub(crate) fn run_app(actions: &[&dyn Action]) -> Result<()> { Err(e) => { // --help and --version print their output to stdout and return an // error of kind HelpDisplayed / VersionDisplayed. The output has - // already been printed by clap — just exit successfully. + // already been printed by clap - just exit successfully. use clap::ErrorKind; match e.kind { ErrorKind::HelpDisplayed | ErrorKind::VersionDisplayed => { @@ -111,7 +111,7 @@ pub(crate) const DEFAULT_COMPILE_MODE: &str = "debug"; // -- Shared flag builders --------------------------------------------------- -/// `-p` / `--path` — project directory. +/// `-p` / `--path` - project directory. pub(crate) fn path_flag() -> Arg<'static, 'static> { Arg::with_name("path") .short("p") @@ -121,7 +121,7 @@ pub(crate) fn path_flag() -> Arg<'static, 'static> { .help("Path to the project") } -/// `-c` / `--compile-mode` — debug, release, or hot-reload. +/// `-c` / `--compile-mode` - debug, release, or hot-reload. pub(crate) fn compile_mode_flag() -> Arg<'static, 'static> { Arg::with_name("compile-mode") .short("c") @@ -132,7 +132,7 @@ pub(crate) fn compile_mode_flag() -> Arg<'static, 'static> { .help("Build profile: debug, release, or hot-reload") } -/// `-o` / `--output-path` — where to place build artifacts. +/// `-o` / `--output-path` - where to place build artifacts. pub(crate) fn output_path_flag() -> Arg<'static, 'static> { Arg::with_name("output-path") .short("o") @@ -142,7 +142,7 @@ pub(crate) fn output_path_flag() -> Arg<'static, 'static> { .help("Build output directory") } -/// `-t` / `--target` — native or web (WASM). +/// `-t` / `--target` - native or web (WASM). pub(crate) fn target_flag() -> Arg<'static, 'static> { Arg::with_name("target") .short("t") @@ -153,19 +153,19 @@ pub(crate) fn target_flag() -> Arg<'static, 'static> { .help("Build target: native executable or WASM+WebGPU") } -/// `--clean` — force-rebuild cooked assets before building. +/// `--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") } -/// `--features` — comma-separated Cargo features for pill_project. +/// `--features` - comma-separated Cargo features for project. pub(crate) fn features_flag() -> Arg<'static, 'static> { Arg::with_name("features") .long("features") .takes_value(true) - .help("Cargo features to enable for pill_project (comma-separated)") + .help("Cargo features to enable for project (comma-separated)") } // -- Shared parsers --------------------------------------------------------- diff --git a/engine/pill_launcher/src/utils/common.rs b/engine/pill_launcher/src/utils/common.rs index ef6f239a..69125817 100644 --- a/engine/pill_launcher/src/utils/common.rs +++ b/engine/pill_launcher/src/utils/common.rs @@ -32,9 +32,9 @@ pub(crate) const EXECUTABLE_SUFFIX: &str = ".exe"; pub(crate) const EXECUTABLE_SUFFIX: &str = ""; // Linux, macOS, etc. – no extension #[cfg(target_os = "windows")] -pub(crate) const DYNAMIC_LIBRARY_PREFIX: &str = ""; // pill_project.dll +pub(crate) const DYNAMIC_LIBRARY_PREFIX: &str = ""; // project.dll #[cfg(not(target_os = "windows"))] -pub(crate) const DYNAMIC_LIBRARY_PREFIX: &str = "lib"; // libpill_project.so / .dylib +pub(crate) const DYNAMIC_LIBRARY_PREFIX: &str = "lib"; // lib_project.so / .dylib #[cfg(target_os = "windows")] pub(crate) const DYNAMIC_LIBRARY_SUFFIX: &str = ".dll"; @@ -55,7 +55,7 @@ pub(crate) fn dynamic_library_name(name: &str) -> String { /// 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. +/// 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") @@ -254,7 +254,7 @@ pub(crate) fn rewrite_scratch_manifest( 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 pill_project = get_cargo_path(project_directory); + let project = get_cargo_path(project_directory); let manifest = scratch_pill_web_app_dir.join("Cargo.toml"); @@ -282,9 +282,7 @@ pub(crate) fn rewrite_scratch_manifest( } } - content.push_str(&format!( - "\npill_project = {{ path = \"{pill_project}\" }}\n" - )); + content.push_str(&format!("\nproject = {{ path = \"{project}\" }}\n")); content.push_str("\n[workspace]\nresolver = \"2\"\n"); content.push_str("\n[profile.release]\n"); content.push_str("opt-level = \"z\"\n"); diff --git a/engine/pill_launcher/src/utils/native_target.rs b/engine/pill_launcher/src/utils/native_target.rs index 7fbc7f09..cebb6700 100644 --- a/engine/pill_launcher/src/utils/native_target.rs +++ b/engine/pill_launcher/src/utils/native_target.rs @@ -5,7 +5,7 @@ // - CLI flag registration for build-related actions. // - Cargo stderr parsing for user-friendly error messages. // - ANSI terminal detection (cached). -// - build_project(): compile pill_project + pill_native + pill_runtime. +// - build_project(): compile project + pill_native + pill_runtime. // - run_project(): build then launch the standalone executable. use anyhow::{bail, Context, Error, Result}; @@ -48,7 +48,7 @@ pub(crate) fn register_build_flags( /// Build and then launch the native standalone executable for a project. /// Supports optional stdout capture (for benchmarks) and --features passthrough. -/// Sets PILL_PROJECT_DIR, PILL_ENGINE_WORKSPACE_DIR, and other env vars. +/// Sets PROJECT_DIR, PILL_ENGINE_WORKSPACE_DIR, and other env vars. pub(crate) fn run_project( project_directory_path: &PathBuf, output_directory_path: &PathBuf, @@ -82,7 +82,8 @@ pub(crate) fn run_project( cmd.current_dir(output_directory_path) .env("PILL_LAUNCHER_BIN", &launcher_bin) .env("PILL_ENGINE_WORKSPACE_DIR", &engine_workspace) - .env("PILL_PROJECT_DIR", project_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), @@ -129,16 +130,15 @@ pub(crate) fn run_project( })?; if !status.success() { - eprintln!( - "Project exited with error code: {}", - status.code().map_or("unknown".into(), |c| c.to_string()) - ); + let code = status.code().map_or("unknown".into(), |c| c.to_string()); + eprintln!("Project exited with error code: {code}"); + bail!("Project exited with error code: {code}"); } Ok(None) } } -/// Build pill_project + pill_native + pill_runtime via cargo in the engine workspace. +/// 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, and per-project target dirs. pub(crate) fn build_project( @@ -155,15 +155,20 @@ pub(crate) fn build_project( 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 = + let (engine_workspace_directory_path, _workspace_guard) = prepare_workspace_for_project(project_directory_path, compile_mode)?; + // _workspace_guard restores engine/Cargo.toml on drop let project_title = get_project_title(project_directory_path).context("Failed to get project title")?; - let cargo_target_dir = engine_workspace_directory_path - .join("target_projects") - .join(&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) + }; let pill_engine_dir = get_path(Location::PillEngineCrate); if *compile_mode != CompileMode::HotReload { @@ -175,7 +180,7 @@ pub(crate) fn build_project( let mut arguments = vec![ "build", "-p", - "pill_project", + "project", "-p", "pill_native", "-p", @@ -314,8 +319,7 @@ pub(crate) fn build_project( stage_packaged_resource_files(project_directory_path, &data_directory)?; } - let project_source = - compilation_artifacts_folder_path.join(dynamic_library_name("pill_project")); + 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")); @@ -335,11 +339,11 @@ pub(crate) fn build_project( if *compile_mode != CompileMode::HotReload || !hot_reload_child { if copy_file_if_newer( &project_source, - &data_directory.join(dynamic_library_name("pill_project")), + &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("pill_project")))?; + codesign_ad_hoc(&data_directory.join(dynamic_library_name("project")))?; } else { println!("Skipping copying of project dynamic library"); } @@ -358,7 +362,7 @@ pub(crate) fn build_project( if *compile_mode == CompileMode::HotReload { if copy_file_if_newer( &project_source, - &data_directory.join(dynamic_library_name("pill_project_hot_reloaded")), + &data_directory.join(dynamic_library_name("project_hot_reloaded")), )? { println!("Copied project hot-reload dynamic library"); } else { diff --git a/engine/pill_launcher/src/utils/wasm_target.rs b/engine/pill_launcher/src/utils/wasm_target.rs index 43a47d90..13fe1eab 100644 --- a/engine/pill_launcher/src/utils/wasm_target.rs +++ b/engine/pill_launcher/src/utils/wasm_target.rs @@ -1,7 +1,7 @@ // This file orchestrates WASM/WebGPU builds via wasm-pack. // // Responsibilities: -// - build(): entry point — copies a WASM template into a scratch directory, +// - 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 @@ -60,7 +60,7 @@ pub fn build_project( &build_wasm_dir.join("res"), )?; - // Size budget check on release — fail if the binary exceeds the limit. + // Size budget check on release - fail if the binary exceeds the limit. if *compile_mode == CompileMode::Release { // Enforce optional size budget (analysis moved to size-benchmark action). if let Some(limit) = max_size_kb { diff --git a/engine/pill_launcher/src/utils/web_dev_server.rs b/engine/pill_launcher/src/utils/web_dev_server.rs index 7a29d52a..a21778a7 100644 --- a/engine/pill_launcher/src/utils/web_dev_server.rs +++ b/engine/pill_launcher/src/utils/web_dev_server.rs @@ -1,7 +1,7 @@ // 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 +// - 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