fixstep API

← fixstep · version 0.1.4

46 public items, in source order. Generated from the registry index, which re-derives them from the package source at publish time — so a signature here is the one that version actually ships.

pub fn ease_fraction(k: float, dt: float) -> float

What one frame of dt closes of a gap, in [0, 1).

⚠ Zero for a frame with no time in it — which is what a PAUSED consumer hands down, and it must not creep.

pub fn approach(now: float, want: float, k: float, dt: float) -> float

Move now toward want by one frame's worth.

⚠ **k is the gap fraction closed per SECOND**, so it is a rate and not a step: an approach chasing something moving at v settles at a steady lag of v / k. ⚠⚠ That derivation assumes a CONTINUOUS target — against one that stands still and then jumps a whole step, the real excursion is the step, not v / k, and a consumer that sized k on the formula will be out by the ratio of the two (measured at **3.7x** in this package's first consumer).

pub fn wrap_deg(a: float) -> float

An angle in degrees, folded into [0, 360).

pub fn wrap_delta(d: float) -> float

A DIFFERENCE of angles, folded into (−180, 180] — the short way round.

⚠⚠ **This is not defensive tidying, and a real key set reaches the wrong-way swing.** In this package's first consumer, holding one key gives a bearing of 360° and adding a second gives 60°. The true turn is 60°; eased as plain numbers it is **−300°**, and the camera spins five sixths of a circle the wrong way on one extra keypress — while looking like a working camera the whole time, which is why it needs a test rather than an eye.

pub fn approach_angle(now: float, want: float, k: float, dt: float) -> float

Move an ANGLE toward another by one frame's worth, the short way.

⚠ A separate door from approach rather than a flag on it: an angle is not a number that happens to wrap, and a consumer that eased a bearing with approach gets a camera that is correct at every angle except across the seam.

pub struct Bank { progress: integer }

Progress banked toward the next whole unit.

⚠ **One field, and the struct exists so the caller cannot forget to write the remainder back.** loft has no by-reference integer, so a free function over an integer would have to answer a pair and leave the carry to the call site — which is the seven-sites problem wearing a library's clothes. Passed as a parameter it aliases, so bank_gain updates it in place and answers only the whole units.

pub fn bank_new() -> Bank

A bank with nothing carried.

pub fn bank_whole(units_per_second: integer) -> integer

How much banked progress makes one whole unit, for a clock counting units_per_second base units a second.

⚠ dryopea's answer is the BANK_WHOLE constant above; this is the derivation, for a consumer whose clock counts something else.

pub fn bank_rate(per_second: float) -> integer

Quantise a rate quoted in whole units per second.

⚠ **It ROUNDS rather than truncating**, and the difference is visible at every rate that is not a whole millionth: truncating would make a mover systematically slower, which is the shape of defect this whole plan exists to remove. ⚠ Truncation is also what @D003 was — the same mistake one unit further out.

pub fn bank_gain(b: Bank, rate: integer, elapsed: integer, whole: integer) -> integer

Gain ground at rate for elapsed base time units, and answer the whole units it releases. The remainder stays banked.

⚠ **This is the one implementation of *do not lose a fraction* in dryopea**, and it is exact: progress is an integer, rate × elapsed is an integer, and a whole unit costs an integer, so there is nothing to round and no epsilon to get the direction of wrong (CLAUDE.md § Timers and epsilons: *neither direction is safe*).

⚠ **A rate of zero banks nothing and keeps what is banked** — a mover that stops has not forgotten how far it had come. Same for a non-positive elapsed: tick_clock.loft gives the reason time going backwards must be dropped rather than owed.

⚠ **What it does NOT do is decide whether the mover may move.** enemy_tick asks the pre-walk window first, helper_tick asks whether the crew member is alive; a bank that knew about standing would have to know about every other reason not to move too.

pub fn bank_most(rate: integer, elapsed: integer, whole: integer) -> integer

The MOST one step at rate over elapsed could release — the ceiling, carry included, touching no bank.

⚠ **An upper bound is a different question from a release**, and conflating them is what a caller does when it has no bank to spend. play.loft::play_steer_reach is the case: it asks how far ahead a held key should point, which must exceed anything one tick can cover, and it must not consume the player's carry to find out.

pub fn bank_progress(b: const Bank) -> integer

What is banked, in raw progress.

⚠ **Readable so a run can be COMPARED and RESTORED** — compare.loft::state_diff reads it as part of a mover's state, and it is an integer now, so the comparison is exact without the "floats EXACTLY" argument that file had to make.

pub fn bank_fraction(b: const Bank, whole: integer) -> float

What is banked, as a fraction of a whole unit.

⚠ **The AUTHORING door, and the one place a float appears** — emit.loft writes a captured situation as .keys text and script.loft reads it back, and *hexes* is the unit a person authors in. The round trip is exact because a double carries 53 bits and BANK_WHOLE needs 42: bank_restore_fraction recovers the integer this answered.

pub fn bank_restore(b: Bank, progress: integer, whole: integer)

Put a bank back where it was, in raw progress.

⚠ Clamped into [0, whole) rather than trusted, for the reason clock_restore clamps: a carry at or past a whole unit is a state bank_gain can never leave behind, and accepting one would release a unit nothing paid for.

pub fn bank_restore_fraction(b: Bank, units: float, whole: integer)

Put a bank back from a fraction of a whole unit — bank_fraction's other half.

pub fn bank_reset(b: Bank)

Throw the carry away.

⚠ **Deliberately a door of its own rather than bank_restore(b, 0), because it is a DECISION.** A helper that comes back from recovery is a repaired vehicle rather than the wreck that was carried in, so its banked ground goes with the damage; an enemy that is merely blocked keeps everything. Two behaviours, and the one that discards says so by name.

pub struct TickClock { step: integer, banked: integer, rate_num: integer, rate_den: integer, rate_carry: integer, pump_at: integer, pumped: boolean }

A fixed step and the time banked toward the next one.

⚠ **Both integers, and that is the whole file.** banked is always in [0, step) between calls, so there is no rounding to accumulate and no epsilon to get the direction of wrong (CLAUDE.md § Timers and epsilons: *neither direction is safe*).

pub fn clock_new(step: integer) -> TickClock

A clock ticking every step base units.

⚠ **One argument, and a clock nobody configures behaves exactly as dryopea's did before this file** — uncapped, unscaled, carrying its remainder. plans/26 § The two rules under all of it: *defaults mean the behaviour you already had*.

⚠ A step below 1 is clamped to 1 rather than refused, because the alternative is a nullable clock at every call site for a case no caller has: clock_new(0) is a mistake, not a mode.

pub fn clock_units_from_micros(us: integer) -> integer

Convert wall-clock microseconds — what loft's ticks() answers — to this clock's base units.

⚠ **A multiplication, so it is exact.** main.loft used to divide microseconds by 1e6 into float seconds before the simulation saw them, which threw the exactness away at the boundary and is half of what plan 26 was opened for (plans/26 § 1).

pub fn clock_seconds_from_units(units: integer) -> float

The other direction, in SECONDS.

⚠⚠ **The seam plan 26 L3 was going to CLOSE, and what it did instead was change hands.** Before L3 every one-shot timer in the game came through here to count float seconds — the boost and its cooldown, 90 ticks of helper recovery, 20 s of tower rebuild, the wave lull — and tick_timer.loft took all four into integer units. What is left is not simulation at all:

* **authoring** — emit.loft writes a captured situation as .keys text and a person reads it, and *seconds* is the unit a person authors in. Exactly bank_fraction's position one family over, and exactly as exact: a duration is a whole number of base units, so the round trip through clock_units_from_seconds recovers the integer this answered. * **presentation** — the camera's ease is 1 − e^(−k·dt) over real frame time and is float by nature (plans/26 § The invariant).

⚠ **No mover and no timer may call this.** A mover that did would be throwing away what bank_gain was built to keep, and a timer that did would be re-introducing the direction problem tick_timer.loft exists to delete.

pub fn clock_units_from_seconds(seconds: float) -> integer

Float seconds to base units, ROUNDED — the authoring door's other half.

⚠ **Rounding rather than truncating, for tests/26_l1's reason one layer out**: a float second's worth of units is a hair over or under its exact count, and truncating a hair-under value costs a whole unit at every boundary. The error is ~1e-10 against a step of 2 000 000.

⚠ Negative time answers 0 rather than a negative count — the same refusal clock_advance makes, stated here too because the rounding would otherwise turn a small negative into -0.5 and then into 0 by accident rather than by rule.

pub fn clock_set_rate(clk: TickClock, num: integer, den: integer)

Run simulated time at num / den of wall time.

⚠ **A RATIONAL rather than a float, and that is the whole reason it needs no epsilon.** clock_set_rate(clk, 1, 3) is a third of speed exactly, for ever: a frame worth 100 units contributes 33 and the leftover 1/3 is carried, where elapsed * 0.3333… drifts against a rate it could not state in the first place. Same distinction tick_bank.loft § THE RATE IS SCALED AND THE TIME IS NOT draws one family over — except that a clock's rate is authored by a KEYPRESS rather than by numbers.json, so it is exact rather than quantised.

⚠ **Pause is 0 / 1**, and it banks nothing while keeping what is banked — so unpausing resumes mid-step rather than re-phasing the simulation. A step of 0 is a mistake, never a pause (clock_new).

⚠ A negative num is clamped to 0 rather than rewinding. Time going backwards is dropped everywhere else in this file, and a rate that ran the simulation in reverse would need every consumer to have an inverse tick.

⚠ **Changing the rate DROPS the wall carry**, because the carry is measured in den-ths of a base unit and the old den is gone. It is under one base unit — a third of a microsecond — and a rate change is an event a player caused, not a per-frame path.

pub fn clock_rate_num(clk: const TickClock) -> integer

The rate's numerator — 0 for a paused clock.

pub fn clock_rate_den(clk: const TickClock) -> integer

The rate's denominator — never 0, and 1 for an unconfigured clock.

pub fn clock_advance(clk: TickClock, elapsed: integer) -> integer

Spend elapsed time, and answer the whole steps it bought.

⚠ **Integer division, not a subtraction loop.** The loop is what lost the tick — eleven subtractions of a repeating fraction leave a remainder a hair under the twelfth — and it is also O(steps), so a frame that delivered an hour walked five thousand iterations to say so. One division answers both.

⚠ **Time going backwards is DROPPED, not banked.** A negative elapsed is a clock that was reset or a caller subtracting in the wrong order; banking it would owe the simulation negative ticks and then swallow the next real frame. Answering 0 and keeping the bank is the only behaviour that cannot corrupt a run.

⚠ **No cap, and clock_advance_capped is the door that has one.** A frame that delivers 20 s buys 30 ticks here, which is the property dryopea's 654 gate measurements rest on.

pub fn clock_advance_capped(clk: TickClock, elapsed: integer, max_steps: integer) -> integer

The same, for a driver that may have been backgrounded — at most max_steps, and the excess is GONE.

⚠⚠ **It DROPS rather than defers, and the difference is the whole door.** clock_advance has already reduced the bank to the sub-step remainder by the time this clamps, so the steps beyond the cap are not owed: the next frame answers 1, not a burst. The tempting wrong version clamps the answer and leaves the backlog banked, which runs the simulation behind the wall for ever after — tests/26_l4 § A cap that defers is that version, kept as the control, and it is the only thing that makes this function's gate non-trivial.

⚠ **And it must not compress**, which is moros's own note: *a compressed catch-up is the variable-step bug wearing a bound's clothes*. Nothing here can — the step is fixed and this answers a COUNT — but a driver that multiplied its tick's dt to "catch up" would reintroduce exactly what plans/22 § LOD is warned against.

⚠ A cap below 1 is clamped to 1, for clock_new's reason: a cap of zero is a simulation that can never advance, which is a mistake rather than a mode. Pause is a rate of 0.

pub fn clock_pump(clk: TickClock, now: integer) -> integer

Spend the time since the last reading of an ABSOLUTE wall clock.

⚠ **The server pump's door: it owns the wall and the caller owns no subtraction.** now - last written at the call site is where a driver gets the order backwards once and silently swallows a frame — clock_advance drops negative time, so the mistake looks like a stutter rather than an error.

⚠ **The FIRST call primes and answers none.** A pump whose first reading was treated as elapsed would open a server with its whole uptime as one burst of ticks.

⚠ **A wall going backwards re-primes** rather than dropping the reading, because an absolute clock that jumped back is the new truth; dropping it would owe every future pump the difference.

⚠ No cap — moros's server has none and plans/26 § 3 says why the two repos are both right. A pump that needs one wraps this in the same clamp clock_advance_capped applies.

pub fn clock_step(_clk: const TickClock, n: integer) -> integer

Ask for n steps, and answer how many that was.

⚠ **It does not touch the bank, and that is the whole point of it being a separate door.** A count is not elapsed time: a script saying tick 30 neither spends a live session's remainder nor leaves one behind. play.loft § TWO ways to ask made this distinction one level up and measured what folding them costs.

⚠ It looks trivial and is not free to delete: without it a caller that has a count must spell it as n * step, which is the exact mistake tests/19_p1 records — and which is only safe here because the arithmetic is integer. The door is what says so.

⚠⚠ **_clk IS UNDERSCORED BECAUSE IT IS UNREAD BY DESIGN, AND THE UNDERSCORE IS LOAD-BEARING** (0.1.1). Not reading it *is* the contract — a count must not spend a live session's remainder — so never-read is a true report of an intended fact rather than a defect. Left bare it fails the chunk's LOFT_DENY_WARNINGS=1 CI, and both tempting fixes are wrong: touching clk to quiet it breaks the contract, and an .allow_warnings file switches the gate off for every FUTURE warning in this package too.

⚠ **The parameter stays rather than the signature dropping it.** A caller holding a clock should reach for clock_step(clk, n) beside clock_advance(clk, elapsed); a bare step(n) would be an arithmetic helper that names no case, and naming the case is the entire reason this door exists.

pub fn clock_step_units(clk: const TickClock) -> integer

The step this clock ticks on, in base units.

pub fn clock_banked(clk: const TickClock) -> integer

Time banked toward the next step, in base units — always in [0, step).

⚠ **Readable so a run can be RESTORED**, which is the whole of what a rollback consumer needs from the timing half (plans/26 § What this plan does NOT build): re-running N steps from a restored bank is bit-identical because clock_step is exact.

pub fn clock_restore(clk: TickClock, banked: integer)

Put a clock back where it was — the other half of clock_banked.

⚠ Clamped into [0, step) rather than trusted: a bank at or past the step is a state clock_advance can never leave behind, and accepting one would make the next call answer a step nobody's time paid for.

pub fn clock_alpha(clk: const TickClock) -> float

How far the bank is toward the next step, in [0, 1).

⚠ **The one number a renderer needs from a fixed-step clock**, and the reason it is here rather than at the call site: banked / step written by hand is a caller reaching INTO the accumulator, and a caller that can read banked can also write it. This is the read door, and clock_banked (raw units, for save/restore) is the other one — two questions, two answers, one field.

⚠ **Half-open by construction, not by a clamp.** clock_advance leaves banked in [0, step) and clock_restore refuses anything else, so the division cannot reach 1.0 — which is what a caller lerping between two states depends on: an alpha of exactly 1.0 would draw the NEXT state one frame early, on the frame before the tick that produces it.

⚠⚠ **What a consumer does with it is a PRESENTATION decision and this file must not make it.** The textbook use is lerp(previous_state, current_state, alpha) — and dryopea measured it against two alternatives over one drive (tests/26_l5_the_alpha.loft, @M035):

* lerp(prev, cur, alpha) draws the step that has ALREADY happened, so it is **exactly one step behind, for ever** — 2.598 m here, because a step is 2/3 of a second; * cur + alpha·(cur − prev) is **exact to machine precision** while the velocity holds, and jumps a whole step's travel on the frame the mover starts or stops; * a first-order ease toward cur needs no previous state, lags between the two, and is the only one whose picture depends on the frames it was drawn on.

⚠ Three prices on three different axes, and which one is right is a function of how long the STEP is — so a clock that picked one would be wrong for every consumer whose step is a different length. This answers *where between two steps are we*; it does not answer *what should be drawn there*.

pub fn clock_drive(slow: TickClock, fast: const TickClock, n: integer) -> integer

Spend n steps of fast on slow, and answer the slow steps that bought.

⚠⚠ **The case is a strategy layer over a unit layer** — dryopea has the shape twice already (the camera steps at frame rate while the sim steps at 1.5 Hz, and plans/22 § LOD contemplates ticking distant things every N ticks). ⚠ **And it is the OPPOSITE of that plan's warning**: *bank progress, never multiply it* refuses to give a distant enemy N × dt of movement in one tick. This gives a slower CLOCK the time those N ticks took, and it still ticks one step at a time.

⚠ **Spelling it n * step at the call site is the shape of mistake tests/19_p1 records** — a count folded into a duration, wrong for 602 of the first 1000 n. Here it is exact, because both sides are integers; the door exists so that the next consumer does not re-derive it in seconds and find out the hard way, and tests/26_l4 § Composing by hand is exact says so out loud rather than letting this comment overclaim.

⚠ **A slow clock driven this way LAGS its driver by less than one fast step, and never leads.** The fast clock's own bank is time that has not become a tick yet, so it has not been handed on — which is the correct answer and not an approximation: the composed clock is exactly as far along as the ticks that actually happened.

⚠ **slow's own rate still applies.** A rate set on both clocks multiplies, which is a composition a caller asked for; set it on the one that faces the wall.

pub struct Timer { spent: integer, total: integer }

A one-shot duration and how much of it has been spent.

⚠ **Both integers, and total == 0 is UNARMED** — the zero-default that makes a partial struct literal correct everywhere this replaces a 0.0 seconds field. See the header.

pub fn timer_new() -> Timer

A timer nobody has armed.

pub fn timer_armed(units: integer) -> Timer

A timer armed for units — the factory, for a struct literal that wants one already running.

⚠ Enemy's pre-walk window is the case: an enemy is built already standing, so the field has to arrive armed.

pub fn timer_arm(t: Timer, units: integer)

Arm it for units, from zero.

⚠ **A duration of zero or less DISARMS rather than firing.** Both readings are defensible and this one is what the call sites already mean: a schedule authored with a lull of 0 sends the next wave immediately because nothing is running, not because a timer fired. ⚠ It also re-arms from wherever it was, so a boost pressed during a boost restarts the clock rather than extending it — the behaviour the float version had, stated rather than inherited.

pub fn timer_running(t: const Timer) -> boolean

Is this timer running — armed, and not yet fired?

pub fn timer_left(t: const Timer) -> integer

Base units still to go. Zero for a timer that is not running.

⚠ **The DOWN reading**, and it is a subtraction rather than a second field. helper.recover and Vehicle.boost_left were both stored this way and each carried its own epsilon; there is one number now and it is exact.

pub fn timer_spent(t: const Timer) -> integer

Base units gone by. Zero for a timer that is not running.

⚠ **The UP reading**, off the same number. TowerCharge.repair was stored this way — *seconds banked toward the rebuild* — because zero had to mean "nobody is working on it", and that is still what an unarmed timer says.

pub fn timer_total(t: const Timer) -> integer

The whole duration this timer was armed for, in base units.

pub fn timer_fraction(t: const Timer) -> float

How far through it is, in [0, 1) — 0.0 for a timer that is not running.

⚠ **The presentation door, and the one place a float appears** — a HUD showing a rebuild part-done, exactly bank_fraction's position one family over. Nothing in the simulation may read it: a comparison against 1.0 here is the epsilon problem walking back in through the display.

pub fn timer_spend(t: Timer, units: integer) -> boolean

Spend units of it. Answers whether THIS call finished it.

⚠ **It fires exactly once and then disarms**, which is the whole difference from bank_gain: the remainder past the boundary is DISCARDED, because a one-shot's residue means nothing afterwards. Carrying it would make the next arming shorter than the one before — measured in tests/26_l3 § A one-shot built on a bank, where a 5.0 s cooldown costs 8 ticks the first time and 7 the second.

⚠ A timer that is not running answers false and stays that way, so a caller may spend into it unconditionally — which is what vehicle_burn does with the cooldown every single tick.

⚠ Non-positive time is dropped rather than owed, for clock_advance's reason: time going backwards must not un-fire anything.

pub fn timer_cancel(t: Timer)

Stop it without firing.

⚠ **A door of its own rather than timer_arm(t, 0), because it is a DECISION** — the same argument bank_reset makes. A vehicle that respawns loses the boost it was in the middle of; a cooldown that is merely running keeps everything. Two behaviours, and the one that throws work away says so by name.

pub fn timer_restore_left(t: Timer, left: integer, total: integer)

Put a timer back from a DOWN reading — units left of total.

⚠ Clamped rather than trusted, for clock_restore's reason: left past total is a state timer_spend can never leave behind, and a left of zero or less is a timer that has already fired, which is an unarmed one.

pub fn timer_restore_spent(t: Timer, spent: integer, total: integer)

Put a timer back from an UP reading — units spent of total.

⚠ The other half, and the two are not interchangeable at the call site: emit.loft writes whichever reading its field was authored in, so a tower's rebuild round-trips as *banked* and a helper's recovery as *left*.