The harness

5,536 words · 3 diagrams

A single object that can drive and interrogate the whole simulation without touching the UI. It is exposed as window.ir in the browser and used directly by the Node runner and the tests.

ir.help() in the console is the quickest list. This page explains the supported vocabulary and the distinctions that the one-line help cannot.

Code: packages/devtools/src/harness.ts


Why it exists in a package, not the app

flowchart LR
    H["<b>GameHarness</b>"]
    H --> B["browser<br/><code>window.ir</code>"]
    H --> N["headless runner<br/><code>pnpm sim</code>"]
    H --> T["tests<br/><code>devtools.test.ts</code>"]

    B --> R["a bug reproduced in Chrome…"]
    R --> T2["…replays as a Node test"]
    T2 -.- NOTE["this has happened repeatedly —<br/>the frame-transition and save<br/>bugs were both found this way"]
    classDef note fill:none,stroke:none,color:#64748b,font-style:italic
    class NOTE note
    style H fill:#0369a1,stroke:#0c4a6e,color:#fff

Everything it returns is JSON-serializable, for exactly that reason: an agent driving a browser over the debug protocol gets structured state back, not pixels.


Reading state

Call Returns
ir.summary() one line: tick, hash, frame, speed, systems
ir.status() everything the debug overlay shows, structured
ir.inspect(id?) one entity in full — frames, canonical + local coords, velocities
ir.dossier(address) one star or one body as a page of astronomy
ir.snapshot(alpha?) the raw presentation snapshot
ir.picture(debug?) reconstruction dimensions, history, allocations and GPU timings
ir.lens() the camera as an instrument — mm, f-stop, depth of field, EV
ir.bodies(system?) flat listing of a system's bodies with addresses
ir.systemsNearby(ly) nearest star systems, catalog and procedural
ir.logs(n) recent structured log records
ir.summary()
// tick 2590 (40.47 s, 1x) | hash fdf43017 | Debug One in sf:g:milky-way/s:SOL/b:0@0.350000,-1.100000 | 51849.8 m/s alt 0.000 mm | systems 1, frames 19

inspect and dossier answer different questions and take different arguments. inspect takes an entity id and reports a pose and a velocity; dossier takes an address and reports what the thing is — mass, orbit, rotation, atmosphere, light — in the groups the planetarium's object panel draws. A body is not an entity, so "what is Europa" has no answer in the entity store. Fields nothing has measured come back as a Fact with a null value and a pending reason, and pendingCount is how many (ADR-0014). It is lenient about its input in the same way ir.look is, through the same resolver, and returns null for an address that names nothing.

ir.status().boot says whether the app has booted: booting before there is a renderer, drawing while the boot cover is still up over a scene that already renders, and booted once the cover is gone. It is answered by the presentation host, so the driver waits on it rather than on a DOM selector or an engine field a rename would silently break. Headlessly it is booted.

ir.lens() is the optics, and ir.status().lens is the same object. Focal length, gauge and zoom are what the camera is set to; the field of view, the sharp band, the blur circle against the pixel it has to hide inside, the Airy disk and the exposure are derived from them against the viewport the picture actually lands on (ADR-0017). It is on status because a still is a body, a pose and an optical setup — a capture that records the first two is not reproducible. Both are null headlessly, which is the honest answer rather than a plausible one taken at a nominal resolution: a circle of confusion is a claim about a display, and there is no display.


Measuring the stellar field

ir.galaxy() returns a read-only inspector for the current world's galaxy seed. sample(position?) accepts a UniverseVector, defaults to the Sun, and reports population densities, Johnson V emission, RGB extinction per parsec, dust modulation, normalization, and both field and active generation versions. count(options?) integrates the reference cylinder; tangencies() reports the arm curve's tangent longitudes. ray(direction, options?, origin?) returns emergent RGB radiance, optical depth, and transmittance. Direction is a displacement in simulation axes; origin defaults to the Sun. Options include distanceParsecs, maxStepParsecs, and sampling, which accepts reference, observer, or settled.

pnpm sim --galaxy-plates .scratch/galaxy --galaxy-width 384 --quiet

The command writes face-on, edge-on, observer, young-arms, bar-bulge, and halo PNGs, paired .f64 arrays, and report.json. The report declares units, dimensions, sample counts, timings, hashes and the shared display stretch. The raw arrays contain interleaved RGB float64 little-endian values in V-anchored nW m⁻² sr⁻¹. Green is Johnson V radiance; red and blue describe illustrative chromaticity, and the channel sum is not bolometric power. The observer plate is an equirectangular sky with the galactic center in the middle and longitude increasing to the left.

In a script, openSession().harness.galaxy().plate({ view: 'face-on', width: 192, height: 192 }) returns the raw image. The population option selects thinDisk, thickDisk, youngArms, barBulge, or halo; observer supplies a different UniverseVector for the observer view. Calling these functions does not advance or mutate the world. Plates use dust transport with a 10 pc step maximum by default. dustScale: 0 selects the emission-only control and its 100 pc default; maxStepParsecs overrides either. Metadata records these settings. See ADR-0032 for parameters and calibration limits.

pnpm sim --galaxy-calibration --quiet emits the linear-light acceptance report and exits with status 1 if a bound fails. ir.galaxy().calibration() returns the same report without an exposure or display response; localDust() lists the Local Bubble approximation and nine source-derived cloud records. The reference sky includes diffuse Galactic and extragalactic light beyond the modeled stellar component. The report declares that limitation, its units, and the 0.3 mag bound. Enhanced processing consumes that calibrated field separately from this report. Matched camera images and transitions remain subject to preview acceptance; passing the linear-light bounds does not establish their appearance.

To regenerate the checked-in source manifest and runtime cloud/sky tables:

node apps/ingest/src/galaxyReference.ts /path/to/cube_ext.fits /path/to/RadianceOut.csv
pnpm exec prettier -w packages/universe/src/galaxy/localClouds.generated.ts packages/universe/src/galaxy/skyCalibration.generated.ts

The inputs are the uncompressed Lallement 2022 FITS cube and GAMBONS supplemental sky map; source links, hashes and exact selection windows accompany the output in data/reference/galaxy.json. No catalog or procedural systems are regenerated.

In the browser's planetarium, ir.galaxyView('face-on') and ir.galaxyView('edge-on') select the fixed external instruments also available under Presets → Milky Way. Each scopes its recipe lens and photographic exposure while it owns the view. Leaving it releases that scope and restores the player's lens and mode; no world state or global preference is rewritten. ir.look('s:SOL/b:2') returns to an Earth view.

ir.galaxyJourney(0) holds 64,000 km above Earth with the player's lens and selected camera mode. ir.galaxyJourney(1, 36) travels to 30 kpc above the galactic center over 36 presentation seconds; ir.galaxyJourney(0, 36) returns. The first argument is progress from zero to one, and an omitted duration holds the requested view immediately. ir.observerStatus().journey reads progress, destination and remaining duration without moving the camera. The Milky Way controls expose the same trip and a slider. Automatic adapts to the framed light, Manual retains the chosen lens exposure, and Enhanced keeps its declared composition. Entering a journey does not select a long-exposure instrument.

ir.galaxy().render() returns the projected target's dimensions and bytes, field and kernel versions, normalization, galactic-center origin in parsecs, step bounds, dust scale, sampling profile, settled state, readiness, observer frame, sensor exposure and bounded sky selection. submissions counts scene submissions requested from the volume; draws includes integral tiles and projection draws; held says whether the projected target was reused on the last submission. A held projection may coexist with an advancing cache bake.

When the ordinary physical cache is enabled, cache reports initialFaceSize, faceSize and selectedFaceSize, tile progress, publication and cancellation counts, retained entries, the 0.15 pc reuse radius, allocated bytes and physical units. using identifies the selected cache path; liveDraws and samplingDraws distinguish integration from cache sampling. The first complete cube has 32-pixel faces, followed by 128 and 512 pixels. All retain physical V-anchored RGB in units of 1,000 nW m⁻² sr⁻¹ per stored unit. The display response is applied afterward. A bounded live target serves a cold or translated view while a replacement bakes; only complete cubes publish.

temporal reports the live history dimensions, interleave stride, ray target, phase count, resets and owned bytes. stars reports source count, source uploads, maximum reductions, projection bytes and the resolved-star extinction cache. Its queue, lag and completed-source counts distinguish delayed physical refresh from an idle selection. cache.archive reports successful disk restores, completed writes and storage failures. A failed disk cache does not prevent a new bake.

--sample 12 --sample-js "ir.galaxy().render()" records that state one frame at a time. Read cache publication and draw counters beside frame timings; submission counts alone do not measure cost. The report is null without a renderer. ir.lens() gives the resolved optics and exposure. The fixed face-on instrument uses f/2, 2,400 s, ISO 400; edge-on uses f/2, 600 s, ISO 400. These recipes apply only while the named instrument owns the view.

The field transports stellar emission through the shared dust model. A declared photopic/V ratio of 1.25 converts V power to luminance; color remains illustrative. resolvedStarExtinction reports the resolved-star cache's readiness. Star sprites use source-bounded dust columns; the report's queue, lag and completion counters show whether they are still catching up with an observer move. ADR-0038 defines the catalog-relative transmission and finite update cycles.

node scripts/drive.mjs --url http://localhost:5173/planetarium \
  --width 1920 --height 1080 --dpr 1 \
  --js "ir.galaxyView('face-on')" --js "ir.chrome(false)" \
  --wait 1500 --js "ir.galaxy().render()" --shot galaxy-face-on.jpg

Finding and loading destinations

Call Effect
ir.targets() destinations near the player, with addresses — start here
ir.search(text) everywhere matching a name, nearest first — the whole catalog
ir.rowsFor([…]) those addresses, as listing rows measured from here
ir.findWorlds(q) bodies matching what a world is, swept in worker batches
ir.goTo(target) resolve a human form and move the ship to that system or body
ir.loadSystem(id) generate a system without moving the ship

goTo and the observatory's look accept human forms such as SOL, s:SOL/b:2, or b:2 relative to the current system. The low-level parseAddress function remains strict; use the harness resolver for human input.

findWorlds is a third question and the expensive one. targets and search read what is already there; this generates every system inside the radius to test it, because a body does not exist until its seed is expanded and so cannot be indexed. It answers in batches on the worker pool:

const sweep = ir.findWorlds(
  { starClasses: ['M'], sea: true, landable: true },
  {
    lightYears: 25,
    onBatch: (found, progress) => console.log(found.length, progress),
  },
)
sweep.systems // how many systems it will walk
await sweep.done // the nearest matches, sorted
sweep.cancel() // stop; queued jobs are dropped and running ones are told

The nearest thousand are kept and the rest counted — "rocky, within 150 light years" is 37,929 systems and over a hundred thousand bodies. ADR-0035.

targets and search are not the same list narrowed. targets is a star sweep with a radius and answers "what is near me"; search is an index lookup over the whole catalog and answers "what is called this". Filtering the survey to serve a name query can only ever find what was already a few light years away. Both take { origin: 'observer' } when the question is about the planetarium's camera rather than the ship — in that mode the two are nowhere near each other.


Driving the clock

Call Effect
ir.step(ticks) advance exactly N ticks, ignoring wall clock
ir.runSeconds(s) advance exactly s × 64 ticks
ir.pause() / ir.resume()
ir.timeWarp(x) multiplier on how many ticks a second of wall clock buys (1 = real time)

step is the deterministic one — it does not consult the clock at all, which is what makes scripted scenarios reproducible.

Every detent timeWarp accepts is delivered over a ship that is coasting, and the ceiling is on integration. A ship with no control input, no spin under flight assist, and a periapsis above the ground band is propagated from an epoch rather than stepped, so the frame jumps (ADR-0025); one that is thrusting or skimming an atmosphere is stepped through every tick and capped at 1,920 simulated seconds a second. Which of the two is happening is the difference between a warp that reads as delivered and one that reads as capped, so both ir.summary() and ir.inspect() say: the summary line appends , coasting, and the inspection carries a coasting boolean. ir.status().world.achievedTimeScale against timeScale is the same fact from the clock's side.


Flying

flowchart TB
    subgraph POS["put the ship somewhere"]
        O["ir.orbit(address, altitudeKm)<br/><i>a valid two-body solution —<br/>it stays there</i>"]
        L["ir.land(address, latDeg, lonDeg)<br/><i>places on the pad —<br/>landed after one tick</i>"]
        G["ir.goToSystem(id, au)"]
    end
    subgraph AIM["aim and burn"]
        F["ir.face(address)<br/><i>free — changes nothing else</i>"]
        BT["ir.burnToward(address, throttle)"]
        T["ir.throttle(0..1)<br/><i>the main drive — a setting,<br/>not a held key</i>"]
        C["ir.control({translation, rotation, throttle})<br/><i>the thrusters, the attitude,<br/>and the drive if given</i>"]
        HOLD["ir.hold()<br/><i>hands off everything,<br/>drive included</i>"]
    end
    POS --> AIM

Two notes worth internalizing:

  • orbit is not a teleport to coordinates — it sets a state that solves the two-body problem, so the ship stays in that orbit. It also places the ship on the sunward side, pointing along the orbit, because a debug tool that drops you on the night side of an unlit world facing nothing is technically correct and useless.
  • face and burnToward are separate because looking and accelerating are different acts. face costs nothing and does not perturb the trajectory.
  • The thrusters and the drive are two controls. translation is the reaction-control system, six ways at under a g, and holds only while it is set; throttle is the main drive, ahead only, at three g, and stays where it is put — through control calls that do not mention it, and through a save. burnToward sets the throttle, and every placement verb cuts it, because a ship put into a circular orbit with its drive lit is not in that orbit on the next tick.
  • land does not land you. It puts the ship on the pad — local y = 0 in a surface frame is the ground — and the contact test makes it landed on the next tick, so ir.land(...).player.landed is false and one ir.step() fixes it. ir.scenario('surface') hides this because it steps 64 ticks. Landedness is only ever a consequence of touching the ground: stepFlight short-circuits for an entity that is already landed, so a ship declared landed three meters up never meets the contact test and hovers there while the overlay reports an altitude of zero.
  • Every angle a verb takes is in degrees — land, visit, drop, descend — and every listing prints degrees, so ir.sites() output pastes into any of them. Under TypeScript the Degrees brand makes the caller say so (deg(20) from @inertialref/shared); radians begin below the harness. ir.observatory.stand is the object itself and takes radians: ir.visit is the degree door to it.
  • The verbs live in Maneuvers (packages/devtools/src/maneuvers.ts), which a test can build over a session directly. Each returns what it promises — the state it wrote, the nose, the phase it framed — where the harness returns its status. ir.status().player carries the same heading and phase, so "arrives looking at it" is the dot of heading with -local.
  • ir.flightAssist(enabled) exists and is absent from ir.help(). It is control input and it is in the state hash, so a test comparing hashes has to know it is there. ir.scenarios() lists the five scenario names.

On foot

The pilot of a landed ship can step out. ir.onFoot is the session's half of walking — the browser keeps pointer lock, the look and the view — so a test or pnpm sim puts a walker on the ground with no renderer:

ir.land('g:milky-way/s:SOL/b:3', 20, -63)
ir.step(1) // landed after one tick
ir.onFoot.stepOut() // a walker beside the ship, and now the player
ir.onFoot.status() // active, walker, ship, canFly, grounded, …
ir.onFoot.board() // back into the ship it stepped out of
ir.onFoot.atMarsPad() // parked on the Mars pad, standing beside the Rocinante

stepOut and board answer { ok, value } or { ok: false, error }: a ship that has not landed on solid ground has nowhere to put a walker, and a walker whose ship is gone has nothing to board.

A ship verb on foot boards first. goTo, orbit, shot, land, face, burnToward, goToSystem, control, throttle, hold and flightAssist step the walker back into its ship and then act on the ship, as the dock's travel buttons do; with no ship to board they throw. The walker records the ship it left and the save keeps it, so with two ships landed board reaches the one it came from after a reload. Every session grants the walkers a loaded save carries its own flight permission: openSession({ canFly: false }) refuses flight to a walker the save says may fly. Stepping out headlessly leaves the clock alone; the game runs time at 1× when its player steps out.


Moving only the camera

ir.look(target, options?) moves the observatory camera without moving the ship or changing canonical state. That distinction is deliberate: ir.look('s:SOL/b:4') and ir.goTo('s:SOL/b:4') can both fill the frame with Jupiter, but only goTo leaves the ship there.

ir.observatory exposes the camera itself for repeated interaction:

ir.observatory.drag(dx, dy)
ir.observatory.zoom(delta)
ir.observatory.setPhase(angle)
ir.observatory.frameTarget()
ir.observatory.clear()

ir.aim(yawDeg, pitchDeg) turns the head without moving the camera. It is an offset on whatever the pose aims at, so ir.aim(0, 0) is the way back to the subject; it survives a drag, a dolly and a wheel notch, and is cleared by whatever replaces the pose — a focus, a frame, a stance, a composition. Standing, it drives the stance's own heading and pitch.

In flight the ship arm has a camera of its own, with two views. ir.view('orbit') stands it off the hull and looks at the ship while it maneuvers — the view the thrusters and the drive are watched from — and ir.view('chase') puts it back behind the hull; ir.view() cycles. ir.flightCamera is the camera itself:

ir.flightCamera.drag(dx, dy) // orbit, or turn the head in the chase
ir.flightCamera.turn(dx, dy) // turn the head in either view
ir.flightCamera.zoom(factor) // dolly the orbit; above 1 retreats
ir.flightCamera.recenter() // look where the view aims again

The orbit is measured in hull lengths about the ship in the world's own axes, pole on the local up, and tethered at eight lengths; it opens where the chase was standing. ir.status().flightCamera reports the view, the orbit and the look, so a plate taken beside the hull records the camera it was taken from.

Compositions

Sixteen named framings, relative to whatever is under the camera, with two placers:

ir.shots() // names and descriptions
ir.shot('crescent', address) // teleports the ship into the picture
ir.compose('crescent') // moves the camera into it

The ids are portrait, blue-marble, close, wide, half-lit, raking, high-angle, far-crescent, backlit, full-face, gibbous, half, crescent, glint, sunset and oblique. The last three aim at a limb or a specular point rather than at the body's center, which is why they were ship-only until the aim became an offset; sunset and oblique stand off below the orbit floor, so ir.compose lands them on the surface arm.

Pictures

A portable picture stores its seed and algorithm manifest, photographic instant, target, framing and tracking state, full lens and camera processing. Built-ins and imported shots use the same validated format.

ir.presets() // ids, labels and what each one is
ir.preset('earthrise') // takes it, and fits the lens it solved
const picture = ir.capturePicture('my-view', 'My view') // held instant and full camera
ir.takePicture(picture) // validates and restores the picture

takePicture refuses a different seed or algorithm manifest. Pictures do not carry a catalog version, so a changed catalog can move a restored shot without a warning. This differs from canonical-save drift reporting. ADR-0033 records the format and current limitation.

ir.rise() is the framing behind Earthrise on its own: it stands on the body being looked at with its parent a stated clearance over the horizon, and returns the field of view it solved — Earth is 1.90° across from Luna and Mars is 42.39° from Phobos, so the lens is part of the picture rather than a setting beside it.

ir.rise({ clearance: 3, height: 110000 }) // degrees and meters
ir.chrome(false) // the interface out of the frame — the plate state
ir.layers(false) // names and traces off, which is a different claim

Standing on a body

The observatory has a second arm, below the orbit camera's 1.5-radii clamp. ir.visit puts the camera on the ground and ir.ascend takes it back to the framing it left; both are camera moves, so world.stateHash() is untouched — ir.land is the one that teleports the ship.

ir.sites(address?)                       // the named places on a body, with the sun over each
ir.light(address?)                       // sun elevation on the ground, phase from orbit, and a verdict
ir.visit(address?, { site, height })     // stand there, now. Degrees and meters
ir.visit(address?, { latitude, longitude, heading, pitch })
ir.drop(lat, lon, { address, seconds })  // fly down there, facing the star
ir.observatory.setStanceScrub(0.5)       // the height slider, logarithmic
ir.ascend()                              // and this abandons a drop in flight
ir.settled()                             // whether the sky and the ground have stopped arriving

Stand where the sun is. A picture taken in the dark compares nothing: a star field jittering under a black disk, or a slope a low sun has left in shadow, differs from the next capture by thousands of pixels while a lit face differs by none. ir.sites() carries sun, the star's elevation over each place in degrees at the held instant, so the lit site is the one with the largest; ir.light() says the same for the view as it stands — sun on the ground, phase from orbit with zero the full face and 180 the silhouette — and lit is the verdict a fixture is chosen by. ir.settled() is what a capture waits for instead of a number of milliseconds: the sky's physical cubes publish tile by tile and the streamer fills a ring at a time, and a still taken while either is in flight is a still of something still arriving. pnpm drive --settle <ms> polls it.

visit cuts and drop flies, and the difference is the intent rather than the destination. visit is what a plate is captured through, so the frame after it returns is the frame asked for; drop is eight seconds of ballistic entry that ends level with the horizon facing the star, which is a thing to watch and useless to photograph the start of. ir.ascend() abandons one part-way at the framing it left. ADR-0034.

Sites are derived from the body's own terrain rather than authored, so "the highest ground on this world" survives regeneration and is still the interesting place afterward. Four come from a beam search — summit, basin, shore, rough — and two are chosen outright for the renderer: corner, where three faces of the addressing cube meet, and pole, where the east/north basis is singular. A world that drains names four more off its drainage graph (ADR-0043) — headwater, confluence, mouth and lake: the source of the largest river, the junction of its largest tributary, where it meets the sea, and the largest lake — each stood on the water the field draws. On a body with no solid surface ir.sites returns an empty list: a giant's parameters run through the survey without complaint, but every row would be a place ir.visit refuses to stand.

The height is set outright rather than eased, which is what makes a plate loop work: each ir.visit is the frame you asked for, with nothing settling in between.

for (const height of [40000, 2000, 120, 2]) {
  ir.visit('g:milky-way/s:SOL/b:5.6', { site: 'pole', height })
  // capture here
}

The performance timeline

ir.timing() // the level: off | trace | full
ir.timing('trace') // the same switch the URL, the preference and the panel reach
ir.timing.tracks() // what this session has actually emitted onto
ir.timing.mark('after the seek') // a marker of your own, across every track
ir.timing.drain() // the retained entries, cleared by name on the way out
await ir.profile(2000) // arm, record, disarm, report

ir.profile is the one that answers a question. It returns structured data and a .text block — the shape terrainBaseline established — because the point is a terminal-readable answer from a single --js call:

node scripts/drive.mjs --url http://localhost:5173/planetarium \
  --js "ir.visit('g:milky-way/s:SOL/b:2',{site:'summit',height:2})" \
  --wait 5000 --js "(await ir.profile(2000)).text"
node scripts/drive.mjs --down

The last line of the report is the deliverable: "9 of 61 frames over 25 ms; terrain.select was the largest measured span in 7 of them, 8.4 ms of 31.0 ms" rather than a screenshot and a p95. It names the largest span inside the late frames, not the busiest one overall — a span that is cheap on average and catastrophic four times is exactly what a mean hides — and carries the frame duration beside it, because the ratio is what says whether the name explains anything. A worker job is never named: the pool times it from dispatch to answer on the page's clock, so it starts inside a frame and outlives it, and it ran on another thread.

Three things it will not tell you, each for a reason worth knowing.

The worker tracks are absent from a drain. A worker's User Timing entries live on the worker's own performance timeline, invisible to the page's getEntriesByType, because each side times and emits against its own timeOrigin. They are in a trace, on their own threads — pnpm timing --threads separates them.

A share of frame time is undefined for concurrent spans, printed as an em dash. Worker queue waits overlap each other and the frames they cross, so the ratio is real and describes nothing.

A span under about 300 µs is a mean, not a reading. performance.now() here steps in 100 µs — nothing sets COOP/COEP — so one bar is not a measurement even though a window of them is.

ir.profile restores the level it found, so a profile taken mid-session does not leave the timeline retaining for the rest of it. off is the default everywhere: there is no capability query for the custom-track arguments, so a level that turned itself on would make browsers that cannot draw these entries pay for them anyway.

Recording a trace instead, and reading it back:

node scripts/drive.mjs --url "http://localhost:5173/?timing=trace" --wait 5000 --trace 3000
pnpm timing --top 20            # the table and the verdict
pnpm timing --track Tasks --threads   # one row per worker

Measuring the picture

ir.picture() // live reconstruction report; null without a renderer
ir.picture('motion') // temporal velocity
ir.picture('disocclusion') // rejected history
ir.picture('reactivity') // blended regions that reduce history reuse
ir.picture('none') // return to the image

The other temporal debug views are depth, age, locks, exposure and shading. The report states path, integer renderWidth/renderHeight and displayWidth/displayHeight, jitter phase and phaseCount, submitted frames, history resets, initialization initMs and gpuTimings by pass. Timestamp availability depends on the backend and detailed timing being enabled. workingTextureBytes counts raw upscaler textures, excluding the scene, optical targets and driver overhead. It is not total GPU memory.

Select a repeatable picture through the URL: ?picture=native, ?picture=msaa:quality, ?picture=temporal:quality or ?picture=bilinear:quality. An optional third field selects sharpness, such as temporal:quality:crisp; the bilinear diagnostic takes only the scale. The page override does not change saved settings. Temporal native is available as ?picture=temporal:native.

Compare a fixed camera on a generated world outside Sol, at a held photographic instant and identical display dimensions. Allow at least phaseCount presented frames after a temporal cut, record the phase with the plate and compare two boots of the same build to establish its own variation. Use ir.gpu() for the complete chain and the report's per-pass readings for reconstruction alone. Testing gives the focused gates.

Where the GPU's frame goes

await ir.gpu(40) // ms per presented frame, across a drained queue
await ir.passes(40) // the same frames, by named pass

ir.passes holds the loop, submits the frames through the presenting chain with a timestamp pair on every pass three encodes, and names each pass by its target: scene is the sensor's pass, scene after copy is the same pass resumed after the sea read the frame behind it, defocus 0–3, motion tile max, motion neighbor max, motion blur, psf down 0–5, psf up 0–4 and psf resolve are the optics, galaxy volume, galaxy rays, galaxy history, galaxy structure and galaxy sky are the galaxy's, and canvas is the output. Every quad pass is named where it draws, by runQuadPasses in render/quadPass.ts. It returns wallMs — ir.gpu()'s figure over the same frames — beside busyMs, the passes' shares summed, and the two agree within 2% at the shore; a larger disagreement is time in work the timeline does not see, such as the upscaler's own passes, which shows in gaps. Those passes are also reported beside the rest under owned: the upscaler lends the timeline its own timer for the run, and its compute passes come back as { source, label, samples, latencyMs } — the package's begin-to-end means, latency rather than share, and not in busyMs. Adding owned to gaps counts that work twice. The package's timer has eight query slots and a run draws its frames back to back, so samples is at most eight whatever the frame count: the run's first frames.

A pass's ms is its share, not its timestamp pair. Apple's GPUs pipeline passes and frames, and Metal samples a pass's timestamps at its stage boundaries, so the raw pair is latency: at the shore every pass, a 60×37 blur level and the output quad included, read 23–35 ms begin to end in a 24 ms frame. Each pass is charged for how far it moved the completion frontier instead, and the raw pair is kept as latencyMs. render/passTimeline.ts has the arithmetic, and why three's own timestamp tracking cannot be read for this.

A lever's cost is the difference in wallMs with the lever moved, taken in one boot: the default read at the start and the end of a sweep agrees within 1.5 ms, and a second boot can read every pass a third slower at once. Set the lever on engine.surfaceQuality, let the terrain settle, then measure.


Measuring terrain

ir.zoo() // one body per surface archetype, found rather than listed
ir.descend(address?, { site, steps }) // fly a descent on paper
ir.terrain() // what the live streamer holds this frame, rocks included, and who produces it
ir.terrainBaseline() // the zoo, its descents, and measured patch cost
engine.surfaceQuality = { terrain: 'coarse', ground: 'lean', sea: 'plain', rocks: false } // the levers
orbitalBaker.report() // which generated bodies have a baked sphere, and whether it is ready
orbitalBaker.targetFor(address) // its two cube targets — albedo and relief — for a face readback
ir.dossier(address) // the record, whose Geology card is the surface grammar

ir.descend runs with no world state changed, no worker used and no frame drawn, so it produces the same level churn, peak burst and cache numbers in a browser console, in pnpm sim --terrain-baseline and in a Node test. It takes latitude and longitude in degrees, the same boundary ir.visit, ir.drop and ir.land state — ir.sites output pastes straight in, and radians begin below the harness. ir.terrain is the counterpart: what the streamer actually has, which is null headlessly rather than a zero. Its patches counts ground built and placed this frame, not the selection's length — a cold arrival reports zero patches over zero vertices rather than six regions still in a worker. It states both caches, cached for heightfields and geometry for meshes, because they fail differently: refinement gates on the mesh, so a geometry cache losing what the draw set is standing on collapses the selection to the roots while cached sits at its steady value and reports nothing wrong.

visited, culled, starved and level are from the last frame that walked, not from this one. The walks are a pure function of the eye, the optics, the level floor and the geometry cache, so a stance or a converged hover holds all four still and reuses the answer — the counters stay at whatever the last re-selection left. selections is the one that says so: a total since the streamer was made, so two reads a second apart that differ by sixty are a selection recomputed every frame and two that agree are the memo holding. Read it beside the rest or visited: 446 will describe a frame that walked nothing.

ir.terrain().producer is where the next heightfield request goes — gpu for the tile producer, pool for the worker pool, none for a session with neither; headlessly ir.terrain() itself is null — rather than where the ground on screen came from, because a producer can stop mid session and the fields already held came from wherever they came from. ?producer=cpu on the URL keeps the pool on a WebGPU page, which is the A/B every GPU figure was taken against.

The browser's persistent archive is separate from those live streamer counts:

await engine.terrainCache() // availability, activity counters and retained storage
await engine.clearTerrainCache() // discard the archive without changing saves

activity distinguishes hits, misses, invalid records, successful writes, read/write errors, cancellation and pending writes. storage reports retained entries and bytes alongside their limits. An unavailable store reports available: false; generation continues. CPU and GPU producer records remain separate even though their storage and activity counters share the archive.

The first frame on a body reports zeros for a reason that is not the relief gate. The subdivision floor is a worker answer, so a body the streamer has not measured has no ceiling to select against and it holds the ground back rather than guessing one — the same way it waits for the heightfields.

ir.terrain().scatter is the rocks on it, and it is part of the terrain report rather than a verb of its own because a rock count without the ground under it is not a number anyone can act on: resolving above zero beside rocks at zero is a field filling in, and the same pair beside starved is a descent outrunning both budgets at once. range is how far rocks are drawn this frame — 212 m at the flight lens over the baseline viewport, and a function of the lens like everything else here.

Every frame-rate figure is a function of the surface levers as well, so state them. engine.surfaceQuality is the field the frame loop reads — the refinement threshold, the ground's octaves, the sea's refraction and waves, the rocks — and a driving script can set it between two --casts to measure one term: render/quality.ts names what each step spends. The materials compare before they write, so flipping a lever costs the next frame nothing but the change. The record that outlives the session is the render.surface preference, which the graphics panel edits and state/engineKnobs.ts carries to the field, so a script's write holds until that key next moves and does not survive a reload — which is what you want of a measurement.

Every count here is a function of the lens, so every report states one. The demand climbs steeply with the pixels-per-radian — measured, the telephoto end of the slider wants 1.9× to 3.2× what the flight lens does — so a figure taken without the optics beside it is one nobody can reproduce or compare with yesterday's. ir.descend and ir.terrainBaseline print the lens and the viewport in their headers and carry both in the returned object; ir.terrain carries the live one. Both probes take a lens and a viewport to ask the question at another setting, and ir.descend takes a maxPatches — the streamer's own 1,280 is a safety net that degrades the whole disk by a level when it bites, so raising it is the only way to see what a selection wanted.


Scripted scenes

ir.cutscene.list() // scenes with descriptions and durations
ir.play('tng-intro')
ir.pause()
ir.seekCutscene(1150)
ir.cutscene.status() // the playhead, or null
ir.cutscene.lastOutcome() // ended, stopped or abandoned
ir.stopCutscene()

ir.cutscene is the director itself, the way ir.observatory is the observatory; play, seekCutscene and stopCutscene stay on ir as the verbs a console types. The scenes it plays are a session option, openSession({ cutscenes }), defaulting to the shipped ones.

Pause before seeking for a frame-exact still. The browser needs to render after the seek before the sampled cinematic state is current; follow the capture procedure in Driving.


Scenarios

await ir.scenario('orbit') // circular orbit, 300 km
await ir.scenario('approach') // burning toward a world
await ir.scenario('surface') // parked on the ground
await ir.scenario('interstellar') // holding off Alpha Centauri
await ir.scenario('descent') // orbit to 2 m over every zoo body, on paper

Named, repeatable set-ups. Each returns the resulting status, so a test can assert on it directly.


Proving

const report = await ir.selfTest()
console.log(report.report)

Runs the twelve capability checks and returns { passed, total, results, report }. The results array is structured, so a driver can assert on individual checks rather than parsing text.

12/12 capabilities proven
PASS  5. Approach a planet — fell 16.65 m in 60 s at 0.0092 m/s², within 0.03% of free fall
PASS  9. Origin rebasing — 500 rebases, 2560 km of origin travel, zero drift
…

Five checks build scratch worlds. The rest read the live session — and capability 3 is not read-only: it loads Alpha Centauri in order to measure the distance to it, so after a self-test the session has two systems loaded rather than one. Worth knowing before using the self-test as a mid-session probe.


Persistence

const text = ir.save() // serialized save; size follows dynamic state and references
ir.load(text) // → Result<stateHash, error>

load returns a Result rather than throwing — a save is untrusted input.


Driving from an automated browser session

scripts/drive.mjs is the driver — the Chrome DevTools Protocol against a Chrome it launches itself, never an extension in somebody's own browser. pnpm drive --help, and the drive skill for the traps.

The pattern that works well:

sequenceDiagram
    participant A as driver
    participant P as page

    A->>P: navigate
    A->>P: wait (textures stream in after a look or a seek)
    A->>P: ir.scenario('surface')
    A->>P: ir.step(20000)
    A->>P: JSON.stringify(ir.status())
    P-->>A: structured state
    A->>A: assert, screenshot, iterate

Read state back as JSON and assert on it. A screenshot tells you something rendered; ir.status() tells you what. --sample <n> reads one value per animation frame from inside the page, which is the only way to see something that moves — a strobe, a level churn, a stall — because a still cannot show it and a round trip per frame is longer than a frame.


Extending the harness

Add a method when a sequence is one you keep retyping — that is the signal it is part of the vocabulary rather than a one-off. Keep the return JSON-serializable and add the line to help(), which is what people actually read.

If a set-up sequence is shared with the app or the runner, it belongs in openSession instead, which owns standing a world up — see extending.


  • Observability — what the harness reads from
  • Testing — turning a session into a regression test