# asc_stars — ASCENSION: the star field and its reveal
Controls how far the stars are brought up out of the dusk as night falls.
The stars are **shader-resident**: `star_layer` in
`preseed/shaders/asc_sky.shader` draws two jittered twinkling layers plus a
faint milky band on the sky dome, gated by a `starGate` that reaches lower
down the sky as `night` rises. What this module owns is the runtime ramp that
scales them.
## Owns
- `REVEAL` — the daylight floor and the night gain for star brightness.
- The `stars` property on the shared `asc_sky_mat` material. That property, and
nothing else on that material.
## Reads from the orchestrator
`sun`, the signal published by asc_sky:
| field | use |
|---|---|
| `sun.nightMix` | 0 = day, 1 = full night — drives the reveal |
| `sun.skyGuid` | which material to write |
## Boundaries
- `night` and `game_t` on `asc_sky_mat` belong to **asc_sky**. Writing them
here would fight it every frame.
- The star geometry, twinkle and milky band live in `asc_sky.shader`, which is
shared with the sky. Changing the *look* of the stars is a shader edit;
changing *when they appear* is this file.
- Known limitation in that shader: `star_layer` cells the sky on
`atan2(x, -z)` and the cell grid does not wrap at the ±π meridian, so there
is a seam on the −Z axis — which the final two shots tilt toward.
# asc_sky — ASCENSION: sky dome, sun and atmosphere
Controls the sunset itself: the sky dome the finale is seen against, the scene
sun and ambient lights, and the elevation curve that drives the whole part's
day → dusk → night transition.
This is the **publisher** of the part's master signal. `update` returns
```lua
{ elev, nightMix, dir, skyGuid }
```
and the orchestrator hands that table to asc_stars, asc_terrain and
asc_clouds. Because everything downstream keys off `ELEV`, retiming the
sunset here retimes the star reveal, the ridge haze and the cloud palette
together — which is the intent. Its `update` hook is mandatory.
## Owns
- `ELEV` — sun elevation across the 35 s, the single source of the transition.
- `SUN_INTENSITY` — scene brightness, keyed separately from elevation.
- The `asc_dome` entity: an inverted 900-unit sphere carrying `asc_sky_mat`.
- Suppressing `ProceduralSky.syncSunToLight` so the engine's sky bridge cannot
re-orient the light out from under the animated sunset.
- `Lighting.applySetup` and the sun / ambient `Light` components it creates.
- The `night` and `game_t` properties on `asc_sky_mat`.
## Reads from the orchestrator
Nothing but `ctx`, `t` and `dt`. This module is upstream of every other
aspect.
## Boundaries
- The `stars` property on `asc_sky_mat` belongs to **asc_stars**. This module
writes `night` and `game_t` only.
- The sun disk, halo and horizon glow are drawn by `asc_sky.shader` from
`input.light_direction` — the shader takes no sun uniform, so the dome's sun
follows the `Light` written here *through the renderer*. Moving the sun means
moving that light.
- `Lighting.applySetup` creates the `sun` and `ambient` entities that the
`entity.find` calls immediately below it depend on. Those lines are one unit;
separating them leaves both ids nil and the sunset silently frozen.
# 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:
```lua
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:
```lua
{ 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:
```lua
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.
```lua
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`), because `aux.sceneExposure` is `gradeBri`
times 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.
# asc_tornado
ASCENSION's aspect 3 — the fire tornado. The hero, and the largest single line in
the part's frame budget.
Six states, all of them on the clock bus and none of them in this module's own
memory: arrival, ignition, approach, spin peak, tear, gone. Three things carry
them.
**The volume.** A 128 × 256 × 128 emissive box spanning 180 × 280 × 180 u, sited
on `torX`/`torZ` and standing on the *deformed* sand — the base height comes from
DESERT's published `duneGroundAt`, not from y = 0. It is filled by `asc_pyre` and
raymarched by `preseed.volume` into its composite at post priority 5, which is
what puts the column *inside* the storm: STORM's medium composites at 6, so its
extinction and inscatter apply over the fire rather than under it.
**The sparks.** One camera-facing quad swarm on `asc_spark`, flown entirely in the
vertex stage off bus scalars. No per-frame CPU.
**The lights.** One base point light on `fireKey` plus `aux.emberLights` of eight
along the column, carried around the axis by the same accumulated angle the fill
turns by. Nine, and not a tenth; none of them casts a shadow.
## The three calibrations
`EMISSION_GAIN` compensates the marcher, which delivers `Σ trans·emission·(1−e^−στ)`
rather than the emission itself, so a ray crossing the core at alpha 0.93 lands
93 % of the authored radiance.
`DISPLAY_CEIL` is the brightest byte this post chain actually delivers, measured
rather than derived: the vignette and grade between the tonemap and the
framebuffer cost about 14 %, so asking for a red channel at full display clamps
it *and* clamps green with it, and the core comes back a neutral grey. The whole
accent row is scaled by the real ceiling before the tonemap is inverted, which is
what keeps the hue when the magnitude has nowhere left to go.
`KEY_GAIN` and `BASE_RANGE` turn the bus's author-facing `fireKey` curve into a
point light's intensity and reach. They are calibrated at the P4/P5 stations,
where `fireKey` is at its highest and the near sand is closest — a calibration
taken at the P6 crane, where the curve is already falling, leaves the near dunes
lit to a neutral cream in exactly the beats the focal hierarchy makes them
texture.
## The two bus amendments
`aux.torSpinAccum` — the integral of `torSpin` in **revolutions**, the rotational
twin of `windAccum`. A fill shader handed an angular *rate* has to integrate it to
get an angle, and integrating per frame would make every seek a different tornado.
`aux.torTear` — the collapse's loss of **axis**, which `ignite` does not carry.
`ignite` falling is the fire going out top-down; this is the three ropes drifting
apart. `ignite` alone cannot tell t = 12 (rising through 0.1) from t = 27.8
(falling through 0.1), and those are not the same picture.
## The cut ladder
`preseed/volume` marches into ONE shared 1600 × 900 render target for the whole
demo, and its size is a module constant that five other parts also march into.
Rung ① is therefore spent on the other factor of the same product — samples per
ray — rather than on pixels: 2.00 u × 172 steps drops to 3.10 × 112, which is
about 40 % of the march's work for a resolution the composite does not have to be
taught about. Rung ③ takes the step count down again, drops the fill's finest
noise band, *and* takes the volume to 96 × 192 × 96, which needs a re-spawn and
gets one, exactly on the crossing.
## Switching itself off
Outside t ∈ [10, 28.2] the fill is dispatched exactly once more, to clear the
texture — a 3D texture holds whatever it was last filled with, and a seek out of
the column's window would otherwise leave a frozen tornado standing in the desert
— and the march is then handed a **degenerate AABB**, so every ray's box
intersection is empty and the marcher early-outs on its first branch. That is how
this aspect disables its own composite without reaching into `preseed/volume`'s
private pass key.
# asc_grade — ASCENSION: the part's post treatment
Controls what the finale is seen *through*: bloom, colour grade, tonemap,
vignette, and the letterbox bar height.
## Owns
- `GRADE` — the post-process spec handed to `Fx.grade`. Keys set to `false`
(fog, chromatic aberration, motion blur) actively remove those passes;
absent keys are left alone, so this spec is a statement about the whole
stack, not a patch on it.
- `BARS` — the letterbox fraction.
Applied once, on `enter`.
## Reads from the orchestrator
Nothing. This aspect has no `update` hook — it does not vary over the part.
## Boundaries
- The **fades** are not here. `fadeIn = 1.5` and `fadeOut = 3.0` are fields on
the part table and the director drives them, through the same always-on
`demo_fade` pass this module sets the bars on. The director owns
`Fx.setFade`; this module owns `Fx.setBars`.
- `Fx.grade` runs once at entry and is never touched again, so one exposure and
one bloom setting serve both the blazing golden-hour opening and the
near-black star field 30 s later. `sun.nightMix` is the signal that already
describes that transition if the grade should ever follow it — taking it
would mean adding an `update` hook and having the orchestrator pass `sun`.
- The clouds composite at priority 5, ahead of the grade, so the deck feeds
bloom and tonemap rather than being layered over them.
# asc_dust (module)
ASCENSION's aspect 4: THE SANDSTORM. The air, and the air is most of every frame
between t = 5 and t = 28.
Three things, all one storm:
* **The medium** — `preseed.asc.asc_dust` (the compute shader) marched at
800 × 450 and composited by `preseed.shaders.asc_dustmix` at post **priority
6**, after the fire's own composite at 5, so the storm's extinction and
inscatter are applied OVER the column and the column sits IN the storm.
* **The particulate** — up to 45 000 grains on `preseed.shaders.asc_grain`, one
mesh in a tile that re-tiles around the camera, flown entirely in the vertex
stage. The module writes twenty material scalars and moves one entity; the
swarm does the rest.
* **Three point lights** for the lifted-sand glow at the tornado's base in P5,
≤ 118 each, none casting. Three of the part's twenty, and never a fourth.
## What it reads
Only the bus: `dustDen` `visRange` `windDir` `windStr` `windAccum` `gust`
`scour` `torX` `torZ` `torR` `torH` `ignite` `worldLight` `sunInt`, and
`aux.dustLitByFire` `aux.skyMid` `aux.skyHorizon` `aux.skyVoid` `aux.sunDir`
`aux.ambInt` `aux.sceneExposure` `aux.gradeSat` `aux.torSpinAccum`
`aux.quality`. Plus DESERT's published `ctx.data.duneGroundAt`, which is where
the swarm's ground datum comes from.
It publishes no bus scalar of its own.
## The two curves it authors
`uFront` (the face's along-wind position) and `topH` (the body's height) are
this module's, keyed against `ck.t` on the beat sheet's own beats. `dustDen` is
non-monotone after t = 12 — it falls through the collapse — so a face driven
directly off it would arrive and then retreat back through the camera. The
DENSITY behind the face is `dustDen` verbatim, so the clearing across
25 → 28.5 is the bus's beat and not a second curve.
## Determinism
Advection is `windAccum` and `aux.torSpinAccum`, both cumulative tables sampled
at `t` rather than per-frame accumulations. There is no persistent state here a
frame depends on: seek to the same instant a hundred times and get the same
storm. Verified by capturing t = 17 twice across a forced part rebuild and
comparing the two PNGs byte for byte.
## The two targets are never destroyed
Fixed size, created under stable keys, and deliberately left alive at `exit` —
a mutation is queued for the next frame, so on a re-entry the exit's destroy
lands after the new build's create and the pass spends the whole part sampling
an unresolved texture, which the renderer serves as **white**.
# asc_finish
ASCENSION aspect 5 — FINISH. The title in the sand, the three credit cards, and
the last frame's handoff.
## What it owns
| job | instrument | window |
|---|---|---|
| `RAISING PRESEED` lying flat in the pan | `font.textMesh` on `Anton`, extruded, rendered through DESERT's sand shader | `aux.typeReveal` 26.25 → 32.40 |
| three credit cards on the left rail | `Text3D.component`, billboard, camera-pinned | 26.40 → 32.40 |
| the endcard point | `asc_point`, untouched | `aux.pointAmp` 33.60 → 35.00 |
`asc_point` is not this module's code and this module does not write to it. The
handoff — `#FFF0C8` at NDC (0.000, +0.060) at priority 210 — is verified here,
not authored here.
## The title is geometry
`Text3D.component` is a quad. `man Text3D`: *"the quad auto-sizes to fit the
text"*, with fields `color` / `outline` / `background` / `billboard` and no
normals. §7 H5 asks for *"sand catching light … on its relief edges"*, which a
rasterised alpha-blended billboard cannot be at any parameter setting. So the
set line is tessellated from true glyph outlines and extruded — the same
mechanism `preseed/title3d.module` already ships in this demo — and the cards,
where a flat card genuinely is the object, stay on `Text3D.component`.
## The geometry, and why it is baked
`font.textMesh` lays glyphs in the XY plane and extrudes along +Z. The title
lies flat, so the whole thing is rotated once at build time:
```
world_x = glyph_x · BASE_DIR.x + glyph_y · BASE_FWD.x
world_y = glyph_z -- the extrusion becomes the riser
world_z = glyph_x · BASE_DIR.z + glyph_y · BASE_FWD.z
```
`BASE_DIR` is the ground-projected camera-**right** at the S6 lock and
`BASE_FWD` the ground-projected camera-**forward**. That yaw — 18.43° off the
pan's own x axis — is not decoration: the locked camera stands at part-local
x = +60 and looks at x = 0, so a set line laid along +x reads visibly tilted.
Baking it into the vertices leaves the entity transform a pure translation,
which is what lets `localScale.y` mean exactly one thing: how far the relief has
risen out of the sand. The reveal is that scale, driven by
`max(aux.typeReveal, aux.typeGlow)`.
Normals get the same rotation with the inverse of the non-uniform scale, so a
side wall's normal stays horizontal and a top face's stays vertical. A stale or
mis-scaled normal here reads as flat lighting rather than as broken geometry,
which is the worst available failure.
## The letters are DESERT's sand
The material is the same shader as the pan, and every frame the whole property
set is mirrored off the live `asc_sand_field` material with
`renderer.material.describe`. The letters are therefore lit by DESERT's
elevation-normalised key with DESERT's fill, ripple, chroma ceiling and haze —
identical to the sand they lie in. The only thing separating them from the pan
is relief: a cast shadow and two lit side walls.
That is deliberate and it is also the risk. The letter's top face is coplanar
with the pan and the same colour, so the title is read almost entirely off its
shadow. The measured contrast is in the lane report; the named escape hatch if
it fails is §10 item 5 — standing letters half-buried at the same footprint.
From `aux.typeGlow` (31.20 → 32.40) the same material's dome term is lifted
toward `#FFF0C8` and `world_light` is held against the world going out. There is
no emissive channel and no second pass: the glow is the sand being lit by a sky
that is now the colour of the tornado's last breath.
## The cards
Pinned in the camera's own basis at `CARD_DIST`, so the rail is where the rail
is whatever the pose does — and from t = 28.000 the pose is locked anyway. Three
rail slots, so the 0.3 s hand-over between two cards reads as a hand-over rather
than a collision. Copy is §0's, unchanged: `CODE + DIRECTION — CLAUDE`,
`ENGINE — ZERO`, `ASSEMBLY SUMMER 2026`. The em dash is a real glyph in `Anton`
— it lays 1.962 wide against the hyphen form's 1.710, measured, not assumed.
An inactive card is parked 4000 u under the world rather than hidden with a
flag: `setHidden` on a spawned mesh entity has been measured in this part not to
change the rendered frame, and a card still drawn after t = 32.40 fails §7's
endcard gate.
## Bus scalars read
`aux.typeReveal` · `aux.typeGlow` · `aux.pointAmp` (read by `asc_point`) ·
`phraseMix` · `t` for card scheduling only. Nothing else. No scalar was added.
## Introspection
`M.setLineCorners()` returns the four part-local corners of the set line so a
capture can be checked against a number rather than an impression.
# asc_dunefield
ASCENSION's dune sea — everything solid in the finale. It replaces `asc_terrain`,
whose four ridge bands were the retired sunset valley.
Four pieces of geometry. One **deforming field**: a 224 × 224 grid over 1400 world
units (50 176 vertices, ~99 k triangles) whose positions and normals are written
every frame by `asc_dune.computeShader`, a `kind: vertices` pass. Three **static
parallax ranges** at z = −880 / −1500 / −2400, silhouettes with no deform cost and
no shadow cast, because a 1400 unit field ends at a visible edge and the horizon of
a dune sea has to be dunes. And the **playa** they all stand on, a 9000 unit plane
half a unit under the field's tapered rim, so the horizon is the real horizon
rather than the field's own boundary.
The deform's height law is the sum of four terms and a pure function of the clock
bus: transverse dunes with a gentle windward ramp and a steep lee slip face; the
migration of that whole train downwind by `windAccum × CREST_RATE`; `scour`
lowering and rounding the crests as the storm planes them off; and the tornado's
passage, swept along the 24-sample ground track up to `torTrackN` as a trench with
twin berms and radial rake. There is deliberately **no accumulation buffer** — the
director seeks constantly, and an accumulator would make every seek a different
desert and a held reveal frame irreproducible.
Across t 26.25 → 29.0 the field is driven flat inside `TYPE_PAN`
(x −170…170, z −250…−60) with a 24 unit feathered edge, on `aux.typeReveal`. That
is FINISH's stage; this module builds it and does not draw the letters.
## The one named cross-lane resource
`ctx.data.duneHeights` is the handle of the f32 buffer the deform pass writes its
heights into, and `ctx.data.duneHeightsMeta` is `{ n, w, cell }`. The tornado sites
its base on the deformed sand and the storm lifts its grains off the real crests by
reading those two, rather than duplicating this module's noise and drifting from
it. Vertex `i` is `iz * n + ix`.
## Checking it
`M.check(st)` answers the two questions a deform pass has to answer about itself
from the engine rather than from prose — `renderer.mesh.boundsSource(guid)` reports
`"compute"` once a pass has written the vertices, and `ref:status()` reports
whether the dispatches actually ran, since a dispatch call returning true means
queued. `M.sampleHeights(st)` starts a readback of the heights buffer.
## Bus scalars it reads
`windDir` `windStr` `windAccum` `scour` `aux.torTrack` `aux.torTrackN`
`aux.typeReveal` `aux.quality` `worldLight` `visRange` `aux.skyHorizon`
`aux.skyVoid` `skyMix` `aux.sceneExposure`. It does **not** read `gust`: a dune does
not wobble at 3 Hz.
# asc_shots — ASCENSION: aspect 2, CAMERA & LENS
One spline, one lens, one cut. Every camera value in the part comes out of
`M.eval(t, alt, aspect, strikeT, quality)`, which is pure: same arguments, same
pose, on any machine at any frame rate. `M.update` is the only thing that writes
DemoCamera.
## What crosses to other aspects
The table `M.update` returns, and nothing else:
| field | what it is |
|---|---|
| `pos` `rot` `fov` `near` `far` | the pose the frame was drawn with |
| `fovAuthored` | §4's FOV column — see "The FOV the engine will not draw" |
| `alt` `depth` | for aspect 1 to **validate** the bus against the camera, not to stage against (§4) |
| `r` `azimuth` `pitch` `roll` `nx` `cut` `locked` | the pose, decomposed |
| `focus` `focusSubject` `fStop` `focal` `cocFar` `shutter` `dof` | the lens |
Consumed from the bus: `ck.t`, `ck.alt`, `ck.seedY`, `ck.aux.strikeT`,
`ck.aux.quality`. Nothing else, and no raw time.
**A card rack is one line from aspect 8, no require:** set
`ctx.data.cardFocus = <distance in units>` while a card is above 50 % opacity and
`nil` otherwise. The lens racks to it and opens to f/2.8.
## Framing tools, callable from an `execute`
The subjects of this part mostly do not exist yet, so framing is checked by
projection rather than by eye:
- `M.project(t, alt, worldPoint, aspect?, strikeT?)` → `{ ndc, visible, inActiveFrame, dist }`.
`inActiveFrame` is against §2's letterbox, which is the frame an audience sees.
- `M.projectDir(t, alt, dir, …)` → the same for a direction at infinity. Feed it
`ck.aux.sunDir` to find out where the sun lands.
- `M.axisNdc(t, aspect?, strikeT?)` → the column axis's composed NDC, cut folded in.
- `M.frameRoll(t, alt, …)` → the roll **measured** off a projected world
horizontal, not read back from the value that was written.
- `M.lockSpan(alt, aspect?)` → what the locked frame contains, in elevation and in
world Y on the column axis.
- `M.altCheck(altOf)` → §4's y column against the altitude the camera flies.
- `M.colRadius(y)` / `M.columnFocus(r, y)` / `M.farCoCpx(...)` / `M.POS` / `M.refAlt`.
## The channels
`POS` is §4's key list, interpolated in **cylindrical** coordinates so the twenty-bar
sweep reads as an orbit rather than as a series of chords. The camera's **Y is
`ck.alt` off the bus every frame** — never a key here — so `depth` crossing zero
at t = 5.000 is true of the camera and not merely of the clock. §4's own y column
stays in `POS[i].y` as a cross-check.
`PITCH` is an absolute aim elevation. `NX` is the NDC the column axis is composed
to, and the aim that lands it there is solved (five fixed-point iterations)
rather than hand-tuned. `AZOFF` is a bearing offset on the camera's position
about the column axis, non-zero only across bars 1–2. `ROLL` is §4's +4° through
the blackout. `FSTOP` is the aperture.
**The lock is structural.** Every channel is evaluated at `min(t, 25.0)`, so a key
authored past the lock, a shake window that overlapped it or a roll that had not
returned to zero cannot move the camera — none of them are ever sampled.
Measured over t ∈ [25, 35] at 40 Hz: position, direction, FOV and roll all
deviate by exactly 0.0.
## The one cut
§2 allows exactly one, a 3° angle change, and it fires on `ck.aux.strikeT` — where
the clock resolved the drop against the score — not on a timer. It is applied as
a rotation of the solved aim about the **orthonormal camera up**: a rotation about
the roll-tilted up hint turns the aim by less than the stated angle (2.887° for an
authored 3°, measured). The total aim change across the cut frame is **3.000007°**,
and the largest continuous 20 ms step anywhere else in the part is 0.880°.
`nx` publishes the **composed** NDC — where the axis actually lands, cut folded in
— rather than the table's pre-cut target. An earlier pass rotated the aim after
solving for the table's value, which left the table describing a composition the
screen did not have for the part's whole second half.
The shutter is closed for the frame the cut lands on.
## The FOV the engine will not draw
**§4's FOV column does not reach the screen in this engine build.** Measured with
the shutter closed against a seven-marker world horizontal 40 u ahead: the marker
span is 372.1 px at 1280×720 with `Camera.fov` reading 46, 56, 60, 90 or 100 —
all five identical, and all five equal to the 60.0° prediction of 372.4 px. The
value reads back correctly from the component field, from
`layers.active.camera.fov` and from the `camera.set` tool; only the raster
ignores it. `a2_r2_fov_probe_100.png` is that frame at `fov = 100`.
So the module composes against `M.FOV_DRAWN` (60.0) and writes that one constant
to the camera. Two reasons, both load-bearing:
1. **Composition.** Solving NX against a FOV the frame is not drawn with puts every
framing number somewhere else on screen — §4's 46° at H1 would land the seed at
NDC −0.243 instead of §8's −0.333.
2. **The shutter.** `@frame.camera` DOES track the written value. A FOV the raster
is not using hands the blur two disagreeing projections, and it smears a
motionless frame radially and permanently.
`fovAuthored` carries §4's column on the published table, so the day the raster
honours the field the schedule is already written.
## The lens
`preseed.asc.asc_lens` is this part's lens — defocus, then the shutter. Its own
header carries why neither shipping system could be driven. `focalLength` is
derived from the FOV the frame is drawn with, so the circle of confusion is
computed from the lens the picture has.
Focus tracks the seed for t 0–2.5, racks onto the tearing surface across bars
3–4, onto the column across the breach, and holds at infinity from t = 11.5. A
card rack overrides all of it. **Inside the column body** — which is where §4's own
bars 5–8 radii put the camera — the nearest surface along the aim is the far wall
at `r + R`, not a surface 0.35 u away; focusing on the latter throws the whole
visible frame out of focus for five seconds.
Defocus is switched off (`dofOn = 0`) from t = 11.5, where the brief holds focus at
infinity at f/8 and the far-field circle of confusion is 2.7e-5 mm. From the lock
both halves are off and the feature is released outright.
`M.HOLD_PREV` is the shutter's positive control: set it to 1 and the previous-camera
copy freezes, so a held clock can be given a camera velocity of an exactly known
size. Ships at 0.
## §10's ladder
Rung ⑥ (DOF quality high → medium) is the only rung this aspect owns:
`taps` 24 → 12 at `ck.aux.quality >= 6`. Nothing else here reads the ladder.
## Brief-internal contradictions, escalated rather than fudged
1. **§4's camera is inside §2's column for bars 1–8.** §2 puts the base radius at
8 u; §4 puts the camera at r = 0.97 (bars 1–2), 2.61 (t = 5), 5.21 (H2, where
the column's radius is 7.95) and 7.81 (t = 7.5). It only clears the body at
r > R around t ≈ 8.4. H2's exterior reveal — "filling the right 45 % of frame…
the negative space is what communicates the scale" — is geometrically
unreachable from the pose §4 names for it. `M.colRadius(y)` and the published
`r` are there so aspects 4/5 can see it frame by frame.
2. **The locked frame cannot hold both the seed and the horizon.** From
(52, 862, −64), r = 82.46: the seed at y = 780 is at elevation −44.8° and the
anvil top at y = 900 is at +24.7°, a 69.5° span that no lens in §4's column
covers. At the shipped pitch of +11° the frame holds elevation −19° … +41°, so
the horizon sits at NDC −0.337, the anvil top at +0.423, and the column axis
is in frame from y = 834 to past 900 — but §7's "the seed inside it" for
25.0–26.25 is 45° below the bottom edge. `M.lockSpan()` reports all of it.
3. **§4's y disagrees with the bus at two keys** — −4.15 vs −4.20 at bars 1–2, and
−0.30 at t = 5.000 against a bus altitude of 0.00 while the same row fixes the
waterline at t = 5.000 exactly. The bus wins; `M.altCheck()` reports both.
4. **"Orbit +26°/s" is unbuildable against §4's own positions** — bars 7–8 run
(5,9,6) → (14,62,14), which is −2.1°/s of azimuth. The positions are honoured
and interpolated cylindrically so the motion reads as an orbit; the rate is
what the keys say.
# asc_point — the last star, and the ENDCARD handoff
ASCENSION's final frame, and the only thing that crosses the part boundary.
§11 of the brief: at t = 35.000 the frame is pure black with **one** point of
light — `#FFF0C8`, 0.5 % of frame height, one soft halo at 12 px — at NDC
**(0.000, +0.060)**. ENDCARD opens on that exact pixel and resolves the leading
`O` of ORIGOZERO around it.
## What it is
A single post-process pass, `asc_endcard_point`, at priority **210**.
Everything it draws comes off the shared clock:
| from the bus | used for |
|---|---|
| `aux.pointAmp` | the envelope — 0 until t = 33.60, 1 by t = 34.10, held to 35.000 |
| `Clock.handoff().ndc` | the centre, converted to uv once on enter |
| `Clock.handoff().hex` | the colour |
| `Clock.POINT_CORE_FRAC` | disc diameter as a fraction of frame height (0.005) |
| `Clock.POINT_HALO_PX` | halo radius in px at 720p, scaled to the live frame |
## Why a post pass and not a billboard
* NDC (0.000, +0.060) is a **screen** fact. §4 bar 28 says the point holds
absolutely still; a billboard inherits every hundredth of a degree the camera
breathes.
* "0.5 % of frame height" and "12 px" are **screen** measurements. The pass
reads the live frame size and honours both at any render resolution.
* Priority 210 puts it **after** the demo's fade pass (200). The part's
`fadeOut` is 3.0 s, so at t = 34.600 the fade has taken the scene 87 % to
black. A point drawn before the fade would be dimmed by that same 87 % and
H5 would be a grey speck. Drawn after it, the point is at full value on a
black field — which is the frame §11 describes.
Because the pass is after the tonemap, the colour it writes is a **display**
value: `#FFF0C8` goes in as 255/240/200 over 255, not linearised.
## Ownership
Aspect 1 (clock & orchestration). The position and the colour are the handoff
and are not tunable by another aspect; the envelope is on the bus.