# Zero

Where the rifle is zeroed (the zero range), the air and slope it was zeroed in, and how far the group prints off the aim point there.

## Generated table

**Record:** profile · **Covers:** `rifle.zero_range_yd`, `rifle.zero_env`, `rifle.zero_incl_deg`, `rifle.zero_height_in`, `rifle.zero_offset_in`, `rifle.zero_offset_use`, `come_ups`

| Field | Type | Required | Limits in the schema | Solver reads | In a shared log |
|---|---|---|---|---|---|
| `come_ups[].date` | string | no | pattern ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ | no | as stored |
| `come_ups[].id` | string | yes, within | - | no | as stored |
| `come_ups[].note` | string | no | - | no | removed when notes are hidden |
| `come_ups[].range_yd` | number | yes, within | min 25; max 5000 | no | as stored |
| `come_ups[].station_pressure_inHg` | number | no | min 15; max 32 | no | as stored |
| `come_ups[].temp_F` | number | no | min -40; max 130 | no | as stored |
| `come_ups[].unit` | `'mil'` or `'moa'` | yes, within | - | no | as stored |
| `come_ups[].value` | number | yes, within | min -172; max 1376 | no | as stored |
| `come_ups[].zero_range_yd` | number | yes, within | min 10; max 2000 | no | as stored |
| `rifle.zero_env` | object | no | - | yes | as stored |
| `rifle.zero_env.altitude_ft` | number | no | min -1500; max 20000 | yes | removed when location is hidden |
| `rifle.zero_env.rh_pct` | number | yes, within | - | yes | as stored |
| `rifle.zero_env.station_pressure_inHg` | number | yes, within | - | yes | rounded to 0.1 inHg when location is hidden |
| `rifle.zero_env.temp_F` | number | yes, within | - | yes | as stored |
| `rifle.zero_height_in` | number | yes | - | yes | as stored |
| `rifle.zero_incl_deg` | number | no | - | yes | as stored |
| `rifle.zero_offset_in` | number | yes | - | yes | as stored |
| `rifle.zero_offset_use` | boolean | no | - | yes | as stored |
| `rifle.zero_range_yd` | number | yes | min 10; max 2000 | yes | as stored |

## What it holds

These fields of a profile version describe the rifle's zero (and the confirmed come-ups dialed from it): the distance at which the bullet crosses the line of sight with the scope on its zero setting, the air and the slope the rifle was zeroed in, and where the group prints there compared with the aim point. Every come-up the solver gives is measured from this zero. The zero range, height and offset are always present; the zero air and slope are optional, and with neither stored the rifle is "zeroed on level ground, in the shot's air". Sight height is a [rifle](rifle.md) field.

## What to know first

**No zero air means the shot's air.** With `rifle.zero_env` absent the rifle is zeroed again in the air of each shot solved. A stored zero air fixes the zero instead, which moves every come-up of a shot in other air by one small angle, the same at every range.

**The connector's input is `zero_air`, not `zero_env`, and each writer has its own limits.** An input under the stored name is not used: the rest saves and the reply says "I ignored: zero_env". "Limits, by writer" gives each limit.

**Zero height and offset count only when turned on.** "Use my zero offsets in every solve" (`rifle.zero_offset_use`) does so under every model; otherwise only Custom with `zeroOffset: 'apply'` reads them. A `what_if` carrying either turns the option on for that answer, and the other's stored value counts too.

**A zero edit keeps the calibration but can leave it without groups.** The calibration is kept unless a reset is asked for, yet a later fit leaves out every group shot under a version whose zero range, air or slope differs. Zero height and zero offset are not compared.

**Confirmed come-ups are never solved from, and belong to one zero range.** The list (`come_ups`) holds dials the shooter hit, each stamped with its zero range. A changed zero range makes earlier ones stale: out of every comparison until it is set back.

## The fields

**`rifle.zero_range_yd`** The distance, in yards, at which the rifle is zeroed. Required; a new profile starts at 100. Example: 200.

**`rifle.zero_env`** The air the rifle was zeroed in: one object with three numbers, all required when the object is present, and an optional fourth, the zero altitude (its own entry below). They are `rifle.zero_env.temp_F` (air temperature, degrees Fahrenheit), `rifle.zero_env.rh_pct` (relative humidity, percent) and `rifle.zero_env.station_pressure_inHg` (station pressure in inches of mercury: the actual pressure at the firing point, not a weather report's sea-level number). Nothing else is stored about the zero air: no latitude, no place and no date. Absent (the default): zeroed in the air of each shot. Writers remove the key to say that; they do not store an empty object.

**`rifle.zero_env.altitude_ft`** The altitude the rifle was zeroed at, in feet above sea level. Optional, and only inside recorded zero conditions; absent means the zero's air takes the altitude of the shot, as before 2026-10-13. Only a model that reads a site altitude uses it (the three classic values of the [lapse](../options/lapse.md) option and the classic temperature taper). The profile screen's Zero section shows its box when the saved profile's model reads one, or when the profile already holds a value. For a model that does not read one, a zero altitude is not a different zero: changing it asks nothing about the calibration and sets no earlier group aside. Limits: -1,500 to 20,000 ft in the app, the connector and the record schema. **It changes nothing for an ordinary zero** (run 2026-10-13: a 100 yd level zero and a 600 yd zero on a 30 degree slope solve to the same come-up, digit for digit, at a zero altitude of 0, 5,000 and 12,000 ft): during the zeroing flight the bullet stays near the rifle's own height, and the classic air changes only with height above or below the rifle. It showed only on a 1,500 yd zero down a 30 degree slope from sea level (about 0.025 mil at 2,000 yd), where the path passes the lowest altitude the model follows. The classic reference behaves the same: on 2026-10-14 its atmospheric zero (95 yd, level) at a zero altitude of 0 and of 10,000 ft gave the same come-up and velocity to 2,000 yd, digit for digit, on a level shot, and 0 against 5,000 ft did the same on a 30 degree downhill shot from a 5,000 ft site. Its zero has no slope, so the one case where the zero altitude moves our answer cannot be compared. Example: 1200.

**`rifle.zero_incl_deg`** The slope of the line of sight when the rifle was zeroed, in degrees; uphill is positive, downhill negative. Absent means level, and every reader treats a stored 0 exactly like an absent key. Writers differ in whether a 0 is stored: the profile screen removes the key at blank but stores a typed 0; the connector removes it when given 0, except that a 0 already stored stays (the connector sees no change). Example: a zero shot down a 6 degree slope is -6.

**`rifle.zero_height_in`** Where the group prints at the zero range with the scope on its zero setting, in inches above (plus) or below (minus) the aim point. Required; 0, the default, means on the aim point. Example: 0.5 for a group half an inch high.

**`rifle.zero_offset_in`** The same measurement sideways, in inches right (plus) or left (minus) of the aim point. Required; 0, the default, means on the aim point. Example: -0.25 for a group a quarter inch left.

**`rifle.zero_offset_use`** "Use my zero offsets in every solve": true makes every model solve with [zeroOffset](../options/zeroOffset.md) `'apply'`, so the zero height and offset count whatever the preset. Optional; absent (the default) or false leaves the model's own choice. The app's zero step turns it on when it saves offsets measured from a [zero check](../log/zero-check.md); the profile screen's Zero section has the switch.

**`come_ups`** At most 12 confirmed come-ups, each a dial the shooter hit, kept with the version. Optional; absent or empty means none. It is a list of the entries below, in the order they were made. A version saved with none stored carries no `come_ups` key at all. Nothing in the profile checks an entry against what the profile predicts.

**`come_ups[].id`** The entry's own id, made when it is added; `edit_profile` removes by it and `get_profile` lists it. Two entries never share one.

**`come_ups[].range_yd`** The range in yards, 25 to 5,000, and past the zero range the entry was made under. Two entries may share a range (two days' dials).

**`come_ups[].value`, `come_ups[].unit`** What was dialed from the zero, and `mil` or `moa`. A come-up must be -50 to 400 mil once the unit is applied; the schema's bounds are wider because they hold either unit. The unit is the entry's own: a scope that changes units later does not convert it.

**`come_ups[].zero_range_yd`** The profile's zero range when the entry was made, stamped by the writer and not typed. An entry whose stamp differs from the profile's present zero range is stale and in no comparison. Editing an entry stamps it with the present zero range.

**`come_ups[].date`** An optional day, `YYYY-MM-DD`.

**`come_ups[].temp_F`, `come_ups[].station_pressure_inHg`** Optional: the temperature (-40 to 130 °F) and station pressure (15 to 32 inHg) the come-up was dialed in. Each is used alone; one not given is read in standard air (59 °F, 29.92 inHg) by the screen's comparison and by `explain_dope`, unless the call gave a temperature or pressure, which then fills the missing part. The screen's row says which part was not given. Humidity is always 50 percent.

**`come_ups[].note`** An optional line of at most 80 characters, cleaned to one line where it is typed. It is someone else's text to a reader of a shared or copied profile.

**Limits, by writer.** App: the save check the profile screen's sections share. Connector: the input of `edit_profile` and `create_profile`. Conversion: the step every solve goes through first; a profile that fails it cannot be solved, and `edit_profile` and `create_profile` refuse to save it. Solve: what the solver itself refuses for one answer.

| Field | App | Connector | Conversion | Solve |
|---|---|---|---|---|
| `rifle.zero_range_yd` | 10 to 2000 | 10 to 2000 | > 0 | > 0 |
| `rifle.zero_env.temp_F` | > -459.67 | -40 to 130 | none | > -459.67 |
| `rifle.zero_env.rh_pct` | 0 to 100 | 0 to 100 | 0 to 100 | 0 to 100 |
| `rifle.zero_env.station_pressure_inHg` | > 0 | 15 to 32 | > 0 | > 0 |
| `rifle.zero_incl_deg` | -45 to 45 | -45 to 45 | -45 to 45 | -45 to 45 |
| `rifle.zero_height_in` | -20 to 20 | -20 to 20 | none | none |
| `rifle.zero_offset_in` | -20 to 20 | -20 to 20 | none | none |

Units are those of each field: yards, degrees Fahrenheit, percent, inches of mercury, degrees, inches. The record schema holds the zero range to the same range as the app and the connector (since 2026-10-15; the table at the top), and asks only that each other value is a number. A profile stored before that with a zero range outside it still loads and solves, and its next saved version, in the app or through the connector, must come back inside it. In the Solve column, -459.67 is absolute zero, and a zero range above 0 can still be refused: a zero no bullet can make (one that needs a bore angle beyond 45°) is answered as "out of range", like a target too far, not as a profile error. Both the app and the connector save such a value; every solve of the profile then answers out of range. The conversion drops a zero height or offset that is 0 or not a number and passes any other value on. The `what_if` values of `solve` and `range_card` take a zero height and offset of -20 to 20.

## Who writes it

**The app.** The profile screen's Zero section has every field: the zero range, the slope, the air with one tick, "Zeroed in ordinary conditions, close to where I will shoot", that stores no zero air (and, when the profile's model reads a site altitude, the altitude where you zeroed), "Zero height" and "Zero offset" (blank is saved as 0), and the switch "Use my zero offsets in every solve" (off removes the key). Below it, the zero history lists the profile's zero checks and opens the zero step, which can save the measured offsets as the next version with the switch on. Without a zero check, "Move the zero without a zero check" saves typed inches (where the groups land at the zero range, high and right plus) as the zero height and offset in the same way. A profile started in the app has a 100 yd zero, height and offset 0, and no zero air or slope. Every section saves through one check.

**The connector.** `edit_profile` takes `zero_range_yd`, `zero_air` (an object with `temp_F`, `rh_pct` and `station_pressure_inHg`, all three, or null to clear it), `zero_incl_deg`, `zero_height_in` and `zero_offset_in`. `create_profile` takes the same inputs and otherwise starts from the app's defaults. Give only what changes. A value equal to the stored one is not a change, and an edit with no change saves nothing. An input the tool does not know, `zero_env` among them, is not used, and the reply names it ("I ignored: ...").

**Confirmed come-ups: the app.** The Zero section's block "Confirmed come-ups" lists the entries, each with the profile's prediction and the gap, and adds, edits and removes them; it saves with the section's one Save, as one new version, and asks no keep-or-reset question. A stale entry cannot be edited, only removed. The block is read-only on an old version and on a shared profile.

**Confirmed come-ups: the connector.** `edit_profile` takes `add_come_ups` (each with a range, a value, a unit not given meaning the scope's, and optionally date, temperature, pressure and note) and `remove_come_ups` (ids): up to 12 in all, a bad one refuses the whole call, and the entry is stamped with the zero range that call saves.

**Copies and restores.** Copying a shared profile (in the app or with `copy_shared_profile`) writes the zero and the confirmed come-ups exactly as the sharer's record held them, and restoring a deleted profile writes it as the deleted version held it; neither applies the limits above. The [origin](origin.md) and [versions](versions.md) pages describe the two.

**Never fitted.** No calibration writes any of these fields; a fit carries them to the new version unchanged. The server does not set them.

## Who reads it

**The solver.** The conversion from the stored profile passes the zero range always, the zero air when present, the slope when it is a number other than 0, and the height and offset when each is a number other than 0.

Each solve then finds the bore angle that puts the bullet on the line of sight at the zero range: flown with no wind and no Earth rotation, on the zero slope, in the zero air if there is one and otherwise in the shot's air. The zero air is the stored `rifle.zero_env` with the latitude of the shot being solved and its own zero altitude, or the shot's site altitude when it has none, and its humidity becomes vapour by the solve's own [vapour](../options/vapour.md) choice, as the shot's does; every other air setting of the solve applies to it too. Only [zeroOffset](../options/zeroOffset.md) `'apply'` reads `rifle.zero_height_in` and `rifle.zero_offset_in`: the zero flight then passes the stored height above the line of sight instead of through it, and the offset's angle is taken off the windage; that page has the math. Two things turn it on: `rifle.zero_offset_use` under any model, or a Custom model's own choice; the same choice left in `model.options` under any other `model.preset` is ignored. On a zero slope the sight height enters the zero by the rule of [angle](../options/angle.md), and how the bore angle is used for each range is the subject of [flight](../options/flight.md).

**What "zeroed" then means in the answers.** With no zero air stored, a level shot in calm air at the zero range comes out at 0 whatever the air is. It is not 0 for every shot there: wind, a slope other than the zero's, or the Earth's rotation on a shot fired east or west each leave a small come-up at the zero range. With a zero air stored, a shot in other air differs from the same profile without one by the same small angle at every range: thousandths of a mil for a 100 yd zero, more for a longer zero. Windage is zeroed too: the sideways drift the bullet has at the zero range in calm air is taken off every windage, so a calm shot at the zero range reads 0 (with the option off). That drift is worked out in the shot's air and on the shot's slope, also when a zero air or slope is stored.

**The fitter.** A calibration solves every group with the zero of the version being fitted, and first drops the groups shot with a version whose zero range, zero air or zero slope is different (see "Changing it").

**The app.** Every answer the app works out (the Calculator, range cards, the range log, the Calibrate screen, the Workbench) goes through the same conversion. The editor and guided setup show the fields for editing.

**The connector.** Every tool that solves reads these fields through the conversion. Some look at the zero height and offset under any model:

- A `what_if` on `solve` or `range_card` that carries `zero_height_in` or `zero_offset_in` turns the option on for that one answer. The stored value of the other one then counts too, though the reply lists only the given one as changed. A `what_if` with neither leaves the option as the model has it.
- `sensitivity` always reports what 0.1 in of zero height is worth, with the option on for that figure.
- `check_profile` notes a zero height or offset that is stored while the profile's model does not use it.
- `explain_dope` refuses a come-up at or inside the zero range, offers "the zero is high or low" as one explanation of confirmed come-ups, and starts from the stored zero height only when the profile's model uses it.

**Confirmed come-ups.** Three readers, none a solve. The Zero section's comparison solves the profile for each entry in use, in the profile's own model and the air that entry carries, and prints the profile's figure and the gap; an entry the profile cannot reach says so, and a profile that cannot be solved shows the list with no figures. Today's line says how many are in use and the largest miss in clicks. A row whose entry has only one part of its air reads `70 °F, pressure not given` or `28.50 inHg, temperature not given`; the missing part is read in standard air (59 °F, 29.92 inHg). `explain_dope`, given no come-ups, tests the entries in use (see that page for the choice of six and the air). The solver, the fitter and the range card never read the list.

**The server.** The admins' pooled export lists each profile's zero range. It has no column for the zero air, slope, height or offset.

## Why it is stored

**Zero range.** A come-up is an angle from the zero, so no answer exists without it.

**Zero air and zero slope.** A rifle zeroed in cold, dense air or on a slope leaves the range with a slightly different bore angle than one zeroed level in today's air. Storing the conditions lets every model zero the rifle where it was really zeroed. They are facts about the rifle, not a choice of model, so all models use them when they are there.

**Zero height and zero offset.** Few rifles are zeroed exactly on the aim point. The two numbers record the measured miss so that the one option that corrects for it has something to read, and so the measurement stays with the profile while the model is one that ignores it.

## Changing it

An edit saves a new version and each logged group keeps the version it was shot with; the [versions](versions.md) page explains that once for every field.

**The edit category is "zero".** A change to the zero range, zero air, zero slope, zero height, zero offset or the "Use my zero offsets" switch keeps the calibration by default. In the app, a profile with anything a reset could remove (a fitted curve, a calibration record, a Spin trim, velocity-temperature rows, a scope tracking factor other than 1) gets the keep-or-reset question with "keep" already chosen. The connector saves a zero-only edit without asking and reports the calibration as kept; it resets only when called with `calibration` set to reset. An edit that also changes something in the "shape" or "ammo" category (the sight height, for example) defaults to reset. What a reset removes is on the [calibration](calibration.md) page.

**Confirmed come-ups.** Adding, editing or removing one saves a new version with the list changed and everything else carried over; the category is none, so a calibrated profile is not asked to keep or reset and the calibration is untouched. A change to the zero range keeps the list and makes the earlier entries stale. Restoring an old version in the app brings back that version's list, none at all if it had none. The versions list shows "Confirmed come-ups" with the count before and after.

**Older groups.** A fit is always of the profile's newest version, and it uses a group only if the version the group was shot with has the same zero range, the same three zero-air numbers (or none on both) and the same zero slope (absent and 0 are the same). A group that fails is listed as left out, with the reason; it is not deleted, and a later version that restores the old value makes it usable again. So after a change of zero range with the calibration kept, the profile still carries its fitted curve, but a new fit finds none of the earlier groups usable and has nothing to work with until groups are shot with the new version. How a group records its version is on the [observation](../log/observation.md) page.

**Zero height and zero offset do not affect which groups a fit uses.** (A turret move recorded by the zero step does: see [zero check](../log/zero-check.md).) Groups shot before and after such a change are fitted together, all of them solved with the newest version's zero; its height and offset count only when that version's model has the option on.

## Sharing and privacy

Every field on this page but three reads "as stored" in the table. The confirmed come-ups go wherever the zero goes, on a share with one person, with the public or with the admins, and the entry's note follows the notes switch: with notes hidden, `come_ups[].note` is removed from every entry (the browser and the server both do it), while the date, the temperature and the pressure are shown as stored. A copy of a shared profile keeps the entries. When the share hides location, `rifle.zero_env.altitude_ft`, the altitude the rifle was zeroed at, is removed (since 2026-10-13), and `rifle.zero_env.station_pressure_inHg` is rounded to 0.1 inHg (since 2026-10-15). For the rest, a reader of a shared log sees the zero exactly as the owner does, on a share with one person, with the public or with the admins, and whether or not location is hidden. The zero air carries no place and no date. Its station pressure, even rounded, shows roughly how high above sea level the rifle was zeroed.

---

[All reference pages](../index.md)
