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:
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.- Spatial bucketing — only pairs within
RAIL_EDGE_SPANare candidates at all, so the edge pass is near-linear in the node count rather than quadratic. - 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.
- 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
- Volume — the space the web is solved from
- Relic interiors — authoring one
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:
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.- Spatial bucketing - only pairs within
RAIL_EDGE_SPANare candidates at all, so the edge pass is near-linear in the node count rather than quadratic. - 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 |
required | |
places
|
dict
|
the authored destinations, |
None
|
margin
|
float
|
how far inside the wall a node and a leg must stay.
Defaults to |
None
|
step
|
float
|
spine spacing. Defaults to |
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 |
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".