mirror of
https://github.com/libretro/virtualjaguar-libretro.git
synced 2026-11-08 01:07:11 -10:00
Merge pull request #250 from libretro/perf/netlink-reply-wait
perf(net): bounded wall-clock wait for netlink replies (Wi-Fi lag fix)
This commit is contained in:
12 files changed
+816
-8
No files matched your search
@@ -95,3 +95,5 @@ test/tools/__pycache__/
|
||||
*.gcda
|
||||
*.gcno
|
||||
coverage.xml
|
||||
test/tools/netlink_latency
|
||||
test/tools/netlink_delay_proxy
|
||||
@@ -808,7 +808,7 @@ test: test/test_cheat test/test_event_queue test/test_jlink test/test_jlink_tcp
|
||||
test/test_cd_boot test/test_cd_hle_boot test/test_cd_bios_boot test/test_cd_toc_contract test/test_cd_fifo_stream test/test_cd_ssi_stream test/test_cd_second_transfer test/test_cd_hle_idempotent test/test_cd_lost_wakeup test/test_cd_pregap \
|
||||
test/test_audio_dac test/test_blitter \
|
||||
test/tools/test_memory_map test/tools/test_op_gpu_object test/test_uart_core test/test_netlink_host \
|
||||
test/tools/netlink_pair
|
||||
test/tools/netlink_pair test/tools/netlink_latency test/tools/netlink_delay_proxy
|
||||
./test/test_cheat
|
||||
./test/test_event_queue
|
||||
./test/test_jlink
|
||||
@@ -818,6 +818,7 @@ test: test/test_cheat test/test_event_queue test/test_jlink test/test_jlink_tcp
|
||||
./test/test_uart_core ./$(TARGET)
|
||||
./test/test_netlink_host ./$(TARGET)
|
||||
bash test/tools/netlink_pair_test.sh ./$(TARGET)
|
||||
bash test/tools/netlink_latency_test.sh ./$(TARGET)
|
||||
./test/test_blitter_mmio
|
||||
./test/test_blitter_cmd ./$(TARGET)
|
||||
./test/test_pit_clock_rate
|
||||
@@ -967,6 +968,13 @@ test/tools/netlink_pair: test/tools/netlink_pair.c test/harness/harness.c test/h
|
||||
$(CC) -O2 -Wall -std=c99 $(INCFLAGS) -Itest \
|
||||
-o $@ test/tools/netlink_pair.c test/harness/harness.c -ldl -lm
|
||||
|
||||
test/tools/netlink_latency: test/tools/netlink_latency.c test/harness/harness.c test/harness/harness.h
|
||||
$(CC) -O2 -Wall -std=c99 $(INCFLAGS) -Itest \
|
||||
-o $@ test/tools/netlink_latency.c test/harness/harness.c -ldl -lm
|
||||
|
||||
test/tools/netlink_delay_proxy: test/tools/netlink_delay_proxy.c
|
||||
$(CC) -O2 -Wall -o $@ test/tools/netlink_delay_proxy.c
|
||||
|
||||
test/tools/netlink_game: test/tools/netlink_game.c test/harness/harness.c test/harness/harness.h
|
||||
$(CC) -O2 -Wall -std=c99 $(INCFLAGS) -Itest \
|
||||
-o $@ test/tools/netlink_game.c test/harness/harness.c -ldl -lm
|
||||
|
||||
@@ -72,3 +72,8 @@ Desktop/testing shortcuts: environment variables `VJ_NETLINK_HOST` and
|
||||
only to check the link menu works.
|
||||
- Localhost/LAN latency is the supported envelope; internet play over VPNs
|
||||
(Tailscale etc.) may work but is best-effort.
|
||||
- Play feels laggy on Wi-Fi even though ping looks fine: make sure
|
||||
*Network Link Latency Hiding* is enabled (it is by default). Without
|
||||
it every link exchange rounds up to whole video frames regardless of
|
||||
actual network speed. The wait adapts to the measured connection
|
||||
automatically — there is nothing to tune.
|
||||
+8
-1
@@ -380,6 +380,11 @@ static void netlink_apply(int mode)
|
||||
if (env && env[0] && atoi(env) > 0)
|
||||
port = atoi(env);
|
||||
|
||||
pvar.key = "virtualjaguar_netlink_wait";
|
||||
pvar.value = NULL;
|
||||
if (environ_cb(RETRO_ENVIRONMENT_GET_VARIABLE, &pvar) && pvar.value)
|
||||
JLinkSetWaitEnabled(strcmp(pvar.value, "disabled") != 0);
|
||||
|
||||
env = getenv("VJ_NETLINK_HOST");
|
||||
if (env && env[0])
|
||||
{
|
||||
@@ -1631,7 +1636,9 @@ void retro_run(void)
|
||||
|
||||
/* Service the network link: progress TCP connect/accept, drain the
|
||||
* socket into the transport ring, then let the UART start an RX
|
||||
* frame for anything that arrived. */
|
||||
* frame for anything that arrived. FrameTick refills the per-frame
|
||||
* reply-wait budget. */
|
||||
JLinkFrameTick();
|
||||
JLinkPoll();
|
||||
UARTPoll();
|
||||
|
||||
|
||||
@@ -166,6 +166,20 @@ struct retro_core_option_v2_definition option_defs_us[] = {
|
||||
},
|
||||
"42171"
|
||||
},
|
||||
{
|
||||
"virtualjaguar_netlink_wait",
|
||||
"Network Link Latency Hiding",
|
||||
NULL,
|
||||
"Briefly holds each frame until the link partner's reply arrives, so network latency doesn't round every link exchange up to whole video frames. The wait adapts automatically to the measured connection (a few ms on localhost, more on Wi-Fi) and is capped so audio/video pacing survives. Disable only for troubleshooting or benchmarking.",
|
||||
NULL,
|
||||
NULL,
|
||||
{
|
||||
{ "enabled", NULL },
|
||||
{ "disabled", NULL },
|
||||
{ NULL, NULL },
|
||||
},
|
||||
"enabled"
|
||||
},
|
||||
{
|
||||
"virtualjaguar_cd_trace",
|
||||
"CD Trace (Diagnostic)",
|
||||
|
||||
+187
-4
@@ -1,6 +1,11 @@
|
||||
/* jlink.c — byte-transport seam for the JERRY UART.
|
||||
Loopback: bytes sent come back on the receive queue, modeling a
|
||||
console whose UARTO is wired to its own UARTI. */
|
||||
#if !defined(_WIN32) && !defined(_DEFAULT_SOURCE)
|
||||
#define _DEFAULT_SOURCE 1 /* glibc: expose POSIX (nanosleep) under c99 */
|
||||
#endif
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "jlink.h"
|
||||
#include "jlink_tcp.h"
|
||||
@@ -9,6 +14,47 @@
|
||||
|
||||
#define JLINK_RING_SIZE 256
|
||||
|
||||
/* Wall-clock wait support for JLinkAwaitReply (netlink reply wait).
|
||||
Platforms without it (bare console targets) compile a no-op. */
|
||||
#if defined(_WIN32)
|
||||
#include <windows.h>
|
||||
#define JLINK_HAVE_WAIT 1
|
||||
static long long JLinkNowUsec(void)
|
||||
{
|
||||
/* QueryPerformanceCounter: GetTickCount64's ~15 ms granularity would
|
||||
let the bounded wait overshoot its whole budget in one tick. */
|
||||
LARGE_INTEGER f, c;
|
||||
QueryPerformanceFrequency(&f);
|
||||
QueryPerformanceCounter(&c);
|
||||
return (c.QuadPart / f.QuadPart) * 1000000LL
|
||||
+ (c.QuadPart % f.QuadPart) * 1000000LL / f.QuadPart;
|
||||
}
|
||||
static void JLinkSleepUsec(int usec)
|
||||
{
|
||||
Sleep((DWORD)((usec + 999) / 1000));
|
||||
}
|
||||
#elif defined(__unix__) || defined(__APPLE__) || defined(__linux__) || \
|
||||
defined(__ANDROID__)
|
||||
#include <time.h>
|
||||
#include <sys/time.h>
|
||||
#define JLINK_HAVE_WAIT 1
|
||||
static long long JLinkNowUsec(void)
|
||||
{
|
||||
struct timeval tv;
|
||||
gettimeofday(&tv, NULL);
|
||||
return (long long)tv.tv_sec * 1000000LL + tv.tv_usec;
|
||||
}
|
||||
static void JLinkSleepUsec(int usec)
|
||||
{
|
||||
/* nanosleep, not usleep: glibc hides usleep/useconds_t under strict
|
||||
-std=c99 (the test builds), and nanosleep is plain POSIX.1-2001. */
|
||||
struct timespec ts;
|
||||
ts.tv_sec = 0;
|
||||
ts.tv_nsec = (long)usec * 1000L;
|
||||
nanosleep(&ts, NULL);
|
||||
}
|
||||
#endif
|
||||
|
||||
static int jlinkMode = JLINK_MODE_DISABLED;
|
||||
static uint8_t jlinkRing[JLINK_RING_SIZE];
|
||||
static uint32_t jlinkHead = 0; /* next byte to pop */
|
||||
@@ -20,6 +66,37 @@ static int jlinkTCPPort = 42171;
|
||||
static uint32_t jlinkTxTotal = 0;
|
||||
static uint32_t jlinkRxTotal = 0;
|
||||
|
||||
/* Reply wait: after a TX burst goes out over a real transport, the games
|
||||
spin on ASISTAT for the partner's answer. The spin burns its emulated
|
||||
frame budget in ~1 ms of wall clock, so ANY transport latency slips
|
||||
the reply past retro_run and delivery quantizes to whole video frames
|
||||
(measured: 445 exchanges/s direct -> 35/s with 6 ms RTT). While a
|
||||
reply is outstanding, JLinkAwaitReply blocks in wall-clock time so the
|
||||
reply lands in the SAME frame. Wall-clock only; no emulated state is
|
||||
touched.
|
||||
|
||||
The per-frame wait budget is ADAPTIVE — no user tuning. Reply latency
|
||||
is sampled at the transport boundary (burst armed -> first byte back)
|
||||
into an EWMA, and each frame may wait up to twice that (plus margin),
|
||||
clamped to [4, 15] ms. Localhost converges to the floor (sub-ms cost);
|
||||
a real network converges to what it actually needs. Same approach as
|
||||
melonDS's local-wireless sync: the emulator stalls for the peer
|
||||
automatically, bounded so A/V pacing survives. */
|
||||
static int jlinkAwaitingReply = 0;
|
||||
static int jlinkWaitEnabled = 1; /* option; 0 = disabled */
|
||||
static int jlinkWaitBudgetUsec = 0; /* per-frame remainder */
|
||||
/* Latency sampling is decoupled from jlinkAwaitingReply: the wait can
|
||||
time out and disarm, but the late reply must STILL be measured or the
|
||||
adaptive budget can never grow past its floor. */
|
||||
static int jlinkSamplePending = 0;
|
||||
static long long jlinkLastTxUsec = 0;
|
||||
static long long jlinkReplyEwmaUsec = 0; /* smoothed reply latency */
|
||||
|
||||
#define JLINK_WAIT_FLOOR_USEC 4000
|
||||
#define JLINK_WAIT_CEIL_USEC 15000
|
||||
#define JLINK_WAIT_MARGIN_USEC 2000
|
||||
#define JLINK_SAMPLE_MAX_USEC 50000 /* slower = not a reply, ignore */
|
||||
|
||||
static void JLinkRingPush(uint8_t b)
|
||||
{
|
||||
uint32_t tail;
|
||||
@@ -28,6 +105,22 @@ static void JLinkRingPush(uint8_t b)
|
||||
tail = (jlinkHead + jlinkCount) % JLINK_RING_SIZE;
|
||||
jlinkRing[tail] = b;
|
||||
jlinkCount++;
|
||||
if (jlinkSamplePending)
|
||||
{
|
||||
/* First byte back after a TX: sample the reply latency for the
|
||||
adaptive wait budget. Arrival is timestamped here no matter
|
||||
which path pumped it — and regardless of whether the wait
|
||||
already timed out — so a too-small budget measures the
|
||||
quantized latency, grows, and self-corrects within a few
|
||||
exchanges. */
|
||||
#ifdef JLINK_HAVE_WAIT
|
||||
long long sample = JLinkNowUsec() - jlinkLastTxUsec;
|
||||
if (sample >= 0 && sample <= JLINK_SAMPLE_MAX_USEC)
|
||||
jlinkReplyEwmaUsec += (sample - jlinkReplyEwmaUsec) / 8;
|
||||
#endif
|
||||
jlinkSamplePending = 0;
|
||||
}
|
||||
jlinkAwaitingReply = 0; /* the partner spoke */
|
||||
/* Counted on ARRIVAL, not on the game draining the ring. jlinkTxTotal is
|
||||
* incremented in JLinkSendByte(), i.e. at the transport boundary; counting
|
||||
* RX at JLinkRecvByte() instead measured the other side of the ring, so a
|
||||
@@ -89,6 +182,11 @@ void JLinkClose(void)
|
||||
* session never carried. */
|
||||
jlinkTxTotal = 0;
|
||||
jlinkRxTotal = 0;
|
||||
jlinkAwaitingReply = 0;
|
||||
jlinkWaitBudgetUsec = 0;
|
||||
jlinkSamplePending = 0;
|
||||
jlinkLastTxUsec = 0;
|
||||
jlinkReplyEwmaUsec = 0;
|
||||
}
|
||||
|
||||
int JLinkMode(void)
|
||||
@@ -120,10 +218,21 @@ void JLinkSendByte(uint8_t b)
|
||||
if (jlinkMode == JLINK_MODE_LOOPBACK)
|
||||
JLinkRingPush(b);
|
||||
else if (jlinkMode == JLINK_MODE_TCP_SERVER
|
||||
|| jlinkMode == JLINK_MODE_TCP_CLIENT)
|
||||
JLinkTCPSend(b);
|
||||
else if (jlinkMode == JLINK_MODE_NETPACKET)
|
||||
JLinkNPQueueByte(b);
|
||||
|| jlinkMode == JLINK_MODE_TCP_CLIENT
|
||||
|| jlinkMode == JLINK_MODE_NETPACKET)
|
||||
{
|
||||
if (jlinkMode == JLINK_MODE_NETPACKET)
|
||||
JLinkNPQueueByte(b);
|
||||
else
|
||||
JLinkTCPSend(b);
|
||||
jlinkAwaitingReply = 1;
|
||||
jlinkSamplePending = 1;
|
||||
#ifdef JLINK_HAVE_WAIT
|
||||
/* Per TX byte, so the sample measures last-byte-out -> first-byte
|
||||
back rather than including our own burst length. */
|
||||
jlinkLastTxUsec = JLinkNowUsec();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t JLinkTxTotal(void)
|
||||
@@ -159,6 +268,80 @@ void JLinkPoll(void)
|
||||
}
|
||||
}
|
||||
|
||||
void JLinkSetWaitEnabled(int enabled)
|
||||
{
|
||||
jlinkWaitEnabled = enabled ? 1 : 0;
|
||||
}
|
||||
|
||||
/* Called once per video frame from retro_run: refill the wait budget
|
||||
from the measured reply latency. */
|
||||
void JLinkFrameTick(void)
|
||||
{
|
||||
long long budget;
|
||||
if (!jlinkWaitEnabled)
|
||||
{
|
||||
jlinkWaitBudgetUsec = 0;
|
||||
return;
|
||||
}
|
||||
budget = jlinkReplyEwmaUsec * 2 + JLINK_WAIT_MARGIN_USEC;
|
||||
if (budget < JLINK_WAIT_FLOOR_USEC)
|
||||
budget = JLINK_WAIT_FLOOR_USEC;
|
||||
if (budget > JLINK_WAIT_CEIL_USEC)
|
||||
budget = JLINK_WAIT_CEIL_USEC;
|
||||
jlinkWaitBudgetUsec = (int)budget;
|
||||
#ifdef JLINK_HAVE_WAIT
|
||||
{
|
||||
/* Headless diagnostic: VJ_NETLINK_WAIT_DEBUG=1 logs the adaptive
|
||||
state once a second. */
|
||||
static int dbg = -1;
|
||||
static unsigned dbgFrames = 0;
|
||||
if (dbg < 0)
|
||||
{
|
||||
const char *e = getenv("VJ_NETLINK_WAIT_DEBUG");
|
||||
dbg = (e && e[0] == '1') ? 1 : 0;
|
||||
}
|
||||
if (dbg && (++dbgFrames % 60u) == 0)
|
||||
fprintf(stderr, "jlink-wait: ewma=%dus budget=%dus\n",
|
||||
(int)jlinkReplyEwmaUsec, jlinkWaitBudgetUsec);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Block (bounded) until the partner's reply reaches the RX ring. Only
|
||||
engages while a TX burst is unanswered on a real transport; the games
|
||||
call this path thousands of times per frame while spinning, so the
|
||||
fast-path bail must stay cheap. */
|
||||
void JLinkAwaitReply(void)
|
||||
{
|
||||
#ifdef JLINK_HAVE_WAIT
|
||||
long long start, now;
|
||||
if (!jlinkAwaitingReply || jlinkWaitBudgetUsec <= 0 || jlinkCount > 0)
|
||||
return;
|
||||
if (!JLinkConnected())
|
||||
return;
|
||||
start = JLinkNowUsec();
|
||||
for (;;)
|
||||
{
|
||||
JLinkPump();
|
||||
if (jlinkCount > 0)
|
||||
break;
|
||||
if (!JLinkConnected())
|
||||
break; /* peer went away mid-wait */
|
||||
now = JLinkNowUsec();
|
||||
if (now - start >= (long long)jlinkWaitBudgetUsec)
|
||||
{
|
||||
/* Peer silent for the whole budget: disarm until the next TX
|
||||
burst so idle ASISTAT polling doesn't stall every frame. */
|
||||
jlinkAwaitingReply = 0;
|
||||
break;
|
||||
}
|
||||
JLinkSleepUsec(500);
|
||||
}
|
||||
now = JLinkNowUsec();
|
||||
jlinkWaitBudgetUsec -= (int)(now - start);
|
||||
#endif
|
||||
}
|
||||
|
||||
void JLinkPump(void)
|
||||
{
|
||||
if (jlinkMode == JLINK_MODE_NETPACKET)
|
||||
|
||||
@@ -27,6 +27,9 @@ void JLinkNPDeliver(const uint8_t *buf, size_t len);
|
||||
host is ignored in server mode (listens on INADDR_ANY). */
|
||||
void JLinkSetTCPEndpoint(const char *host, int port);
|
||||
const char *JLinkGetTCPHost(void);
|
||||
void JLinkSetWaitEnabled(int enabled);
|
||||
void JLinkFrameTick(void);
|
||||
void JLinkAwaitReply(void);
|
||||
|
||||
int JLinkOpen(int mode);
|
||||
void JLinkClose(void);
|
||||
|
||||
+7
-1
@@ -142,7 +142,13 @@ uint16_t UARTReadWord(uint32_t offset)
|
||||
&& !JLinkRxPending())
|
||||
{
|
||||
static unsigned pumpGate = 0;
|
||||
if ((++pumpGate & 15u) == 0)
|
||||
/* An unanswered TX burst: wait in wall-clock (bounded per
|
||||
frame) so the reply lands THIS frame instead of
|
||||
quantizing to the next retro_run. */
|
||||
JLinkAwaitReply();
|
||||
if (JLinkRxPending())
|
||||
UARTKickRx();
|
||||
else if ((++pumpGate & 15u) == 0)
|
||||
{
|
||||
JLinkPump();
|
||||
UARTKickRx();
|
||||
|
||||
@@ -130,9 +130,26 @@ static void video_refresh(const void *data, unsigned width, unsigned height, siz
|
||||
(void)data; (void)width; (void)height; (void)pitch;
|
||||
}
|
||||
static void input_poll(void) {}
|
||||
|
||||
/* Optional A-press schedule (argv[6]: comma-separated frame numbers,
|
||||
each held 20 frames) — enough to advance title/menu screens into
|
||||
gameplay so in-game audio can be captured. */
|
||||
#define MAX_PRESSES 16
|
||||
#define PRESS_HOLD 20
|
||||
static int g_press_frames[MAX_PRESSES];
|
||||
static int g_num_presses = 0;
|
||||
static int g_cur_frame = 0;
|
||||
|
||||
static int16_t input_state(unsigned port, unsigned device, unsigned index, unsigned id)
|
||||
{
|
||||
(void)port; (void)device; (void)index; (void)id;
|
||||
int i;
|
||||
(void)index;
|
||||
if (port != 0 || device != RETRO_DEVICE_JOYPAD || id != RETRO_DEVICE_ID_JOYPAD_A)
|
||||
return 0;
|
||||
for (i = 0; i < g_num_presses; i++)
|
||||
if (g_cur_frame >= g_press_frames[i]
|
||||
&& g_cur_frame < g_press_frames[i] + PRESS_HOLD)
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
static void audio_sample_one(int16_t l, int16_t r) { (void)l; (void)r; }
|
||||
@@ -331,6 +348,7 @@ int main(int argc, char **argv)
|
||||
core_path, g_use_bios, g_seconds, g_rom);
|
||||
|
||||
for (frame = 0; frame < g_seconds * FRAMES_PER_SEC; frame++) {
|
||||
g_cur_frame = frame;
|
||||
retro_run();
|
||||
frames_in_sec++;
|
||||
if (frames_in_sec >= FRAMES_PER_SEC) {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/* netlink_delay_proxy.c — TCP relay that adds fixed one-way delay.
|
||||
*
|
||||
* netlink_delay_proxy --listen PORT --connect HOST:PORT --delay-ms N
|
||||
*
|
||||
* Accepts ONE client, connects upstream, and relays both directions,
|
||||
* holding every chunk for N milliseconds before forwarding. Simulates
|
||||
* Wi-Fi/LAN latency on localhost so netlink latency behavior can be
|
||||
* reproduced headlessly (2*N ms round trip). Exits when either side
|
||||
* closes. POSIX only — test tooling, not shipped.
|
||||
*/
|
||||
#define _DEFAULT_SOURCE 1
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/time.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <arpa/inet.h>
|
||||
|
||||
#define QCAP 4096 /* queued chunks per direction */
|
||||
#define CHUNK 2048
|
||||
|
||||
typedef struct
|
||||
{
|
||||
unsigned char buf[CHUNK];
|
||||
int len;
|
||||
long long due_usec;
|
||||
} chunk_t;
|
||||
|
||||
typedef struct
|
||||
{
|
||||
chunk_t q[QCAP];
|
||||
int head, count;
|
||||
} cqueue_t;
|
||||
|
||||
static long long now_usec(void)
|
||||
{
|
||||
struct timeval tv;
|
||||
gettimeofday(&tv, NULL);
|
||||
return (long long)tv.tv_sec * 1000000LL + tv.tv_usec;
|
||||
}
|
||||
|
||||
static int tcp_nodelay(int fd)
|
||||
{
|
||||
int one = 1;
|
||||
return setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
|
||||
}
|
||||
|
||||
/* Pump one direction: read available data into the queue, write out
|
||||
everything whose delay has elapsed. Returns 0 on EOF/error. */
|
||||
static int pump(int from, int to, cqueue_t *q, long long delay_usec,
|
||||
int readable)
|
||||
{
|
||||
long long now = now_usec();
|
||||
if (readable && q->count < QCAP)
|
||||
{
|
||||
chunk_t *c = &q->q[(q->head + q->count) % QCAP];
|
||||
ssize_t n = recv(from, c->buf, CHUNK, 0);
|
||||
if (n <= 0)
|
||||
return 0;
|
||||
c->len = (int)n;
|
||||
c->due_usec = now + delay_usec;
|
||||
q->count++;
|
||||
}
|
||||
while (q->count > 0)
|
||||
{
|
||||
chunk_t *c = &q->q[q->head];
|
||||
ssize_t n;
|
||||
if (c->due_usec > now)
|
||||
break;
|
||||
n = send(to, c->buf, (size_t)c->len, 0);
|
||||
if (n <= 0)
|
||||
return 0;
|
||||
if (n < c->len)
|
||||
{
|
||||
memmove(c->buf, c->buf + n, (size_t)(c->len - n));
|
||||
c->len -= (int)n;
|
||||
break;
|
||||
}
|
||||
q->head = (q->head + 1) % QCAP;
|
||||
q->count--;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
int listen_port = 0, up_port = 0, delay_ms = 0;
|
||||
char up_host[128];
|
||||
int lfd, cfd = -1, ufd = -1, i;
|
||||
struct sockaddr_in sa;
|
||||
int one = 1;
|
||||
static cqueue_t q_c2u, q_u2c;
|
||||
|
||||
up_host[0] = '\0';
|
||||
for (i = 1; i < argc; i++)
|
||||
{
|
||||
if (!strcmp(argv[i], "--listen") && i + 1 < argc)
|
||||
listen_port = atoi(argv[++i]);
|
||||
else if (!strcmp(argv[i], "--connect") && i + 1 < argc)
|
||||
{
|
||||
const char *colon = strchr(argv[++i], ':');
|
||||
if (!colon) { fprintf(stderr, "bad --connect\n"); return 1; }
|
||||
memcpy(up_host, argv[i], (size_t)(colon - argv[i]));
|
||||
up_host[colon - argv[i]] = '\0';
|
||||
up_port = atoi(colon + 1);
|
||||
}
|
||||
else if (!strcmp(argv[i], "--delay-ms") && i + 1 < argc)
|
||||
delay_ms = atoi(argv[++i]);
|
||||
}
|
||||
if (!listen_port || !up_host[0] || !up_port)
|
||||
{
|
||||
fprintf(stderr, "usage: %s --listen PORT --connect HOST:PORT "
|
||||
"--delay-ms N\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
lfd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
|
||||
memset(&sa, 0, sizeof(sa));
|
||||
sa.sin_family = AF_INET;
|
||||
sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
sa.sin_port = htons((unsigned short)listen_port);
|
||||
if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) < 0
|
||||
|| listen(lfd, 1) < 0)
|
||||
{
|
||||
perror("listen");
|
||||
return 1;
|
||||
}
|
||||
fprintf(stderr, "proxy: listening on %d -> %s:%d (+%d ms each way)\n",
|
||||
listen_port, up_host, up_port, delay_ms);
|
||||
|
||||
cfd = accept(lfd, NULL, NULL);
|
||||
if (cfd < 0) { perror("accept"); return 1; }
|
||||
close(lfd);
|
||||
tcp_nodelay(cfd);
|
||||
|
||||
ufd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
memset(&sa, 0, sizeof(sa));
|
||||
sa.sin_family = AF_INET;
|
||||
sa.sin_port = htons((unsigned short)up_port);
|
||||
if (inet_pton(AF_INET, up_host, &sa.sin_addr) != 1)
|
||||
{
|
||||
fprintf(stderr, "proxy: bad upstream host\n");
|
||||
return 1;
|
||||
}
|
||||
if (connect(ufd, (struct sockaddr *)&sa, sizeof(sa)) < 0)
|
||||
{
|
||||
perror("connect");
|
||||
return 1;
|
||||
}
|
||||
tcp_nodelay(ufd);
|
||||
fprintf(stderr, "proxy: relaying\n");
|
||||
|
||||
for (;;)
|
||||
{
|
||||
fd_set rd;
|
||||
struct timeval tv;
|
||||
int maxfd = (cfd > ufd ? cfd : ufd), rv;
|
||||
FD_ZERO(&rd);
|
||||
if (q_c2u.count < QCAP) FD_SET(cfd, &rd);
|
||||
if (q_u2c.count < QCAP) FD_SET(ufd, &rd);
|
||||
tv.tv_sec = 0;
|
||||
tv.tv_usec = 500; /* wake often enough to release due chunks */
|
||||
rv = select(maxfd + 1, &rd, NULL, NULL, &tv);
|
||||
if (rv < 0)
|
||||
{
|
||||
if (errno == EINTR) continue;
|
||||
break;
|
||||
}
|
||||
if (!pump(cfd, ufd, &q_c2u, (long long)delay_ms * 1000,
|
||||
FD_ISSET(cfd, &rd)))
|
||||
break;
|
||||
if (!pump(ufd, cfd, &q_u2c, (long long)delay_ms * 1000,
|
||||
FD_ISSET(ufd, &rd)))
|
||||
break;
|
||||
}
|
||||
close(cfd);
|
||||
close(ufd);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/* netlink_latency.c — measures UART link exchange throughput across the
|
||||
* full stack with REAL 68K polling code, with netlink_delay_proxy
|
||||
* between the endpoints simulating network latency.
|
||||
*
|
||||
* netlink_latency <core> --role echo --port N (tcp_client)
|
||||
* netlink_latency <core> --role probe --port N [opts] (tcp_server)
|
||||
*
|
||||
* Both sides boot a synthetic cartridge whose 68K program spins on
|
||||
* ASISTAT and echoes every received byte back incremented — the same
|
||||
* poll-the-link-mid-frame pattern Doom/BattleSphere use for lockstep
|
||||
* tic exchange. The probe host injects one byte to start the ping-pong
|
||||
* chain, then paces retro_run at 60 fps (like a real frontend) and
|
||||
* counts completed exchanges via JLinkRxTotal over --measure-sec.
|
||||
*
|
||||
* The exchange rate exposes receive-side frame quantization: the 68K's
|
||||
* poll loop burns its 16.7 ms of emulated frame time in ~1 ms of wall
|
||||
* clock, so without a wall-clock wait in the core any transport latency
|
||||
* slips the reply to a later frame and the rate collapses to ~1 per
|
||||
* video frame regardless of actual RTT (the Wi-Fi lag failure mode).
|
||||
*
|
||||
* probe opts: --measure-sec N (default 3), --min-rate X / --max-rate X
|
||||
* (pass/fail bounds on exchanges/sec), --wait enabled|disabled (the
|
||||
* virtualjaguar_netlink_wait core option; the wait budget itself is
|
||||
* adaptive inside the core, not user-tuned).
|
||||
* Exit 0 on pass, 1 on fail/error. Driven by netlink_latency_test.sh.
|
||||
*/
|
||||
#define _DEFAULT_SOURCE 1
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/time.h>
|
||||
#include "../harness/harness.h"
|
||||
|
||||
typedef void (*jerry_ww_t)(uint32_t, uint16_t, uint32_t);
|
||||
typedef uint16_t (*jerry_rw_t)(uint32_t, uint32_t);
|
||||
typedef int (*jlink_conn_t)(void);
|
||||
typedef uint32_t (*jlink_u32_t)(void);
|
||||
typedef void (*retro_run_t)(void);
|
||||
|
||||
#define ROM_SIZE 131072
|
||||
#define MAX_WAIT_FRAMES 900
|
||||
#define FRAME_USEC 16667
|
||||
|
||||
/* 68K UART echo loop, hand-assembled, entry $802000 (ROM offset $2000):
|
||||
* lea $F10030.l,a0 41F9 00F1 0030
|
||||
* move.w #$0001,$F10034.l 33FC 0001 00F1 0034 (fast baud)
|
||||
* poll:
|
||||
* move.w $F10032.l,d0 3039 00F1 0032 (ASISTAT)
|
||||
* btst #7,d0 0800 0007 (RBF)
|
||||
* beq.s poll 67F4
|
||||
* move.w (a0),d1 3210 (read ASIDATA)
|
||||
* addq.w #1,d1 5241
|
||||
* andi.w #$FF,d1 0241 00FF
|
||||
* move.w d1,(a0) 3081 (transmit)
|
||||
* bra.s poll 60E8
|
||||
*/
|
||||
static const uint8_t echo_prog[] = {
|
||||
0x41, 0xF9, 0x00, 0xF1, 0x00, 0x30,
|
||||
0x33, 0xFC, 0x00, 0x01, 0x00, 0xF1, 0x00, 0x34,
|
||||
0x30, 0x39, 0x00, 0xF1, 0x00, 0x32,
|
||||
0x08, 0x00, 0x00, 0x07,
|
||||
0x67, 0xF4,
|
||||
0x32, 0x10,
|
||||
0x52, 0x41,
|
||||
0x02, 0x41, 0x00, 0xFF,
|
||||
0x30, 0x81,
|
||||
0x60, 0xE8
|
||||
};
|
||||
|
||||
static const char *make_synth_rom(const char *role)
|
||||
{
|
||||
static char path[256];
|
||||
static uint8_t rom_buf[ROM_SIZE];
|
||||
FILE *f;
|
||||
const char *tmp = getenv("TMPDIR");
|
||||
snprintf(path, sizeof(path), "%s/vj_netlink_lat_%s.j64",
|
||||
tmp ? tmp : "/tmp", role);
|
||||
memset(rom_buf, 0, ROM_SIZE);
|
||||
rom_buf[0x404] = 0x00; rom_buf[0x405] = 0x80;
|
||||
rom_buf[0x406] = 0x20; rom_buf[0x407] = 0x00;
|
||||
memcpy(rom_buf + 0x2000, echo_prog, sizeof(echo_prog));
|
||||
f = fopen(path, "wb");
|
||||
if (!f) return NULL;
|
||||
fwrite(rom_buf, 1, ROM_SIZE, f);
|
||||
fclose(f);
|
||||
return path;
|
||||
}
|
||||
|
||||
static long long now_usec(void)
|
||||
{
|
||||
struct timeval tv;
|
||||
gettimeofday(&tv, NULL);
|
||||
return (long long)tv.tv_sec * 1000000LL + tv.tv_usec;
|
||||
}
|
||||
|
||||
/* One frontend-like frame slot: run, then sleep out the remainder. */
|
||||
static void paced_frame(retro_run_t run_frame)
|
||||
{
|
||||
long long fstart = now_usec(), spent;
|
||||
run_frame();
|
||||
spent = now_usec() - fstart;
|
||||
if (spent < FRAME_USEC)
|
||||
usleep((useconds_t)(FRAME_USEC - spent));
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
harness_config cfg = HARNESS_CONFIG_DEFAULT;
|
||||
const char *role = NULL;
|
||||
const char *port = "42171";
|
||||
const char *wait_opt = "enabled";
|
||||
int measure_sec = 3;
|
||||
double min_rate = -1.0, max_rate = -1.0;
|
||||
char port_env[64];
|
||||
jerry_ww_t jerry_ww;
|
||||
jerry_rw_t jerry_rw;
|
||||
jlink_conn_t jlink_connected;
|
||||
jlink_u32_t rx_total;
|
||||
retro_run_t run_frame;
|
||||
int i, connected = 0;
|
||||
|
||||
for (i = 1; i < argc; i++)
|
||||
{
|
||||
if (!strcmp(argv[i], "--role") && i + 1 < argc)
|
||||
{
|
||||
role = argv[++i];
|
||||
argv[i - 1] = argv[i] = (char *)"--quiet";
|
||||
}
|
||||
else if (!strcmp(argv[i], "--port") && i + 1 < argc)
|
||||
{
|
||||
port = argv[++i];
|
||||
argv[i - 1] = argv[i] = (char *)"--quiet";
|
||||
}
|
||||
else if (!strcmp(argv[i], "--measure-sec") && i + 1 < argc)
|
||||
{
|
||||
measure_sec = atoi(argv[++i]);
|
||||
argv[i - 1] = argv[i] = (char *)"--quiet";
|
||||
}
|
||||
else if (!strcmp(argv[i], "--min-rate") && i + 1 < argc)
|
||||
{
|
||||
min_rate = atof(argv[++i]);
|
||||
argv[i - 1] = argv[i] = (char *)"--quiet";
|
||||
}
|
||||
else if (!strcmp(argv[i], "--max-rate") && i + 1 < argc)
|
||||
{
|
||||
max_rate = atof(argv[++i]);
|
||||
argv[i - 1] = argv[i] = (char *)"--quiet";
|
||||
}
|
||||
else if (!strcmp(argv[i], "--wait") && i + 1 < argc)
|
||||
{
|
||||
wait_opt = argv[++i];
|
||||
argv[i - 1] = argv[i] = (char *)"--quiet";
|
||||
}
|
||||
}
|
||||
if (!role || (strcmp(role, "echo") && strcmp(role, "probe")))
|
||||
{
|
||||
fprintf(stderr, "usage: netlink_latency <core> --role echo|probe "
|
||||
"[--port N] [--measure-sec N] [--min-rate X] "
|
||||
"[--max-rate X] [--wait enabled|disabled]\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
snprintf(port_env, sizeof(port_env), "VJ_NETLINK_PORT=%s", port);
|
||||
putenv(port_env);
|
||||
putenv((char *)"VJ_NETLINK_HOST=127.0.0.1");
|
||||
|
||||
cfg.frames = 1;
|
||||
cfg.options[0].key = "virtualjaguar_netlink";
|
||||
cfg.options[0].value = !strcmp(role, "probe") ? "tcp_server" : "tcp_client";
|
||||
cfg.options[1].key = "virtualjaguar_netlink_port";
|
||||
cfg.options[1].value = port;
|
||||
cfg.options[2].key = "virtualjaguar_netlink_wait";
|
||||
cfg.options[2].value = wait_opt;
|
||||
cfg.num_options = 3;
|
||||
|
||||
if (!harness_init_from_args(&cfg, argc, argv)) return 1;
|
||||
if (!cfg.rom_path)
|
||||
{
|
||||
cfg.rom_path = make_synth_rom(role);
|
||||
if (!cfg.rom_path) return 1;
|
||||
}
|
||||
if (!harness_load_rom(&cfg)) return 1;
|
||||
|
||||
jerry_ww = (jerry_ww_t)harness_dlsym(&cfg, "JERRYWriteWord");
|
||||
jerry_rw = (jerry_rw_t)harness_dlsym(&cfg, "JERRYReadWord");
|
||||
jlink_connected = (jlink_conn_t)harness_dlsym(&cfg, "JLinkConnected");
|
||||
rx_total = (jlink_u32_t)harness_dlsym(&cfg, "JLinkRxTotal");
|
||||
run_frame = (retro_run_t)harness_dlsym(&cfg, "retro_run");
|
||||
if (!jerry_ww || !jerry_rw || !jlink_connected || !rx_total || !run_frame)
|
||||
{
|
||||
fprintf(stderr, "[%s] symbols missing — build with TEST_EXPORTS=1\n",
|
||||
role);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Rendezvous. */
|
||||
for (i = 0; i < MAX_WAIT_FRAMES && !connected; i++)
|
||||
{
|
||||
run_frame();
|
||||
usleep(5000);
|
||||
if (jlink_connected())
|
||||
connected = 1;
|
||||
}
|
||||
if (!connected)
|
||||
{
|
||||
fprintf(stderr, "[%s] FAIL: link never came up\n", role);
|
||||
harness_shutdown(&cfg);
|
||||
return 1;
|
||||
}
|
||||
fprintf(stderr, "[%s] link up\n", role);
|
||||
|
||||
if (!strcmp(role, "echo"))
|
||||
{
|
||||
/* The 68K does all the work. Deliberately NOT paced at 60 fps:
|
||||
a free-running echo answers within ~2 ms consistently, so the
|
||||
measured rate isolates the PROBE side's frame quantization
|
||||
instead of beating against a second paced loop's drifting
|
||||
phase (which made the metric noisy run-to-run). */
|
||||
uint32_t last = rx_total();
|
||||
int idle = 0;
|
||||
while (jlink_connected() && idle < 8000) /* ~10 s of silence */
|
||||
{
|
||||
uint32_t now_rx;
|
||||
run_frame();
|
||||
usleep(1000);
|
||||
now_rx = rx_total();
|
||||
idle = (now_rx != last) ? 0 : idle + 1;
|
||||
last = now_rx;
|
||||
}
|
||||
fprintf(stderr, "[echo] done\n");
|
||||
harness_shutdown(&cfg);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Probe: kick the ping-pong chain, warm up, then measure. */
|
||||
{
|
||||
long long t0, t1;
|
||||
uint32_t rx0, rx1;
|
||||
double rate, elapsed;
|
||||
|
||||
jerry_ww(0xF10030, 0x0040, 0); /* first byte starts the chain */
|
||||
for (i = 0; i < 60; i++) /* 1 s warmup: lets the core's
|
||||
adaptive wait budget converge
|
||||
before the measured window */
|
||||
paced_frame(run_frame);
|
||||
if (rx_total() == 0)
|
||||
{
|
||||
fprintf(stderr, "[probe] FAIL: chain never started\n");
|
||||
harness_shutdown(&cfg);
|
||||
return 1;
|
||||
}
|
||||
|
||||
rx0 = rx_total();
|
||||
t0 = now_usec();
|
||||
while (now_usec() - t0 < (long long)measure_sec * 1000000LL)
|
||||
paced_frame(run_frame);
|
||||
t1 = now_usec();
|
||||
rx1 = rx_total();
|
||||
|
||||
elapsed = (double)(t1 - t0) / 1000000.0;
|
||||
rate = (double)(rx1 - rx0) / elapsed;
|
||||
printf("[probe] %.1f exchanges/sec (%u exchanges in %.2f s, "
|
||||
"wait=%s)\n", rate, rx1 - rx0, elapsed, wait_opt);
|
||||
|
||||
harness_shutdown(&cfg);
|
||||
if (min_rate >= 0.0 && rate < min_rate)
|
||||
{
|
||||
printf("[probe] FAIL: rate %.1f < min %.1f\n", rate, min_rate);
|
||||
return 1;
|
||||
}
|
||||
if (max_rate >= 0.0 && rate > max_rate)
|
||||
{
|
||||
printf("[probe] FAIL: rate %.1f > max %.1f "
|
||||
"(proxy delay not in effect?)\n", rate, max_rate);
|
||||
return 1;
|
||||
}
|
||||
printf("[probe] PASS\n");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
# netlink_latency_test.sh — proves the netlink reply wait defeats
|
||||
# receive-side frame quantization. Both cases run the 68K ping-pong
|
||||
# exchange through netlink_delay_proxy adding 3 ms each way (~Wi-Fi):
|
||||
#
|
||||
# disabled: replies quantize to whole video frames — the exchange
|
||||
# rate collapses to ~1 per frame regardless of actual RTT.
|
||||
# enabled (default): the adaptive wait rides out the RTT inside the
|
||||
# frame and sustains a multiple of the quantized rate.
|
||||
#
|
||||
# CI runners vary widely in speed, so the assertion is a RATIO —
|
||||
# enabled must beat disabled by >= 1.5x (measured ~2x locally, ~2.7x on
|
||||
# GH Actions macOS) — plus an absolute ceiling on the control proving
|
||||
# the injected delay actually quantized it.
|
||||
#
|
||||
# Usage: netlink_latency_test.sh <core>
|
||||
set -u
|
||||
|
||||
CORE="${1:?usage: netlink_latency_test.sh <core>}"
|
||||
TOOLS="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROXY_PID=""
|
||||
PROBE_PID=""
|
||||
|
||||
cleanup()
|
||||
{
|
||||
[ -n "$PROXY_PID" ] && kill "$PROXY_PID" 2>/dev/null
|
||||
[ -n "$PROBE_PID" ] && kill "$PROBE_PID" 2>/dev/null
|
||||
wait 2>/dev/null
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'cleanup; trap - EXIT; exit 130' INT TERM
|
||||
|
||||
# run_case <label> <core_port> <proxy_port> <wait>
|
||||
# Prints the measured exchanges/sec on stdout; returns nonzero on error.
|
||||
run_case()
|
||||
{
|
||||
local label="$1" core_port="$2" proxy_port="$3" wait_opt="$4"
|
||||
local probe_log rate
|
||||
probe_log="$(mktemp "${TMPDIR:-/tmp}/vj_nl_lat_XXXXXX.log")"
|
||||
|
||||
"$TOOLS/netlink_delay_proxy" --listen "$proxy_port" \
|
||||
--connect "127.0.0.1:$core_port" --delay-ms 3 2>/dev/null &
|
||||
PROXY_PID=$!
|
||||
sleep 0.3
|
||||
"$TOOLS/netlink_latency" "$CORE" --role probe --port "$core_port" \
|
||||
--measure-sec 2 --wait "$wait_opt" >"$probe_log" 2>&1 &
|
||||
PROBE_PID=$!
|
||||
sleep 0.3
|
||||
"$TOOLS/netlink_latency" "$CORE" --role echo --port "$proxy_port" \
|
||||
--wait "$wait_opt" >/dev/null 2>&1
|
||||
wait "$PROBE_PID"
|
||||
local rv=$?
|
||||
PROBE_PID=""
|
||||
kill "$PROXY_PID" 2>/dev/null
|
||||
wait "$PROXY_PID" 2>/dev/null
|
||||
PROXY_PID=""
|
||||
grep -E "exchanges/sec|FAIL" "$probe_log" | sed "s/^/[$label] /" >&2
|
||||
rate="$(sed -n 's/.*\] \([0-9.]*\) exchanges\/sec.*/\1/p' "$probe_log" | head -1)"
|
||||
rm -f "$probe_log"
|
||||
[ "$rv" -ne 0 ] && return "$rv"
|
||||
[ -z "$rate" ] && return 1
|
||||
echo "$rate"
|
||||
return 0
|
||||
}
|
||||
|
||||
rate_off="$(run_case "control disabled" 42751 42761 disabled)" || {
|
||||
echo "netlink_latency_test: FAIL (control case errored)"; exit 1; }
|
||||
rate_on="$(run_case "fixed enabled" 42752 42762 enabled)" || {
|
||||
echo "netlink_latency_test: FAIL (enabled case errored)"; exit 1; }
|
||||
|
||||
# Frame quantization needs emulation to run much faster than real time:
|
||||
# a frame's 68K spin then burns out in ~1 ms of wall clock and the
|
||||
# delayed reply always slips to the next retro_run. On heavily
|
||||
# instrumented builds (ASan, gcov) run_frame is SLOWER than real time,
|
||||
# the spin itself spans the network delay, and the control never
|
||||
# quantizes (seen: 110/s on the ASan runner). The bug physically
|
||||
# cannot manifest there, so the test skips rather than asserting.
|
||||
slow=$(awk -v off="$rate_off" 'BEGIN { print (off > 65) ? "yes" : "no" }')
|
||||
if [ "$slow" = "yes" ]; then
|
||||
echo "netlink_latency_test: SKIP (control=$rate_off/s never quantized —" \
|
||||
"runner emulates slower than real time, cannot exhibit the bug)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Control sanity: the chain ran, and the injected delay quantized it
|
||||
# (paced 60 fps probe can't exceed ~60/s when every reply slips a frame).
|
||||
ok=$(awk -v off="$rate_off" -v on="$rate_on" 'BEGIN {
|
||||
print (off >= 5 && on >= 1.5 * off) ? "yes" : "no" }')
|
||||
if [ "$ok" != "yes" ]; then
|
||||
echo "netlink_latency_test: FAIL (disabled=$rate_off enabled=$rate_on;" \
|
||||
"need disabled >= 5 and enabled >= 1.5x disabled)"
|
||||
exit 1
|
||||
fi
|
||||
echo "netlink_latency_test: PASS (disabled=$rate_off enabled=$rate_on)"
|
||||
Reference in new issue
Block a user