phase 1c-extract: typed kRPC service client + SpaceCenter extract
Built the missing piece that connects the ksp-bridge to a real KSP instance via kRPC. This adds a typed service client on top of the existing KRPCClient, plus the SpaceCenter-specific extraction logic that pulls the universe state from a running KSP save. @kerbal-rt/krpc-client - types.ts — runtime representation of kRPC Type descriptors (TypeCode enum, KrpcType interface, decodeKrpcType, typeName) - decoder.ts — kRPC value codec: primitive decode/encode, class refs, enums, collections (LIST/SET/TUPLE/DICTIONARY), system messages (STATUS/SERVICES/STREAM/EVENT/PROCEDURE_CALL). 34 unit tests. - services.ts — ServiceCache built from KRPC.GetServices() response. Lookup by (service, procedure), enum value/name resolution. 12 tests. - service-client.ts — KrpcServices: high-level invoke-by-name client. loadServices() helper to connect + load catalog. 9 integration tests with a mock kRPC server. - schema.ts — added Set/Dictionary/Event/Expression types so the decoder can handle system messages without external .proto files. Also fixed a bug where 'STREAM' was being encoded as 0 due to protobufjs's nested-enum string lookup. ksp-bridge - extract.ts — the actual SpaceCenter calls. ~280 procedure calls per poll for a typical KSP save (UT, bodies, vessels, then per-body and per-vessel class methods in parallel). Build the UniverseSnapshot. - krpc-adapter.ts — rewrote to use KrpcServices (typed) instead of the stub extract function. Supports an optional injected services for testing. - bridge.ts — uses the new ExtractedState type and buildSnapshot. - index.ts — connects to kRPC; falls back to mock mode if no server. - convert.ts — backward-compat shim, re-exports from extract.ts. The ksp-bridge can now talk to a real KSP install. We do NOT need the kRPC mod's .proto files on disk — the server's GetServices() response is the source of truth for type info. Documented the full list of procedures we call, the kRPC value encoding, and the new architecture in ksp/README.md. Tests: 119 total, all green. Typecheck and build clean across all 11 projects. Bonus: fixed an integer-overflow bug in the krpc-client connect() handshake (was passing 'RPC'/'STREAM' strings to protobufjs; its nested-enum string lookup silently encodes as 0, which made the stream handshake send the wrong type. Switched to numeric codes.)
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Test-only encoding helpers for the value encoding tests.
|
||||
*
|
||||
* These are exact mirrors of the kRPC wire encoding for the primitive
|
||||
* types we use in test fixtures. Imported by the integration test that
|
||||
* drives a mock kRPC server.
|
||||
*
|
||||
* DO NOT use these in production code; use `encodeValue` from decoder.ts
|
||||
* which dispatches based on the KrpcType descriptor.
|
||||
*/
|
||||
import { encodeVarint } from './connection.js';
|
||||
|
||||
export function encodeDouble(v: number): Uint8Array {
|
||||
const out = new Uint8Array(8);
|
||||
new DataView(out.buffer).setFloat64(0, v, true);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function encodeFloat(v: number): Uint8Array {
|
||||
const out = new Uint8Array(4);
|
||||
new DataView(out.buffer).setFloat32(0, v, true);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function encodeSint32(v: number): Uint8Array {
|
||||
return encodeVarint(((v << 1) ^ (v >> 31)) >>> 0);
|
||||
}
|
||||
|
||||
export function encodeUint32(v: number): Uint8Array {
|
||||
return encodeVarint(v);
|
||||
}
|
||||
|
||||
export function encodeUint64(v: bigint): Uint8Array {
|
||||
const out: number[] = [];
|
||||
let x = v;
|
||||
while (x >= 0x80n) {
|
||||
out.push(Number((x & 0x7fn) | 0x80n));
|
||||
x >>= 7n;
|
||||
}
|
||||
out.push(Number(x));
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
export function encodeString(v: string): Uint8Array {
|
||||
const utf8 = new TextEncoder().encode(v);
|
||||
return Buffer.concat([encodeVarint(utf8.length), Buffer.from(utf8)]);
|
||||
}
|
||||
|
||||
export function encodeBool(v: boolean): Uint8Array {
|
||||
return new Uint8Array([v ? 1 : 0]);
|
||||
}
|
||||
@@ -64,8 +64,12 @@ export class KRPCClient {
|
||||
this.opts.rpcPort,
|
||||
this.opts.connectTimeoutMs,
|
||||
);
|
||||
// The ConnectionRequest.Type enum has RPC = 0, STREAM = 1. We pass
|
||||
// the numeric value directly because the nested-enum name lookup
|
||||
// is brittle across protobufjs versions when the enum is nested
|
||||
// inside the message.
|
||||
sendMessage(this.rpcSocket, KRPC.ConnectionRequest, {
|
||||
type: 'RPC',
|
||||
type: 0, // RPC
|
||||
clientName: this.opts.clientName,
|
||||
});
|
||||
const resp = decodeMessage<{
|
||||
@@ -86,7 +90,7 @@ export class KRPCClient {
|
||||
this.opts.connectTimeoutMs,
|
||||
);
|
||||
sendMessage(this.streamSocket, KRPC.ConnectionRequest, {
|
||||
type: 'STREAM',
|
||||
type: 1, // STREAM
|
||||
clientIdentifier: this.clientIdentifier,
|
||||
});
|
||||
const streamResp = decodeMessage<{ status: number | string; message: string }>(
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* kRPC value decoder.
|
||||
*
|
||||
* kRPC values are encoded on the wire using a hybrid scheme:
|
||||
*
|
||||
* - For **primitive types** (DOUBLE, FLOAT, SINT32, SINT64, UINT32, UINT64,
|
||||
* BOOL, STRING, BYTES) the bytes are exactly the standard protobuf wire
|
||||
* encoding of that single value, NOT wrapped in a message. So a `double`
|
||||
* is just 8 little-endian bytes, a `string` is `[varint length][utf8]`,
|
||||
* and so on.
|
||||
*
|
||||
* - For **CLASS types** the bytes are a single varint-encoded `uint64` —
|
||||
* the object id. An object id of 0 means `None` (the CLASS is nullable).
|
||||
* The actual class data lives server-side; to get a property you call
|
||||
* `Service.ClassName.GetX(id)`.
|
||||
*
|
||||
* - For **ENUMERATION** the bytes are a single signed varint (zigzag-encoded
|
||||
* sint32 in protobuf terms).
|
||||
*
|
||||
* - For **collections** (LIST, SET, TUPLE, DICTIONARY) the bytes are a
|
||||
* serialized `KRPC.List` / `KRPC.Set` / `KRPC.Tuple` / `KRPC.Dictionary`
|
||||
* message. Each element is itself encoded using the scheme above (so the
|
||||
* element bytes are variable-length). A null collection is a single byte
|
||||
* `\x00` (NOT a length-prefixed empty list).
|
||||
*
|
||||
* - For **system messages** (STATUS, SERVICES, STREAM, EVENT,
|
||||
* PROCEDURE_CALL) the bytes are the standard protobuf serialization of
|
||||
* the corresponding KRPC message.
|
||||
*
|
||||
* Reference: the Python client's `krpc/decoder.py` (Krpc 0.5.x).
|
||||
*/
|
||||
import protobuf from 'protobufjs';
|
||||
import { decodeVarint, encodeVarint } from './connection.js';
|
||||
import { TypeCode, type KrpcType, typeName } from './types.js';
|
||||
|
||||
/** Result of decoding a value. */
|
||||
export type DecodedValue =
|
||||
| number
|
||||
| bigint
|
||||
| boolean
|
||||
| string
|
||||
| Uint8Array
|
||||
| null
|
||||
| DecodedValue[]
|
||||
| Set<DecodedValue>
|
||||
| Map<DecodedValue, DecodedValue>
|
||||
| { [k: string]: unknown };
|
||||
|
||||
/**
|
||||
* Decode a value from its wire bytes according to a KrpcType.
|
||||
*
|
||||
* @param type The KrpcType descriptor (from GetServices or a
|
||||
* pre-built cache).
|
||||
* @param data The raw bytes (response.value for returns, or
|
||||
* the value field of a KRPC.Argument for args).
|
||||
* @param messageTypes Optional protobufjs type registry for decoding
|
||||
* system messages (KRPC.Status, etc.) by name.
|
||||
* Only needed for MESSAGE-style TypeCodes.
|
||||
*/
|
||||
export function decodeValue(
|
||||
type: KrpcType,
|
||||
data: Uint8Array,
|
||||
messageTypes?: Record<string, protobuf.Type>,
|
||||
): DecodedValue {
|
||||
switch (type.code) {
|
||||
case TypeCode.NONE:
|
||||
return null;
|
||||
|
||||
case TypeCode.DOUBLE:
|
||||
return decodeDouble(data);
|
||||
case TypeCode.FLOAT:
|
||||
return decodeFloat(data);
|
||||
case TypeCode.SINT32:
|
||||
return decodeSint32(data);
|
||||
case TypeCode.SINT64:
|
||||
return decodeSint64(data);
|
||||
case TypeCode.UINT32:
|
||||
return decodeUint32(data);
|
||||
case TypeCode.UINT64:
|
||||
return decodeUint64(data);
|
||||
case TypeCode.BOOL:
|
||||
return decodeBool(data);
|
||||
case TypeCode.STRING:
|
||||
return decodeString(data);
|
||||
case TypeCode.BYTES:
|
||||
return decodeBytes(data);
|
||||
|
||||
case TypeCode.CLASS: {
|
||||
// The wire form of a CLASS is a uint64 object id; 0 = None.
|
||||
// We decode as a BigInt so callers can pass the id back as an
|
||||
// argument to other class methods.
|
||||
const id = bigFromVarint(data);
|
||||
return id === 0n ? null : id;
|
||||
}
|
||||
|
||||
case TypeCode.ENUMERATION: {
|
||||
// Wire form: a single signed varint (sint32 in protobuf terms).
|
||||
return decodeSint32(data);
|
||||
}
|
||||
|
||||
case TypeCode.STATUS: {
|
||||
return decodeSystemMessage('Status', data, messageTypes);
|
||||
}
|
||||
case TypeCode.SERVICES: {
|
||||
return decodeSystemMessage('Services', data, messageTypes);
|
||||
}
|
||||
case TypeCode.STREAM: {
|
||||
return decodeSystemMessage('Stream', data, messageTypes);
|
||||
}
|
||||
case TypeCode.EVENT: {
|
||||
return decodeSystemMessage('Event', data, messageTypes);
|
||||
}
|
||||
case TypeCode.PROCEDURE_CALL: {
|
||||
return decodeSystemMessage('ProcedureCall', data, messageTypes);
|
||||
}
|
||||
|
||||
case TypeCode.LIST:
|
||||
return decodeList(type, data, messageTypes);
|
||||
case TypeCode.SET:
|
||||
return decodeSet(type, data, messageTypes);
|
||||
case TypeCode.TUPLE:
|
||||
return decodeTuple(type, data, messageTypes);
|
||||
case TypeCode.DICTIONARY:
|
||||
return decodeDictionary(type, data, messageTypes);
|
||||
|
||||
default:
|
||||
throw new Error(`unknown TypeCode ${type.code} (${typeName(type)})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── primitive decoders ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read a fixed-width little-endian IEEE 754 number from a buffer.
|
||||
*
|
||||
* JavaScript's `DataView.getFloat64()` already does the right thing on
|
||||
* little-endian platforms, which Node always is. We still go through
|
||||
* `DataView` so the intent is explicit and the code is portable to
|
||||
* big-endian platforms (if we ever run on one).
|
||||
*/
|
||||
function readFloatLE(buf: Uint8Array, offset: number, bytes: 4 | 8): number {
|
||||
// Pad short reads with zero bytes. This shouldn't happen in practice,
|
||||
// but it's defensive against a truncated response.
|
||||
if (buf.length < offset + bytes) {
|
||||
const padded = new Uint8Array(bytes);
|
||||
padded.set(buf.subarray(offset, offset + bytes));
|
||||
buf = padded;
|
||||
offset = 0;
|
||||
}
|
||||
const view = new DataView(buf.buffer, buf.byteOffset + offset, bytes);
|
||||
return bytes === 8 ? view.getFloat64(0, true) : view.getFloat32(0, true);
|
||||
}
|
||||
|
||||
export function decodeDouble(data: Uint8Array): number {
|
||||
return readFloatLE(data, 0, 8);
|
||||
}
|
||||
|
||||
export function decodeFloat(data: Uint8Array): number {
|
||||
return readFloatLE(data, 0, 4);
|
||||
}
|
||||
|
||||
export function decodeSint32(data: Uint8Array): number {
|
||||
// Protobuf sint32 is zigzag-encoded. `decodeVarint` returns a regular
|
||||
// varint, so we need the zigzag step too.
|
||||
const [raw] = decodeVarint(Buffer.from(data));
|
||||
return zigzagDecode(Number(raw));
|
||||
}
|
||||
|
||||
export function decodeSint64(data: Uint8Array): number {
|
||||
// The Python client decodes sint64 to a plain int (BigInt in their
|
||||
// case is a separate branch). For our use case the values we care
|
||||
// about (enum cases) are well within int32 range, so we decode to
|
||||
// a JS number. If you really need full int64, decode to BigInt.
|
||||
return decodeSint32(data);
|
||||
}
|
||||
|
||||
export function decodeUint32(data: Uint8Array): number {
|
||||
const [v] = decodeVarint(Buffer.from(data));
|
||||
return Number(v);
|
||||
}
|
||||
|
||||
export function decodeUint64(data: Uint8Array): bigint {
|
||||
// Use BigInt for the 64-bit case. The kRPC ObjectId and StreamId are
|
||||
// uint64s and can exceed Number.MAX_SAFE_INTEGER in principle
|
||||
// (though kRPC never generates ids that large in practice).
|
||||
return bigFromVarint(data);
|
||||
}
|
||||
|
||||
export function decodeBool(data: Uint8Array): boolean {
|
||||
if (data.length < 1) return false;
|
||||
return data[0] !== 0;
|
||||
}
|
||||
|
||||
export function decodeString(data: Uint8Array): string {
|
||||
// Wire form: [varint length][utf8 bytes].
|
||||
const buf = Buffer.from(data);
|
||||
const [len, pos] = decodeVarint(buf);
|
||||
return buf.subarray(pos, pos + Number(len)).toString('utf-8');
|
||||
}
|
||||
|
||||
export function decodeBytes(data: Uint8Array): Uint8Array {
|
||||
const buf = Buffer.from(data);
|
||||
const [len, pos] = decodeVarint(buf);
|
||||
return new Uint8Array(buf.subarray(pos, pos + Number(len)));
|
||||
}
|
||||
|
||||
function zigzagDecode(n: number): number {
|
||||
return (n >>> 1) ^ -(n & 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a varint (assumed ≤ 64 bits) as a BigInt. Used for uint64 values
|
||||
* where the precision loss of Number() is unacceptable.
|
||||
*/
|
||||
function bigFromVarint(data: Uint8Array): bigint {
|
||||
let result = 0n;
|
||||
let shift = 0n;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const b = data[i];
|
||||
if (b === undefined) break;
|
||||
result |= BigInt(b & 0x7f) << shift;
|
||||
if ((b & 0x80) === 0) return result;
|
||||
shift += 7n;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── collection decoders ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Decode a KRPC.List message (when its serialized form is provided).
|
||||
* Used internally by `decodeList`. Also exported for tests.
|
||||
*/
|
||||
export function decodeKrpcList(
|
||||
data: Uint8Array,
|
||||
messageType: protobuf.Type,
|
||||
): Uint8Array[] {
|
||||
if (data.length === 1 && data[0] === 0) {
|
||||
// A list may be serialized as a single 0x00 byte to indicate None.
|
||||
return [];
|
||||
}
|
||||
const msg = messageType.decode(data) as unknown as { items: Uint8Array[] };
|
||||
return msg.items ?? [];
|
||||
}
|
||||
|
||||
export function decodeList(
|
||||
type: KrpcType,
|
||||
data: Uint8Array,
|
||||
messageTypes?: Record<string, protobuf.Type>,
|
||||
): DecodedValue[] {
|
||||
if (data.length === 1 && data[0] === 0) return null as unknown as DecodedValue[];
|
||||
const listType = messageTypes?.['List'];
|
||||
if (!listType) {
|
||||
throw new Error('decodeList: no protobufjs type for KRPC.List registered');
|
||||
}
|
||||
const items = decodeKrpcList(data, listType);
|
||||
const elemType = type.types[0];
|
||||
if (!elemType) throw new Error('LIST type missing element type');
|
||||
return items.map((it) => decodeValue(elemType, it, messageTypes));
|
||||
}
|
||||
|
||||
export function decodeSet(
|
||||
type: KrpcType,
|
||||
data: Uint8Array,
|
||||
messageTypes?: Record<string, protobuf.Type>,
|
||||
): Set<DecodedValue> {
|
||||
if (data.length === 1 && data[0] === 0) return null as unknown as Set<DecodedValue>;
|
||||
const setType = messageTypes?.['Set'];
|
||||
if (!setType) {
|
||||
throw new Error('decodeSet: no protobufjs type for KRPC.Set registered');
|
||||
}
|
||||
const msg = setType.decode(data) as unknown as { items: Uint8Array[] };
|
||||
const elemType = type.types[0];
|
||||
if (!elemType) throw new Error('SET type missing element type');
|
||||
return new Set((msg.items ?? []).map((it) => decodeValue(elemType, it, messageTypes)));
|
||||
}
|
||||
|
||||
export function decodeTuple(
|
||||
type: KrpcType,
|
||||
data: Uint8Array,
|
||||
messageTypes?: Record<string, protobuf.Type>,
|
||||
): DecodedValue[] {
|
||||
if (data.length === 1 && data[0] === 0) return null as unknown as DecodedValue[];
|
||||
const tupleType = messageTypes?.['Tuple'];
|
||||
if (!tupleType) {
|
||||
throw new Error('decodeTuple: no protobufjs type for KRPC.Tuple registered');
|
||||
}
|
||||
const msg = tupleType.decode(data) as unknown as { items: Uint8Array[] };
|
||||
return type.types.map((inner, i) =>
|
||||
decodeValue(inner, msg.items[i] ?? new Uint8Array(0), messageTypes),
|
||||
);
|
||||
}
|
||||
|
||||
export function decodeDictionary(
|
||||
type: KrpcType,
|
||||
data: Uint8Array,
|
||||
messageTypes?: Record<string, protobuf.Type>,
|
||||
): Map<DecodedValue, DecodedValue> {
|
||||
if (data.length === 1 && data[0] === 0) {
|
||||
return null as unknown as Map<DecodedValue, DecodedValue>;
|
||||
}
|
||||
const dictType = messageTypes?.['Dictionary'];
|
||||
if (!dictType) {
|
||||
throw new Error('decodeDictionary: no protobufjs type for KRPC.Dictionary registered');
|
||||
}
|
||||
const msg = dictType.decode(data) as unknown as {
|
||||
entries: { key: Uint8Array; value: Uint8Array }[];
|
||||
};
|
||||
const keyType = type.types[0];
|
||||
const valType = type.types[1];
|
||||
if (!keyType || !valType) throw new Error('DICTIONARY type missing key/value types');
|
||||
const out = new Map<DecodedValue, DecodedValue>();
|
||||
for (const e of msg.entries ?? []) {
|
||||
out.set(
|
||||
decodeValue(keyType, e.key, messageTypes),
|
||||
decodeValue(valType, e.value, messageTypes),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function decodeSystemMessage(
|
||||
name: string,
|
||||
data: Uint8Array,
|
||||
messageTypes?: Record<string, protobuf.Type>,
|
||||
): { [k: string]: unknown } {
|
||||
const t = messageTypes?.[name];
|
||||
if (!t) throw new Error(`decodeSystemMessage: no type registered for ${name}`);
|
||||
return t.decode(data) as unknown as { [k: string]: unknown };
|
||||
}
|
||||
|
||||
// ── encoders (mirrors, for sending arguments) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Encode a value into the kRPC wire format for `type`.
|
||||
*
|
||||
* This is the inverse of `decodeValue`. Used by the service client to
|
||||
* serialize procedure arguments. Collections and system messages use the
|
||||
* protobufjs registry the same way the decoder does.
|
||||
*/
|
||||
export function encodeValue(
|
||||
type: KrpcType,
|
||||
value: DecodedValue,
|
||||
messageTypes?: Record<string, protobuf.Type>,
|
||||
): Uint8Array {
|
||||
switch (type.code) {
|
||||
case TypeCode.NONE:
|
||||
return new Uint8Array(0);
|
||||
case TypeCode.DOUBLE:
|
||||
return encodeDouble(value as number);
|
||||
case TypeCode.FLOAT:
|
||||
return encodeFloat(value as number);
|
||||
case TypeCode.SINT32:
|
||||
return encodeSint32(value as number);
|
||||
case TypeCode.SINT64:
|
||||
return encodeSint32(value as number);
|
||||
case TypeCode.UINT32:
|
||||
return encodeUint32(value as number);
|
||||
case TypeCode.UINT64:
|
||||
return encodeUint64(value as bigint);
|
||||
case TypeCode.BOOL:
|
||||
return encodeBool(value as boolean);
|
||||
case TypeCode.STRING:
|
||||
return encodeString(value as string);
|
||||
case TypeCode.BYTES:
|
||||
return encodeBytes(value as Uint8Array);
|
||||
|
||||
case TypeCode.CLASS: {
|
||||
// CLASS args are encoded as uint64 object id; null = 0.
|
||||
if (value === null || value === undefined) return encodeUint64(0n);
|
||||
if (typeof value === 'bigint') return encodeUint64(value);
|
||||
if (typeof value === 'number') return encodeUint64(BigInt(value));
|
||||
throw new Error(`encodeValue: CLASS expects bigint object id, got ${typeof value}`);
|
||||
}
|
||||
|
||||
case TypeCode.ENUMERATION:
|
||||
return encodeSint32(value as number);
|
||||
|
||||
case TypeCode.LIST: {
|
||||
const listType = messageTypes?.['List'];
|
||||
if (!listType) throw new Error('encodeValue: no type for KRPC.List');
|
||||
const elemType = type.types[0];
|
||||
if (!elemType) throw new Error('LIST type missing element type');
|
||||
const items = (value as DecodedValue[]).map((v) => encodeValue(elemType, v, messageTypes));
|
||||
const msg = listType.create({ items });
|
||||
return listType.encode(msg).finish();
|
||||
}
|
||||
case TypeCode.TUPLE: {
|
||||
const tupleType = messageTypes?.['Tuple'];
|
||||
if (!tupleType) throw new Error('encodeValue: no type for KRPC.Tuple');
|
||||
const items = (value as DecodedValue[]).map((v, i) =>
|
||||
encodeValue(type.types[i] as KrpcType, v, messageTypes),
|
||||
);
|
||||
const msg = tupleType.create({ items });
|
||||
return tupleType.encode(msg).finish();
|
||||
}
|
||||
case TypeCode.DICTIONARY: {
|
||||
const dictType = messageTypes?.['Dictionary'];
|
||||
if (!dictType) throw new Error('encodeValue: no type for KRPC.Dictionary');
|
||||
const keyType = type.types[0];
|
||||
const valType = type.types[1];
|
||||
if (!keyType || !valType) throw new Error('DICTIONARY type missing key/value types');
|
||||
const entries: { key: Uint8Array; value: Uint8Array }[] = [];
|
||||
for (const [k, v] of (value as Map<DecodedValue, DecodedValue>).entries()) {
|
||||
entries.push({
|
||||
key: encodeValue(keyType, k, messageTypes),
|
||||
value: encodeValue(valType, v, messageTypes),
|
||||
});
|
||||
}
|
||||
const msg = dictType.create({ entries });
|
||||
return dictType.encode(msg).finish();
|
||||
}
|
||||
case TypeCode.SET: {
|
||||
// kRPC 0.5 doesn't really use SET much; if needed we'd encode as
|
||||
// KRPC.Set. For now, only LIST/TUPLE/DICT are exercised by our
|
||||
// SpaceCenter calls.
|
||||
throw new Error('encodeValue: SET not implemented (kRPC 0.5 does not use it)');
|
||||
}
|
||||
case TypeCode.STATUS:
|
||||
case TypeCode.SERVICES:
|
||||
case TypeCode.STREAM:
|
||||
case TypeCode.EVENT:
|
||||
case TypeCode.PROCEDURE_CALL:
|
||||
throw new Error(`encodeValue: system message ${typeName(type)} cannot be sent as argument`);
|
||||
|
||||
default:
|
||||
throw new Error(`encodeValue: unknown TypeCode ${type.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
function encodeDouble(v: number): Uint8Array {
|
||||
const out = new Uint8Array(8);
|
||||
new DataView(out.buffer).setFloat64(0, v, true);
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeFloat(v: number): Uint8Array {
|
||||
const out = new Uint8Array(4);
|
||||
new DataView(out.buffer).setFloat32(0, v, true);
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeSint32(v: number): Uint8Array {
|
||||
return encodeVarint(zigzagEncode(v));
|
||||
}
|
||||
|
||||
function encodeUint32(v: number): Uint8Array {
|
||||
return encodeVarint(v);
|
||||
}
|
||||
|
||||
function encodeUint64(v: bigint): Uint8Array {
|
||||
// Manual varint encoding for BigInt to avoid Number truncation.
|
||||
const out: number[] = [];
|
||||
let x = v;
|
||||
while (x >= 0x80n) {
|
||||
out.push(Number((x & 0x7fn) | 0x80n));
|
||||
x >>= 7n;
|
||||
}
|
||||
out.push(Number(x));
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
function encodeBool(v: boolean): Uint8Array {
|
||||
return new Uint8Array([v ? 1 : 0]);
|
||||
}
|
||||
|
||||
function encodeString(v: string): Uint8Array {
|
||||
const utf8 = new TextEncoder().encode(v);
|
||||
return Buffer.concat([encodeVarint(utf8.length), Buffer.from(utf8)]);
|
||||
}
|
||||
|
||||
function encodeBytes(v: Uint8Array): Uint8Array {
|
||||
return Buffer.concat([encodeVarint(v.length), Buffer.from(v)]);
|
||||
}
|
||||
|
||||
function zigzagEncode(n: number): number {
|
||||
return (n << 1) ^ (n >> 31);
|
||||
}
|
||||
@@ -7,10 +7,16 @@
|
||||
* - sendMessage / recvMessage / recvRawMessage / encodeVarint / decodeVarint:
|
||||
* low-level wire-format helpers
|
||||
* - KRPC namespace: protobufjs types for the kRPC meta-protocol
|
||||
* - KrpcType / TypeCode: runtime representation of kRPC type descriptors
|
||||
* - decodeValue / encodeValue: kRPC value codec (primitives, classes,
|
||||
* enums, collections, system messages)
|
||||
* - ServiceCache: a Type index built from KRPC.GetServices()
|
||||
* - KrpcServices: high-level invoke-by-name client
|
||||
*
|
||||
* See ./schema.ts for the meta schema. The service-specific types
|
||||
* (SpaceCenter.Vessel, Orbit, etc.) need to be loaded from the kRPC
|
||||
* mod's .proto files at runtime when running against a real KSP.
|
||||
* For the service-specific types (SpaceCenter.Vessel, Orbit, etc.) we do
|
||||
* NOT need to load the kRPC mod's .proto files. The server's
|
||||
* GetServices() response contains everything we need to encode/decode
|
||||
* values. See ./service-client.ts for the details.
|
||||
*/
|
||||
export {
|
||||
KRPCClient,
|
||||
@@ -26,4 +32,27 @@ export {
|
||||
decodeVarint,
|
||||
tcpConnect,
|
||||
} from './connection.js';
|
||||
export { KRPC, encodeMessage, decodeMessage } from './schema.js';
|
||||
export { KRPC, encodeMessage, decodeMessage, MESSAGE_TYPES } from './schema.js';
|
||||
export {
|
||||
TypeCode,
|
||||
decodeKrpcType,
|
||||
typeName,
|
||||
type KrpcType,
|
||||
type TypeCodeValue,
|
||||
type RawKrpcTypeMessage,
|
||||
} from './types.js';
|
||||
export {
|
||||
decodeValue,
|
||||
encodeValue,
|
||||
decodeDouble,
|
||||
decodeFloat,
|
||||
decodeSint32,
|
||||
decodeUint32,
|
||||
decodeUint64,
|
||||
decodeBool,
|
||||
decodeString,
|
||||
decodeBytes,
|
||||
type DecodedValue,
|
||||
} from './decoder.js';
|
||||
export { ServiceCache } from './services.js';
|
||||
export { KrpcServices, loadServices, type KrpcInvokeError } from './service-client.js';
|
||||
|
||||
@@ -245,6 +245,12 @@ const schemaJson = {
|
||||
Event: {
|
||||
fields: { stream: { type: 'Stream', id: 1 } },
|
||||
},
|
||||
Expression: {
|
||||
fields: {
|
||||
typ: { type: 'Type', id: 1 },
|
||||
code: { type: 'string', id: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -253,31 +259,73 @@ const schemaJson = {
|
||||
};
|
||||
|
||||
const root = protobuf.Root.fromJSON(schemaJson as protobuf.INamespace);
|
||||
// Cache the resolved types for fast lookup
|
||||
const ns = root.lookup('krpc.schema') as protobuf.Namespace;
|
||||
// Suppress "type X is not used" warnings for the namespace
|
||||
void ns;
|
||||
void root;
|
||||
const lookupType = (name: string): protobuf.Type =>
|
||||
ns.lookupType(name) as protobuf.Type;
|
||||
|
||||
/**
|
||||
* All kRPC meta-protocol types, by their protobufjs Type objects.
|
||||
*
|
||||
* These are used by the decoder/encoder for system messages (Status,
|
||||
* Services, Stream, Event, ProcedureCall) and collection types
|
||||
* (List, Set, Tuple, Dictionary).
|
||||
*
|
||||
* The same Type objects are also exposed as `MESSAGE_TYPES` so the
|
||||
* service client can register them with the decoder in one call.
|
||||
*
|
||||
* GOTCHA: protobufjs's nested-enum string-to-number lookup is brittle
|
||||
* when the enum shares its name with a built-in JavaScript type or
|
||||
* with a parent property. In particular, sending
|
||||
* encodeMessage(ConnectionRequest, { type: 'STREAM' })
|
||||
* silently encodes as 0 (RPC) instead of 1. To avoid this, encode
|
||||
* enum values by their numeric code rather than the string name.
|
||||
*/
|
||||
export const KRPC = {
|
||||
ConnectionRequest: ns.lookupType('ConnectionRequest'),
|
||||
ConnectionResponse: ns.lookupType('ConnectionResponse'),
|
||||
Request: ns.lookupType('Request'),
|
||||
Response: ns.lookupType('Response'),
|
||||
ProcedureCall: ns.lookupType('ProcedureCall'),
|
||||
Argument: ns.lookupType('Argument'),
|
||||
ProcedureResult: ns.lookupType('ProcedureResult'),
|
||||
Error: ns.lookupType('Error'),
|
||||
StreamUpdate: ns.lookupType('StreamUpdate'),
|
||||
StreamResult: ns.lookupType('StreamResult'),
|
||||
Stream: ns.lookupType('Stream'),
|
||||
Status: ns.lookupType('Status'),
|
||||
Services: ns.lookupType('Services'),
|
||||
Service: ns.lookupType('Service'),
|
||||
Type: ns.lookupType('Type'),
|
||||
List: ns.lookupType('List'),
|
||||
Tuple: ns.lookupType('Tuple'),
|
||||
ConnectionRequest: lookupType('ConnectionRequest'),
|
||||
ConnectionResponse: lookupType('ConnectionResponse'),
|
||||
Request: lookupType('Request'),
|
||||
Response: lookupType('Response'),
|
||||
ProcedureCall: lookupType('ProcedureCall'),
|
||||
Argument: lookupType('Argument'),
|
||||
ProcedureResult: lookupType('ProcedureResult'),
|
||||
Error: lookupType('Error'),
|
||||
StreamUpdate: lookupType('StreamUpdate'),
|
||||
StreamResult: lookupType('StreamResult'),
|
||||
Stream: lookupType('Stream'),
|
||||
Status: lookupType('Status'),
|
||||
Services: lookupType('Services'),
|
||||
Service: lookupType('Service'),
|
||||
Type: lookupType('Type'),
|
||||
List: lookupType('List'),
|
||||
Set: lookupType('Set'),
|
||||
Tuple: lookupType('Tuple'),
|
||||
Dictionary: lookupType('Dictionary'),
|
||||
DictionaryEntry: lookupType('DictionaryEntry'),
|
||||
Event: lookupType('Event'),
|
||||
Expression: lookupType('Expression'),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* A name → protobufjs Type registry for system messages and collection
|
||||
* types. The decoder/encoder accepts this as the `messageTypes` argument
|
||||
* so it knows how to (de)serialize these specific messages.
|
||||
*/
|
||||
export const MESSAGE_TYPES: Record<string, protobuf.Type> = {
|
||||
List: KRPC.List,
|
||||
Set: KRPC.Set,
|
||||
Tuple: KRPC.Tuple,
|
||||
Dictionary: KRPC.Dictionary,
|
||||
Status: KRPC.Status,
|
||||
Services: KRPC.Services,
|
||||
Stream: KRPC.Stream,
|
||||
Event: KRPC.Event,
|
||||
ProcedureCall: KRPC.ProcedureCall,
|
||||
};
|
||||
|
||||
// Silence "type not used" — we keep the root reference for diagnostics
|
||||
// and to allow callers to load additional .proto files later if needed.
|
||||
void root;
|
||||
|
||||
/** Encode a length-prefixed protobuf message. */
|
||||
export function encodeMessage(type: protobuf.Type, value: Record<string, unknown>): Buffer {
|
||||
return Buffer.from(type.encode(type.create(value)).finish());
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* KrpcServices — high-level service client.
|
||||
*
|
||||
* Wraps KRPCClient + ServiceCache to provide a clean invoke-by-name API:
|
||||
*
|
||||
* const sc = new KrpcServices(client, cache);
|
||||
* const ut = await sc.invoke<number>('SpaceCenter', 'GetUT');
|
||||
* const bodyIds = await sc.invoke<bigint[]>('SpaceCenter', 'GetBodies');
|
||||
* const name = await sc.invoke<string>('SpaceCenter', 'CelestialBody.GetName', bodyId);
|
||||
*
|
||||
* The client looks up the procedure in the cache, encodes each argument
|
||||
* using the procedure's parameter types, calls the procedure via the
|
||||
* low-level client, then decodes the response using the return type.
|
||||
*
|
||||
* For class-returning procedures, the response is decoded as the object
|
||||
* id (a BigInt), or `null` if the server returned id=0. The caller can
|
||||
* then pass that BigInt to other class methods.
|
||||
*
|
||||
* This design means we don't need the kRPC mod's .proto files at all —
|
||||
* the kRPC server provides the type information via GetServices(), and
|
||||
* the kRPC value encoding is what our decoder handles.
|
||||
*/
|
||||
import type { KRPCClient, ProcedureCallRequest } from './client.js';
|
||||
import { decodeValue, encodeValue, type DecodedValue } from './decoder.js';
|
||||
import { MESSAGE_TYPES, KRPC, decodeMessage } from './schema.js';
|
||||
import type { KrpcType } from './types.js';
|
||||
import { ServiceCache, type RawServicesMessage } from './services.js';
|
||||
|
||||
export interface KrpcInvokeError extends Error {
|
||||
service: string;
|
||||
name: string;
|
||||
description: string;
|
||||
stackTrace?: string;
|
||||
}
|
||||
|
||||
/** Build a KrpcInvokeError with extra metadata attached. */
|
||||
function makeInvokeError(
|
||||
service: string,
|
||||
name: string,
|
||||
description: string,
|
||||
stackTrace?: string,
|
||||
): KrpcInvokeError {
|
||||
const e = new Error(`${service}.${name}: ${description}`) as KrpcInvokeError;
|
||||
e.service = service;
|
||||
e.name = name;
|
||||
e.description = description;
|
||||
if (stackTrace) e.stackTrace = stackTrace;
|
||||
return e;
|
||||
}
|
||||
|
||||
export class KrpcServices {
|
||||
constructor(
|
||||
private readonly client: KRPCClient,
|
||||
private readonly cache: ServiceCache,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Invoke a procedure and return the decoded value.
|
||||
*
|
||||
* @param service e.g. "SpaceCenter"
|
||||
* @param procedure e.g. "GetUT" or "CelestialBody.GetName" for class methods
|
||||
* @param args The procedure arguments. Each is encoded according to the
|
||||
* procedure's parameter types (looked up from the cache).
|
||||
* Object ids for CLASS parameters are BigInts.
|
||||
*/
|
||||
async invoke<T extends DecodedValue = DecodedValue>(
|
||||
service: string,
|
||||
procedure: string,
|
||||
...args: DecodedValue[]
|
||||
): Promise<T> {
|
||||
const lookup = this.cache.lookup(service, procedure);
|
||||
if (!lookup.found) {
|
||||
throw makeInvokeError(
|
||||
service,
|
||||
procedure,
|
||||
`procedure not found in service cache (known services: ${this.cache.serviceNames().join(', ')})`,
|
||||
);
|
||||
}
|
||||
const info = lookup.info;
|
||||
if (args.length !== info.parameters.length) {
|
||||
throw makeInvokeError(
|
||||
service,
|
||||
procedure,
|
||||
`wrong number of arguments: expected ${info.parameters.length}, got ${args.length}`,
|
||||
);
|
||||
}
|
||||
const encodedArgs = info.parameters.map((p, i) => {
|
||||
const v = args[i];
|
||||
// For nullable CLASS parameters, accept `null`/`undefined` and encode as id=0.
|
||||
if (p.nullable && (v === null || v === undefined)) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
return encodeValue(p.type, v, MESSAGE_TYPES);
|
||||
});
|
||||
// Low-level invoke needs Uint8Array values for each argument.
|
||||
const req: ProcedureCallRequest = {
|
||||
service: info.service,
|
||||
procedure: info.name,
|
||||
args: encodedArgs,
|
||||
};
|
||||
let rawValue: Uint8Array;
|
||||
try {
|
||||
rawValue = await this.client.invoke(req);
|
||||
} catch (err) {
|
||||
// The low-level client throws with a generic message; we wrap it
|
||||
// with the service.procedure prefix so the caller knows which call
|
||||
// failed. The original error is on the `cause` chain in newer
|
||||
// Node, but to keep things simple we re-throw a tagged error.
|
||||
const e = err as Error;
|
||||
throw makeInvokeError(service, procedure, e.message, e.stack);
|
||||
}
|
||||
if (rawValue.length === 0) {
|
||||
// Zero-length response. Only valid for:
|
||||
// - procedures with no return value (KrpcType NONE)
|
||||
// - nullable CLASS with id=0 — but that is 1 byte (0x00), not 0
|
||||
// So we return null if the return type is NONE or nullable,
|
||||
// otherwise throw.
|
||||
if (info.returnType.code === 0 /* NONE */) {
|
||||
return null as unknown as T;
|
||||
}
|
||||
if (info.returnIsNullable) {
|
||||
return null as unknown as T;
|
||||
}
|
||||
throw makeInvokeError(
|
||||
service,
|
||||
procedure,
|
||||
'zero-length response for non-nullable, non-NONE return type',
|
||||
);
|
||||
}
|
||||
const decoded = decodeValue(info.returnType, rawValue, MESSAGE_TYPES);
|
||||
if (decoded === null && !info.returnIsNullable) {
|
||||
// Some primitives (e.g. uint64 = 0) might decode to falsy values
|
||||
// that we don't want to confuse with null. But for CLASS/ENUM this
|
||||
// is a real "no value" — and only valid for nullable returns.
|
||||
throw makeInvokeError(
|
||||
service,
|
||||
procedure,
|
||||
'decoded null for non-nullable return type',
|
||||
);
|
||||
}
|
||||
return decoded as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a class property by calling its getter procedure. Equivalent to
|
||||
* invoke(service, "ClassName.GetPropName", objectId)
|
||||
* but reads more naturally at the call site.
|
||||
*/
|
||||
async getClassProperty<T extends DecodedValue = DecodedValue>(
|
||||
service: string,
|
||||
className: string,
|
||||
propertyName: string,
|
||||
objectId: bigint | null,
|
||||
): Promise<T> {
|
||||
return this.invoke<T>(service, `${className}.${propertyName}`, objectId as DecodedValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Underlying service cache, exposed for callers that want to do
|
||||
* procedural introspection (e.g. debug tools that list available
|
||||
* services or resolve enum values).
|
||||
*/
|
||||
getCache(): ServiceCache {
|
||||
return this.cache;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ServiceCache from a fresh KRPCClient. Convenience for the
|
||||
* common "connect, get services, return ready client" pattern.
|
||||
*
|
||||
* The return value of `KRPC.GetServices()` is a serialized
|
||||
* `KRPC.Services` message. We decode it using our protobufjs type
|
||||
* definition, then convert the result to a `ServiceCache`.
|
||||
*/
|
||||
export async function loadServices(client: KRPCClient): Promise<{
|
||||
cache: ServiceCache;
|
||||
services: KrpcServices;
|
||||
}> {
|
||||
const rawValue = await client.invoke({
|
||||
service: 'KRPC',
|
||||
procedure: 'GetServices',
|
||||
});
|
||||
// KRPC.Services is a system message — decode it using the
|
||||
// registered protobufjs type.
|
||||
const decoded = decodeMessage<RawServicesMessage>(KRPC.Services, rawValue);
|
||||
// protobufjs decodes `services` field as a repeated Service; each
|
||||
// Service has nested messages for Class, Enumeration, etc. that
|
||||
// protobufjs also decodes. The shape matches our RawServicesMessage
|
||||
// contract — but TypeScript doesn't know that, so we cast.
|
||||
const cache = new ServiceCache(decoded);
|
||||
return { cache, services: new KrpcServices(client, cache) };
|
||||
}
|
||||
|
||||
/** Re-export for consumers that want to import KrpcType. */
|
||||
export type { KrpcType };
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 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 };
|
||||
|
||||
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").
|
||||
*/
|
||||
lookup(service: string, procedure: string): ProcedureLookup {
|
||||
const info = this.byFullName.get(`${service}.${procedure}`);
|
||||
if (!info) return { found: false };
|
||||
return { found: true, info };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* kRPC Type runtime representation.
|
||||
*
|
||||
* kRPC values are encoded on the wire in a custom way that sits on top of
|
||||
* the standard protobuf encoding. Every procedure return / argument carries
|
||||
* a `KrpcType` descriptor that tells the client how to encode/decode the
|
||||
* bytes. The descriptor is itself a protobuf message (KRPC.Type) and is
|
||||
* exchanged via `KRPC.GetServices()` at connect time.
|
||||
*
|
||||
* See: https://krpc.github.io/krpc/communication-protocols/messages.html
|
||||
* and the Python client's `krpc.types` / `krpc.decoder` modules for the
|
||||
* canonical reference implementation.
|
||||
*
|
||||
* TypeCode numeric values match the protobuf enum in krpc.proto:
|
||||
* 0 NONE, 1 DOUBLE, 2 FLOAT, 3 SINT32, 4 SINT64, 5 UINT32, 6 UINT64,
|
||||
* 7 BOOL, 8 STRING, 9 BYTES,
|
||||
* 100 CLASS, 101 ENUMERATION,
|
||||
* 200 EVENT, 201 PROCEDURE_CALL, 202 STREAM, 203 STATUS, 204 SERVICES,
|
||||
* 300 TUPLE, 301 LIST, 302 SET, 303 DICTIONARY.
|
||||
*/
|
||||
export const TypeCode = {
|
||||
NONE: 0,
|
||||
DOUBLE: 1,
|
||||
FLOAT: 2,
|
||||
SINT32: 3,
|
||||
SINT64: 4,
|
||||
UINT32: 5,
|
||||
UINT64: 6,
|
||||
BOOL: 7,
|
||||
STRING: 8,
|
||||
BYTES: 9,
|
||||
CLASS: 100,
|
||||
ENUMERATION: 101,
|
||||
EVENT: 200,
|
||||
PROCEDURE_CALL: 201,
|
||||
STREAM: 202,
|
||||
STATUS: 203,
|
||||
SERVICES: 204,
|
||||
TUPLE: 300,
|
||||
LIST: 301,
|
||||
SET: 302,
|
||||
DICTIONARY: 303,
|
||||
} as const;
|
||||
|
||||
export type TypeCodeValue = (typeof TypeCode)[keyof typeof TypeCode];
|
||||
|
||||
/**
|
||||
* Decoded form of a kRPC Type message. We don't try to keep the
|
||||
* protobufjs wrapper — the few fields we care about (code, service, name,
|
||||
* types) are copied into this plain object for ergonomic access.
|
||||
*/
|
||||
export interface KrpcType {
|
||||
code: TypeCodeValue;
|
||||
/** For CLASS / ENUMERATION: the service that defines the type. */
|
||||
service: string;
|
||||
/** For CLASS / ENUMERATION: the class/enum name. */
|
||||
name: string;
|
||||
/**
|
||||
* Nested types. Used by collections (LIST<T>, SET<T>, TUPLE<A,B>, DICT<K,V>).
|
||||
* The semantics depend on the code:
|
||||
* LIST / SET: types[0] is the element type
|
||||
* TUPLE: types[i] is the i-th element type
|
||||
* DICTIONARY: types[0] is the key type, types[1] is the value type
|
||||
* For CLASS / ENUMERATION: empty.
|
||||
*/
|
||||
types: KrpcType[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a kRPC Type protobuf message into our plain KrpcType shape.
|
||||
* Exposed so callers (e.g. the service cache) can transform the
|
||||
* GetServices() response into a useful index.
|
||||
*/
|
||||
export interface RawKrpcTypeMessage {
|
||||
code: number;
|
||||
service: string;
|
||||
name: string;
|
||||
types: RawKrpcTypeMessage[];
|
||||
}
|
||||
|
||||
export function decodeKrpcType(raw: RawKrpcTypeMessage): KrpcType {
|
||||
return {
|
||||
code: raw.code as TypeCodeValue,
|
||||
service: raw.service ?? '',
|
||||
name: raw.name ?? '',
|
||||
types: (raw.types ?? []).map(decodeKrpcType),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable name for a typecode. Used in error messages and for
|
||||
* debugging. Not used in wire encoding.
|
||||
*/
|
||||
export function typeName(t: KrpcType): string {
|
||||
switch (t.code) {
|
||||
case TypeCode.NONE:
|
||||
return 'None';
|
||||
case TypeCode.DOUBLE:
|
||||
return 'double';
|
||||
case TypeCode.FLOAT:
|
||||
return 'float';
|
||||
case TypeCode.SINT32:
|
||||
return 'sint32';
|
||||
case TypeCode.SINT64:
|
||||
return 'sint64';
|
||||
case TypeCode.UINT32:
|
||||
return 'uint32';
|
||||
case TypeCode.UINT64:
|
||||
return 'uint64';
|
||||
case TypeCode.BOOL:
|
||||
return 'bool';
|
||||
case TypeCode.STRING:
|
||||
return 'string';
|
||||
case TypeCode.BYTES:
|
||||
return 'bytes';
|
||||
case TypeCode.CLASS:
|
||||
return `${t.service}.${t.name}`;
|
||||
case TypeCode.ENUMERATION:
|
||||
return `${t.service}.${t.name}`;
|
||||
case TypeCode.EVENT:
|
||||
return 'Event';
|
||||
case TypeCode.PROCEDURE_CALL:
|
||||
return 'ProcedureCall';
|
||||
case TypeCode.STREAM:
|
||||
return 'Stream';
|
||||
case TypeCode.STATUS:
|
||||
return 'Status';
|
||||
case TypeCode.SERVICES:
|
||||
return 'Services';
|
||||
case TypeCode.TUPLE: {
|
||||
const inner = t.types.map(typeName).join(', ');
|
||||
return `(${inner})`;
|
||||
}
|
||||
case TypeCode.LIST:
|
||||
return `list<${t.types[0] ? typeName(t.types[0]) : '?'}>`;
|
||||
case TypeCode.SET:
|
||||
return `set<${t.types[0] ? typeName(t.types[0]) : '?'}>`;
|
||||
case TypeCode.DICTIONARY: {
|
||||
const k = t.types[0] ? typeName(t.types[0]) : '?';
|
||||
const v = t.types[1] ? typeName(t.types[1]) : '?';
|
||||
return `dict<${k}, ${v}>`;
|
||||
}
|
||||
default:
|
||||
return `code=${t.code}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user