Files
KSP-MissionControl/packages/krpc-client/src/services.ts
T
Mavis e9ebbf17d2 fix: translate PascalCase procedure names to .NET getter/setter convention
The kRPC server (a C# application) exposes C# properties using
.NET accessor naming: a property `UT` on the SpaceCenter service
becomes two procedures named `get_UT` and `set_UT`. A class
property `CelestialBody.Name` becomes `CelestialBody.get_Name`
and `CelestialBody.set_Name`.

We had been calling `SpaceCenter.GetUT()` (PascalCase, no
underscore) and looking up the literal key. That key never
matched, so the cache always returned 'procedure not found' —
even though `get_UT` was sitting right there.

The Python client side-steps this by using snake_case for
everything (`SpaceCenter.ut` -> `SpaceCenter.get_UT`), and
the C# client just uses .NET convention directly. Our
TypeScript/extract code uses the more familiar PascalCase
form, so the cache lookup now does the translation.

The translation is purely a fallback path:
- exact match tried first
- if not found and the name starts with Get/Set, the
  .NET-style `get_X`/`set_X` variant is tried
- works for both top-level ("GetUT") and class-prefixed
  ("CelestialBody.GetName") procedure names

This unblocks the real bridge: `SpaceCenter.GetUT`,
`SpaceCenter.GetActiveVessel`, `SpaceCenter.GetBodies`,
`SpaceCenter.GetVessels`, and all the class methods like
`CelestialBody.GetName`, `Vessel.GetType`, `Orbit.GetApoapsis`
should now resolve to actual kRPC procedures.
2026-06-03 00:11:39 +00:00

249 lines
8.4 KiB
TypeScript

/**
* ServiceCache — a queryable index over a KRPC.GetServices() response.
*
* After connecting to kRPC, the canonical first call is `KRPC.GetServices()`,
* which returns a `KRPC.Services` message describing every service, every
* class, every enum, and every procedure. We decode that into a lookup
* table keyed by (service, procedure) so the service client can ask:
*
* - "what is the return type of SpaceCenter.GetBodies?"
* - "what are the param types of SpaceCenter.CelestialBody.GetName?"
* - "is SpaceCenter.VesselType.Ship == 0?"
*
* The cache is built once after connect, then read-only. It is decoupled
* from the network so it can be unit-tested by feeding in a hand-crafted
* Services message.
*/
import {
decodeKrpcType,
type KrpcType,
type RawKrpcTypeMessage,
} from './types.js';
/** Shape of the KRPC.GetServices() response after protobufjs decoding. */
export interface RawServicesMessage {
services: RawServiceMessage[];
}
export interface RawServiceMessage {
name: string;
procedures: RawProcedureMessage[];
classes: { name: string }[];
enumerations: RawEnumerationMessage[];
}
export interface RawProcedureMessage {
name: string;
parameters: RawParameterMessage[];
returnType: RawKrpcTypeMessage;
returnIsNullable: boolean;
}
export interface RawParameterMessage {
name: string;
type: RawKrpcTypeMessage;
nullable: boolean;
}
export interface RawEnumerationMessage {
name: string;
values: { name: string; value: number }[];
}
export interface ProcedureInfo {
service: string;
name: string;
/** Full procedure name with class prefix, e.g. `CelestialBody.GetName`. */
fullName: string;
returnType: KrpcType;
returnIsNullable: boolean;
parameters: { name: string; type: KrpcType; nullable: boolean }[];
}
/**
* Result of a name lookup. Either we found the proc and we know its
* signature, or we didn't and the caller can decide what to do.
*/
export type ProcedureLookup =
| { found: true; info: ProcedureInfo }
| { found: false };
/**
* Generate the .NET-style variants of a PascalCase procedure name.
* For a top-level procedure like "GetUT" -> ["get_UT"].
* For a class-prefixed one like "CelestialBody.GetName" ->
* ["CelestialBody.get_Name", "get_CelestialBody.Name"].
* (We try the most likely variant first; the second is an extra
* fallback in case the kRPC server ever uses a flat "get_X.Y" form,
* which historical versions have done for some properties.)
*/
function netNameVariants(procedure: string): string[] {
const variants: string[] = [];
const lastDot = procedure.lastIndexOf('.');
if (lastDot < 0) {
// Top-level: "GetUT" -> "get_UT"
if (procedure.startsWith('Get') && procedure.length > 3) {
variants.push(`get_${procedure.slice(3)}`);
} else if (procedure.startsWith('Set') && procedure.length > 3) {
variants.push(`set_${procedure.slice(3)}`);
}
} else {
// Class-prefixed: "CelestialBody.GetName" -> "CelestialBody.get_Name"
const prefix = procedure.slice(0, lastDot);
const method = procedure.slice(lastDot + 1);
if (method.startsWith('Get') && method.length > 3) {
variants.push(`${prefix}.get_${method.slice(3)}`);
} else if (method.startsWith('Set') && method.length > 3) {
variants.push(`${prefix}.set_${method.slice(3)}`);
}
}
return variants;
}
export class ServiceCache {
/** "SpaceCenter.CelestialBody.GetName" -> ProcedureInfo */
private byFullName = new Map<string, ProcedureInfo>();
/** "SpaceCenter" -> "SpaceCenter" (just the service name) */
private services = new Set<string>();
/** "SpaceCenter.VesselType" -> "Ship" (enum name) -> int value */
private enumValues = new Map<string, Map<string, number>>();
/** "SpaceCenter" -> "VesselType" (enum name) -> Map<name, value> */
private enumsByService = new Map<string, Map<string, Map<string, number>>>();
constructor(raw: RawServicesMessage) {
for (const svc of raw.services ?? []) {
this.services.add(svc.name);
for (const proc of svc.procedures ?? []) {
// proc.name already includes the class prefix when applicable
// (e.g. "CelestialBody.GetName"), per the kRPC wire format.
const info: ProcedureInfo = {
service: svc.name,
name: proc.name,
fullName: `${svc.name}.${proc.name}`,
returnType: decodeKrpcType(proc.returnType),
returnIsNullable: !!proc.returnIsNullable,
parameters: (proc.parameters ?? []).map((p) => ({
name: p.name,
type: decodeKrpcType(p.type),
nullable: !!p.nullable,
})),
};
this.byFullName.set(info.fullName, info);
}
for (const e of svc.enumerations ?? []) {
const m = new Map<string, number>();
for (const v of e.values ?? []) {
m.set(v.name, v.value);
}
const fq = `${svc.name}.${e.name}`;
this.enumValues.set(fq, m);
let inner = this.enumsByService.get(svc.name);
if (!inner) {
inner = new Map();
this.enumsByService.set(svc.name, inner);
}
inner.set(e.name, m);
}
}
}
/** All known service names, e.g. ["KRPC", "SpaceCenter", "KerbalAlarmClock", ...]. */
serviceNames(): string[] {
return [...this.services].sort();
}
/**
* All procedure full names in a service, e.g.
* ["SpaceCenter.GetUT", "SpaceCenter.CelestialBody.GetName", ...].
*/
proceduresInService(service: string): string[] {
const out: string[] = [];
for (const info of this.byFullName.values()) {
if (info.service === service) out.push(info.fullName);
}
return out.sort();
}
/**
* Look up a procedure by `service.procedure` (e.g. "SpaceCenter.GetUT") or
* by the class-prefixed form ("SpaceCenter.CelestialBody.GetName").
*
* The kRPC server exposes C# properties using .NET naming conventions:
* a property `UT` on the SpaceCenter service becomes two procedures,
* `get_UT` and `set_UT`. Class properties like `CelestialBody.Name`
* become `CelestialBody.get_Name` and `CelestialBody.set_Name`.
* We accept the more familiar PascalCase form as a fallback, both
* for top-level procedures (GetUT -> get_UT) and class-prefixed
* ones (CelestialBody.GetName -> CelestialBody.get_Name).
*/
lookup(service: string, procedure: string): ProcedureLookup {
const direct = this.byFullName.get(`${service}.${procedure}`);
if (direct) return { found: true, info: direct };
// PascalCase -> .NET-style fallback. We try both the simple form
// (GetUT -> get_UT) and the class-prefixed form
// (CelestialBody.GetName -> CelestialBody.get_Name) so user
// code can use either convention.
for (const variant of netNameVariants(procedure)) {
const hit = this.byFullName.get(`${service}.${variant}`);
if (hit) return { found: true, info: hit };
}
return { found: false };
}
/**
* Resolve an enum value name to its int code. e.g.
* getEnumValue("SpaceCenter", "VesselType", "Ship") -> 0
* Throws if the enum or value is unknown.
*/
getEnumValue(service: string, enumName: string, valueName: string): number {
const m = this.enumValues.get(`${service}.${enumName}`);
if (!m) {
throw new Error(`unknown enum ${service}.${enumName}`);
}
const v = m.get(valueName);
if (v === undefined) {
throw new Error(`unknown value ${valueName} for ${service}.${enumName}`);
}
return v;
}
/**
* Inverse: resolve an int code to a value name. Returns null if the
* code is not in the enum's range — kRPC may add new values in newer
* versions, so callers should be defensive.
*/
getEnumName(
service: string,
enumName: string,
valueCode: number,
): string | null {
const m = this.enumValues.get(`${service}.${enumName}`);
if (!m) return null;
for (const [n, v] of m.entries()) {
if (v === valueCode) return n;
}
return null;
}
/**
* All enum value names for an enum, e.g.
* getEnumNames("SpaceCenter", "VesselType")
* -> ["Ship", "Station", "Lander", "Probe", ...]
*/
getEnumNames(service: string, enumName: string): string[] {
const m = this.enumValues.get(`${service}.${enumName}`);
if (!m) return [];
return [...m.keys()].sort();
}
/**
* Count the number of distinct procedures across all services.
* Useful for tests and diagnostics.
*/
procedureCount(): number {
return this.byFullName.size;
}
}