# The Astrolabe and Harrow

The Terrestrial Astrolabe is an endgame ritual device. Each activation permanently escalates the Maw, seeds exotic materials into the world, and wipes your machines — an irreversible trade you make on purpose. The Artificer's Harrow is the design-your-own challenge that the Astrolabe's final specialist unlocks. The code lives in `scripts/core/AstrolabeManager.gd`, the four specialists in `scripts/village/SpecialistManager.gd`, and `scripts/core/HarrowManager.gd`.

## Building the Astrolabe: the four specialists

The Astrolabe is gated behind four specialists who arrive in a fixed order, each triggered by a different milestone. `SpecialistManager` (`scripts/village/SpecialistManager.gd`) owns the arrival state, which lives in `GameManager.current_run["specialists_arrived"]` so it saves and restores with the run.

| Order | Specialist | Trigger | Signal handler |
|---|---|---|---|
| 1 | The Wandering Archivist | The village bell rings | `_on_village_bell_rang` |
| 2 | The Stonewarden | A tile is dug at the `quartz` layer depth | `_on_tile_dug` |
| 3 | The Assayer | Any material in `EXOTIC_IDS` enters inventory | `_on_inventory_changed` |
| 4 | The Artificer | Population reaches `ARTIFICER_POPULATION`, all three others present | `_on_population_changed` |

`EXOTIC_IDS` for the Assayer trigger is `["ember_essence", "void_iron", "ancient_clay", "prismatic_shard"]`.

### The Artificer population gate

The Artificer is the slowest gate in the run. Its threshold is read from `data/balance.json` -> `village` -> `artificer_population` (default 120), and the arrival additionally requires the other three specialists to have arrived first:

```gdscript
func _on_population_changed(new_pop: int) -> void:
	if has_arrived("artificer"):
		return
	if new_pop < ARTIFICER_POPULATION:
		return
	if not has_arrived("archivist") or not has_arrived("stonewarden") or not has_arrived("assayer"):
		return
	_arrive("artificer")
```

> CLAUDE.md notes the Artificer gate as `village.artificer_population` and the constant default as 120; both the balance file and `SpecialistManager.ARTIFICER_POPULATION` agree at 120. (The `DIALS` catalog in `HarrowManager` also references 47 as the baseline village population for its "souls" dial, which matches `village.base_population`.)

Each `_arrive()` appends to `specialists_arrived`, emits `EventBus.specialist_arrived`, writes a gold journal narrative, and (after a short defer) shows a walk-in popup panel with the specialist's portrait, body text, and a hint pointing at the next arrival. `all_arrived()` reports whether every specialist in `SPECIALIST_DATA` has come.

## The ritual cutscene trigger

Placing the **third** Binding Stake in the world (not crafting it) fires the Astrolabe cutscene. `AstrolabeManager._on_machine_placed` listens to `EventBus.machine_placed`:

```gdscript
if has_astrolabe() and count_binding_stakes() >= 3:
	GameManager.current_run["astrolabe_cutscene_seen"] = true
	EventBus.astrolabe_cutscene_triggered.emit()
```

The `astrolabe_cutscene_seen` run flag guards against replay: `machine_placed` also fires as a save loads its stakes, and `current_run` is restored before machines, so the flag is already correct by the time load-time signals arrive. A separate `astrolabe_components_modal_seen` flag drives a one-time celebration modal the first time the Astrolabe itself is built.

`can_activate()` returns true when `has_astrolabe()` and `count_binding_stakes() >= 3`.

## The three sliders

`activate()` is called by the Astrolabe panel with three slider values:

| Slider | Range | Meaning |
|---|---|---|
| `slider_volume` | `0.0`–`1.0` | Tile restore volume (maps to 10%–90% of dug tiles refilled) and the primary chew-rate increase. |
| `slider_discovery` | `-1.0`–`1.0` | World-wide discovery density (-50%–+50%); also adds to the chew cost. |
| `slider_depth` | `-1.0`–`1.0` | Shallow vs deep bias for restoration; deep focus degrades strange-root defense. |

## Permanent Maw escalation (`astrolabe_chew_mult`)

Every activation stacks a **permanent** chew-rate multiplier stored in `current_run["astrolabe_chew_mult"]`. `get_chew_rate_mult()` reads it (default 1.0), and it is consumed by the Maw's live tick, breach ETA, and offline sim alike (see [The Maw](/docs/underroot/the-maw)). Despite its name, `compute_chew_increase()` returns a **growth rate**, not an addend:

```gdscript
func compute_chew_increase(slider_volume: float, slider_discovery: float) -> float:
	var disc_norm := (slider_discovery + 1.0) * 0.5      # 0.0–1.0
	var disc_cost := CHEW_INCREASE_DISCOVERY_MIN + disc_norm * (CHEW_INCREASE_DISCOVERY_MAX - CHEW_INCREASE_DISCOVERY_MIN)
	var base_increase := CHEW_INCREASE_BASE + slider_volume * CHEW_INCREASE_VOLUME + disc_cost
	return base_increase * ChallengeManager.ritual_chew_mult()
```

`activate()` then **compounds** it:

```gdscript
var new_mult := get_chew_rate_mult() * (1.0 + compute_chew_increase(slider_volume, slider_discovery))
```

Growth per ritual is ×1.30 at min sliders, ×1.465 at mid, ×1.63 at max. The stack is **deliberately uncapped** — the run ends when the player dies, not at a ceiling, and nothing downstream needs a bound because each front consumes at most one wall column per frame regardless of rate.

> **This was additive** (`stack + increase`) until the compounding pass. Additively the curve decelerated — at mid sliders ritual 1 cost +82% of standing threat and ritual 9 cost +11% — so the rituals meant to be the most dangerous decision were the cheapest. The four constants were rescaled at the same time; a growth rate needs smaller numbers than an addend.

The `ChallengeManager.ritual_chew_mult()` hook (the Ravenous Maw challenge, the Harrow's Reckoning dial) is applied inside `compute_chew_increase` — the single source — so the panel's cost preview and the real activation agree. Consequence tuning defaults live in `AstrolabeManager` and are overridden by `data/balance.json` -> `astrolabe`:

| Constant | Balance key | Default |
|---|---|---|
| `CHEW_INCREASE_BASE` | `chew_increase_base` | `0.08` |
| `CHEW_INCREASE_VOLUME` | `chew_increase_volume` | `0.18` |
| `CHEW_INCREASE_DISCOVERY_MIN` | `chew_increase_discovery_min` | `0.22` |
| `CHEW_INCREASE_DISCOVERY_MAX` | `chew_increase_discovery_max` | `0.37` |
| `EXOTIC_CHANCE_PER_USE` | `exotic_chance_per_use` | `0.02` |
| `EXOTIC_CHANCE_CAP` | `exotic_chance_cap` | `0.20` |

**Calibration.** The four chew constants are solved backwards from one anchor: after 12 max-slider rituals the Maw eats a full 9-stack `glazed_vault_block` column (45,000 HP, resistance 7.0) in ~60 s, against `MawAdaptation.MAX_MULTIPLIER` familiarity at `MawController.PRESSURE_MAX`:

```
45000 / 60 s = 750 HP/s  ->  needs stack x350  ->  350^(1/12) = x1.63 per ritual
```

`tools/astrolabe_curve_check.gd` asserts that target against the live `balance.json` and prints the 12-ritual table (`CURVE OK` / `CURVE FAIL`). It mirrors the formula rather than calling it — autoloads are unavailable to `--script` runs — so a formula change needs updating in both places.

### Presenting the cost to the player

**Two preview call sites mirror the compounding arithmetic** and will quote a false price if they drift: `MachineController._refresh_ritual_preview` (the slider panel's "Maw speed after" consequence row) and the fire-confirm modal, both `current_mult * (1.0 + compute_chew_increase(...))`.

Both show the **growth rate** next to the absolute before/after:

```
Maw speed after   ×3.14  (now ×2.20 — grows 46%)
Maw speed: ×2.20 → ×3.14   (permanent, +46%)
```

That percentage is not decoration — **keep it.** Two absolutes alone read as a flat "+0.94" and invite a linear extrapolation, which was harmless while the stack was additive and is wrong by an order of magnitude now (at ritual 9, max sliders, the real jump is +31, not +1.25). The rate is constant for a given slider position however many rituals have been fired, so a player who fires max sliders repeatedly sees "grows 63%" every time and learns the rule by repetition.

`MachineController._fmt_chew_mult()` sheds decimals above ×10 and ×100 — the consequence row has ~180px inside a 300px panel and the stack compounds into the hundreds. The `Hunger ×N` readout in `MawBar` / `MawMedallion` applies the same rule for the same reason.

## Binding Stakes (strange root defense degrades)

The ritual also degrades the strange-root bounce defense via the depth slider. `depth_factor = (slider_depth + 1.0) * 0.5` (0 shallow, 1 deep):

- **Bounce duration** shrinks: `astrolabe_bounce_dur` loses up to `BOUNCE_LOSS_MAX` (8 s), floored at `MIN_BOUNCE_DURATION` (5 s). Read via `get_bounce_duration()`, base 30 s.
- **Bounce cooldown** grows: `astrolabe_bounce_cd` gains up to `BOUNCE_CD_INCREASE_MAX` (0.5), capped at `MAX_BOUNCE_COOLDOWN` (4.0). Read via `get_bounce_cooldown_mult()`.

These feed the Maw's `repel()` (stun duration) and `consume_tile()` (column cooldown) respectively.

## The Maw wall jump

`activate()` emits `EventBus.astrolabe_maw_jump.emit(wall_pct)` where `wall_pct = RESTORE_PCT_MIN + slider_volume * RESTORE_PCT_SPAN`. `MawController` handles this by breaching that fraction of **each front's own** standing wall volume, eaten from the Maw side inward — the Maw is deliberately not lunged forward, so firing the ritual can never be an instant breach. In a Two Fronts run the east wall gives immediately and each other front's wall breaches after a randomised delay (`GameConstants.ASTROLABE_WEST_BREACH_DELAY_MIN`/`MAX`, 3-10 s). See [The Maw](/docs/underroot/the-maw) for `_breach_front_walls`.

## Machine wipe

Every ritual wipes machines through `_wipe_machines()`: the Astrolabe and all Binding Stakes are destroyed entirely (consumed by the ritual), duplicates of every other machine type are destroyed (one survivor kept per type), and survivors have their fuel wiped and are deactivated. Removal routes through `MachineManager.remove_machine()` so the `_occupied_tiles` spatial index is cleared too — a bare `machines.erase()` would leave ghost footprints that block all future placement.

> The Astrolabe **Core** and **Lens** are one-per-run inventory tokens, not placed machines, so they survive the wipe. Owning a Core installs the permanent x2 crafting boon; owning a Lens enables machine relocation and parallel crafting. `has_core()` / `has_lens()` check `Inventory.get_amount(...) > 0`.

### Survivors are neither filled in nor walled off

The wipe runs **before** the tile restore, so whatever is still standing has to survive the refill. Two guarantees hold:

1. **Never filled in.** `_restore_tiles()` passes `MachineManager.occupied_tiles()` to the planner as an exclusion set, *and* re-checks it at the write site as it applies the plan. The planner's exclusion alone was advisory — nothing downstream enforced it — so the guarantee now holds regardless of what the planner decides.
2. **Never sealed in.** Excluded tiles were originally kept out of the fill without being kept *reachable*, so a restore could pack every route solid and leave a surviving machine in a bubble: its own tiles open, no way in. The `count >= total` branch did this to every machine by construction. `WorldManager._spare_protected_access()` now walks out from any protected tile that has lost its last open neighbour, breadth-first through the planned fill, and un-fills the shortest corridor back to open ground — or to daylight, which is the only thing left to reach when the restore is filling everything. It costs a handful of tiles out of the restore volume; machines sitting in open tunnels short-circuit before the search starts.

## Exotic seeding

After the first activation, `astrolabe_exotics_seeded` is set and exotics begin appearing through two disjoint paths:

- **Restored caverns (random).** `_restore_tiles()` refills dug tiles chosen by `WorldManager.plan_connectivity_safe_fill()`, which honours the depth bias, never seals a cavern off from the surface, and protects standing machines (above). Each refilled tile has a chance `EXOTIC_CHANCE_PER_USE * uses` (capped at `EXOTIC_CHANCE_CAP`, scaled by a depth multiplier) to be upgraded to an exotic via `_pick_exotic_for_depth()`.
- **Undug ground (deterministic, Path B).** `roll_exotic_for_tile(x, y)` is called from `LayerGenerator.generate_tile` for every solid tile. It is stable per `(x, y, uses)` using `EXOTIC_TILE_SALT` / `EXOTIC_PICK_SALT` hashes, costs nothing until the first ritual (`uses == 0` returns `""`), and only fires within the exotic depth band (`EXOTIC_DEPTH_FLOOR` 20 to `EXOTIC_DEPTH_CEIL` 320).

Exotic materials and their approximate tile-Y depth ranges live in `EXOTIC_SEED_MATERIALS` and `EXOTIC_DEPTH_RANGES`; `EXOTIC_SEED_WEIGHTS` biases the weighted pick so machine-gating mats appear reliably without starving the rarer astrolabe-exclusive endgame exotics.

Note the deliberate asymmetry with the chew curve: the exotic chance flattens at `EXOTIC_CHANCE_CAP` from ritual 10, while the chew stack compounds without limit. Rituals past 10 buy discovery density and refilled cavern at a steeply rising price — which is what makes stopping a real decision.

## Discovery density (compounding)

Each ritual permanently scales world-wide discovery rates by `1.0 + slider_discovery * 0.5` (0.5x–1.5x), clamped between `DISCOVERY_MULT_MIN` (0.25) and `DISCOVERY_MULT_MAX` (2.5) so repeated rituals cannot reach absurd extremes. Max slider reaches the ceiling by about the third ritual (`1.5^3` clamps down to 2.5); rituals beyond that pay off in exotic seeding and Maw hunger, not more discovery density. Read via `get_discovery_mult()` and applied at dig time in `DiscoveryManager`. `discovery_mult_for(def)` excludes "bad" discovery types (`DENSITY_EXCLUDED_REWARD_TYPES`: `maw_resonance`, `plague_spore`, `hazard`) — a denser world never means more resurgences, plagues, or hazards.

The mult is applied to each discovery's **layer base** chance. The mushroom near-water proximity boost is the one exception: its flat `0.15` / `0.08` values are a designed absolute spawn rate, so `_discovery_chance` treats them as a **floor** — `max(base × mult, boost)` — rather than a base to re-multiply. Before this fix the mult tripled the boost (`0.15 → 0.45`) and, because water-pocket density also scales with the mult, ~94% of tiles ended up "near water" and mushrooms blanketed the mid layers (measured ~80% tile coverage at the cap; ~50% after the fix + the 2.5 ceiling).

> Both the discovery mult and the chew stack compound, but only the discovery side is clamped. That is intentional: the reward plateaus, the cost does not.

## Reset

`reset()` erases every `astrolabe_*` key from `current_run` (uses, chew mult, bounce dur/cd, exotics-seeded, discovery mult, and the two modal flags) so a brand-new run starts clean.

## The Artificer's Harrow

The Harrow (`scripts/core/HarrowManager.gd`) is the 7th Challenge card: a design-your-own-challenge system unlocked once the Artificer specialist's work is mastered. A Harrow config is a dictionary:

```text
{harrow: true, version, name, author, includes: [...challenge_ids], dials: {dial_id: t}}
```

where `t` in `(0, 1]` is severity (`t = 0` means the dial is at baseline and omitted). The active config for a run lives in `current_run["harrow"]`. `HarrowManager` owns the catalog, the unlock gate, config hygiene, and the codes; the **effect math lives in `ChallengeManager`** (the multiplier owner) — the Harrow only supplies severity values.

### The dial catalog

`DIALS` defines twelve dials across three clusters (`CLUSTERS`): "THE MAW AND THE WORLD", "YOUR WORKS FAIL YOU", and "THE VILLAGE IS FRAGILE". Each dial has a `kind`:

- `mult`: `dial_value()` lerps `base`->`worst` by `t`, multiplied on top of Challenge multipliers.
- `abs`: an absolute value consumed via `min()`/`lerp` at its effect site (e.g. `grace` 360 s -> 30 s, `souls` 47 -> 1).
- `composite`: consumers read `dial_t()` and apply their own lerps (one named idea driving several knobs).

Ranges are harder-only by construction: `t = 0` is always baseline. Representative dials include `hunger` (pressure growth x1 -> x4, and at full turn the cap rises too), `learning` (x1 -> x3), `ritual_fury` / "The Reckoning" (x1 -> x2.5), `tools` / "Green Iron" (x1 -> x0.4), `walls` / "Bad Mortar" (x1 -> x0.4), and `souls` / "The Few" (47 -> 1).

> `ritual_fury` multiplies the ritual **growth rate** now that the stack compounds, so it is considerably sharper than its x2.5 label suggests across a multi-ritual run. `ChallengeManager._CAP_RITUAL_CHEW` (4.0) is the backstop on the combined Ravenous-Maw-times-dial figure.

### The unlock gate

The Harrow is account-wide and unlocks when every built-in challenge has been mastered — i.e. you own all six challenge mastery trophies:

```gdscript
func is_unlocked() -> bool:
	for id: String in CosmeticManager.CHALLENGE_TROPHY.keys():
		if not CosmeticManager.is_owned(str(CosmeticManager.CHALLENGE_TROPHY[id])):
			return false
	return true
```

Because it keys off cosmetic ownership, the gate survives resets and restores. `_maybe_announce_unseal` fires a one-time toast (guarded by `SaveManager.harrow_announced()`) when the sixth trophy lands.

### Config hygiene and codes

`sanitize(cfg)` is the single funnel for all external input (redemption codes, the design library, the UI). It rejects anything that is not a harrow payload, clamps every severity into `(0, 1]`, drops sub-baseline or non-numeric dials, dedups and filters `includes` against `ChallengeManager.ORDER`, and caps the name at `NAME_MAX_LEN` (24). A hand-tampered code therefore cannot sneak below baseline and still post scores. Configs round-trip as `UROOT1-` codes (deflate + base64, same encoding as save codes) via `export_code()` / `decode_code()`.

`pending_config` is staged by the Harrow screen's Begin button and read by `GameManager.reset_for_new_run()` into `current_run["harrow"]`, then cleared — mirroring `ChallengeManager.pending_selection`.

### Verification

`tools/harrow_check.gd` is a headless logic check (run as a temp autoload `ZZHarrowCheck`). It exercises `sanitize` (tampered payloads dropped, over-range clamped, sub-baseline dropped), code round-tripping, active-config queries, the `ChallengeManager` effect stacking (dial x included-challenge, then floors/caps), and the run-start `souls` scaling. It prints `HARROW OK` / `HARROW FAIL` and quits with a matching exit code.

## Key files

| File | Role |
|---|---|
| `scripts/core/AstrolabeManager.gd` | Ritual activation, permanent chew stack, exotic seeding, discovery density, machine wipe. |
| `scripts/world/WorldManager.gd` | `plan_connectivity_safe_fill()` and `_spare_protected_access()` — the restore planner and its machine guarantees. |
| `scripts/village/SpecialistManager.gd` | Four specialists, arrival triggers, Artificer population gate, walk-in popups. |
| `scripts/core/HarrowManager.gd` | Harrow dial catalog, unlock gate, config sanitize/encode/decode. |
| `tools/astrolabe_curve_check.gd` | Headless chew-curve gate (`CURVE OK` / `CURVE FAIL`); prints the 12-ritual table. |
| `tools/harrow_check.gd` | Headless Harrow logic gate (`HARROW OK` / `HARROW FAIL`). |
| `data/balance.json` (`astrolabe`, `village`) | Consequence curve and `artificer_population`. |

## Related

- [The Maw](/docs/underroot/the-maw)
- [The Astrolabe Consequence Curve](/docs/underroot/the-astrolabe-consequence-curve)
- [World and Terrain](/docs/underroot/world-and-terrain)
- [Challenges](/docs/underroot/challenges)
- [Projects and Specialists](/docs/underroot/projects-and-specialists)
- [Machines](/docs/underroot/machines)
- [Codes and Redemption](/docs/underroot/codes-and-redemption)