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:#fffEverything 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 19inspect 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 --quietThe 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.tsThe 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.jpgFinding 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 toldThe 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 --> AIMTwo notes worth internalizing:
orbitis 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.faceandburnTowardare separate because looking and accelerating are different acts.facecosts nothing and does not perturb the trajectory.- The thrusters and the drive are two controls.
translationis the reaction-control system, six ways at under a g, and holds only while it is set;throttleis the main drive, ahead only, at three g, and stays where it is put — throughcontrolcalls that do not mention it, and through a save.burnTowardsets 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. landdoes not land you. It puts the ship on the pad — localy = 0in a surface frame is the ground — and the contact test makes it landed on the next tick, soir.land(...).player.landedisfalseand oneir.step()fixes it.ir.scenario('surface')hides this because it steps 64 ticks. Landedness is only ever a consequence of touching the ground:stepFlightshort-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, soir.sites()output pastes into any of them. Under TypeScript theDegreesbrand makes the caller say so (deg(20)from@inertialref/shared); radians begin below the harness.ir.observatory.standis the object itself and takes radians:ir.visitis 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().playercarries the sameheadingandphase, so "arrives looking at it" is the dot ofheadingwith-local. ir.flightAssist(enabled)exists and is absent fromir.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 RocinantestepOut 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 againThe 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 itThe 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 picturetakePicture 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 claimStanding 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 arrivingStand 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, reportir.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 --downThe 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 workerMeasuring 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 imageThe 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 passir.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 grammarir.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 savesactivity 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 paperNamed, 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, iterateRead 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.
Related
- Observability — what the harness reads from
- Testing — turning a session into a regression test