feat: color the agent console and log every command

DOS 콘솔에서 직접 보기 위한 네 가지 수정.

1. 컬러. Open Watcom의 conio.h에는 textattr()이 없고(cprintf/cputs/getch만
   제공) 이 FreeDOS 콘솔은 ANSI 이스케이프도 해석하지 않는다. 그래서 cprintf가
   줄을 배치하게 두고 -- 스크롤을 알아서 처리한다 -- 방금 쓴 셀의 VGA 속성
   바이트만 다시 칠한다. 커서가 그 줄 다음 행의 0열에 있다는 점을 이용해
   스크롤을 직접 추적하지 않고 대상 셀을 찾는다. 줄바꿈된 긴 줄도 처리한다.

2. GET, HASH, LIST, READ, WRITE, QUIT, 미지원 명령을 로깅한다. 이전에는
   EXEC과 PUT만 보였다. PING은 wait-ready가 초당 두 번 폴링하므로 제외한다.
   LIST는 잘림 여부를, PUT은 short write를 구분해 남긴다.

3. put_path가 채워지기만 하고 쓰이지 않아 PUT 완료 줄에 경로가 없었다.
   완료 경로가 둘(길이 0, 본문 수신 완료)이라 put_finished()로 합쳤다.

4. 틱->초 변환이 1.1% 빨랐다. BIOS 틱은 18.2065Hz이므로 delta/18이 아니라
   delta*549/100 (하루치 틱에도 32비트를 넘지 않는다) 을 쓴다.

REBUILD.BAT을 추가한다. BUILD.BAT은 현재 디렉터리에서 wmake만 실행하는데
호스트가 exec으로 부를 때의 시작 디렉터리가 거기가 아니다.

QEMU FreeDOS에서 Open Watcom C++16으로 빌드하고(no warnings, -we 활성)
콘솔 스크린샷으로 색상, 줄바꿈 색칠, 명령별 로그를 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012PQm6oAvWX4Lp3iSN5AHGT
This commit is contained in:
2026-08-16 17:13:05 +09:00
co-authored by Claude Opus 5
parent eff5cbfb12
commit 8c3dee222e
3 changed files with 140 additions and 39 deletions
+40 -4
View File
@@ -40,7 +40,43 @@ uv run ferro-vm put host-file 'C:\DOS\FILE'
uv run ferro-vm get 'C:\DOS\FILE' host-file
```
The foreground agent shows timestamped connection, transfer, and command
start/finish lines. It also keeps the same metadata in `C:\TCPAGENT.LOG`,
rotating files larger than 256 KiB to `C:\TCPAGENT.OLD`. Payloads and command
output are never written to that metadata log.
## Agent-side logging
The foreground agent prints one timestamped line per event on the VGA console
and keeps the same text in `C:\TCPAGENT.LOG`, rotating files larger than 256 KiB
to `C:\TCPAGENT.OLD`. Payloads and command output are never written to that
metadata log.
Every command is logged with a request line and a result line carrying byte
counts and elapsed time — `EXEC`, `PUT`, `GET`, `HASH`, `LIST`, `READ`, and
`WRITE`. `PING` is deliberately excluded because `wait-ready` polls it twice a
second. Connection events (`connecting`, `connected`, `connect failed; retry N`,
`link lost`) are logged too; those are invisible to the host by definition,
since they happen when the socket is down.
Lines are colored by writing VGA attribute bytes after `cprintf` lays out the
line: gray timestamps, cyan requests, yellow `EXEC` command text, green success,
red failure. Open Watcom's DOS `conio.h` has no `textattr()`, and ANSI escapes
are not interpreted on this FreeDOS console, so neither of the usual routes
works. Elapsed times come from the BIOS tick counter at 18.2065 Hz (~55 ms
resolution).
Note that mTCP is not driven while `system()` runs a child, so a DOS command
lasting tens of seconds can drop the TCP connection. The agent logs `link lost`
and reconnects on its own, but the host loses that command's result.
## Rebuilding inside the VM
`REBUILD.BAT` compiles and installs the agent in a single `exec`. `BUILD.BAT`
only runs `wmake` in the current directory, which is not where a host-driven
`ferro-vm exec` starts.
```powershell
uv run ferro-vm put tools/tcpagent/tcpagent.cpp 'C:\MTSRC\MTCP\APPS\TCPAGENT\TCPAGENT.CPP'
uv run ferro-vm put tools/tcpagent/REBUILD.BAT 'C:\REBUILD.BAT'
uv run ferro-vm exec 'C:\REBUILD.BAT'
uv run ferro-vm reset
```
The reset is required: the running agent holds the old image in memory, and
`C:\FDAUTO.BAT` starts it at boot.
+22
View File
@@ -0,0 +1,22 @@
@echo off
rem Rebuild TCPAGENT.EXE from C:\MTSRC and install it, in one EXEC.
rem BUILD.BAT only runs wmake in the current directory, which is not where a
rem host-driven `ferro-vm exec` starts. Copy this to C:\ and run it by path.
rem The running agent keeps the old image in memory, so reset the VM afterwards:
rem uv run ferro-vm reset
C:
cd C:\MTSRC\MTCP\APPS\TCPAGENT
set WATCOM=C:\DEVEL\WATCOMC
set PATH=C:\DEVEL\WATCOMC\BINW;C:\DEVEL\WATCOMC\BINP;C:\FREEDOS\BIN
set INCLUDE=C:\DEVEL\WATCOMC\H
set EDPATH=
if exist TCPAGENT.OBJ del TCPAGENT.OBJ
if exist TCPAGENT.EXE del TCPAGENT.EXE
wmake
if not exist TCPAGENT.EXE goto fail
copy /Y TCPAGENT.EXE C:\FREEDOS\BIN\TCPAGENT.EXE
echo BUILD-OK
goto end
:fail
echo BUILD-FAILED
:end
+78 -35
View File
@@ -38,17 +38,42 @@ static unsigned long ticks(void) {
_bios_timeofday(_TIME_GETCLOCK,&value);
return (unsigned long)value;
}
/* The BIOS tick is 18.2065 Hz, so one tick is 5.49254 hundredths of a second.
549/100 keeps the error under 0.05% and cannot overflow 32 bits for a delta
up to a full day (1573040 ticks * 549 fits). Plain delta/18 ran 1.1% fast. */
static void elapsed_text(unsigned long started,char *out) {
unsigned long now=ticks(),delta=now>=started?now-started:now+(1573040UL-started);
sprintf(out,"%lu.%02lus",delta/18UL,(delta%18UL)*100UL/18UL);
unsigned long hundredths=delta*549UL/100UL;
sprintf(out,"%lu.%02lus",hundredths/100UL,hundredths%100UL);
}
/* Open Watcom's DOS conio has no textattr()/textcolor() -- it offers only
cprintf/cputs/getch and friends -- and ANSI escapes are not interpreted on
the installed FreeDOS console. So let cprintf lay the line out (it scrolls
correctly) and then repaint the attribute bytes of the cells it just wrote.
The cursor sits at column 0 of the row after the line, which is what lets us
find those cells without tracking scrolling ourselves. */
#define TIMESTAMP_WIDTH 9
static void colorize(unsigned total,int attr) {
unsigned char __far *vram; unsigned cols,used,row,col,start,k;
if(*(unsigned char __far *)MK_FP(0x0040,0x0049)==7) return; /* MDA: no color */
cols=*(unsigned __far *)MK_FP(0x0040,0x004A);
if(cols<40||cols>132) cols=80;
used=(total+cols-1)/cols; if(!used) used=1;
row=*(unsigned char __far *)MK_FP(0x0040,0x0051);
if(row<used) return; /* line scrolled off the top; nothing to paint */
start=row-used;
vram=(unsigned char __far *)MK_FP(0xB800,0);
for(k=0;k<total;++k) {
row=start+k/cols; col=k%cols;
vram[((unsigned)row*cols+col)*2+1]=(unsigned char)(k<TIMESTAMP_WIDTH?0x08:attr);
}
}
static void log_line(int attr,const char *fmt,...) {
struct dostime_t now; va_list ap; char text[760];
_dos_gettime(&now); va_start(ap,fmt); vsprintf(text,fmt,ap); va_end(ap);
/* Open Watcom's DOS conio has no textattr(), and ANSI escapes are not
interpreted on the installed FreeDOS console. Keep output clean. */
(void)attr;
cprintf("%02u:%02u:%02u %s\r\n",now.hour,now.minute,now.second,text);
colorize(TIMESTAMP_WIDTH+(unsigned)strlen(text),attr);
{ FILE *f=fopen("C:\\TCPAGENT.LOG","a");
if(f){fprintf(f,"%02u:%02u:%02u %s\n",now.hour,now.minute,now.second,text);fclose(f);} }
}
@@ -59,6 +84,12 @@ static void init_log(void) {
f=fopen("C:\\TCPAGENT.LOG","a");
if(f){fputs("--- TCPAGENT start ---\n",f);fclose(f);}
}
/* PUT completes either here in command_put (zero length) or in the receive loop
once the body arrives, so keep the one line both paths emit in one place. */
static void put_finished(void) {
char elapsed[24]; elapsed_text(put_started,elapsed);
log_line(0x0A,"< PUT %s OK %s",put_path,elapsed);
}
void __interrupt __far ctrl_break(void) { stop_requested=1; }
void __interrupt __far ctrl_c(void) { stop_requested=1; }
@@ -120,60 +151,71 @@ static int decode_path(const char *hex,char *path,int cap) {
}
static void command_read(char *args) {
char path[260],*off=strchr(args,' '); FILE *f; long pos; size_t count; int eof;
if(!off){error_text("READ requires path and offset");return;} *off++='\0';
if(!decode_path(args,path,sizeof(path))){error_text("Invalid path encoding");return;}
pos=atol(off); f=fopen(path,"rb"); if(!f){error_text("Cannot open file");return;}
if(fseek(f,pos,SEEK_SET)){fclose(f);error_text("Cannot seek file");return;}
if(!off){log_line(0x0C,"< READ ERR missing offset");error_text("READ requires path and offset");return;} *off++='\0';
if(!decode_path(args,path,sizeof(path))){log_line(0x0C,"< READ ERR bad path encoding");error_text("Invalid path encoding");return;}
pos=atol(off); log_line(0x0B,"> READ %s @%ld",path,pos);
f=fopen(path,"rb"); if(!f){log_line(0x0C,"< READ ERR cannot open");error_text("Cannot open file");return;}
if(fseek(f,pos,SEEK_SET)){fclose(f);log_line(0x0C,"< READ ERR cannot seek");error_text("Cannot seek file");return;}
count=fread(data,1,CHUNK_SIZE,f); eof=count<CHUNK_SIZE; fclose(f);
log_line(0x0A,"< READ %uB eof=%d",(unsigned)count,eof);
sprintf(linebuf,"OK %d ",eof); write_text(linebuf); write_hex(data,count); write_text("\r\n");
}
static void command_write(char *args) {
char path[260],*mode=strchr(args,' '),*payload; FILE *f; int count;
if(!mode){error_text("WRITE requires path, mode and data");return;} *mode++='\0';
payload=strchr(mode,' '); if(!payload){error_text("WRITE requires data");return;} *payload++='\0';
if(!decode_path(args,path,sizeof(path))){error_text("Invalid path encoding");return;}
count=decode_hex(payload,data,CHUNK_SIZE); if(count<0){error_text("Invalid data encoding");return;}
f=fopen(path,mode[0]=='A'?"ab":"wb"); if(!f){error_text("Cannot write file");return;}
if(count&&fwrite(data,1,count,f)!=(size_t)count){fclose(f);error_text("Short write");return;}
fclose(f); ok_data((const unsigned char *)"",0);
if(!mode){log_line(0x0C,"< WRITE ERR missing mode");error_text("WRITE requires path, mode and data");return;} *mode++='\0';
payload=strchr(mode,' '); if(!payload){log_line(0x0C,"< WRITE ERR missing data");error_text("WRITE requires data");return;} *payload++='\0';
if(!decode_path(args,path,sizeof(path))){log_line(0x0C,"< WRITE ERR bad path encoding");error_text("Invalid path encoding");return;}
count=decode_hex(payload,data,CHUNK_SIZE); if(count<0){log_line(0x0C,"< WRITE ERR bad data encoding");error_text("Invalid data encoding");return;}
log_line(0x0B,"> WRITE %s %c %dB",path,mode[0]=='A'?'A':'T',count);
f=fopen(path,mode[0]=='A'?"ab":"wb"); if(!f){log_line(0x0C,"< WRITE ERR cannot open");error_text("Cannot write file");return;}
if(count&&fwrite(data,1,count,f)!=(size_t)count){fclose(f);log_line(0x0C,"< WRITE ERR short write");error_text("Short write");return;}
fclose(f); log_line(0x0A,"< WRITE OK"); ok_data((const unsigned char *)"",0);
}
static void command_put(char *args) {
char path[260],*length_text=strchr(args,' ');
if(!length_text){error_text("PUT requires path and length");return;}
if(!length_text){log_line(0x0C,"< PUT ERR missing length");error_text("PUT requires path and length");return;}
*length_text++='\0';
if(!decode_path(args,path,sizeof(path))){error_text("Invalid path encoding");return;}
if(!decode_path(args,path,sizeof(path))){log_line(0x0C,"< PUT ERR bad path encoding");error_text("Invalid path encoding");return;}
put_remaining=strtoul(length_text,0,10); strcpy(put_path,path); put_started=ticks();
log_line(0x0B,"> PUT %s %luB",path,put_remaining);
log_line(0x0B,"> PUT %s %luB",put_path,put_remaining);
put_file=fopen(path,"wb");
if(!put_file){put_remaining=0;log_line(0x0C,"< PUT ERR cannot open");error_text("Cannot write file");return;}
if(!put_remaining){char elapsed[24];fclose(put_file);put_file=0;elapsed_text(put_started,elapsed);log_line(0x0A,"< PUT OK %s",elapsed);ok_data((const unsigned char *)"",0);}
if(!put_file){put_remaining=0;log_line(0x0C,"< PUT %s ERR cannot open",put_path);error_text("Cannot write file");return;}
if(!put_remaining){fclose(put_file);put_file=0;put_finished();ok_data((const unsigned char *)"",0);}
}
static void command_get(char *args) {
char path[260]; FILE *f; long length; size_t count;
if(!decode_path(args,path,sizeof(path))){error_text("Invalid path encoding");return;}
f=fopen(path,"rb"); if(!f){error_text("Cannot open file");return;}
char path[260],elapsed[24]; FILE *f; long length; size_t count; unsigned long started=ticks();
if(!decode_path(args,path,sizeof(path))){log_line(0x0C,"< GET ERR bad path encoding");error_text("Invalid path encoding");return;}
log_line(0x0B,"> GET %s",path);
f=fopen(path,"rb"); if(!f){log_line(0x0C,"< GET %s ERR cannot open",path);error_text("Cannot open file");return;}
fseek(f,0,SEEK_END); length=ftell(f); fseek(f,0,SEEK_SET);
sprintf(linebuf,"DATA %ld\r\n",length); write_text(linebuf);
while((count=fread(data,1,CHUNK_SIZE,f))>0) if(send_all(data,count)<0)break;
fclose(f);
fclose(f); elapsed_text(started,elapsed);
log_line(0x0A,"< GET OK %ldB %s",length,elapsed);
}
static void command_hash(char *args) {
char path[260]; FILE *f; size_t count; unsigned i; unsigned long length=0,hash=2166136261UL;
if(!decode_path(args,path,sizeof(path))){error_text("Invalid path encoding");return;}
f=fopen(path,"rb"); if(!f){error_text("Cannot open file");return;}
char path[260],elapsed[24]; FILE *f; size_t count; unsigned i;
unsigned long length=0,hash=2166136261UL,started=ticks();
if(!decode_path(args,path,sizeof(path))){log_line(0x0C,"< HASH ERR bad path encoding");error_text("Invalid path encoding");return;}
log_line(0x0B,"> HASH %s",path);
f=fopen(path,"rb"); if(!f){log_line(0x0C,"< HASH %s ERR cannot open",path);error_text("Cannot open file");return;}
while((count=fread(data,1,CHUNK_SIZE,f))>0){length+=(unsigned long)count;for(i=0;i<count;++i){hash^=data[i];hash*=16777619UL;}}
fclose(f); sprintf(linebuf,"STAT %lu %08lX\r\n",length,hash); write_text(linebuf);
fclose(f); elapsed_text(started,elapsed);
log_line(0x0A,"< HASH %luB %08lX %s",length,hash,elapsed);
sprintf(linebuf,"STAT %lu %08lX\r\n",length,hash); write_text(linebuf);
}
static void command_list(char *args) {
char path[260],pattern[300],output[CHUNK_SIZE]; struct find_t found;
unsigned used=0; int rc;
if(!decode_path(args,path,sizeof(path))){error_text("Invalid path encoding");return;}
unsigned used=0,entries=0; int rc,truncated=0;
if(!decode_path(args,path,sizeof(path))){log_line(0x0C,"< LIST ERR bad path encoding");error_text("Invalid path encoding");return;}
log_line(0x0B,"> LIST %s",path);
strcpy(pattern,path); if(pattern[0]&&pattern[strlen(pattern)-1]!='\\') strcat(pattern,"\\"); strcat(pattern,"*.*");
rc=_dos_findfirst(pattern,_A_NORMAL|_A_RDONLY|_A_HIDDEN|_A_SYSTEM|_A_SUBDIR|_A_ARCH,&found);
while(rc==0) { char entry[100]; int len;
if(strcmp(found.name,".")&&strcmp(found.name,"..")) { sprintf(entry,"%s\t%lu\t%s\n",found.name,found.size,(found.attrib&_A_SUBDIR)?"DIR":"FILE"); len=strlen(entry); if(used+(unsigned)len>=sizeof(output)) break; memcpy(output+used,entry,len); used+=(unsigned)len; }
if(strcmp(found.name,".")&&strcmp(found.name,"..")) { sprintf(entry,"%s\t%lu\t%s\n",found.name,found.size,(found.attrib&_A_SUBDIR)?"DIR":"FILE"); len=strlen(entry); if(used+(unsigned)len>=sizeof(output)) {truncated=1;break;} memcpy(output+used,entry,len); used+=(unsigned)len; ++entries; }
rc=_dos_findnext(&found);
}
log_line(truncated?0x0E:0x0A,"< LIST %u entries%s",entries,truncated?" (truncated)":"");
ok_data((unsigned char *)output,used);
}
static void command_exec(char *args) {
@@ -202,6 +244,7 @@ static void command_exec(char *args) {
}
static void process_line(char *line) {
char *cmd=line,*args=strchr(line,' '); if(args)*args++='\0';else args=cmd+strlen(cmd);
/* PING is deliberately not logged: wait-ready polls it twice a second. */
if(!strcmp(cmd,"PING"))ok_data((const unsigned char *)"PONG",4);
else if(!strcmp(cmd,"READ"))command_read(args);
else if(!strcmp(cmd,"WRITE"))command_write(args);
@@ -210,8 +253,8 @@ static void process_line(char *line) {
else if(!strcmp(cmd,"HASH"))command_hash(args);
else if(!strcmp(cmd,"LIST"))command_list(args);
else if(!strcmp(cmd,"EXEC"))command_exec(args);
else if(!strcmp(cmd,"QUIT")){ok_data((const unsigned char *)"BYE",3);stop_requested=1;}
else { char message[96]; sprintf(message,"Unknown command: %.70s",cmd); error_text(message); }
else if(!strcmp(cmd,"QUIT")){log_line(0x07,"* QUIT received");ok_data((const unsigned char *)"BYE",3);stop_requested=1;}
else { char message[96]; sprintf(message,"Unknown command: %.70s",cmd); log_line(0x0C,"< ERR %s",message); error_text(message); }
}
static int connect_host(void) {
IpAddr_t host={10,0,2,2}; int8_t rc;
@@ -241,10 +284,10 @@ int main(void) {
if(put_remaining) {
unsigned available=(unsigned)(rc-i);
unsigned take=put_remaining<available ? (unsigned)put_remaining : available;
if(fwrite(data+i,1,take,put_file)!=(size_t)take){fclose(put_file);put_file=0;put_remaining=0;error_text("Short write");}
if(fwrite(data+i,1,take,put_file)!=(size_t)take){fclose(put_file);put_file=0;put_remaining=0;log_line(0x0C,"< PUT %s ERR short write (disk full?)",put_path);error_text("Short write");}
else {
put_remaining-=take; i+=(int)take-1;
if(!put_remaining){char elapsed[24];fclose(put_file);put_file=0;elapsed_text(put_started,elapsed);log_line(0x0A,"< PUT OK %s",elapsed);ok_data((const unsigned char *)"",0);}
if(!put_remaining){fclose(put_file);put_file=0;put_finished();ok_data((const unsigned char *)"",0);}
}
} else if(c=='\r'||c=='\n') {
if(used){linebuf[used]='\0';process_line(linebuf);used=0;}