Files
RetroArch/tools/command_table_check.c
T
Rob LoachandLibretroAdmin c2e5bce004 MCP: structured results, and LOAD_CONTENT checks before it unloads (#19815)
* LOAD_CONTENT: check paths before unloading

* mcp: results carry structuredContent

* LIST_CORES filters by content path

* Structured rows for core options and memory map

* MCP: do not advertise a cacheable tool list

* SET_INPUT holds several controls at once

* A row opening with MORE is a row, not a cursor

* MCP: fewer tools, and rows a client can rely on

structuredContent and outputSchema never reached a client over stdin
and stdout. The gate read the MCP-Protocol-Version header, or the
version the stateless revision sends with every request; stdio has
neither, so the revision agreed at initialize was parsed, used for the
handshake reply and then dropped. It now holds for the session, which
over stdio is the connection.

A command answers with structuredContent exactly when it declared an
outputSchema, so a client reads the schema it was handed rather than
probing each reply. SET_INPUT, RELEASE_INPUT and GET_LOG declare one
now.

The "MORE [next]" line was read by a literal in the server. The shape
of a reply names its own trailers instead - the lines that carry a
number about the reply rather than a row of it - so GET_LOG's NEXT and
SKIPPED are the same mechanism rather than two more special cases, and
a reply says nothing about being cut short where it cannot be. A reply
whose lines are prose declares no columns: GET_LOG's page runs to 60k,
and a row apiece would answer with it twice.

Thirty-four commands are no longer offered as tools. A hotkey that
steps a selection - the next state slot, the previous shader, the next
cheat - is a hotkey because a person presses it while watching the
screen. Where a command takes the value outright that one is the tool,
and stepping towards it is only a way to get the value wrong; where
nothing reports the selection either, there is nothing worth offering
yet. READ_CORE_RAM and WRITE_CORE_RAM go too: an achievement address
looks like READ_CORE_MEMORY's system address and is not one. The disc
and the menu are kept, because stepping them blind is all there is and
a disc swap is part of playing a game. All of them are still commands
on every line interface.

The tool list is 45 tools and 20k, from 79 and 23k, and the end-to-end
test holds it to a budget: every description is context a client pays
for before it does anything.

* PRESS: hold, run and let go in one request

A tap was four requests: SET_INPUT, RUN_FRAMES, SET_INPUT to release,
RUN_FRAMES again. Each is its own HTTP connection against four slots,
so a field test spent about 25 of them crossing a menu and about 10 on
one jump.

PRESS <player> <control>[+<control>...] <frames> holds the buttons,
runs that many frames and lets them go, answered once the last frame
has run. It is RUN_FRAMES' job with buttons held through it: the same
deferred reply, the same one-at-a-time rule, the same pause the runloop
applies to frame advance, so nothing new blocks and no second state
machine exists. Whatever ends the frames lets the buttons go, including
the error that ends them early.

Axes stay SET_INPUT's. They are held at a value rather than pressed,
and SET_INPUT with RUN_FRAMES is still the way to hold a control across
several steps.

SET_INPUT and PRESS read the same request - a player, controls joined
by "+", and a number - so that parse is one routine now, and both
resolve every control before either acts on any.

* CHANGES: the MCP and command work in this series

* GET_STATUS: say what it actually answers with

The description promised the content's CRC32 and the reply has never
carried one: it is the system and the content's file name, separated by
a comma.

HELP prints that text on the line interfaces and tools/list hands it to
every MCP client, where it is the prose a client reads to decide how to
take the reply apart. One told to expect three comma-separated fields
gets two.

Adding the CRC instead would be worse. Working it out means hashing the
content, which for a core using need_fullpath reads a whole disc image
from disk - task_movie.c already carries that note, having hit it - and
GET_STATUS is the command a client polls. It would also change the
reply for every client that reads the line today.

* command: descriptions that match the replies

tools/list hands a command's description to every MCP client, and HELP
prints it on the line interfaces: it is the prose a client reads to
decide how to take a reply apart. Five of them described a reply that
was not the one sent.

SET_INPUT and RELEASE_INPUT answer with three fields - the player, the
buttons held and the axes held - and the description named two. A
client splitting on what it was told got the player where it expected
the buttons. RELEASE_INPUT did not say it answered at all, and with no
player it lets go for everyone while answering for the first of them,
which is worth saying rather than leaving to be discovered.

GET_LOG can open with SKIPPED and a count where the lines asked for had
already been overwritten, which the description did not mention: a
client reads it as a log line.

READ_CORE_MEMORY and WRITE_CORE_MEMORY both answer - with the bytes,
and with how many were written - and neither said so.

* READ_CORE_MEMORY: format bytes without snprintf

Both memory reads wrote their reply with an snprintf per byte, to turn
one byte into two hexadecimal digits. At COMMAND_READ_NBYTES_MAX that
is 16384 calls, which costs 1.5ms on an aarch64 Pi - a tenth of a frame
spent formatting, on the main thread, in the command an assistant reads
memory with most. A nibble table is 0.02ms for the same bytes, 68 times
faster, and the output is identical for all 256 values of a byte in
both of the formats the two sites used.

The harness core now maps its memory, because nothing in the tree did:
READ_CORE_MEMORY, WRITE_CORE_MEMORY and GET_MEMORY_MAP had no test that
reached past "no memory map defined", so the formatting this changes
was uncovered. The test asserts the replies byte for byte, including
every digit of a byte, a read clamped at the end of its region, and one
outside every region. Breaking the table deliberately fails three of
them.

* PRESS: leave SET_INPUT's holds in place

PRESS lets go only of the buttons it pressed itself, so a button a
client already holds with SET_INPUT is still held once the press ends.

* command, mcp: trim comments and order new struct members

Drop the superseded LIST_CORES and reply-splitting comments, shorten
the new comments to what stays true, and order the new struct members
by alignment.

---------

Co-authored-by: LibretroAdmin <[email protected]>
2026-10-14 18:59:27 +02:00

423 lines
11 KiB
C

/* Check that every command in command.h describes itself.
*
* The command tables in command.h are the one list of what RetroArch
* can be told to do. HELP prints them, and interfaces that hand the
* commands to a client (tool lists, key binding help) are generated from
* them, so a command added without a description reaches all of those
* as a blank. This fails on any row of action_map or map that has no
* description, an empty one, or an unknown flag.
*
* A command that takes an argument also has to describe it as
* parameters, or a structured client is given a tool it cannot call
* properly; one that takes none must have NULL there.
*
* Rows are matched as C initializers:
* action_map: { "NAME", handler, "arg", "description", flags, params, rows },
* map: { "NAME", ID, "description", flags },
*
* Usage, from the repository root: tools/check.sh command_table_check */
#include "check_common.h"
#define HEADER "command.h"
static const char *const flag_names[] = {
"0", "CMD_INFO_DESTRUCTIVE", "CMD_INFO_NO_TOOL", "CMD_INFO_READ_ONLY"
};
#define FLAGS_CLASS(c) (CK_IS_WORD(c) || (c) == '|' || CK_IS_SPACE(c))
/* A part of a row: where it is, or b NULL when the row has none. */
struct span
{
const char *b;
const char *e;
};
struct row
{
struct span name;
struct span arg;
struct span desc;
struct span flags;
struct span params;
const char *end;
};
static const char *ws(const char *p, const char *e)
{
while (p < e && CK_IS_SPACE(*p))
p++;
return p;
}
/* "((?:[^"\\]|\\.)*)" at p: the end, or NULL */
static const char *escaped_literal(const char *p, const char *e,
struct span *s)
{
const char *q;
if (p >= e || *p != '"')
return NULL;
for (q = p + 1; q < e && *q != '"'; q++)
{
if (*q == '\\')
{
if (q + 1 >= e || q[1] == '\n')
return NULL;
q++;
}
}
if (q >= e)
return NULL;
s->b = p + 1;
s->e = q;
return q + 1;
}
/* What follows the description: (?:,\s*([\w|\s]+?)\s*)? and, for
* action_map, (?:,\s*(\w+)\s*)?, then the closing brace - in the order
* the expression tries them. */
/* An optional field after the parameters that this check does not read:
* the shape of the command's reply, "&cmd_rows_x". Gives back what it
* was handed where there is none. */
static const char *skip_rows(const char *u, const char *e)
{
const char *v;
const char *w;
if (u >= e || *u != ',')
return u;
v = ws(u + 1, e);
if (v < e && *v == '&')
v++;
for (w = v; v < e && CK_IS_WORD(*v); v++);
return v > w ? ws(v, e) : u;
}
static const char *tail(const char *p, const char *e, int with_params,
struct row *r)
{
int take;
for (take = 1; take >= 0; take--)
{
const char *s;
long k;
r->flags.b = NULL;
if (take)
{
const char *a;
if (p >= e || *p != ',')
continue;
a = p + 1;
s = ws(a, e);
/* \s* gives back what it took, one byte at a time */
for (k = (long)(s - a); k >= 0; k--)
{
const char *fb = a + k;
const char *fe;
for (fe = fb + 1; fe <= e && FLAGS_CLASS(fe[-1]); fe++)
{
int ptake;
const char *q = ws(fe, e);
for (ptake = with_params; ptake >= 0; ptake--)
{
const char *u = q;
r->params.b = NULL;
if (ptake)
{
const char *w;
if (u >= e || *u != ',')
continue;
u = ws(u + 1, e);
for (w = u; u < e && CK_IS_WORD(*u); u++);
if (u == w)
continue;
r->params.b = w;
r->params.e = u;
u = ws(u, e);
}
u = skip_rows(u, e);
if (u < e && *u == '}')
{
r->flags.b = fb;
r->flags.e = fe;
return u + 1;
}
}
if (fe >= e)
break;
}
}
continue;
}
{
int ptake;
for (ptake = with_params; ptake >= 0; ptake--)
{
const char *u = p;
r->params.b = NULL;
if (ptake)
{
const char *w;
if (u >= e || *u != ',')
continue;
u = ws(u + 1, e);
for (w = u; u < e && CK_IS_WORD(*u); u++);
if (u == w)
continue;
r->params.b = w;
r->params.e = u;
u = ws(u, e);
}
u = skip_rows(u, e);
if (u < e && *u == '}')
return u + 1;
}
}
}
return NULL;
}
static int is_name_char(int c)
{
return (c >= 'A' && c <= 'Z') || CK_IS_DIGIT(c) || c == '_';
}
/* A row at p, which is '{':
* action_map: \{\s*"([A-Z0-9_]+)"\s*,\s*(\w+)\s*,\s*"([^"]*)"\s*
* (?:,\s*"((?:[^"\\]|\\.)*)"\s*)?(?:,\s*([\w|\s]+?)\s*)?
* (?:,\s*(\w+)\s*)?\}
* map: \{\s*"([A-Z0-9_]+)"\s*,\s*([A-Z0-9_]+)\s*
* (?:,\s*"((?:[^"\\]|\\.)*)"\s*)?(?:,\s*([\w|\s]+?)\s*)?\} */
static int row_at(const char *p, const char *e, int action, struct row *r)
{
const char *q = ws(p + 1, e);
const char *w;
int take;
memset(r, 0, sizeof(*r));
if (q >= e || *q != '"')
return 0;
for (w = ++q; q < e && is_name_char(*q); q++);
if (q == w || q >= e || *q != '"')
return 0;
r->name.b = w;
r->name.e = q;
q = ws(q + 1, e);
if (q >= e || *q != ',')
return 0;
q = ws(q + 1, e);
for (w = q; q < e && (action ? CK_IS_WORD(*q) : is_name_char(*q)); q++);
if (q == w)
return 0;
q = ws(q, e);
if (action)
{
if (q >= e || *q != ',')
return 0;
q = ws(q + 1, e);
if (q >= e || *q != '"')
return 0;
for (w = ++q; q < e && *q != '"'; q++);
if (q >= e)
return 0;
r->arg.b = w;
r->arg.e = q;
q = ws(q + 1, e);
}
for (take = 1; take >= 0; take--)
{
const char *u = q;
const char *end;
r->desc.b = NULL;
if (take)
{
if (u >= e || *u != ',')
continue;
u = ws(u + 1, e);
if (!(u = escaped_literal(u, e, &r->desc)))
continue;
u = ws(u, e);
}
if ((end = tail(u, e, action, r)))
{
r->end = end;
return 1;
}
}
return 0;
}
static void put_span_repr(struct ck_sb *sb, const struct span *s)
{
if (!s->b)
ck_sb_puts(sb, "None");
else
ck_repr(sb, s->b, s->e);
}
static int span_is(const struct span *s, const char *str)
{
return s->b && (size_t)(s->e - s->b) == strlen(str)
&& !memcmp(s->b, str, strlen(str));
}
static void trim(const char **b, const char **e)
{
while (*b < *e && (CK_IS_SPACE(**b) || (**b >= 0x1c && **b <= 0x1f)))
(*b)++;
while (*e > *b && (CK_IS_SPACE((*e)[-1])
|| ((*e)[-1] >= 0x1c && (*e)[-1] <= 0x1f)))
(*e)--;
}
static void check_flags(const struct row *r, struct ck_strv *bad)
{
int ok = r->flags.b && r->flags.e > r->flags.b;
if (ok)
{
const char *p = r->flags.b;
while (ok)
{
const char *bar = (const char*)memchr(p, '|',
(size_t)(r->flags.e - p));
const char *b = p;
const char *e = bar ? bar : r->flags.e;
size_t i;
int known = 0;
trim(&b, &e);
for (i = 0; i < sizeof(flag_names) / sizeof(flag_names[0]); i++)
known |= (size_t)(e - b) == strlen(flag_names[i])
&& !memcmp(b, flag_names[i], (size_t)(e - b));
ok = known;
if (!bar)
break;
p = bar + 1;
}
}
if (!ok)
{
struct ck_sb m = {0};
ck_sb_add(&m, r->name.b, (size_t)(r->name.e - r->name.b));
ck_sb_puts(&m, ": flags ");
put_span_repr(&m, &r->flags);
ck_sb_puts(&m, " are not a combination of 0, CMD_INFO_DESTRUCTIVE,"
" CMD_INFO_NO_TOOL, CMD_INFO_READ_ONLY");
ck_strv_push(bad, m.p);
}
}
static void check_desc(const struct row *r, struct ck_strv *bad)
{
const char *b, *e;
if (r->desc.b)
{
b = r->desc.b;
e = r->desc.e;
trim(&b, &e);
if (b < e)
return;
}
{
struct ck_sb m = {0};
ck_sb_add(&m, r->name.b, (size_t)(r->name.e - r->name.b));
ck_sb_puts(&m, ": no description");
ck_strv_push(bad, m.p);
}
}
/* The table that starts with head, as far as the next "};". */
static int table(const char *text, const char *head, const char **b,
const char **e)
{
const char *s = strstr(text, head);
if (!s)
return 0;
if (!(*e = strstr(s, "};")))
{
fprintf(stderr, "command_table_check: a table in %s does not end\n",
HEADER);
exit(1);
}
*b = s;
return 1;
}
int main(int argc, char **argv)
{
struct ck_strv bad = {0};
unsigned long count = 0;
const char *ab, *ae, *hb, *he, *p;
size_t len, i;
char *text;
int action;
(void)argv;
if (argc > 1)
{
fprintf(stderr, "usage: command_table_check\n");
return 2;
}
if (!(text = ck_read_file(HEADER, &len)))
{
fprintf(stderr, "command_table_check: cannot read %s\n", HEADER);
return 1;
}
if ( !table(text, "struct cmd_action_map action_map[] = {", &ab, &ae)
|| !table(text, "struct cmd_map map[] = {", &hb, &he))
{
printf("command_table_check: tables not found in %s\n", HEADER);
return 1;
}
for (action = 1; action >= 0; action--)
{
const char *b = action ? ab : hb;
const char *e = action ? ae : he;
for (p = b; (p = (const char*)memchr(p, '{', (size_t)(e - p))); )
{
struct row r;
if (!row_at(p, e, action, &r))
{
p++;
continue;
}
count++;
check_desc(&r, &bad);
check_flags(&r, &bad);
if (action)
{
int takes_arg = !span_is(&r.arg, "No argument");
int none = !r.params.b || span_is(&r.params, "NULL");
struct ck_sb m = {0};
if (takes_arg && none)
{
ck_sb_add(&m, r.name.b, (size_t)(r.name.e - r.name.b));
ck_sb_puts(&m, ": takes ");
put_span_repr(&m, &r.arg);
ck_sb_puts(&m, " but has no parameters; a structured client"
" cannot call it");
ck_strv_push(&bad, m.p);
}
else if (!takes_arg && !none)
{
ck_sb_add(&m, r.name.b, (size_t)(r.name.e - r.name.b));
ck_sb_puts(&m, ": takes no argument but has parameters ");
put_span_repr(&m, &r.params);
ck_strv_push(&bad, m.p);
}
}
p = r.end;
}
}
if (bad.n)
{
printf("command_table_check: every command in %s needs a description"
" and flags:\n", HEADER);
for (i = 0; i < bad.n; i++)
printf(" %s\n", bad.v[i]);
return 1;
}
printf("ok: %lu commands in %s all describe themselves\n", count, HEADER);
free(text);
return 0;
}