Skip to content

feat: vessel parts API — topology + per-part resource / thermal / behavioural state#90

Open
jonpepler wants to merge 19 commits into
TeaGuild:mainfrom
jonpepler:telemachus/parts-topology
Open

feat: vessel parts API — topology + per-part resource / thermal / behavioural state#90
jonpepler wants to merge 19 commits into
TeaGuild:mainfrom
jonpepler:telemachus/parts-topology

Conversation

@jonpepler
Copy link
Copy Markdown
Contributor

@jonpepler jonpepler commented May 28, 2026

Summary

A native parts-API surface for clients that need to reason about vessel structure, built around a structural snapshot plus targeted per-part live lookups rather than one firehose stream:

Key Returns
v.topology Active vessel structure: { topologySeq, rootFlightId, parts: [...] } with per-part flightId / persistentId / parentFlightId / name / title / manufacturer / category / inverseStage / crewCapacity / maxTemp / crashTolerance / dryMass / orgPos[x,y,z] / up[x,y,z] / bounds.size{x,y,z} / bounds.center{x,y,z} / fuelLineTarget / modules[]
v.topologySeq Monotonic int, bumps on every structural change
r.resourceFor[flightId] Live { resourceName: { amount, maxAmount, flow?, nominalFlow? }, … } for a single part; flow + nominalFlow appear when a module on the part contributes to that resource
therm.part[flightId] Live { temperature, maxTemperature, temperatureK, maxTemperatureK } for a single part; temperature/maxTemperature in °C, the K variants in Kelvin (core temp only, no skin temp)
v.partState[flightId] Live { seq, modules: [{ type, state, ...extras }] }: semantic deployable / activation state per supported module

v.topology is cached and event-invalidated; consumers subscribe to v.topologySeq and refetch the topology only when the seq ticks. v.partState carries its own embedded vessel-level seq for consumer-side dedup. Resource and thermal lookups are stateless per-call walks of vessel.parts.

Why this shape

Position uses Part.orgPos, not the live transform, so topology represents the vessel as assembled rather than wobbling with simulation state. Bounds are computed from the prefab (PartGeometryUtil.MergeBounds) and cached per part name, which avoids world-AABB inflation from rotation and joint flex. Trade-off: mid-extend deployables don't bump the bounds.

Resources and thermals are separate per-part keys rather than embedded in topology so topology can be cached across the quiet stretches between staging events. Modules in v.topology are a raw name list and staging is per-part inverseStage, both trivial to group client-side.

v.partState maps supported modules onto a fixed state vocabulary so consumers branch on type strings rather than walking module class hierarchies. Mods that subclass a supported base pick up the same mapping automatically.

r.resourceFor flow is signed (producers positive, consumers negative) and per-module. nominalFlow is omitted when it can't be computed cheaply rather than guessed. Each module dispatch is wrapped in a try/catch so one misbehaving module doesn't affect the rest of the response.

Invalidation

Topology (v.topologySeq bumps on):

  • onVesselChange (Tracking Station "Fly" / [ / ] between existing vessels)
  • onPartCouple / onPartUndock / onPartDie
  • onVesselCreate / onVesselDestroy
  • onFlightReady

onVesselWasModified is deliberately not subscribed; the topology payload is built from prefab bounds, as-assembled orgPos, parent links and the static module-name list, none of which change with deployable state, ignition, arming, or crew transfer. Subscribing to it bumped the seq dozens of times per flight without the payload changing.

Part state (v.partState[...].seq bumps on):

  • onStageActivate
  • onVesselWasModified
  • onPartCouple / onPartUndock / onPartDie
  • onPartActionUIDismiss (right-click menu closed; player likely just acted)

Both caches rebuild lazily: handlers just bump the seq and drop the cache; the next read does the walk. v.partState adds a 10s backstop that force-invalidates if no real event has fired, covering player toggles (G key, custom action groups) that don't surface as global GameEvents on this KSP version. Worst-case staleness for non-event transitions is therefore ~10s.

r.resourceFor module-flow dispatch
Module Current rate (flow) Nominal cap (nominalFlow) Sign
ModuleDeployableSolarPanel flowRate chargeRate × efficiencyMult + (producer)
ModuleGenerator (RTGs / fuel cells) output.rate × efficiency / −input.rate × efficiency output.rate / −input.rate + outputs / − inputs
ModuleResourceConverter (covers ISRU + drills + fuel cells via ModuleResourceHarvester inheritance) Ratio × lastTimeFactor Ratio + outputs / − inputs
ModuleEngines (covers ModuleEnginesFX via inheritance) −prop.currentRequirement / fixedDeltaTime omitted − (consumer)
ModuleAlternator outputRate omitted + (producer, EC only)
ModuleDataTransmitter (antenna, transmitting only) −packetResourceCost / packetInterval same − (consumer, EC)
ModuleCommand (probe core / crewed pod) −resHandler.inputResources[].rate same − (consumer)
ModuleReactionWheel (when Active) −resHandler.inputResources[].rate same − (consumer; approximation, KSP doesn't expose live draw)
ModuleLight (when on) −resourceAmount same − (consumer, EC)
TelemachusPowerDrain (this mod's data-link antenna) −powerConsumption omitted − (consumer, EC)

Mod modules that subclass any of the above pick up the same dispatch through is pattern matching, so e.g. Near Future Solar's panels flow without further work.

v.partState supported modules
type Source module State vocabulary used
solarPanel ModuleDeployableSolarPanel extended / retracted / deploying / retracting / broken (+ tracking: bool)
radiator ModuleDeployableRadiator extended / retracted / deploying / retracting / broken
antenna ModuleDeployableAntenna extended / retracted / deploying / retracting / broken
parachute ModuleParachute stowed / armed / deploying / extended / broken
engine ModuleEngines (incl. ModuleEnginesFX) active / inactive (+ flameout: true when out of fuel)
drill ModuleResourceHarvester (via BaseConverter.IsActivated) active / inactive
cargoBay ModuleCargoBay paired with sibling ModuleAnimateGeneric extended / retracted / deploying / retracting
landingGear ModuleWheels.ModuleWheelDeployment (stateString) extended / retracted / deploying / retracting / broken

Modules that don't match a supported type are skipped (no raw passthrough; that's what v.topology.parts[].modules is for). Unrecognised FSM states map to "unknown" rather than guessing. Decouplers were dropped from the v1 list: a one-shot binary the topology seq already covers via onPartUndock.

Fixes made on this branch

Two live-flight bugs caught and fixed before this PR:

  • Engine flow was units-per-frame, not units-per-sec. Propellant.currentRequirement is set per FixedUpdate; emitting it directly read ~56× too low against the observed tank drain. Fixed to divide by TimeWarp.fixedDeltaTime with a dt > 0 guard.
  • Alternator emitted ghost EC after flameout. KSP leaves outputRate at its last non-zero value once an engine stops. The alternator dispatch now gates on a live sibling-engine check (EngineIgnited && !flameout), so the row drops when thrust stops (commit 00d85d8).

Validation

  • dotnet build clean (4 pre-existing warnings, 0 errors).
  • README sections added for v.topology (full part shape including up / bounds.center / fuelLineTarget), v.partState, and the r.* / therm.* tables.
  • OpenAPI regenerated — all five keys present in docs/openapi.yaml + docs/api-schema.json, including the full v.topology shape with up / bounds.center / fuelLineTarget and the r.resourceFor flow / nominalFlow fields.
  • tools/generate-openapi.ts now strips the sourceFile field from the committed docs/api-schema.json; it carried a machine-local absolute path and produced large per-rebuild diffs.

Exercised live against running KSP: topology seq held flat through action-group events and only bumped on genuine structure changes (dock, stage decouple); r.resourceFor sign convention correct across solar, RTG, engine, command pod, alternator, and antenna cases with engine units confirmed against catalog figures; therm.part tracked a full ascent/descent temperature arc; v.partState transitions confirmed for solarPanel, engine, and parachute including the tracking extra and PAW-dismiss seq bumps; fuel-line fuelLineTarget round-trips correctly.

jonpepler added 19 commits May 14, 2026 01:40
Adds a small native parts API for ship-map / part-list consumers
that doesn't force streaming live per-part state or scraping
transforms:

  - v.topology — cached structural snapshot of the active vessel
    (rootFlightId + per-part flightId/persistentId/parentFlightId,
    name, title, manufacturer, category, inverseStage, crewCapacity,
    maxTemp, crashTolerance, dryMass, orgPos, prefab-bounds size,
    raw modules[]). Lazy rebuild; invalidated on onVesselWasModified
    / onPartCouple / onPartUndock / onPartDie / onVesselCreate /
    onVesselDestroy / onFlightReady.
  - v.topologySeq — monotonic int bumped on every invalidation,
    so consumers can subscribe to a lightweight key and refetch
    v.topology only when the seq ticks.
  - r.resourceFor[flightId] — live per-part resources keyed by
    the same flightId v.topology emits.
  - therm.part[flightId] — live per-part thermal state, core
    temperature only (skin temp deferred).

Position uses Part.orgPos rather than the live partTransform so
topology stays a structural snapshot rather than a wobbly slice
of simulation state. Bounds use the prefab via
PartGeometryUtil.MergeBounds(GetPartRendererBounds(prefab),
prefab.transform).size, cached per AvailablePart name — stable
across the session, no jitter from joint flex, accepts the
trade-off that animated deployables won't bump the size.

Modules are a raw passthrough string[] so consumers can identify
engines / RCS / docking ports / science / mods without backend
opinion. Staging is per-part inverseStage; grouping derives
trivially on the consumer.
Rebuilt the schema + openapi to pick up v.topology, v.topologySeq,
r.resourceFor and therm.part.

tools/generate-openapi.ts also now omits the sourceFile field from
the committed docs/api-schema.json. The field is populated with an
absolute path from the build machine, so it differs between
contributors and produces a large per-rebuild diff on the committed
artifact. Nothing in the docs site consumes it; keeping it out of
the committed JSON keeps the review surface focused on actual
schema changes. The field is still present on the in-memory entries
the generator works with.
Adds the Vessel topology block under v.* with the per-part field
table, plus r.resourceFor[flightId] in the r.* table and
therm.part[flightId] in the therm.* table.
Hooks GameEvents.onVesselChange so v.topologySeq bumps when the
player switches between existing vessels (Tracking Station Fly,
[ / ] hotkeys). Without it, a station subscribing only to seq
would miss the swap; the topology key itself still rebuilt on
vessel-reference inequality, but the lightweight signal didn't.
Per-part live lookup keyed by flightID. Returns
{ seq, modules: [{ type, state, ...extras }] } where:

  - type is a semantic name (solarPanel / radiator / antenna /
    parachute / engine / drill / cargoBay / landingGear) rather
    than the raw KSP module class name, so consumers and mods
    that subclass ModuleDeployablePart can share rendering logic.
  - state uses a fixed vocabulary (extended / retracted /
    deploying / retracting / stowed / armed / active / inactive /
    broken) mapped from each modules native KSP enum.
  - seq is a vessel-level invalidation counter stamped into every
    response so consumers can dedup unchanged pushes without
    diffing the modules array. No separate v.partStateSeq key
    needed — lookups are per-part.

Cached lazily, invalidated on onStageActivate, onVesselWasModified,
onPartCouple, onPartUndock, onPartDie and onPartActionUIDismiss.
Plus a 10s backstop covering toggles that dont fire a global event
(action-group keys, custom AGs, mid-PAW interactions): worst-case
staleness is therefore ~10s for non-event transitions.

Decoupler intentionally omitted from v1 — the topology seq already
bumps on undock and the per-part state shape doesnt fit a one-shot
binary as naturally as solars / radiators / engines do.
Extend the per-part live-resource handler with signed flow contributions
from each part's modules. Lets clients render producers vs consumers
per resource (Power Systems dashboards, ISRU efficiency, fuel-flow
visibility) without per-vessel polling or kerboscript glue.

Wire-shape additions on `r.resourceFor[flightId]`:

  flow         signed units/sec
               positive = producing, negative = consuming
               summed across the part's modules
  nominalFlow  100%-efficiency cap, same sign
               omitted when no module supplies it
               omitted when equal to flow

Module coverage (dispatched in AddModuleFlow):

  ModuleDeployableSolarPanel — chargeRate × efficiencyMult / flowRate
  ModuleGenerator             — resHandler.{output,input}Resources × efficiency
  ModuleResourceConverter     — outputList / inputList × lastTimeFactor
                              (ModuleResourceHarvester inherits, no second case)
  ModuleEngines               — propellants[].currentRequirement
                                (ModuleEnginesFX inherits, no second case)

Rows are emitted for resources a part contributes flow to even when the
part stores none — an RTG without an EC battery still reports
ElectricCharge with amount/maxAmount = 0 plus flow / nominalFlow.

Engines mark the row's nominal as incomplete so the client doesn't
compare a partial nominal against a full flow total. v1 trade-off: at
full throttle, nominal == flow and we omit it anyway; for partial
throttle, the client shows flow only and skips the efficiency readout.

Module dispatch is wrapped in try/catch per module so one bad cast
doesn't crater the whole payload.
Propellant.currentRequirement is the resource amount needed for the
current physics FixedUpdate — i.e. units-per-frame at the KSP physics
rate (~50 Hz), not units-per-second.

Every other dispatch case in this handler (solar / generator / converter)
emits units-per-second, so the engine row was reading ~50× too small on
the wire. Live-validation against an LV-T45 burn showed reported flow
-0.123 LF/sec while the tank drained ~7 LF/sec — a clean factor-of-50
match for the 0.02s physics step.

Fix: divide by TimeWarp.fixedDeltaTime (with a dt > 0 guard) so the
emitted flow matches the units/sec convention used elsewhere.

Caught during the 2026-05-15 live validation pass.
Topology output is built from prefab bounds (cached per AvailablePart),
the as-assembled orgPos, parent links, and the static module name list.
None of those change with deployable state, engine ignition, parachute
arming, or crew transfer — yet onVesselWasModified fires on all of them.

Live validation 2026-05-15 saw v.topologySeq bump 83 → 198 across one
suborbital flight, the bulk of which came from solar deploy, engine
ignition, and parachute arming — none of which actually altered the
serialised payload. Downstream consumers (seq-driven topology refetch
in @gonogo/data) refetched dozens of times for identical payloads.

Limit subscriptions to the events that genuinely change topology
output: onVesselChange (active-vessel swap), onPartCouple,
onPartUndock, onPartDie, onVesselCreate, onVesselDestroy, onFlightReady.

partState's separate seq still subscribes to onVesselWasModified +
onPartActionUIDismiss — those events DO change partState output, so
that handler keeps the noisier subscription set.
Live validation 2026-05-15 found PowerSystems showing 'No active flow'
on a craft whose F12 menu reported ~0.04 EC/s drain per antenna. The
v1 dispatch covered solar panels / generators / converters / engines
only — leaving stock modules that draw or produce EC outside the
balance.

New dispatch cases:

- ModuleAlternator — engine-paired EC producer. outputRate is the
  live computed value, hardcoded resource ElectricCharge (the only
  thing vanilla alternators emit). nominalFlow omitted (no public
  catalog-max field).
- ModuleDataTransmitter — stock antenna EC draw during data transmit.
  Only emits a row when IsBusy(); idle antennas contribute nothing.
  Rate is packetResourceCost / packetInterval.
- ModuleCommand — pod/probe-core EC draw via resHandler.inputResources.
  Hibernating cores emit nothing (KSP zeroes their draw when hibernated).
- ModuleReactionWheel — active reaction wheel EC draw via
  resHandler.inputResources. Approximated as catalog-max (the live
  intensity-scaled rate isn't on a public field); client can read
  the row as a worst-case ceiling.
- ModuleLight — active light bank EC draw via resourceAmount.
- TelemachusPowerDrain — this mod's own antenna draw. powerConsumption
  is the live EC/sec set inside OnUpdate based on link state.

Each case emits a signed flow + nominalFlow following the existing
convention (positive = produces, negative = consumes; nominalFlow
omitted when KSP doesn't expose a catalog-max). Per-module try/catch
in the dispatcher still wraps everything, so a misbehaving module
on any part won't crater the response.
ModuleAlternator.outputRate is the live computed EC production rate
from KSP's engine alternator. KSP keeps this field at its last-
non-zero value after the engine flames out — so without an explicit
gate, the dispatch keeps emitting ghost EC flow long after the
engine has stopped thrusting.

Observed live during the 2026-05-15 staging test: a flamed-out
LV-T45 on a 1-part debris vessel reported +4.70 EC/s indefinitely.
The engine's v.partState correctly read 'flameout: true' but
r.resourceFor still showed alternator production.

Fix: walk the part's sibling modules; only emit the alternator row
when there's a ModuleEngines on the same part with EngineIgnited
&& !flameout. Matches the engine-pairing check that was in the
v1 draft and got incorrectly simplified in 27d14de.

Caught by validation; ghost-EC reading reproduced cleanly post-
flameout, gate restores correctness.
part.orgRot * Vector3.up gives each part's local +up direction in the
vessel's assembly frame — [0, 1, 0] for axially-stacked parts (most),
[±1, 0, 0] or [0, 0, ±1] for radially mounted parts (docking ports,
side nose cones, radial decouplers), and a negative-Y component for
inverted parts. Consumers can orient shapes / thrust arrows / fuel-line
directions without inferring orientation from neighbour position. The
new 'up' field is appended to the per-part dictionary in v.topology;
existing fields untouched. v.topologySeq doesn't bump on its own —
orgRot is captured during assembly and frozen for the session, so the
existing structural events already cover invalidation.
CModuleFuelLine inherits CompoundPartModule whose .target points at
the receiving tank — the 'to' end of the line. The 'from' end is
already discoverable via parentFlightId. Resolving target here keeps
the wire format flat (sibling of parentFlightId) and lets Ship Map's
flow-direction renderer draw arrows without walking modules to fish
out the linkage. null for non-fuel-line parts.
For radial-mount parts (radial decouplers, surface ladders, brackets)
the prefab mesh is not centred on the attach-node anchor. orgPos is
the anchor; the mesh extends a non-symmetric distance from it. Clients
that centred the body box on orgPos got the mesh visually 'sunken'
into the parent stack.

PartGeometryUtil.MergeBounds returns both .size and .center — we cached
only .size until now. Add .center alongside in the per-part payload.
Existing consumers can ignore it (default zero is correct for the
axially-stacked majority); the Ship Map renderer uses it to position
the body box at orgPos + rotated(boundsCenter).
@jonpepler jonpepler force-pushed the telemachus/parts-topology branch from eb27009 to 1d46704 Compare May 29, 2026 00:29
@jonpepler jonpepler marked this pull request as ready for review May 29, 2026 00:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant