Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc766357dc | |||
| 1d5b6a9f93 | |||
| 273dcd1408 | |||
| 639d265278 | |||
| e9ebbf17d2 | |||
| ee75d0b6c9 | |||
| 916222f4f4 | |||
| 62e7ed0a77 | |||
| 2b0573d328 | |||
| b1b78a06a3 | |||
| a6ba6e6583 | |||
| dea84b65bb | |||
| 25dd42503b | |||
| 7c46bc0408 | |||
| c4b631c4c4 |
@@ -46,8 +46,10 @@ function err(msg: string): void {
|
|||||||
console.error(`[ksp-bridge] ${msg}`);
|
console.error(`[ksp-bridge] ${msg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BUILD_TAG = 'v2-debug';
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
log(`config: api=${API_URL} host=${HOST}:${RPC_PORT} poll=${POLL_MS}ms`);
|
log(`config: api=${API_URL} host=${HOST}:${RPC_PORT} poll=${POLL_MS}ms [${BUILD_TAG}]`);
|
||||||
|
|
||||||
// First, try to connect to a real kRPC server. If it works, run
|
// First, try to connect to a real kRPC server. If it works, run
|
||||||
// the real extract loop. If it fails, fall back to mock mode.
|
// the real extract loop. If it fails, fall back to mock mode.
|
||||||
@@ -60,8 +62,28 @@ async function main(): Promise<void> {
|
|||||||
await adapter.connect();
|
await adapter.connect();
|
||||||
log(`connected to kRPC at ${HOST}:${RPC_PORT} — running with real KSP state`);
|
log(`connected to kRPC at ${HOST}:${RPC_PORT} — running with real KSP state`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e === null ? 'null' : e instanceof Error ? e.message : String(e);
|
// Hardened error formatter: handles null, undefined, Error,
|
||||||
log(`no kRPC server at ${HOST}:${RPC_PORT}: ${msg}`);
|
// strings, and arbitrary objects. Never crashes the formatter
|
||||||
|
// itself, so we always see *something*.
|
||||||
|
let msg: string;
|
||||||
|
if (e === null) {
|
||||||
|
msg = 'null';
|
||||||
|
} else if (e === undefined) {
|
||||||
|
msg = 'undefined';
|
||||||
|
} else if (typeof e === 'string') {
|
||||||
|
msg = e;
|
||||||
|
} else if (e instanceof Error) {
|
||||||
|
msg = e.message;
|
||||||
|
} else if (typeof e === 'object') {
|
||||||
|
try {
|
||||||
|
msg = JSON.stringify(e);
|
||||||
|
} catch {
|
||||||
|
msg = String(e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
msg = String(e);
|
||||||
|
}
|
||||||
|
log(`[${BUILD_TAG}] no kRPC server at ${HOST}:${RPC_PORT}: ${msg}`);
|
||||||
log('Falling back to MOCK mode (synthetic state).');
|
log('Falling back to MOCK mode (synthetic state).');
|
||||||
return runMock();
|
return runMock();
|
||||||
}
|
}
|
||||||
@@ -165,6 +187,14 @@ function mockState(ut: number): ExtractedState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main().catch((e) => {
|
main().catch((e) => {
|
||||||
err(`fatal: ${e}`);
|
let msg: string;
|
||||||
|
if (e === null) msg = 'null';
|
||||||
|
else if (e === undefined) msg = 'undefined';
|
||||||
|
else if (e instanceof Error) msg = `${e.message} (stack: ${e.stack ?? 'n/a'})`;
|
||||||
|
else if (typeof e === 'string') msg = e;
|
||||||
|
else if (typeof e === 'object') {
|
||||||
|
try { msg = JSON.stringify(e); } catch { msg = String(e); }
|
||||||
|
} else msg = String(e);
|
||||||
|
err(`fatal: ${msg}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,8 +64,20 @@ export class KRPCAdapter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.client.connect();
|
await this.client.connect();
|
||||||
const loaded = await loadServices(this.client);
|
try {
|
||||||
this.services = loaded.services;
|
const loaded = await loadServices(this.client);
|
||||||
|
this.services = loaded.services;
|
||||||
|
} catch (e) {
|
||||||
|
// loadServices calls client.invoke, which decodes the
|
||||||
|
// KRPC.GetServices response (a HUGE KRPC.Services message).
|
||||||
|
// If anything goes wrong decoding that, surface a clear
|
||||||
|
// error instead of the buried protobufjs TypeError.
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
const stack = e instanceof Error ? e.stack : '';
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[ksp-bridge] loadServices stack:', stack);
|
||||||
|
throw new Error(`kRPC loadServices (GetServices decode) failed: ${msg}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
|
|||||||
@@ -84,6 +84,19 @@ export class KRPCClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
|
// Wrap EVERYTHING in a top-level try so we always get a clean
|
||||||
|
// error message (not a buried TypeError from protobufjs
|
||||||
|
// nested-enum resolution). The specific sub-step failures are
|
||||||
|
// caught inline for nicer messages, but this top-level guard
|
||||||
|
// is the safety net.
|
||||||
|
try {
|
||||||
|
return await this._connectImpl();
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`kRPC connect failed at unknown step: ${formatErr(e)} (stack: ${e instanceof Error ? e.stack : 'n/a'})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _connectImpl(): Promise<void> {
|
||||||
// RPC handshake
|
// RPC handshake
|
||||||
try {
|
try {
|
||||||
this.rpcSocket = await tcpConnect(
|
this.rpcSocket = await tcpConnect(
|
||||||
@@ -106,11 +119,19 @@ export class KRPCClient {
|
|||||||
});
|
});
|
||||||
let resp: { status: number | string; message: string; clientIdentifier: Uint8Array };
|
let resp: { status: number | string; message: string; clientIdentifier: Uint8Array };
|
||||||
try {
|
try {
|
||||||
|
const rpcRaw = await recvRawMessage(this.rpcSocket);
|
||||||
|
if (process.env.KRPC_DEBUG) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
'[krpc-client] rpc handshake raw response (' + rpcRaw.length + ' bytes):',
|
||||||
|
Buffer.from(rpcRaw).toString('hex'),
|
||||||
|
);
|
||||||
|
}
|
||||||
resp = decodeMessage<{
|
resp = decodeMessage<{
|
||||||
status: number | string;
|
status: number | string;
|
||||||
message: string;
|
message: string;
|
||||||
clientIdentifier: Uint8Array;
|
clientIdentifier: Uint8Array;
|
||||||
}>(KRPC.ConnectionResponse, await recvRawMessage(this.rpcSocket));
|
}>(KRPC.ConnectionResponse, rpcRaw);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`kRPC RPC handshake (response decode) failed: ${formatErr(e)}`);
|
throw new Error(`kRPC RPC handshake (response decode) failed: ${formatErr(e)}`);
|
||||||
}
|
}
|
||||||
@@ -136,10 +157,26 @@ export class KRPCClient {
|
|||||||
type: 1, // STREAM
|
type: 1, // STREAM
|
||||||
clientIdentifier: this.clientIdentifier,
|
clientIdentifier: this.clientIdentifier,
|
||||||
});
|
});
|
||||||
const streamResp = decodeMessage<{ status: number | string; message: string }>(
|
let streamResp: { status: number | string; message: string };
|
||||||
KRPC.ConnectionResponse,
|
try {
|
||||||
await recvRawMessage(this.streamSocket),
|
const streamRaw = await recvRawMessage(this.streamSocket);
|
||||||
);
|
// Diagnostic: log the raw bytes for the stream handshake response
|
||||||
|
// so we can see what the kRPC server actually sent. Useful when
|
||||||
|
// debugging "Cannot read properties of null" type errors.
|
||||||
|
if (process.env.KRPC_DEBUG) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
'[krpc-client] stream handshake raw response (' + streamRaw.length + ' bytes):',
|
||||||
|
Buffer.from(streamRaw).toString('hex'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
streamResp = decodeMessage<{ status: number | string; message: string }>(
|
||||||
|
KRPC.ConnectionResponse,
|
||||||
|
streamRaw,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`kRPC Stream handshake (response decode) failed: ${formatErr(e)}`);
|
||||||
|
}
|
||||||
if (streamResp.status !== 'OK' && streamResp.status !== 0) {
|
if (streamResp.status !== 'OK' && streamResp.status !== 0) {
|
||||||
throw new Error(`Stream handshake failed: ${streamResp.status} ${streamResp.message}`);
|
throw new Error(`Stream handshake failed: ${streamResp.status} ${streamResp.message}`);
|
||||||
}
|
}
|
||||||
@@ -149,7 +186,7 @@ export class KRPCClient {
|
|||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error('[krpc-client] stream loop error:', err);
|
console.error('[krpc-client] stream loop error:', err);
|
||||||
});
|
});
|
||||||
}
|
} // end _connectImpl
|
||||||
|
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
return this.rpcSocket !== null && this.streamSocket !== null && !this.closed;
|
return this.rpcSocket !== null && this.streamSocket !== null && !this.closed;
|
||||||
@@ -188,16 +225,28 @@ export class KRPCClient {
|
|||||||
}
|
}
|
||||||
sendMessage(this.rpcSocket, KRPC.Request, { calls: [call] });
|
sendMessage(this.rpcSocket, KRPC.Request, { calls: [call] });
|
||||||
const raw = await recvRawMessage(this.rpcSocket);
|
const raw = await recvRawMessage(this.rpcSocket);
|
||||||
|
if (process.env.KRPC_DEBUG) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
`[krpc-client] ${req.service}.${req.procedure} response (${raw.length} bytes):`,
|
||||||
|
Buffer.from(raw).toString('hex'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The kRPC schema defines `error` as a sub-message of type Error
|
||||||
|
// (not raw bytes), so protobufjs decodes it as a nested object
|
||||||
|
// automatically — service/name/description/stackTrace are already
|
||||||
|
// populated when the field is set.
|
||||||
const response = decodeMessage<{
|
const response = decodeMessage<{
|
||||||
error?: { service: string; name: string; description: string };
|
error?: { service: string; name: string; description: string; stackTrace: string };
|
||||||
results: {
|
results: {
|
||||||
error?: { service: string; name: string; description: string };
|
error?: { service: string; name: string; description: string; stackTrace: string };
|
||||||
value: Uint8Array;
|
value: Uint8Array;
|
||||||
}[];
|
}[];
|
||||||
}>(KRPC.Response, raw);
|
}>(KRPC.Response, raw);
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`RPC error: ${response.error.service}.${response.error.name}: ${response.error.description}`,
|
`RPC error: ${response.error.service}.${response.error.name}: ${response.error.description}` +
|
||||||
|
(response.error.stackTrace ? `\n${response.error.stackTrace}` : ''),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (response.results.length === 0) {
|
if (response.results.length === 0) {
|
||||||
@@ -209,7 +258,8 @@ export class KRPCClient {
|
|||||||
}
|
}
|
||||||
if (r.error) {
|
if (r.error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`RPC result error: ${r.error.service}.${r.error.name}: ${r.error.description}`,
|
`RPC result error: ${r.error.service}.${r.error.name}: ${r.error.description}` +
|
||||||
|
(r.error.stackTrace ? `\n${r.error.stackTrace}` : ''),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return r.value;
|
return r.value;
|
||||||
@@ -239,13 +289,18 @@ export class KRPCClient {
|
|||||||
};
|
};
|
||||||
sendMessage(this.rpcSocket, KRPC.Request, { calls: [addCall] });
|
sendMessage(this.rpcSocket, KRPC.Request, { calls: [addCall] });
|
||||||
const response = decodeMessage<{
|
const response = decodeMessage<{
|
||||||
results: { value: Uint8Array; error?: { name: string; description: string } }[];
|
results: {
|
||||||
|
value: Uint8Array;
|
||||||
|
error?: { service: string; name: string; description: string; stackTrace: string };
|
||||||
|
}[];
|
||||||
}>(KRPC.Response, await recvRawMessage(this.rpcSocket));
|
}>(KRPC.Response, await recvRawMessage(this.rpcSocket));
|
||||||
if (response.results.length === 0) throw new Error('empty AddStream response');
|
if (response.results.length === 0) throw new Error('empty AddStream response');
|
||||||
const r0 = response.results[0];
|
const r0 = response.results[0];
|
||||||
if (!r0) throw new Error('empty AddStream result');
|
if (!r0) throw new Error('empty AddStream result');
|
||||||
if (r0.error) {
|
if (r0.error) {
|
||||||
throw new Error(`AddStream error: ${r0.error.name}: ${r0.error.description}`);
|
throw new Error(
|
||||||
|
`AddStream error: ${r0.error.service}.${r0.error.name}: ${r0.error.description}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const stream = decodeMessage<{ id: number }>(KRPC.Stream, r0.value);
|
const stream = decodeMessage<{ id: number }>(KRPC.Stream, r0.value);
|
||||||
return stream.id;
|
return stream.id;
|
||||||
@@ -293,9 +348,23 @@ export class KRPCClient {
|
|||||||
try {
|
try {
|
||||||
const raw = await recvRawMessage(this.streamSocket);
|
const raw = await recvRawMessage(this.streamSocket);
|
||||||
const update = decodeMessage<{
|
const update = decodeMessage<{
|
||||||
results: { id: number; result: { value: Uint8Array } }[];
|
results: {
|
||||||
|
id: number;
|
||||||
|
result: {
|
||||||
|
error?: { service: string; name: string; description: string; stackTrace: string };
|
||||||
|
value: Uint8Array;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
}>(KRPC.StreamUpdate, raw);
|
}>(KRPC.StreamUpdate, raw);
|
||||||
for (const r of update.results) {
|
for (const r of update.results) {
|
||||||
|
if (r.result.error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
`[krpc-client] stream ${r.id} error: ${r.result.error.service}.${r.result.error.name}: ${r.result.error.description}`,
|
||||||
|
);
|
||||||
|
for (const h of this.streamHandlers) h(r.id, new Uint8Array());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
for (const h of this.streamHandlers) h(r.id, r.result.value);
|
for (const h of this.streamHandlers) h(r.id, r.result.value);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -36,8 +36,16 @@ const schemaJson = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
ConnectionResponse: {
|
ConnectionResponse: {
|
||||||
|
// NOTE: status is wire-varint-enum-OK=0, but we model it
|
||||||
|
// as a plain uint32 to avoid protobufjs nested-enum
|
||||||
|
// resolution bugs that throw "Cannot read properties of
|
||||||
|
// null (reading 'code')" when the field is omitted from
|
||||||
|
// the wire (which the kRPC server does for the happy
|
||||||
|
// path). Our code already does `resp.status !== 0` /
|
||||||
|
// `!== 'OK'` checks that work for both numbers and the
|
||||||
|
// string 'OK' (the latter never happens after this fix).
|
||||||
fields: {
|
fields: {
|
||||||
status: { type: 'ConnectionResponse.Status', id: 1 },
|
status: { type: 'uint32', id: 1 },
|
||||||
message: { type: 'string', id: 2 },
|
message: { type: 'string', id: 2 },
|
||||||
clientIdentifier: { type: 'bytes', id: 3 },
|
clientIdentifier: { type: 'bytes', id: 3 },
|
||||||
},
|
},
|
||||||
@@ -80,6 +88,14 @@ const schemaJson = {
|
|||||||
},
|
},
|
||||||
ProcedureResult: {
|
ProcedureResult: {
|
||||||
fields: {
|
fields: {
|
||||||
|
// Same nested-enum-as-field-type issue as
|
||||||
|
// ConnectionResponse.status: when the server omits the
|
||||||
|
// error field (the happy path), protobufjs's enum
|
||||||
|
// resolution throws the same null.code TypeError. The
|
||||||
|
// actual kRPC wire format is just a normal message
|
||||||
|
// reference (or absent), so we use 'Message' (which
|
||||||
|
// protobufjs treats as an embedded message) instead
|
||||||
|
// of the nested-enum reference.
|
||||||
error: { type: 'Error', id: 1 },
|
error: { type: 'Error', id: 1 },
|
||||||
value: { type: 'bytes', id: 2 },
|
value: { type: 'bytes', id: 2 },
|
||||||
},
|
},
|
||||||
@@ -145,12 +161,33 @@ const schemaJson = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Procedure: {
|
Procedure: {
|
||||||
|
// The kRPC server sends `game_scenes` (a repeated
|
||||||
|
// GameScene enum, field 6) on every Procedure. The
|
||||||
|
// GameScene enum is nested inside Procedure. We model
|
||||||
|
// it as a repeated uint32 to dodge the protobufjs
|
||||||
|
// nested-enum default-value bug, same as Type.code and
|
||||||
|
// ConnectionResponse.status. We don't actually use this
|
||||||
|
// field on the client side; it's just here so the
|
||||||
|
// decoder doesn't choke on the wire bytes.
|
||||||
fields: {
|
fields: {
|
||||||
name: { type: 'string', id: 1 },
|
name: { type: 'string', id: 1 },
|
||||||
parameters: { rule: 'repeated', type: 'Parameter', id: 2 },
|
parameters: { rule: 'repeated', type: 'Parameter', id: 2 },
|
||||||
returnType: { type: 'Type', id: 3 },
|
returnType: { type: 'Type', id: 3 },
|
||||||
returnIsNullable: { type: 'bool', id: 4 },
|
returnIsNullable: { type: 'bool', id: 4 },
|
||||||
documentation: { type: 'string', id: 5 },
|
documentation: { type: 'string', id: 5 },
|
||||||
|
gameScenes: { rule: 'repeated', type: 'uint32', id: 6 },
|
||||||
|
},
|
||||||
|
nested: {
|
||||||
|
GameScene: {
|
||||||
|
values: {
|
||||||
|
SPACE_CENTER: 0,
|
||||||
|
FLIGHT: 1,
|
||||||
|
TRACKING_STATION: 2,
|
||||||
|
EDITOR_VAB: 3,
|
||||||
|
EDITOR_SPH: 4,
|
||||||
|
MISSION_BUILDER: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Parameter: {
|
Parameter: {
|
||||||
@@ -188,8 +225,17 @@ const schemaJson = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Type: {
|
Type: {
|
||||||
|
// The `code` field on Type is a wire-varint enum
|
||||||
|
// (TypeCode = uint32 under the hood). We model it as a
|
||||||
|
// plain uint32 to dodge the protobufjs nested-enum
|
||||||
|
// default-value lookup bug that throws
|
||||||
|
// "Cannot read properties of null (reading 'code')"
|
||||||
|
// when decoding a Type message whose code field is
|
||||||
|
// present. Same fix as ConnectionResponse.status.
|
||||||
|
// The values stay as a nested TypeCode enum for
|
||||||
|
// documentation / programmatic lookup (in services.ts).
|
||||||
fields: {
|
fields: {
|
||||||
code: { type: 'Type.TypeCode', id: 1 },
|
code: { type: 'uint32', id: 1 },
|
||||||
service: { type: 'string', id: 2 },
|
service: { type: 'string', id: 2 },
|
||||||
name: { type: 'string', id: 3 },
|
name: { type: 'string', id: 3 },
|
||||||
types: { rule: 'repeated', type: 'Type', id: 4 },
|
types: { rule: 'repeated', type: 'Type', id: 4 },
|
||||||
|
|||||||
@@ -68,6 +68,38 @@ export type ProcedureLookup =
|
|||||||
| { found: true; info: ProcedureInfo }
|
| { found: true; info: ProcedureInfo }
|
||||||
| { found: false };
|
| { 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 {
|
export class ServiceCache {
|
||||||
/** "SpaceCenter.CelestialBody.GetName" -> ProcedureInfo */
|
/** "SpaceCenter.CelestialBody.GetName" -> ProcedureInfo */
|
||||||
private byFullName = new Map<string, ProcedureInfo>();
|
private byFullName = new Map<string, ProcedureInfo>();
|
||||||
@@ -135,11 +167,29 @@ export class ServiceCache {
|
|||||||
/**
|
/**
|
||||||
* Look up a procedure by `service.procedure` (e.g. "SpaceCenter.GetUT") or
|
* Look up a procedure by `service.procedure` (e.g. "SpaceCenter.GetUT") or
|
||||||
* by the class-prefixed form ("SpaceCenter.CelestialBody.GetName").
|
* 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 {
|
lookup(service: string, procedure: string): ProcedureLookup {
|
||||||
const info = this.byFullName.get(`${service}.${procedure}`);
|
const direct = this.byFullName.get(`${service}.${procedure}`);
|
||||||
if (!info) return { found: false };
|
if (direct) return { found: true, info: direct };
|
||||||
return { found: true, info };
|
|
||||||
|
// 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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -78,7 +78,23 @@ export interface RawKrpcTypeMessage {
|
|||||||
types: RawKrpcTypeMessage[];
|
types: RawKrpcTypeMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeKrpcType(raw: RawKrpcTypeMessage): KrpcType {
|
/**
|
||||||
|
* Decode a kRPC Type protobuf message into our plain KrpcType shape.
|
||||||
|
*
|
||||||
|
* Returns a NONE-type KrpcType if `raw` is null/undefined or doesn't
|
||||||
|
* have a `code` field — which happens for procedures with no return
|
||||||
|
* value (the kRPC server omits the `return_type` field). We treat that
|
||||||
|
* as the NONE type code (0) rather than throwing.
|
||||||
|
*/
|
||||||
|
export function decodeKrpcType(raw: RawKrpcTypeMessage | null | undefined): KrpcType {
|
||||||
|
if (!raw || typeof raw.code !== 'number') {
|
||||||
|
return {
|
||||||
|
code: 0 as TypeCodeValue, // NONE
|
||||||
|
service: '',
|
||||||
|
name: '',
|
||||||
|
types: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
code: raw.code as TypeCodeValue,
|
code: raw.code as TypeCodeValue,
|
||||||
service: raw.service ?? '',
|
service: raw.service ?? '',
|
||||||
|
|||||||
@@ -196,4 +196,68 @@ describe('ServiceCache', () => {
|
|||||||
expect(r.info.returnType.types[0]?.code).toBe(100);
|
expect(r.info.returnType.types[0]?.code).toBe(100);
|
||||||
expect(r.info.returnType.types[0]?.name).toBe('CelestialBody');
|
expect(r.info.returnType.types[0]?.name).toBe('CelestialBody');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles procedures with no return type (returnType is null)', () => {
|
||||||
|
// Real kRPC server omits the `return_type` field for void
|
||||||
|
// procedures (e.g. AddStream, setters). protobufjs decodes
|
||||||
|
// missing message fields as null. Our cache must not crash.
|
||||||
|
const raw = {
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
name: 'KRPC',
|
||||||
|
procedures: [
|
||||||
|
{
|
||||||
|
name: 'AddStream',
|
||||||
|
parameters: [],
|
||||||
|
returnType: null, // <-- the trigger
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
classes: [],
|
||||||
|
enumerations: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const cache = new ServiceCache(raw as unknown as Parameters<typeof ServiceCache>[0]);
|
||||||
|
const r = cache.lookup('KRPC', 'AddStream');
|
||||||
|
expect(r.found).toBe(true);
|
||||||
|
if (!r.found) throw new Error('unreachable');
|
||||||
|
// Missing return type becomes NONE (code 0).
|
||||||
|
expect(r.info.returnType.code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to .NET-style getter/setter naming for C# properties', () => {
|
||||||
|
// The kRPC server exposes C# properties using .NET accessor
|
||||||
|
// conventions: a property `UT` on SpaceCenter becomes two
|
||||||
|
// procedures named `get_UT` and `set_UT`. User code typically
|
||||||
|
// writes `SpaceCenter.GetUT()` (PascalCase). The cache must
|
||||||
|
// transparently translate to the wire-format name.
|
||||||
|
const raw = {
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
name: 'SpaceCenter',
|
||||||
|
procedures: [
|
||||||
|
{ name: 'get_UT', parameters: [], returnType: null, returnIsNullable: false },
|
||||||
|
{ name: 'set_UT', parameters: [], returnType: null, returnIsNullable: false },
|
||||||
|
{ name: 'get_ActiveVessel', parameters: [], returnType: null, returnIsNullable: true },
|
||||||
|
{ name: 'CelestialBody.get_Name', parameters: [], returnType: null, returnIsNullable: false },
|
||||||
|
],
|
||||||
|
classes: [],
|
||||||
|
enumerations: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const cache = new ServiceCache(raw as unknown as Parameters<typeof ServiceCache>[0]);
|
||||||
|
// Top-level PascalCase -> .NET getter
|
||||||
|
expect(cache.lookup('SpaceCenter', 'GetUT').found).toBe(true);
|
||||||
|
// Top-level PascalCase -> .NET setter
|
||||||
|
expect(cache.lookup('SpaceCenter', 'SetUT').found).toBe(true);
|
||||||
|
expect(cache.lookup('SpaceCenter', 'GetActiveVessel').found).toBe(true);
|
||||||
|
// Class-prefixed: "CelestialBody.GetName" -> "CelestialBody.get_Name"
|
||||||
|
expect(cache.lookup('SpaceCenter', 'CelestialBody.GetName').found).toBe(true);
|
||||||
|
// Exact match still works
|
||||||
|
expect(cache.lookup('SpaceCenter', 'get_UT').found).toBe(true);
|
||||||
|
// And unknown names still return not-found
|
||||||
|
expect(cache.lookup('SpaceCenter', 'NoSuchProcedure').found).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user