Scenario Files
A scenario is one run of moqtap intercept written down: what the network
underneath the session does, how the two QUIC connections are configured, what
the shaper does to media, what happens to individual objects, and what changes
part-way through.
moqtap intercept -u moqt://relay.example.com --scenario bad-wifi.jsonThe file is read and checked before the proxy binds a port, so a scenario that cannot be honoured fails immediately rather than half-applying itself to live traffic.
Let your editor write it
Section titled “Let your editor write it”The format is deeper than it first looks — queue.overflow, discipline and
the action values are enumerations you cannot guess — so the schema is
published, and one line at the top of a file gets you completion, enum
dropdowns and inline errors in VS Code, the JetBrains IDEs, Neovim and anything
else that speaks JSON Schema:
{ "$schema": "https://moqtap.com/schema/scenario/v1.json", "version": 1, "network": { "preset": "lte" }}moqtap accepts the $schema key and otherwise ignores it: it never fetches the
URL, and it does not check your file against it. Your editor does that. The
tool’s own check is version, which is stricter.
moqtap scenario new writes a starter with the line already in it, and
moqtap scenario schema prints the schema the build in front of you actually
reads. That document is generated from the very types the loader deserializes,
so a build cannot describe a format it does not read — the published copy and
the tool cannot drift apart in the way a hand-maintained schema does.
The smallest useful file
Section titled “The smallest useful file”One line runs a whole session over a mobile network:
{ "version": 1, "network": { "preset": "lte" } }Nothing else is required. Every top-level key except version is optional, and
a file that omits all of them is a valid scenario that changes nothing.
Start from an example
Section titled “Start from an example”Six worked scenarios ship with the CLI, in
examples/scenarios/.
CI loads every one of them on every commit — and arms what it loads, since a
scenario that parses can still be one no proxy could run — so none of them can
rot into an example that no longer works. Each opens with the $schema line, so
they autocomplete the moment you copy one.
| File | What it shows |
|---|---|
lte-link.json | The whole file, for a session over a mobile network |
drop-first-object-of-every-group.json | One rule, aimed at the object a player restarts from |
recover-mid-run.json | A lossy edge that clears at thirty seconds — recovery, which only shows up when conditions move |
bandwidth-ceiling.json | A rate cap, a class that claims everything, and a queue that holds two seconds then drops |
relay-leg-only.json | Transport limits on the relay leg, with the client’s connection untouched |
every-top-level-key.json | All five keys at once, with the network written out in full rather than named |
Write one, and check it
Section titled “Write one, and check it”moqtap scenario new writes a starter file to edit from:
moqtap scenario new # the smallest file, on LTEmoqtap scenario new --preset lossy-edge --full # with a rule and a timeline step--full is the one to read first. JSON has no comments, so a starter cannot
explain itself inline — what it can do is put a worked rule and a worked
timeline step in front of you before you write either from scratch.
moqtap scenario check loads a file and says what it would do, without starting
a proxy or needing a relay:
$ moqtap scenario check harness.jsonscenario harness.json: network, 1 rule(s), 1 timeline step(s) [impaired sockets: one client at a time]It checks everything a run checks before it accepts a connection: the schema, every rule, and every network profile the file would arm — including the ones that only appear on the timeline. A rule that parses but no hook can decide, and a profile that would not become illegal until second thirty, are both refused here rather than part-way into a run.
It takes many files, checks all of them even after one has failed, and exits non-zero if any did, which is what makes it a gate rather than a convenience:
$ moqtap scenario check scenarios/*.jsontypo.json: unknown field `netwrok`, expected one of `$schema`, `version`, `network`, `transport`, `shaping`, `rules`, `timeline` at line 3 column 11v2.json: scenario version 2 is not supported: this build reads version 1error: 2 of 6 scenario files did not check outTop-level keys
Section titled “Top-level keys”| Key | What it sets |
|---|---|
version | Which revision of this format the file was written against. Must be 1 |
network | What the path underneath QUIC does: loss, delay and jitter, reordering, duplication, corruption, rate |
transport | QUIC transport parameters, set separately for each leg |
shaping | Egress shaping for media: token buckets, classes, a bounded queue |
rules | What happens to individual units, matched and acted on one at a time |
timeline | Changes that land part-way through the run |
network
Section titled “network”Either one of seven named profiles:
"network": { "preset": "satellite" }| Preset | What it models |
|---|---|
lte | Mobile LTE: tens of milliseconds of correlated jitter, a modest uplink, and enough loss to make a retransmission strategy matter |
wifi | Consumer Wi-Fi: short delays with a heavy tail from contention and MAC retries, and the occasional duplicate those retries produce |
satellite | Geostationary satellite: a quarter-second each way, a constrained uplink, and a reduced path MTU |
three-g | A 3G cellular link: around a fifth of a second each way with a long tail from radio-layer retransmission, a couple of megabits of capacity, about one percent loss |
lossy-edge | A lossy access edge: bursty loss from a Gilbert-Elliott chain, wide uniform jitter, reordering and the occasional corrupted bit |
bufferbloat | A short hop in front of a queue deep enough to hold seconds of traffic at the rate that drains it, so the delay a bulk sender sees is the backlog it built rather than anything the delay model imposed |
datacentre-clean | The control: no loss, no jitter, no reordering, no duplication, no corruption — only a fixed sub-millisecond delay and a gigabit bucket, so a run against it isolates what the code under test does on its own |
Or a profile written out in full, with the models named per direction:
"network": { "profile": { "downlink": {}, "uplink": {}, "peers": "all" } }A scenario naming a network puts both legs on an impaired socket, and one
proxy holds that for one client at a time. A scenario without one is
unaffected, and the run header says which of the two you have.
A rule is a matcher and an action. The matcher picks out units by track alias,
group, subgroup, object ID, publisher priority, stream kind or direction;
ranges are written { "start": 0, "end": 0 }, and several may be listed.
"rules": [ { "name": "lose-the-first-object-of-every-group", "matcher": { "object_id": [{ "start": 0, "end": 0 }] }, "action": "drop" }]The actions are pass, drop, delay, replace-payload and — for a control
message or a datagram — replace. The ones that take arguments are written as
an object rather than a bare string:
"action": { "replace-payload": { "payload_hex": "deadbeef" } }payload_hex is lowercase hex of even length, and the engine requires the
replacement to be exactly as long as the payload it replaces — the example
above therefore only ever fires against a four-byte object. A length mismatch
is refused at the rule rather than allowed to mis-frame the stream, so a
replace-payload aimed at objects of varying size will refuse most of them.
name is not decoration. JSON has no comments, so it is the only place inside
the file where a rule can say what it is for.
A dropped object is elided rather than blanked, so every object that survives keeps the ID it arrived with.
shaping
Section titled “shaping”Named token buckets, classes that aim a matcher at a bucket, a bounded queue, and what becomes of a unit that overflows the queue or outlives its hold.
"shaping": { "buckets": [{ "name": "video", "rate_bps": 500000, "burst_bytes": 65536 }], "classes": [{ "name": "video", "bucket": "video", "matcher": {} }], "queue": { "depth_bytes": 1048576, "depth_objects": 64, "max_hold": { "secs": 2, "nanos": 0 }, "overflow": "drop", "on_expiry": "drop" }, "discipline": "fifo"}Control streams are never shaped, whatever the classes say. Pacing a SUBSCRIBE behind a video bucket breaks a session rather than testing one.
transport
Section titled “transport”QUIC transport parameters, per leg. The connection your client makes and the connection moqtap makes to the relay are configured separately, so you can starve one side of streams or flow-control credit and leave the other alone.
"transport": { "client": { "max_concurrent_uni_streams": 1 }, "upstream": { "initial_rtt": { "secs": 0, "nanos": 40000000 } }}A connection takes its transport configuration once, at setup, so a timeline step that changes this reaches connections that have not been made yet.
timeline
Section titled “timeline”Steps that apply a new network, transport or shaping at an offset into
the run. A run is a script, not a constant — and recovery is a behaviour that
only shows up when conditions move.
"timeline": [ { "at": { "secs": 30, "nanos": 0 }, "network": { "preset": "satellite" } }]version and unknown keys
Section titled “version and unknown keys”version is checked before the rest of the document is interpreted, and an
unknown key anywhere is fatal.
Both are deliberate, and they catch different mistakes. A misspelled shaping
would otherwise be a run that reports itself applied and does nothing. A field
this release has removed or retyped is the quiet one — the file still parses
and every key is still known, but the value means something other than what its
author meant — and only the version number catches that.
The refusal names what it found, so a file written against a later release reports the release rather than complaining about a key it has never heard of.
The seed
Section titled “The seed”Everything a network draws at random comes from a single seed. --seed
defaults to a fresh value on every run, and the run header prints the number it
drew:
scenario bad-wifi.json: network, 1 rule(s), 1 timeline step(s) [impaired sockets: one client at a time]seed 1430453990272362564 (pass --seed 1430453990272362564 to run it again)Passing that number back reproduces the run exactly, which is what makes a bad session something you can hand to a colleague, a bug report or CI.
The seed is deliberately not part of the file. A scenario is meant to be shared, and a shared scenario carrying a fixed seed gives every reader the same “random” run — a deterministic scenario that merely looks noisy, hiding exactly the bugs it was written to find.