Files
benilla-octo/crates/benilla-app/tests/world_api_wall.rs
T
gromandClaude Opus 5 c315fb4f35 Anisotropic filtering and trilinear apply in the session
The Options row shipped last commit said the setting needed a relaunch.
It does not have to. The reference's "enabled upon restart" is about its
TEXTURES, not its CVars — both register with `flags = 1`, live — because
its sampler state was baked inside each `TextureCreate` (`0x449ae0`) and
it kept no list of live textures. Ours can be re-stamped in place.

Two things make it possible, and neither is new work:

- A governed sampler now wears `POLICY_SAMPLER_LABEL`, written by the one
  constructor `TexFilterSetting::descriptor`. That is the `applyGlobal`
  split the reference draws, made legible at runtime: terrain, WMO/M2 and
  the liquid sheets take the policy; weather sprites, the minimap, glue,
  Lua widgets, the sky and decals keep their own mode. All four governed
  lanes went through that constructor, so a lane cannot forget the label
  and silently opt itself out of every later change.
- Extracting a `RENDER_WORLD`-only `Image` takes its *pixels* and leaves
  the asset behind (`take_gpu_data` is an `Option::take` on `image.data`).
  So every resident texture's own descriptor is still readable in the main
  world, for nothing — there is no ledger to keep and none to go stale.

The policy itself stops being a `OnceLock` and becomes one atomic word
plus a revision, so it can be published again on every write and both
halves of the refresh can key off the same number in the same frame:
`refresh_policy_samplers` (render world, `ExtractSchedule`, before every
Prepare) swaps `GpuImage::sampler`, and `invalidate_materials::<M>` raises
`AssetEvent::Modified` so bind groups that captured the OLD sampler are
built again. `static_gx` carries two samplers of its own and rebuilds its
cached cell bind groups in place rather than dropping them — clearing the
cache would be one line, but a region with no GPU state does not draw, so
every building in view would blink out and trickle back under the bake
budget.

Measured, not asserted. `WOW_CVAR_LATE=<name>=<value>` is new here: it
writes CVars *after* the scene has settled, then re-settles the image
around them. A latched lever and a live one give the same boot picture, so
no boot-time A/B can tell them apart. On the Elwynn vista:

    anisotropic 16 at boot  vs  16 written late:
        0 of 1,440,000 pixels differ

and the control that proves the harness is not doing the work (boot 16,
written 16 again late, so no refresh runs but the re-settle is still paid)
is byte-identical to a plain boot. The cost of the change is 873 samplers,
75 material bind groups and 264 static_gx cell bind groups, once; nothing
is re-uploaded, since the pixels never move.

The first run of that A/B was NOT identical — 6.7% of the frame, a dapple
of stale tree shadow on a road that was otherwise correct. That is
`CutoutShadowCasterMaterial`, which deliberately reuses the forward pass's
own leaf handle and so holds a governed sampler, and which lives in
`benilla-app` rather than the engine. Hence the shape the invalidation
took: a public `invalidate_materials::<M>` each crate registers beside its
own `MaterialPlugin`, published through the world-API wall with the record
that says why (the engine naming every material type in the program is the
inversion that wall exists to stop). Forgetting one is silent in exactly
that way — a bind group built after the change is right and one already
cached is wrong — so the capture A/B is written into `docs/GRAPHICS.md` as
the check for this feature, not a unit test pretending to be one.

The Options row keeps its deferral to Apply, now for the real reason: the
commit is cheap once and silly sixteen times, which is what a drag across
the groove would ask for.

Verified: ALL GATES GREEN (520 s); `benilla-app --lib` against the install
at the exact 269-failure baseline, set-identical by `comm`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-26 15:44:27 -06:00

888 lines
36 KiB
Rust

//! The world API wall: every `benilla-world` item that `benilla-app` code names, counted from
//! source. [`PUBLISHED`] is the designed API, [`SORTED_LEAKS`] the crossings filed to close and
//! how, and anything else that crosses is an unsorted leak. Only the leak count is ratcheted,
//! toward zero; a table row nothing names any more fails the test until it is deleted.
//!
//! Not counted: `#[cfg(test)]` bodies (a test naming an internal is not an API consumer) and
//! comments. Plugin registrations in the composition root count like anything else.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
/// The engine's module set when the world lives inside `benilla-app`: an engine module missing
/// here hides its leaks. Unused once `crates/benilla-world` exists, which the scan checks.
const ENGINE_ROOTS: &[&str] = &[
"art_scope",
"assets",
"billboard",
"clouds",
"clutter",
"collision",
"debug_panel",
"decal",
"dev_state",
"doodad_anim",
"entity_shade",
"exterior_cull",
"ffx_glow",
"instance_tint",
"interact",
"interior",
"lighting",
"liquid",
"map_proj",
"mesh_tag",
"model_fade",
"model_forms",
"model_render",
"modkeys",
"particles",
"perf",
"pipe_warm",
"ribbons",
"rig_anim",
"rig_palette",
"schedule",
"sky",
"sky_order",
"skybox",
"sun",
"surface",
"terrain",
"terrain_stream",
"view",
"water_fx",
"wdl",
"weather",
"wmo_portal",
"world_census",
"world_plugins",
"world_point",
"world_unit",
"world_map",
"zfill",
];
/// The instruments inside the engine module set: they sit above the engine, so they are not part
/// of the doorway being measured.
const INSTRUMENT_ROOTS: &[&str] = &["art_scope", "debug_panel", "perf", "pipe_warm"];
/// The `benilla-app` instruments (probes, the crash hook, the dev panels) that consume
/// `benilla-world` like any caller. An API shaped by what a debugger wanted to poke is not the
/// designed API, so what only they name is counted separately, outside the gate.
const INSTRUMENT_CONSUMERS: &[&str] = &["capture", "crash", "debug_panel", "perf", "pipe_warm"];
/// Is this file one of the app-side instruments?
fn is_instrument_consumer(rel: &str) -> bool {
let root = rel.split(['/', '.']).next().unwrap_or("");
INSTRUMENT_CONSUMERS.contains(&root)
}
/// The designed API: every engine item game code may name, with the record that published it
/// (`wall`: published without one). Adding a row is a claim made in review, with its record.
const PUBLISHED: &[(&str, &str)] = &[
("bgwin::BgWinPlugin", "wall"),
// The engine owns the log; the app owns where this player's state lives, so "attach the log
// to this path" is the seam between them. Published rather than filed as a leak because the
// composition root is the only caller there can be: `crash.rs` already reads the same ring,
// and a session log that only an instrument build wrote would be missing from every player
// build, which is the one place it is needed.
("log_ring::FILE_CAP_BYTES", "wall"),
("log_ring::attach_file", "wall"),
("bgwin::background_run", "record"),
("bgwin::no_pixel_run", "record"),
("billboard::BillboardCard", "record"),
("boot::tuned_default_plugins", "record"),
("build_id::BuildId", "record"),
("build_id::banner", "record"),
("collision::ColliderEpoch", "record"),
("collision::MoverTraceExclusions", "record"),
("collision::WorldCollision", "record"),
("decal::WorldDecal", "record"),
("dev_state::STILL_INPUTS_CHANGED", "record"),
("doodad_anim::AnimMatPart", "record"),
("doodad_anim::DoodadAnimHost", "record"),
("doodad_anim::MatAnim", "record"),
("doodad_anim::TintLoop", "record"),
("doodad_anim::UvLoops", "record"),
("doodad_anim::register_entity_uv", "record"),
("doodad_anim::register_fx_uv", "record"),
("doodad_anim::register_tint", "record"),
("doodad_anim::spawn_anim_host", "record"),
("ffx_glow::FfxBackdrop", "record"),
("ffx_glow::FfxGlow", "record"),
("ffx_glow::GlueFfx", "record"),
("final_pass::FinalPassTarget", "record"),
("instance_tint::InstanceTintMirrors", "record"),
("instance_tint::InstanceTints", "record"),
("interact::PickMesh", "record"),
("interact::WorldObject", "record"),
("interact::WorldPick", "record"),
("interact::cast_pick_ray", "record"),
("interior::NodeAmbient", "record"),
("lighting::LightBlob", "record"),
("lighting::WorldTime", "record"),
("lighting::WowLighting", "record"),
("mac_quit::MacQuitPlugin", "record"),
("mat_anim_table::MatAnimMirrors", "record"),
("mat_anim_table::MatAnimTable", "record"),
("mat_anim_table::affine_row", "record"),
("mesh_tag::HIGHLIGHT_BIT", "record"),
("model_fade::ModelFade", "record"),
("model_fade::ParentModel", "record"),
("model_fade::RenderFade", "record"),
("model_fade::UnitRenderAlpha", "wall"),
("model_forms::ModelForms", "record"),
("model_render::BatchVariants", "record"),
("model_render::EntityUvLane", "record"),
("model_render::M2BatchMaterials", "record"),
("model_render::ModelKind", "record"),
("model_render::ModelPart", "record"),
("model_render::lazy::realize", "record"),
("modkeys::SyntheticHold", "wall"),
("particles::ViewThrottled", "record"),
("particles::render::EFFECT_DRAW_STATS", "record"),
("particles::spawn_emitter", "record"),
("ride_frame::RideFrame", "record"),
("rig_anim::AnimParked", "wall"),
("rig_anim::GlobalSeqDrive", "wall"),
("rig_anim::PosePost", "wall"),
("rig_anim::RigAnchor", "wall"),
("rig_anim::RigFrame", "wall"),
("rig_anim::RigPose", "wall"),
("rig_palette::RigPalettes", "record"),
("rig_palette::RigSkin", "record"),
("rig_rider::RigRider", "record"),
("schedule::WorldLive", "record"),
("schedule::WorldStage", "record"),
("sky_order::Rung", "wall"),
("terrain_stream::SPAWN_XY", "wall"),
("terrain_stream::ViewFocus", "record"),
("terrain_stream::WorldLoadProgress", "wall"),
// GFX (anisotropic): the registration hook of the live texture-filter policy. Published, not
// filed as a leak, because its whole shape is "a material anywhere asks the engine to rebuild
// its bind groups when `trilinear` or `anisotropic` moves" — the caller is necessarily outside
// the engine whenever a material is, and `benilla-app`'s cutout shadow caster is one (it binds
// the forward pass's own leaf handle, so it holds a governed sampler). Moving the material to
// the engine would be the wrong close: it is a shadow-rig material, and it would drag the lane
// with it. The alternative — the engine naming every material type in the program — is the
// inversion this wall exists to stop.
("tex_filter_live::invalidate_materials", "record"),
("thread_qos::QosClass", "record"),
("thread_qos::ThreadQosPlugin", "wall"),
("thread_qos::promote_current_thread", "record"),
("view::MsaaFormats", "record"),
("view::MsaaSetting", "record"),
("view::ViewDistance", "record"),
("view::Viewer", "wall"),
("view::WorldCamera", "record"),
("vis_chain::VisChainOnly", "record"),
("weather::WeatherMessage", "record"),
("weather::WeatherState", "record"),
("wmo_portal::room_pvs_visible", "record"),
("world_census::CensusReport", "record"),
("world_census::WorldCensus", "record"),
("world_map::CurrentMap", "record"),
("world_map::MapChange", "record"),
("world_plugins::WorldPlugins", "record"),
("world_point::Subject", "record"),
("world_point::WorldPoint", "record"),
("world_unit::ViewerUnit", "wall"),
("world_unit::WorldUnit", "wall"),
("worldview::run", "record"),
];
/// The sorted leaks: crossings filed to close, with how. `CLOSE/absorb`: a facade takes the call;
/// `CLOSE/invert`: the engine publishes the fact instead of the game reaching in;
/// `CLOSE/move-engine`: engine code in a game file moves engine-side; `CLOSE/move-game`: gameplay
/// moves out of the engine. Closing a leak is deleting its row.
const SORTED_LEAKS: &[(&str, &str)] = &[
("billboard::BillboardJointRig", "CLOSE/absorb"),
("billboard::BillboardPlace", "CLOSE/absorb"),
("billboard::billboard_basis", "CLOSE/move-engine"),
("billboard::billboard_joint_palette", "CLOSE/absorb"),
("clutter::ClutterConfig", "CLOSE/absorb"),
("collision::PickOccluder", "CLOSE/absorb"),
("decal::DecalFrame", "CLOSE/absorb"),
("doodad_anim::DoodadAnimTier", "CLOSE/absorb"),
("doodad_anim::TintAnimMaterials", "CLOSE/move-engine"),
("doodad_anim::UvAnimMaterials", "CLOSE/move-engine"),
("doodad_anim::classify", "CLOSE/absorb"),
("doodad_anim::sample_mat_anim", "CLOSE/invert"),
("doodad_anim::wants_rig", "CLOSE/move-engine"),
("entity_shade::GroundShade", "CLOSE/absorb"),
("ffx_glow::FfxDeathFade", "CLOSE/absorb"),
("instance_tint::IDENTITY", "CLOSE/absorb"),
("instance_tint::pack", "CLOSE/absorb"),
("interact::PickBox", "CLOSE/absorb"),
("interact::WorldClick", "CLOSE/move-game"),
("interact::WorldRightClick", "CLOSE/move-game"),
("interact::WorldRightPress", "CLOSE/move-game"),
("interact::cast_pick_ray_inflated", "CLOSE/absorb"),
("interior::BodyBakeCenter", "CLOSE/absorb"),
("interior::ContainmentAttach", "CLOSE/absorb"),
("interior::InteriorLit", "CLOSE/invert"),
("interior::InteriorReauthor", "CLOSE/invert"),
("interior::classify_entity_interior", "CLOSE/absorb"),
("interior::part_interior_lit", "CLOSE/absorb"),
("lighting::GameClock", "CLOSE/absorb"),
("lighting::PropProbeSlot", "CLOSE/move-engine"),
("lighting::PropProbes", "CLOSE/move-engine"),
("liquid::WaterChunkInfo", "CLOSE/absorb"),
("map_proj::WorldProj", "CLOSE/move-game"),
("map_proj::ZoneRect", "CLOSE/move-game"),
("mesh_tag::describe", "CLOSE/absorb"),
("mesh_tag::with_alpha", "CLOSE/invert"),
("model_fade::DespawnFade", "CLOSE/move-game"),
("model_fade::FadeMaterials", "CLOSE/absorb"),
("model_fade::FadeSet", "CLOSE/absorb"),
("model_fade::JoinedFade", "CLOSE/absorb"),
("model_fade::MAX_MODEL_CHAIN", "CLOSE/invert"),
("model_fade::PartFade", "CLOSE/absorb"),
("model_fade::PendingAppearFade", "CLOSE/invert"),
("model_fade::UnitAppearFade", "CLOSE/move-game"),
("model_fade::apply_render_fade", "CLOSE/absorb"),
("model_fade::fade_alpha", "CLOSE/absorb"),
("model_fade::join_unit_appear_fade", "CLOSE/absorb"),
("model_render::FarSideOfWater", "CLOSE/invert"),
("model_render::FarSideTwins", "CLOSE/invert"),
("model_render::ModelVisSet", "CLOSE/absorb"),
("model_render::ShadeSel", "CLOSE/absorb"),
("model_render::far_resolved", "CLOSE/invert"),
("model_render::replace_fog_policy", "CLOSE/absorb"),
("modkeys::DEV_CHORD", "CLOSE/move-engine"),
("modkeys::dev_chord", "CLOSE/move-engine"),
("particles::EmitClock", "CLOSE/absorb"),
("particles::EmitterFade", "CLOSE/invert"),
("particles::EmitterFrames", "CLOSE/absorb"),
("particles::OwnerLoss", "CLOSE/absorb"),
("particles::ParticleEmitter", "CLOSE/invert"),
("particles::buffer::EffectLightOverride", "CLOSE/invert"),
("particles::buffer::EffectQuads", "CLOSE/invert"),
("particles::buffer::EffectVertex", "CLOSE/absorb"),
("particles::buffer::WorldEffectDraw", "CLOSE/absorb"),
("particles::buffer::begin_effect_frame", "CLOSE/absorb"),
("ribbons::RibbonSeq", "CLOSE/absorb"),
("ribbons::RibbonTrail", "CLOSE/invert"),
("ribbons::spawn_ribbon", "CLOSE/absorb"),
("rig_palette::RigPaletteMirrors", "CLOSE/absorb"),
("rig_palette::RigPart", "CLOSE/absorb"),
("rig_palette::RigStarved", "CLOSE/invert"),
("rig_palette::rig_cost_enabled", "CLOSE/absorb"),
("terrain_stream::AreaAuthoritySet", "CLOSE/absorb"),
("terrain_stream::CurrentArea", "CLOSE/absorb"),
("terrain_stream::PendingCollider", "CLOSE/move-engine"),
("terrain_stream::PropLobeLight", "CLOSE/move-engine"),
("terrain_stream::ShadeResolve", "CLOSE/move-engine"),
("terrain_stream::SpawnedModel", "CLOSE/move-engine"),
("terrain_stream::TerrainStreamer", "CLOSE/absorb"),
("terrain_stream::build_collider_task", "CLOSE/move-engine"),
("terrain_stream::doodad_ground_shade", "CLOSE/move-engine"),
("terrain_stream::fold_interior_probe", "CLOSE/move-engine"),
("terrain_stream::hex_word", "CLOSE/move-engine"),
("terrain_stream::m2_anim_bound", "CLOSE/move-engine"),
("terrain_stream::m2_fade", "CLOSE/move-engine"),
(
"terrain_stream::placement_collider_data",
"CLOSE/move-engine",
),
("terrain_stream::point_light", "CLOSE/absorb"),
("terrain_stream::spawn_model_entities", "CLOSE/move-engine"),
("view::CAM_FOVY", "CLOSE/absorb"),
("view::FARCLIP_RANGE", "CLOSE/absorb"),
("view::NEARCLIP_DEFAULT", "CLOSE/absorb"),
("wmo_portal::UnitWmoRoom", "CLOSE/absorb"),
("wmo_portal::WmoPortalInstance", "CLOSE/absorb"),
("wmo_portal::WmoPvsSet", "CLOSE/absorb"),
];
/// The gate on the leak count. Lower it when a leak closes.
///
/// **Re-baselined 2026-10-18, from 107 to the 174 that were really there.** The ratchet's whole
/// job is to fail the *next* crossing, and a ceiling 67 below the count cannot: the test had been
/// red for long enough that nothing could tell a fresh leak from the standing debt. 174 is a
/// recorded debt, not an achievement, and [`LEAK_SLACK`] still drags it down as closes land.
///
/// Where the debt lives, measured rather than guessed — `WOW_API_DUMP=1` now prints the per-file
/// inversion beside the per-item list, because that is the view a close is planned from. The
/// crossings are not spread over the game; the three heaviest files carry a third of them, and
/// the second column is what a move would close **outright**, the items nothing else names:
/// `entities/wmo_props.rs` 27 (16 alone), `torch_shadow.rs` 23 (8), `entities/carried_light.rs`
/// 20 (6), `entities/equipment/spawn.rs` 16 (0), `entities/attach/mod.rs` 14 (4),
/// `player/camera.rs` 13 (2), `aura_visual.rs` 12 (2), `entities.rs` 12 (2).
///
/// Those are renderer and model-spawn files sitting on the game side of the line, which is why
/// they name so much of the engine, so the disposition for most of this debt is `move-engine`,
/// one cluster at a time, in the round that is already in that file. Each is real work, not a
/// file move: `carried_light.rs` also reaches into `entities::spell_fx`, and `torch_shadow.rs`
/// into `video` and `char_select`, so a cluster closes only once its gameplay edge is cut. What
/// would make this test lie is the other move — publishing engine internals as API to turn the
/// number green.
const LEAK_CEILING: usize = 174;
/// How far under [`LEAK_CEILING`] the count may sit before the test asks for the ceiling to be
/// lowered: one closure does not fail the gate, a whole stage of work cannot go unrecorded.
const LEAK_SLACK: usize = 10;
#[test]
fn the_world_api_doorway_stays_shut() {
let all = measure();
// An item only an instrument names is outside the doorway (`INSTRUMENT_CONSUMERS`); one a
// game module also names is inside it.
let (probes, surface): (BTreeMap<_, _>, BTreeMap<_, _>) = all
.into_iter()
.partition(|(_, files)| files.iter().all(|f| is_instrument_consumer(f)));
// The doorway is the published API plus the leaks; only the leaks gate.
let published_by: BTreeMap<&str, &str> = PUBLISHED.iter().copied().collect();
let sorted_by: BTreeMap<&str, &str> = SORTED_LEAKS.iter().copied().collect();
let both: Vec<&str> = published_by
.keys()
.filter(|k| sorted_by.contains_key(*k))
.copied()
.collect();
assert!(
both.is_empty(),
"an item cannot be both published and a sorted leak: {both:?}"
);
let (published, leaks): (BTreeMap<_, _>, BTreeMap<_, _>) = surface
.into_iter()
.partition(|(k, _)| published_by.contains_key(k.as_str()));
let n = leaks.len();
let sorted = leaks
.keys()
.filter(|k| sorted_by.contains_key(k.as_str()))
.count();
// Always print the numbers (`--nocapture` shows them).
eprintln!(
"world API surface: {} items — {n} leaks (ceiling {LEAK_CEILING}, slack {LEAK_SLACK}; \
{sorted} sorted, {} unsorted), {} published; + {} named only by the instruments",
published.len() + n,
n - sorted,
published.len(),
probes.len()
);
// A row no game file names any more must go: a stale published row would let the item cross
// again for free.
let stale: Vec<&str> = PUBLISHED
.iter()
.map(|(k, _)| *k)
.filter(|k| !published.contains_key(*k) && !probes.contains_key(*k))
.chain(
SORTED_LEAKS
.iter()
.map(|(k, _)| *k)
.filter(|k| !leaks.contains_key(*k) && !probes.contains_key(*k)),
)
.collect();
assert!(
stale.is_empty(),
"rows in PUBLISHED / SORTED_LEAKS that no game file names any more — delete them (a \
closed leak is a deleted row; a published item nobody uses is not API):\n {}",
stale.join("\n ")
);
// `WOW_API_DUMP=1` prints the surface itself, most-named first, each item with its table tag.
if std::env::var("WOW_API_DUMP").is_ok() {
let tagged = |m: &BTreeMap<String, BTreeSet<String>>, tag: &dyn Fn(&str) -> String| {
let mut rows: Vec<_> = m
.iter()
.map(|(k, v)| (v.len(), format!("{k} [{}]", tag(k))))
.collect();
rows.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
rows.iter()
.map(|(n, k)| format!(" {n:3} file(s) {k}"))
.collect::<Vec<_>>()
.join("\n")
};
// The per-file inversion: which game file names the most engine internals, and how many
// of those it is the only namer of — the two numbers that say where a `move-engine`
// closes a batch rather than one row.
let mut by_file: BTreeMap<&str, (usize, usize)> = BTreeMap::new();
for files in leaks.values() {
for f in files {
by_file.entry(f.as_str()).or_default().0 += 1;
if files.len() == 1 {
by_file.entry(f.as_str()).or_default().1 += 1;
}
}
}
let mut ranked: Vec<_> = by_file.into_iter().collect();
ranked.sort_by(|a, b| b.1 .0.cmp(&a.1 .0).then(a.0.cmp(b.0)));
let per_file = ranked
.iter()
.map(|(f, (all, only))| format!(" {all:3} crossing(s), {only:3} of them alone {f}"))
.collect::<Vec<_>>()
.join("\n");
eprintln!(
"--- leaks ---\n{}\n--- published ---\n{}\n--- named only by the instruments ---\n{}\n\
--- the leaks by game file: where a move-engine closes a batch ---\n{per_file}",
tagged(&leaks, &|k| sorted_by
.get(k)
.map_or_else(|| "unsorted".to_string(), |b| b.to_string())),
tagged(&published, &|k| published_by[k].to_string()),
tagged(&probes, &|k| published_by.get(k).map_or_else(
|| "instrument".to_string(),
|b| format!("published {b}")
)),
);
}
if n > LEAK_CEILING {
let fresh: Vec<_> = leaks
.iter()
.filter(|(k, _)| !sorted_by.contains_key(k.as_str()))
.map(|(k, v)| (v.len(), k))
.collect();
panic!(
"world API leaks: {n}, ceiling {LEAK_CEILING}.\n\
Something new crossed the line and no record publishes it. Either close it, or — if \
it is genuine engine API — add it to PUBLISHED with the record that says so, \
a claim made in review. The unsorted leaks on this tree:\n\n{}",
render(&fresh)
);
}
assert!(
n + LEAK_SLACK >= LEAK_CEILING,
"world API leaks are down to {n} and the ceiling still says {LEAK_CEILING}. Lower \
LEAK_CEILING to {n} in this file so the next leak has to earn its place — the ratchet \
only holds if the number follows the work down."
);
}
/// The wall that points the other way: an engine file naming a gameplay item is a dependency
/// `benilla-world` cannot express. Zero, and the crate graph keeps it there.
#[test]
fn the_engine_names_nothing_of_the_game() {
let surface = measure_back();
let n = surface.len();
eprintln!("engine→game surface: {n} items (target 0 — CLOSED)");
assert!(
n == 0,
"the engine names {n} gameplay items, and this wall is CLOSED — it reached zero and must \
stay there.\n\
An engine file reaching into the game is the dependency `benilla-world` cannot have: not \
a lint, a compile error waiting for the crate to exist. Invert it (the engine publishes \
the fact, the game reads it), move the caller, or move the thing named.\n\n{}",
render(
&surface
.iter()
.map(|(k, v)| (v.len(), k))
.collect::<Vec<_>>()
)
);
}
/// Every distinct engine item named by non-engine code, and the files that name it.
fn measure() -> BTreeMap<String, BTreeSet<String>> {
let app = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let split = app
.parent()
.is_some_and(|c| c.join("benilla-world").is_dir());
let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let src = app.join("src");
let tests = test_module_files(&src);
for file in rs_files(&src) {
let rel = file
.strip_prefix(&src)
.unwrap()
.to_string_lossy()
.to_string();
// Without the world crate the file's module decides its side; with it, every file here
// is on the game side.
if (!split && is_engine(&rel)) || tests.contains(&rel) {
continue;
}
let text = std::fs::read_to_string(&file).unwrap();
for (path, _) in paths_in(&text, split, true) {
out.entry(path).or_default().insert(rel.clone());
}
}
drop_module_paths(out)
}
/// Drop every entry that is only the path to another entry: `particles::buffer` and
/// `mesh_tag::spawn_tag` look alike, but a module path is a strict prefix of another named item
/// and a function is a prefix of nothing.
fn drop_module_paths(
all: BTreeMap<String, BTreeSet<String>>,
) -> BTreeMap<String, BTreeSet<String>> {
let keys: Vec<String> = all.keys().cloned().collect();
all.into_iter()
.filter(|(k, _)| {
let prefix = format!("{k}::");
!keys.iter().any(|other| other.starts_with(&prefix))
})
.collect()
}
/// Every gameplay item named by an engine file. Empty once the world crate exists, where such a
/// name is a compile error.
fn measure_back() -> BTreeMap<String, BTreeSet<String>> {
let app = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
if app
.parent()
.is_some_and(|c| c.join("benilla-world").is_dir())
{
return BTreeMap::new(); // the crate graph enforces it
}
let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let src = app.join("src");
let tests = test_module_files(&src);
for file in rs_files(&src) {
let rel = file
.strip_prefix(&src)
.unwrap()
.to_string_lossy()
.to_string();
if tests.contains(&rel) {
continue;
}
// Instruments may see both sides: they sit above the engine.
let root = rel.split(['/', '.']).next().unwrap_or("");
if !is_engine(&rel) || INSTRUMENT_ROOTS.contains(&root) {
continue;
}
let text = std::fs::read_to_string(&file).unwrap();
for (path, _) in paths_in(&text, false, false) {
out.entry(path).or_default().insert(rel.clone());
}
}
out
}
/// Every source file that is a test module in its own file: declared `#[cfg(test)] mod x;`,
/// with its body in `x.rs` or `x/mod.rs` beside its parent. Its names do not count.
fn test_module_files(src: &Path) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for file in rs_files(src) {
let text = std::fs::read_to_string(&file).unwrap();
// `a.rs`'s `mod b;` is `a/b.rs`; so is `a/mod.rs`'s.
let dir = match file.file_name().and_then(|n| n.to_str()) {
Some("mod.rs") | Some("lib.rs") | Some("main.rs") => {
file.parent().unwrap().to_path_buf()
}
_ => file.with_extension(""),
};
let mut armed = false;
for line in text.lines() {
let t = line.trim();
if t == "#[cfg(test)]" {
armed = true;
continue;
}
if armed {
if let Some(name) = t
.strip_suffix(';')
.and_then(|d| d.rsplit_once("mod "))
.map(|(_, n)| n)
{
for cand in [
dir.join(format!("{name}.rs")),
dir.join(name).join("mod.rs"),
] {
if cand.is_file() {
out.insert(
cand.strip_prefix(src)
.unwrap()
.to_string_lossy()
.to_string(),
);
}
}
}
armed = false;
}
}
}
out
}
fn is_engine(rel: &str) -> bool {
let root = rel.split(['/', '.']).next().unwrap_or("");
ENGINE_ROOTS.contains(&root)
}
/// Walk a source file and yield `(canonical item, line)` for every engine path it names in code.
/// It follows `crate::root::` (or `benilla_world::` with the crate), expands a `use` brace group
/// into its items, and stops a path at its first capitalised segment (`WorldAssets::get` is one).
fn paths_in(text: &str, split: bool, want_engine: bool) -> Vec<(String, usize)> {
let prefix = if split { "benilla_world::" } else { "crate::" };
let mut found = Vec::new();
let mut depth: i32 = 0;
// A `#[cfg(test)]` guards the item after it. `test_at` arms at the attribute's depth `d`: a
// braced item ends when the depth comes back to `d` (it never drops below), an unbraced one
// (`mod tests;`, `use …;`) at its semicolon.
let mut test_at: Option<i32> = None;
let mut test_open = false;
for (n, line) in logical_lines(text).iter().map(|(n, l)| (*n, l.as_str())) {
let t = line.trim_start();
if t.starts_with("#[cfg(test)]") {
test_at = Some(depth);
test_open = false;
}
let skip = test_at.is_some() || t.starts_with("//") || t.starts_with('*');
if !skip {
let mut rest = line;
while let Some(i) = rest.find(prefix) {
// A bare `xcrate::` is not our prefix; require a non-ident char before it.
let boundary = i == 0 || !is_ident(rest.as_bytes()[i - 1] as char);
let tail = &rest[i + prefix.len()..];
if boundary {
// A crate-root item (`crate::SPAWN_XY`) belongs to the game, since `lib.rs`
// stays in `benilla-app`; an engine file naming one is a reverse crossing.
let root_item = !split
&& ident(tail).is_some_and(|(r, a)| {
r.starts_with(char::is_uppercase) && !a.starts_with("::")
});
if root_item && !want_engine {
if let Some((r, _)) = ident(tail) {
found.push((format!("crate::{r}"), n + 1));
}
rest = &rest[i + prefix.len()..];
continue;
}
let (root, after) = if split {
(String::new(), tail)
} else {
match ident(tail) {
Some((r, a))
if a.starts_with("::") && !r.starts_with(char::is_uppercase) =>
{
(r, &a[2..])
}
_ => (String::new(), ""),
}
};
// An instrument is neither side: outside the doorway, and not a reverse
// dependency either, since `WorldPlugins` registers it.
let instrument = !split && INSTRUMENT_ROOTS.contains(&root.as_str());
let engine = split || (!instrument && ENGINE_ROOTS.contains(&root.as_str()));
let keep = if want_engine {
engine
} else {
!engine && !instrument
};
if keep && !after.is_empty() {
for tail in expand(after) {
let full = if root.is_empty() {
tail
} else {
format!("{root}::{tail}")
};
let item = canon(&full);
// A bare module name is not an item; `drop_module_paths` misses one
// whose members are named only where the scan does not look.
let bare_module =
!item.contains("::") && !item.starts_with(char::is_uppercase);
if !bare_module {
found.push((item, n + 1));
}
}
}
}
rest = &rest[i + prefix.len()..];
}
}
depth += line.matches('{').count() as i32 - line.matches('}').count() as i32;
if let Some(d) = test_at {
if depth > d {
test_open = true;
} else if test_open || t.trim_end().ends_with(';') {
test_at = None;
test_open = false;
}
}
}
found
}
/// The file's lines, with a `use` brace group that spans several lines joined into one, since the
/// scan is line-based. A joined line keeps its first line's number; a `//` inside it is cut (a
/// `use` has no string literal for one to sit in).
fn logical_lines(text: &str) -> Vec<(usize, String)> {
fn open(s: &str) -> i32 {
s.matches('{').count() as i32 - s.matches('}').count() as i32
}
fn strip_comment(s: &str) -> &str {
s.find("//").map_or(s, |i| &s[..i])
}
let mut out = Vec::new();
let mut lines = text.lines().enumerate();
while let Some((n, line)) = lines.next() {
let t = line.trim_start();
let is_use = t.starts_with("use ")
|| (t.starts_with("pub") && t.contains(" use ") && !t.contains('='));
if is_use && open(strip_comment(line)) > 0 {
let mut joined = strip_comment(line).trim_end().to_string();
let mut depth = open(&joined);
for (_, next) in lines.by_ref() {
let next = strip_comment(next);
joined.push(' ');
joined.push_str(next.trim());
depth += open(next);
if depth <= 0 {
break;
}
}
out.push((n, joined));
} else {
out.push((n, line.to_string()));
}
}
out
}
fn is_ident(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
/// The leading identifier of `s`, and what follows it.
fn ident(s: &str) -> Option<(String, &str)> {
let end = s.find(|c: char| !is_ident(c)).unwrap_or(s.len());
(end > 0).then(|| (s[..end].to_string(), &s[end..]))
}
/// The dotted tails a path fragment names: one, or every leaf of a `use` brace group.
fn expand(s: &str) -> Vec<String> {
let s = s.trim_start();
if !s.starts_with('{') {
let mut out = String::new();
let mut rest = s;
while let Some((seg, after)) = ident(rest) {
if !out.is_empty() {
out.push_str("::");
}
out.push_str(&seg);
if !after.starts_with("::") {
break;
}
let tail = &after[2..];
// `interact::{WorldClick, WorldRightClick}`: a group hanging off a walked path.
if tail.trim_start().starts_with('{') {
let head = out;
return expand(tail)
.into_iter()
.map(|leaf| format!("{head}::{leaf}"))
.collect();
}
rest = tail;
}
return if out.is_empty() { vec![] } else { vec![out] };
}
let mut depth = 0usize;
let mut end = s.len();
for (i, c) in s.char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = i;
break;
}
}
_ => {}
}
}
let mut out = Vec::new();
let mut depth = 0usize;
for part in split_top(&s[1..end], &mut depth) {
let part = part.trim();
let part = part.split(" as ").next().unwrap_or(part).trim();
if part.is_empty() || part == "self" {
continue;
}
match part.find('{') {
Some(b) => {
let head = part[..b].trim_end_matches(':');
for leaf in expand(&part[b..]) {
out.push(format!("{head}::{leaf}"));
}
}
None => out.extend(expand(part)),
}
}
out
}
/// Split on commas that are not inside a nested brace group.
fn split_top<'a>(s: &'a str, depth: &mut usize) -> Vec<&'a str> {
let (mut out, mut start) = (Vec::new(), 0);
for (i, c) in s.char_indices() {
match c {
'{' => *depth += 1,
'}' => *depth = depth.saturating_sub(1),
',' if *depth == 0 => {
out.push(&s[start..i]);
start = i + 1;
}
_ => {}
}
}
out.push(&s[start..]);
out
}
/// A path folded to the symbol it names: everything up to and including the first capitalised
/// segment (a type), or the whole path when every segment is lowercase (a function).
fn canon(path: &str) -> String {
let mut out: Vec<&str> = Vec::new();
for seg in path.split("::") {
out.push(seg);
if seg.starts_with(char::is_uppercase) {
break;
}
}
out.join("::")
}
fn rs_files(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
for e in std::fs::read_dir(&d).into_iter().flatten().flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().is_some_and(|x| x == "rs") {
out.push(p);
}
}
}
out.sort();
out
}
fn render(items: &[(usize, &String)]) -> String {
let mut v = items.to_vec();
v.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1)));
v.iter()
.map(|(n, k)| format!(" {n:3} file(s) {k}"))
.collect::<Vec<_>>()
.join("\n")
}
/// A `use` group that spans lines counts every member; the comment inside it is cut, and the
/// closing `};` ends the statement.
#[test]
fn a_use_group_spanning_lines_counts_every_member() {
let text =
"use benilla_world::interact::{\n PickParts,\n ray_mesh_bounds, // a lead\n \
ray_posed_mesh,\n};\nfn f() {}\n";
let mut found: Vec<String> = paths_in(text, true, true)
.into_iter()
.map(|(p, _)| p)
.collect();
found.sort();
assert_eq!(
found,
[
"interact::PickParts",
"interact::ray_mesh_bounds",
"interact::ray_posed_mesh"
]
);
}