Grav-tether
A tractor beam: lock / tow / reel a load, or swing a fighter around an anchor.
Thin wrappers over the engine-native tractor (sim.AddTractorConnection) plus the
mission-facing behavior the raw API doesn't provide — mode presets, a reel ramp, the
canonical impulse-only enforcement (cap / snap), and a moving circle-point swing.
Key facts (engine-confirmed): a static tether reels the target fully in, so holding a
load at a distance uses a per-tick rope-toggle; a player hull can be tractor-pulled.
Pair with closest_in_front for nose-aim acquisition (the engine has
no raycast).
Ask grav_tether_has, not grav_tether_get, for is this tethered?
grav_tether_get returns the live engine connection, and a Tow is a rope-toggle:
it deletes that connection whenever the load is inside the rope length and re-adds it
when the load drifts out. So get reads None for most of a perfectly good tow. A
menu gated on it offers Tow to something already under tow and never offers
Release — which is exactly what the shipped Weapons hold-click did. Use get only
when you want the connection object itself (to read .offset).
The offset point is not world-fixed - this module just never passes one
Older notes here said the tractor's offset point is world-fixed. It is measurably
source-relative: AddTractorConnection(host, target, vec3(0,0,200), 0) held a
target at exactly 200u and exactly 0 deg off the host's nose while the host's heading
swung 51 deg. A load tethered here always reels to the source's own position because
this module passes no offset - which is what a tow wants. To bolt something ON to
a hull instead of dragging it behind one, use mount.
API
grav_tether — attach a beam between two space objects to lock / tow / reel a load.
Thin wrappers over the ENGINE-native tractor system (sim.AddTractorConnection /
DeleteTractorConnection / GetTractorConnection) plus the mission-facing behavior
the raw API doesn't provide: the mode presets (lock / tow / reel), a reel ramp, and the
canonical impulse-only enforcement.
Confirmed in-engine (Phase 0 spike, GRAV_TETHER_PLAN.md):
* AddTractorConnection(src, tgt, offset_point, pull_distance) pulls tgt toward
src + offset_point; pull_distance is a rope rest-length (the target
settles at that distance).
* the connection's .offset float is a stiffness dial: 0 = rigid lock,
~5 = a good taut tow, higher = looser/laggier.
* a tether can only hold at impulse — warp (playerThrottle > 1) outruns the
rate-limited pull. Canonical (old-game Arena a28 precedent). Enforced here per
tether: cap (default, governs the source back to impulse) or snap (breaks
the tether and drops the load).
NOTE: the cosmos_dev mock STORES connections but does not simulate the pull, so the physics is engine-verified; the registry / enforcer / reel logic below is Python and IS unit-tested against the mock.
THE OFFSET POINT IS SOURCE-RELATIVE - it rotates with the hull. Engine-measured (LM_TestRange/maps/test_tractor_mount.mast): AddTractorConnection with an offset held a target at exactly 200u and exactly 0 deg off the source's nose through a 51 deg turn. Older notes here and in GRAV_TETHER_PLAN.md called it "world-fixed"; that was only ever true of the case this module uses, because a tow passes NO offset and the load therefore reels to the source's own position. The wrong wording cost a real design decision once. To bolt something ONTO a hull rather than drag it behind one, use sbs_utils.procedural.mount - which shares the engine call but deliberately not this registry, since _enforce_impulse would cap the carrying ship to impulse.
grav_tether_attach(source, target, offset=None, stiffness=0.0, pull_distance=0.0, overspeed=None)
Open (or replace) a tether so source pulls target.
offset - point (relative to source) the target is pulled toward. stiffness - the connection's .offset dial: 0 = rigid lock, ~5 = taut tow. pull_distance - rope rest-length; the target settles at this distance. overspeed - per-tether enforcement mode; None uses the module default. Returns the tractor_connection, or None if either object is missing.
grav_tether_clear_all()
Drop all tethers (fresh mission / test reset).
Drops OUR tethers one by one rather than calling ClearTractorConnections(), which is
global: the engine has a single tractor pool, and other systems build connections in
it that are not tethers. procedural.mount welds a turret to a hull with one, and a
global clear silently unwelded every mount while mount's own bookkeeping went on
insisting they were attached. Deleting only what this module registered keeps the two
uses independent.
Tolerates having no sim: this runs from reset_mission_state(), which can fire with no frame context at all, and dropping our own state must never depend on the engine being there. The engine-side connections die with the old sim regardless.
grav_tether_get(source, target)
Return the live tractor_connection for the pair, or None.
grav_tether_has(source, target)
True if this exact PAIR is tethered — ask this, not :func:grav_tether_get.
grav_tether_get returns the live ENGINE connection, and a Tow is a rope-TOGGLE: it
deletes the connection whenever the load is inside the rope length and re-adds it when
the load drifts out. So get reads None for most of a perfectly good tow, and a UI
gated on it offers "Tow" to something already under tow and never offers "Release".
Use get only when you want the engine object itself (to read .offset).
grav_tether_involves(obj)
True if obj is either end (source or target) of any live tether — for a one-button toggle where the ship may be the puller (tow/reel) or the pulled (swing).
Registry-based, so it is honest during a rope-toggle tow (see :func:grav_tether_has).
grav_tether_lock(source, target, offset=None, overspeed=None)
Rigid grab: target locked onto the source's offset point (cargo, hangar recovery).
grav_tether_mass(obj)
What this object weighs, via the installed provider. Never returns 0.
grav_tether_mass_ratio(source, target)
target mass / source mass. >1 means the LOAD is the heavier end.
The one number the constraints layer turns on: who drags whom, and how much it costs the puller.
grav_tether_reel(source, target, rate=DEFAULT_REEL_RATE, stiffness=DEFAULT_TOW_STIFFNESS, offset=None, overspeed=None)
Reel the load in: start the rope at the current separation and ramp it to 0,
then emit grav_tether_reeled for the caller to hand off (collect / dock).
grav_tether_release(source, target)
Break a single tether (source no longer pulls target). Safe if none exists.
grav_tether_release_all(source)
Break every tether where source is the puller.
grav_tether_release_any(obj)
Release every tether obj is part of, at either end.
grav_tether_rope(source, target, rope_len, stiffness=DEFAULT_TOW_STIFFNESS, overspeed=None)
Hold the target at ~rope_len from the source via a per-tick ROPE-TOGGLE:
beyond rope_len a stiff pull snaps it back to the circle; inside, the tether is
released so it moves free. Engine-confirmed (data harness): a STATIC tether reels
the target fully in regardless of pull_distance (1500 -> ~165), so holding a load
at a distance REQUIRES this toggle (which held 798/801/801 at rope_len=800). Both
Tow (source drags a trailing load) and Swing (anchor holds the ship) are this same
rope-hold — only the source/target roles differ.
grav_tether_set_attach_policy(fn)
Install (or clear with None) the attach veto callback. An attach whose fn(source_id, target_id) returns False is refused (attach returns None).
grav_tether_set_grab_speed_limit(limit)
Refuse a grab on anything moving faster than limit throttle. None = no rule.
grav_tether_set_mass_fn(fn)
Install (or clear with None) the mass provider: fn(id) -> float.
Without one every object weighs :data:DEFAULT_MASS, so the mass rules below all
reduce to "evenly matched" - no gating, no drag. That is deliberate: a library that
guessed at mass would be confidently wrong, and a mission that has not said what
things weigh should get the un-gated behavior it had before.
grav_tether_set_overspeed_default(mode)
Set the module default overspeed mode (cap / snap / off) for new tethers.
grav_tether_set_tow_energy_cost(per_mass_per_tick)
Energy the puller spends per tick, per unit of towed mass. 0 = free.
grav_tether_sources_of(target)
List the source ids currently tethering target (a tow/lock source, or a swing
anchor). Lets a mission see who is working a shared quest target (claim-on-tether).
grav_tether_swing(anchor, ship, rope_len, stiffness=1.0, overspeed=None)
Fighter swing (SECONDARY): hold the ship on a CIRCLE of radius rope_len around the anchor so it orbits on its own throttle. A plain rope-toggle pulls toward the anchor center, which has no centrifugal balance and spirals the ship in (measured 758→663). Instead each tick we aim the pull at the point on the circle at the ship's CURRENT bearing — a purely radial correction that holds the radius without killing tangential motion. Engine-confirmed a player hull can be tractor-pulled; final feel is a fly-it.
grav_tether_target_too_fast(target)
Whether this target is moving too fast to get hold of.
grav_tether_tick(t=None)
Runs on the TickDispatcher (~10 Hz) while any tether is live; also directly callable (tests). Enforces impulse and advances reels; self-heals dead objects.
grav_tether_tow(source, target, distance, stiffness=DEFAULT_TOW_STIFFNESS, overspeed=None)
Trailing tow: hold the load at ~distance from the source via the rope-toggle
(a static tether would reel it fully in). As the source moves, the load trails behind
at that distance - no offset needed here; the drag makes it trail for free.
NOTE the offset point is only "world-fixed" in the sense that THIS module never
passes one. Engine-measured (LM_TestRange/maps/test_tractor_mount.mast):
AddTractorConnection(host, target, vec3(0,0,200), 0) holds the target in the
SOURCE'S BODY FRAME - exactly 200u at exactly 0 deg off the nose while the host's
heading swung 51 deg. Passing no offset is what makes a load reel to the source's own
position, which is what a tow wants and what the "reels fully in regardless of
pull_distance" measurement was really showing. To bolt something ON to a hull rather
than drag it behind one, use :mod:sbs_utils.procedural.mount.