current state

This commit is contained in:
2026-09-18 22:41:20 +02:00
parent 5cc156992f
commit a2196feb78
14 changed files with 823 additions and 170 deletions
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "netsim"
version = "0.1.0"
edition = "2024"
[dependencies]
+170
View File
@@ -0,0 +1,170 @@
//! Bad-internet simulator: a UDP proxy between game client and server that adds
//! delay, jitter and packet loss, per direction. Dev tool only — the game itself
//! never knows it exists.
//!
//! Usage:
//! cargo run -p netsim -- [--listen 7778] [--upstream 127.0.0.1:7777]
//! [--delay MS] [--jitter MS] [--loss PERCENT]
//! [--up-delay MS] [--up-jitter MS] [--up-loss PERCENT]
//! [--down-delay MS] [--down-jitter MS] [--down-loss PERCENT]
//!
//! `--delay/--jitter/--loss` set both directions; the `--up-*` (client → server) and
//! `--down-*` (server → client) variants override one direction. Jitter is uniform in
//! ±MS around the delay; reordering emerges from jitter naturally, as on a real link.
//! Point the client at the listen port (e.g. `client 127.0.0.1:7778`).
use std::cmp::Reverse;
use std::collections::BinaryHeap;
use std::net::{SocketAddr, UdpSocket};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
#[derive(Clone, Copy)]
struct LinkParams {
delay_ms: f32,
jitter_ms: f32,
loss_pct: f32,
}
/// Xorshift64* — plenty for impairment dice; avoids pulling in a rand dependency.
struct Rng(u64);
impl Rng {
fn new() -> Self {
let seed = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() as u64;
Rng(seed | 1)
}
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
/// Uniform in [0, 1).
fn unit(&mut self) -> f32 {
(self.next() >> 40) as f32 / (1u64 << 24) as f32
}
}
impl LinkParams {
/// Roll the dice for one datagram: `None` = lost, `Some(d)` = deliver after `d`.
fn impair(&self, rng: &mut Rng) -> Option<Duration> {
if rng.unit() * 100.0 < self.loss_pct {
return None;
}
let jitter = (rng.unit() * 2.0 - 1.0) * self.jitter_ms;
Some(Duration::from_secs_f32((self.delay_ms + jitter).max(0.0) / 1000.0))
}
}
/// A datagram waiting for its delivery time. `Up` = client → server.
enum Dir {
Up,
Down,
}
struct Pending {
due: Instant,
dir: Dir,
data: Vec<u8>,
}
// BinaryHeap ordering: only `due` matters (earliest first via Reverse).
impl PartialEq for Pending {
fn eq(&self, other: &Self) -> bool { self.due == other.due }
}
impl Eq for Pending {}
impl PartialOrd for Pending {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { Some(self.cmp(other)) }
}
impl Ord for Pending {
fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.due.cmp(&other.due) }
}
fn parse_args() -> (u16, SocketAddr, LinkParams, LinkParams) {
let mut listen: u16 = 7778;
let mut upstream: SocketAddr = "127.0.0.1:7777".parse().unwrap();
let mut both = LinkParams { delay_ms: 0.0, jitter_ms: 0.0, loss_pct: 0.0 };
let mut up_over = [None::<f32>; 3]; // delay, jitter, loss
let mut down_over = [None::<f32>; 3];
let mut args = std::env::args().skip(1);
while let Some(flag) = args.next() {
let val = args.next().unwrap_or_else(|| panic!("missing value for {flag}"));
let num = || val.parse::<f32>().unwrap_or_else(|_| panic!("bad number for {flag}: {val}"));
match flag.as_str() {
"--listen" => listen = val.parse().expect("bad listen port"),
"--upstream" => upstream = val.parse().expect("bad upstream address"),
"--delay" => both.delay_ms = num(),
"--jitter" => both.jitter_ms = num(),
"--loss" => both.loss_pct = num(),
"--up-delay" => up_over[0] = Some(num()),
"--up-jitter" => up_over[1] = Some(num()),
"--up-loss" => up_over[2] = Some(num()),
"--down-delay" => down_over[0] = Some(num()),
"--down-jitter" => down_over[1] = Some(num()),
"--down-loss" => down_over[2] = Some(num()),
_ => panic!("unknown flag {flag}"),
}
}
let apply = |over: [Option<f32>; 3]| LinkParams {
delay_ms: over[0].unwrap_or(both.delay_ms),
jitter_ms: over[1].unwrap_or(both.jitter_ms),
loss_pct: over[2].unwrap_or(both.loss_pct),
};
(listen, upstream, apply(up_over), apply(down_over))
}
fn main() {
let (listen, upstream, up, down) = parse_args();
let client_sock = UdpSocket::bind(("127.0.0.1", listen)).expect("bind listen port");
let server_sock = UdpSocket::bind("127.0.0.1:0").expect("bind upstream socket");
server_sock.connect(upstream).expect("connect upstream");
client_sock.set_nonblocking(true).unwrap();
server_sock.set_nonblocking(true).unwrap();
println!("netsim: 127.0.0.1:{listen} ⇄ {upstream}");
println!(" up: {:.0} ms ±{:.0} ms, {:.1}% loss", up.delay_ms, up.jitter_ms, up.loss_pct);
println!(" down: {:.0} ms ±{:.0} ms, {:.1}% loss", down.delay_ms, down.jitter_ms, down.loss_pct);
let mut rng = Rng::new();
let mut queue: BinaryHeap<Reverse<Pending>> = BinaryHeap::new();
// The one game client, learned from its first datagram (re-learned on address change).
let mut client_addr: Option<SocketAddr> = None;
let mut buf = [0u8; 2048];
loop {
let now = Instant::now();
while let Ok((n, from)) = client_sock.recv_from(&mut buf) {
client_addr = Some(from);
if let Some(d) = up.impair(&mut rng) {
queue.push(Reverse(Pending { due: now + d, dir: Dir::Up, data: buf[..n].to_vec() }));
}
}
while let Ok(n) = server_sock.recv(&mut buf) {
if let Some(d) = down.impair(&mut rng) {
queue.push(Reverse(Pending { due: now + d, dir: Dir::Down, data: buf[..n].to_vec() }));
}
}
while queue.peek().is_some_and(|Reverse(p)| p.due <= now) {
let Reverse(p) = queue.pop().unwrap();
match p.dir {
Dir::Up => { server_sock.send(&p.data).ok(); }
Dir::Down => {
if let Some(addr) = client_addr {
client_sock.send_to(&p.data, addr).ok();
}
}
}
}
std::thread::sleep(Duration::from_millis(1));
}
}