shrike/hud.py¶

Part of SHRIKE.

   1"""SHRIKE's heads-up display: foveal arcs, a warming border and diegetic rings.
   2
   3The HUD is deliberately not a corner-panel dashboard. Everything the pilot
   4reads while dodging sits inside the foveal zone around the ship:
   5
   6* **Hull, capacitor, O2 and fuel** are thin arcs ringing the ship, each with a
   7  dim track behind a bright fill, so a glance costs no eye movement. Each is
   8  captioned at a fixed compass point *outside every ring the HUD draws*, and
   9  hull and capacitor carry their number there because they have no cell in the
  10  strip below.
  11* **Every resource the run prices is a number under the ring.** The strip
  12  (:meth:`Hud.resource_lines`) carries scrap, fuel and air, and it is in the
  13  run-one floor because run one quotes five scrap for a patch and fifteen fuel
  14  for a jump. The rule the second blind playtest bought: *anything the run can
  15  charge or credit has a visible number from the moment it is first touched.*
  16  Under it sit the power-state pips (:meth:`Hud.state_pips`), which are what
  17  the wings key and the generator key have to show for themselves.
  18* **The shield pad is drawn on the bearing it covers.** Q and E moved a defence
  19  that existed only in the collision maths, which is a control with no output
  20  and reads as an unbound key.
  21* **The signature meter is a gauge and a border.** An arc over the ship's nose
  22  carries the reading and its number; the screen border warms from cold blue to
  23  red behind it, off the same fraction. The border alone was the original
  24  design and it failed outright: a blind pilot watched the frame redden for
  25  seventy-five seconds without learning that it was a meter, and died to the
  26  arrival it had been counting down to. A tint is a mood; a gauge is a reading,
  27  and this meter needs both. When it locks, the HUD says so in the imperative
  28  and then draws the countdown and a marker on the approach bearing, because a
  29  killer nobody has ever seen is not a boss, it is a bug.
  30* **The warp spool is a ring closing on the ship.** It shrinks toward the hull
  31  over ``balance.WARP_SPOOL_S`` and visibly stutters every time a hit adds
  32  seconds to the channel. It never simply disappears: a jump whites the screen
  33  out, and an abort or a failure holds the ring in red for
  34  :data:`WARP_OUTCOME_FLASH_S` with the ending written under it, so "the circle
  35  went big and then small and nothing happened" is not a state the HUD has.
  36* **The sector's edge and its compass.** A standing line says the ship has
  37  flown out of the sector (:meth:`Hud.set_boundary_notice`), and while nothing
  38  worth flying to is on screen, edge markers give the bearing and range of the
  39  nearest vein, hulk and depot (:meth:`Hud.set_compass_targets`). A waypoint
  40  label is the one thing on the screen placed by where a rock is rather than by
  41  a layout, so it steps along its edge to a row clear of the whole standing HUD
  42  (:meth:`Hud._label_zones`) and stands its marker down rather than share a row
  43  with one of the transient plates at the foot of the screen.
  44* **Damage direction is edge desaturation**, never a red overlay: the edge the
  45  hit came from drains of colour for a moment. :meth:`Hud.edge_desaturation`
  46  exposes the same per-edge intensities as a hook, so a post-process pass can
  47  drive the real desaturation while the HUD draws the fallback band.
  48* **Dying is a state you can see coming.** Below :data:`LOW_HULL_FRACTION` the
  49  whole border pulses and the klaxon caption keeps coming back; under
  50  ``balance.O2_LOW_WARNING_SECONDS`` of air the remaining seconds are counted
  51  down beside the ship. Both are warnings, not readouts, so onboarding never
  52  subtracts them: a pilot who cannot see the hull can still see it is critical.
  53* **The warp ring says what it is spooling to.** ``WARP SPOOL`` names the
  54  destination and its fuel price while the channel runs, an interruption
  55  flashes the seconds it just cost, and the abort key is written under it.
  56* **Floating text belongs to the world, not to the corner.** A pickup's value
  57  and a first-sighting enemy tag are drawn where the thing is
  58  (:meth:`Hud.float_text`), and a one-line :meth:`Hud.show_toast` carries the
  59  short answers a key press deserves ("select a destination: M"). A label that
  60  names a counter the HUD keeps flies across to it as it fades, so the number
  61  in the corner grows because of something the pilot watched happen. Anything
  62  paid in fractions goes through :meth:`Hud.credit_float`, which banks per
  63  source and writes one label per whole unit: a beam paying a tenth of a unit
  64  a frame used to caption itself ``+0 SCRAP`` beside a climbing counter.
  65* **Everything under the hull has a reserved slot.** The warp chip, the
  66  survival warnings, the run's current instruction and the boundary line each
  67  own a band (``BAND_*_PX``) and never leave it, and the waypoint labels stay
  68  out of the controls strip's band. One shared offset put four sentences on one
  69  row of pixels, and the result was legible as none of them. The transient
  70  answer line is the one row anchored to the screen rather than to the hull,
  71  so it steps around whichever standing lines are up
  72  (:meth:`Hud.message_row`) instead of being written through them.
  73* **Every reading around the hull sits on its own plate.** The gauge captions
  74  and the signature reading are drawn over the playfield, and the playfield is
  75  allowed to be a lantern cone bloomed several stops over white. The burn frame
  76  is the one frame where HULL and SIGNATURE decide whether the pilot
  77  understands their own death, and it was the one frame where neither could be
  78  read. Nothing the HUD draws over the playfield is allowed to cover them
  79  either: a rising float takes a lane beside a reading rather than through it
  80  (:meth:`Hud._float_lane`), and the standing lines stand down when the caption
  81  track reaches their band (:meth:`Hud.caption_block`).
  82* **The affordance line names the verb in reach.** Whenever something the ship
  83  can act on is close enough to act on, one short line sits above the gauge
  84  ring and says which key does what ("F: DOCK", "HOLD RMB: CRACK THE CORE").
  85  It is pushed every frame by whoever resolved the reach
  86  (:meth:`Hud.set_affordance`) and it is never subtracted: a verb nobody names
  87  is a verb nobody performs.
  88* **The controls strip is always there.** Two rows on a dark plate along the
  89  bottom of the screen list the whole keyboard in the order a pilot needs it.
  90  It is on by default for every run, toggled with the controls key or from the
  91  settings screen, and it is independent of the onboarding lessons, which stop
  92  showing after run three and never come back.
  93* **The warp chip answers "when can I leave?"** It sits where the spool ring
  94  will close and reads one of three states: no destination picked, ready at a
  95  named fuel price, or short of fuel by a stated amount.
  96* **Every audio cue also lands here as a caption on the caption plate**
  97  (:data:`CUE_SUBTITLES`, written by :func:`caption_for`), because a mix that
  98  carries mechanical information must never be hearing-gated. The caption is a
  99  diegetic phrase in small caps on a dark strip (KLAXON, HULL BREACHED,
 100  WHISTLE) and it carries no punctuation at all. Three punctuated spellings
 101  have now been read off the middle of the screen as placeholder text that had
 102  shipped, so the plate does the work the glyphs were failing to do.
 103* **Arriving somewhere is an event with a name.** A jump lands the hull in a
 104  field of rocks that looks like the last field of rocks, so
 105  :meth:`Hud.show_arrival_card` names the biome and says one line about it, and
 106  :meth:`Hud.set_sector_name` leaves the name standing in the corner for as
 107  long as the sector lasts.
 108* **The control card is onboarding's own channel**, and it is not the caption
 109  queue. One centred panel names the keys of the lesson in hand and stays up
 110  until the player does the thing, so a control the player never found cannot
 111  scroll away under a combat caption. :meth:`Hud.show_control_card` puts one up
 112  and :meth:`Hud.clear_control_card` takes it down; the schedule that decides
 113  which card is current lives in ``shrike.onboarding``. The card is drawn where
 114  :meth:`Hud.control_card_layout` says, which is never over the ship's own
 115  status band (:meth:`Hud.status_band_top`): a fixed top-band panel sat
 116  permanently on ``SIGNATURE LOCKED`` and on every breach message whenever the
 117  aim lead carried the hull up the screen. It yields in order (up, then its
 118  hint, then its size, then off), and the ship's readings never move for it.
 119
 120Every pixel constant here is authored against a 720-pixel-tall window and
 121scaled at draw time by :attr:`Hud.effective_scale`, which is the window's own
 122height scale (:func:`screen_scale_for`) times the player's UI-scale slider. A
 123window twice as tall therefore draws a HUD twice as large rather than a HUD
 124half as readable.
 125
 126Onboarding subtracts elements rather than adding them: the HUD draws whatever
 127is in :attr:`Hud.visible_elements`, and ``flow.Onboarding`` narrows that set for
 128the first three runs. :data:`ALL_HUD_ELEMENTS` is the vocabulary and
 129:data:`MINIMAL_HUD_ELEMENTS` the run-one floor.
 130
 131Sources are discovered, not injected. The HUD scans the run's singletons and
 132the ship's subtree for the signals in :data:`_WANTED_SIGNALS` and connects to
 133whatever exists, re-scanning while anything is still missing. A HUD in a scene
 134with no power system simply draws empty arcs instead of raising.
 135"""
 136
 137from __future__ import annotations
 138
 139import math
 140import weakref
 141from typing import NamedTuple
 142
 143import numpy as np
 144
 145from simvx.core import AnchorPreset, Control, Input, Node, Signal, Vec3
 146
 147from . import balance
 148from .runtime import CameraRig, Groups, Services, SignalNames, heading_to_direction
 149
 150# ============================================================================
 151# Local presentation constants
 152#
 153# balance.py owns tuning that changes how the game plays; these govern how the
 154# HUD looks, which is this module's own business. They are all in pixels at a
 155# UI scale of 1.0 or in seconds.
 156# ============================================================================
 157
 158#: Radius of the innermost gauge arc, measured from the ship on screen.
 159ARC_RADIUS_PX = 58.0
 160#: Radial gap between the gauge ring and the interact fill inside it.
 161INTERACT_FILL_RADIUS_PX = 34.0
 162ARC_THICKNESS_PX = 5.0
 163#: Degrees of arc per drawn segment; small enough that the chord reads as curved.
 164ARC_SEGMENT_DEGREES = 6.0
 165
 166#: Captions outside the rings, on the compass point of the arc they name. Four
 167#: unlabelled arcs are four colours a pilot has to have been told about; four
 168#: labelled ones are a gauge cluster.
 169#:
 170#: They are drawn *clear of every ring*, not on the ring's own radius. Centring
 171#: a label on the compass point put the side captions half over the arcs and
 172#: half over the hull, and a blind playtest photographed "O2" written across the
 173#: ship and "FUEL" with its first two letters behind the fuel arc. The side
 174#: captions now hang outward from the rings and the top and bottom ones sit
 175#: above and below them, so nothing the HUD draws around the hull is written on
 176#: anything else the HUD draws around the hull.
 177ARC_LABEL_GAP_PX = 10.0
 178#: The gauge captions were authored at 0.75 and read as four illegible smudges
 179#: against a bright nebula in a blind playtest. They are the names of the four
 180#: numbers that keep the pilot alive; they are drawn at full alpha and at a
 181#: size a glance can resolve.
 182ARC_LABEL_FONT_SCALE = 0.9
 183#: Wide enough for the longest caption and its numeral ("HULL 100").
 184ARC_LABEL_BOX_PX = 104.0
 185ARC_LABEL_LINE_PX = 16.0
 186#: The plate every reading around the hull is written on.
 187#:
 188#: These numbers are drawn over the playfield, and the playfield is allowed to
 189#: be a bloomed white lantern shaft: a blind playtest photographed the burn
 190#: frame with the hull arc, the HULL number and the SIGNATURE reading all
 191#: inside the cone and none of them readable, on the one frame in the game
 192#: where reading them matters most. The plate is what puts the ship's own
 193#: numbers in front of whatever the sector is doing, and it costs one rect per
 194#: caption.
 195READING_PLATE_PADDING_X_PX = 5.0
 196READING_PLATE_PADDING_Y_PX = 2.0
 197#: Clearance between a ring caption and whatever is written beyond it.
 198ARC_LABEL_CLEARANCE_PX = 4.0
 199#: How hull and capacitor write their numbers. O2 and fuel carry theirs in the
 200#: resource strip, so their captions stay bare: two copies of one number is one
 201#: copy too many, and these two had no copy at all.
 202ARC_LABEL_WITH_VALUE = "{caption} {value:.0f}"
 203
 204#: The shield arc, drawn just outside the gauge ring on the bearing it covers.
 205#: Q and E move a defence the pilot could not see: the pads answered the key in
 206#: the collision maths and nowhere else, which is a control with no output.
 207SHIELD_ARC_GAP_PX = 9.0
 208SHIELD_ARC_THICKNESS_PX = 6.0
 209
 210#: The power-state pips: solar wings, generator and afterburner, as three small
 211#: lamps on the second row of the ship-state block. Each names the key that
 212#: moves it, because the whole complaint was that X and Z pressed into silence.
 213STATE_PIP_LINE_PX = 17.0
 214STATE_PIP_CELL_PX = 116.0
 215STATE_PIP_FONT_SCALE = 0.85
 216#: How each pip reads, ``(on, off)``. The afterburner has no off text: an
 217#: engine that is not burning is the resting state and does not need a lamp.
 218PIP_WINGS = ("X WINGS OUT", "X WINGS IN")
 219PIP_GENERATOR = ("Z GEN RUNNING", "Z GEN OFF")
 220PIP_AFTERBURNER = ("SHIFT BURNING", "SHIFT BURN")
 221
 222#: The warp ring starts this far out and closes onto the gauge ring.
 223WARP_RING_START_RADIUS_PX = 170.0
 224WARP_STUTTER_S = 0.45
 225WARP_STUTTER_AMPLITUDE_PX = 9.0
 226WARP_STUTTER_HZ = 22.0
 227
 228#: The label under the closing ring, and the gap between it and the ring.
 229WARP_LABEL = "WARP SPOOL"
 230#: What the abort line says. The action, not the key: a remapped drive still
 231#: aborts on whatever the pilot bound it to, and this is the name they saw.
 232WARP_ABORT_HINT = "tap R again to abort"
 233#: The price the ring quotes beside the destination. The caller may hand down
 234#: its own wording (the warp chip itemises a surcharge, and a ring quoting a
 235#: bare total beside a chip that itemises reads as two different prices); when
 236#: it does not, the ring spells the number it was given.
 237WARP_LABEL_PRICE = " for {price} fuel"
 238WARP_LABEL_GAP_PX = 16.0
 239WARP_LABEL_LINE_PX = 20.0
 240WARP_LABEL_FONT_SCALE = 1.1
 241#: How long the interruption line stays up after a hit lengthens the channel.
 242WARP_INTERRUPT_FLASH_S = 1.4
 243WARP_INTERRUPT_FONT_SCALE = 1.3
 244#: What an interruption is spelled out as. "INTERRUPTED" on its own told the
 245#: pilot that something had happened and nothing about what or why; this names
 246#: the cost in seconds and the cause in the same breath.
 247WARP_INTERRUPT_PREFIX = "SPOOL INTERRUPTED"
 248WARP_INTERRUPT_CAUSE = "taking fire"
 249
 250#: The warp chip: one line in the context band under the ship, up whenever no
 251#: spool is running, answering "can I leave, and what does it cost?".
 252WARP_STATUS_FONT_SCALE = 1.0
 253#: How far the chip's plate is padded past its own type, sideways only. A
 254#: refused spool holds its red ring at the outer radius, and the outer radius
 255#: crosses this band: a rendered refusal drew the ring straight through
 256#: "need 15, have 5", which is the shortfall a stranded pilot has most need of.
 257#: The plate is laid down after the ring and before the type, so the ring
 258#: passes behind the chip rather than through it. It takes no vertical padding
 259#: because the band's row already carries the type's leading, and a plate
 260#: taller than its band walks into the resource strip's plate above it.
 261WARP_STATUS_PLATE_PADDING_X_PX = 10.0
 262
 263#: Border thickness at signature zero and at :data:`balance.SIGNATURE_MAX`.
 264SIGNATURE_BORDER_MIN_PX = 2.0
 265SIGNATURE_BORDER_MAX_PX = 18.0
 266
 267#: The signature gauge proper: an outer arc over the ship's nose, with the
 268#: meter's own number written above it. The border alone failed a blind
 269#: playtest outright, because a screen tint is a mood and not a reading: the
 270#: pilot who died to the first arrival had watched the frame turn red for
 271#: seventy-five seconds without ever learning that the red was a meter, that it
 272#: was filling, or how full it was. The border stays, because a peripheral warm
 273#: is the right thing to feel; this is the thing to read.
 274SIGNATURE_ARC_GAP_PX = 20.0
 275SIGNATURE_ARC_SPAN_DEGREES = 156.0
 276SIGNATURE_ARC_THICKNESS_PX = 4.0
 277
 278#: Where the rings around the hull stop. The shield pad follows the aim and can
 279#: be at any bearing, so it sets the outer edge everywhere; the signature gauge
 280#: only ever covers the nose, so it sets a taller one above the ship alone. The
 281#: gauge captions are placed off these two, which is what keeps them off the
 282#: arcs however wide the rings get.
 283RING_OUTER_PX = ARC_RADIUS_PX + SHIELD_ARC_GAP_PX + SHIELD_ARC_THICKNESS_PX * 0.5
 284SIGNATURE_RING_OUTER_PX = ARC_RADIUS_PX + SIGNATURE_ARC_GAP_PX + SIGNATURE_ARC_THICKNESS_PX * 0.5
 285#: The inner edge of a ring caption: how far from the hull the type begins.
 286ARC_LABEL_RADIUS_PX = RING_OUTER_PX + ARC_LABEL_GAP_PX
 287ARC_LABEL_TOP_RADIUS_PX = SIGNATURE_RING_OUTER_PX + ARC_LABEL_GAP_PX
 288
 289#: The signature reading is written above the hull caption rather than at a
 290#: fixed distance from its own arc, so the two never share a row.
 291SIGNATURE_LABEL_GAP_PX = (
 292    ARC_LABEL_TOP_RADIUS_PX + ARC_LABEL_LINE_PX + ARC_LABEL_CLEARANCE_PX - ARC_RADIUS_PX - SIGNATURE_ARC_GAP_PX
 293)
 294SIGNATURE_LABEL_LINE_PX = 18.0
 295SIGNATURE_LABEL_FONT_SCALE = 0.95
 296SIGNATURE_LABEL = "SIGNATURE {value:.0f}/{maximum:.0f}"
 297#: How far above the ship the whole signature gauge reaches, label included.
 298#: Whatever sits above the ring measures from here rather than from the ring,
 299#: or it lands on the reading.
 300SIGNATURE_GAUGE_TOP_PX = ARC_RADIUS_PX + SIGNATURE_ARC_GAP_PX + SIGNATURE_LABEL_GAP_PX + SIGNATURE_LABEL_LINE_PX
 301
 302#: What the lock says. It is an instruction, not a status: by the time it is up
 303#: the only question the pilot has is what to do about it. The stamp is the
 304#: headline and the standing line is the answer, because a stamp that fades has
 305#: told a first-time pilot nothing.
 306#:
 307#: The answer is the warp drive and only the warp drive. Silent running is what
 308#: keeps the meter off the ceiling, and it does nothing once the ceiling has
 309#: been hit: ``SignatureMeter.locked`` latches for the whole sector and the
 310#: ladder is already running. Offering it here would be an instruction that
 311#: does not work, which is worse than no instruction at all.
 312SIGNATURE_LOCKED_STAMP = "SIGNATURE LOCKED"
 313SIGNATURE_LOCKED_INSTRUCTION = "SIGNATURE LOCKED: the Shrike is inbound. Warp out (R) before it lands"
 314
 315#: The inbound Shrike's countdown and the marker on its approach vector. The
 316#: telegraph ladder is generous with seconds and was spending all of them on a
 317#: sound; this is the same warning written down.
 318HUNTER_COUNTDOWN = "SHRIKE INBOUND: {seconds:.0f} s"
 319HUNTER_MARKER_RADIUS_PX = 11.0
 320HUNTER_MARKER_EDGE_MARGIN_PX = 54.0
 321HUNTER_MARKER_FONT_SCALE = 1.0
 322#: How far along the approach bearing the marker is planted. Past the edge of
 323#: the visible field on purpose, so the marker rides the screen edge and reads
 324#: as a bearing rather than drifting over the play area as a thing that is
 325#: already there.
 326HUNTER_MARKER_RANGE_UNITS = 60.0
 327
 328#: Seconds the lost chunk holds at full length before it starts draining, and
 329#: seconds it then takes to drain onto the new level. Both are fixed times
 330#: rather than a rate across the whole ring: a rate gave the heaviest blow in
 331#: the game the *shortest* animation, because a chunk a third of the ring long
 332#: was a third of the way through the drain before it was drawn. A blow now
 333#: reads as a blow at any size, and the size is in how much of the ring lights
 334#: up rather than in how long it takes to go.
 335HULL_CHIP_HOLD_S = 0.35
 336HULL_CHIP_DECAY_S = 0.35
 337#: How much thicker the lost chunk is drawn than the arc it came off. Colour
 338#: alone could not carry it: every Shrike arrival happens at a full signature
 339#: meter, which puts a saturated red arc twenty pixels outside the hull arc and
 340#: a red border behind both, and the chunk was a small red sliver inside all of
 341#: that. A chunk that stands proud of the ring reads as a loss on a red screen
 342#: and reads for a pilot who cannot separate the two reds at all.
 343HULL_CHIP_THICKNESS_MULT = 2.2
 344
 345#: How long one edge stays desaturated after a hit from that bearing.
 346DAMAGE_EDGE_DECAY_S = 1.1
 347#: Damage, in hull points, that saturates the edge hook at 1.0.
 348DAMAGE_EDGE_FULL_HIT = 25.0
 349DAMAGE_EDGE_BAND_PX = 26.0
 350#: Weight the smallest hit still raises its edge to. A scratch from behind is
 351#: exactly the hit a pilot needs to be told the bearing of, and a band drawn at
 352#: a tenth of an alpha is a band nobody sees.
 353DAMAGE_EDGE_MIN_WEIGHT = 0.4
 354
 355#: Window height every pixel constant in this module is authored against, and
 356#: the height at which :func:`screen_scale_for` returns 1.0.
 357UI_SCALE_REFERENCE_HEIGHT_PX = 720.0
 358#: Clamps on the height-derived half of the UI scale. The floor keeps a small
 359#: window legible, the ceiling stops a 4K screen drawing a HUD that eats the
 360#: playfield.
 361UI_SCALE_FROM_SCREEN_MIN = 0.75
 362UI_SCALE_FROM_SCREEN_MAX = 2.0
 363#: Height of one text unit in pixels: ``draw_text(scale=s)`` draws glyphs about
 364#: ``TEXT_EM_PX * s`` tall. Used to state text sizes as a share of the screen.
 365TEXT_EM_PX = 16.0
 366
 367SUBTITLE_HOLD_S = 2.6
 368SUBTITLE_MAX_LINES = 3
 369SUBTITLE_LINE_HEIGHT_PX = 26.0
 370SUBTITLE_BOTTOM_MARGIN_PX = 74.0
 371#: Captions are read at a glance from the middle of a dodge, so the floor is a
 372#: share of the screen rather than a pixel count: at the reference height this
 373#: scale draws them at :data:`SUBTITLE_MIN_HEIGHT_FRACTION` of the screen, and
 374#: the screen half of the UI scale holds that share at any other height.
 375SUBTITLE_FONT_SCALE = 1.2
 376SUBTITLE_MIN_HEIGHT_FRACTION = 0.02
 377#: The plate the caption stack is written on. Bare text floating over the star
 378#: field read as debug output rather than as a subtitle track; a caption needs
 379#: the same dark strip a film gives one before the words mean "this is a sound".
 380SUBTITLE_PLATE_PADDING_X_PX = 18.0
 381SUBTITLE_PLATE_PADDING_Y_PX = 6.0
 382#: Rough advance width of one glyph as a share of its em, used to size plates
 383#: and to wrap captions. The renderer's font is close enough to monospaced at
 384#: HUD sizes that a plate measured this way never crops a line.
 385GLYPH_ADVANCE_FRACTION = 0.56
 386
 387#: The control card. Onboarding's channel: one panel, big type, no queue.
 388CONTROL_CARD_FONT_SCALE = 2.0
 389CONTROL_CARD_HINT_FONT_SCALE = 1.1
 390#: Where the panel's top edge sits, as a fraction of the screen's height. High
 391#: enough to clear the ship and its gauge ring at the centre of the screen.
 392CONTROL_CARD_TOP_FRACTION = 0.11
 393CONTROL_CARD_WIDTH_PX = 640.0
 394CONTROL_CARD_PADDING_PX = 10.0
 395CONTROL_CARD_LINE_HEIGHT_PX = 36.0
 396CONTROL_CARD_HINT_LINE_HEIGHT_PX = 24.0
 397#: How far a long lesson line may be shrunk to stay inside its own plate. The
 398#: card is authored at :data:`CONTROL_CARD_FONT_SCALE`, but the lesson lines are
 399#: sentences of varying length and the long ones ran off both ends of the plate.
 400CONTROL_CARD_MIN_FONT_SCALE = 0.9
 401#: How far above the screen's top edge the card may be pushed when it has to
 402#: yield, and the gap it keeps from whatever it is yielding to.
 403CONTROL_CARD_MIN_TOP_PX = 8.0
 404CONTROL_CARD_CLEARANCE_PX = 10.0
 405#: The card's yielded form: title only, in smaller type on a tighter plate. A
 406#: pilot flying with the mouse high puts the ship near the top of the screen,
 407#: and the full card then has nowhere to be that is not on top of the ship's
 408#: own status band. The lesson shrinks; the ship's readings do not move.
 409CONTROL_CARD_COMPACT_FONT_SCALE = 1.35
 410CONTROL_CARD_COMPACT_LINE_PX = 26.0
 411CONTROL_CARD_COMPACT_PADDING_PX = 8.0
 412#: The card's hold meter: a real bar across the foot of the panel, drawn as a
 413#: dim track with a bright fill. The lesson script used to report a hold as
 414#: typed characters in the hint line, and a row of hashes inside square brackets
 415#: in the middle of a tutorial panel is what unfinished software looks like.
 416CONTROL_CARD_BAR_HEIGHT_PX = 6.0
 417CONTROL_CARD_BAR_GAP_PX = 6.0
 418#: The typed form, still read off a hint so it can be turned into the drawn one.
 419TYPED_BAR_OPEN = "["
 420TYPED_BAR_CLOSE = "]"
 421TYPED_BAR_FULL = "#"
 422TYPED_BAR_EMPTY = "."
 423TYPED_BAR_GLYPHS = TYPED_BAR_FULL + TYPED_BAR_EMPTY
 424
 425#: The sector arrival card: the name of the place and one line about it, held
 426#: long enough to read from the middle of a warp-in and then gone. It is not a
 427#: control card and never queues behind one; it owns the band under the card.
 428ARRIVAL_CARD_HOLD_S = 2.5
 429ARRIVAL_CARD_FADE_S = 0.45
 430ARRIVAL_CARD_TOP_FRACTION = 0.24
 431ARRIVAL_CARD_FONT_SCALE = 2.2
 432ARRIVAL_CARD_FLAVOUR_FONT_SCALE = 1.15
 433ARRIVAL_CARD_LINE_PX = 40.0
 434ARRIVAL_CARD_FLAVOUR_LINE_PX = 24.0
 435
 436#: The standing sector name, top right, so "where am I" survives the card that
 437#: announced it. Sits opposite the scrap and notoriety stack.
 438SECTOR_NAME_FONT_SCALE = 1.05
 439SECTOR_NAME_LINE_PX = 20.0
 440#: The box the name is right-aligned inside. Named because a waypoint label has
 441#: to know how much of the top right corner the name is actually holding.
 442SECTOR_NAME_BOX_PX = 320.0
 443
 444STAMP_HOLD_S = 1.6
 445STAMP_FADE_S = 0.5
 446STAMP_FONT_SCALE = 2.4
 447#: Where the stamp lands, as fractions of the screen height. Named rather than
 448#: inlined because the top band has to know what it must not draw over.
 449STAMP_TOP_FRACTION = 0.32
 450STAMP_HEIGHT_FRACTION = 0.12
 451
 452READOUT_MARGIN_PX = 22.0
 453READOUT_FONT_SCALE = 1.0
 454READOUT_LINE_HEIGHT_PX = 20.0
 455
 456#: The developer frame readout, behind :data:`DEBUG_FRAME_STATS_ACTION`. A
 457#: rolling window rather than the live dt: one frame's wall clock on a laptop is
 458#: mostly scheduler noise, and the question the readout exists to answer is
 459#: whether this build is hotter than the last one. The worst frame in the window
 460#: is printed beside the mean, because a stutter is what a pilot actually feels
 461#: and a mean hides it.
 462DEBUG_FRAME_STATS_ACTION = "debug_frame_stats"
 463FRAME_STATS_WINDOW = 90
 464FRAME_STATS_FONT_SCALE = 0.85
 465FRAME_STATS_LINE_PX = 16.0
 466FRAME_STATS_WIDTH_PX = 300.0
 467#: How many GPU phases the readout prints, worst first. A driver with timestamp
 468#: pools reports every pass; the list is long and the hot end is the only part
 469#: worth reading off a screen.
 470FRAME_STATS_GPU_ROWS = 6
 471
 472#: The toast line: one short answer to a key press, above the caption stack.
 473TOAST_HOLD_S = 3.2
 474TOAST_FADE_S = 0.4
 475TOAST_FONT_SCALE = 1.4
 476TOAST_LINE_HEIGHT_PX = 30.0
 477#: Pixels between the toast line and the top of the caption stack.
 478TOAST_GAP_PX = 12.0
 479#: Clearance the message line keeps from every standing line it steps around.
 480#:
 481#: The answer to a key press is anchored to the screen and the ship's standing
 482#: lines are anchored to the hull, so the two rows cross whenever the pilot
 483#: flies at the height that puts them together. A blind playtest photographed
 484#: the chart refusal written straight through OBJECTIVE, and read neither. The
 485#: message keeps its row unless a standing line is in it, and then it takes the
 486#: nearest free row rather than sharing one (:meth:`Hud.message_row`).
 487MESSAGE_ROW_GAP_PX = 10.0
 488#: How a second answer is folded into the line rather than replacing it.
 489#:
 490#: Two systems can answer one key press in the same frame: the tutorial says
 491#: what the script wants first, the run says why the key refused. Whichever
 492#: spoke last used to be the whole answer, and the blind playtest that pressed
 493#: Z in its first minute read "FIRST: FLY: W A S D" and filed the generator key
 494#: as dead. Both now land, in one sentence.
 495TOAST_WITH_NOTE = "{text} ({note})"
 496#: Seconds a note stays open to being joined by a later answer to the same
 497#: press. Long enough to cross a frame, short enough that the next key press is
 498#: answered on its own.
 499TOAST_NOTE_JOIN_S = 0.5
 500
 501#: World-anchored floating text: a pickup's value, a first-sighting tag.
 502FLOAT_TEXT_HOLD_S = 1.6
 503FLOAT_TEXT_FADE_S = 0.45
 504FLOAT_TEXT_RISE_PX = 46.0
 505FLOAT_TEXT_FONT_SCALE = 1.1
 506#: Ceiling on how many float labels are alive at once. A shoal dying into a
 507#: scoop pass must not be able to paper the screen.
 508FLOAT_TEXT_MAX = 8
 509#: A pickup label that credits a counter flies to it rather than drifting off
 510#: into space. "+12 SCRAP" rising over the wreck it came out of and fading
 511#: never once pointed at the SCRAP readout, so the readout stayed a number in a
 512#: corner that nobody had connected to anything they had done. The flight eases
 513#: in, so the label is still legible where it landed before it goes.
 514FLOAT_TEXT_FLIGHT_EASE = 2.4
 515#: Which counters a float can be addressed to. The ids are the resource words
 516#: the labels themselves use, lowercased, so a label that names a counted
 517#: resource finds its counter without the caller having to say so twice.
 518FLOAT_TARGET_SCRAP = "scrap"
 519FLOAT_TARGETS: frozenset[str] = frozenset({FLOAT_TARGET_SCRAP})
 520#: How a banked credit is written once a whole unit of it has landed.
 521CREDIT_FLOAT_FORMAT = "+{amount:.0f} {label}"
 522
 523#: Hull fraction below which the border pulses and the klaxon keeps returning.
 524LOW_HULL_FRACTION = 0.30
 525#: Seconds between repeats of the low-hull klaxon while the hull stays there.
 526LOW_HULL_KLAXON_S = 5.0
 527#: Pulse rate of the low-hull border, in cycles per second, and its alpha band.
 528LOW_HULL_PULSE_HZ = 1.6
 529LOW_HULL_PULSE_MIN_ALPHA = 0.18
 530LOW_HULL_PULSE_MAX_ALPHA = 0.55
 531LOW_HULL_BAND_PX = 30.0
 532#: The dark gutter between the signature border and the low-hull pulse, and
 533#: how far inboard that puts the pulse. Both warnings are red rings on the same
 534#: four edges, and drawn concentric from the same edge they read as one band: a
 535#: pilot at low hull could not see the signature border collapse on arrival,
 536#: which is the whole point of having jumped. The pulse steps inboard past the
 537#: signature band's widest, leaving the outermost pixels to the meter alone, so
 538#: what a jump does to the screen is visible whatever the hull is doing. In
 539#: unscaled pixels because the band it steps around is: see
 540#: :meth:`Hud._draw_signature_border`.
 541LOW_HULL_GUTTER_PX = 8.0
 542LOW_HULL_INSET_PX = SIGNATURE_BORDER_MAX_PX + LOW_HULL_GUTTER_PX
 543#: The warning lines under the gauge ring, and their type size.
 544WARNING_FONT_SCALE = 1.25
 545WARNING_LINE_HEIGHT_PX = 24.0
 546LOW_HULL_WARNING = "HULL CRITICAL"
 547
 548# ---------------------------------------------------------------------------
 549# Reserved layout slots under the ship
 550#
 551# Everything the HUD writes below the hull used to be measured from the same
 552# offset, so the warp chip, the boundary line and the survival warnings all
 553# landed on one row and overprinted each other into an unreadable smear. Each
 554# kind of line now owns a band and never leaves it, whether or not its
 555# neighbours have anything to say: a slot that is empty stays empty, because a
 556# line that moves when its neighbour appears is a line the pilot has to find
 557# again every time.
 558#
 559# Offsets are pixels below the ship's centre at UI scale 1, from the top of the
 560# band. Bands are sized by the lines they can hold, so widening one is a matter
 561# of raising its line count and letting the next band fall where it falls.
 562# ---------------------------------------------------------------------------
 563
 564#: The resource strip: every number the run can charge or credit, in one row
 565#: under the gauge ring. It is the first band because it is the one a pilot
 566#: glances at mid-manoeuvre, and it is never subtracted: the game quotes prices
 567#: in scrap, fuel and air from the first minute of the first run, and a price
 568#: quoted against a number nobody can see is a price nobody can pay.
 569#: It starts below the capacitor caption, which is the lowest thing the ring
 570#: cluster writes, rather than at a hand-set offset that the caption then landed
 571#: on, and its own plate's padding is part of the offset at both ends: the
 572#: plate is what the eye reads as the edge of the strip, so a neighbour that
 573#: clears the type but not the plate still reads as touching it.
 574RESOURCE_LINE_PX = 22.0
 575#: Width of one resource cell. Fixed, so a number growing a digit never shoves
 576#: its neighbours sideways.
 577RESOURCE_CELL_PX = 116.0
 578RESOURCE_FONT_SCALE = 1.05
 579#: Margin the strip's plate keeps around the widest of its two rows.
 580RESOURCE_PLATE_PADDING_PX = 6.0
 581BAND_RESOURCES_PX = ARC_LABEL_RADIUS_PX + ARC_LABEL_LINE_PX + ARC_LABEL_CLEARANCE_PX + RESOURCE_PLATE_PADDING_PX
 582
 583#: The context line: what the drive can do right now. One line.
 584BAND_CONTEXT_PX = BAND_RESOURCES_PX + RESOURCE_LINE_PX + STATE_PIP_LINE_PX + RESOURCE_PLATE_PADDING_PX
 585BAND_CONTEXT_LINES = 1
 586#: The survival warnings: hull, air, and the air's own advice. Three lines.
 587BAND_WARNING_PX = BAND_CONTEXT_PX + BAND_CONTEXT_LINES * WARNING_LINE_HEIGHT_PX
 588BAND_WARNING_LINES = 3
 589#: The objective line: what the run is asking for, the lock's instruction most
 590#: of all. Two lines, because the lock's instruction is a long sentence.
 591BAND_OBJECTIVE_PX = BAND_WARNING_PX + BAND_WARNING_LINES * WARNING_LINE_HEIGHT_PX
 592BAND_OBJECTIVE_LINES = 2
 593#: The boundary line: where the sector stops. One line, furthest out, because
 594#: it is the least urgent thing down here.
 595BAND_BOUNDARY_PX = BAND_OBJECTIVE_PX + BAND_OBJECTIVE_LINES * WARNING_LINE_HEIGHT_PX
 596BAND_BOUNDARY_LINES = 1
 597#: How far the whole ladder reaches under the hull. The bands are pinned to the
 598#: ship and the ship is not pinned to anything: thrust makes the camera lag and
 599#: the hull rides down the screen, which is what walked the bottom of this
 600#: ladder into the controls strip along the foot of the frame.
 601BAND_LADDER_PX = BAND_BOUNDARY_PX + BAND_BOUNDARY_LINES * WARNING_LINE_HEIGHT_PX
 602#: The two lowest bands ride up together when the hull is low enough that the
 603#: ladder would otherwise reach the bottom furniture, and this is as far as they
 604#: can ever go: the foot of the resource strip's plate, which is up for the
 605#: whole run. What they actually get is whatever of it the bands between are
 606#: not using this frame (:meth:`Hud._lower_band_ceiling`).
 607BAND_LOWER_LIFT_MAX_PX = BAND_OBJECTIVE_PX - BAND_CONTEXT_PX
 608
 609#: Half-width of the band the lines are laid out in, so a long instruction has
 610#: somewhere to go rather than being clipped into a shorter sentence.
 611BAND_HALF_WIDTH_PX = 380.0
 612
 613#: What to do about the air, in the same breath as the countdown. A number
 614#: counting down with no acquisition beside it is a death sentence with a
 615#: clock; these are the two places oxygen actually comes from.
 616LOW_O2_HINT = "LOW O2: canisters at depots; ice fields refill (M: chart)"
 617
 618#: The same for the other clock. An empty tank does not suffocate anybody, it
 619#: strands them, and a pilot who learns that at the moment the drive refuses
 620#: has already spent the scrap that would have bought the way out. So the tank
 621#: warns while there is still something to do about it, and names the one
 622#: place fuel comes from in the same breath.
 623#:
 624#: The advice is named on its own because the drive's own refusal carries it
 625#: too, and once the tank will not cover a jump both lines are on screen a
 626#: hand's width apart. Two spellings of one instruction read as two errands.
 627LOW_FUEL_SOURCE = "depots sell fuel cells"
 628LOW_FUEL_HINT = f"LOW FUEL: {LOW_FUEL_SOURCE}"
 629#: Jumps left in the tank at or under which the hint stands up, measured at
 630#: ``balance.WARP_FUEL_BASE`` because that is the fare the chart quotes for a
 631#: hop to the next column. It is the depot screen's own threshold
 632#: (``flow.DEPOT_LOW_FUEL_JUMPS``), and test_hud pins the two together: a bay
 633#: that says the tank is low and a HUD that does not is two answers to one
 634#: question.
 635LOW_FUEL_JUMPS = 2.0
 636
 637#: The standing cue for a pilot flying out of the sector, pushed per frame by
 638#: whoever owns the boundary, and cleared this long after the pushes stop.
 639BOUNDARY_NOTICE_HOLD_S = 0.25
 640
 641#: Screen-edge compass markers for the nearest thing of each kind, pushed per
 642#: frame the same way. They are suppressed the moment any of them is on screen:
 643#: a marker over something the pilot can already see is noise.
 644COMPASS_HOLD_S = 0.25
 645COMPASS_RADIUS_PX = 7.0
 646COMPASS_EDGE_MARGIN_PX = 40.0
 647COMPASS_FONT_SCALE = 0.9
 648#: How far inside the screen a target has to land to count as visible.
 649COMPASS_ON_SCREEN_MARGIN_PX = 24.0
 650#: One short word per kind, and a silhouette to go with it. A vein is a
 651#: "VEIN" and never "ORE": the number beside a waypoint is a range, and a
 652#: waypoint labelled with the name of a resource reads as a second currency.
 653COMPASS_LABELS: dict[str, str] = {"deposit": "VEIN", "wreck": "HULK", "depot": "DEPOT"}
 654COMPASS_SEGMENTS: dict[str, int] = {"deposit": 3, "wreck": 4, "depot": 8}
 655#: The unit on every waypoint range. A bare number next to a word is read as a
 656#: quantity of that word; "41 u" is read as a distance, which is what it is.
 657RANGE_UNIT_SUFFIX = "u"
 658#: Minimum vertical separation between two waypoint labels before the later one
 659#: is pushed clear of the earlier.
 660MARKER_LABEL_SEPARATION_PX = 22.0
 661#: Half the width of the rect a waypoint label is centred in, and the wider one
 662#: the Shrike countdown uses.
 663MARKER_LABEL_HALF_WIDTH_PX = 90.0
 664HUNTER_LABEL_HALF_WIDTH_PX = 130.0
 665#: How far inside the screen's side edges a marker label's rect is kept. The
 666#: marker itself is clamped by :meth:`Hud._marker_point`, but its centred label
 667#: rect used to hang half off the edge ("SHRIKE INBOUND: 60 s" started at
 668#: x=-33 in a 1280-wide frame), so the rect slides inboard instead.
 669LABEL_EDGE_INSET_PX = 6.0
 670#: How far inside the bottom edge a marker is allowed to pin, whatever edge
 671#: margin its own kind uses. The caption strip and the controls strip own the
 672#: bottom of the screen between them, and a range written through either of
 673#: them is two unreadable lines rather than one reading. The marker's own
 674#: label hangs below it, so the reservation carries the label's row as well.
 675MARKER_LABEL_GAP_PX = 16.0
 676MARKER_BOTTOM_MARGIN_PX = (
 677    SUBTITLE_BOTTOM_MARGIN_PX
 678    + SUBTITLE_LINE_HEIGHT_PX * SUBTITLE_MAX_LINES
 679    + MARKER_LABEL_GAP_PX
 680    + WARNING_LINE_HEIGHT_PX
 681)
 682#: Clearance a waypoint label keeps from every piece of standing HUD it steps
 683#: around (:meth:`Hud._label_zones`). A label butted flush against the edge of
 684#: the sector name or of a caption plate is still two readings the eye has to
 685#: separate; a few pixels of dark between them is what makes them two lines.
 686MARKER_ZONE_CLEARANCE_PX = 5.0
 687#: How far a waypoint label may step from the row under its own ring before it
 688#: stops being that ring's label. The stack under the hull is wide and deep, so
 689#: "the nearest free row" can be most of a screen away, and a range floating at
 690#: the top of the frame over a ring at the bottom of it is worse than no range
 691#: at all. Past this the marker stands down for the frame instead.
 692MARKER_LABEL_MAX_TRAVEL_PX = 120.0
 693
 694#: The spool's endings. A ring that simply disappears is the whole of "the
 695#: circle goes big then small and nothing happens", so an ending that is not a
 696#: jump holds the ring for a beat in red with its name under it, and a jump
 697#: whites out the screen the way arriving somewhere should.
 698WARP_OUTCOME_FLASH_S = 1.2
 699WARP_ABORTED_LABEL = "SPOOL ABORTED"
 700WARP_JUMP_FLASH_S = 0.45
 701WARP_JUMP_FLASH_ALPHA = 0.85
 702
 703#: The depot beacon: a ring at the depot's bearing with the range under it.
 704BEACON_RADIUS_PX = 9.0
 705BEACON_EDGE_MARGIN_PX = 48.0
 706BEACON_FONT_SCALE = 1.0
 707#: How close the hull has to be for the beacon to become the dock prompt.
 708#: Pinned by test to ``trading.DOCK_RADIUS``, which is the radius the bay
 709#: actually docks at: a prompt that appears outside the bay is a key that does
 710#: nothing, and one that appears inside it and is written somewhere else is a
 711#: key nobody finds. A blind pilot crossed the ring three times before docking,
 712#: because "F: DOCK" was one small line at the top of the screen with a
 713#: tutorial card over it.
 714DOCK_PROMPT_RANGE_UNITS = 14.0
 715#: What the ring says when nothing else has claimed the verb line.
 716DOCK_PROMPT = "F: DOCK"
 717#: The word that marks a pushed affordance as being about the dock, so the
 718#: ring can carry the run's own wording ("DOCKED: hold F to sell") instead of
 719#: a fixed line that is wrong the moment the bay opens.
 720DOCK_PROMPT_KEYWORD = "DOCK"
 721#: The ring's armed form: filled, larger, and in the affordance's own colour,
 722#: so being inside the bay is a state the ring shows rather than a fact the
 723#: pilot has to infer from a range that stopped going down.
 724BEACON_ARMED_RADIUS_MULT = 1.7
 725BEACON_ARMED_FONT_SCALE = 1.25
 726
 727#: An interact fill is pushed per frame while the button is held; it clears
 728#: this long after the last push so a released button does not leave a ghost.
 729INTERACT_FILL_HOLD_S = 0.12
 730
 731#: The affordance line: pushed per frame by whoever owns the reach, and cleared
 732#: this long after the pushes stop, so a verb that has just left range goes with
 733#: it instead of hanging over empty space.
 734AFFORDANCE_HOLD_S = 0.20
 735#: How far above the signature gauge the affordance line sits, and its type.
 736#: Measured from the top of the gauge rather than from the ring, so the verb
 737#: in reach never lands on the meter's reading.
 738AFFORDANCE_GAP_PX = 22.0
 739AFFORDANCE_LINE_PX = 24.0
 740AFFORDANCE_FONT_SCALE = 1.3
 741
 742#: The persistent controls bar along the bottom edge.
 743CONTROLS_BAR_MARGIN_PX = 12.0
 744CONTROLS_BAR_LINE_PX = 20.0
 745CONTROLS_BAR_FONT_SCALE = 0.95
 746#: The default bar, in two rows. The first is written in the order a pilot
 747#: needs the keys rather than the order the action map lists them: fly, aim,
 748#: shoot, harvest, act, leave, look. The second covers every remaining bound
 749#: key, because a verb the strip does not name is a verb the player does not
 750#: have, and silent running is the answer to half of what kills them.
 751CONTROLS_BAR_TEXT = "WASD FLY   MOUSE AIM   LMB FIRE   RMB MINE   F INTERACT   R WARP   M CHART   ESC PAUSE   H HIDE"
 752CONTROLS_BAR_SECOND_ROW = "C SILENT   SHIFT BURN   Q/E SHIELD   Z GENERATOR   X WINGS   G JETTISON   TAB SCOOP"
 753#: The strip's own plate. Dim letters over a starfield is what turned the bar
 754#: into a smear of motes with words behind it; the plate is what makes the row
 755#: legible over anything the sector happens to be drawing under it.
 756CONTROLS_BAR_PLATE_PADDING_PX = 5.0
 757#: How much of the bottom edge the strip owns. Waypoint labels are kept above
 758#: it: a range written through a key list is two unreadable lines, not one.
 759CONTROLS_BAR_BAND_PX = CONTROLS_BAR_MARGIN_PX + 2.0 * CONTROLS_BAR_LINE_PX + 2.0 * CONTROLS_BAR_PLATE_PADDING_PX
 760
 761#: Seconds between rescans while any wanted signal still has no emitter.
 762SOURCE_RESCAN_S = 0.5
 763#: Seconds between re-reads of the hull's magazines. A depot refit is the one
 764#: thing that changes the ammunition aboard without a shot being fired.
 765AMMO_POLL_S = 0.5
 766
 767# Palette. Low-albedo environments and emissive gameplay colours mean the HUD
 768# can stay dim: these are read against near-black, not against daylight.
 769#: The dim rail behind every gauge fill. Nearly opaque rather than a wash: the
 770#: track is what tells a bright frame where the ring is, and at the old 0.55 it
 771#: dissolved into a lit lantern cone along with the fill it was backing.
 772COLOUR_TRACK = (0.10, 0.12, 0.16, 0.88)
 773COLOUR_HULL = (0.86, 0.89, 0.95, 0.92)
 774COLOUR_CAPACITOR = (0.42, 0.84, 1.00, 0.92)
 775COLOUR_O2 = (0.55, 1.00, 0.78, 0.92)
 776COLOUR_FUEL = (1.00, 0.72, 0.34, 0.92)
 777COLOUR_SIGNATURE_COLD = (0.20, 0.48, 0.72, 0.35)
 778COLOUR_SIGNATURE_HOT = (0.92, 0.13, 0.10, 0.90)
 779COLOUR_WARP_RING = (0.62, 0.82, 1.00, 0.88)
 780COLOUR_INTERACT = (1.00, 0.94, 0.72, 0.90)
 781COLOUR_STAMP = (1.00, 0.96, 0.92, 1.00)
 782COLOUR_SUBTITLE = (0.92, 0.94, 1.00, 0.90)
 783COLOUR_READOUT = (0.78, 0.82, 0.90, 0.85)
 784#: The developer readout and the plate under it. Deliberately not a game colour:
 785#: nothing in the fiction is this green, so a screenshot with it on can never be
 786#: mistaken for a build that shipped with a debug overlay lit.
 787COLOUR_FRAME_STATS = (0.62, 1.00, 0.72, 0.95)
 788COLOUR_FRAME_STATS_PLATE = (0.02, 0.05, 0.03, 0.72)
 789COLOUR_DESATURATION = (0.52, 0.53, 0.56, 0.60)
 790#: The PWR arc while a lantern blackout holds the bus at zero: a dead gauge in
 791#: grey, not a live one that happens to read empty.
 792COLOUR_BLACKOUT = (0.45, 0.47, 0.52, 0.80)
 793COLOUR_TOAST = (1.00, 0.92, 0.68, 0.96)
 794COLOUR_FLOAT_TEXT = (0.98, 0.90, 0.62, 0.95)
 795COLOUR_ENEMY_TAG = (1.00, 0.62, 0.86, 0.95)
 796COLOUR_WARNING = (1.00, 0.42, 0.34, 0.95)
 797#: The chunk a blow just took off the hull arc. Deliberately not
 798#: :data:`COLOUR_WARNING`: see :data:`HULL_CHIP_THICKNESS_MULT` for why a red
 799#: sliver was invisible on the one screen it has to be read on. White-hot
 800#: separates it from the signature arc, the border and the hull arc at once.
 801COLOUR_HULL_CHIP = (1.00, 0.98, 0.92, 1.00)
 802COLOUR_LOW_HULL_PULSE = (0.90, 0.16, 0.14, 1.00)
 803COLOUR_BEACON = (0.62, 1.00, 0.82, 0.90)
 804#: The beacon once the hull is inside the bay: the affordance colour, so the
 805#: ring and the verb line read as one thing.
 806COLOUR_BEACON_ARMED = (0.72, 1.00, 0.88, 0.98)
 807COLOUR_COMPASS = (0.72, 0.86, 1.00, 0.82)
 808COLOUR_HINT = (0.86, 0.90, 1.00, 0.85)
 809COLOUR_WARP_FAILED = (1.00, 0.32, 0.26, 0.95)
 810COLOUR_JUMP_FLASH = (1.00, 1.00, 1.00, 1.00)
 811COLOUR_AFFORDANCE = (0.72, 1.00, 0.88, 0.98)
 812COLOUR_CONTROLS_BAR = (0.62, 0.66, 0.76, 0.72)
 813COLOUR_WARP_STATUS = (0.62, 0.82, 1.00, 0.80)
 814COLOUR_WARP_STATUS_BLOCKED = (1.00, 0.62, 0.40, 0.85)
 815COLOUR_CONTROL_CARD_PANEL = (0.05, 0.07, 0.12, 0.82)
 816COLOUR_CONTROL_CARD_EDGE = (0.42, 0.84, 1.00, 0.55)
 817COLOUR_CONTROL_CARD_TEXT = (1.00, 0.97, 0.90, 0.98)
 818COLOUR_CONTROL_CARD_HINT = (0.72, 0.80, 0.92, 0.88)
 819COLOUR_CONTROL_CARD_BAR = (0.42, 0.84, 1.00, 0.95)
 820COLOUR_CONTROL_CARD_BAR_TRACK = (0.16, 0.20, 0.28, 0.85)
 821COLOUR_CONTROLS_BAR_PLATE = (0.03, 0.04, 0.07, 0.72)
 822COLOUR_OBJECTIVE = (1.00, 0.86, 0.52, 0.96)
 823COLOUR_HUNTER_MARKER = (1.00, 0.38, 0.30, 0.95)
 824COLOUR_SUBTITLE_PLATE = (0.02, 0.03, 0.06, 0.80)
 825COLOUR_SCRAP = (0.98, 0.90, 0.62, 0.95)
 826COLOUR_ARRIVAL_CARD = (0.86, 0.96, 1.00, 1.00)
 827COLOUR_ARRIVAL_FLAVOUR = (0.72, 0.82, 0.94, 0.95)
 828COLOUR_SHIELD = (0.52, 0.92, 1.00, 0.95)
 829COLOUR_SHIELD_BROKEN = (1.00, 0.46, 0.36, 0.90)
 830COLOUR_STATE_PIP_ON = (0.62, 1.00, 0.78, 0.95)
 831COLOUR_STATE_PIP_OFF = (0.42, 0.46, 0.54, 0.75)
 832COLOUR_AMMO = (1.00, 0.84, 0.62, 0.95)
 833#: The plate under the resource strip and its pips. Faint, because these numbers
 834#: sit over the playfield and the plate is there to stop a bright nebula or a
 835#: passing wreck washing the row out, not to box it in.
 836COLOUR_RESOURCE_PLATE = (0.02, 0.03, 0.06, 0.55)
 837#: The plate under each reading around the hull. Darker and more opaque than
 838#: the strip's, because the thing it has to survive is not a nebula: it is a
 839#: lantern cone drawn several stops over white across the middle of the frame.
 840COLOUR_READING_PLATE = (0.02, 0.03, 0.06, 0.78)
 841#: The plate under the drive's chip. Heavier again than a reading's, because
 842#: what it has to keep off those numbers is not a backdrop but a line: the
 843#: failed spool's ring holds its radius across this band for a beat.
 844COLOUR_WARP_STATUS_PLATE = (0.02, 0.03, 0.06, 0.92)
 845
 846
 847def screen_scale_for(height: float) -> float:
 848    """The height-derived half of the UI scale for a window *height* tall.
 849
 850    Linear in the window's height against :data:`UI_SCALE_REFERENCE_HEIGHT_PX`
 851    and clamped to :data:`UI_SCALE_FROM_SCREEN_MIN` ..
 852    :data:`UI_SCALE_FROM_SCREEN_MAX`, so text keeps its share of the screen
 853    instead of shrinking into it.
 854    """
 855    scale = float(height) / UI_SCALE_REFERENCE_HEIGHT_PX
 856    return min(UI_SCALE_FROM_SCREEN_MAX, max(UI_SCALE_FROM_SCREEN_MIN, scale))
 857
 858
 859# ============================================================================
 860# Element vocabulary and the subtraction schedule
 861# ============================================================================
 862
 863
 864class HudElements:
 865    """Ids for every subtractable HUD element.
 866
 867    ``flow.Onboarding.visible_hud_elements`` returns a subset of these and the
 868    run scene hands it to :meth:`Hud.set_visible_elements`. Ids are strings so
 869    a saved assist profile can round-trip them.
 870    """
 871
 872    HULL = "hull"
 873    CAPACITOR = "capacitor"
 874    O2 = "o2"
 875    FUEL = "fuel"
 876    #: The numeric strip under the ring, and the power-state pips beside it.
 877    RESOURCES = "resources"
 878    STATE_PIPS = "state_pips"
 879    SHIELD = "shield"
 880    SECTOR = "sector"
 881    ARRIVAL_CARD = "arrival_card"
 882    SIGNATURE = "signature"
 883    AMMO = "ammo"
 884    SCRAP = "scrap"
 885    NOTORIETY = "notoriety"
 886    GENERATOR = "generator"
 887    SILENT_RUNNING = "silent_running"
 888    WARP_SPOOL = "warp_spool"
 889    INTERACT_FILL = "interact_fill"
 890    DAMAGE_DIRECTION = "damage_direction"
 891    SUBTITLES = "subtitles"
 892    STAMP = "stamp"
 893    CONTROL_CARD = "control_card"
 894    TOAST = "toast"
 895    FLOAT_TEXT = "float_text"
 896    WARNINGS = "warnings"
 897    BEACON = "beacon"
 898    AFFORDANCE = "affordance"
 899    CONTROLS_BAR = "controls_bar"
 900    WARP_STATUS = "warp_status"
 901
 902
 903ALL_HUD_ELEMENTS: frozenset[str] = frozenset(
 904    value for name, value in vars(HudElements).items() if not name.startswith("_") and isinstance(value, str)
 905)
 906
 907#: The run-one floor: the design's gauges, plus the diegetic and accessibility
 908#: elements that are never subtracted. The spool ring, the interact fill and the
 909#: damage edge are part of the fiction rather than a readout, and subtitles and
 910#: stamps carry information the mix would otherwise gate on hearing, so
 911#: onboarding hides none of them. The control card is in the floor for the same
 912#: reason in reverse: run one is the run that needs it, and so are the survival
 913#: warnings, the toast line, world-anchored labels and the depot beacon: they
 914#: answer a question the pilot has just asked, or tell them they are about to
 915#: die, and neither is a readout to be earned. The affordance line, the controls
 916#: bar and the warp chip are in the floor for the same reason: they name the
 917#: verbs, and a game that hides its verbs from a first-time pilot has subtracted
 918#: the wrong thing.
 919#:
 920#: The resource numbers are in the floor because of the rule the second blind
 921#: playtest bought at the cost of a whole run: **anything the run can charge or
 922#: credit carries a visible number from the first moment it is touched.** Run
 923#: one quotes five scrap for a patch, fifteen fuel for a jump and ten air for a
 924#: canister, and the schedule was subtracting all three readouts while the
 925#: prices went on being charged. Only ammunition and notoriety may still wait,
 926#: because run one neither spends nor earns either.
 927MINIMAL_HUD_ELEMENTS: frozenset[str] = frozenset(
 928    {
 929        HudElements.HULL,
 930        HudElements.CAPACITOR,
 931        HudElements.O2,
 932        HudElements.FUEL,
 933        HudElements.SCRAP,
 934        HudElements.RESOURCES,
 935        HudElements.STATE_PIPS,
 936        HudElements.SHIELD,
 937        HudElements.SECTOR,
 938        HudElements.ARRIVAL_CARD,
 939        HudElements.GENERATOR,
 940        HudElements.SIGNATURE,
 941        HudElements.WARP_SPOOL,
 942        HudElements.INTERACT_FILL,
 943        HudElements.DAMAGE_DIRECTION,
 944        HudElements.SUBTITLES,
 945        HudElements.STAMP,
 946        HudElements.CONTROL_CARD,
 947        HudElements.TOAST,
 948        HudElements.FLOAT_TEXT,
 949        HudElements.WARNINGS,
 950        HudElements.BEACON,
 951        HudElements.AFFORDANCE,
 952        HudElements.CONTROLS_BAR,
 953        HudElements.WARP_STATUS,
 954    }
 955)
 956
 957#: The resources whose numbers the strip carries, as
 958#: ``(element_id, label, colour)``. Every one of them is a price the game
 959#: quotes out loud somewhere in run one.
 960RESOURCE_CELLS: tuple[tuple[str, str, tuple[float, float, float, float]], ...] = (
 961    (HudElements.SCRAP, "SCRAP", COLOUR_SCRAP),
 962    (HudElements.FUEL, "FUEL", COLOUR_FUEL),
 963    (HudElements.O2, "O2", COLOUR_O2),
 964)
 965
 966#: The ammunition cell, which joins the strip only once the hull has a ballistic
 967#: weapon or a box aboard. It counts loaded rounds and stowed boxes together,
 968#: because "can I keep shooting" is one question. A hull with a gun and nothing
 969#: to feed it reads ``AMMO 0``, which is the reading that matters most.
 970AMMO_CELL = "AMMO {rounds:.0f}"
 971
 972
 973# ============================================================================
 974# Subtitle glyphs
 975# ============================================================================
 976
 977#: Every audio cue, as ``cue_id -> caption``. ``audio.AudioDirector`` plays
 978#: these ids and the HUD captions them; the two must agree, so the ids here are
 979#: the registry and a new cue is added to both in one change. The wordings match
 980#: ``audio.CUES`` exactly for every shared id.
 981#:
 982#: A caption is a diegetic phrase in small caps naming what was heard: KLAXON,
 983#: HULL BREACHED, WHISTLE. It is never the cue's id, never a developer shorthand
 984#: and never punctuated. Three spellings of the same line have now been read off
 985#: the middle of the screen by a blind pilot and filed as placeholder text that
 986#: had shipped (``!! klaxon``, then ``[klaxon]``, then ``* klaxon *``), because
 987#: punctuation around a word is what unfinished software looks like. The dark
 988#: plate under the stack (:data:`COLOUR_SUBTITLE_PLATE`) is the whole of the
 989#: convention now: a caption track is a strip of prose on a plate.
 990CUE_SUBTITLES: dict[str, str] = {
 991    "generator_hum": "GENERATOR HUM",
 992    "hull_creak": "HULL CREAKS",
 993    "silent_running": "HULL CREAKS ONLY",
 994    "klaxon": "KLAXON",
 995    "sonar_ping": "SONAR PING",
 996    "groan": "DISTANT GROAN",
 997    "heartbeat": "HEARTBEAT",
 998    "kill": "KILL CHIME",
 999    "kill_chime": "KILL CHIME",
1000    "weapon_fire": "SHOT",
1001    "breathing": "LABOURED BREATHING",
1002    "spool_stutter": "SPOOL STUTTERS",
1003    "spawn_chevron": "HOSTILES INBOUND",
1004    "mortar_whistle": "WHISTLE",
1005    "signature_locked": "SIGNATURE LOCKED",
1006    "tear_in": "IT TEARS IN",
1007    "lantern_burn": "LANTERN BURN",
1008    "lantern_charge": "LANTERN CHARGING",
1009    "energy_denied": "CAPACITOR EMPTY",
1010    "breach": "HULL BREACHED",
1011}
1012
1013#: How a positional cue names the side it came from, in the order of
1014#: :data:`_EDGES` (left, right, top, bottom). Words rather than arrows: an arrow
1015#: welded to a caption is one more piece of punctuation to be read as scaffolding.
1016CAPTION_DIRECTIONS = ("LEFT", "RIGHT", "ABOVE", "BELOW")
1017#: How a direction is joined to its caption.
1018CAPTION_DIRECTION_FORMAT = "{caption}, {direction}"
1019
1020
1021def caption_for(cue: str) -> str:
1022    """The caption line for audio cue *cue*.
1023
1024    An id with no entry in :data:`CUE_SUBTITLES` is captioned from the id
1025    itself rather than dropped: a missing caption is an accessibility defect
1026    and a clumsy one is a cosmetic defect, and they are not the same size. It
1027    is still written in the caption track's own voice, so an id that slips
1028    through reads as a sound rather than as an instruction.
1029    """
1030    caption = CUE_SUBTITLES.get(cue)
1031    return caption if caption is not None else str(cue).replace("_", " ").upper()
1032
1033
1034def _caption_base(line: str) -> str:
1035    """*line* without its side suffix, for de-duplicating one sound's captions."""
1036    for direction in CAPTION_DIRECTIONS:
1037        suffix = CAPTION_DIRECTION_FORMAT.format(caption="", direction=direction)
1038        if line.endswith(suffix):
1039            return line[: -len(suffix)]
1040    return line
1041
1042
1043# ============================================================================
1044# Geometry helpers
1045# ============================================================================
1046
1047#: ``element_id -> (centre_degrees, span_degrees, colour, caption, compass)``.
1048#: Screen angles run clockwise from +X because screen Y points down, so 90 is
1049#: below the ship. *compass* is the fixed point the caption is pinned to, which
1050#: is always the arc's own bearing: the caption is placed outside every ring
1051#: there, not on the arc, so four coloured strokes read as four named gauges
1052#: rather than as four strokes with words written through them.
1053_ARC_SPECS: tuple[tuple[str, float, float, tuple[float, float, float, float], str, str], ...] = (
1054    (HudElements.CAPACITOR, 90.0, 84.0, COLOUR_CAPACITOR, "PWR", "bottom"),
1055    (HudElements.HULL, 270.0, 84.0, COLOUR_HULL, "HULL", "top"),
1056    (HudElements.O2, 180.0, 58.0, COLOUR_O2, "O2", "left"),
1057    (HudElements.FUEL, 0.0, 58.0, COLOUR_FUEL, "FUEL", "right"),
1058)
1059
1060#: Which gauges write their number beside their caption. Hull and capacitor have
1061#: no cell in the resource strip, and a bar with no number is the reading a
1062#: blind pilot could not quote back: "how much hull have I got" had no answer
1063#: anywhere on the screen.
1064_ARC_VALUE_ELEMENTS: frozenset[str] = frozenset({HudElements.HULL, HudElements.CAPACITOR})
1065
1066_EDGES = ("left", "right", "top", "bottom")
1067
1068#: Every signal the HUD listens for, mapped to the method that handles it.
1069#: Discovery stops rescanning once each of these has an emitter.
1070_SIGNAL_HANDLERS: dict[str, str] = {
1071    SignalNames.HULL_CHANGED: "_on_hull_changed",
1072    SignalNames.BREACH_OPENED: "_on_breach_opened",
1073    SignalNames.BREACH_PATCHED: "_on_breach_patched",
1074    SignalNames.CAPACITOR_CHANGED: "_on_capacitor_changed",
1075    SignalNames.ENERGY_DENIED: "_on_energy_denied",
1076    SignalNames.O2_CHANGED: "_on_o2_changed",
1077    SignalNames.FUEL_CHANGED: "_on_fuel_changed",
1078    SignalNames.GENERATOR_CHANGED: "_on_generator_changed",
1079    SignalNames.SILENT_RUNNING_CHANGED: "_on_silent_running_changed",
1080    SignalNames.SOLAR_STATE_CHANGED: "_on_solar_state_changed",
1081    SignalNames.AFTERBURNER_CHANGED: "_on_afterburner_changed",
1082    SignalNames.SIGNATURE_CHANGED: "_on_signature_changed",
1083    SignalNames.SIGNATURE_LOCKED: "_on_signature_locked",
1084    SignalNames.AMMO_CHANGED: "_on_ammo_changed",
1085    SignalNames.SCRAP_CHANGED: "_on_scrap_changed",
1086    SignalNames.NOTORIETY_CHANGED: "_on_notoriety_changed",
1087    SignalNames.PLAYER_DAMAGED: "_on_player_damaged",
1088    SignalNames.ENEMY_KILLED: "_on_enemy_killed",
1089    SignalNames.WAVE_SPAWNED: "_on_wave_spawned",
1090    SignalNames.HUNTER_TELEGRAPH: "_on_hunter_telegraph",
1091    SignalNames.WARP_SPOOL_STARTED: "_on_warp_spool_started",
1092    SignalNames.WARP_SPOOL_INTERRUPTED: "_on_warp_spool_interrupted",
1093    SignalNames.WARP_SPOOL_CANCELLED: "_on_warp_spool_cancelled",
1094    SignalNames.WARP_COMPLETED: "_on_warp_completed",
1095    # The audio director's own announcement of every cue it plays, however the
1096    # play was reached. It is what keeps a cue played directly by a system
1097    # (the hunter's burn klaxon) on the caption track.
1098    SignalNames.AUDIO_CUE: "_on_audio_cue",
1099}
1100
1101_WANTED_SIGNALS: frozenset[str] = frozenset(_SIGNAL_HANDLERS)
1102
1103
1104class CardLayout(NamedTuple):
1105    """Where a top-band panel is drawn, and how much of it survived the fit."""
1106
1107    x: float
1108    y: float
1109    width: float
1110    height: float
1111    padding: float
1112    line: float
1113    font_scale: float
1114    second_line: float
1115    second_font_scale: float
1116    #: Height reserved under the text for the drawn hold bar, 0 when there is none.
1117    bar: float = 0.0
1118
1119    @property
1120    def bottom(self) -> float:
1121        return self.y + self.height
1122
1123    @property
1124    def shows_second_line(self) -> bool:
1125        return self.second_line > 0.0
1126
1127
1128def _clamp01(value: float) -> float:
1129    return 0.0 if value < 0.0 else 1.0 if value > 1.0 else value
1130
1131
1132def _fraction(current: float, maximum: float) -> float:
1133    return 0.0 if maximum <= 0.0 else _clamp01(current / maximum)
1134
1135
1136def _lerp_colour(a, b, t: float) -> tuple[float, float, float, float]:
1137    """Blend two RGBA tuples, landing exactly on an endpoint at 0 and 1."""
1138    if t <= 0.0:
1139        return tuple(a)
1140    if t >= 1.0:
1141        return tuple(b)
1142    return tuple(a[i] + (b[i] - a[i]) * t for i in range(4))
1143
1144
1145def _with_alpha(colour, alpha: float) -> tuple[float, float, float, float]:
1146    return (colour[0], colour[1], colour[2], colour[3] * _clamp01(alpha))
1147
1148
1149def _opaque(colour) -> tuple[float, float, float, float]:
1150    """*colour* at full alpha, for the elements that are read rather than felt."""
1151    return (colour[0], colour[1], colour[2], 1.0)
1152
1153
1154def _read_typed_bar(hint: str) -> tuple[float | None, str]:
1155    """Split a typed progress bar off *hint*, as ``(fraction, remaining_text)``.
1156
1157    The onboarding script used to report a hold as :data:`TYPED_BAR_OPEN` around
1158    a run of fill and empty glyphs. On the screen that is a line of punctuation
1159    in the middle of a tutorial panel, and a blind playtest read it as debug
1160    output; as a fraction it is exactly the bar the card should be drawing. It
1161    passes :meth:`Hud.show_control_card`'s ``progress`` argument now; this guard
1162    stays so a caller that has not been taught the argument still gets the drawn
1163    version and the typed form never reaches the glass.
1164    """
1165    start = hint.find(TYPED_BAR_OPEN)
1166    end = hint.find(TYPED_BAR_CLOSE, start + 1) if start >= 0 else -1
1167    if start < 0 or end < 0:
1168        return (None, hint)
1169    body = hint[start + 1 : end]
1170    if not body or set(body) - set(TYPED_BAR_GLYPHS):
1171        return (None, hint)
1172    filled = sum(1 for glyph in body if glyph == TYPED_BAR_FULL)
1173    remainder = f"{hint[:start]}{hint[end + 1 :]}".strip()
1174    return (filled / len(body), remainder)
1175
1176
1177def _credits_nothing(text: str) -> bool:
1178    """True for a label like ``"+0 SCRAP"``: a credit that rounds away to nothing.
1179
1180    Such a label is never drawn. It is not a small credit, it is a wrong one:
1181    the counter beside it climbs by the fraction the label rounded off, so the
1182    pilot is shown a zero and a number that moved, in the same breath.
1183    """
1184    head = str(text).split(maxsplit=1)
1185    if not head or not head[0].startswith("+"):
1186        return False
1187    try:
1188        return float(head[0][1:]) == 0.0
1189    except ValueError:
1190        return False
1191
1192
1193def _plate_width(text: str, font_scale: float, scale: float, padding_px: float, limit: float) -> float:
1194    """Width of a dark plate wide enough to hold *text*, capped at *limit*."""
1195    width = len(str(text)) * TEXT_EM_PX * GLYPH_ADVANCE_FRACTION * font_scale * scale
1196    return min(limit, width + 2.0 * padding_px * scale)
1197
1198
1199def _gpu_phase_rows() -> tuple[tuple[str, float], ...]:
1200    """The hottest GPU passes the running engine is publishing, worst first.
1201
1202    Read off ``App.last_telemetry``, which the windowed loop refreshes every
1203    frame. Empty on every backend that has no timestamp pools, on a headless
1204    run, and before the first frame has resolved a query.
1205    """
1206    try:
1207        from simvx.graphics import App
1208    except Exception:
1209        return ()
1210    app = App.current()
1211    telemetry = getattr(app, "last_telemetry", None) if app is not None else None
1212    phases = telemetry.get("gpu_phase_times") if telemetry else None
1213    if not phases:
1214        return ()
1215    rows = sorted(((str(name), float(value)) for name, value in phases.items()), key=lambda row: -row[1])
1216    return tuple(rows[:FRAME_STATS_GPU_ROWS])
1217
1218
1219def _draw_arc(
1220    renderer, cx: float, cy: float, radius: float, start_deg: float, sweep_deg: float, thickness: float, colour
1221) -> None:
1222    """Stroke an arc as a short polyline of thick segments."""
1223    if abs(sweep_deg) < 1e-3 or radius <= 0.0:
1224        return
1225    steps = max(2, int(abs(sweep_deg) / ARC_SEGMENT_DEGREES) + 1)
1226    step = math.radians(sweep_deg) / steps
1227    angle = math.radians(start_deg)
1228    px, py = cx + math.cos(angle) * radius, cy + math.sin(angle) * radius
1229    for _ in range(steps):
1230        angle += step
1231        nx, ny = cx + math.cos(angle) * radius, cy + math.sin(angle) * radius
1232        renderer.draw_thick_line(px, py, nx, ny, thickness, colour=colour)
1233        px, py = nx, ny
1234
1235
1236# ============================================================================
1237# Hud
1238# ============================================================================
1239
1240
1241class Hud(Control):
1242    """The run's heads-up display; registered as ``Services.HUD``.
1243
1244    Anchored full-rect and never positioned absolutely, so it survives a window
1245    resize and a UI scale change without relayout code. It emits nothing: every
1246    value it shows arrives on a signal it discovered for itself.
1247    """
1248
1249    dynamic = True
1250
1251    def __init__(self, **kwargs):
1252        super().__init__(**kwargs)
1253        # A full-screen HUD must never eat a click meant for the guns.
1254        self.mouse_filter = False
1255
1256        # Accessibility: the player's UI-scale slider, multiplied by the scale
1257        # the window's own height asks for. Only the slider is settable.
1258        self._settings_scale: float = 1.0
1259        self._screen_scale: float = 1.0
1260
1261        self._visible_elements: frozenset[str] = ALL_HUD_ELEMENTS
1262
1263        self._gauges: dict[str, float] = {
1264            HudElements.HULL: 1.0,
1265            HudElements.CAPACITOR: 1.0,
1266            HudElements.O2: 1.0,
1267            HudElements.FUEL: 1.0,
1268        }
1269        #: Absolute readings behind the arcs. The arcs carry the fraction and
1270        #: the strip carries the number, and both come off the one signal.
1271        self._levels: dict[str, float] = {
1272            HudElements.HULL: 0.0,
1273            HudElements.CAPACITOR: 0.0,
1274            HudElements.O2: 0.0,
1275            HudElements.FUEL: 0.0,
1276            HudElements.SCRAP: 0.0,
1277        }
1278        self._signature = 0.0
1279        #: Whether the meter has locked and the arrival it bought is still to
1280        #: come. Cleared by the tear-in and by leaving the sector, which are
1281        #: the two things that make the instruction stale.
1282        self._signature_locked = False
1283        self._notoriety = 0
1284        self._open_breaches = 0
1285        self._generator_running = False
1286        self._silent_running = False
1287        self._wings_state = ""
1288        self._afterburner = False
1289        self._ammo: dict[str, int] = {}
1290
1291        self._sector_name = ""
1292        self._arrival_name = ""
1293        self._arrival_flavour = ""
1294        self._arrival_remaining = 0.0
1295
1296        self._warp_active = False
1297        self._warp_elapsed = 0.0
1298        self._warp_total = balance.WARP_SPOOL_S
1299        self._warp_stutter = 0.0
1300        self._warp_target = ""
1301        self._warp_fuel = 0.0
1302        self._warp_price_text = ""
1303        self._warp_interrupt_text = ""
1304        self._warp_interrupt_remaining = 0.0
1305
1306        self._edge_damage: dict[str, float] = dict.fromkeys(_EDGES, 0.0)
1307
1308        #: The hull fraction before the last blow, while the lost chunk is
1309        #: still draining off the arc. Equal to the gauge once it has settled.
1310        self._hull_chip = 1.0
1311        #: Seconds the chunk still holds at full length, and the fraction of
1312        #: the ring per second it gives back once the hold runs out. The rate
1313        #: is set per blow so the drain takes HULL_CHIP_DECAY_S whatever the
1314        #: blow cost.
1315        self._hull_chip_hold = 0.0
1316        self._hull_chip_rate = 0.0
1317
1318        #: Seconds of air left at the drain rate reported with the last reading.
1319        self._o2_seconds = float("inf")
1320        #: Ordinary jumps the tank still covers, at the base fare. Infinite
1321        #: until a reading lands, so a HUD nobody has told is never "low".
1322        self._fuel_jumps = float("inf")
1323        self._low_hull = False
1324        self._low_hull_klaxon_in = 0.0
1325
1326        self._beacon_position: Vec3 | None = None
1327        self._beacon_label = ""
1328
1329        self._boundary_notice = ""
1330        self._boundary_remaining = 0.0
1331        self._objective = ""
1332        self._compass: tuple[tuple[Vec3, str], ...] = ()
1333        self._compass_remaining = 0.0
1334
1335        self._warp_outcome_text = ""
1336        self._warp_outcome_remaining = 0.0
1337        self._warp_outcome_fraction = 0.0
1338        self._warp_jump_flash = 0.0
1339
1340        self._subtitles: list[list] = []  # [caption, remaining_seconds]
1341        self._toast_text = ""
1342        self._toast_remaining = 0.0
1343        #: The second clause of the answer line and how long a later answer may
1344        #: still join it. See :meth:`set_toast_note`.
1345        self._toast_note = ""
1346        self._toast_note_join = 0.0
1347        #: Rows waypoint labels have already taken this frame, as
1348        #: ``(row, left, right)``. Shared by the beacon, the compass and the
1349        #: Shrike countdown, which used to keep three private lists and write
1350        #: "DEPOT 8 u" straight through "VEIN 7 u".
1351        self._marker_rows: list[tuple[float, float, float]] = []
1352        #: [text, remaining, total, colour, node_ref, position, toward]
1353        self._floats: list[list] = []
1354        #: Sub-unit credits waiting for a whole one, per source. See
1355        #: :meth:`credit_float`.
1356        self._credit_banks: dict[str, float] = {}
1357        self._stamp_text = ""
1358        self._stamp_remaining = 0.0
1359
1360        self._card_text = ""
1361        self._card_hint = ""
1362        self._card_verb = ""
1363        self._card_progress: float | None = None
1364
1365        self._interact_fraction = 0.0
1366        self._interact_remaining = 0.0
1367
1368        self._affordance_text = ""
1369        self._affordance_remaining = 0.0
1370
1371        self._controls_bar_text = CONTROLS_BAR_TEXT
1372        self._controls_bar_second_row = CONTROLS_BAR_SECOND_ROW
1373        self._controls_bar_visible = True
1374
1375        self._warp_status = ""
1376        self._warp_status_blocked = False
1377
1378        self._elapsed = 0.0
1379        self._rescan_in = 0.0
1380        self._ammo_poll_in = AMMO_POLL_S
1381        # Which signals each emitter is already wired for. Keyed weakly and by
1382        # the node itself rather than by ``id()``: enemies and hunters are
1383        # freed mid-run, and a recycled address would otherwise look like an
1384        # emitter the HUD had already connected to.
1385        self._connected: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
1386        self._connected_names: set[str] = set()
1387        self._ship = None
1388        self._rig: CameraRig | None = None
1389        #: The developer frame readout: off until :data:`DEBUG_FRAME_STATS_ACTION`,
1390        #: then a rolling window of wall-clock frame times and whatever per-pass
1391        #: GPU timings the driver is publishing.
1392        self._frame_stats = False
1393        self._frame_times: list[float] = []
1394        self._gpu_phases: tuple[tuple[str, float], ...] = ()
1395        #: The inbound hunter, while one is inbound. Looked up from the hunter
1396        #: group rather than injected, and only while the meter is locked, so a
1397        #: run with no hunter in it pays nothing for the lookup.
1398        self._hunter = None
1399
1400    # ------------------------------------------------------------------ setup
1401
1402    def on_ready(self):
1403        self.set_anchor_preset(AnchorPreset.FULL_RECT)
1404        self._refresh_screen_scale()
1405        tree = self.tree
1406        if tree is not None:
1407            tree.screen_resized.connect(self._on_screen_resized)
1408        self._connect_sources()
1409
1410    def on_exit_tree(self):
1411        tree = self.tree
1412        if tree is not None:
1413            tree.screen_resized.disconnect(self._on_screen_resized)
1414
1415    # --------------------------------------------------------------- scaling
1416
1417    @property
1418    def ui_scale(self) -> float:
1419        """The player's UI-scale slider. The window's height scales on top."""
1420        return self._settings_scale
1421
1422    @ui_scale.setter
1423    def ui_scale(self, value: float) -> None:
1424        self._settings_scale = max(0.0, float(value))
1425
1426    @property
1427    def screen_scale(self) -> float:
1428        """The height-derived half of the scale, from :func:`screen_scale_for`."""
1429        return self._screen_scale
1430
1431    @property
1432    def effective_scale(self) -> float:
1433        """What the HUD actually draws at: window height times the slider."""
1434        return self._screen_scale * self._settings_scale
1435
1436    def _on_screen_resized(self, size) -> None:
1437        del size
1438        self._refresh_screen_scale()
1439
1440    def _refresh_screen_scale(self) -> None:
1441        """Re-derive the height half of the UI scale from the live window."""
1442        tree = self.tree
1443        if tree is None:
1444            return
1445        self._screen_scale = screen_scale_for(float(tree.screen_size[1]))
1446
1447    # ------------------------------------------------------- public interface
1448
1449    def flash_stamp(self, text: str) -> None:
1450        """Stamp *text* across the centre of the screen for a beat."""
1451        self._stamp_text = str(text)
1452        self._stamp_remaining = STAMP_HOLD_S + STAMP_FADE_S
1453
1454    def show_interact_fill(self, fraction: float) -> None:
1455        """Draw the interact hold as a radial fill on the ship.
1456
1457        Push this every frame the button is held; it clears shortly after the
1458        pushes stop. A *fraction* of zero or less clears it at once.
1459        """
1460        fraction = float(fraction)
1461        if fraction <= 0.0:
1462            self._interact_fraction = 0.0
1463            self._interact_remaining = 0.0
1464            return
1465        self._interact_fraction = _clamp01(fraction)
1466        self._interact_remaining = INTERACT_FILL_HOLD_S
1467
1468    def set_affordance(self, text: str) -> None:
1469        """Name the verb currently in reach, or clear the line with ``""``.
1470
1471        Push this every frame something is in range; it clears itself
1472        :data:`AFFORDANCE_HOLD_S` after the pushes stop, so a resolver that
1473        simply stops calling does not leave a stale prompt over empty space.
1474        The line is one short imperative naming the key and the verb, and only
1475        ever one: two prompts at once is a menu, and this is a reticle.
1476        """
1477        text = str(text)
1478        if not text:
1479            self._affordance_text = ""
1480            self._affordance_remaining = 0.0
1481            return
1482        self._affordance_text = text
1483        self._affordance_remaining = AFFORDANCE_HOLD_S
1484
1485    @property
1486    def affordance(self) -> str:
1487        """The verb line currently up, empty when nothing is in reach."""
1488        return self._affordance_text if self._affordance_remaining > 0.0 else ""
1489
1490    def set_controls_bar(self, text: str, second_row: str | None = None) -> None:
1491        """Replace the persistent controls strip along the bottom edge.
1492
1493        *second_row* replaces the strip's lower row; left out, the row already
1494        there is kept, so a caller rewriting the primary verbs does not have to
1495        restate the whole keyboard to hold on to the rest of it.
1496        """
1497        self._controls_bar_text = str(text)
1498        if second_row is not None:
1499            self._controls_bar_second_row = str(second_row)
1500
1501    @property
1502    def controls_bar(self) -> str:
1503        """The strip's first row as it is drawn, empty while it is hidden."""
1504        return self._controls_bar_text if self._controls_bar_visible else ""
1505
1506    @property
1507    def controls_bar_second_row(self) -> str:
1508        """The strip's second row as it is drawn, empty while it is hidden."""
1509        return self._controls_bar_second_row if self._controls_bar_visible else ""
1510
1511    @property
1512    def controls_bar_visible(self) -> bool:
1513        """Whether the bar is showing. On by default, for every run."""
1514        return self._controls_bar_visible
1515
1516    @controls_bar_visible.setter
1517    def controls_bar_visible(self, value: bool) -> None:
1518        self._controls_bar_visible = bool(value)
1519
1520    def toggle_controls_bar(self) -> bool:
1521        """Flip the bar and return its new state."""
1522        self._controls_bar_visible = not self._controls_bar_visible
1523        return self._controls_bar_visible
1524
1525    # ------------------------------------------------- the developer readout
1526
1527    def toggle_frame_stats(self) -> bool:
1528        """Flip the developer frame readout and return its new state."""
1529        self._frame_stats = not self._frame_stats
1530        if not self._frame_stats:
1531            self._frame_times.clear()
1532            self._gpu_phases = ()
1533        return self._frame_stats
1534
1535    @property
1536    def frame_stats_visible(self) -> bool:
1537        return self._frame_stats
1538
1539    def frame_stats_lines(self) -> tuple[str, ...]:
1540        """What the readout is saying: the CPU window, then the hottest passes.
1541
1542        A tuple rather than a drawing, so a test can assert on the numbers and
1543        so the same lines can be printed to a terminal from a headless probe.
1544        """
1545        if not self._frame_times:
1546            return ()
1547        mean = sum(self._frame_times) / len(self._frame_times)
1548        worst = max(self._frame_times)
1549        fps = 1.0 / mean if mean > 0.0 else 0.0
1550        lines = [
1551            f"FRAME {mean * 1000.0:6.2f} ms  ({fps:5.1f} fps)",
1552            f"WORST {worst * 1000.0:6.2f} ms  over {len(self._frame_times)} frames",
1553        ]
1554        if self._gpu_phases:
1555            lines.append("GPU PASSES (ms)")
1556            lines.extend(f"  {name[:22]:<22} {value:6.3f}" for name, value in self._gpu_phases)
1557        else:
1558            lines.append("GPU PASSES: no timestamp pool")
1559        return tuple(lines)
1560
1561    def _tick_frame_stats(self, dt: float) -> None:
1562        """Sample the frame clock, and the driver's pass timings where they exist.
1563
1564        Every access in :func:`_gpu_phase_rows` is guarded, so a backend with no
1565        timestamp pool prints nothing rather than failing.
1566        """
1567        if Input.is_action_just_pressed(DEBUG_FRAME_STATS_ACTION):
1568            self.toggle_frame_stats()
1569        if not self._frame_stats:
1570            return
1571        if dt > 0.0:
1572            self._frame_times.append(float(dt))
1573            if len(self._frame_times) > FRAME_STATS_WINDOW:
1574                del self._frame_times[: len(self._frame_times) - FRAME_STATS_WINDOW]
1575        self._gpu_phases = _gpu_phase_rows()
1576
1577    def set_warp_status(self, text: str, *, blocked: bool = False) -> None:
1578        """Say whether the drive can leave, and at what price.
1579
1580        Drawn where the spool ring will close, and only while no spool is
1581        running: once the ring is up it is answering the same question itself.
1582        *blocked* colours the line as a refusal ("not enough fuel") rather than
1583        as an offer.
1584        """
1585        self._warp_status = str(text)
1586        self._warp_status_blocked = bool(blocked)
1587
1588    @property
1589    def warp_status(self) -> str:
1590        """The warp chip's line, empty when the drive has nothing to say."""
1591        return self._warp_status
1592
1593    def show_subtitle(self, cue: str, *, direction: Vec3 | None = None) -> None:
1594        """Caption an audio *cue* by its id, through :func:`caption_for`.
1595
1596        Pass *direction* (the bearing from the ship to the sound) for a spatial
1597        cue and the caption names the side it came from, the way a caption track
1598        writes an off-screen sound.
1599        """
1600        line = caption_for(cue)
1601        if direction is not None:
1602            line = CAPTION_DIRECTION_FORMAT.format(
1603                caption=line, direction=CAPTION_DIRECTIONS[self._edge_index(direction)]
1604            )
1605        self._push_subtitle(line)
1606
1607    def _push_subtitle(self, line: str) -> None:
1608        """Add *line* to the caption stack, one line per sound.
1609
1610        A cue often reaches the track twice, once from the signal the HUD
1611        consumes itself and once from the audio director's announcement, and
1612        the two spellings may differ only by a side suffix. Lines are
1613        de-duplicated on the caption without its suffix, keeping whichever
1614        spelling names a side, so one klaxon is always one line.
1615        """
1616        base = _caption_base(line)
1617        for entry in self._subtitles:
1618            if _caption_base(entry[0]) == base:
1619                if len(line) > len(entry[0]):
1620                    entry[0] = line
1621                entry[1] = SUBTITLE_HOLD_S
1622                return
1623        self._subtitles.append([line, SUBTITLE_HOLD_S])
1624        del self._subtitles[:-SUBTITLE_MAX_LINES]
1625
1626    def show_toast(self, text: str) -> None:
1627        """Put one short line up, replacing whichever toast was showing.
1628
1629        The toast is the answer to something the player just did: a key that
1630        needs a destination first, a depot one jump out. It sits above the
1631        caption stack in its own type so a combat caption cannot bury it, and
1632        it goes away on its own after :data:`TOAST_HOLD_S`.
1633
1634        A note set within :data:`TOAST_NOTE_JOIN_S` survives the replacement and
1635        is folded into the new line, because a note and a toast that arrive in
1636        the same frame are two halves of one answer to one key press.
1637        """
1638        if self._toast_note_join <= 0.0:
1639            self._toast_note = ""
1640        self._toast_text = str(text)
1641        self._toast_remaining = TOAST_HOLD_S + TOAST_FADE_S
1642
1643    def set_toast_note(self, text: str) -> None:
1644        """Add a second clause to the answer line instead of replacing it.
1645
1646        The tutorial's "what the script wants first" and the run's own refusal
1647        are both answers to the same press, and they are raised by different
1648        nodes in an order neither of them controls. Whichever spoke last used to
1649        be the whole line, so a first-run pilot who pressed the generator key
1650        read ``FIRST: FLY: W A S D`` and never learned there was no generator
1651        fitted. A note joins whatever the line says, in either order, and stands
1652        alone when nothing else answered.
1653        """
1654        text = str(text).strip()
1655        if not text:
1656            return
1657        self._toast_note = text
1658        self._toast_note_join = TOAST_NOTE_JOIN_S
1659        if self._toast_remaining <= 0.0:
1660            self._toast_text = text
1661            self._toast_remaining = TOAST_HOLD_S + TOAST_FADE_S
1662
1663    @property
1664    def toast(self) -> str:
1665        """The toast currently up, note folded in, empty when none is."""
1666        if self._toast_remaining <= 0.0:
1667            return ""
1668        note = self._toast_note
1669        if not note or note == self._toast_text or note in self._toast_text:
1670            return self._toast_text
1671        return TOAST_WITH_NOTE.format(text=self._toast_text, note=note)
1672
1673    def float_text(
1674        self,
1675        text: str,
1676        *,
1677        position: Vec3 | None = None,
1678        node: Node | None = None,
1679        hold_s: float = FLOAT_TEXT_HOLD_S,
1680        colour=COLOUR_FLOAT_TEXT,
1681        toward: str | None = None,
1682        plate: bool = False,
1683    ) -> None:
1684        """Float *text* up from a point in the world and fade it out.
1685
1686        Anchor it to a *node* to have the label follow something that moves
1687        (an enemy wearing its first-sighting tag), or to a fixed *position*
1688        for something that does not (a pickup's value at the point it was
1689        scooped). A label with neither anchor is dropped rather than drawn in
1690        a corner: world text that is not in the world says nothing.
1691
1692        A label whose credit rounds to zero is dropped rather than drawn (see
1693        :func:`_credits_nothing`); use :meth:`credit_float` for anything paid in
1694        fractions, which is every beam and every vent.
1695
1696        *toward* names a counter from :data:`FLOAT_TARGETS` and makes the label
1697        fly to it instead of drifting: a credit the pilot can watch land is the
1698        whole of how a corner readout becomes a consequence. Left as ``None``
1699        it is inferred from the label's own words, so ``"+12 SCRAP"`` finds the
1700        scrap counter without the caller having to say so twice; pass ``""`` to
1701        turn the flight off for a label that credits nothing.
1702
1703        Set *plate* for a label that has to survive its own effect. Unbacked
1704        text is fine over the near-black arena and useless inside a flare: the
1705        Shrike's burn wrote its price into the middle of the white cone that
1706        had just delivered it, and the caption plate is the one treatment on
1707        this HUD that reads through a flood of light.
1708        """
1709        if node is None and position is None:
1710            return
1711        if _credits_nothing(text):
1712            return
1713        reference = weakref.ref(node) if node is not None else None
1714        anchor = Vec3(position) if position is not None else None
1715        hold = max(0.05, float(hold_s))
1716        target = self._infer_float_target(text) if toward is None else str(toward)
1717        self._floats.append([str(text), hold, hold, tuple(colour), reference, anchor, target, bool(plate)])
1718        del self._floats[:-FLOAT_TEXT_MAX]
1719
1720    def credit_float(
1721        self,
1722        source: str,
1723        amount: float,
1724        label: str,
1725        *,
1726        node: Node | None = None,
1727        position: Vec3 | None = None,
1728        colour=COLOUR_FLOAT_TEXT,
1729        toward: str | None = None,
1730    ) -> bool:
1731        """Bank *amount* against *source* and float a label per whole unit earned.
1732
1733        A mining beam pays a fraction of a unit per frame and a vent pays for as
1734        long as the ship sits in it, so a label per tick is a column of
1735        ``+0 SCRAP`` beside a counter that is visibly climbing: the number was
1736        right and the label was calling it a lie. Credits bank per source and
1737        only the whole part is ever written, so the labels sum to exactly what
1738        the counter took and not one of them says nothing.
1739
1740        The remainder is carried, never dropped, which is what makes ten ticks
1741        of a tenth of a unit one honest ``+1``. Returns True when a label went up.
1742        """
1743        bank = self._credit_banks.get(source, 0.0) + max(0.0, float(amount))
1744        whole = math.floor(bank)
1745        self._credit_banks[source] = bank - whole
1746        if whole < 1.0:
1747            return False
1748        self.float_text(
1749            CREDIT_FLOAT_FORMAT.format(amount=whole, label=label),
1750            node=node,
1751            position=position,
1752            colour=colour,
1753            toward=toward,
1754        )
1755        return True
1756
1757    def credit_bank(self, source: str) -> float:
1758        """The fraction of a unit *source* is carrying toward its next label."""
1759        return self._credit_banks.get(source, 0.0)
1760
1761    @staticmethod
1762    def _infer_float_target(text: str) -> str:
1763        """The counter a label credits, read off the label, or ``""``."""
1764        for word in str(text).lower().replace(",", " ").split():
1765            if word in FLOAT_TARGETS:
1766                return word
1767        return ""
1768
1769    @property
1770    def float_texts(self) -> tuple[str, ...]:
1771        """The world-anchored labels currently alive, oldest first."""
1772        return tuple(entry[0] for entry in self._floats)
1773
1774    def set_depot_beacon(self, position: Vec3 | None, label: str = "DEPOT") -> None:
1775        """Mark *position* with an edge beacon and a live range, or clear it.
1776
1777        The sector's depot is the only thing a scrap hold can be spent on, so
1778        it gets the one persistent world marker the HUD draws. Pass ``None``
1779        for a sector that has no depot.
1780        """
1781        self._beacon_position = Vec3(position) if position is not None else None
1782        self._beacon_label = str(label)
1783
1784    @property
1785    def depot_beacon(self) -> Vec3 | None:
1786        """Where the beacon is pointing, or None when there is nothing to mark."""
1787        return self._beacon_position
1788
1789    @property
1790    def beacon_range(self) -> float:
1791        """Plane distance from the hull to the beacon, ``inf`` when there is none."""
1792        target = self._beacon_position
1793        ship = self._ship
1794        if target is None or ship is None:
1795            return float("inf")
1796        offset = Vec3(target) - Vec3(ship.world_position)
1797        return math.hypot(float(offset.x), float(offset.z))
1798
1799    @property
1800    def beacon_in_range(self) -> bool:
1801        """Whether the hull is inside the bay the beacon marks.
1802
1803        Readable state, because it is what the ring's own look is driven off:
1804        a marker that changes when the ship crosses a line the pilot cannot see
1805        is the only thing on the screen that says the line is there.
1806        """
1807        return self.beacon_range <= DOCK_PROMPT_RANGE_UNITS
1808
1809    @property
1810    def dock_prompt(self) -> str:
1811        """The verb written on the ring, or ``""`` when the hull is not in the bay.
1812
1813        The run's own affordance whenever it is talking about the dock, so
1814        "DOCKED: hold F to sell" reaches the ring the same way "F: DOCK" does,
1815        and the fixed prompt otherwise: a pilot inside a bay that says nothing
1816        is a pilot who flies back out of it.
1817        """
1818        if not self.beacon_in_range:
1819            return ""
1820        affordance = self.affordance
1821        if affordance and DOCK_PROMPT_KEYWORD in affordance.upper():
1822            return affordance
1823        return DOCK_PROMPT
1824
1825    def set_boundary_notice(self, text: str) -> None:
1826        """Say that the ship is outside the sector, for as long as it is.
1827
1828        Pushed every frame by whoever owns the boundary and cleared
1829        :data:`BOUNDARY_NOTICE_HOLD_S` after the pushes stop, so turning back
1830        takes the line down without anyone having to remember to.
1831        """
1832        text = str(text)
1833        if not text:
1834            self._boundary_notice = ""
1835            self._boundary_remaining = 0.0
1836            return
1837        self._boundary_notice = text
1838        self._boundary_remaining = BOUNDARY_NOTICE_HOLD_S
1839
1840    @property
1841    def boundary_notice(self) -> str:
1842        """The standing boundary line, empty when the ship is inside."""
1843        return self._boundary_notice if self._boundary_remaining > 0.0 else ""
1844
1845    def set_objective(self, text: str) -> None:
1846        """Set the standing line saying what the run wants of this sector.
1847
1848        A standing line, not a toast: it stays up until the next call replaces
1849        it or ``""`` clears it, because "what am I here for" is a question a
1850        pilot asks minutes after the answer was first given. It shares the
1851        objective band with the lock's instruction, which outranks it: once the
1852        Shrike is inbound there is only one objective.
1853        """
1854        self._objective = str(text)
1855
1856    @property
1857    def objective(self) -> str:
1858        """The standing objective line, empty when nothing has been asked."""
1859        return self._objective
1860
1861    def set_compass_targets(self, targets) -> None:
1862        """Offer the screen-edge compass the things worth flying to.
1863
1864        *targets* is a sequence of ``(world_position, kind)`` with kinds from
1865        :data:`COMPASS_LABELS`. The HUD decides whether to draw them, because
1866        only it knows what is on screen: markers appear exactly when none of
1867        them is visible, which is the moment the sector looks empty.
1868        """
1869        self._compass = tuple((Vec3(position), str(kind)) for position, kind in targets)
1870        self._compass_remaining = COMPASS_HOLD_S if self._compass else 0.0
1871
1872    @property
1873    def compass_targets(self) -> tuple[tuple[Vec3, str], ...]:
1874        """The targets the compass is currently holding, empty when none is."""
1875        return self._compass if self._compass_remaining > 0.0 else ()
1876
1877    def set_warp_target(self, name: str, fuel: float = 0.0, *, price_text: str = "") -> None:
1878        """Name the destination the running spool is buying, and its price.
1879
1880        The ring is a countdown either way; this is what turns it into a
1881        decision the pilot can read. Clearing the name (empty *name*) leaves
1882        the ring unlabelled, which is what an unattributed spool deserves.
1883
1884        *price_text* is the caller's own spelling of the price, for a caller
1885        that has already itemised it ("22 (15 +7 HUNTED)"). Without it the
1886        ring spells *fuel* as a bare number, which is right for a caller that
1887        only has one. The two must not disagree: a ring quoting a total beside
1888        a status chip quoting the itemisation reads as two separate charges,
1889        so anything that itemises anywhere itemises here too.
1890        """
1891        self._warp_target = str(name)
1892        self._warp_fuel = max(0.0, float(fuel))
1893        self._warp_price_text = str(price_text)
1894
1895    @property
1896    def warp_target(self) -> str:
1897        """The destination the spool is labelled with, empty when unlabelled."""
1898        return self._warp_target
1899
1900    @property
1901    def warp_price(self) -> str:
1902        """The price the ring is quoting, as the ring spells it.
1903
1904        Empty while the spool has no destination or no price, so a caller can
1905        tell "free" from "unpriced" without re-deriving either.
1906        """
1907        if not self._warp_target:
1908            return ""
1909        if self._warp_price_text:
1910            return self._warp_price_text
1911        return f"{self._warp_fuel:.0f}" if self._warp_fuel > 0.0 else ""
1912
1913    def fail_warp_spool(self, reason: str) -> None:
1914        """Say why a channel could not finish, on the ring and on the toast.
1915
1916        The ship calls this after standing the drive down, so the red flash
1917        already up gains the reason that caused it. An abort the pilot asked
1918        for keeps the plain :data:`WARP_ABORTED_LABEL`; this is for the two
1919        endings they did not choose.
1920        """
1921        reason = str(reason)
1922        if not reason:
1923            return
1924        self._warp_outcome_text = reason
1925        self._warp_outcome_remaining = WARP_OUTCOME_FLASH_S
1926        self.show_toast(reason)
1927
1928    @property
1929    def warp_outcome_text(self) -> str:
1930        """The ending currently flashing on the ring, empty between spools."""
1931        return self._warp_outcome_text if self._warp_outcome_remaining > 0.0 else ""
1932
1933    @property
1934    def warp_jump_flash(self) -> float:
1935        """Seconds of white-out left from the last jump, 0 when none is running."""
1936        return self._warp_jump_flash
1937
1938    def _flash_warp_outcome(self, text: str) -> None:
1939        """Hold the ring for a beat on *text*, at the radius it had reached."""
1940        self._warp_outcome_fraction = self.warp_spool_fraction
1941        self._warp_outcome_text = str(text)
1942        self._warp_outcome_remaining = WARP_OUTCOME_FLASH_S
1943
1944    def end_warp_spool(self) -> None:
1945        """Take the spool ring down for a channel that ended without a jump.
1946
1947        ``WARP_COMPLETED`` and ``WARP_SPOOL_CANCELLED`` both land here on their
1948        own; this is the direct entry for a refuser that stands the drive down
1949        before the ring has anything to answer to. It clears the ring and not
1950        the ending flashing on it: those are two different sentences, and the
1951        second one is the one the pilot needed.
1952        """
1953        self._warp_active = False
1954        self._warp_elapsed = 0.0
1955        self._warp_stutter = 0.0
1956        self._warp_target = ""
1957        self._warp_fuel = 0.0
1958        self._warp_price_text = ""
1959        self._warp_interrupt_remaining = 0.0
1960
1961    @property
1962    def low_hull(self) -> bool:
1963        """Whether the hull is under :data:`LOW_HULL_FRACTION`."""
1964        return self._gauges[HudElements.HULL] < LOW_HULL_FRACTION
1965
1966    @property
1967    def low_o2(self) -> bool:
1968        """Whether the air is under ``balance.O2_LOW_WARNING_SECONDS``."""
1969        return self._o2_seconds <= balance.O2_LOW_WARNING_SECONDS
1970
1971    @property
1972    def low_fuel(self) -> bool:
1973        """Whether the tank covers :data:`LOW_FUEL_JUMPS` ordinary jumps or fewer.
1974
1975        Counted in jumps rather than in units, the way the bay counts them:
1976        what decides this is how many more times the drive will fire, and a
1977        full tank on a short-ranged hull is still a short range. A HUD that
1978        has not been told the fuel yet is not low on it, so the count starts
1979        at infinity and the first ``FUEL_CHANGED`` writes the truth.
1980        """
1981        return self._fuel_jumps <= LOW_FUEL_JUMPS
1982
1983    def show_control_card(self, text: str, *, verb: str = "", hint: str = "", progress: float | None = None) -> None:
1984        """Put one control card up, replacing whichever card was showing.
1985
1986        *text* is the line that names the keys and *hint* an optional smaller
1987        line under it. *verb* is bookkeeping for the caller: it records what
1988        this card is waiting for, and the HUD hands it back on
1989        :attr:`control_card_verb` so a schedule can ask what is on screen
1990        without keeping a second copy of it. The card never expires on its own;
1991        it is a lesson, not a caption.
1992
1993        *progress* fills a drawn bar across the foot of the card, from 0 to 1,
1994        and ``None`` leaves the card without one. A hold that reported itself as
1995        ``[####....]`` in the hint told the pilot the game was printing debug
1996        text at them; a bar is what a hold looks like. A hint that still carries
1997        a typed bar is read for its fraction and stripped, so a caller that has
1998        not been taught the argument yet still gets the drawn version.
1999        """
2000        hint = str(hint)
2001        if progress is None:
2002            progress, hint = _read_typed_bar(hint)
2003        self._card_text = str(text)
2004        self._card_hint = hint
2005        self._card_verb = str(verb)
2006        self._card_progress = None if progress is None else _clamp01(float(progress))
2007
2008    def clear_control_card(self) -> None:
2009        """Take the control card down."""
2010        self._card_text = ""
2011        self._card_hint = ""
2012        self._card_verb = ""
2013        self._card_progress = None
2014
2015    @property
2016    def control_card_progress(self) -> float | None:
2017        """The card's hold fraction, or None when the card has no bar."""
2018        return self._card_progress
2019
2020    @property
2021    def control_card(self) -> str:
2022        """The card currently up, empty when none is."""
2023        return self._card_text
2024
2025    @property
2026    def control_card_hint(self) -> str:
2027        """The card's second line, empty when it has none."""
2028        return self._card_hint
2029
2030    @property
2031    def control_card_verb(self) -> str:
2032        """What the card on screen is waiting for, empty when none is up."""
2033        return self._card_verb
2034
2035    # ------------------------------------------------------------ the sector
2036
2037    def reset_for_sector(self) -> None:
2038        """Wipe everything the last sector was still saying.
2039
2040        Every transient on the glass is timed rather than scoped, so a jump
2041        used to carry the old sector's whole answer layer into the new one:
2042        its stamp, its answer line, its captions, its floating credits, the
2043        edges reddened by a blow taken light-years away, a boundary notice
2044        for an edge that no longer exists, the ending flashed on a ring that
2045        has already fired, and a half-finished interact fill. None of them
2046        outlive the place they were raised in, so the arrival clears them all
2047        at once rather than each of them separately deciding it should.
2048
2049        Not cleared: :attr:`warp_jump_flash`, which is this jump's own
2050        white-out and the thing covering the camera cut, and the standing
2051        readings, which belong to the ship rather than to the sector. The
2052        caller announces the new sector *after* this, so the arrival card and
2053        the new objective are raised onto clean glass.
2054        """
2055        self._stamp_text = ""
2056        self._stamp_remaining = 0.0
2057        self._toast_text = ""
2058        self._toast_remaining = 0.0
2059        self._toast_note = ""
2060        self._toast_note_join = 0.0
2061        self._subtitles.clear()
2062        self._floats.clear()
2063        for edge in self._edge_damage:
2064            self._edge_damage[edge] = 0.0
2065        self._boundary_notice = ""
2066        self._boundary_remaining = 0.0
2067        self._warp_outcome_text = ""
2068        self._warp_outcome_remaining = 0.0
2069        self._warp_outcome_fraction = 0.0
2070        self._warp_interrupt_text = ""
2071        self._warp_interrupt_remaining = 0.0
2072        self._interact_fraction = 0.0
2073        self._interact_remaining = 0.0
2074        self.clear_control_card()
2075
2076    def set_sector_name(self, name: str) -> None:
2077        """Name the place the ship is in. Stands until the next sector."""
2078        self._sector_name = str(name)
2079
2080    @property
2081    def sector_name(self) -> str:
2082        """The standing sector name, empty when none has been set."""
2083        return self._sector_name
2084
2085    def show_arrival_card(self, name: str, flavour: str = "") -> None:
2086        """Announce an arrival: the name of the place and one line about it.
2087
2088        Held for :data:`ARRIVAL_CARD_HOLD_S` and then gone, which is why the
2089        name is also latched onto :meth:`set_sector_name`: a title card
2090        answers "where am I" once, and the corner answers it for the rest of
2091        the sector. A jump used to land the ship in an unnamed field of rocks
2092        indistinguishable from the last one, so nothing about a warp read as
2093        travel.
2094        """
2095        self._arrival_name = str(name)
2096        self._arrival_flavour = str(flavour)
2097        self._arrival_remaining = ARRIVAL_CARD_HOLD_S if self._arrival_name else 0.0
2098        if self._arrival_name:
2099            self.set_sector_name(self._arrival_name)
2100
2101    @property
2102    def arrival_card(self) -> str:
2103        """The arrival card's headline while it is up, else empty."""
2104        return self._arrival_name if self._arrival_remaining > 0.0 else ""
2105
2106    @property
2107    def arrival_flavour(self) -> str:
2108        """The arrival card's second line while it is up, else empty."""
2109        return self._arrival_flavour if self._arrival_remaining > 0.0 else ""
2110
2111    # --------------------------------------------------------- ship readings
2112
2113    def resource_lines(self) -> tuple[str, ...]:
2114        """The resource strip's cells, in the order they are drawn.
2115
2116        One cell per resource the run can charge or credit and that this HUD
2117        is drawing. The pause overlay prints the same tuple, so the numbers
2118        the pilot reads mid-flight and the numbers on the paused panel can
2119        never be two different readings of the same tanks.
2120        """
2121        return tuple(text for text, _colour in self._resource_cells())
2122
2123    @property
2124    def ammo_total(self) -> int:
2125        """Rounds aboard: every magazine plus every stowed box, or 0 for none."""
2126        return sum(self._ammo.values())
2127
2128    def _resource_cells(self) -> list[tuple[str, tuple[float, float, float, float]]]:
2129        """The strip's cells as ``(text, colour)``, empty when it is subtracted."""
2130        if not self.is_element_visible(HudElements.RESOURCES):
2131            return []
2132        cells = [
2133            (f"{label} {self._levels[element]:.0f}", _opaque(colour))
2134            for element, label, colour in RESOURCE_CELLS
2135            if self.is_element_visible(element)
2136        ]
2137        if self._ammo and self.is_element_visible(HudElements.AMMO):
2138            cells.append((AMMO_CELL.format(rounds=self.ammo_total), _opaque(COLOUR_AMMO)))
2139        return cells
2140
2141    def state_pips(self) -> tuple[tuple[str, bool], ...]:
2142        """The power-state pips as ``(text, lit)``, in the order they are drawn.
2143
2144        Wings and generator always have a pip, lit or not: a key whose only
2145        output is a lamp that is off still answered the press. The afterburner
2146        pip is only there while it burns, because thrust is its own answer.
2147        """
2148        if not self.is_element_visible(HudElements.STATE_PIPS):
2149            return ()
2150        wings_out = self._wings_state in ("deployed", "deploying")
2151        running = self._generator_running
2152        burning = self._afterburner
2153        return (
2154            (PIP_WINGS[0] if wings_out else PIP_WINGS[1], wings_out),
2155            (PIP_GENERATOR[0] if running else PIP_GENERATOR[1], running),
2156            (PIP_AFTERBURNER[0] if burning else PIP_AFTERBURNER[1], burning),
2157        )
2158
2159    @property
2160    def shield_bearing(self) -> float | None:
2161        """Screen angle of the shield arc's centre in degrees, or None.
2162
2163        Screen angles run clockwise from +X because screen Y points down, and
2164        the flight plane maps straight onto the screen axes, so a world heading
2165        is its own screen angle negated.
2166        """
2167        shield = getattr(self._ship, "shield", None)
2168        if shield is None or not self.is_element_visible(HudElements.SHIELD):
2169            return None
2170        return -math.degrees(float(shield.absolute_centre()))
2171
2172    def set_visible_elements(self, elements) -> None:
2173        """Restrict the HUD to *elements* (ids from :class:`HudElements`).
2174
2175        Onboarding calls this once per run; passing :data:`ALL_HUD_ELEMENTS`
2176        restores the full display.
2177        """
2178        unknown = set(elements) - ALL_HUD_ELEMENTS
2179        if unknown:
2180            raise ValueError(f"unknown HUD element ids: {sorted(unknown)}")
2181        self._visible_elements = frozenset(elements)
2182
2183    def is_element_visible(self, element: str) -> bool:
2184        """Whether *element* is currently drawn."""
2185        return element in self._visible_elements
2186
2187    @property
2188    def visible_elements(self) -> frozenset[str]:
2189        """The element ids this HUD is currently drawing."""
2190        return self._visible_elements
2191
2192    def edge_desaturation(self) -> dict[str, float]:
2193        """Per-edge desaturation weights in 0..1, for the post-process hook.
2194
2195        Keys are ``"left"``, ``"right"``, ``"top"`` and ``"bottom"``. A hit
2196        raises the edge nearest its bearing and the value decays over
2197        :data:`DAMAGE_EDGE_DECAY_S`.
2198        """
2199        return dict(self._edge_damage)
2200
2201    # Read-only state, for tests and for anything that mirrors the HUD.
2202
2203    @property
2204    def hull_fraction(self) -> float:
2205        return self._gauges[HudElements.HULL]
2206
2207    @property
2208    def hull_chip(self) -> float:
2209        """Where the draining damage chunk still reaches on the hull arc.
2210
2211        Above :attr:`hull_fraction` while a just-lost chunk is animating off
2212        the ring, equal to it once the arc has settled.
2213        """
2214        return max(self._hull_chip, self._gauges[HudElements.HULL])
2215
2216    @property
2217    def capacitor_fraction(self) -> float:
2218        return self._gauges[HudElements.CAPACITOR]
2219
2220    @property
2221    def capacitor_blacked_out(self) -> bool:
2222        """True while a lantern blackout is holding the power bus at zero.
2223
2224        Read off the power system's own state, so the PWR arc greys for the
2225        held second rather than drawing a live gauge that happens to be empty.
2226        """
2227        power = self.tree.singletons.get(Services.POWER) if self.tree is not None else None
2228        return bool(getattr(power, "blacked_out", False))
2229
2230    @property
2231    def o2_fraction(self) -> float:
2232        return self._gauges[HudElements.O2]
2233
2234    @property
2235    def fuel_fraction(self) -> float:
2236        return self._gauges[HudElements.FUEL]
2237
2238    @property
2239    def signature_fraction(self) -> float:
2240        """Signature as a fraction of :data:`balance.SIGNATURE_MAX`."""
2241        return _fraction(self._signature, balance.SIGNATURE_MAX)
2242
2243    @property
2244    def signature_value(self) -> float:
2245        """The meter's own reading, in the units the gauge writes."""
2246        return self._signature
2247
2248    @property
2249    def signature_locked(self) -> bool:
2250        """Whether an arrival the meter bought is still inbound."""
2251        return self._signature_locked
2252
2253    @property
2254    def hunter_countdown(self) -> float:
2255        """Seconds until the tear-in, or 0 when nothing is inbound."""
2256        approach = self._hunter_approach()
2257        return approach[0] if approach is not None else 0.0
2258
2259    def _hunter_approach(self) -> tuple[float, float] | None:
2260        """The inbound hunter's ``(seconds, bearing)``, or None when none is.
2261
2262        The hunter does not exist until the meter locks and is freed when it
2263        leaves, so this refreshes the reference from the hunter group rather
2264        than holding one. It is only ever called while the lock is up.
2265        """
2266        hunter = self._hunter
2267        approach = getattr(hunter, "approach", None) if hunter is not None else None
2268        if approach is not None:
2269            return approach
2270        tree = self.tree
2271        if tree is None:
2272            return None
2273        for node in tree.group(Groups.HUNTER):
2274            approach = getattr(node, "approach", None)
2275            if approach is not None:
2276                self._hunter = node
2277                return approach
2278        self._hunter = None
2279        return None
2280
2281    @property
2282    def warp_spooling(self) -> bool:
2283        """Whether the ring is up, whether or not it has closed any distance."""
2284        return self._warp_active
2285
2286    @property
2287    def warp_spool_fraction(self) -> float:
2288        """How far the spool ring has closed, 0 when no spool is running."""
2289        if not self._warp_active:
2290            return 0.0
2291        return _fraction(self._warp_elapsed, self._warp_total)
2292
2293    @property
2294    def warp_stutter(self) -> float:
2295        """Decaying 0..1 stutter weight, raised by every spool interruption."""
2296        return self._warp_stutter
2297
2298    @property
2299    def subtitles(self) -> tuple[str, ...]:
2300        """The captions currently on screen, oldest first."""
2301        return tuple(entry[0] for entry in self._subtitles)
2302
2303    @property
2304    def stamp_text(self) -> str:
2305        """The stamp currently on screen, empty when none is."""
2306        return self._stamp_text if self._stamp_remaining > 0.0 else ""
2307
2308    @property
2309    def interact_fraction(self) -> float:
2310        """The interact hold fill, 0 when nothing is being held."""
2311        return self._interact_fraction
2312
2313    # ------------------------------------------------------------ per-frame
2314
2315    def on_update(self, dt: float):
2316        self._elapsed += dt
2317        self._tick_frame_stats(dt)
2318
2319        for entry in self._subtitles:
2320            entry[1] -= dt
2321        self._subtitles = [entry for entry in self._subtitles if entry[1] > 0.0]
2322
2323        for entry in self._floats:
2324            entry[1] -= dt
2325        self._floats = [entry for entry in self._floats if entry[1] > 0.0]
2326
2327        if self._toast_note_join > 0.0:
2328            self._toast_note_join = max(0.0, self._toast_note_join - dt)
2329        if self._toast_remaining > 0.0:
2330            self._toast_remaining = max(0.0, self._toast_remaining - dt)
2331            if self._toast_remaining <= 0.0:
2332                self._toast_note = ""
2333
2334        if self._stamp_remaining > 0.0:
2335            self._stamp_remaining = max(0.0, self._stamp_remaining - dt)
2336
2337        if self._arrival_remaining > 0.0:
2338            self._arrival_remaining = max(0.0, self._arrival_remaining - dt)
2339
2340        self._tick_low_hull(dt)
2341
2342        if self._interact_remaining > 0.0:
2343            self._interact_remaining -= dt
2344            if self._interact_remaining <= 0.0:
2345                self._interact_fraction = 0.0
2346
2347        if self._affordance_remaining > 0.0:
2348            self._affordance_remaining -= dt
2349            if self._affordance_remaining <= 0.0:
2350                self._affordance_text = ""
2351
2352        if self._boundary_remaining > 0.0:
2353            self._boundary_remaining -= dt
2354            if self._boundary_remaining <= 0.0:
2355                self._boundary_notice = ""
2356
2357        if self._compass_remaining > 0.0:
2358            self._compass_remaining -= dt
2359            if self._compass_remaining <= 0.0:
2360                self._compass = ()
2361
2362        if self._warp_outcome_remaining > 0.0:
2363            self._warp_outcome_remaining = max(0.0, self._warp_outcome_remaining - dt)
2364            if self._warp_outcome_remaining <= 0.0:
2365                self._warp_outcome_text = ""
2366
2367        if self._warp_jump_flash > 0.0:
2368            self._warp_jump_flash = max(0.0, self._warp_jump_flash - dt)
2369
2370        if self._warp_active:
2371            self._warp_elapsed += dt
2372        if self._warp_stutter > 0.0:
2373            self._warp_stutter = max(0.0, self._warp_stutter - dt / WARP_STUTTER_S)
2374        if self._warp_interrupt_remaining > 0.0:
2375            self._warp_interrupt_remaining = max(0.0, self._warp_interrupt_remaining - dt)
2376
2377        if any(self._edge_damage.values()):
2378            decay = dt / DAMAGE_EDGE_DECAY_S
2379            for edge in _EDGES:
2380                self._edge_damage[edge] = max(0.0, self._edge_damage[edge] - decay)
2381
2382        if self._hull_chip > self._gauges[HudElements.HULL]:
2383            if self._hull_chip_hold > 0.0:
2384                self._hull_chip_hold = max(0.0, self._hull_chip_hold - dt)
2385            else:
2386                rate = self._hull_chip_rate or (1.0 / HULL_CHIP_DECAY_S)
2387                self._hull_chip = max(self._gauges[HudElements.HULL], self._hull_chip - rate * dt)
2388
2389        if self._connected_names != _WANTED_SIGNALS:
2390            self._rescan_in -= dt
2391            if self._rescan_in <= 0.0:
2392                self._connect_sources()
2393
2394        # A refit changes what is aboard without firing a shot, so the
2395        # magazines are re-read on a slow poll rather than only on the signal.
2396        self._ammo_poll_in -= dt
2397        if self._ammo_poll_in <= 0.0:
2398            self._ammo_poll_in = AMMO_POLL_S
2399            self._poll_ammo()
2400
2401    def _tick_low_hull(self, dt: float) -> None:
2402        """Keep the klaxon coming back while the hull is in the red.
2403
2404        A single caption at the moment the hull crossed the line is a warning
2405        the player can miss under fire, so the low-hull state re-raises it
2406        every :data:`LOW_HULL_KLAXON_S` for as long as it holds. Climbing back
2407        out of the red arms it again, which is the only way a repair reads.
2408        """
2409        low = self.low_hull
2410        if not low:
2411            self._low_hull = False
2412            self._low_hull_klaxon_in = 0.0
2413            return
2414        if not self._low_hull:
2415            self._low_hull = True
2416            self._low_hull_klaxon_in = LOW_HULL_KLAXON_S
2417            self.show_subtitle("klaxon")
2418            return
2419        self._low_hull_klaxon_in -= dt
2420        if self._low_hull_klaxon_in <= 0.0:
2421            self._low_hull_klaxon_in = LOW_HULL_KLAXON_S
2422            self.show_subtitle("klaxon")
2423
2424    # ------------------------------------------------------------- discovery
2425
2426    def _scan_roots(self) -> list[Node]:
2427        tree = self.tree
2428        if tree is None:
2429            return []
2430        roots: list[Node] = [node for node in tree.singletons.values() if node is not self]
2431        for group in (Groups.SHIP, Groups.HUNTER, Groups.DEPOTS):
2432            roots.extend(tree.group(group))
2433        return roots
2434
2435    def _connect_sources(self) -> None:
2436        """Connect to every wanted signal that now has an emitter.
2437
2438        Modules mount in whatever order the run scene builds them, and the
2439        hunter does not exist until the signature locks, so this runs again
2440        every :data:`SOURCE_RESCAN_S` until nothing is missing.
2441        """
2442        self._rescan_in = SOURCE_RESCAN_S
2443        seen: set[int] = set()
2444        for root in self._scan_roots():
2445            self._connect_subtree(root, seen)
2446        ships = self.tree.group(Groups.SHIP) if self.tree is not None else []
2447        self._ship = ships[0] if ships else None
2448        if self._rig is None and self.tree is not None and self.tree.root is not None:
2449            self._rig = self.tree.root.find(CameraRig)
2450        self._seed_readings()
2451
2452    def _seed_readings(self) -> None:
2453        """Read the tanks and the hold once, rather than waiting for a change.
2454
2455        Every gauge here is fed by a change signal, and a full tank that has
2456        not changed yet emits nothing: the strip opened every run reading
2457        ``FUEL 0`` beside a tank with a hundred in it until the first burn.
2458        """
2459        singletons = self.tree.singletons if self.tree is not None else {}
2460        power = singletons.get(Services.POWER)
2461        for current, maximum, handler in (
2462            ("o2", "o2_max", self._on_o2_changed),
2463            ("fuel", "fuel_max", self._on_fuel_changed),
2464            ("capacitor", "capacitor_max", self._on_capacitor_changed),
2465        ):
2466            value = getattr(power, current, None)
2467            ceiling = getattr(power, maximum, None)
2468            if value is not None and ceiling is not None:
2469                handler(float(value), float(ceiling))
2470        hull = getattr(self._ship, "hull", None)
2471        hull_max = getattr(self._ship, "hull_max", None)
2472        if hull is not None and hull_max is not None:
2473            self._on_hull_changed(float(hull), float(hull_max))
2474        scrap = getattr(singletons.get(Services.ECONOMY), "scrap", None)
2475        if scrap is not None:
2476            self._on_scrap_changed(float(scrap))
2477        self._poll_ammo()
2478        for node in self._emitters_of(self._ship, SignalNames.SOLAR_STATE_CHANGED):
2479            self._on_solar_state_changed(getattr(node, "state", ""))
2480        for node in self._emitters_of(self._ship, SignalNames.GENERATOR_CHANGED):
2481            self._on_generator_changed(getattr(node, "running", False))
2482
2483    def _poll_ammo(self) -> None:
2484        """Read the hull's magazines and its stowed boxes into the strip.
2485
2486        ``AMMO_CHANGED`` fires when a magazine moves, which never happens for a
2487        gun that has been bought and not yet fired, so a refit would otherwise
2488        leave the readout empty beside a loaded weapon. The magazines are read
2489        off the hardpoints instead and the signal keeps the number live between
2490        polls. A hull with no ballistic weapon and no box reads nothing at all:
2491        the cell exists only once there is ammunition to spend.
2492        """
2493        rounds: dict[str, int] = {}
2494        for node in self._magazines(self._ship):
2495            rounds[str(node.weapon_id)] = int(node.rounds)
2496        racks = [
2497            rack for rack in self._emitters_of(self._ship, SignalNames.AMMO_CHANGED) if hasattr(rack, "ammo_boxes")
2498        ]
2499        for rack in racks:
2500            for weapon_id in list(rounds):
2501                rounds[weapon_id] += int(rack.ammo_boxes(weapon_id)) * int(balance.AMMO_BOX_ROUNDS)
2502        # A rack that can be read is the authority, empty answer included: that
2503        # is how a weapon sold at a depot takes its count off the strip.
2504        if racks or rounds:
2505            self._ammo = rounds
2506
2507    @staticmethod
2508    def _magazines(root):
2509        """Every ballistic weapon under *root*: a node with rounds and an id."""
2510        if root is None:
2511            return
2512        stack = [root]
2513        while stack:
2514            node = stack.pop()
2515            if isinstance(getattr(node, "rounds", None), int) and getattr(node, "weapon_id", None):
2516                yield node
2517            stack.extend(node.children)
2518
2519    @staticmethod
2520    def _emitters_of(root, signal_name: str):
2521        """Every node under *root* that emits *signal_name*, root included."""
2522        if root is None:
2523            return
2524        stack = [root]
2525        while stack:
2526            node = stack.pop()
2527            if isinstance(getattr(node, signal_name, None), Signal):
2528                yield node
2529            stack.extend(node.children)
2530
2531    def _connect_subtree(self, node: Node, seen: set[int]) -> None:
2532        if id(node) in seen:
2533            return
2534        seen.add(id(node))
2535        done = self._connected.get(node)
2536        if done is None:
2537            done = set()
2538            self._connected[node] = done
2539        for name, method in _SIGNAL_HANDLERS.items():
2540            if name in done:
2541                continue
2542            signal = getattr(node, name, None)
2543            if isinstance(signal, Signal):
2544                signal.connect(getattr(self, method))
2545                done.add(name)
2546                self._connected_names.add(name)
2547        for child in node.children:
2548            self._connect_subtree(child, seen)
2549
2550    # -------------------------------------------------------------- handlers
2551
2552    def _on_hull_changed(self, current: float, maximum: float) -> None:
2553        fraction = _fraction(current, maximum)
2554        previous = self._gauges[HudElements.HULL]
2555        if fraction < previous:
2556            # A blow: hold the chunk that was just lost lit, then drain it over
2557            # a fixed time. Re-arming the hold on a second blow is deliberate:
2558            # two hits in quick succession are one wound to look at.
2559            self._hull_chip = max(self._hull_chip, previous)
2560            self._hull_chip_hold = HULL_CHIP_HOLD_S
2561            self._hull_chip_rate = max(self._hull_chip - fraction, 1e-6) / HULL_CHIP_DECAY_S
2562        elif fraction > previous:
2563            # A repair (or the seed): a rising reading has no chunk to mourn.
2564            self._hull_chip = fraction
2565            self._hull_chip_hold = 0.0
2566            self._hull_chip_rate = 0.0
2567        # An unchanged reading is a re-announcement, not a repair, and it must
2568        # leave the chunk alone. The ship re-states its hull a few frames after
2569        # a blow (the breach bookkeeping does it), and clearing on "not lower"
2570        # deleted the chunk three frames into an animation authored to last
2571        # most of a second. On the one hit that matters, the Shrike's lantern,
2572        # the ring therefore snapped to its new length while the screen was
2573        # still white, and the arc read as never having moved at all.
2574        self._gauges[HudElements.HULL] = fraction
2575        self._levels[HudElements.HULL] = float(current)
2576
2577    def _on_breach_opened(self, open_breaches: int) -> None:
2578        self._open_breaches = int(open_breaches)
2579        self.show_subtitle("breach")
2580
2581    def _on_breach_patched(self, open_breaches: int) -> None:
2582        self._open_breaches = int(open_breaches)
2583
2584    def _on_capacitor_changed(self, current: float, maximum: float) -> None:
2585        self._gauges[HudElements.CAPACITOR] = _fraction(current, maximum)
2586        self._levels[HudElements.CAPACITOR] = float(current)
2587
2588    def _on_energy_denied(self, consumer: str) -> None:
2589        del consumer
2590        self.show_subtitle("energy_denied")
2591
2592    def _on_o2_changed(self, current: float, maximum: float) -> None:
2593        self._gauges[HudElements.O2] = _fraction(current, maximum)
2594        self._levels[HudElements.O2] = float(current)
2595        drain = balance.O2_DRAIN_PER_S + balance.O2_DRAIN_PER_BREACH_PER_S * self._open_breaches
2596        self._o2_seconds = float(current) / drain if drain > 0.0 else float("inf")
2597        if self.low_o2:
2598            self.show_subtitle("breathing")
2599
2600    def _on_fuel_changed(self, current: float, maximum: float) -> None:
2601        self._gauges[HudElements.FUEL] = _fraction(current, maximum)
2602        self._levels[HudElements.FUEL] = float(current)
2603        fare = float(balance.WARP_FUEL_BASE)
2604        self._fuel_jumps = float(current) / fare if fare > 0.0 else float("inf")
2605
2606    def _on_generator_changed(self, running: bool) -> None:
2607        self._generator_running = bool(running)
2608        if running:
2609            self.show_subtitle("generator_hum")
2610
2611    def _on_solar_state_changed(self, state: str) -> None:
2612        self._wings_state = str(state)
2613
2614    def _on_afterburner_changed(self, active: bool) -> None:
2615        self._afterburner = bool(active)
2616
2617    def _on_silent_running_changed(self, active: bool) -> None:
2618        self._silent_running = bool(active)
2619        if active:
2620            self.show_subtitle("silent_running")
2621
2622    def _on_signature_changed(self, value: float) -> None:
2623        self._signature = float(value)
2624
2625    def _on_signature_locked(self) -> None:
2626        # The stamp is the headline and the objective band carries the answer.
2627        # A toast as well would be a third copy of one sentence on one screen.
2628        self._signature_locked = True
2629        self.flash_stamp(SIGNATURE_LOCKED_STAMP)
2630        self.show_subtitle("klaxon")
2631
2632    def _on_ammo_changed(self, weapon_id: str, rounds: int) -> None:
2633        self._ammo[str(weapon_id)] = int(rounds)
2634
2635    def _on_scrap_changed(self, total: float) -> None:
2636        self._levels[HudElements.SCRAP] = float(total)
2637
2638    def _on_notoriety_changed(self, value: int, reason: str) -> None:
2639        del reason
2640        self._notoriety = int(value)
2641
2642    def _on_player_damaged(self, amount: float, direction: Vec3) -> None:
2643        edge = _EDGES[self._edge_index(direction)]
2644        weight = max(DAMAGE_EDGE_MIN_WEIGHT, _clamp01(float(amount) / DAMAGE_EDGE_FULL_HIT))
2645        self._edge_damage[edge] = max(self._edge_damage[edge], weight)
2646
2647    def _on_audio_cue(self, cue: str, caption: str) -> None:
2648        """Caption a cue straight off the audio director's own announcement.
2649
2650        Every played cue lands here whoever played it, so a system that talks
2651        to the director directly (the hunter's burn klaxon) reaches the
2652        caption track without also having to know the HUD. The wording is
2653        audio's, side suffix included; :meth:`_push_subtitle` folds it with
2654        any caption the HUD raised for the same sound from a signal.
2655        """
2656        del cue
2657        self._push_subtitle(str(caption))
2658
2659    def _on_enemy_killed(self, archetype: str, position: Vec3, elite: bool) -> None:
2660        del archetype, position, elite
2661        self.show_subtitle("kill_chime")
2662
2663    def _on_wave_spawned(self, count: int) -> None:
2664        del count
2665        self.show_subtitle("spawn_chevron")
2666
2667    def _on_hunter_telegraph(self, stage: str) -> None:
2668        if stage == "t60":
2669            self.show_subtitle("groan")
2670        elif stage == "t30":
2671            self.show_subtitle("klaxon")
2672        elif stage == "t0":
2673            # The countdown has run out and the beast is on screen: the
2674            # instruction it was carrying has been overtaken by events.
2675            self._signature_locked = False
2676            self.flash_stamp("ARRIVAL")
2677
2678    def _on_warp_spool_started(self, emergency: bool) -> None:
2679        del emergency
2680        self._warp_active = True
2681        self._warp_elapsed = 0.0
2682        self._warp_total = balance.WARP_SPOOL_S
2683        self._warp_stutter = 0.0
2684        self._warp_target = ""
2685        self._warp_fuel = 0.0
2686        self._warp_price_text = ""
2687        self._warp_interrupt_remaining = 0.0
2688
2689    def _on_warp_spool_interrupted(self, added_seconds: float) -> None:
2690        added = float(added_seconds)
2691        self._warp_total += added
2692        self._warp_stutter = 1.0
2693        # Named cost and named cause: "INTERRUPTED" alone taught nothing, and
2694        # the one thing the pilot needs is that being shot is what lengthens it.
2695        self._warp_interrupt_text = f"{WARP_INTERRUPT_PREFIX} +{added:.1f} s ({WARP_INTERRUPT_CAUSE})"
2696        self._warp_interrupt_remaining = WARP_INTERRUPT_FLASH_S
2697        self.show_subtitle("spool_stutter")
2698
2699    def _on_warp_spool_cancelled(self) -> None:
2700        self._flash_warp_outcome(WARP_ABORTED_LABEL)
2701        self.end_warp_spool()
2702
2703    def _on_warp_completed(self, emergency: bool) -> None:
2704        del emergency
2705        self._warp_jump_flash = WARP_JUMP_FLASH_S
2706        # Leaving is one of the two answers the lock's instruction offers, so
2707        # arriving somewhere else is the moment it stops being true.
2708        self._signature_locked = False
2709        self._hunter = None
2710        self.end_warp_spool()
2711
2712    # ------------------------------------------------------------- geometry
2713
2714    @staticmethod
2715    def _edge_index(direction: Vec3) -> int:
2716        """Screen edge nearest *direction*, as an index into :data:`_EDGES`.
2717
2718        *direction* is the bearing from the ship toward the threat, the same
2719        convention ``ShieldArc.covers`` takes. On screen +X is right and -Z is
2720        up, so the plane components map straight onto screen axes.
2721        """
2722        dx = float(direction.x)
2723        dy = float(direction.z)  # world +Z is screen down
2724        if abs(dx) >= abs(dy):
2725            return 1 if dx > 0.0 else 0  # right / left
2726        return 2 if dy < 0.0 else 3  # top / bottom
2727
2728    def _project(self, world: Vec3, rect: tuple[float, float, float, float]) -> tuple[float, float] | None:
2729        """Where *world* lands on screen, or None when it is behind the camera."""
2730        x, y, w, h = rect
2731        rig = self._rig
2732        if rig is None or rig.camera is None or h <= 0.0:
2733            return None
2734        camera = rig.camera
2735        vp = camera.projection_matrix(w / h) @ camera.view_matrix
2736        clip = vp @ np.array([float(world.x), float(world.y), float(world.z), 1.0], dtype=np.float64)
2737        if clip[3] <= 1e-6:
2738            return None
2739        return (x + (clip[0] / clip[3] * 0.5 + 0.5) * w, y + (clip[1] / clip[3] * 0.5 + 0.5) * h)
2740
2741    def _ship_screen_position(self, rect: tuple[float, float, float, float]) -> tuple[float, float]:
2742        """Where the ship sits on screen, falling back to the rect centre."""
2743        x, y, w, h = rect
2744        centre = (x + w * 0.5, y + h * 0.5)
2745        ship = self._ship
2746        if ship is None:
2747            return centre
2748        return self._project(Vec3(ship.world_position), rect) or centre
2749
2750    # ----------------------------------------------------------------- draw
2751
2752    def on_draw(self, renderer):
2753        rect = self.get_global_rect()
2754        cx, cy = self._ship_screen_position(rect)
2755        scale = self.effective_scale
2756        self._marker_rows = []
2757
2758        self._draw_signature_border(renderer, rect)
2759        self._draw_damage_edges(renderer, rect, scale)
2760        self._draw_low_hull_pulse(renderer, rect, scale)
2761        self._draw_reading_plates(renderer, cx, cy, scale)
2762        self._draw_gauge_arcs(renderer, cx, cy, scale)
2763        self._draw_shield_arc(renderer, cx, cy, scale)
2764        self._draw_resource_strip(renderer, cx, cy, scale)
2765        self._draw_signature_gauge(renderer, cx, cy, scale)
2766        self._draw_warp_ring(renderer, rect, cx, cy, scale)
2767        self._draw_warp_outcome(renderer, rect, cx, cy, scale)
2768        self._draw_warp_status(renderer, cx, cy, scale)
2769        self._draw_interact_fill(renderer, cx, cy, scale)
2770        self._draw_affordance(renderer, cx, cy, scale)
2771        self._draw_hunter_marker(renderer, rect, scale)
2772        self._draw_beacon(renderer, rect, scale)
2773        self._draw_compass(renderer, rect, scale)
2774        self._draw_float_texts(renderer, rect, scale)
2775        self._draw_warnings(renderer, rect, cx, cy, scale)
2776        self._draw_controls_bar(renderer, rect, scale)
2777        self._draw_readouts(renderer, rect, scale)
2778        self._draw_sector_name(renderer, rect, scale)
2779        self._draw_control_card(renderer, rect, scale)
2780        self._draw_arrival_card(renderer, rect, scale)
2781        self._draw_toast(renderer, rect, scale)
2782        self._draw_subtitles(renderer, rect, scale)
2783        self._draw_stamp(renderer, rect, scale)
2784        self._draw_jump_flash(renderer, rect)
2785        self._draw_frame_stats(renderer, rect, scale)
2786
2787    def _draw_signature_border(self, renderer, rect) -> None:
2788        if not self.is_element_visible(HudElements.SIGNATURE):
2789            return
2790        x, y, w, h = rect
2791        heat = self.signature_fraction
2792        colour = _lerp_colour(COLOUR_SIGNATURE_COLD, COLOUR_SIGNATURE_HOT, heat)
2793        thickness = SIGNATURE_BORDER_MIN_PX + (SIGNATURE_BORDER_MAX_PX - SIGNATURE_BORDER_MIN_PX) * heat
2794        renderer.draw_rect((x, y), (w, thickness), colour=colour, filled=True)
2795        renderer.draw_rect((x, y + h - thickness), (w, thickness), colour=colour, filled=True)
2796        renderer.draw_rect((x, y), (thickness, h), colour=colour, filled=True)
2797        renderer.draw_rect((x + w - thickness, y), (thickness, h), colour=colour, filled=True)
2798
2799    def _draw_signature_gauge(self, renderer, cx: float, cy: float, scale: float) -> None:
2800        """The signature meter as a gauge you can read, over the ship's nose.
2801
2802        The border is the same reading felt rather than read, and the two are
2803        driven by the one fraction so they can never disagree. This one carries
2804        the number, because "how close am I" is a question with an answer and
2805        the border was only ever able to say "closer than before".
2806        """
2807        if not self.is_element_visible(HudElements.SIGNATURE):
2808            return
2809        heat = self.signature_fraction
2810        colour = _lerp_colour(COLOUR_SIGNATURE_COLD, COLOUR_SIGNATURE_HOT, heat)
2811        radius = (ARC_RADIUS_PX + SIGNATURE_ARC_GAP_PX) * scale
2812        thickness = SIGNATURE_ARC_THICKNESS_PX * scale
2813        start = 270.0 - SIGNATURE_ARC_SPAN_DEGREES * 0.5
2814        _draw_arc(renderer, cx, cy, radius, start, SIGNATURE_ARC_SPAN_DEGREES, thickness, COLOUR_TRACK)
2815        filled = SIGNATURE_ARC_SPAN_DEGREES * heat
2816        if filled > 0.0:
2817            _draw_arc(renderer, cx, cy, radius, start, filled, thickness, _opaque(colour))
2818        line = SIGNATURE_LABEL_LINE_PX * scale
2819        renderer.draw_text(
2820            SIGNATURE_LABEL.format(value=self._signature, maximum=balance.SIGNATURE_MAX),
2821            rect=(cx - 160.0 * scale, cy - radius - SIGNATURE_LABEL_GAP_PX * scale - line, 320.0 * scale, line),
2822            colour=_opaque(_lerp_colour(COLOUR_READOUT, COLOUR_SIGNATURE_HOT, heat)),
2823            scale=SIGNATURE_LABEL_FONT_SCALE * scale,
2824            alignment="centre",
2825            vertical_alignment="centre",
2826        )
2827
2828    def _draw_hunter_marker(self, renderer, rect, scale: float) -> None:
2829        """Point at where the Shrike is coming from, and count it down.
2830
2831        Between the lock and the tear-in the ladder had exactly one visible
2832        channel, a border that was already red, and a pilot who spent the whole
2833        warning not knowing there was one. The marker rides the approach
2834        bearing and the label carries the seconds, so the warning is a bearing
2835        and a clock rather than a mood.
2836        """
2837        if not self._signature_locked or not self.is_element_visible(HudElements.SIGNATURE):
2838            return
2839        approach = self._hunter_approach()
2840        if approach is None:
2841            return
2842        seconds, bearing = approach
2843        ship = self._ship
2844        if ship is None:
2845            return
2846        direction = heading_to_direction(bearing)
2847        world = Vec3(ship.world_position) + Vec3(direction) * HUNTER_MARKER_RANGE_UNITS
2848        px, py = self._marker_point(world, rect, HUNTER_MARKER_EDGE_MARGIN_PX * scale, scale)
2849        renderer.draw_circle((px, py), HUNTER_MARKER_RADIUS_PX * scale, colour=COLOUR_HUNTER_MARKER, segments=3)
2850        half = HUNTER_LABEL_HALF_WIDTH_PX * scale
2851        wanted = py + MARKER_LABEL_GAP_PX * scale
2852        # Required: the countdown is the one marker that never stands down. A
2853        # pilot who cannot see the clock cannot spend it.
2854        row = self._marker_label_y(wanted, px, half, self._marker_rows, rect, scale, required=True)
2855        renderer.draw_text(
2856            HUNTER_COUNTDOWN.format(seconds=max(0.0, seconds)),
2857            rect=(
2858                self._marker_label_left(px, half, rect, scale),
2859                row,
2860                2.0 * half,
2861                WARNING_LINE_HEIGHT_PX * scale,
2862            ),
2863            colour=COLOUR_HUNTER_MARKER,
2864            scale=HUNTER_MARKER_FONT_SCALE * scale,
2865            alignment="centre",
2866            vertical_alignment="centre",
2867        )
2868
2869    def _draw_damage_edges(self, renderer, rect, scale: float) -> None:
2870        if not self.is_element_visible(HudElements.DAMAGE_DIRECTION):
2871            return
2872        x, y, w, h = rect
2873        band = DAMAGE_EDGE_BAND_PX * scale
2874        for edge, weight in self._edge_damage.items():
2875            if weight <= 0.0:
2876                continue
2877            colour = _with_alpha(COLOUR_DESATURATION, weight)
2878            if edge == "left":
2879                renderer.draw_rect((x, y), (band, h), colour=colour, filled=True)
2880            elif edge == "right":
2881                renderer.draw_rect((x + w - band, y), (band, h), colour=colour, filled=True)
2882            elif edge == "top":
2883                renderer.draw_rect((x, y), (w, band), colour=colour, filled=True)
2884            else:
2885                renderer.draw_rect((x, y + h - band), (w, band), colour=colour, filled=True)
2886
2887    def _draw_low_hull_pulse(self, renderer, rect, scale: float) -> None:
2888        """Pulse the whole border while the hull is under the low mark.
2889
2890        The signature border is a different animal: it warms slowly and never
2891        blinks. This one breathes, on all four edges at once, which is the one
2892        thing on the screen that means the next hit may be the last.
2893
2894        It is also drawn :data:`LOW_HULL_INSET_PX` inboard rather than off the
2895        screen edge, so the two rings are a pair rather than a smear. The
2896        outermost pixels belong to the signature meter alone, which is what
2897        lets a jump be seen: arriving collapses that band to a cold hairline,
2898        and under a low hull the change used to happen underneath a wider,
2899        brighter, almost identically red ring drawn on top of it.
2900        """
2901        if not self.low_hull or not self.is_element_visible(HudElements.WARNINGS):
2902            return
2903        x, y, w, h = rect
2904        inset = LOW_HULL_INSET_PX
2905        x, y, w, h = x + inset, y + inset, w - inset * 2.0, h - inset * 2.0
2906        band = min(LOW_HULL_BAND_PX * scale, w * 0.5, h * 0.5)
2907        if band <= 0.0:
2908            return
2909        wave = 0.5 + 0.5 * math.sin(self._elapsed * LOW_HULL_PULSE_HZ * math.tau)
2910        alpha = LOW_HULL_PULSE_MIN_ALPHA + (LOW_HULL_PULSE_MAX_ALPHA - LOW_HULL_PULSE_MIN_ALPHA) * wave
2911        colour = _with_alpha(COLOUR_LOW_HULL_PULSE, alpha)
2912        renderer.draw_rect((x, y), (w, band), colour=colour, filled=True)
2913        renderer.draw_rect((x, y + h - band), (w, band), colour=colour, filled=True)
2914        renderer.draw_rect((x, y), (band, h), colour=colour, filled=True)
2915        renderer.draw_rect((x + w - band, y), (band, h), colour=colour, filled=True)
2916
2917    def _draw_band(self, renderer, cx: float, cy: float, scale: float, offset: float, lines, font: float) -> None:
2918        """Write *lines* into the reserved band *offset* pixels under the ship.
2919
2920        Every line under the hull goes through here, which is what keeps the
2921        bands from drifting into each other: a caller picks its band and the
2922        band picks the pixels. *lines* is a sequence of ``(text, colour)``.
2923        """
2924        line_height = WARNING_LINE_HEIGHT_PX * scale
2925        top = cy + offset * scale
2926        width = BAND_HALF_WIDTH_PX * 2.0 * scale
2927        for index, (text, colour) in enumerate(lines):
2928            renderer.draw_text(
2929                text,
2930                rect=(cx - BAND_HALF_WIDTH_PX * scale, top + index * line_height, width, line_height),
2931                colour=colour,
2932                scale=font * scale,
2933                alignment="centre",
2934                vertical_alignment="centre",
2935            )
2936
2937    def _warning_lines(self) -> list[tuple[str, tuple[float, float, float, float]]]:
2938        """The survival lines the warning band is carrying, at most its height.
2939
2940        Ordered by what kills first, because the band is shorter than the
2941        worst case and the tail is what gets dropped: a breached hull, then
2942        the air, then the tank. An empty tank strands a pilot who is otherwise
2943        fine, so it yields to both of the clocks that do not.
2944        """
2945        if not self.is_element_visible(HudElements.WARNINGS):
2946            return []
2947        lines: list[tuple[str, tuple[float, float, float, float]]] = []
2948        if self.low_hull:
2949            lines.append((LOW_HULL_WARNING, COLOUR_WARNING))
2950        if self.low_o2:
2951            lines.append((f"OXYGEN {max(0.0, self._o2_seconds):.0f}s", COLOUR_WARNING))
2952            lines.append((LOW_O2_HINT, COLOUR_HINT))
2953        if self.low_fuel and LOW_FUEL_SOURCE not in self._warp_status.lower():
2954            # The drive's own refusal already names the shop once the tank
2955            # will not cover a jump, and the two lines land a hand's width
2956            # apart. The refusal is the better of the two, because it quotes
2957            # the shortfall as well, so the hint is the one that stands down.
2958            lines.append((LOW_FUEL_HINT, COLOUR_HINT))
2959        return lines[:BAND_WARNING_LINES]
2960
2961    def caption_block(self, rect, scale: float) -> tuple[float, float] | None:
2962        """Top and bottom of the caption track's plate, or ``None`` when it is empty.
2963
2964        The track grows upward from a fixed base as sounds stack, so how far up
2965        the screen it reaches is a thing that changes several times a second.
2966        The standing lines under the hull are pinned to the ship, and the two
2967        meet at whatever height the camera is holding: a rendered arrival put
2968        ``IT TEARS IN`` straight through ``OBJECTIVE: MINE THE GLOWING VEINS``
2969        and left neither readable.
2970        """
2971        if not self._subtitles or not self.is_element_visible(HudElements.SUBTITLES):
2972            return None
2973        _x, y, _w, h = rect
2974        line = SUBTITLE_LINE_HEIGHT_PX * scale
2975        padding = SUBTITLE_PLATE_PADDING_Y_PX * scale
2976        base = y + h - SUBTITLE_BOTTOM_MARGIN_PX * scale - line * len(self._subtitles)
2977        return (base - padding, base + line * len(self._subtitles) + padding)
2978
2979    def _band_is_clear(self, offset: float, lines: int, rect, scale: float) -> bool:
2980        """Whether a band *offset* under the hull is clear of the caption track."""
2981        block = self.caption_block(rect, scale)
2982        if block is None:
2983            return True
2984        top = self._band_top(offset, rect, scale)
2985        bottom = top + WARNING_LINE_HEIGHT_PX * scale * max(1, lines)
2986        return bottom <= block[0] or top >= block[1]
2987
2988    def _bottom_furniture_top(self, rect, scale: float) -> float:
2989        """The topmost pixel the permanent furniture along the bottom edge owns.
2990
2991        Today that is the controls strip and nothing else. It is the floor the
2992        ladder under the hull stops at: the strip is up for the whole run, so a
2993        standing line written through it is not a collision that clears, it is
2994        two sentences ruined for as long as the pilot flies that low.
2995        """
2996        _x, y, _w, h = rect
2997        return y + h - (CONTROLS_BAR_BAND_PX * scale if self.controls_bar else 0.0)
2998
2999    def _lower_band_lift(self, rect, scale: float) -> float:
3000        """Pixels the objective and boundary bands ride up, together, off the floor.
3001
3002        Everything above them is a reading of the ship and stays welded to the
3003        hull. These two are statements about the run and the sector, they are
3004        the bottom of the ladder, and they are the pair a diving camera walks
3005        into the controls strip: holding thrust puts the hull most of the way
3006        down the frame and the boundary notice off the bottom of it entirely.
3007
3008        The pair moves as one, by the whole overflow, so the gap between them
3009        never changes and neither appearing shifts the other. It rides up only
3010        over rows nothing is using (:meth:`_lower_band_ceiling`): a reserved
3011        row is free ground while its own line is silent and never while it is
3012        not, and a survival warning is not a thing that yields. When the lift
3013        that is available is not enough the line stands down for the frame, the
3014        way it already does for the caption track, because a sentence written
3015        half off the bottom edge is not a sentence anybody read.
3016        """
3017        _cx, cy = self._ship_screen_position(rect)
3018        overflow = cy + BAND_LADDER_PX * scale - self._bottom_furniture_top(rect, scale)
3019        if overflow <= 0.0:
3020            return 0.0
3021        spare = max(0.0, BAND_OBJECTIVE_PX - self._lower_band_ceiling())
3022        return min(overflow, spare * scale)
3023
3024    def _lower_band_ceiling(self) -> float:
3025        """The offset the objective band may not ride above, in unscaled pixels.
3026
3027        The bottom of the lowest band over the pair that is writing something
3028        this frame, so the lift takes empty rows and stops dead at a used one.
3029        The resource strip's own plate reaches to :data:`BAND_CONTEXT_PX`, and
3030        the strip is up for the whole run, so that is the hard ceiling.
3031        """
3032        warnings = len(self._warning_lines())
3033        if warnings:
3034            return BAND_WARNING_PX + warnings * WARNING_LINE_HEIGHT_PX
3035        if self._warp_chip_is_up():
3036            return BAND_CONTEXT_PX + BAND_CONTEXT_LINES * WARNING_LINE_HEIGHT_PX
3037        return BAND_CONTEXT_PX
3038
3039    def _warp_chip_is_up(self) -> bool:
3040        """Whether the drive's one-line answer is occupying the context band."""
3041        return bool(self._warp_status) and not self._warp_active and self.is_element_visible(HudElements.WARP_STATUS)
3042
3043    def _band_top(self, offset: float, rect, scale: float) -> float:
3044        """The top pixel of the band *offset* under the hull, lift included."""
3045        _cx, cy = self._ship_screen_position(rect)
3046        top = cy + offset * scale
3047        if offset >= BAND_OBJECTIVE_PX:
3048            top -= self._lower_band_lift(rect, scale)
3049        return top
3050
3051    def _band_clears_floor(self, offset: float, rect, scale: float) -> bool:
3052        """Whether one lifted line at *offset* still fits above the bottom furniture."""
3053        top = self._band_top(offset, rect, scale)
3054        return top + WARNING_LINE_HEIGHT_PX * scale <= self._bottom_furniture_top(rect, scale)
3055
3056    def _objective_line(self, rect, scale: float) -> str:
3057        """What the objective band is carrying: the lock's instruction, or the run's."""
3058        if self._warp_active or not self.is_element_visible(HudElements.WARNINGS):
3059            return ""
3060        if not self._band_is_clear(BAND_OBJECTIVE_PX, 1, rect, scale):
3061            return ""
3062        if not self._band_clears_floor(BAND_OBJECTIVE_PX, rect, scale):
3063            return ""
3064        return SIGNATURE_LOCKED_INSTRUCTION if self._signature_locked else self._objective
3065
3066    def _boundary_line(self, rect, scale: float) -> str:
3067        """What the boundary band is carrying, empty while the drive is spooling."""
3068        if self._warp_active or not self.is_element_visible(HudElements.WARNINGS):
3069            return ""
3070        if not self._band_is_clear(BAND_BOUNDARY_PX, 1, rect, scale):
3071            return ""
3072        if not self._band_clears_floor(BAND_BOUNDARY_PX, rect, scale):
3073            return ""
3074        return self.boundary_notice
3075
3076    def _draw_warnings(self, renderer, rect, cx: float, cy: float, scale: float) -> None:
3077        """The survival states and the standing cues, in their reserved bands.
3078
3079        Three bands rather than one stack: what the ship is in trouble about,
3080        what the run is asking of it, and where the sector stops. They used to
3081        share an offset with each other and with the warp chip, and a pilot who
3082        flew out of a sector while the meter was locked got all four sentences
3083        printed through one another.
3084
3085        While the drive is spooling the standing lines stand down: the ring's
3086        own label sweeps down through these bands as it closes, and it is the
3087        live version of what the objective is asking for anyway. They stand
3088        down for the caption track on the same principle: a sound the pilot is
3089        hearing right now is the live version of what is happening, the
3090        objective is a standing reminder that will still be true in two
3091        seconds, and the two written through each other are neither. The
3092        survival band never stands down for anything, because a hull warning
3093        buried under a caption is the death nobody saw coming.
3094
3095        The two lowest bands also ride up off the controls strip when a diving
3096        camera has put the hull low enough to walk them into it
3097        (:meth:`_lower_band_lift`).
3098        """
3099        if not self.is_element_visible(HudElements.WARNINGS):
3100            return
3101        self._draw_band(renderer, cx, cy, scale, BAND_WARNING_PX, self._warning_lines(), WARNING_FONT_SCALE)
3102        lifted = cy - self._lower_band_lift(rect, scale)
3103        objective = self._objective_line(rect, scale)
3104        if objective:
3105            lines = [(objective, COLOUR_OBJECTIVE)]
3106            self._draw_band(renderer, cx, lifted, scale, BAND_OBJECTIVE_PX, lines, WARNING_FONT_SCALE)
3107        boundary = self._boundary_line(rect, scale)
3108        if boundary:
3109            row = [(boundary, COLOUR_HINT)]
3110            self._draw_band(renderer, cx, lifted, scale, BAND_BOUNDARY_PX, row, WARNING_FONT_SCALE)
3111
3112    def _marker_point(self, target: Vec3, rect, margin: float, scale: float) -> tuple[float, float]:
3113        """Where a waypoint marker for *target* is pinned, *margin* inside the edge.
3114
3115        A pitched camera puts anything far enough behind it outside the frustum,
3116        and the projection then says nothing at all. Falling back to the centre
3117        of the screen, as this used to, plants the marker on the ship: a pilot
3118        flew at a DEPOT marker that was sitting over their own hull for forty
3119        seconds and left the sector doing it. The flight plane maps straight
3120        onto the screen axes, so a failed projection is answered with the plane
3121        bearing, which is a real direction rather than a shrug.
3122
3123        The bottom edge keeps :data:`MARKER_BOTTOM_MARGIN_PX` whatever *margin*
3124        asks for, because the captions and the controls strip live down there.
3125        """
3126        x, y, w, h = rect
3127        floor = max(margin, MARKER_BOTTOM_MARGIN_PX * scale)
3128        point = self._project(target, rect)
3129        if point is None:
3130            sx, sy = self._ship_screen_position(rect)
3131            ship = self._ship
3132            if ship is None:
3133                return (x + w * 0.5, y + h * 0.5)
3134            offset = Vec3(target) - Vec3(ship.world_position)
3135            dx, dz = float(offset.x), float(offset.z)  # world +Z is screen down
3136            reach = math.hypot(dx, dz)
3137            if reach < 1e-6:
3138                return (sx, sy)
3139            span = w + h
3140            point = (sx + dx / reach * span, sy + dz / reach * span)
3141        return (
3142            min(x + w - margin, max(x + margin, point[0])),
3143            min(y + h - floor, max(y + margin, point[1])),
3144        )
3145
3146    def _range_label(self, base: str, target: Vec3) -> str:
3147        """``"DEPOT 41 u"``: a waypoint's name, its range and the unit.
3148
3149        The unit is not decoration. A blind playtest read ``ORE 147`` in the
3150        corner of the screen as a second currency standing against the SCRAP
3151        counter, and went looking for the shop that took it.
3152        """
3153        ship = self._ship
3154        if ship is None:
3155            return base
3156        offset = Vec3(target) - Vec3(ship.world_position)
3157        return f"{base} {math.hypot(float(offset.x), float(offset.z)):.0f} {RANGE_UNIT_SUFFIX}"
3158
3159    def _marker_label_left(self, px: float, half_width: float, rect, scale: float) -> float:
3160        """The left edge of a centred marker label, slid inboard of the screen.
3161
3162        The marker is clamped to the edge margin, but a label centred on it is
3163        wider than the margin is deep, so a marker pinned to a side edge used
3164        to print the outer half of its text off the screen. The rect keeps its
3165        width and slides until it fits; the text stays centred inside it.
3166        """
3167        x, _y, w, _h = rect
3168        inset = LABEL_EDGE_INSET_PX * scale
3169        left = px - half_width
3170        return min(max(left, x + inset), x + w - 2.0 * half_width - inset)
3171
3172    def reading_band(self, rect, scale: float) -> tuple[float, float, float, float]:
3173        """The column of screen the ship's own standing lines own, as a rect.
3174
3175        Everything the HUD writes about the hull is drawn on one vertical
3176        column: the affordance line and the signature reading above the ring,
3177        and the resource strip, the warp chip, the warnings, the objective and
3178        the boundary notice below it. A waypoint label is the one thing on the
3179        screen that is placed by where a rock is rather than by a layout, so it
3180        is the one thing that lands in the middle of them, and it did: a blind
3181        playtest photographed ``PRESS R TO JUMDEPOT ON``, which is the warp chip
3182        and a depot marker sharing a row of pixels.
3183        """
3184        cx, _cy = self._ship_screen_position(rect)
3185        half = BAND_HALF_WIDTH_PX * scale
3186        bottom = self._band_top(BAND_BOUNDARY_PX, rect, scale) + WARNING_LINE_HEIGHT_PX * scale
3187        return (cx - half, self.status_band_top(rect, scale), cx + half, bottom)
3188
3189    def _corner_zones(self, rect, scale: float) -> list[tuple[float, float, float, float]]:
3190        """The standing furniture in the corners, as rects.
3191
3192        The sector name in the top right and the two readout stacks down the
3193        left edge. They are the persistent part of the HUD nothing else was
3194        told about, so a marker pinned to the top edge printed ``HULK 153 u``
3195        and ``VEIN 131 u`` straight through ``DEBRIS FIELD``.
3196        """
3197        x, y, w, _h = rect
3198        margin = READOUT_MARGIN_PX * scale
3199        pad = READING_PLATE_PADDING_X_PX
3200        zones: list[tuple[float, float, float, float]] = []
3201        if self._sector_name and self.is_element_visible(HudElements.SECTOR):
3202            line = SECTOR_NAME_LINE_PX * scale
3203            width = _plate_width(self._sector_name, SECTOR_NAME_FONT_SCALE, scale, pad, SECTOR_NAME_BOX_PX * scale)
3204            zones.append((x + w - margin - width, y + margin, x + w - margin, y + margin + line))
3205        line = READOUT_LINE_HEIGHT_PX * scale
3206        stacks = (
3207            (self._top_left_readouts(), y + margin),
3208            (self._bottom_left_readouts(), self._bottom_left_base(rect, scale)),
3209        )
3210        for texts, top in stacks:
3211            if not texts:
3212                continue
3213            widest = max(texts, key=len)
3214            width = _plate_width(widest, READOUT_FONT_SCALE, scale, pad, w)
3215            zones.append((x + margin, top, x + margin + width, top + line * len(texts)))
3216        return zones
3217
3218    def _plate_zones(self, rect, scale: float) -> list[tuple[float, float, float, float]]:
3219        """The two dark plates at the foot of the screen, as rects.
3220
3221        The caption track and the answer line. Both are transient and both are
3222        centred, which is exactly where a marker riding the bottom edge puts its
3223        range: a rendered gate dropped the ``KILL CHIME / HULL BREACHED`` plate
3224        over ``HULK 91 u`` and left the hulk's range unreadable.
3225        """
3226        x, _y, w, _h = rect
3227        zones: list[tuple[float, float, float, float]] = []
3228        block = self.caption_block(rect, scale)
3229        if block is not None:
3230            widest = max(self._subtitles, key=lambda entry: len(entry[0]))[0]
3231            plate = _plate_width(widest, SUBTITLE_FONT_SCALE, scale, SUBTITLE_PLATE_PADDING_X_PX, w)
3232            zones.append((x + (w - plate) * 0.5, block[0], x + (w + plate) * 0.5, block[1]))
3233        text = self.toast
3234        if text and self.is_element_visible(HudElements.TOAST):
3235            padding = SUBTITLE_PLATE_PADDING_Y_PX * scale
3236            base = self.message_row(rect, scale)
3237            plate = _plate_width(text, TOAST_FONT_SCALE, scale, SUBTITLE_PLATE_PADDING_X_PX, w)
3238            top = base - padding
3239            bottom = base + TOAST_LINE_HEIGHT_PX * scale + padding
3240            zones.append((x + (w - plate) * 0.5, top, x + (w + plate) * 0.5, bottom))
3241        return zones
3242
3243    def _label_zones(self, rect, scale: float) -> list[tuple[float, float, float, float]]:
3244        """The standing HUD a waypoint label steps around, as screen rects.
3245
3246        The ship's own readings (:meth:`reading_band`), whichever tutorial or
3247        arrival card is up, and the standing furniture in the corners. The cards
3248        yield around the ship's band and the labels are placed by where a rock
3249        is, so the two met in the one place neither of them was watching: a
3250        blind playtest photographed ``HULK 25 u`` written into the middle of the
3251        aiming lesson.
3252
3253        Round 13 taught the markers to avoid each other and stopped there, which
3254        left every collision with the rest of the HUD standing: the sector name
3255        and the ``HULL 100`` chip beside the ring each took a range through them
3256        in one gate. A label that dodges only its own kind is a label that lands
3257        on everything else.
3258
3259        The transient plates at the foot of the screen are not here: they are
3260        :meth:`_plate_zones`, and a label treats them differently because they
3261        go away on their own.
3262        """
3263        zones = [self.reading_band(rect, scale)]
3264        for layout in (self.control_card_layout(rect, scale), self.arrival_card_layout(rect, scale)):
3265            if layout is not None:
3266                zones.append((layout.x, layout.y, layout.x + layout.width, layout.bottom))
3267        zones.extend(self._corner_zones(rect, scale))
3268        return zones
3269
3270    def _blocked_rows(
3271        self, zones, left_edge: float, right_edge: float, taken: list, scale
3272    ) -> list[tuple[float, float]]:
3273        """Row spans a label between *left_edge* and *right_edge* may not take.
3274
3275        One merged, disjoint list rather than a rect at a time, because dodging
3276        a zone one at a time is how a label ends up oscillating between two of
3277        them: the tutorial card sits directly above the ship's band, so stepping
3278        up out of the band lands in the card and stepping down out of the card
3279        lands back in the band. Merged spans have edges, and an edge is a row
3280        that is clear of everything at once.
3281        """
3282        line = WARNING_LINE_HEIGHT_PX * scale
3283        clearance = MARKER_ZONE_CLEARANCE_PX * scale
3284        separation = MARKER_LABEL_SEPARATION_PX * scale
3285        spans: list[tuple[float, float]] = []
3286        for left, top, right, bottom in zones:
3287            if left_edge < right and right_edge > left:
3288                spans.append((top - line - clearance, bottom + clearance))
3289        for other_row, other_left, other_right in taken:
3290            if left_edge < other_right and right_edge > other_left:
3291                spans.append((other_row - separation, other_row + separation))
3292        merged: list[list[float]] = []
3293        for start, end in sorted(spans):
3294            if merged and start < merged[-1][1]:
3295                merged[-1][1] = max(merged[-1][1], end)
3296            else:
3297                merged.append([start, end])
3298        return [(start, end) for start, end in merged]
3299
3300    @staticmethod
3301    def _nearest_free_row(wanted: float, spans, floor: float, ceiling: float) -> float | None:
3302        """The row nearest *wanted* between *floor* and *ceiling* and outside *spans*.
3303
3304        ``None`` when every row in the range is spoken for, which is a marker
3305        that has been shut out of its own edge. The candidates are *wanted* and
3306        the edges of the merged spans, because the nearest clear row to a point
3307        inside a span is that span's own edge.
3308        """
3309        if floor > ceiling:
3310            return None
3311        candidates = [wanted]
3312        for start, end in spans:
3313            candidates.append(start)
3314            candidates.append(end)
3315        best: float | None = None
3316        for candidate in candidates:
3317            row = min(max(candidate, floor), ceiling)
3318            if any(start < row < end for start, end in spans):
3319                continue
3320            if best is None or abs(row - wanted) < abs(best - wanted):
3321                best = row
3322        return best
3323
3324    def _marker_label_y(
3325        self,
3326        wanted: float,
3327        px: float,
3328        half_width: float,
3329        taken: list,
3330        rect,
3331        scale,
3332        *,
3333        required: bool = False,
3334    ) -> float | None:
3335        """A free row for a marker label at *wanted*, clear of the HUD and *taken*.
3336
3337        Two waypoints on similar bearings pin to the same edge, and their labels
3338        used to be written on top of each other: the beacon, the compass and the
3339        Shrike countdown each kept a private list of taken rows, so ``DEPOT 8 u``
3340        and ``VEIN 7 u`` landed on one row and read as ``DEPO7 08 u``. *taken*
3341        is now one list for the whole frame, and it carries each label's span as
3342        well as its row, so two markers at opposite edges still share a row.
3343
3344        The label steps along its own edge, up or down to whichever free row is
3345        nearer, until it is clear of the standing HUD too, however far that is:
3346        the tutorial card and the readings under the hull are not going away and
3347        a marker without a range is not a waypoint.
3348
3349        The two transient plates at the foot of the screen are the exception. A
3350        label steps around them as well, but only as far as
3351        :data:`MARKER_LABEL_MAX_TRAVEL_PX` from the row it was standing on;
3352        beyond that it returns ``None`` and the caller stands the whole marker
3353        down for the frame, because a plate that has taken the row is a plate
3354        that will hand it back in a second or two. *required* markers never
3355        stand down: that is the Shrike countdown and an armed dock prompt, and a
3356        warning that yields to a caption is a warning nobody got.
3357        """
3358        _x, y, _w, h = rect
3359        line = WARNING_LINE_HEIGHT_PX * scale
3360        ceiling = y + h - CONTROLS_BAR_BAND_PX * scale - line
3361        floor = y + line
3362        left_edge = self._marker_label_left(px, half_width, rect, scale)
3363        right_edge = left_edge + 2.0 * half_width
3364        standing = self._label_zones(rect, scale)
3365        spans = self._blocked_rows(standing, left_edge, right_edge, taken, scale)
3366        row = self._nearest_free_row(wanted, spans, floor, ceiling)
3367        if row is None:
3368            row = min(max(wanted, floor), ceiling)
3369        plates = self._plate_zones(rect, scale)
3370        if plates:
3371            spans = self._blocked_rows(standing + plates, left_edge, right_edge, taken, scale)
3372            clear = self._nearest_free_row(row, spans, floor, ceiling)
3373            if clear is None or abs(clear - row) > MARKER_LABEL_MAX_TRAVEL_PX * scale:
3374                if not required:
3375                    return None
3376            else:
3377                row = clear
3378        taken.append((row, left_edge, right_edge))
3379        return row
3380
3381    def _draw_compass(self, renderer, rect, scale: float) -> None:
3382        """Point at each of the nearest vein and hulk that is not on screen.
3383
3384        The depot beacon generalised: a pilot who has flown out of the content
3385        is given the bearing and the range of each kind of thing there is to
3386        fly back to. A marker goes away when its own target comes into view,
3387        and only then. Suppressing the whole set the moment any one of them was
3388        visible is what made a depot marker vanish mid-flight and left a pilot
3389        holding a heading out of the sector.
3390        """
3391        targets = self.compass_targets
3392        if not targets or not self.is_element_visible(HudElements.BEACON):
3393            return
3394        x, y, w, h = rect
3395        inset = COMPASS_ON_SCREEN_MARGIN_PX * scale
3396        margin = COMPASS_EDGE_MARGIN_PX * scale
3397        line = WARNING_LINE_HEIGHT_PX * scale
3398        rows = self._marker_rows
3399        for position, kind in targets:
3400            point = self._project(position, rect)
3401            if point is not None and x + inset <= point[0] <= x + w - inset and y + inset <= point[1] <= y + h - inset:
3402                continue  # the thing itself is on screen; a marker over it is noise
3403            px, py = self._marker_point(position, rect, margin, scale)
3404            half = MARKER_LABEL_HALF_WIDTH_PX * scale
3405            row = self._marker_label_y(py + MARKER_LABEL_GAP_PX * scale, px, half, rows, rect, scale)
3406            if row is None:
3407                # Shut out of its own edge by a panel. A ring with no range on
3408                # it is a dot; the panel is transient and the marker returns.
3409                continue
3410            renderer.draw_circle(
3411                (px, py),
3412                COMPASS_RADIUS_PX * scale,
3413                colour=COLOUR_COMPASS,
3414                filled=False,
3415                segments=COMPASS_SEGMENTS.get(kind, 6),
3416            )
3417            renderer.draw_text(
3418                self._range_label(COMPASS_LABELS.get(kind, kind.upper()), position),
3419                rect=(self._marker_label_left(px, half, rect, scale), row, 2.0 * half, line),
3420                colour=COLOUR_COMPASS,
3421                scale=COMPASS_FONT_SCALE * scale,
3422                alignment="centre",
3423                vertical_alignment="centre",
3424            )
3425
3426    def _draw_beacon(self, renderer, rect, scale: float) -> None:
3427        """Mark the sector's depot, how far away it is, and when you are in it.
3428
3429        Drawn where the depot is when that is on screen, and pinned inside the
3430        edge in its direction when it is not, so the marker is a bearing and a
3431        range rather than a thing that vanishes the moment it matters.
3432
3433        Inside the bay it becomes the prompt. A blind pilot crossed the ring
3434        three times without docking, because the range kept counting and the one
3435        line that named the key was small, at the top of the screen, and under a
3436        tutorial card. The ring fills, grows and carries the verb itself, which
3437        puts the answer where the pilot is already looking.
3438        """
3439        target = self._beacon_position
3440        if target is None or not self.is_element_visible(HudElements.BEACON):
3441            return
3442        prompt = self.dock_prompt
3443        armed = bool(prompt)
3444        colour = COLOUR_BEACON_ARMED if armed else COLOUR_BEACON
3445        radius = BEACON_RADIUS_PX * scale * (BEACON_ARMED_RADIUS_MULT if armed else 1.0)
3446        px, py = self._marker_point(target, rect, BEACON_EDGE_MARGIN_PX * scale, scale)
3447        half = MARKER_LABEL_HALF_WIDTH_PX * scale
3448        # An armed bay carries the verb rather than a range, and the verb is the
3449        # answer to "why am I sitting in this ring": it never stands down.
3450        row = self._marker_label_y(
3451            py + MARKER_LABEL_GAP_PX * scale, px, half, self._marker_rows, rect, scale, required=armed
3452        )
3453        if row is None:
3454            return
3455        renderer.draw_circle((px, py), radius, colour=colour, filled=armed, segments=4)
3456        if armed:
3457            renderer.draw_circle((px, py), radius * 1.6, colour=colour, filled=False, segments=4)
3458        renderer.draw_text(
3459            prompt if armed else self._range_label(self._beacon_label, target),
3460            rect=(self._marker_label_left(px, half, rect, scale), row, 2.0 * half, WARNING_LINE_HEIGHT_PX * scale),
3461            colour=colour,
3462            scale=(BEACON_ARMED_FONT_SCALE if armed else BEACON_FONT_SCALE) * scale,
3463            alignment="centre",
3464            vertical_alignment="centre",
3465        )
3466
3467    def _float_target_point(self, name: str, rect, scale: float):
3468        """Where on screen the counter *name* is drawn, or None when it is not.
3469
3470        The strip under the ring is the target now that the hold lives there: a
3471        label flying a hand's width to a number beside the ship reads as
3472        income, where the same label crossing the whole screen to a corner
3473        mostly read as something leaving.
3474        """
3475        if name != FLOAT_TARGET_SCRAP:
3476            return None
3477        cells = self._resource_cells()
3478        if not cells or not self.is_element_visible(HudElements.SCRAP):
3479            return None
3480        cx, cy = self._ship_screen_position(rect)
3481        left = cx - RESOURCE_CELL_PX * scale * len(cells) * 0.5
3482        return (
3483            left + RESOURCE_CELL_PX * scale * 0.5,
3484            cy + (BAND_RESOURCES_PX + RESOURCE_LINE_PX * 0.5) * scale,
3485        )
3486
3487    def _float_lane(self, point, width: float, line: float, readings, rect, scale: float) -> float:
3488        """How far sideways a rising float steps to clear the ship's own readings.
3489
3490        A rising label sweeps :data:`FLOAT_TEXT_RISE_PX` upward from where it
3491        landed, and the readings around the hull sit in a narrow column in the
3492        middle of that sweep. The burn float is the case that matters: it is
3493        anchored on the ship it just hit, so it rose straight through ``HULL``
3494        on the one frame where the hull number had something to say. The lane
3495        is decided from the whole flight rather than from this frame's row, so
3496        a label picks a side once and holds it instead of hopping sideways
3497        half way up.
3498
3499        Only labels planted in the world get a lane. One pinned to a node is a
3500        label *about* that node, and a tag that steps off the enemy it names is
3501        a tag pointing at nothing; those keep their anchor and their overlap.
3502
3503        Zero when nothing is in the way, which is almost always.
3504        """
3505        if not readings:
3506            return 0.0
3507        left = point[0] - width * 0.5
3508        right = point[0] + width * 0.5
3509        bottom = point[1]
3510        top = point[1] - FLOAT_TEXT_RISE_PX * scale - line
3511        blocked = [
3512            plate
3513            for plate in readings
3514            if left < plate[0] + plate[2] and right > plate[0] and top < plate[1] + plate[3] and bottom > plate[1]
3515        ]
3516        if not blocked:
3517            return 0.0
3518        wanted_right = max(plate[0] + plate[2] for plate in blocked) + width * 0.5 - point[0]
3519        wanted_left = min(plate[0] for plate in blocked) - width * 0.5 - point[0]
3520        x, _y, w, _h = rect
3521        if point[0] + wanted_right + width * 0.5 <= x + w:
3522            return wanted_right
3523        if point[0] + wanted_left - width * 0.5 >= x:
3524            return wanted_left
3525        return wanted_right if abs(wanted_right) <= abs(wanted_left) else wanted_left
3526
3527    def _draw_float_texts(self, renderer, rect, scale: float) -> None:
3528        """Draw the world-anchored labels, flying to the counter they credit.
3529
3530        A label with no counter rises and fades where it landed, which is what
3531        a first-sighting tag or a refill wants. One that names a counter eases
3532        across to it instead, so the number in the corner visibly grows because
3533        of the thing the pilot just flew through.
3534
3535        A riser that would sweep through one of the ship's own readings takes a
3536        lane beside it (:meth:`_float_lane`): the reading is what the label is
3537        there to explain, and a label written over it explains nothing.
3538        """
3539        if not self._floats or not self.is_element_visible(HudElements.FLOAT_TEXT):
3540            return
3541        line = WARNING_LINE_HEIGHT_PX * scale
3542        # Measured on the first label that could need a lane, and usually never.
3543        readings: list[tuple[float, float, float, float]] | None = None
3544        for text, remaining, total, colour, reference, anchor, toward, plate in self._floats:
3545            node = reference() if reference is not None else None
3546            world = anchor
3547            live = getattr(node, "world_position", None) if getattr(node, "tree", None) is not None else None
3548            if live is not None:
3549                world = Vec3(live)
3550            if world is None:
3551                continue
3552            point = self._project(world, rect)
3553            if point is None:
3554                continue
3555            travelled = 1.0 - remaining / total
3556            destination = self._float_target_point(toward, rect, scale) if toward else None
3557            planted = destination is None and live is None
3558            # Measured only when it is going to be used, for the plate or for
3559            # the lane: this runs per label per frame.
3560            plate_width = (
3561                _plate_width(text, FLOAT_TEXT_FONT_SCALE, scale, SUBTITLE_PLATE_PADDING_X_PX, rect[2])
3562                if plate or planted
3563                else 0.0
3564            )
3565            if destination is None:
3566                px, py = point[0], point[1] - FLOAT_TEXT_RISE_PX * scale * travelled
3567                if planted:
3568                    if readings is None:
3569                        cx, cy = self._ship_screen_position(rect)
3570                        readings = self.reading_plate_rects(cx, cy, scale)
3571                    px += self._float_lane(point, plate_width, line, readings, rect, scale)
3572            else:
3573                flight = travelled**FLOAT_TEXT_FLIGHT_EASE
3574                px = point[0] + (destination[0] - point[0]) * flight
3575                py = point[1] + (destination[1] - point[1]) * flight
3576            alpha = min(1.0, remaining / FLOAT_TEXT_FADE_S)
3577            if plate:
3578                plate_padding = SUBTITLE_PLATE_PADDING_Y_PX * scale
3579                renderer.draw_rect(
3580                    (px - plate_width * 0.5, py - line - plate_padding),
3581                    (plate_width, line + 2.0 * plate_padding),
3582                    colour=_with_alpha(COLOUR_SUBTITLE_PLATE, alpha),
3583                    filled=True,
3584                )
3585            renderer.draw_text(
3586                text,
3587                rect=(px - 140.0 * scale, py - line, 280.0 * scale, line),
3588                colour=_with_alpha(colour, alpha),
3589                scale=FLOAT_TEXT_FONT_SCALE * scale,
3590                alignment="centre",
3591                vertical_alignment="centre",
3592            )
3593
3594    def _band_occupancy(self, rect, scale: float) -> list[tuple[float, float]]:
3595        """Every row under the hull that is spoken for this frame, outward from the ring.
3596
3597        The standing lines (:meth:`_standing_bands`) and the failed spool's own
3598        name, which is not standing but is written into the same column and is
3599        up long enough to be landed on.
3600        """
3601        spans = self._standing_bands(rect, scale)
3602        outcome = self.warp_outcome_row(rect, scale)
3603        if outcome is not None:
3604            spans.append(outcome)
3605            spans.sort()
3606        return spans
3607
3608    def _standing_bands(self, rect, scale: float) -> list[tuple[float, float]]:
3609        """Top and bottom of every standing line the HUD is writing under the hull.
3610
3611        Ordered outward from the ring, and only what is actually on screen this
3612        frame: a band reserved for a warning nobody is having is free ground,
3613        and the message line is what uses it. The plate under the resource strip
3614        is part of the strip's extent, because the plate is what the eye reads
3615        as the edge of the row.
3616        """
3617        _cx, cy = self._ship_screen_position(rect)
3618        spans: list[tuple[float, float]] = []
3619        cells = self._resource_cells()
3620        pips = self.state_pips()
3621        if cells or pips:
3622            pad = RESOURCE_PLATE_PADDING_PX * scale
3623            height = (RESOURCE_LINE_PX * bool(cells) + STATE_PIP_LINE_PX * bool(pips)) * scale
3624            top = cy + BAND_RESOURCES_PX * scale - pad
3625            spans.append((top, top + height + 2.0 * pad))
3626        line = WARNING_LINE_HEIGHT_PX * scale
3627        if self._warp_chip_is_up():
3628            top = cy + BAND_CONTEXT_PX * scale
3629            spans.append((top, top + line))
3630        warnings = len(self._warning_lines())
3631        if warnings:
3632            top = cy + BAND_WARNING_PX * scale
3633            spans.append((top, top + line * warnings))
3634        if self._objective_line(rect, scale):
3635            top = self._band_top(BAND_OBJECTIVE_PX, rect, scale)
3636            spans.append((top, top + line))
3637        if self._boundary_line(rect, scale):
3638            top = self._band_top(BAND_BOUNDARY_PX, rect, scale)
3639            spans.append((top, top + line))
3640        return spans
3641
3642    def message_row(self, rect, scale: float) -> float:
3643        """The row the transient answer line owns this frame.
3644
3645        The answer to a key press is anchored to the screen and the ship's
3646        standing lines are anchored to the hull, so the two cross whenever the
3647        pilot flies at the height that brings them together, and a blind
3648        playtest photographed the chart refusal written straight through
3649        OBJECTIVE. The message keeps its usual row and steps up past whatever
3650        standing line is in it, taking the first free row it finds. When the
3651        stack under the hull leaves no free row at all, it goes above the ship's
3652        whole cluster instead, and the tutorial card yields to it there
3653        (:meth:`_top_band_ceiling`).
3654        """
3655        x, y, w, h = rect
3656        del x, w
3657        line = TOAST_LINE_HEIGHT_PX * scale
3658        gap = MESSAGE_ROW_GAP_PX * scale
3659        row = y + h - SUBTITLE_BOTTOM_MARGIN_PX * scale - TOAST_GAP_PX * scale
3660        row -= SUBTITLE_LINE_HEIGHT_PX * scale * SUBTITLE_MAX_LINES + line
3661        _cx, cy = self._ship_screen_position(rect)
3662        floor = cy + BAND_RESOURCES_PX * scale - gap - line
3663        for top, bottom in reversed(self._band_occupancy(rect, scale)):
3664            if row < bottom + gap and row + line > top - gap:
3665                row = top - gap - line
3666        if row < floor:
3667            return self.status_band_top(rect, scale) - gap - line
3668        return row
3669
3670    def _message_in_top_band(self, rect, scale: float) -> bool:
3671        """Whether the message line has been pushed above the ship's cluster."""
3672        if self._toast_remaining <= 0.0 or not self.is_element_visible(HudElements.TOAST):
3673            return False
3674        return self.message_row(rect, scale) < self.status_band_top(rect, scale)
3675
3676    def _draw_toast(self, renderer, rect, scale: float) -> None:
3677        """The answer to the last key press, on its own plate above the captions.
3678
3679        It gets the caption track's treatment because it is read the same way:
3680        one line, glanced at mid-manoeuvre, over whatever the sector happens to
3681        be drawing. Unbacked and dimmed it lost every contest with the standing
3682        lines under the hull, and a refusal a pilot cannot read is a refusal
3683        they file as a broken key.
3684        """
3685        text = self.toast
3686        if not text or not self.is_element_visible(HudElements.TOAST):
3687            return
3688        x, _y, w, _h = rect
3689        line = TOAST_LINE_HEIGHT_PX * scale
3690        base = self.message_row(rect, scale)
3691        alpha = min(1.0, self._toast_remaining / TOAST_FADE_S)
3692        plate_width = _plate_width(text, TOAST_FONT_SCALE, scale, SUBTITLE_PLATE_PADDING_X_PX, w)
3693        plate_padding = SUBTITLE_PLATE_PADDING_Y_PX * scale
3694        renderer.draw_rect(
3695            (x + (w - plate_width) * 0.5, base - plate_padding),
3696            (plate_width, line + 2.0 * plate_padding),
3697            colour=_with_alpha(COLOUR_SUBTITLE_PLATE, alpha),
3698            filled=True,
3699        )
3700        renderer.draw_text(
3701            text,
3702            rect=(x, base, w, line),
3703            colour=_with_alpha(_opaque(COLOUR_TOAST), alpha),
3704            scale=TOAST_FONT_SCALE * scale,
3705            alignment="centre",
3706            vertical_alignment="centre",
3707        )
3708
3709    def ring_labels(self) -> tuple[tuple[str, str], ...]:
3710        """The gauge captions as ``(compass_point, text)``, in drawing order.
3711
3712        Hull and capacitor carry their number; oxygen and fuel do not, because
3713        theirs is in the resource strip a hand's width below.
3714        """
3715        labels = []
3716        for element, _centre, _span, _colour, caption, compass in _ARC_SPECS:
3717            if not self.is_element_visible(element):
3718                continue
3719            if element in _ARC_VALUE_ELEMENTS:
3720                caption = ARC_LABEL_WITH_VALUE.format(caption=caption, value=self._levels[element])
3721            labels.append((compass, caption))
3722        return tuple(labels)
3723
3724    @staticmethod
3725    def _arc_label_rect(compass: str, cx: float, cy: float, scale: float) -> tuple[tuple, str]:
3726        """Where a ring caption on *compass* is written, and how it is aligned.
3727
3728        The side captions hang outward from the rings and are aligned to the
3729        edge nearest the hull, so the type grows away from the ship instead of
3730        into it. The top and bottom ones are centred above and below every ring
3731        that reaches their bearing.
3732        """
3733        box = ARC_LABEL_BOX_PX * scale
3734        line = ARC_LABEL_LINE_PX * scale
3735        side = ARC_LABEL_RADIUS_PX * scale
3736        if compass == "left":
3737            return ((cx - side - box, cy - line * 0.5, box, line), "right")
3738        if compass == "right":
3739            return ((cx + side, cy - line * 0.5, box, line), "left")
3740        if compass == "top":
3741            return ((cx - box * 0.5, cy - ARC_LABEL_TOP_RADIUS_PX * scale - line, box, line), "centre")
3742        return ((cx - box * 0.5, cy + side, box, line), "centre")
3743
3744    def reading_plate_rects(self, cx: float, cy: float, scale: float) -> list[tuple[float, float, float, float]]:
3745        """Backing plates for the readings around the hull, as ``(x, y, w, h)``.
3746
3747        Every caption the ring cluster writes and the signature reading above
3748        it. Sized to the type rather than to the caption box, so the plates read
3749        as chips behind the numbers rather than as a panel drawn around the
3750        ship. Public because the thing they exist to survive is a rendered
3751        frame, and a test that cannot ask where they are can only assert that
3752        some rects were drawn.
3753        """
3754        pad_x = READING_PLATE_PADDING_X_PX * scale
3755        pad_y = READING_PLATE_PADDING_Y_PX * scale
3756        line = ARC_LABEL_LINE_PX * scale
3757        plates: list[tuple[float, float, float, float]] = []
3758        for compass, text in self.ring_labels():
3759            box, alignment = self._arc_label_rect(compass, cx, cy, scale)
3760            width = _plate_width(text, ARC_LABEL_FONT_SCALE, scale, READING_PLATE_PADDING_X_PX, box[2] + 2.0 * pad_x)
3761            if alignment == "right":
3762                left = box[0] + box[2] - width
3763            elif alignment == "left":
3764                left = box[0]
3765            else:
3766                left = box[0] + (box[2] - width) * 0.5
3767            plates.append((left, box[1] - pad_y, width, line + 2.0 * pad_y))
3768        if self.is_element_visible(HudElements.SIGNATURE):
3769            text = SIGNATURE_LABEL.format(value=self._signature, maximum=balance.SIGNATURE_MAX)
3770            width = _plate_width(text, SIGNATURE_LABEL_FONT_SCALE, scale, READING_PLATE_PADDING_X_PX, 320.0 * scale)
3771            top = cy - (ARC_RADIUS_PX + SIGNATURE_ARC_GAP_PX + SIGNATURE_LABEL_GAP_PX) * scale
3772            line = SIGNATURE_LABEL_LINE_PX * scale
3773            plates.append((cx - width * 0.5, top - line - pad_y, width, line + 2.0 * pad_y))
3774        return plates
3775
3776    def _draw_reading_plates(self, renderer, cx: float, cy: float, scale: float) -> None:
3777        """Lay the reading plates down before anything is written on them."""
3778        for x, y, width, height in self.reading_plate_rects(cx, cy, scale):
3779            renderer.draw_rect((x, y), (width, height), colour=COLOUR_READING_PLATE, filled=True)
3780
3781    def _draw_gauge_arcs(self, renderer, cx: float, cy: float, scale: float) -> None:
3782        radius = ARC_RADIUS_PX * scale
3783        thickness = ARC_THICKNESS_PX * scale
3784        texts = dict(self.ring_labels())
3785        blacked = self.capacitor_blacked_out
3786        for element, centre_deg, span_deg, colour, _caption, compass in _ARC_SPECS:
3787            if not self.is_element_visible(element):
3788                continue
3789            if element == HudElements.CAPACITOR and blacked:
3790                colour = COLOUR_BLACKOUT
3791            start = centre_deg - span_deg * 0.5
3792            _draw_arc(renderer, cx, cy, radius, start, span_deg, thickness, COLOUR_TRACK)
3793            filled = span_deg * self._gauges[element]
3794            if filled > 0.0:
3795                _draw_arc(renderer, cx, cy, radius, start, filled, thickness, colour)
3796            if element == HudElements.HULL:
3797                chip = span_deg * (self.hull_chip - self._gauges[element])
3798                if chip > 0.0:
3799                    # Drawn proud of the ring as well as in its own colour, so
3800                    # the loss is a shape change and not only a hue change.
3801                    chip_thickness = thickness * HULL_CHIP_THICKNESS_MULT
3802                    _draw_arc(renderer, cx, cy, radius, start + filled, chip, chip_thickness, COLOUR_HULL_CHIP)
3803            box, alignment = self._arc_label_rect(compass, cx, cy, scale)
3804            renderer.draw_text(
3805                texts[compass],
3806                rect=box,
3807                colour=_opaque(colour),
3808                scale=ARC_LABEL_FONT_SCALE * scale,
3809                alignment=alignment,
3810                vertical_alignment="centre",
3811            )
3812
3813    def _draw_shield_arc(self, renderer, cx: float, cy: float, scale: float) -> None:
3814        """Draw the shield pad on the bearing it is actually covering.
3815
3816        Q and E used to move a defence with no output at all: the arc existed
3817        in the collision maths and nowhere on the screen, so the two keys read
3818        as unbound. It rides just outside the gauge ring and turns red once the
3819        pad is broken, which is the other state the pilot has to know about.
3820        """
3821        shield = getattr(self._ship, "shield", None)
3822        bearing = self.shield_bearing
3823        if shield is None or bearing is None:
3824            return
3825        span = math.degrees(float(shield.half_width)) * 2.0
3826        radius = (ARC_RADIUS_PX + SHIELD_ARC_GAP_PX) * scale
3827        broken = bool(getattr(shield, "broken", False))
3828        colour = COLOUR_SHIELD_BROKEN if broken else COLOUR_SHIELD
3829        charge = _fraction(float(getattr(shield, "charge", 0.0)), float(getattr(shield, "charge_max", 0.0)))
3830        _draw_arc(renderer, cx, cy, radius, bearing - span * 0.5, span, SHIELD_ARC_THICKNESS_PX * scale, COLOUR_TRACK)
3831        filled = span * (0.0 if broken else charge)
3832        if filled > 0.0:
3833            _draw_arc(renderer, cx, cy, radius, bearing - span * 0.5, filled, SHIELD_ARC_THICKNESS_PX * scale, colour)
3834        elif broken:
3835            _draw_arc(renderer, cx, cy, radius, bearing - span * 0.5, span, SHIELD_ARC_THICKNESS_PX * scale, colour)
3836
3837    def _draw_resource_strip(self, renderer, cx: float, cy: float, scale: float) -> None:
3838        """The numbers the run charges and credits, in one row under the ring.
3839
3840        Two rows, in fact: the resources on top and the power-state pips under
3841        them, both in fixed cells so a number growing a digit never shoves its
3842        neighbours sideways.
3843
3844        Both rows sit on a faint plate. They are drawn over the playfield rather
3845        than over a panel, and a bright nebula or a wreck drifting under them
3846        took the row with it: a blind playtest read the scrap count off a frame
3847        where only the word survived. The plate is the cheapest thing that makes
3848        a number in front of the world stay a number.
3849        """
3850        cells = self._resource_cells()
3851        self._draw_resource_plate(renderer, cx, cy, scale, cells)
3852        if cells:
3853            self._draw_cells(
3854                renderer,
3855                cx,
3856                cy + BAND_RESOURCES_PX * scale,
3857                scale,
3858                cells,
3859                RESOURCE_CELL_PX,
3860                RESOURCE_LINE_PX,
3861                RESOURCE_FONT_SCALE,
3862            )
3863        pips = self.state_pips()
3864        if pips:
3865            self._draw_cells(
3866                renderer,
3867                cx,
3868                cy + (BAND_RESOURCES_PX + RESOURCE_LINE_PX) * scale,
3869                scale,
3870                [(text, COLOUR_STATE_PIP_ON if lit else COLOUR_STATE_PIP_OFF) for text, lit in pips],
3871                STATE_PIP_CELL_PX,
3872                STATE_PIP_LINE_PX,
3873                STATE_PIP_FONT_SCALE,
3874            )
3875
3876    def _draw_resource_plate(self, renderer, cx: float, cy: float, scale: float, cells) -> None:
3877        """The backing plate under both rows of the strip, sized to the wider one."""
3878        pips = self.state_pips()
3879        width = max(RESOURCE_CELL_PX * len(cells), STATE_PIP_CELL_PX * len(pips)) * scale
3880        if width <= 0.0:
3881            return
3882        pad = RESOURCE_PLATE_PADDING_PX * scale
3883        height = (RESOURCE_LINE_PX * bool(cells) + STATE_PIP_LINE_PX * bool(pips)) * scale
3884        renderer.draw_rect(
3885            (cx - width * 0.5 - pad, cy + BAND_RESOURCES_PX * scale - pad),
3886            (width + 2.0 * pad, height + 2.0 * pad),
3887            colour=COLOUR_RESOURCE_PLATE,
3888            filled=True,
3889        )
3890
3891    @staticmethod
3892    def _draw_cells(renderer, cx: float, top: float, scale: float, cells, cell_px, line_px, font) -> None:
3893        """Lay *cells* out as one centred row of fixed-width boxes."""
3894        cell = cell_px * scale
3895        line = line_px * scale
3896        left = cx - cell * len(cells) * 0.5
3897        for index, (text, colour) in enumerate(cells):
3898            renderer.draw_text(
3899                text,
3900                rect=(left + index * cell, top, cell, line),
3901                colour=colour,
3902                scale=font * scale,
3903                alignment="centre",
3904                vertical_alignment="centre",
3905            )
3906
3907    def _draw_affordance(self, renderer, cx: float, cy: float, scale: float) -> None:
3908        """The one verb in reach, above the gauge ring and under the reticle.
3909
3910        Stands down when the bay's ring is already carrying this very sentence
3911        (:meth:`dock_prompt`): the ring is beside the ship the whole time the
3912        hull is inside it, and the same three words printed twice a hand's
3913        width apart read as a stutter rather than as emphasis.
3914        """
3915        text = self.affordance
3916        if not text or not self.is_element_visible(HudElements.AFFORDANCE):
3917            return
3918        if text == self.dock_prompt and self.is_element_visible(HudElements.BEACON):
3919            return
3920        line = AFFORDANCE_LINE_PX * scale
3921        top = cy - (SIGNATURE_GAUGE_TOP_PX + AFFORDANCE_GAP_PX) * scale - line
3922        renderer.draw_text(
3923            text,
3924            rect=(cx - 260.0 * scale, top, 520.0 * scale, line),
3925            colour=COLOUR_AFFORDANCE,
3926            scale=AFFORDANCE_FONT_SCALE * scale,
3927            alignment="centre",
3928            vertical_alignment="centre",
3929        )
3930
3931    def warp_chip_plate_rect(self, cx: float, cy: float, scale: float) -> tuple[float, float, float, float] | None:
3932        """The plate behind the drive's chip as ``(x, y, w, h)``, None when it is down.
3933
3934        Public for the reason the readings' plates are (:meth:`reading_plate_rects`):
3935        the thing it exists to survive is a rendered frame, and what it has to
3936        survive is drawn a moment before it.
3937        """
3938        if not self._warp_chip_is_up():
3939            return None
3940        width = _plate_width(
3941            self._warp_status,
3942            WARP_STATUS_FONT_SCALE,
3943            scale,
3944            WARP_STATUS_PLATE_PADDING_X_PX,
3945            BAND_HALF_WIDTH_PX * 2.0 * scale,
3946        )
3947        return (cx - width * 0.5, cy + BAND_CONTEXT_PX * scale, width, WARNING_LINE_HEIGHT_PX * scale)
3948
3949    def _draw_warp_status(self, renderer, cx: float, cy: float, scale: float) -> None:
3950        """The drive's standing answer, in the context band under the ship.
3951
3952        Suppressed while a spool is actually running: the ring and its own
3953        label are the live version of the same sentence. It is written on a
3954        plate, and the plate is what the failed ring passes behind: the refusal
3955        and the red ring that announces it are on screen together, and the ring
3956        holds the outer radius, which is this row.
3957        """
3958        if not self._warp_chip_is_up():
3959            return
3960        plate = self.warp_chip_plate_rect(cx, cy, scale)
3961        if plate is not None:
3962            x, y, width, height = plate
3963            renderer.draw_rect((x, y), (width, height), colour=COLOUR_WARP_STATUS_PLATE, filled=True)
3964        colour = COLOUR_WARP_STATUS_BLOCKED if self._warp_status_blocked else COLOUR_WARP_STATUS
3965        self._draw_band(renderer, cx, cy, scale, BAND_CONTEXT_PX, [(self._warp_status, colour)], WARP_STATUS_FONT_SCALE)
3966
3967    def _draw_controls_bar(self, renderer, rect, scale: float) -> None:
3968        """The persistent key list along the bottom edge, on its own plate.
3969
3970        Two rows on a dark plate. Dim letters straight over a star field is
3971        what turned the strip into a mote-speckled smear, and one row could
3972        only ever name half the keyboard.
3973        """
3974        rows = [row for row in (self.controls_bar, self.controls_bar_second_row) if row]
3975        if not rows or not self.is_element_visible(HudElements.CONTROLS_BAR):
3976            return
3977        x, y, w, h = rect
3978        line = CONTROLS_BAR_LINE_PX * scale
3979        padding = CONTROLS_BAR_PLATE_PADDING_PX * scale
3980        plate_height = line * len(rows) + 2.0 * padding
3981        plate_top = y + h - CONTROLS_BAR_MARGIN_PX * scale - plate_height
3982        renderer.draw_rect((x, plate_top), (w, plate_height), colour=COLOUR_CONTROLS_BAR_PLATE, filled=True)
3983        for index, row in enumerate(rows):
3984            renderer.draw_text(
3985                row,
3986                rect=(x, plate_top + padding + index * line, w, line),
3987                colour=COLOUR_CONTROLS_BAR,
3988                scale=CONTROLS_BAR_FONT_SCALE * scale,
3989                alignment="centre",
3990                vertical_alignment="centre",
3991            )
3992
3993    def _draw_warp_ring(self, renderer, rect, cx: float, cy: float, scale: float) -> None:
3994        if not self._warp_active or not self.is_element_visible(HudElements.WARP_SPOOL):
3995            return
3996        closed = self.warp_spool_fraction
3997        outer = WARP_RING_START_RADIUS_PX * scale
3998        inner = ARC_RADIUS_PX * scale
3999        radius = outer + (inner - outer) * closed
4000        if self._warp_stutter > 0.0:
4001            radius += (
4002                self._warp_stutter
4003                * WARP_STUTTER_AMPLITUDE_PX
4004                * scale
4005                * math.sin(self._elapsed * WARP_STUTTER_HZ * math.tau)
4006            )
4007        radius = max(1.0, radius)
4008        renderer.draw_circle((cx, cy), radius, colour=COLOUR_WARP_RING, filled=False, segments=64)
4009        self._draw_warp_label(renderer, rect, cx, cy, radius, scale)
4010
4011    def _warp_label_top(
4012        self, rect, cy: float, radius: float, scale: float, lines: int, *, standing_only: bool = False
4013    ) -> float:
4014        """Where the ring's own caption starts, clear of the standing lines.
4015
4016        The caption rides the ring inward, so the row it lands on changes every
4017        frame and sweeps straight through the band under the hull on the way:
4018        a rendered spool wrote ``ICE FIELD for 15 fuel`` through
4019        ``LOW FUEL: depots sell fuel cells`` and left neither readable. It is
4020        pushed outward rather than in, because inward is the hull, the gauge
4021        ring and every reading on it. Outward is empty screen.
4022
4023        *standing_only* leaves the ending's own name out of what is stepped
4024        around, which is what the ending's name has to ask for: it is in the
4025        occupancy itself, and a line that steps around itself walks off the
4026        bottom of the screen.
4027        """
4028        top = cy + radius + WARP_LABEL_GAP_PX * scale
4029        block = WARP_LABEL_LINE_PX * scale * lines
4030        gap = MESSAGE_ROW_GAP_PX * scale
4031        # Outward in order, so a push clear of one band is tested against the
4032        # next one out before it is used.
4033        bands = self._standing_bands(rect, scale) if standing_only else self._band_occupancy(rect, scale)
4034        for band_top, band_bottom in bands:
4035            if top < band_bottom + gap and top + block > band_top - gap:
4036                top = band_bottom + gap
4037        return top
4038
4039    def _draw_warp_label(self, renderer, rect, cx: float, cy: float, radius: float, scale: float) -> None:
4040        """Say what the ring is: the drive, the destination, the price, the abort.
4041
4042        A closing circle is a countdown to nothing until it is named. The
4043        lines sit under the ring so they travel inward with it and never cross
4044        the hull in the middle, stepping outward past whatever standing line
4045        the ring's own radius has put them in (:meth:`_warp_label_top`).
4046        """
4047        line = WARP_LABEL_LINE_PX * scale
4048        lines = [WARP_LABEL]
4049        if self._warp_target:
4050            quoted = self.warp_price
4051            price = WARP_LABEL_PRICE.format(price=quoted) if quoted else ""
4052            lines.append(f"{self._warp_target}{price}")
4053        lines.append(WARP_ABORT_HINT)
4054        top = self._warp_label_top(rect, cy, radius, scale, len(lines))
4055        for index, text in enumerate(lines):
4056            renderer.draw_text(
4057                text,
4058                rect=(cx - 200.0 * scale, top + index * line, 400.0 * scale, line),
4059                colour=COLOUR_WARP_RING,
4060                scale=WARP_LABEL_FONT_SCALE * scale,
4061                alignment="centre",
4062                vertical_alignment="centre",
4063            )
4064        if self._warp_interrupt_remaining <= 0.0:
4065            return
4066        renderer.draw_text(
4067            self._warp_interrupt_text,
4068            rect=(cx - 200.0 * scale, cy - radius - WARP_LABEL_GAP_PX * scale - line, 400.0 * scale, line),
4069            colour=_with_alpha(COLOUR_WARNING, min(1.0, self._warp_interrupt_remaining / TOAST_FADE_S)),
4070            scale=WARP_INTERRUPT_FONT_SCALE * scale,
4071            alignment="centre",
4072            vertical_alignment="centre",
4073        )
4074
4075    def _warp_outcome_radius(self, scale: float) -> float:
4076        """The radius the ending flash holds: where the ring was when it stopped."""
4077        outer = WARP_RING_START_RADIUS_PX * scale
4078        inner = ARC_RADIUS_PX * scale
4079        return max(1.0, outer + (inner - outer) * self._warp_outcome_fraction)
4080
4081    def warp_outcome_row(self, rect, scale: float) -> tuple[float, float] | None:
4082        """Top and bottom of the ending's name, or None when no ending is flashing.
4083
4084        The flash and the answer line are both transient, both centred and both
4085        raised by the same key press, and neither was told about the other: a
4086        rendered refusal put the toast plate's top edge through the bottom half
4087        of ``SPOOL ABORTED``. This is what carries the name into
4088        :meth:`_band_occupancy`, so the message row steps around it exactly as
4089        it steps around a standing line.
4090        """
4091        if self._warp_outcome_remaining <= 0.0 or not self._warp_outcome_text:
4092            return None
4093        if not self.is_element_visible(HudElements.WARP_SPOOL):
4094            return None
4095        _cx, cy = self._ship_screen_position(rect)
4096        top = self._warp_label_top(rect, cy, self._warp_outcome_radius(scale), scale, 1, standing_only=True)
4097        return (top, top + WARP_LABEL_LINE_PX * scale)
4098
4099    def _draw_warp_outcome(self, renderer, rect, cx: float, cy: float, scale: float) -> None:
4100        """Hold the ring in red on the ending it just had, and name it.
4101
4102        Drawn where the ring was when it stopped, so the flash reads as that
4103        ring failing rather than as a new element appearing from nowhere. The
4104        name under it steps outward past the standing lines the way the live
4105        ring's caption does, and the answer line steps around the name.
4106        """
4107        row = self.warp_outcome_row(rect, scale)
4108        if row is None:
4109            return
4110        radius = self._warp_outcome_radius(scale)
4111        alpha = min(1.0, self._warp_outcome_remaining / WARP_OUTCOME_FLASH_S)
4112        colour = _with_alpha(COLOUR_WARP_FAILED, alpha)
4113        renderer.draw_circle((cx, cy), radius, colour=colour, filled=False, segments=64)
4114        renderer.draw_text(
4115            self._warp_outcome_text,
4116            rect=(cx - 260.0 * scale, row[0], 520.0 * scale, row[1] - row[0]),
4117            colour=colour,
4118            scale=WARP_LABEL_FONT_SCALE * scale,
4119            alignment="centre",
4120            vertical_alignment="centre",
4121        )
4122
4123    def _draw_jump_flash(self, renderer, rect) -> None:
4124        """White out the screen for the instant the drive actually jumps."""
4125        if self._warp_jump_flash <= 0.0 or not self.is_element_visible(HudElements.WARP_SPOOL):
4126            return
4127        x, y, w, h = rect
4128        alpha = WARP_JUMP_FLASH_ALPHA * min(1.0, self._warp_jump_flash / WARP_JUMP_FLASH_S)
4129        renderer.draw_rect((x, y), (w, h), colour=_with_alpha(COLOUR_JUMP_FLASH, alpha), filled=True)
4130
4131    def _draw_interact_fill(self, renderer, cx: float, cy: float, scale: float) -> None:
4132        if self._interact_fraction <= 0.0 or not self.is_element_visible(HudElements.INTERACT_FILL):
4133            return
4134        _draw_arc(
4135            renderer,
4136            cx,
4137            cy,
4138            INTERACT_FILL_RADIUS_PX * scale,
4139            -90.0,
4140            360.0 * self._interact_fraction,
4141            ARC_THICKNESS_PX * scale,
4142            COLOUR_INTERACT,
4143        )
4144
4145    def _top_left_readouts(self) -> list[str]:
4146        """The corner stack above the notoriety line, top down.
4147
4148        The hold is not here any more: it is in the strip under the ring, where
4149        it is read at a glance beside the fuel it competes with. Two copies of
4150        one number is one copy too many, and the corner is the one a pilot was
4151        never looking at.
4152        """
4153        if not self.is_element_visible(HudElements.NOTORIETY):
4154            return []
4155        return [f"NOTORIETY {self._notoriety}"]
4156
4157    def _bottom_left_readouts(self) -> list[str]:
4158        """The power postures standing in the bottom corner, top down.
4159
4160        Ammunition is not here either: it is a cell in the strip, beside the
4161        other numbers the run spends, for the same reason the hold moved.
4162        """
4163        lines: list[str] = []
4164        if self.is_element_visible(HudElements.GENERATOR) and self._generator_running:
4165            lines.append("GENERATOR")
4166        if self.is_element_visible(HudElements.SILENT_RUNNING) and self._silent_running:
4167            lines.append("SILENT RUNNING")
4168        return lines
4169
4170    def _bottom_left_base(self, rect, scale: float) -> float:
4171        """The top of the bottom-left readout stack.
4172
4173        The controls strip owns the bottom edge whenever it is up, so the stack
4174        starts above its band rather than through it.
4175        """
4176        _x, y, _w, h = rect
4177        floor = CONTROLS_BAR_BAND_PX if self.controls_bar else 0.0
4178        return (
4179            y
4180            + h
4181            - READOUT_MARGIN_PX * scale
4182            - floor * scale
4183            - READOUT_LINE_HEIGHT_PX * scale * len(self._bottom_left_readouts())
4184        )
4185
4186    def _draw_readouts(self, renderer, rect, scale: float) -> None:
4187        x, y, _w, _h = rect
4188        margin = READOUT_MARGIN_PX * scale
4189        line = READOUT_LINE_HEIGHT_PX * scale
4190        font = READOUT_FONT_SCALE * scale
4191
4192        for index, text in enumerate(self._top_left_readouts()):
4193            renderer.draw_text(text, (x + margin, y + margin + index * line), colour=COLOUR_READOUT, scale=font)
4194
4195        base = self._bottom_left_base(rect, scale)
4196        for index, text in enumerate(self._bottom_left_readouts()):
4197            renderer.draw_text(text, (x + margin, base + index * line), colour=COLOUR_READOUT, scale=font)
4198
4199    def status_band_top(self, rect, scale: float) -> float:
4200        """The topmost pixel the ship's own status band occupies.
4201
4202        The affordance line, the signature reading and the gauge ring are the
4203        ship's band, and nothing in the top band may be drawn over them. A
4204        pilot flying with the mouse high rides the aim lead and puts the hull
4205        near the top of the screen, which is exactly when a fixed tutorial card
4206        used to land on ``SIGNATURE LOCKED``.
4207        """
4208        _x, _y, _w, _h = rect
4209        _cx, cy = self._ship_screen_position(rect)
4210        top = cy - ARC_RADIUS_PX * scale
4211        if self.is_element_visible(HudElements.SIGNATURE):
4212            top = min(top, cy - SIGNATURE_GAUGE_TOP_PX * scale)
4213        if self.affordance and self.is_element_visible(HudElements.AFFORDANCE):
4214            top = min(
4215                top,
4216                cy - (SIGNATURE_GAUGE_TOP_PX + AFFORDANCE_GAP_PX) * scale - AFFORDANCE_LINE_PX * scale,
4217            )
4218        return top
4219
4220    def _top_band_ceiling(self, rect, scale: float) -> float:
4221        """The lowest pixel the top band may reach: the status band, or a stamp.
4222
4223        A message that has been pushed above the ship's cluster because the
4224        stack under the hull was full raises the ceiling too, so the card yields
4225        to the answer rather than being written over it.
4226        """
4227        x, y, w, h = rect
4228        del x, w
4229        ceiling = self.status_band_top(rect, scale)
4230        if self._stamp_remaining > 0.0 and self.is_element_visible(HudElements.STAMP):
4231            ceiling = min(ceiling, y + h * STAMP_TOP_FRACTION)
4232        if self._message_in_top_band(rect, scale):
4233            ceiling = min(ceiling, self.message_row(rect, scale) - MESSAGE_ROW_GAP_PX * scale)
4234        return ceiling
4235
4236    def control_card_layout(self, rect, scale: float) -> CardLayout | None:
4237        """Where the control card fits, or None when there is no card up.
4238
4239        The card yields in three steps rather than overprinting: it moves up,
4240        then it drops its hint line, then it shrinks to a compact title. The
4241        ship's status band never moves for it, because a reading that moves
4242        when a tutorial appears is a reading the pilot has to find again.
4243
4244        An arrival card holds the same slot for its two and a half seconds and
4245        the lesson waits: two panels sharing one band is how the band was lost
4246        in the first place, and a lesson that has been up for a minute can
4247        stand to be off screen while the sector is named.
4248        """
4249        if not self._card_text or not self.is_element_visible(HudElements.CONTROL_CARD):
4250            return None
4251        if self._arrival_remaining > 0.0 and self.is_element_visible(HudElements.ARRIVAL_CARD):
4252            return None
4253        x, y, w, h = rect
4254        preferred = y + h * CONTROL_CARD_TOP_FRACTION
4255        minimum = y + CONTROL_CARD_MIN_TOP_PX * scale
4256        ceiling = self._top_band_ceiling(rect, scale) - CONTROL_CARD_CLEARANCE_PX * scale
4257
4258        hint = CONTROL_CARD_HINT_LINE_HEIGHT_PX * scale if self._card_hint else 0.0
4259        forms = (
4260            (CONTROL_CARD_PADDING_PX, CONTROL_CARD_LINE_HEIGHT_PX, CONTROL_CARD_FONT_SCALE, hint),
4261            (CONTROL_CARD_PADDING_PX, CONTROL_CARD_LINE_HEIGHT_PX, CONTROL_CARD_FONT_SCALE, 0.0),
4262            (CONTROL_CARD_COMPACT_PADDING_PX, CONTROL_CARD_COMPACT_LINE_PX, CONTROL_CARD_COMPACT_FONT_SCALE, 0.0),
4263        )
4264        bar = 0.0 if self._card_progress is None else (CONTROL_CARD_BAR_HEIGHT_PX + CONTROL_CARD_BAR_GAP_PX) * scale
4265        chosen = None
4266        for padding_px, line_px, font, second in forms:
4267            padding = padding_px * scale
4268            line = line_px * scale
4269            width = min(w - 2.0 * padding, CONTROL_CARD_WIDTH_PX * scale)
4270            height = line + second + bar + 2.0 * padding
4271            top = min(preferred, ceiling - height)
4272            chosen = CardLayout(
4273                x=x + (w - width) * 0.5,
4274                y=max(minimum, top),
4275                width=width,
4276                height=height,
4277                padding=padding,
4278                line=line,
4279                font_scale=font,
4280                second_line=second,
4281                second_font_scale=CONTROL_CARD_HINT_FONT_SCALE,
4282                bar=bar,
4283            )
4284            if top >= minimum:
4285                break
4286        if chosen is not None and chosen.bottom > ceiling:
4287            # Nothing fits. A lesson that cannot be drawn without covering the
4288            # hull's own readings steps aside entirely and comes back the
4289            # moment the aim lead lets the hull down the screen again: the
4290            # readings are what keep the pilot alive, and the card is not.
4291            return None
4292        return chosen
4293
4294    def arrival_card_layout(self, rect, scale: float) -> CardLayout | None:
4295        """Where the arrival title card fits, or None when it is not showing.
4296
4297        It owns the top band for its two and a half seconds and yields the same
4298        way the control card does: it drops its flavour line first and then
4299        rides up, so it never lands on the ship's own readings.
4300        """
4301        if self._arrival_remaining <= 0.0 or not self.is_element_visible(HudElements.ARRIVAL_CARD):
4302            return None
4303        x, y, w, h = rect
4304        preferred = y + h * ARRIVAL_CARD_TOP_FRACTION
4305        minimum = y + CONTROL_CARD_MIN_TOP_PX * scale
4306        ceiling = self._top_band_ceiling(rect, scale) - CONTROL_CARD_CLEARANCE_PX * scale
4307
4308        line = ARRIVAL_CARD_LINE_PX * scale
4309        flavour = ARRIVAL_CARD_FLAVOUR_LINE_PX * scale if self._arrival_flavour else 0.0
4310        chosen = None
4311        for second in (flavour, 0.0):
4312            height = line + second
4313            top = min(preferred, ceiling - height)
4314            chosen = CardLayout(
4315                x=x,
4316                y=max(minimum, top),
4317                width=w,
4318                height=height,
4319                padding=0.0,
4320                line=line,
4321                font_scale=ARRIVAL_CARD_FONT_SCALE,
4322                second_line=second,
4323                second_font_scale=ARRIVAL_CARD_FLAVOUR_FONT_SCALE,
4324            )
4325            if top >= minimum:
4326                break
4327        return None if chosen is not None and chosen.bottom > ceiling else chosen
4328
4329    def _draw_control_card(self, renderer, rect, scale: float) -> None:
4330        """Draw the lesson panel: centred, boxed, and in type you cannot miss.
4331
4332        It sits in the upper band of the screen rather than on the centre, so
4333        the panel never covers the ship or the gauge ring around it, and it
4334        yields (:meth:`control_card_layout`) rather than overprinting when the
4335        hull rides high enough to bring its own band up into the same pixels.
4336        """
4337        layout = self.control_card_layout(rect, scale)
4338        if layout is None:
4339            return
4340        renderer.draw_rect(
4341            (layout.x, layout.y), (layout.width, layout.height), colour=COLOUR_CONTROL_CARD_PANEL, filled=True
4342        )
4343        renderer.draw_rect(
4344            (layout.x, layout.y), (layout.width, layout.height), colour=COLOUR_CONTROL_CARD_EDGE, filled=False
4345        )
4346        inner = layout.width - 2.0 * layout.padding
4347        renderer.draw_text(
4348            self._card_text,
4349            rect=(layout.x + layout.padding, layout.y + layout.padding, inner, layout.line),
4350            colour=COLOUR_CONTROL_CARD_TEXT,
4351            scale=layout.font_scale * scale,
4352            alignment="centre",
4353            vertical_alignment="centre",
4354            fit_to_width=True,
4355            min_scale=CONTROL_CARD_MIN_FONT_SCALE * scale,
4356        )
4357        if layout.shows_second_line:
4358            renderer.draw_text(
4359                self._card_hint,
4360                rect=(
4361                    layout.x + layout.padding,
4362                    layout.y + layout.padding + layout.line,
4363                    inner,
4364                    layout.second_line,
4365                ),
4366                colour=COLOUR_CONTROL_CARD_HINT,
4367                scale=layout.second_font_scale * scale,
4368                alignment="centre",
4369                vertical_alignment="centre",
4370                fit_to_width=True,
4371                min_scale=CONTROL_CARD_MIN_FONT_SCALE * 0.7 * scale,
4372            )
4373        if layout.bar > 0.0 and self._card_progress is not None:
4374            height = CONTROL_CARD_BAR_HEIGHT_PX * scale
4375            top = layout.bottom - layout.padding - height
4376            renderer.draw_rect(
4377                (layout.x + layout.padding, top), (inner, height), colour=COLOUR_CONTROL_CARD_BAR_TRACK, filled=True
4378            )
4379            filled = inner * _clamp01(self._card_progress)
4380            if filled > 0.0:
4381                renderer.draw_rect(
4382                    (layout.x + layout.padding, top), (filled, height), colour=COLOUR_CONTROL_CARD_BAR, filled=True
4383                )
4384
4385    def _draw_arrival_card(self, renderer, rect, scale: float) -> None:
4386        """Name the place the ship has just arrived in, once, in large type."""
4387        layout = self.arrival_card_layout(rect, scale)
4388        if layout is None:
4389            return
4390        alpha = min(1.0, self._arrival_remaining / ARRIVAL_CARD_FADE_S)
4391        renderer.draw_text(
4392            self._arrival_name,
4393            rect=(layout.x, layout.y, layout.width, layout.line),
4394            colour=_with_alpha(COLOUR_ARRIVAL_CARD, alpha),
4395            scale=layout.font_scale * scale,
4396            alignment="centre",
4397            vertical_alignment="centre",
4398        )
4399        if layout.shows_second_line:
4400            renderer.draw_text(
4401                self._arrival_flavour,
4402                rect=(layout.x, layout.y + layout.line, layout.width, layout.second_line),
4403                colour=_with_alpha(COLOUR_ARRIVAL_FLAVOUR, alpha),
4404                scale=layout.second_font_scale * scale,
4405                alignment="centre",
4406                vertical_alignment="centre",
4407            )
4408
4409    def _draw_sector_name(self, renderer, rect, scale: float) -> None:
4410        """The standing sector name, top right, opposite the scrap stack."""
4411        if not self._sector_name or not self.is_element_visible(HudElements.SECTOR):
4412            return
4413        x, y, w, _h = rect
4414        margin = READOUT_MARGIN_PX * scale
4415        line = SECTOR_NAME_LINE_PX * scale
4416        width = SECTOR_NAME_BOX_PX * scale
4417        renderer.draw_text(
4418            self._sector_name,
4419            rect=(x + w - margin - width, y + margin, width, line),
4420            colour=COLOUR_READOUT,
4421            scale=SECTOR_NAME_FONT_SCALE * scale,
4422            alignment="right",
4423            vertical_alignment="centre",
4424        )
4425
4426    def _draw_frame_stats(self, renderer, rect, scale: float) -> None:
4427        """The developer readout, top left, over everything and under nothing.
4428
4429        Not subject to the onboarding subtraction schedule and not a HUD element:
4430        it is a measuring instrument, so it appears exactly when it is asked for.
4431        """
4432        lines = self.frame_stats_lines() if self._frame_stats else ()
4433        if not lines:
4434            return
4435        x, y, _w, _h = rect
4436        margin = READOUT_MARGIN_PX * scale
4437        line = FRAME_STATS_LINE_PX * scale
4438        width = FRAME_STATS_WIDTH_PX * scale
4439        renderer.draw_rect(
4440            (x + margin - 6.0 * scale, y + margin - 4.0 * scale),
4441            (width + 12.0 * scale, line * len(lines) + 8.0 * scale),
4442            colour=COLOUR_FRAME_STATS_PLATE,
4443            filled=True,
4444        )
4445        for index, text in enumerate(lines):
4446            renderer.draw_text(
4447                text,
4448                rect=(x + margin, y + margin + index * line, width, line),
4449                colour=COLOUR_FRAME_STATS,
4450                scale=FRAME_STATS_FONT_SCALE * scale,
4451                alignment="left",
4452                vertical_alignment="centre",
4453            )
4454
4455    def _draw_subtitles(self, renderer, rect, scale: float) -> None:
4456        """The caption track: the sounds, on their own plate, at the bottom.
4457
4458        The plate is not decoration. Unbacked captions over a star field read
4459        as debug output that shipped, and a blind pilot filed one as exactly
4460        that. A dark strip behind the words is the convention that says "this
4461        is the caption track", and the speaker glyphs say "this is a sound".
4462        """
4463        if not self._subtitles or not self.is_element_visible(HudElements.SUBTITLES):
4464            return
4465        x, y, w, h = rect
4466        line = SUBTITLE_LINE_HEIGHT_PX * scale
4467        base = y + h - SUBTITLE_BOTTOM_MARGIN_PX * scale - line * len(self._subtitles)
4468
4469        widest = max(self._subtitles, key=lambda entry: len(entry[0]))[0]
4470        plate_width = _plate_width(widest, SUBTITLE_FONT_SCALE, scale, SUBTITLE_PLATE_PADDING_X_PX, w)
4471        plate_padding = SUBTITLE_PLATE_PADDING_Y_PX * scale
4472        renderer.draw_rect(
4473            (x + (w - plate_width) * 0.5, base - plate_padding),
4474            (plate_width, line * len(self._subtitles) + 2.0 * plate_padding),
4475            colour=COLOUR_SUBTITLE_PLATE,
4476            filled=True,
4477        )
4478        for index, (text, remaining) in enumerate(self._subtitles):
4479            colour = _with_alpha(COLOUR_SUBTITLE, min(1.0, remaining / 0.4))
4480            renderer.draw_text(
4481                text,
4482                rect=(x, base + index * line, w, line),
4483                colour=colour,
4484                scale=SUBTITLE_FONT_SCALE * scale,
4485                alignment="centre",
4486                vertical_alignment="centre",
4487            )
4488
4489    def _draw_stamp(self, renderer, rect, scale: float) -> None:
4490        if self._stamp_remaining <= 0.0 or not self.is_element_visible(HudElements.STAMP):
4491            return
4492        x, y, w, h = rect
4493        renderer.draw_text(
4494            self._stamp_text,
4495            rect=(x, y + h * STAMP_TOP_FRACTION, w, h * STAMP_HEIGHT_FRACTION),
4496            colour=_with_alpha(COLOUR_STAMP, min(1.0, self._stamp_remaining / STAMP_FADE_S)),
4497            scale=STAMP_FONT_SCALE * scale,
4498            alignment="centre",
4499            vertical_alignment="centre",
4500        )