diff --git a/tools/livetrack/fieldtest_map.html b/tools/livetrack/fieldtest_map.html
new file mode 100644
index 0000000..fbd48b6
--- /dev/null
+++ b/tools/livetrack/fieldtest_map.html
@@ -0,0 +1,157 @@
+
+
+
+
+ Fieldtest Positions
+
+
+
+
+
+
+
+
+
+ WS: connecting
+ Last: —
+
+
+
+
+
+
diff --git a/tools/livetrack/fwd_positions_udp.pl b/tools/livetrack/fwd_positions_udp.pl
new file mode 100644
index 0000000..0f3a67b
--- /dev/null
+++ b/tools/livetrack/fwd_positions_udp.pl
@@ -0,0 +1,55 @@
+#!/usr/bin/env perl
+# 20260219 ChatGPT
+# $Id$
+# $HeadURL$
+
+# EXAMPLE:
+# perl fwd_positions_udp.pl \
+# --file /usr/local/src/sx1302_hal/util_net_downlink/uplinks_20260219_123102.csv \
+# --host ryzdesk --port 1777
+
+use strict;
+use warnings;
+use IO::Socket::INET;
+use Getopt::Long qw(GetOptions);
+
+my ($file, $host, $port) = ("", "ryzdesk", 1777);
+GetOptions(
+ "file=s" => \$file,
+ "host=s" => \$host,
+ "port=i" => \$port,
+) or die "bad args\n";
+
+die "--file required\n" if !$file;
+
+my $sock = IO::Socket::INET->new(
+ PeerAddr => $host,
+ PeerPort => $port,
+ Proto => "udp",
+) or die "udp socket: $!\n";
+
+£ Use tail -F so rotations keep working
+open(my $fh, "-|", "tail", "-F", $file) or die "tail: $!\n";
+
+$| = 1;
+
+while (my $line = <$fh>) {
+ chomp $line;
+
+ £ Your CSV has the hex payload in field 16 (1-based) => index 15 (0-based)
+ my @f = split(/,/, $line, -1);
+ next if @f < 16;
+
+ my $hex = $f[15] // "";
+ $hex =~ s/[^0-9A-Fa-f]//g;
+ next if $hex eq "";
+
+ my $msg = pack("H*", $hex); £ yields: "C,44.936454,-123.021923,65.40"
+ $msg =~ s/\r?\n$//;
+
+ £ sanity filter: must look like: X,lat,lon,alt
+ next unless $msg =~ /^[A-Z],[+-]?\d+\.\d+,[+-]?\d+\.\d+,[+-]?\d+(\.\d+)?$/;
+
+ $sock->send($msg."\n");
+ print "$msg\n"; £ local visibility
+}