Skip to content

Devops rework - #43

Open
MattSzymonski wants to merge 50 commits into
mainfrom
devops_rework
Open

Devops rework#43
MattSzymonski wants to merge 50 commits into
mainfrom
devops_rework

Conversation

@MattSzymonski

@MattSzymonski MattSzymonski commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Comprehensive overhaul of the Pill project infrastructure, launcher architecture, and developer tooling.

Pill Launcher Refactoring

The launcher's monolithic main.rs (~1,400 lines) has been split into a trait-based action dispatch architecture:

  • 8 action modules under actions/ - build, run, create, docs, cargo, assets, link, unlink
  • 11 utility modules under utils/ - cli, common, files, paths, native_target, wasm_target, web_dev_server, workspace, assets, plantuml
  • Each action implements the Action trait and registers its own CLI flags with clap
  • Improved CLI subcommand, argument, and flag handling - every subcommand has context-sensitive --help
  • Detailed doc comments on every file, every function, and in function scopes

CI Pipeline Overhaul

The old monolithic ci.yml has been replaced with 4 focused GitHub Actions workflows:

Workflow Trigger What it does
ci-basic-tests.yml Every push 5 parallel fast checks (fmt, clippy, native build, WASM+budget, benchmark)
ci-build-image.yml Dockerfile changes Builds & pushes Docker CI image
ci-examples-tests.yml Daily Builds all 8 example projects
ci-pill_launcher-tests.yml Daily Exhaustive launcher action suite (9 test groups)

The actual test logic lives in 3 bash test scripts with shared infrastructure (common.sh):

Script Coverage
run_basic_tests.sh fmt, clippy, native/WASM builds, size reports, WASM budget guard (0.499 MiB), performance benchmark (City, 3 runs)
run_examples_tests.sh Compiles all Pill examples + standalone crates with size reports
run_pill_launcher_tests.sh 9-group exhaustive test covering every subcommand, flag, and error path
  • CI runs locally - bash devops/tests/run_basic_tests.sh
  • On GitHub it runs in Docker - ghcr.io/mattszymonski/pill-ci:latest (Alpine + Rust + wasm-pack + slangc + plantuml)

New: Build Size & Performance Benchmarks

The CI pipeline now includes automated tracking of binary sizes and frame-time performance on every push:

  • Build size reports - every native and WASM build emits a JSON snapshot of all output files in mebibytes (MiB). Native builds report every DLL/executable in build/release/data/; WASM builds report the .wasm binary. Size regressions (e.g. pill_runtime.dll jumping from 10 MB to 15 MB) are immediately visible in CI logs.
  • Performance benchmark - examples/city (10,000 citizens, 5,000 frames) is built and run 3× on every push. Per-frame timing statistics (average_ms, median_ms, min_ms, max_ms, range_ms, stddev_ms) are extracted from the game's JSON output and aggregated across runs. Supports both windowed and headless modes with automatic fallback.
  • WASM size guard - the .wasm binary is checked against a hard budget of 0.499 MiB (523,239 bytes). Exceeding it fails the CI check, preventing accidental binary bloat from new dependencies or missing optimizations.

Examples & Templates

  • New benchmark example: examples/city - 10,000 citizens, 5,000 frames, JSON frame-time output, supports windowed & headless modes
  • Template renamed: pill_defaultnew_project
  • Entry point renamed: game.rsproject.rs across all examples - projects no longer use lib.rs, only project.rs is needed
  • Removed: examples/Empty (consolidated)
  • All example Cargo.toml files updated for new template paths

Naming: Game → Project

The term "game" has been renamed to "project" throughout the codebase, reflecting that Pill supports more than just games.

Documentation added:

let mut content = String::new();
for line in template.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("workspace ") || trimmed.starts_with("workspace=") {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we tolerate 2 options?

content.push_str("[workspace]\nresolver = \"2\"\n");
} else if trimmed.starts_with("pill_engine ") || trimmed.starts_with("pill_engine=") {
content.push_str(&format!(
"pill_engine = {{ path = \"{pill_engine}\", features = [\"project\", \"internal\"] }}\n"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, as mentioned above - I don't think project option is a good way ahead

}

/// Copy the WASM template into a scratch directory so the engine workspace stays pristine.
pub(crate) fn prepare_scratch_crate(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we name all WASM-related functions with a wasm_ prefix? This way until we have another usege for them they can be distinguished as wasm-only functions.


/// Resolve Git-on-Windows pseudo-symlinks (small text files containing a relative path).
/// Real symlinks and regular files pass through unchanged.
pub(crate) fn resolve_pseudo_symlink(path: &Path) -> PathBuf {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe Windows-only helpers should not bloat non-Windows builds? Add platform flag please

/// Panics if the engine workspace directory cannot be found.
pub(crate) fn get_path(location: Location) -> PathBuf {
// engine workspace directory = .../Pill-Engine/engine
let engine_workspace =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a quite heavy operation all in all - we will be traversing up to find the desired directory. I think we could cache this somehow so that we don't find it each time. Unless this is called with various different paths from various locations - then it's hard to cache it once for all. I would prefer, though to establish it once and forget about it and use a common relative path everywhere to a known root.

BuildTarget::Web => {
let port: u16 = matches
.value_of("wasm-port")
.unwrap_or("8080")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why 2 level unwrapping here?

pill_runtime = { path = "../pill_runtime" }

winit = "0.30.12"
winit = "0.30"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again partial bumps

// vec3 fog_color; // offset 0 (12 bytes)
// float fog_density; // offset 12 (4 bytes)
// // total: 16 bytes
// float3 fog_color; // offset 0, size 12, but std140 aligns vec3 to 16

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will soon get deprecated - are these changes necessary?

# Other
glam = { version = "0.30.8", features = ["serde", "bytemuck"] }
winit = "0.30.12"
glam = { version = "0.33", features = ["serde", "bytemuck"] }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

partial updates

console_log = "1"
log = { version = "0.4", features = ["release_max_level_off"] }
winit = { version = "0.30.12" }
winit = { version = "0.30" }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

partial updates

Comment thread engine/Cargo.toml
"pill_native",
"pill_web",
"pill_runtime",
"NO_PATH", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

glad we got rid of that 🍾 good job!

Comment thread examples/city/README.md
## CI

A dedicated `benchmark` job runs on every push to `main` and every PR. It uses
`benchmark_windowed` mode with `xvfb-run` for a virtual display on headless runners.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this mode still valid? Did not see something like that in the commits.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this xvfb-run


const BENCHMARK_CITIZEN_COUNT: usize = 10_000;
const BENCHMARK_DEFAULT_MAX_FRAMES: u64 = 5_000;
const BENCHMARK_WARMUP_FRAMES: u64 = 1_000;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need 1000 frames? Usually it's enough to run N times where N is the size necessary for warming up all caches - I encountered up to 100 iterations in other projects, so maybe we don't need that many. Sometimes it's also good to discard the last N frames that introduce outliers - though this can imply some weird platform-specific hoodoo.

I link here a good resource on this topic for posterity https://gernot-heiser.org/benchmarking-crimes.html

.build(),
)?;
let _orange = engine.add_resource::<Material>(
Material::builder(shared::ORANGE_MATERIAL_NAME)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused?

shared::PLANE_MESH_NAME,
shared::PLANE_MESH_PATH.into(),
))?;
let _pill = engine.add_resource(Mesh::new(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused?

#[cfg(feature = "benchmark_windowed")]
let (orange_material, pill_mesh) = {
let material = engine.get_resource_handle::<Material>("orange")?;
let mesh = engine.get_resource_handle::<Mesh>("pill")?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, pill's material and mesh are created only for non-headless builds?, then maybe gate them behind headless?

// -- Statistics --------------------------------------------------------------

fn print_report(state: &BenchmarkState) {
let times = &state.frame_times_ms;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this might be me being too picky, but there is a danger of storing values during benchmarking - as explained in the below slide:

Image

It's often better to have rolling sums as this eliminates writing to memory, especially if the frame_times_ms does not fit in cache.

@@ -0,0 +1,266 @@
//! This file implements the headless runner, activated by the `headless` feature.
//!
//! Replaces the winit-based run_app() with a bare update loop — no window,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vernacular is a bit LLMish - "3 times 'no'" then "fully supported" -how can it be partially supported :D

.join("pill_launcher")
.join("target")
.join("debug")
.join("PillLauncherUpstream"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this standardized somewhere? I did not see it anywhere else yet.

// initialise a fresh engine inside the new runtime.
let project_dylib_path =
CString::new(project_path_for_create.to_string_lossy().as_bytes())?;
let args = if let Some(ctx) = runtime_context {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let args = if let Some(ctx) = runtime_context {
let args = if let Some(context) = runtime_context {

let config_path = project_resources_directory_path.join("config.ini");

// --- 3. Determine runtime load mode (dylib vs in-process) ---
// Decide how to load the runtime (dynamic library or in-process).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Decide how to load the runtime (dynamic library or in-process).

fn run_app() -> Result<()> {
// --- 1. Detect project directory, run layout, and hot-reload mode ---
// In the development build, standalone will look for the resource files in the "res" directory of the pill project directory
// In the release build, "res" directory is copied to /build/release/data/res (TODO: pack all resources use by pill project into a single data file)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this task tracked somewhere?

let runtime_load_mode = parse_runtime_load_mode(std::env::var("PILL_RUNTIME_MODE").ok())
.or(in_process.then_some(RuntimeLoadMode::InProcess))
.unwrap_or(if cfg!(target_os = "macos") {
RuntimeLoadMode::InProcess

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hints that macOs does dynamic library loading differently? Am I correct?


/// Returns true when the engine workspace's Cargo.toml lists the given
/// project directory name as a workspace member.
fn workspace_includes_project(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While I like having a bulletproof system - do we need all these shenaningans? Sounds a bit like - try X,Y,Z and mash it with a hammer until it works. Shouldn't be have an elegant solution and expect our devs to apply it? We also can allow for "do whatever the heck you want" but this comes at much higher maintenance cost.

engine_source_directory_path: Option<&Path>,
name: &str,
) -> Vec<PathBuf> {
let mut candidates = vec![build_data_directory_path.join(paths::dylib(name))];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, quite expensive operations that might slow down hot-reloading significantly.

if trimmed.starts_with("members") && trimmed.contains('[') {
in_members = true;
}
if in_members && !inserted && trimmed == "]" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this code is duplicated, maybe a further commonalization pass could be handy here? I noticed several other such similar/duplicated functions - could you take a closer look and eliminate those?


/// Build project + pill_native + pill_runtime via cargo in the engine workspace.
/// Copies the standalone executable and dynamic libraries into the output directory.
/// Supports --features, hot-reload, PlantUML pre-rendering, --headless, and per-project target dirs.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

duplicated verbose comment with the one above

cargo_command.env("CARGO_TERM_COLOR", "always");
}

let mut cargo_child = if use_experimental_logs_parser() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't get the reason for using an experimental log parser

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this because we want to parse the logs for failures for CI?

@JDuchniewicz JDuchniewicz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some minor changes and cleanups necessary. Otherwise looks very solid and well-thought through. Good job 👍🏽

Seeing the end of the big-refactor tunnel (pill_tunnel) already 💊

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants