aboutsummaryrefslogtreecommitdiffstats
path: root/usr/irc.c
blob: 441d98a2c0d0969b180e68dfd1ff2a1250e93a5b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
#include "resolve.h"
/* irc HOST [NICK] - a bitchx/irssi-flavored IRC client on 40x18 columns.
   The kernel owns TCP; this owns the protocol and the screen.

   UI: messages scroll above; the bottom line is an irssi-style input line
   "[#chan] text_" redrawn in place with \r (the terminal has no cursor
   addressing - \r + overprint is the entire trick). Long input scrolls
   horizontally like irssi. Keys come from the OSK (SELECT toggles it) and
   from the console ring (pollcon), so a hub can type into the client too.

   Formats, straight from the elders:
     <nick> text        channel message      * nick text   CTCP ACTION
     <nick:#c> text     msg to a non-current channel
     *nick* text        private msg          >target< text  outbound /msg
     -nick- text        notice               -!- text       server/status  */

/* seg first + large: the arg string lives at 0xA000 and the first static
   lands on it; args are copied out in main() before seg is ever written. */
static unsigned char seg[220];      /* one TCP segment from the kernel   */
static char line[240];              /* server line accumulator           */
static unsigned char llen;
static char input[140];             /* the input line being typed        */
static unsigned char inlen;
static unsigned char drawn;         /* chars currently drawn on input row */
static char nick[16];
static char target[32];             /* current channel/query, "" = none  */
static char sbuf[200];              /* outgoing IRC line builder         */
static unsigned char slen;
static unsigned char namebuf[64];
static unsigned char dst[4];
static unsigned char sock;
static unsigned char quitting;
static unsigned char bar_up;            /* is the status/input bar currently drawn? */
/* A WRITABLE empty string for parse defaults. handle() writes terminators
   into pfx (bang) and txt (strip_fmt); a read-only "" literal lives in ROM
   ($4000-$5FFF), where a write is an MBC5 *RAM-bank-select* - it would swap
   the whole $A000 data bank out from under every static (instant garbage).
   A prefix-less or empty-trailing server line is enough to trigger it. */
static char empty[1];

/* ---- tiny string helpers (SDCC libc is us) ------------------------------- */
static unsigned char sceq(const char *a, const char *b) {   /* case-insens. */
    unsigned char ca, cb;
    for (;;) {
        ca = (unsigned char)*a++; cb = (unsigned char)*b++;
        if (ca >= 'a' && ca <= 'z') ca -= 32;
        if (cb >= 'a' && cb <= 'z') cb -= 32;
        if (ca != cb) return 0;
        if (!ca) return 1;
    }
}
static void scpy(char *d, const char *s, unsigned char max) {
    unsigned char i;
    for (i = 0; s[i] && i < (unsigned char)(max - 1); i++) d[i] = s[i];
    d[i] = 0;
}

/* ---- outgoing IRC lines --------------------------------------------------- */
static void sc(const char *s) {           /* append to sbuf */
    while (*s && slen < 197) sbuf[slen++] = *s++;
}
static void sfin(void) {                  /* CRLF + send */
    sbuf[slen++] = '\r'; sbuf[slen++] = '\n';
    net_send(sock, sbuf, slen);
    slen = 0;
}

/* IRC color/format -> ANSI (defined below); used by the say* line printers. */
static void putfmt(const char *s);
static void ansi_reset(void);
static void esc(const char *s) { putc(27); puts(s); }   /* emit a CSI sequence */

#define BARW 39                 /* status/input bar width (keep < 40: no wrap) */

/* ---- the input line ------------------------------------------------------- */
/* erase what's drawn on the bottom row, park at col 0 (default colors) */
static void row_clear(void) {
    unsigned char i;
    if (!bar_up && !drawn) return;        /* nothing on the row: skip the work */
    esc("[0m");                           /* reset, so we erase to black bg */
    putc('\r');
    for (i = 0; i < drawn; i++) putc(' ');
    putc('\r');
    drawn = 0;
    bar_up = 0;
}
/* Redraw the irssi-style status/input bar on the bottom row: a full-width blue
   background, "[channel]" prompt (channel in yellow), then the tail of the
   input line. We paint the whole blue bar first, then overprint the content so
   the hardware cursor lands right after the text (irssi feel) instead of at the
   far edge. Total stays < 40 columns so it never wraps. */
static void row_draw(void) {
    unsigned char pl, vis, i;
    const char *t = target[0] ? target : "(status)";
    row_clear();
    esc("[44m");                          /* blue background for the whole bar */
    for (i = 0; i < BARW; i++) putc(' ');
    putc('\r');
    esc("[37m"); putc('[');               /* white bracket */
    esc("[33m");                          /* channel in yellow */
    for (pl = 0; t[pl] && pl < 12; pl++) putc(t[pl]);
    esc("[37m"); putc(']'); putc(' ');    /* white bracket, on to input */
    pl += 3;                              /* prompt width: '[' + name + '] ' */
    vis = (unsigned char)(BARW - pl - 1); /* room for text (cursor gets 1) */
    i = (inlen > vis) ? (unsigned char)(inlen - vis) : 0;
    for (; i < inlen; i++) putc(input[i]);
    drawn = BARW;                         /* whole bar is dirty next clear */
    esc("[0m");                           /* back to defaults for messages */
    bar_up = 1;
}
/* Print a message line above the input line. NB: these deliberately do NOT
   redraw the status bar - the caller redraws it once after a whole batch of
   lines (see the recv loop), so a flood repaints the 39-cell bar once, not once
   per line. row_clear() erases the bar on the first line of a batch; later
   lines short-circuit it (nothing left to clear). */
static void say(const char *s) {
    row_clear();
    putfmt(s); ansi_reset(); nl();
}
static void say2(const char *a, const char *b) {
    row_clear();
    putfmt(a); putfmt(b); ansi_reset(); nl();
}
static void say3(const char *a, const char *b, const char *c) {
    row_clear();
    putfmt(a); putfmt(b); putfmt(c); ansi_reset(); nl();
}

/* ---- server line parsing -------------------------------------------------- */
/* split "pfx!user@host" -> pfx nick only (in place) */
static void bang(char *p) {
    while (*p && *p != '!') p++;
    *p = 0;
}
static unsigned char isnum(const char *c) {
    return c[0] >= '0' && c[0] <= '9';
}
static unsigned char ishex(char c) {
    return (c>='0'&&c<='9')||(c>='a'&&c<='f')||(c>='A'&&c<='F');
}

/* Map an mIRC color index (0..15, plus the 16..98 extended palette) to the
   nearest of our 7 renderable ANSI colors. We have no black/grey/background,
   so those fold onto white. Returns an ANSI fg code 31..37. */
static unsigned char mirc2ansi(unsigned char n) {
    switch (n) {
        case 2: case 12:  return 34;   /* blue / light blue        */
        case 3: case 9:   return 32;   /* green / light green      */
        case 4: case 5:   return 31;   /* red / brown              */
        case 6: case 13:  return 35;   /* purple / pink -> magenta */
        case 7: case 8:   return 33;   /* orange / yellow          */
        case 10: case 11: return 36;   /* teal / cyan              */
        default:          return 37;   /* white, black, grey, 16+  */
    }
}
static void ansi_reset(void) {                 /* ESC [ 0 m : back to default */
    putc(27); putc('['); putc('0'); putc('m');
}
static void ansi_set(unsigned char code) {     /* code 31..37 -> ESC [ 3 x m */
    putc(27); putc('['); putc('3'); putc((char)('0' + code - 30)); putc('m');
}

/* Print an IRC message, translating formatting to what the LCD can show:
   \x03 N[,BG] -> the mapped ANSI color (background ignored - we have none);
   \x03 alone and \x0f -> reset to default; \x04 RRGGBB[,RRGGBB] hex color is
   dropped (no true-color). Bold/underline/italic/reverse/strike/mono and any
   other control byte (including a raw ESC, so a server can't drive the
   terminal) are filtered out. Emitted on the fly, so nothing grows in place. */
static void putfmt(const char *s) {
    unsigned char c, d, n;
    while ((c = (unsigned char)*s++)) {
        if (c == 3) {                              /* mIRC color */
            if (*s >= '0' && *s <= '9') {
                n = (unsigned char)(*s++ - '0');
                if (*s >= '0' && *s <= '9')
                    n = (unsigned char)((n << 3) + (n << 1) + (*s++ - '0'));  /* n*10+d */
                if (*s == ',' && s[1] >= '0' && s[1] <= '9') {   /* skip ,background */
                    s += 2;
                    if (*s >= '0' && *s <= '9') s++;
                }
                ansi_set(mirc2ansi(n));
            } else {
                ansi_reset();                      /* \x03 with no digits = reset */
            }
            continue;
        }
        if (c == 4) {                              /* \x04 hex color: drop code + args */
            d = 0; while (ishex(*s) && d < 6) { s++; d++; }
            if (*s == ',' && ishex(s[1])) { s++; d = 0; while (ishex(*s) && d < 6) { s++; d++; } }
            continue;
        }
        if (c == 15) { ansi_reset(); continue; }   /* \x0f reset all formatting */
        if (c < 32) continue;                      /* bold/underline/... + raw ESC */
        putc((char)c);
    }
}

/* bitchx-style: give each nick a stable color from a hash of its name, so the
   same person is always the same color. 7 colors (31..37), no modulo op. */
static unsigned char nick_color(const char *n) {
    unsigned char h = 0, i;
    for (i = 0; n[i]; i++) h = (unsigned char)(h + (unsigned char)n[i] * 3);
    while (h >= 6) h = (unsigned char)(h - 6);   /* 6 colors 31..36 (skip white) */
    return (unsigned char)(31 + h);
}
static void put_nick(const char *n) {
    ansi_set(nick_color(n));
    puts(n);
    ansi_reset();
}

static void handle(char *l) {
    char *pfx = empty, *cmd, *arg, *txt = empty;
    char *p = l;

    if (*p == ':') {                       /* :prefix */
        pfx = ++p;
        while (*p && *p != ' ') p++;
        if (*p) *p++ = 0;
    }
    cmd = p;                               /* command */
    while (*p && *p != ' ') p++;
    if (*p) *p++ = 0;
    arg = p;                               /* params, then :trailing */
    while (*p) {
        if (*p == ':' && (p == arg || p[-1] == 0)) { txt = p + 1; p[-1] = 0; break; }
        if (*p == ' ') *p = 0;
        p++;
    }
    /* arg = first param (NUL-joined list), txt = trailing. Formatting is now
       translated when printed (putfmt) rather than stripped here, so mIRC
       colors survive as ANSI while bold/underline/etc. are filtered out. */

    if (sceq(cmd, "PING")) {
        slen = 0; sc("PONG :"); sc(txt[0] ? txt : arg); sfin();
        say("-!- Ping? Pong!");
        return;
    }
    bang(pfx);
    if (sceq(cmd, "PRIVMSG")) {
        char *dest = arg;
        unsigned char act = 0;
        if (txt[0] == 1) {                 /* CTCP */
            char *e = txt + 1;
            while (*e && *e != 1) e++;
            *e = 0; txt++;
            if (txt[0]=='A'&&txt[1]=='C'&&txt[2]=='T'&&txt[3]=='I'&&txt[4]=='O'&&txt[5]=='N'&&txt[6]==' ') {
                act = 1; txt += 7;
            } else if (txt[0]=='V') {      /* VERSION */
                slen = 0; sc("NOTICE "); sc(pfx); sc(" :\001VERSION gbos:sl0pboy:GameBoyColor\001"); sfin();
                return;
            } else return;
        }
        row_clear();
        if (act)                { puts("* "); put_nick(pfx); putc(' '); }
        else if (sceq(dest, nick)) { putc('*'); put_nick(pfx); puts("* "); }
        else if (sceq(dest, target)) { putc('<'); put_nick(pfx); puts("> "); }
        else                    { putc('<'); put_nick(pfx); putc(':'); puts(dest); puts("> "); }
        putfmt(txt); ansi_reset(); nl();
        return;
    }
    if (sceq(cmd, "NOTICE")) {
        row_clear();
        putc('-'); puts(pfx[0] ? pfx : "server"); puts("- "); putfmt(txt); ansi_reset(); nl();
        return;
    }
    if (sceq(cmd, "JOIN")) {
        char *ch = txt[0] ? txt : arg;
        if (sceq(pfx, nick)) {
            scpy(target, ch, sizeof(target));
            say2("-!- now talking in ", target);
        } else say3("-!- ", pfx, " has joined");
        return;
    }
    if (sceq(cmd, "PART")) {
        if (sceq(pfx, nick)) { target[0] = 0; say("-!- you left"); }
        else say3("-!- ", pfx, " has left");
        return;
    }
    if (sceq(cmd, "QUIT")) { say3("-!- ", pfx, " has quit"); return; }
    if (sceq(cmd, "KICK")) {
        char *ch = arg, *who = arg;
        while (*who) who++;
        who++;                              /* param 2: the victim */
        if (sceq(who, nick)) { target[0] = 0; say2("-!- you were kicked from ", ch); }
        else say3("-!- ", who, " was kicked");
        return;
    }
    if (sceq(cmd, "NICK")) {
        char *nn = txt[0] ? txt : arg;
        if (sceq(pfx, nick)) scpy(nick, nn, sizeof(nick));
        row_clear();
        puts("-!- "); puts(pfx); puts(" is now known as "); puts(nn); nl();
        return;
    }
    if (isnum(cmd)) {
        if (cmd[0]=='4'&&cmd[1]=='3'&&cmd[2]=='3') {   /* nick in use */
            unsigned char n = strlen(nick);
            if (n < 14) { nick[n] = '_'; nick[n+1] = 0; }
            slen = 0; sc("NICK "); sc(nick); sfin();
            say2("-!- nick in use, trying ", nick);
            return;
        }
        if (cmd[0]=='3'&&cmd[1]=='3'&&cmd[2]=='2') {   /* topic */
            say2("-!- topic: ", txt);
            return;
        }
        if (cmd[0]=='3'&&cmd[1]=='5'&&cmd[2]=='3') {   /* names */
            say2("-!- users: ", txt);
            return;
        }
        if (cmd[1]=='3'&&cmd[0]=='3') return;          /* 333 topic-by: skip */
        if (cmd[0]=='0'&&cmd[1]=='0'&&(cmd[2]=='4'||cmd[2]=='5')) return; /* MYINFO/ISUPPORT noise */
        /* MOTD can be long and the LCD is slow to scroll it, so suppress it
           client-side (the server still sends it to normal clients). 375 start
           prints one marker; 372 lines / 376 end / 377-378 spam are dropped. */
        if (cmd[0]=='3'&&cmd[1]=='7'&&cmd[2]=='5') { say("-!- motd (suppressed)"); return; }
        if (cmd[0]=='3'&&cmd[1]=='7'&&
            (cmd[2]=='2'||cmd[2]=='6'||cmd[2]=='7'||cmd[2]=='8')) return;
        if (cmd[0]=='4'&&cmd[1]=='2'&&cmd[2]=='2') return; /* 422 no MOTD */
        if (txt[0]) say2("-!- ", txt);                 /* welcome & friends */
        return;
    }
    /* anything else: show it raw-ish, bitchx would too */
    if (txt[0]) say3(cmd, " ", txt);
}

/* accumulate TCP bytes into lines */
static void feed(unsigned char *b, unsigned char n) {
    unsigned char i;
    for (i = 0; i < n; i++) {
        char c = (char)b[i];
        if (c == '\r') continue;
        if (c == '\n') { line[llen] = 0; if (llen) handle(line); llen = 0; }
        else if (llen < 239) line[llen++] = c;
    }
}

/* ---- typed input ---------------------------------------------------------- */
static void run_cmd(char *t) {
    char *a = t;
    while (*a && *a != ' ') a++;
    if (*a) *a++ = 0;                      /* t = /cmd, a = rest */

    if (sceq(t, "/join") || sceq(t, "/j")) {
        if (!*a) { say("-!- join what?"); return; }
        slen = 0; sc("JOIN "); sc(a); sfin();
        return;
    }
    if (sceq(t, "/part") || sceq(t, "/p")) {
        slen = 0; sc("PART "); sc(*a ? a : target); sfin();
        return;
    }
    if (sceq(t, "/msg")) {
        char *m = a;
        while (*m && *m != ' ') m++;
        if (*m) *m++ = 0;
        if (!*a || !*m) { say("-!- /msg nick text"); return; }
        slen = 0; sc("PRIVMSG "); sc(a); sc(" :"); sc(m); sfin();
        row_clear();
        putc('>'); put_nick(a); puts("< "); puts(m); nl();
        return;
    }
    if (sceq(t, "/me")) {
        if (!target[0]) { say("-!- no channel"); return; }
        slen = 0; sc("PRIVMSG "); sc(target); sc(" :\001ACTION "); sc(a); sc("\001"); sfin();
        row_clear();
        puts("* "); put_nick(nick); putc(' '); puts(a); nl();
        return;
    }
    if (sceq(t, "/nick")) {
        if (!*a) { say("-!- /nick newnick"); return; }
        slen = 0; sc("NICK "); sc(a); sfin();
        return;
    }
    if (sceq(t, "/quit")) {
        slen = 0; sc("QUIT :"); sc(*a ? a : "sl0pboy out"); sfin();
        quitting = 1;
        return;
    }
    if (sceq(t, "/raw") || sceq(t, "/quote")) {
        slen = 0; sc(a); sfin();
        return;
    }
    say("-!- commands: /join /part /msg /me /nick /quit /raw");
}

static void submit(void) {
    input[inlen] = 0;
    if (!inlen) return;
    if (input[0] == '/') {
        inlen = 0;
        run_cmd(input);                    /* bar redrawn by key() after submit */
        return;
    }
    if (!target[0]) { inlen = 0; say("-!- join a channel first (/join #gb)"); return; }
    slen = 0; sc("PRIVMSG "); sc(target); sc(" :"); sc(input); sfin();
    row_clear();
    putc('<'); put_nick(nick); puts("> "); puts(input); nl();
    inlen = 0;
}

/* width of the "[channel] " prompt, to know where the input scrolls */
static unsigned char prompt_w(void) {
    const char *t = target[0] ? target : "(status)";
    unsigned char n = 0;
    while (t[n] && n < 12) n++;
    return (unsigned char)(n + 3);
}

/* Handle one typed byte. The expensive full-bar row_draw() is avoided for the
   common cases: appending a char that still fits just prints that one cell (on
   the blue bg), and backspacing just erases one cell (\b keeps its blue color).
   Only when the input scrolls horizontally past the visible width, or on a
   submit that printed something, do we repaint the whole bar. */
static void key(unsigned char c) {
    unsigned char vis;
    if (c == '\n' || c == '\r') { submit(); if (!bar_up) row_draw(); return; }
    vis = (unsigned char)(BARW - prompt_w() - 1);
    if (c == 8 || c == 127) {              /* backspace */
        if (!inlen) return;
        inlen--;
        if (inlen >= vis) row_draw();      /* window scrolled: full repaint */
        else putc(8);                      /* else drop one cell (stays blue) */
        return;
    }
    if (c < 32) return;
    if (inlen >= 139) return;
    if (inlen < vis) {                      /* fits: print just the new cell */
        input[inlen++] = (char)c;
        esc("[44m"); esc("[37m"); putc(c); esc("[0m");
    } else {                               /* would scroll: full repaint */
        input[inlen++] = (char)c;
        row_draw();
    }
}

/* ---- main ----------------------------------------------------------------- */
void main(void) {
    char *argp;
    unsigned char i, n, idle;

    /* gbos does not zero C statics (crt0 zeroes nothing; fork inherits the
       parent's RAM bank) - initialize every piece of state explicitly */
    llen = inlen = drawn = slen = quitting = 0;
    bar_up = 0;
    target[0] = 0;
    empty[0] = 0;                          /* gbos doesn't zero BSS */

    argp = getargs();                      /* copy args before seg is used */
    for (i = 0; argp[i] && i < 63; i++) namebuf[i] = (unsigned char)argp[i];
    namebuf[i] = 0;

    /* split "HOST [NICK]" */
    scpy(nick, "sl0pboy", sizeof(nick));
    for (i = 0; namebuf[i]; i++)
        if (namebuf[i] == ' ') {
            namebuf[i] = 0;
            if (namebuf[i+1]) scpy(nick, (char *)namebuf + i + 1, sizeof(nick));
            break;
        }
    if (!namebuf[0]) {
        puts("usage: irc HOST [NICK]     (port 6667)"); nl();
        puts("  then: /join #chan, SELECT = keyboard"); nl();
        sexit(1);
    }

    if (!resolve((char *)namebuf, dst)) {
        puts("irc: cannot resolve "); puts((char *)namebuf); nl(); sexit(1);
    }

    sock = net_socket(SOCK_TCP);
    if (sock == 0xFF) { puts("irc: no socket"); nl(); sexit(1); }
    /* vary the local port per run (DIV timer noise): a fixed port can hit a
       stale half-dead connection at the server after an unclean exit */
    net_bind(sock, 41000 + ((unsigned int)*(volatile unsigned char *)0xFF04 << 2) + getpid());
    puts("-!- connecting to "); puts((char *)namebuf); puts(":6667"); nl();
    if (net_connect(sock, dst, 6667) == 0xFF) {
        puts("irc: connect failed"); nl(); net_close(sock); sexit(1);
    }

    slen = 0; sc("NICK "); sc(nick); sfin();
    slen = 0; sc("USER gbos 0 * :sl0pboy on gbos"); sfin();
    puts("-!- registering as "); puts(nick);
    puts(" (SELECT = keyboard)"); nl();
    row_draw();

    idle = 0;
    for (;;) {
        n = net_recv_nb(sock, seg, (unsigned char)sizeof(seg));
        if (n == 0) { say("-!- disconnected"); break; }
        if (n != 0xFE && n != 0xFF) {
            feed(seg, n);                  /* render a whole segment of lines */
            if (!bar_up) row_draw();       /* then repaint the bar ONCE */
        }
        if (quitting) { say("-!- quit"); break; }

        /* Poll input EVERY pass, even mid-flood: otherwise a big MOTD (or a
         * busy channel) starves the keyboard for its whole duration - the OSK
         * won't even open, and keystrokes are dropped. That's what made
         * '/join #sl0p right after connect' look broken. */
        i = pollin();                      /* OSK */
        if (!i) i = pollcon();             /* hub-injected console bytes */
        if (i) key(i);

        if (n == 0xFE || n == 0xFF) {      /* nothing received this pass */
            if (!i && ++idle >= 4) { msleep(20); idle = 4; }
        } else {
            idle = 0;                      /* actively receiving; don't sleep */
        }
    }
    net_close(sock);
    row_clear();
    sexit(0);
}