Cold Start
README.md
asc_clock — ASCENSION: the shared clock
The finale's spine. One pure function of part-local t produces every number
that crosses between the part's eight aspects. Nothing else in ASCENSION
reads raw t, wall time or a frame counter — the orchestrator evaluates this
module once per frame and hands the resulting table to every aspect hook.
Eight aspects reading the same numbers resolve into one scene. Eight aspects each keying off their own idea of the beat do not. That is the entire reason this module exists.
How you use it
Your hook is handed the bus. It is the third argument, always:
function M.update(ctx, st, ck, dt)
entity(st.id).localPosition = ctx.at(0, ck.seedY, 0) -- yes
-- local phase = t / 1.25 -- no. never.
end
build(ctx, st, ck) and enter(ctx, st, ck) get it too, evaluated at t = 0.
To read it outside the update chain, asc_clock.current() returns the last
published bus (nil before the part's first frame).
The bus — the brief's §3, and nothing else
Top level is exactly the §3 contract. Every value is hard-clamped to its stated range; a curve can be retuned but it cannot escape its bounds.
| field | range | what it is |
|---|---|---|
t | 0 … 35 | part-local seconds |
bar | 0 … 28 | t / 1.25, float. floor(bar) is the bar index |
phase | 1 … 7 | floor(bar/4) + 1 |
sunElev | 4 … 12 | degrees above horizon |
sunAzim | 118 | degrees. Constant for the whole part |
sunInt | 0 … 12 | sun intensity, engine units. Already multiplied by worldLight |
sunColor | {r,g,b} | linear |
skyMix | 0 … 1 | 0 = full daylight gradient, 1 = flat void black |
depth | 1 … 0 | camera submersion. 1 at y = −4.2, 0 at y ≥ 0, crosses 0 at t = 5.000 exactly |
alt | −4.2 … 900 | camera altitude in units |
rise | 0 … 1 | column raise envelope. 0 before 2.50, 1 at 12.50 |
colTopY | 0 … 900 | column top height, units |
strike | 0 … 1 | bolt envelope. Impulses on the resolved cue and at +3 / +6 beats, 12-frame decay each |
flicker | 0 … 1 | residual column glow, 11 Hz, live from the strike to 25.0, amplitude 1 → 0.25 |
ignite | 0 … 1 | fire coverage on the column's top 200 u. 0 → 1 across 25.000 → the opening |
apex | 0 … 1 | seed-opening envelope. 1 on the opening cue, 0 by 27.500 |
ringR | 0 … 600 | additive shell radius in units. Passes the locked camera (116.3 u) at t ≈ 26.9 |
seedY | −4.2 … 780 | seed world Y, units |
seedInt | 0 … 2000 | seed point-light intensity |
worldLight | 1 … 0 | global light authority. 1.0 until 27.5, 0.0 at 30.0, stays 0 |
expEV | −1.4 … +1.2 | exposure offset, EV |
cue | integer | index into the composed cue track: how many cues have passed |
band | {sub,low,mid,high} | STFT band levels, 0 … 1, raw |
flux | 0 … 1 | spectral flux, raw |
worldLight is the most consequential number in the part. Every light you
create multiplies by it, including yours. It is how the seed's opening takes
the daylight with it, and from t = 30.000 nothing in the scene is lit — only
emissive.
aux — §5 / §6 values, not the §3 contract
Several aspects would otherwise re-derive the palette and the light table by
hand and drift apart. Those live under ck.aux, which is derived, documented
here, and equally a pure function of t:
sunDir · skyZenith · skyHorizon · skyMid · skyVoid · skyCeil ·
horizonTight · midUp · zenRate · antiSun · sceneExposure ·
gradeSat · gradeBri · ambInt · ambColor · bloomThreshold ·
bloomContribution · letterbox · phraseMix · phraseFrom · phraseTo ·
strikeT · openT · cueKind · cueT · globalT · handoffNdc ·
handoffHex · barNum · pointAmp · quality · autoLadder · frameMs ·
gpuMs · shadowStrength · sunDiscScale · emberLights · dustLitByFire ·
typeReveal · typeGlow · torTrack · torTrackN · torSpinAccum ·
torTear
The last two are TORNADO's amendments, added under §8's append-only right and read by that aspect alone.
torSpinAccum (0…27.5) is ∫ torSpin dt in REVOLUTIONS — the rotational
twin of windAccum, and it exists for the same reason. torSpin is an angular
RATE, and a fill shader handed a rate has to integrate it to get an angle;
integrating per frame would make every seek a different tornado, which §5 forbids
in as many words. It is a cumulative table built once at module load and sampled
at t, so the column's rotation is a pure function of t and a held frame is
byte-identical however often it is captured.
torTear (0…1) is the collapse's loss of axis, which ignite does not
carry. ignite falling 1.00 → 0.25 → 0 across 25.0 → 28.2 is the fire going out
TOP-DOWN — the same fire front that swept up on ignition, sweeping back down.
torTear is the other half: how far the three flame ropes have separated from
the axis and drifted downwind. The two are genuinely independent, and ignite
alone cannot tell t = 12 (rising through 0.1) from t = 27.8 (falling through
0.1), which are not the same picture at all.
skyVoid is the colour skyMix folds the gradient toward. §3 calls
skyMix's far end "flat void black" without saying what black; §5 rule 2 does:
"Every dark value carries blue. Void is #03080C, never #000000, until
t = 33.75." So the void is on a curve — §5 P4's flat #060A12 while the world
still has light in it, #03080C once the world is out, real black only across
t 33.75 → 34.00 — and any aspect that folds something toward the void folds it
toward this, not toward a constant of its own.
skyMid is the crossover colour §5's two ends need between them, solved by
asc_palette against rule 1's two failure conditions. An aspect drawing its
own sky-adjacent medium should read this rather than average the two ends —
averaging them is what puts a neutral on screen, and the proof is in
asc_palette's README.
The sky rows are DISPLAY values. Everything else on the bus is linear.
skyZenith / skyHorizon / skyMid / skyVoid are sRGB display fractions
— exactly what §5 wrote — and M.C / sunColor / ambColor are linear.
That split is not an inconsistency, it is the two kinds of surface:
LIT surface → linear. Use M.C, or Palette.hexL("#…"). The render and the
tonemap decide what the value becomes, which is the point of authoring in
linear, and §5's header ("convert to linear at load") is the whole instruction.
EMISSIVE surface that fills frame → display. A sky dome, a volume's emission
term, a full-screen pass: §5's hex is what the pixel must READ AS, and getting
there means inverting the post chain. Do not hand-roll that. asc_palette owns
it: ceilFor, invAces, unSaturate, and the ramp. The dome shader
(preseed/shaders/asc_sky) is the worked example — it blends in display space
and applies the inverse once at the end.
Three numbers on the bus exist so a consumer can do that without re-deriving anything:
sceneExposure— everything multiplicative between a scene value and the tonemap's input (BASE_EXPOSURE · 2^expEV · gradeBri). Divide by it.gradeSat— the colorGrade pass's saturation, to un-apply.skyCeil— the brightest display value an emissive sky-scale element may ask for, derived from §2's bloom threshold and this frame's exposure. §6 lets exactly one element per bar exceed 1.0 linear and the sky is never it. Scale the whole triple to fit the ceiling, never each channel — a per-channel clamp pulls the brightest channel toward the others and walks the colour off its hue family.
Feeding a dark palette row as plain linear loses it outright: ACES's slope near
zero is 0.03/0.14, so #060A12 arrives at 0.0018 and leaves at 0.0004, a ninth
of one 8-bit step. Measured, that came back 82 % literal #000000.
The palette gate
asc_clock.checkPalette() replays the dome's blend law against the triples the
dome actually receives at each of the sixteen judged timestamps and reports §5's
two hard rules as numbers. Run it after changing any curve that touches the sky.
| detector | budget | now |
|---|---|---|
| magenta family (green below both others) | 0.00 % | 0.00 % at all 16 |
| (max−min)/max < 0.25 | ≤ 2 % | 0.00 % at 11, 1.09–1.39 % at 5 |
| (max−min) ≤ 20 | ≤ 2 % | 0.00 % at 11, 0.39–1.80 % at 5 |
The magenta budget is zero because that family is exactly avoidable. The
low-saturation budgets are not zero and cannot be: §5's two ends have a
display-space chord that passes within one unit of the achromatic axis, so some
sample on any continuous ramp between them sits near neutral. asc_palette's
README carries the arithmetic and the on-screen figures, which are larger than the
function's because a level camera compresses many screen rows into a small range
of elevation near the horizon.
phraseMix is the phrase-boundary cross-fade: it runs 0 → 1 across the 0.75 s
centred on each boundary (t = 5, 10, 15, 20, 25, 30) and reads 1 everywhere
else, so a grade owner can lerp phraseFrom → phraseTo.
M.C on the module is §5's palette, authored in sRGB hex and converted to
linear once at load. Use it rather than re-typing a hex string.
The two cue-fired events
The strike and the seed's opening fire off the score, not a timer.
resolveCues() snaps their nominal beats (15.000 and 26.250) onto the nearest
cue within ±0.30 s, once, at part build, preferring the kinds that carry a
downbeat. Against the current score they resolve to a kick at 15.000 and a
motif at 26.250 — exactly on the beat, and now genuinely tied to the audio:
if the composer moves the drop, the image moves with it.
The envelope around the resolved instant is a pure function of t, so a held
preview frame stays byte-identical however many times you capture it.
The ENDCARD handoff
asc_clock.handoff() returns everything that crosses the part boundary:
{ ndc = { 0.0, 0.060 }, hex = "#FFF0C8", color = { … linear … } }
Two numbers and a colour. No buffer, no texture, no starfield.
The debug overlay
Off by default. It prints the whole bus on screen, live, so you can check your
curves against the spine's. require is environment-local, so an execute
call gets its own copy of this module and cannot reach the running part's —
arm it through the engine-global request instead:
require("preseed.asc.asc_clock").requestOverlay(true) -- and (false) to clear
The part picks it up within a quarter of a second. Once it is up,
ui.getScreenTree("asc_clock_bus") reads the printed values back, so the
overlay is a readout as well as a picture. It costs no measurable frame time
while on, and nothing at all while off.
§10's cut ladder — read aux.quality, never decide for yourself
§10 fixes six cuts in a strict order and assigns them to no aspect. There is
one position, it is on the bus as ck.aux.quality (0 = nothing cut, 6 =
every rung spent), and the rule is: if your system is named at or at a lower
rung than the live position, drop to your cut setting. Nobody negotiates and
nobody measures their own cost.
if ck.aux.quality >= 3 then steps = 30 else steps = 40 end -- rung ③
M.CUT_LADDER is the ordered list. The position moves automatically, and it
moves on the only signal that is both correct and affordable.
Not the frame delta. Wall-clock frame-to-frame time is work plus however long
the engine waited to present, and on this machine that is 99 ms of present/idle
vsync wait around 4 ms of real work. An earlier pass drove the ladder from it,
spent all six rungs within seconds of boot and never gave one back, because the
release threshold is a frame time a compositor-throttled window cannot reach.
profiler frame, the tree that does separate work from pacing, costs 39 ms per
sample — twice the budget it would protect.
profiler gpu. It reads resolved GPU timestamp queries out of a ring the
renderer already fills, is compositor-independent by construction, and measures
at 0.21 ms per call. Sampled once every 30 frames it costs 3.5 µs a frame —
0.02 % of budget. Its header line carries the attributed pass total, which is the
number §10's own table is written in.
The ladder arms on a five-sample median at or above 15.5 ms, spends one
rung at a time in §10's order, and gives a rung back only after four consecutive
samples at or under 13.0 ms, so it cannot oscillate. Both numbers are on the
bus: aux.gpuMs (GPU timestamps, the ladder's signal) and aux.frameMs
(wall-clock, including vsync wait, labelled for what it is).
asc_clock.setQuality(n) still overrides by hand, and setAutoLadder(false)
pins it — a measurement can be wrong and a director cannot be argued with.
asc_clock.sampleCost() takes one sample on demand and returns
{ gpuMs, frameMs, quality, auto, ladder }, for the moment a cost claim has to be
backed by an artifact rather than by prose.
No rung on §10's ladder touches §10's never-touch list, and that is checkable rather than asserted. The list is the column's LIGHT march, camera motion blur, the type's legibility and the sun's shadow cascades. The six rungs are the fire volume's resolution and step count, the ember point lights, the column far shell's VIEW steps, the cloud deck's step count, the SPH particle count and DOF quality. The two column entries are different marches.
World conventions
Published on the module so nobody re-types them: BAR BEAT DURATION
BARS SUN_AZIM LETTERBOX COL_BASE_R COL_WAIST_R COL_WAIST_Y
ANVIL_R ANVIL_Y0 ANVIL_Y1 COL_TOP SEED_LEN SEED_START_Y
SEED_TOP_Y CAM_TOP_Y CUT_T MAX_POINT_LIGHTS MAX_PARTICLES.
What expEV is for
§3 gives it the range −1.4 … +1.2 and §4 fixes two keys: 0 at t = 27.500 and −1.4 four bars later. What the positive headroom is FOR is fixed by §6 and §2 together, and it is not a look.
§5's bright phrases ask the sky for #FF9A5A and #FFC98A, whose brightest
channel is display 1.0, and the scene-linear value that lands on display 1.0 is
7.24 — five times §2's bloom threshold. Exposure is what divides the value the sky
has to ask for: at expEV = +1.2 the horizon arrives at 1.35 linear, under the
threshold, and reads within 4/255 of its hex. At expEV = 0 the ceiling lands
27/255 short and §5's row is unreachable.
So expEV is high exactly where §5's palette is bright — +1.2 across the breach
(t 5–12.5) and the breakout (t 23–26.25), 0 through the blackout, down to −1.4 as
the world goes out. It is also the part's only real exposure move: the sky
cancels it (the sky lands on §5 whatever it does), so from the blackout's 0.0 to
the breakout's +1.2, every element that is not the sky gets 1.2 stops brighter.
That is §8 H4's "the eye should have to adjust", as a number.
Owns
- every scalar on the bus and the keyframes behind it
- the STFT binding and the cue-track resolution
- the phrase-boundary cross-fade — both the weights and the values
(
aux.gradeSat/aux.gradeBri), becauseaux.sceneExposureisgradeBritimes the tonemap's exposure and two files cannot own half a product each - the §5 palette → display seam (
asc_palette) and the gate over it - the ENDCARD handoff
- the debug overlay
- §10's cut-ladder position and the GPU signal that moves it
Does not own
Any visual element. This module spawns nothing and draws nothing but the overlay.