feat: vessel parts API — topology + per-part resource / thermal / behavioural state#90
Open
jonpepler wants to merge 19 commits into
Open
feat: vessel parts API — topology + per-part resource / thermal / behavioural state#90jonpepler wants to merge 19 commits into
jonpepler wants to merge 19 commits into
Conversation
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).
eb27009 to
1d46704
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
v.topology{ topologySeq, rootFlightId, parts: [...] }with per-partflightId / 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.topologySeqr.resourceFor[flightId]{ resourceName: { amount, maxAmount, flow?, nominalFlow? }, … }for a single part;flow+nominalFlowappear when a module on the part contributes to that resourcetherm.part[flightId]{ temperature, maxTemperature, temperatureK, maxTemperatureK }for a single part;temperature/maxTemperaturein °C, theKvariants in Kelvin (core temp only, no skin temp)v.partState[flightId]{ seq, modules: [{ type, state, ...extras }] }: semantic deployable / activation state per supported modulev.topologyis cached and event-invalidated; consumers subscribe tov.topologySeqand refetch the topology only when the seq ticks.v.partStatecarries its own embedded vessel-levelseqfor consumer-side dedup. Resource and thermal lookups are stateless per-call walks ofvessel.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.topologyare a raw name list and staging is per-partinverseStage, both trivial to group client-side.v.partStatemaps supported modules onto a fixed state vocabulary so consumers branch ontypestrings rather than walking module class hierarchies. Mods that subclass a supported base pick up the same mapping automatically.r.resourceForflow is signed (producers positive, consumers negative) and per-module.nominalFlowis 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.topologySeqbumps on):onVesselChange(Tracking Station "Fly" /[/]between existing vessels)onPartCouple/onPartUndock/onPartDieonVesselCreate/onVesselDestroyonFlightReadyonVesselWasModifiedis deliberately not subscribed; the topology payload is built from prefab bounds, as-assembledorgPos, 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[...].seqbumps on):onStageActivateonVesselWasModifiedonPartCouple/onPartUndock/onPartDieonPartActionUIDismiss(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.partStateadds 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.resourceFormodule-flow dispatchflow)nominalFlow)ModuleDeployableSolarPanelflowRatechargeRate × efficiencyMultModuleGenerator(RTGs / fuel cells)output.rate × efficiency/−input.rate × efficiencyoutput.rate/−input.rateModuleResourceConverter(covers ISRU + drills + fuel cells viaModuleResourceHarvesterinheritance)Ratio × lastTimeFactorRatioModuleEngines(coversModuleEnginesFXvia inheritance)−prop.currentRequirement / fixedDeltaTimeModuleAlternatoroutputRateModuleDataTransmitter(antenna, transmitting only)−packetResourceCost / packetIntervalModuleCommand(probe core / crewed pod)−resHandler.inputResources[].rateModuleReactionWheel(when Active)−resHandler.inputResources[].rateModuleLight(when on)−resourceAmountTelemachusPowerDrain(this mod's data-link antenna)−powerConsumptionMod modules that subclass any of the above pick up the same dispatch through
ispattern matching, so e.g. Near Future Solar's panels flow without further work.v.partStatesupported modulestypesolarPanelModuleDeployableSolarPaneltracking: bool)radiatorModuleDeployableRadiatorantennaModuleDeployableAntennaparachuteModuleParachuteengineModuleEngines(incl.ModuleEnginesFX)flameout: truewhen out of fuel)drillModuleResourceHarvester(viaBaseConverter.IsActivated)cargoBayModuleCargoBaypaired with siblingModuleAnimateGenericlandingGearModuleWheels.ModuleWheelDeployment(stateString)Modules that don't match a supported type are skipped (no raw passthrough; that's what
v.topology.parts[].modulesis 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 viaonPartUndock.Fixes made on this branch
Two live-flight bugs caught and fixed before this PR:
Propellant.currentRequirementis set perFixedUpdate; emitting it directly read ~56× too low against the observed tank drain. Fixed to divide byTimeWarp.fixedDeltaTimewith adt > 0guard.outputRateat 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 (commit00d85d8).Validation
dotnet buildclean (4 pre-existing warnings, 0 errors).v.topology(full part shape includingup/bounds.center/fuelLineTarget),v.partState, and ther.*/therm.*tables.docs/openapi.yaml+docs/api-schema.json, including the fullv.topologyshape withup/bounds.center/fuelLineTargetand ther.resourceForflow/nominalFlowfields.tools/generate-openapi.tsnow strips thesourceFilefield from the committeddocs/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.resourceForsign convention correct across solar, RTG, engine, command pod, alternator, and antenna cases with engine units confirmed against catalog figures;therm.parttracked a full ascent/descent temperature arc;v.partStatetransitions confirmed forsolarPanel,engine, andparachuteincluding thetrackingextra and PAW-dismiss seq bumps; fuel-linefuelLineTargetround-trips correctly.