Skip to content

Relics (AMD)

Reads a relic interior out of an .amd file and builds it — the declarative front end to Volume.

Overview

A relic is authored as flat sibling records in a Relics section: one record for the relic itself, one per chamber, box or subtracted solid, joined by a Relic: field. See Relic interiors for the authoring guide; this page is the function list.

Two things the reader remembers that are easy to overlook, because both exist to make live preview work without any code in the mission:

  • Where the relic came from. relics_load and relics_build stamp the file path and section onto every record, so relic_reload can re-read it later. A relic assembled in code has no source and is not reloadable — deliberately, and the tools say so rather than pretending.
  • Which volume it built. A mission may build a relic under a name of its own. Anything that guesses the record's key instead then addresses a volume that does not exist and silently does nothing — which is exactly how one demo's authored Scrape band: never reached its watcher. relic_volume_name is the one right answer, and relic_contain and relic_reload both use it.

Quick example

# build it, and start containment from the authored fields
rec = relics_build("maps/ossuary.amd")
relic_contain(rec)

# later - the editor's Preview button does this for you
relic_reload("ossuary")

A rebuild replaces the volume in place under the same name, so a live volume_watch follows it automatically, keeping its margin and hold. It then emits relic_rebuilt (key, volume, file) so a mission can re-scatter its own props; nothing listening to that is a perfectly good outcome.

API

Declarative relic interiors from AMD - a structure a ship flies INSIDE, authored as data instead of a YAML string buried in a Python file.

A relic is a navigable VOLUME (procedural/volume.py): chambers are spheres, passages are capsules, boxes are rectangles, solids are subtracted. Containment is script-side, so the engine's collision system - one keep-out sphere per object - is never involved.

A section authors the relic and its parts as FLAT SIBLINGS, the same shape a cutscene bed and its shots use::

## [Relics](relics)

### [The Ossuary](ossuary)
---
Loc: 12000, 0, -8000
Atmosphere: purple
Containment: tractor
Margin: 60
---

### [hub](ossuary_hub)
---
Relic: ossuary
Chamber: 0, 0, 0, 900
---

### [gallery](ossuary_gallery)
---
Relic: ossuary
Chamber: 3000, 0, 0, 700
Passage to: hub 300
---

A record carrying Relic: is a PART; one carrying neither is the relic itself. Which kind of part follows from the field it carries - Chamber:, Box: or Solid:. This is the bed/shot discriminator, and it is why relics and their parts share ONE archetype: a section resolves to a single archetype, so splitting them would leave half of every relic file untyped and lint calling its fields unknown.

Chamber coordinates are relative to the relic's Loc:, which is what lets one authored layout be dropped at two places in a system.

Parts are records rather than a nested fence on purpose. AMD does support nesting inside one fence, but inner names are unschema'd and unlinted by design - and a record gets a key, a heading and a source span, which is what an editor needs to write one chamber back.

amd_relic_data(text)

Parse one relic fence into a data dict.

amd_relic_facts()

amd_parse_facts handler for relic fences.

Unknown labels return None so they chain to the field registry and then to the default coercion - the same contract amd_landmark_facts follows.

relic_contain(record, name=None)

Start containment for a built relic, honoring its authored fields.

Returns the watcher, or None if the volume has not been built yet.

relic_contents(relic_key)

Every authored content record for a relic, with its world position resolved.

[{part, item, qty, spawn, starts_when, pos}]. The position comes from whichever part carries it - a point marks a spot, a chamber means "somewhere in this room" and resolves to its centre.

relic_contents_arm(relic_key, radius_default=900.0, reveal=RELIC_REVEAL_RANGE)

Arm a relic's authored contents. Returns how many are waiting on a trigger.

Three things happen, in this order:

  1. Every point carrying Roles: gets a role marker - an invisible, selectable object at that spot holding those roles. That is what makes Starts when: reach <role> work at all, since the quest driver's reach test measures against OBJECTS holding a role, and it is the plumbing an author should never have to think about.
  2. Contents with no trigger are placed now. That is the common case - a ruin with things in it - and it needs no word in the file.
  3. The rest are armed and checked by ONE shared tick, in the pattern quest_tick_reach established: a watcher per item would be the same work done many times.

Idempotent by (relic, part): re-arming, or a live reload, places nothing twice.

relic_contents_can_trigger(phrase)

True when phrase is one this can actually evaluate. What lint asks.

relic_contents_clear(relic_key=None)

Forget what has been armed and placed. Does not delete objects.

With no key this is the mission reset: everything, including the signal observer and the shared tick, since the world is going away anyway.

With a KEY it forgets one relic - the galaxy case, where a system is torn down while other systems are still live. The observer and the tick stay, because the relics that are still standing are still waiting on them.

relic_contents_count()

How many content records are armed. The reset-ledger probe - an armed record that survives a mission reset would place loot in the NEXT mission.

relic_contents_state(relic_key, part)

"placed", "waiting" or "unarmed" for one content record.

What a report or a test asks. Distinguishing WAITING from UNARMED matters: both look like "the loot is not there", and only one of them is a bug.

relic_keys()

Every registered relic key.

relic_place(record, x, y, z)

Put a relic somewhere at RUNTIME, overriding its authored Loc:.

An .amd cannot know where a relic will stand when the world decides that late. An Open Universe cell has a transient world origin - the same system lands at a different slot on a different visit - so a galaxy relic has to be placed when the cell is built, not when the file is read.

Every reader goes through relic_pos, so setting loc here moves the geometry, the points, the contents and the containment together. Anything already built keeps the position it was built at: place BEFORE relic_volume.

Takes the record (or a key) and returns it, so it reads as one step in a build.

relic_point(relic_key, name)

The WORLD position of a named point in a relic, or None.

Points are authored RELATIVE to the relic's Loc:, like every other part, so this shifts them - which is the whole reason a point belongs in the relic rather than being a landmark of its own. Move the relic and its cache, its entrance and its ambush move with it; a landmark's Loc: is absolute and would stay behind.

What goes there is the mission's business::

item_spawn("relic_core", *relic_point("ossuary", "cache"), qty=2)
npc_spawn(*relic_point("ossuary", "picket"), "Sentry", "raider", ...)
marker_point(*relic_point("ossuary", "mouth"), "The Ossuary")

relic_point_roles(relic_key, name)

The roles authored on one point, lowercased. Empty when it has none.

relic_points(relic_key, role=None)

Every point in a relic as {name: (x, y, z)} in world coordinates.

role narrows to one purpose - relic_points("ossuary", "spawn") for every place an NPC may appear, "entrance" for the ways in. Roles are matched lowercased, the way they are authored.

relic_pos(record)

A relic's world [x, y, z] - its Loc:, else the origin.

Deliberately simpler than landmark_pos: relics have no galaxy placer, because the landmark one has never been used by a shipped mission (Open Universe rolls its own). If a galaxy mission needs one, add it the way landmarks did rather than assuming this hook exists.

relic_record(key)

The registered record for key, or None.

relic_release(key)

Tear ONE relic down: stop its containment, drop its volume, forget what was armed.

The counterpart to building a relic into a world that comes and goes. A galaxy tears a system down while the next one is already being built, so the whole-registry verbs (volume_clear, relics_clear) are the wrong tools there - they would take the relic the crew is currently inside.

The RECORD stays registered: the relic is a thing the mission still knows about and may rebuild on the next visit. Objects are not deleted either - whoever tore the world down did that, and a relic outliving its props is not this function's business.

relic_reload(key)

Re-read one relic's .amd and rebuild its volume in place. Returns a summary dict.

THE POINT: this is what a live preview needs, and until now every mission had to write it. The editor's Preview button can only ring a doorbell over the debug channel; the rebuild has to happen inside the running mission, so it belongs here rather than in the tool. See cosmos_dev.mission_runner's relic_reload debug action, which calls this and needs no mission code at all.

Geometry only. The props a mission scatters over the walls are its own art, and this cannot know what they are - so it emits relic_rebuilt afterwards and a mission that draws walls re-dresses on that signal.

Rebuilds UNDER THE SAME VOLUME NAME, so a watcher, a brain, or a stored id that addresses the relic keeps addressing it. Containment is re-applied from the AUTHORED fields (relic_contain), so an edit to Margin: or Containment: takes effect on the same Preview as an edit to a chamber - which is the whole promise of authoring it declaratively.

Returns {"key", "volume", "source", "chambers", "passages", "boxes", "solids"}, or None if the key is unknown or the record has no source (built in code, not read from a file - there is nothing to re-read).

relic_volume(record, name=None)

Build the navigable volume for a record and return it.

The layout is authored RELATIVE to the relic's Loc, so the record's position becomes the volume's origin - which is what lets the same layout be placed twice.

relic_volume_name(record, name=None)

Which volume a record's geometry lives in: an explicit name, else the one the record actually BUILT, else its key.

The middle term is the one that matters. A mission is free to build a relic under a name of its own (relics_build(..., name="relic")), and when it does, anything that guesses the record's key instead - containment, reload - silently addresses a volume that does not exist and does nothing at all. That is not hypothetical: it is why the Ossuary's authored Scrape band: 120 never once reached its watcher.

relics_build(file_path, section_key='relics', name=None)

Load a file, build the first relic's volume, and return (record, volume).

The whole declarative path in one call, for the common case of a mission with one relic. name overrides the volume's name; it defaults to the relic's own key.

relics_clear()

Drop every registered relic record. Called by reset_mission_state().

relics_count()

Number of registered relic records. The reset-ledger probe.

relics_from_section(section, source=None, section_key=None)

Relic records from a section node's children, each with its parts attached.

Grouping mirrors the cutscene reader: a record naming a relic is a part of it, collected in DOCUMENT ORDER; a record naming none is the relic itself.

source and section_key are carried onto every record so the relic can be REBUILT from its file later - see relic_reload. They are the reader's own arguments, not anything the author writes; without them a record is a snapshot with no way back to the text it came from, and a live preview has to be written per mission.

relics_load(file_path, section_key='relics', content=None)

Read relics straight from an .amd file. The verb a mission actually wants.

Without this every mission repeats the same three lines - load the document with the relic fence handler wired in, find the section, walk it - and the fence handler is the part that is easy to forget. Miss it and every field silently falls through to the default coercion, so Chamber: 0, 0, 0, 900 becomes a string and the relic builds as nothing.

Returns the records; they are registered too, so relic_record(key) finds them later on a story cue.

content is the text, for a caller that has already read it - an addon inside a packaged .mastlib cannot open its own files by path, so it resolves them with its own reader and hands the text over. file_path is still recorded as the source, so a live editor reload knows what to re-read.

relics_register(section, source=None, section_key=None)

Remember every relic record in section by key, without building any.

Separate from relics_build for the same reason landmarks are: a mission builds most of its relics at setup, but a story beat reveals one on cue, and both need the same record.

relics_reload_all()

Re-read every relic that came from a file. Returns a list of summaries.

What the editor's Preview posts when it does not name one - the common case of a mission with a single relic, where naming it would only be a way to get it wrong.