Skip to content

Rails

The rail web — a relic's navigation graph, solved once from its geometry.

Overview

A volume describes the space. It does not describe the ways through the space, and until this module every trip re-derived them:

eva_goto -> volume_doorways   (O(P^2), uncached)
         -> volume_skirts     (uncached)
         -> an N^2 visibility graph
         -> Dijkstra

Measured on the shipped ruins, that was 96 ms for the first destination a console picked and 17 ms for every one after — per console, per button press, on a bridge. And connectivity is not a per-trip question: it is a property of the ruin. So it is solved when the relic is built, and after that a route is a Dijkstra over cached edges:

before after
first route 96 ms 0.83 ms
every later route 17 ms 0.83 ms
solving the web 92 ms, once per relic

Nothing about the web is authored

There is no Link:, no edge list and no hand-drawn graph. An author writes rooms and Point: records exactly as before; the web is derived from them. What this adds is density and attachment:

  • a room is no longer one node at its center. A 2800-unit freight hall becomes a line of stations along its own long axis, so there is more than one way across it and a route can hug a wall to get round a pillar rather than having only the one path;
  • everything worth flying to is in the graph — a cache, a quest piece, a trigger point. A destination list built from the web therefore offers the things in the ruin, not only the rooms.

Five kinds of node, and only the first is authored:

kind comes from offered as a destination
place a Point: record yes, unless Hidden:
content an Item: or Spawn: when it is placed yes
doorway measured where two primitives overlap no
skirt a ring clear of each subtracted mass no
spine subdividing each room along its own axes no

Derived nodes are keyed with an @ prefix (@spine:14), which no authored key can spell.

Keeping the solve affordable

Density multiplies the node count and the visibility test is the expensive part, so an all-pairs pass is not acceptable even once. Four measures, in the order they matter:

  1. Volume.inside — a visibility walk asks "is this sample in the clear", never "how far is the wall", so the first primitive that swallows a sample settles it.
  2. Spatial bucketing — only pairs within RAIL_EDGE_SPAN are candidates at all, so the edge pass is near-linear in the node count rather than quadratic.
  3. A degree cap — without it an open room is a complete graph. Ten short legs out of a node is more than any route uses, and the cap is measured against the shortest candidates, so what is dropped is the long diagonals a route would not take.
  4. A node budget — the step grows until the web fits RAIL_MAX_NODES. A relic cannot make itself unbuildable by being large.

Rail step: on the relic is the one dial over it: units between spine nodes, default 450, smaller is denser.

It must stay joined up

A denser web is worthless if part of it cannot be reached, so rail_build counts connected components at the end and bridges any split with the closest visible cross pair it can find. A relic that is genuinely in two pieces still reports as two — that is content, not a solver failure, and rail_stats is what says which.

stats = rail_stats("voice")
stats["components"]    # 1 is what you want
stats["orphans"]       # authored places nothing can see

sbs lint runs this at author time: relic-disconnected, relic-unreachable-node and relic-barrier-seals build the web the way the game will and report what came out, so "part of this relic cannot be flown to" is a line number rather than something a player discovers.

Barriers

A barrier is how a relic gets a shut door without anything authoring an edge. It is a sphere in the world with a position and a size — so it can be dressed with a prop and pointed at by a suit's tools — and what it does to the graph is derived, like everything else here: every leg crossing it is severed until it opens.

### [the seized hatch](hatch)
---
Relic: voice
Barrier: 900, -1600, 0, 240
Clear with: beam
---
A pressure hatch, seized shut across the shaft. The bay is below it - and there is a long
way round through the sorting floor, if you would rather not cut.

Opens when: uses the same trigger grammar as Starts when:. A barrier with neither Opens when: nor Clear with: can never open, which is a legitimate thing to author and which lint reports if it walls anything off.

Hidden places

Hidden: yes on a Point: takes it off the destination list until it is found — by flying near it, or by anything that calls rail_reveal. It is a property of the list, never of the graph: a route still passes through a hidden place, because stumbling into a secret on the way somewhere else is the point of having one.

Routing

rail_route(name, start, goal, open_only=True)

goal is a node key or a position. It returns [] when there is no way — never a straight line. A relic has no engine collision at all, so answering with the direct line when the route could not be found means flying through the rock, which is the one thing this whole layer exists to prevent.

open_only=False asks "would there be a way if this opened", which is what the lint uses.

Diagnostics

In a running session, over the debug channel:

{"action": "rails"}                 # stats for every web
{"action": "rails", "name": "voice", "full": true}   # nodes, edges and barriers

rail_dump(name) is the same data in-process.

In the relic editor, press Rails. The panel asks the running session for exactly that and draws it over the geometry: authored places stand out of the derived fill, a leg a shut barrier is severing is red and dashed, and a hidden node is ringed. The button carries the summary. Any edit clears it, because a web drawn over geometry that has since moved reads as the solver disagreeing with the file.

See also

The RAIL WEB: a relic's navigation graph, solved once from its geometry.

A relic interior is a navigable VOLUME (volume.py) - rooms as spheres and boxes, passages as capsules, pillars subtracted. That describes the SPACE. It does not describe the ways THROUGH the space, and until this module every trip re-derived them::

eva_goto -> volume_doorways (O(P^2), uncached)
         -> volume_skirts   (uncached)
         -> an N^2 visibility graph
         -> Dijkstra

measured at 96ms for the first destination a console picks and 17ms for every one after - per console, per button press, on a bridge. And connectivity is not a per-trip question: it is a property of the RUIN. So it is solved once, when the relic is built, and after that a route is a Dijkstra over cached edges with no geometry touched at all.

WHAT IS AUTHORED, AND WHAT IS NOT

No edge is ever authored. An author writes rooms and Point: records exactly as before; the web is derived from them. What this module adds is DENSITY and ATTACHMENT:

  • a room is no longer one node at its center. A 2800-unit freight hall becomes a line of stations along its own long axis, so there is more than one way across it and a route can hug a wall to get round a cradle rather than having only the one path;
  • everything worth flying to is IN the graph - a cache, a quest piece, a trigger point. A destination list built from the web therefore offers the things in the ruin, not only the rooms.

The one authored dial is a step size, and the one authored obstacle is a BARRIER - a sphere that severs every edge crossing it until something opens it. A barrier is a thing in the world, so it can be dressed, targeted and drawn. An edge cannot.

WHY THE SOLVE STAYS AFFORDABLE

Density multiplies the node count and the visibility test is the expensive part, so an all-pairs pass is not acceptable even once. Three measures, in the order they matter:

  1. Volume.inside - a visibility walk asks "is this sample in the clear", never "how far is the wall", so the first primitive that swallows a sample settles it.
  2. Spatial bucketing - only pairs within RAIL_EDGE_SPAN are candidates at all, so the edge pass is near-linear in the node count rather than quadratic.
  3. A node budget - the step grows until the web fits RAIL_MAX_NODES. A relic cannot make itself unbuildable by being large.

AND THE ONE THING THAT MUST NOT REGRESS

A denser web is worthless if it is not JOINED UP. Seven shipped relics fly today and all seven must still fly, so rail_build counts connected components at the end and bridges any split with the closest visible cross pair it can find. A relic that is genuinely in two pieces still reports as two - that is content, not a solver failure, and rail_stats is what says which.

rail_attach(name, key, pos, kind=KIND_CONTENT, roles=None, display=None, hidden=False)

Add a node to a web that is already built, and wire it in.

A relic's contents do not all exist when it is built - Starts when: reach ... places a cache the first time somebody gets near the room holding it. Rebuilding the whole web for one new thing would be absurd, so a late node tests visibility against its own neighborhood only, exactly as the build pass does.

rail_barrier(name, key, pos, radius, display=None, is_open=False)

A sphere that severs every rail edge crossing it.

This is how a relic gets a shut door WITHOUT anything authoring an edge. A barrier is a thing in the world - a seized hatch, a fall of rock - so it has a position and a size, it can be dressed with a prop and the weapons app can be pointed at it. What it does to the graph is derived, like everything else here.

rail_barrier_approach(name, key, from_pos=None)

The node to fly to in order to work on a barrier, or None.

A barrier sits IN the way, so it is not a place - the node beside it is. This answers with the nearest end of an edge the barrier is currently severing, which is by construction both somewhere a route can reach and somewhere the barrier is in reach of.

rail_barrier_is_open(name, key)

True only for a barrier that exists and is open.

rail_barrier_object(name, key)

The space object standing in for this barrier, or None.

rail_barrier_of_object(name, obj_id)

Which barrier this object stands in for, or None. What a damage route asks.

rail_barrier_open(name, key)

Open one. False when there is no such barrier, or it was open already.

rail_barrier_set_object(name, key, obj_id)

Bind a barrier to the SPACE OBJECT standing in for it.

A barrier is a sphere in this graph, and a sphere is not something a beam can hit - which is why cutting one was scripted. Give it a real object and the engine's own weapons work on it: the crew point the suit at a thing, the beam draws, and the barrier opens when the thing dies.

rail_barrier_shut(name, key)

Close one again.

rail_barriers(name, shut_only=False)

[(key, record)] - what the weapons app lists and the editor draws.

rail_build(volume, places=None, margin=None, step=None, name=None, lane=None)

Solve a volume's rail web and register it. Returns the stats dict, or None.

Parameters:

Name Type Description Default
volume

a Volume or its registered name.

required
places dict

the authored destinations, key -> (x, y, z) or key -> {"pos", "roles", "display", "hidden", "kind"}. These keep their own keys, so a route can end at a name a person chose.

None
margin float

how far inside the wall a node and a leg must stay. Defaults to RAIL_MARGIN, which explains why it is the ship's number rather than the relic's.

None
step float

spine spacing. Defaults to RAIL_STEP, and GROWS from there until the web fits RAIL_MAX_NODES.

None
name str

register under this name instead of the volume's.

None

Derived nodes are keyed with an @ prefix (@spine:14), which no authored key can spell - so an authored place can never be shadowed by one.

rail_clear()

Drop every web. On the reset ledger beside volumes.

rail_count()

Reset-ledger probe. Must NOT create anything by asking.

rail_detach(name, key)

Remove a node and every edge touching it.

rail_dump(name)

The whole web as plain data - the editor overlay's source, and a test's eyes.

rail_get(name)

The web registered under name, or None.

rail_hide(name, key)

Take a node off the destination list without taking it out of the graph.

A secret is still somewhere a route may pass THROUGH - stumbling into a hidden room on the way somewhere else is the point of having one.

rail_is_hidden(name, key)

True only for a node that exists and is hidden.

rail_lane(name)

The clearance this web was built to aim for. None when there is no such web.

rail_leg(name, a, b)

The two ends of one edge, for a rope or a drift frame. None if there is no edge.

rail_leg_clearance(name, a, b)

How much room the leg between two nodes has at its tightest, or None.

Measured once at build time and cached, so the flight layer can size a rope, a drift or an arrival radius against the leg it is actually on rather than against a constant that assumes a wide hall. That mismatch is the whole of "it drives into walls": the planner proved 20 units and the flight helped itself to 140.

rail_names()

Every registered web.

rail_node(name, key)

One node's record, or None.

rail_node_pos(name, key)

One node's position, or None.

rail_nodes(name, kind=None, role=None, listed=None)

[(key, record)], in seed order.

Parameters:

Name Type Description Default
kind str

one of the KIND_* values.

None
role str

only nodes carrying this role.

None
listed bool

True for nodes a destination list should offer - not hidden, and not a derived waypoint. False for the rest.

None

rail_reachable(name, start_key, open_only=True)

Every node reachable from one, as a set of keys. What the lint counts.

rail_remove(name)

Drop one web - the galaxy-safe counterpart to rail_clear, which would take the ruin the crew is standing in.

rail_reveal(name, key)

Put a hidden node back on the destination list.

rail_route(name, start, goal, open_only=True)

Waypoints from a position to a node key (or to a position), along the web.

Returns [] when there is no way - never a straight line. A relic has no engine collision at all, so answering with the direct line when the route could not be found means flying THROUGH the rock, which is the one thing this whole layer exists to prevent.

Parameters:

Name Type Description Default
start

where the ship is.

required
goal

a node key, or a position.

required
open_only bool

refuse to route through a shut barrier. Turn it off to ask "would there be a way if this opened", which is what the lint uses.

True

rail_stats(name)

What the solve produced: nodes, edges, components, build time. A dict, or None.

components is the number worth watching. More than one means part of the ruin cannot be flown to from the rest, which is the difference between "my router is wrong" and "this relic is not joined up".