dev: promote DOSBox-X and remove QEMU support
This commit is contained in:
+45
-66
@@ -2,75 +2,54 @@
|
||||
|
||||
## Host support
|
||||
|
||||
Automation currently supports **Windows 10/11 only**. The fast development loop
|
||||
requires only `uv`; it downloads pinned DOSBox-X and Open Watcom DOS releases.
|
||||
The final milestone gate additionally requires QEMU with WHPX support and
|
||||
`ffmpeg.exe` on `PATH`. Other hosts are not supported yet.
|
||||
|
||||
## Getting started
|
||||
The automated DOS development environment currently supports Windows 10/11.
|
||||
The host only needs `uv`. The setup command downloads the pinned DOSBox-X and
|
||||
Open Watcom DOS archives, verifies their SHA-256 hashes, and installs them in the
|
||||
ignored `.dosboxx/` cache.
|
||||
|
||||
```powershell
|
||||
uv run ferro-dos setup --accept-watcom-license
|
||||
```
|
||||
|
||||
Review the Open Watcom license referenced by
|
||||
`tools/toolchains/dosboxx.lock.json` before accepting it. Neither downloaded
|
||||
archives nor installed tools are committed.
|
||||
|
||||
## General DOS environment
|
||||
|
||||
`ferro-dos` provides the development entry points:
|
||||
|
||||
```powershell
|
||||
uv run ferro-dos build
|
||||
uv run ferro-dos exec "FEC.EXE --check TESTS\M6\OKLAST.FE"
|
||||
uv run ferro-dos batch fec\test-dos.bat
|
||||
uv run ferro-dos shell
|
||||
uv run ferro-dos --help
|
||||
```
|
||||
|
||||
Every invocation creates an isolated host directory under `.dosboxx/runs/` and
|
||||
mounts it as writable `C:`. The repository is mounted read-only as `R:` and the
|
||||
pinned Open Watcom installation as read-only `W:`. Current compiler sources,
|
||||
the standard library, and fixtures are copied to `C:\FEC`; all compilation and
|
||||
execution happen there inside DOSBox-X. Successful runs are removed by default.
|
||||
Use `--keep` to retain a workspace and `--show-dos` to display the DOS window.
|
||||
|
||||
This directory-backed layout deliberately has no QEMU, disk-image, TCP-agent,
|
||||
or OCR dependency. A future disk-image backend can be added without changing
|
||||
the command interface.
|
||||
|
||||
## Pytest regression suite
|
||||
|
||||
`ferro-test` uses the same isolated DOSBox-X/Open Watcom environment, builds
|
||||
`FEC.EXE` once, and executes all selected cases sequentially in that one DOS
|
||||
instance. Pytest still reports each registered case separately.
|
||||
|
||||
```powershell
|
||||
uv run ferro-test setup --accept-watcom-license
|
||||
uv run ferro-test run --through m6 -v
|
||||
```
|
||||
|
||||
`setup` reads `tools/toolchains/dosboxx.lock.json`, downloads the exact official
|
||||
archives, verifies their SHA-256 hashes, and extracts them under ignored
|
||||
`.dosboxx/`. Review the Open Watcom license referenced by the lock file before
|
||||
accepting it. Archives and installed tools are deliberately not committed.
|
||||
|
||||
Each `run` creates a disposable DOS drive, copies the current compiler, standard
|
||||
library, and fixtures, then builds `FEC.EXE` once inside DOS with Open Watcom.
|
||||
All selected milestone commands execute sequentially in that same DOSBox-X
|
||||
instance, while pytest reports every emit, Watcom build, runtime, and rejection
|
||||
check separately. Thus stale QEMU binaries cannot make the test pass.
|
||||
|
||||
`--through m6` runs cumulatively from M1; `--only m6` selects one milestone.
|
||||
Use `--keep-failed` to preserve a failed drive under `.dosboxx/runs/`,
|
||||
`--dos-log` to print the captured DOS console, `--trace-dos` to disable command
|
||||
output redirection, and `--show-dos` to keep the GUI open until a key is pressed.
|
||||
|
||||
This is the quick development smoke test. Run the QEMU/FreeDOS workflow below
|
||||
for the authoritative milestone completion gate.
|
||||
|
||||
```powershell
|
||||
uv run ferro-vm start
|
||||
uv run ferro-vm status
|
||||
```
|
||||
|
||||
The command list lives in the CLI itself, not in this file:
|
||||
|
||||
```powershell
|
||||
uv run ferro-vm --help
|
||||
uv run ferro-vm <command> --help
|
||||
uv run ferro-test run --only m6 --dos-log
|
||||
uv run ferro-test --help
|
||||
```
|
||||
|
||||
Working rules, verification gates, and DOS build traps are in `AGENTS.md`.
|
||||
|
||||
## How it fits together
|
||||
|
||||
`TCPAGENT.EXE` runs inside FreeDOS and dials out to `127.0.0.1:5558`; its wire
|
||||
protocol is documented in `tcpagent/README.md`. The `ferro-vm` daemon owns that
|
||||
connection and the QEMU monitor. Local commands reach the daemon over the
|
||||
Windows named pipe `\\.\pipe\ferrolang-vm` — there is no controller or observer
|
||||
TCP port.
|
||||
|
||||
The daemon writes an append-only structured log (`uv run ferro-vm logs`, which
|
||||
uses `lnav` when installed and otherwise falls back to PowerShell `Get-Content
|
||||
-Wait`). It records command metadata, DOS output, exit status, transfers, and
|
||||
agent lifecycle events as UTF-8 lines, and deliberately never logs raw binary
|
||||
payloads or protocol hex.
|
||||
|
||||
`reset` quits QEMU cleanly, restarts it, waits for FreeDOS to boot, submits the
|
||||
default boot-menu Enter, and requires a TCPAGENT `PING`/`PONG` before returning.
|
||||
QEMU `system_reset` is intentionally unsupported: repeated soft resets leave the
|
||||
FreeDOS NE2000 packet driver stuck during initialization.
|
||||
|
||||
## Standalone OCR
|
||||
|
||||
`tools/qemu_ocr.py` remains available for OCRing an existing image:
|
||||
|
||||
```powershell
|
||||
uv run python tools/qemu_ocr.py --image .qemu/qemu-screen.png
|
||||
```
|
||||
`--keep-failed` preserves a failed workspace, `--dos-log` prints captured DOS
|
||||
output, `--trace-dos` disables per-command redirection, and `--show-dos` displays
|
||||
the GUI. Working rules and DOS/Open Watcom build traps are in `AGENTS.md`.
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture the QEMU VGA console and print its text with RapidOCR."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from rapidocr import RapidOCR
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_IMAGE = ROOT / ".qemu" / "qemu-screen.png"
|
||||
|
||||
|
||||
def capture() -> Path:
|
||||
subprocess.run(
|
||||
["ferro-vm", "screenshot"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
if not DEFAULT_IMAGE.is_file():
|
||||
raise RuntimeError(f"QEMU screenshot was not created: {DEFAULT_IMAGE}")
|
||||
return DEFAULT_IMAGE
|
||||
|
||||
|
||||
def recognize(image: Path, min_score: float) -> list[dict[str, object]]:
|
||||
logging.disable(logging.INFO)
|
||||
result = RapidOCR()(image)
|
||||
rows: list[dict[str, object]] = []
|
||||
if result.txts is None or result.boxes is None or result.scores is None:
|
||||
return rows
|
||||
for box, text, score in zip(result.boxes, result.txts, result.scores):
|
||||
if float(score) < min_score:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"x": int(min(point[0] for point in box)),
|
||||
"y": int(min(point[1] for point in box)),
|
||||
"text": text,
|
||||
"score": round(float(score), 5),
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda row: (int(row["y"]), int(row["x"])))
|
||||
return rows
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--image", type=Path, help="OCR an existing image instead of capturing QEMU")
|
||||
parser.add_argument("--min-score", type=float, default=0.5)
|
||||
parser.add_argument("--json", action="store_true", help="emit locations and scores as JSON")
|
||||
parser.add_argument("-o", "--output", type=Path, help="also save output as UTF-8 text")
|
||||
args = parser.parse_args()
|
||||
|
||||
image = args.image.resolve() if args.image else capture()
|
||||
rows = recognize(image, args.min_score)
|
||||
if args.json:
|
||||
rendered = json.dumps({"image": str(image), "lines": rows}, ensure_ascii=False, indent=2)
|
||||
else:
|
||||
rendered = "\n".join(str(row["text"]) for row in rows)
|
||||
if args.output:
|
||||
args.output.write_text(rendered + ("\n" if rendered else ""), encoding="utf-8")
|
||||
if rendered:
|
||||
print(rendered)
|
||||
return 0 if rows else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (OSError, RuntimeError, subprocess.CalledProcessError) as exc:
|
||||
print(f"qemu-ocr: {exc}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
@@ -1,6 +0,0 @@
|
||||
@echo off
|
||||
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=
|
||||
wmake
|
||||
@@ -1,15 +0,0 @@
|
||||
@echo off
|
||||
if not exist TCPAGENT.EXE goto fail
|
||||
copy TCPAGENT.EXE C:\FREEDOS\BIN\TCPAGENT.EXE > nul
|
||||
if errorlevel 1 goto fail
|
||||
find "TCPAGENT.EXE" C:\FDAUTO.BAT > nul
|
||||
if not errorlevel 1 goto done
|
||||
echo SET MTCPCFG=C:\FREEDOS\MTCP.CFG>>C:\FDAUTO.BAT
|
||||
echo IF EXIST C:\FREEDOS\BIN\TCPAGENT.EXE C:\FREEDOS\BIN\TCPAGENT.EXE>>C:\FDAUTO.BAT
|
||||
:done
|
||||
echo TCPAGENT installed.
|
||||
goto end
|
||||
:fail
|
||||
echo TCPAGENT install failed.
|
||||
verify other 2>nul
|
||||
:end
|
||||
@@ -1,38 +0,0 @@
|
||||
# Place this directory at MTCP\APPS\TCPAGENT in an mTCP checkout.
|
||||
tcp_c_dir = ..\..\TCPLIB
|
||||
memory_model = -mc
|
||||
compile_options = @WPP.RSP
|
||||
tcpobjs = packet.obj arp.obj eth.obj ip.obj tcp.obj tcpsockm.obj udp.obj utils.obj timer.obj ipasm.obj trace.obj
|
||||
all : tcpagent.exe
|
||||
.cpp.obj :
|
||||
wpp $[* $(compile_options)
|
||||
tcpagent.obj: tcpagent.cpp
|
||||
wpp $(compile_options) tcpagent.cpp
|
||||
packet.obj:
|
||||
wpp $(tcp_c_dir)\packet.cpp $(compile_options)
|
||||
arp.obj:
|
||||
wpp $(tcp_c_dir)\arp.cpp $(compile_options)
|
||||
eth.obj:
|
||||
wpp $(tcp_c_dir)\eth.cpp $(compile_options)
|
||||
ip.obj:
|
||||
wpp $(tcp_c_dir)\ip.cpp $(compile_options)
|
||||
tcp.obj:
|
||||
wpp $(tcp_c_dir)\tcp.cpp $(compile_options)
|
||||
tcpsockm.obj:
|
||||
wpp $(tcp_c_dir)\tcpsockm.cpp $(compile_options)
|
||||
udp.obj:
|
||||
wpp $(tcp_c_dir)\udp.cpp $(compile_options)
|
||||
utils.obj:
|
||||
wpp $(tcp_c_dir)\utils.cpp $(compile_options)
|
||||
timer.obj:
|
||||
wpp $(tcp_c_dir)\timer.cpp $(compile_options)
|
||||
trace.obj:
|
||||
wpp $(tcp_c_dir)\trace.cpp $(compile_options)
|
||||
ipasm.obj:
|
||||
wasm -0 -mc $(tcp_c_dir)\ipasm.asm
|
||||
tcpagent.exe: tcpagent.obj $(tcpobjs)
|
||||
wlink system dos option map option eliminate option stack=16384 name $@ file *.obj
|
||||
clean : .symbolic
|
||||
del *.obj
|
||||
del *.map
|
||||
del tcpagent.exe
|
||||
@@ -1,101 +0,0 @@
|
||||
# FreeDOS resident TCP agent
|
||||
|
||||
`TCPAGENT.EXE` is a foreground resident automation process. It uses the mTCP
|
||||
packet-driver stack and maintains an outbound connection to the QEMU host at
|
||||
`10.0.2.2:5558`.
|
||||
|
||||
The Windows-only Python `ferro-vm` daemon owns that listener. It logs metadata
|
||||
and decoded command output to `.qemu/ferro-vm.log`; it does not expose an
|
||||
observer/controller TCP port or emit binary payloads to the log. Local host
|
||||
control uses a Windows named pipe.
|
||||
|
||||
## Build in FreeDOS
|
||||
|
||||
1. Obtain the GPLv3 mTCP source tree (tested with the jhpyle/mTCP 2022 fork).
|
||||
2. Copy this directory to `MTCP\APPS\TCPAGENT` inside that tree.
|
||||
3. Set `WATCOM` for Open Watcom and run `BUILD.BAT`.
|
||||
4. Run `INSTALL.BAT`; it installs the executable and adds startup lines after
|
||||
the existing packet-driver setup in `C:\FDAUTO.BAT`.
|
||||
|
||||
The build uses mTCP's compact memory model and Open Watcom C++16. The agent is
|
||||
therefore distributed under GPLv3 when linked with mTCP.
|
||||
|
||||
## Protocol
|
||||
|
||||
`PING`, `READ`, `WRITE`, and `LIST` use text commands. `EXEC` captures both
|
||||
stdout and stderr at the DOS handle level and returns an untruncated raw body:
|
||||
|
||||
- `EXEC <hex command>\n` -> `RESULT <exit> <length> <flags>\r\n<raw bytes>`
|
||||
|
||||
Fast transfer commands are:
|
||||
|
||||
- `PUT <hex DOS path> <byte length>\n<raw bytes>` -> `OK\r\n`
|
||||
- `GET <hex DOS path>\n` -> `DATA <length>\r\n<raw bytes>`
|
||||
- `HASH <hex DOS path>\n` -> `STAT <length> <FNV1A32>\r\n`
|
||||
|
||||
The host invokes them through:
|
||||
|
||||
```powershell
|
||||
uv run ferro-vm put host-file 'C:\DOS\FILE'
|
||||
uv run ferro-vm get 'C:\DOS\FILE' host-file
|
||||
```
|
||||
|
||||
## 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).
|
||||
|
||||
## Long commands
|
||||
|
||||
mTCP is only driven when the agent calls it, and `system()` freezes the agent
|
||||
for the entire child command. So during a long `EXEC` the DOS side is mute: it
|
||||
cannot answer, cannot acknowledge, cannot report progress. Silence therefore
|
||||
proves nothing about whether the command is healthy.
|
||||
|
||||
The host must not read that silence as failure. `ferro-vm exec` waits on
|
||||
QEMU's own view of the guest instead: `info blockstats` keeps counting while
|
||||
the agent is frozen, and `idle_time_ns` distinguishes a slow command from a
|
||||
stuck one. See `--idle-timeout` and `--hard-timeout` in `ferro-vm exec --help`.
|
||||
|
||||
When the host does decide to stop a command it injects Ctrl+C through the QEMU
|
||||
monitor, then answers COMMAND.COM's `Terminate batch file (Y/N/A)?` prompt.
|
||||
That is a request, not a guarantee: Ctrl+C only lands at a DOS break check, and
|
||||
with `BREAK=OFF` (the FreeDOS default in `C:\FDCONFIG.SYS`) a compute-bound
|
||||
child whose output we redirected to a file may never reach one. The host keeps
|
||||
collecting the result either way rather than abandoning a stream that still
|
||||
owes it a `RESULT`.
|
||||
|
||||
Adding `BREAK=ON` to `C:\FDCONFIG.SYS` would make DOS check on every system
|
||||
call and so make Ctrl+C reliable, at a small cost to every DOS call.
|
||||
|
||||
## 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.
|
||||
@@ -1,22 +0,0 @@
|
||||
@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
|
||||
@@ -1,11 +0,0 @@
|
||||
-0
|
||||
-mc
|
||||
-DCFG_H="tcpagent.cfg"
|
||||
-oh
|
||||
-os
|
||||
-s
|
||||
-zp2
|
||||
-zpw
|
||||
-we
|
||||
-i=..\..\TCPINC
|
||||
-i=..\..\INCLUDE
|
||||
@@ -1,12 +0,0 @@
|
||||
#ifndef CONFIG_H
|
||||
#define CONFIG_H
|
||||
#define MTCP_PROGRAM_NAME "tcpagent"
|
||||
#include "Global.Cfg"
|
||||
#define COMPILE_ARP
|
||||
#define IP_FRAGMENTS_ON
|
||||
#define COMPILE_UDP
|
||||
#define COMPILE_TCP
|
||||
#define COMPILE_ICMP
|
||||
#undef TCP_MAX_SOCKETS
|
||||
#define TCP_MAX_SOCKETS (1)
|
||||
#endif
|
||||
@@ -1,303 +0,0 @@
|
||||
#include <bios.h>
|
||||
#include <conio.h>
|
||||
#include <dos.h>
|
||||
#include <fcntl.h>
|
||||
#include <io.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include "types.h"
|
||||
#include "trace.h"
|
||||
#include "utils.h"
|
||||
#include "packet.h"
|
||||
#include "arp.h"
|
||||
#include "udp.h"
|
||||
#include "tcp.h"
|
||||
#include "tcpsockm.h"
|
||||
|
||||
#define LINE_SIZE 9000
|
||||
#define CHUNK_SIZE 4096
|
||||
#define SERVER_PORT 5558
|
||||
#define LOCAL_PORT 2058
|
||||
#define RECV_SIZE 12288
|
||||
|
||||
static char linebuf[LINE_SIZE];
|
||||
static unsigned char data[CHUNK_SIZE+1];
|
||||
static TcpSocket *socketp;
|
||||
static volatile uint8_t stop_requested;
|
||||
static FILE *put_file;
|
||||
static unsigned long put_remaining;
|
||||
static unsigned long put_started;
|
||||
static char put_path[260];
|
||||
|
||||
static unsigned long ticks(void) {
|
||||
long value=0;
|
||||
_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);
|
||||
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);
|
||||
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);} }
|
||||
}
|
||||
static void init_log(void) {
|
||||
FILE *f=fopen("C:\\TCPAGENT.LOG","rb"); long size=0;
|
||||
if(f){fseek(f,0,SEEK_END);size=ftell(f);fclose(f);}
|
||||
if(size>262144L){remove("C:\\TCPAGENT.OLD");rename("C:\\TCPAGENT.LOG","C:\\TCPAGENT.OLD");}
|
||||
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; }
|
||||
|
||||
static void drive(void) {
|
||||
PACKET_PROCESS_MULT(5);
|
||||
Arp::driveArp();
|
||||
Tcp::drivePackets();
|
||||
}
|
||||
|
||||
static int send_all(const void *buffer, unsigned length) {
|
||||
const uint8_t *p=(const uint8_t *)buffer;
|
||||
unsigned sent=0;
|
||||
int rc;
|
||||
while(sent<length && !stop_requested) {
|
||||
drive();
|
||||
rc=socketp->send((uint8_t *)(p+sent),length-sent);
|
||||
if(rc>0) sent+=(unsigned)rc;
|
||||
else if(rc<0 || socketp->isRemoteClosed()) return -1;
|
||||
}
|
||||
return sent==length ? 0 : -1;
|
||||
}
|
||||
|
||||
static void write_text(const char *s) { send_all(s,strlen(s)); }
|
||||
static int hexval(int c) {
|
||||
if(c>='0'&&c<='9') return c-'0';
|
||||
if(c>='A'&&c<='F') return c-'A'+10;
|
||||
if(c>='a'&&c<='f') return c-'a'+10;
|
||||
return -1;
|
||||
}
|
||||
static int decode_hex(const char *src,unsigned char *dst,int cap) {
|
||||
int n=0,hi,lo;
|
||||
while(*src&&src[1]) {
|
||||
if(n>=cap) return -1;
|
||||
hi=hexval((unsigned char)src[0]); lo=hexval((unsigned char)src[1]);
|
||||
if(hi<0||lo<0) return -1;
|
||||
dst[n++]=(unsigned char)((hi<<4)|lo); src+=2;
|
||||
}
|
||||
return *src ? -1 : n;
|
||||
}
|
||||
static void write_hex(const unsigned char *src,unsigned count) {
|
||||
static const char digits[]="0123456789ABCDEF";
|
||||
char out[256]; unsigned i,n;
|
||||
while(count) {
|
||||
n=count>sizeof(out)/2 ? sizeof(out)/2 : count;
|
||||
for(i=0;i<n;++i) { out[i*2]=digits[src[i]>>4]; out[i*2+1]=digits[src[i]&15]; }
|
||||
send_all(out,n*2); src+=n; count-=n;
|
||||
}
|
||||
}
|
||||
static void ok_data(const unsigned char *src,unsigned count) {
|
||||
write_text("OK "); write_hex(src,count); write_text("\r\n");
|
||||
}
|
||||
static void error_text(const char *s) {
|
||||
write_text("ERR "); write_hex((const unsigned char *)s,strlen(s)); write_text("\r\n");
|
||||
}
|
||||
static int decode_path(const char *hex,char *path,int cap) {
|
||||
int n=decode_hex(hex,(unsigned char *)path,cap-1);
|
||||
if(n<0) return 0; path[n]='\0'; return 1;
|
||||
}
|
||||
static void command_read(char *args) {
|
||||
char path[260],*off=strchr(args,' '); FILE *f; long pos; size_t count; int eof;
|
||||
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){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){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))){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",put_path,put_remaining);
|
||||
put_file=fopen(path,"wb");
|
||||
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],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); elapsed_text(started,elapsed);
|
||||
log_line(0x0A,"< GET OK %ldB %s",length,elapsed);
|
||||
}
|
||||
static void command_hash(char *args) {
|
||||
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); 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,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)) {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) {
|
||||
char command[700],elapsed[24]; const char *tmp="C:\\PIEXEC.TMP"; FILE *f;
|
||||
int n,code=-1,fd=-1,save1=-1,save2=-1; long length=0; size_t count; unsigned long started;
|
||||
n=decode_hex(args,(unsigned char *)command,sizeof(command)-1); if(n<0){error_text("Invalid command encoding");return;} command[n]='\0';
|
||||
started=ticks(); log_line(0x0E,"> EXEC %.640s",command);
|
||||
fflush(stdout); fflush(stderr);
|
||||
save1=dup(1); save2=dup(2);
|
||||
fd=open(tmp,O_CREAT|O_TRUNC|O_WRONLY|O_BINARY,S_IREAD|S_IWRITE);
|
||||
if(save1<0||save2<0||fd<0||dup2(fd,1)<0||dup2(fd,2)<0) {
|
||||
if(fd>=0)close(fd);
|
||||
if(save1>=0){dup2(save1,1);close(save1);}
|
||||
if(save2>=0){dup2(save2,2);close(save2);}
|
||||
log_line(0x0C,"< EXEC ERR redirect failed"); error_text("Cannot capture command output"); return;
|
||||
}
|
||||
close(fd); code=system(command); fflush(stdout); fflush(stderr);
|
||||
dup2(save1,1); dup2(save2,2); close(save1); close(save2);
|
||||
f=fopen(tmp,"rb");
|
||||
if(f){fseek(f,0,SEEK_END);length=ftell(f);fseek(f,0,SEEK_SET);}
|
||||
elapsed_text(started,elapsed);
|
||||
log_line(code?0x0C:0x0A,"< EXEC exit=%d %ldB %s",code,length,elapsed);
|
||||
sprintf(linebuf,"RESULT %d %ld 0\r\n",code,length); write_text(linebuf);
|
||||
while(f&&(count=fread(data,1,CHUNK_SIZE,f))>0)if(send_all(data,count)<0)break;
|
||||
if(f)fclose(f); remove(tmp);
|
||||
}
|
||||
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);
|
||||
else if(!strcmp(cmd,"PUT"))command_put(args);
|
||||
else if(!strcmp(cmd,"GET"))command_get(args);
|
||||
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")){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;
|
||||
socketp=TcpSocketMgr::getSocket(); if(!socketp)return -1;
|
||||
socketp->setRecvBuffer(RECV_SIZE);
|
||||
rc=socketp->connect(LOCAL_PORT,host,SERVER_PORT,10000);
|
||||
while(rc==0&&!socketp->isConnectComplete()&&!socketp->isRemoteClosed()&&!stop_requested)drive();
|
||||
if(socketp->isRemoteClosed()){TcpSocketMgr::freeSocket(socketp);socketp=0;return -1;}
|
||||
return 0;
|
||||
}
|
||||
int main(void) {
|
||||
int used,rc; uint16_t key; unsigned attempts=0;
|
||||
init_log();
|
||||
log_line(0x0B,"* TCPAGENT starting; Alt-X returns to DOS");
|
||||
if(Utils::parseEnv()!=0){log_line(0x0C,"* MTCP configuration error");return 2;}
|
||||
if(Utils::initStack(1,TCP_SOCKET_RING_SIZE,ctrl_break,ctrl_c)){log_line(0x0C,"* TCP stack initialization error");return 3;}
|
||||
while(!stop_requested) {
|
||||
if(!attempts)log_line(0x07,"* connecting 10.0.2.2:%u",SERVER_PORT);
|
||||
if(connect_host()!=0){unsigned long spins=0;++attempts;if(attempts==1||attempts%10==0)log_line(0x0C,"* connect failed; retry %u",attempts);while(spins++<60000UL&&!stop_requested)drive();continue;}
|
||||
attempts=0; log_line(0x0A,"* connected; host automation owns console");
|
||||
write_text("TCPAGENT READY\r\n"); used=0;
|
||||
while(!stop_requested&&!socketp->isRemoteClosed()) {
|
||||
drive(); rc=socketp->recv((uint8_t *)data,CHUNK_SIZE);
|
||||
if(rc<0)break;
|
||||
for(int i=0;i<rc;++i) {
|
||||
int c=data[i];
|
||||
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;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){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;}
|
||||
} else if(used<LINE_SIZE-1) linebuf[used++]=(char)c;
|
||||
}
|
||||
if(_bios_keybrd(1)){key=_bios_keybrd(0);if((key&0xff)==3||(key>>8)==45)stop_requested=1;}
|
||||
}
|
||||
socketp->close(); TcpSocketMgr::freeSocket(socketp); socketp=0;
|
||||
if(!stop_requested)log_line(0x0C,"* link lost; retrying");
|
||||
}
|
||||
log_line(0x07,"* stopped; returning to DOS");
|
||||
Utils::endStack(); return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user