microverse · headway:gamedev/deny-cool-license · 3d6abbc0db47

Budgets, Enumerated

Every bounded resource in the engine already knew how full it was. None of them could be found without being named — and a budget nobody names is a budget nobody reports.

+1021 / −74 lines 189 → 212 tests 7 types now reportable 0 new unsafe

The measurement was never the problem. Arena, Pool, Order, DrawList and Graph each already carried a name, an occupancy, a capacity, a high-water mark and a refusal count. What none of them could do was answer a question asked from outside: what is this machine holding right now?

To answer it you had to name every primitive yourself. The shell's per-second report did exactly that, and it shows what that costs.

The failure worth preventing

Hand-written before

println!( "draws {}/{} (peak {}) arena L {}/{} F {}/{}", draws.len(), draws.capacity(), draws.peak(), level.len(), level.capacity(), scratch.len(), scratch.capacity(), ); if draws.dropped() > 0 || stats.dropped > 0 { … } if level.overflows() > 0 || scratch.overflows() > 0 { … } draws.reset_stats(); scratch.reset_stats(); …

Four places name each budget: the format string, its argument list, an overflow branch, a reset_stats call. Miss one and the budget still exists — it just stops being reported.

Enumerated after

let budgeted: &[&dyn Budgeted] = &[&world, &scratch, &draws, &renderer]; budget::visit_all(budgeted, &mut |b| { print!(" {} {}/{}{} (peak {})", b.name, b.used, b.cap, b.kind.unit(), b.peak); });

One slice literal, at the one site that knows what storage exists. Adding a budget is one word, and the overflow lines are driven off the same walk — so a budget cannot be reported in one place and forgotten in the other.

Try it

Add storage to the machine

The panels below run the same two strategies against the same engine. Add a budget and watch which report notices.

Hand-written before

0edits owed before this report is true again

Every budget is accounted for — because nothing has been added yet.

Enumerated after

used peak refused

Four budgets, read from the live primitives.

The transform graph adds two lines from one call — it owns a pool and an ordering of that pool. That is the case an accessor returning a single report could not express.

The mechanism

One call, every budget

reports — no types, just data visit_all( &[&dyn Budgeted]) &world &scratch &draws &renderer budgets(visit) forwards level 0/4096 B frame 84/1024 B draws 441/4096 items instances 441/4096 Batcher is a shell type implementing a core trait. The consumer cannot tell which side a budget came from.
The registry owns nothing and nothing registers with it. Each report is built on demand by reading the live primitive, so there is no second copy of the truth to go stale — and no registration call anyone can forget.
Graph Pool<Node> Order<Node> nodes 3/16 nodes 3/16 same closure, one level down
Two lines, not one. They share a name and fill together, but they are separate storage that can be separately wrong: an ordering that refused a push while the pool still had slots is a bug — and a report that folded them into one line is a report in which that bug is invisible.

Three decisions

What the card asked for, and what it got

Nothing registers

The card said “every pool and arena registers.” There is no registration step.

A registry struct holding &dyn Budgeted would need a capacity — and a capacity picked before anything measures one is exactly the guess BUDGETS.md exists to replace. The registry is a slice literal, sized by the compiler. Nothing can be registered-but-forgotten because nothing registers.

A visitor, not an iterator

Object safety forces it, and the aggregates settle it.

Vec<BudgetReport> allocates, which is denied in this crate. impl Iterator does not allocate but is not object-safe — so &dyn Budgeted could not exist and neither could the slice. And each level of nesting would chain another iterator type through the signature. The cost: a consumer cannot take two reports and stop. Nothing wants to.

Counts saturate, never wrap

A count past u32::MAX reports 4294967295.

Wrapping would turn a 5 GiB arena into a plausible small number and make the report quietly false. Saturating produces a figure no budget here has, which reads as “go and look.” Neither case is expected — this engine measures its budgets in tens of kilobytes — so the only question is which failure is legible.

The other half

A refusal already named itself

The card also asked that a capacity refusal name the exhausted resource and the operation that wanted it — “not just None.” Checking the code first turned this from work into a test: it was already true everywhere.

RefusalResourceOperationCarries
Exhaustedarenaopbytes, padding, used, cap
Overflow<T>poolopthe rejected value
Attachgraphopwhy it was refused
DrawList::pushvia reportinfalliblecounted, not returned

DrawList::push is the one deliberate exception. It is infallible so that rendering failures never reach every gameplay call site, so the guarantee it owes is carried by its budget report instead — the resource is named there, the refusal is counted there. That substitution is now a test rather than a comment.

So the deliverable became a check: tests/budgets.rs drives each primitive to capacity and reads back what it says, asserts a refused operation leaves occupancy and the high-water mark byte for byte as they were, and asserts the overflow count survives the frame boundary that clears the contents.

What it cost, and what is not done

Fallout

DrawList was the only primitive without a name and gained one — thirteen call sites. The shell's Batcher gained a name and a peak it never had, and implements the core's trait: a budget the core has never heard of, sitting in the same enumeration, indistinguishable to a consumer. RenderStats.dropped was deleted, being the second copy the enumeration made unnecessary.

Unresolved handles deliberately stay out of the report. A command naming a mesh that is not loaded would have been dropped by a buffer with room to spare, so reading it as capacity pressure sends whoever is looking to the wrong place. It keeps its own counter and its own warning line.

Unverified: the printed line

There is no display on the machine this ran on — DISPLAY and WAYLAND_DISPLAY both unset — so the window never opens and the report line was never read back. The enumeration is covered by tests on both sides of the boundary; the format string in main.rs is not. Running it once with MICROVERSE_DRAW_COMMANDS=10 should make the overflow warnings fire.