aebee77843
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.)
479 lines
16 KiB
TypeScript
479 lines
16 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|