OBS says Dropped frames (network). Task Manager shows a throughput graph that
looks fine. Neither one tells you why.
Windows already measures the number that explains it — the TCP retransmission
rate — and shows it in no user interface anywhere. netdrop shows that number,
plus the per-adapter discards and errors behind it, and the gap between the link
speed Windows advertises and what you can actually push.
Read-only. Zero dependencies. One PowerShell file. No admin rights.
This is not a speed test and not a packet capture. It sends no traffic of its own. It reads counters Windows is already keeping and explains them.
powershell -NoProfile -ExecutionPolicy Bypass -File netdrop.ps1 -Adapters
netdrop - why your stream drops frames when nothing looks wrong
══════════════════════════════════════════════════════════════
LINK
Local Area Connection Xbox Wireless Adapter for Windows
link speed Windows shows 600.0 Mbps
link speed right now 600.0 Mbps
Wi-Fi Intel(R) Wi-Fi 6 AX201 160MHz
link speed Windows shows 721.0 Mbps
link speed right now 400.0 Mbps ← renegotiated since
802.11ax 5 GHz ch 153 signal 81% (-64 dBm) SSID 5roof-kids
Bluetooth Network Connection Bluetooth Device (Personal Area Network) [Disconnected]
link speed Windows shows 3.0 Mbps
WHAT YOU CAN ACTUALLY PUSH
A link speed is not throughput. Wi-Fi is half duplex on a shared
medium, so real TCP goodput lands near half the PHY rate before any
interference. Windows shows the same number for both.
Local Area Connection
link rate now 600.0 Mbps
realistic goodput ~564.0 Mbps
4K60 stream needs 40.0 Mbps OK 14.1x headroom
Wi-Fi
link rate now 400.0 Mbps
realistic goodput ~200.0 Mbps
4K60 stream needs 40.0 Mbps OK 5.0x headroom
RETRANSMISSIONS measured by Windows, shown by nothing
TCPV4 1,167 of 706,532 segments resent 0.165% ░░░░░░░░░░
right now: 0.0 resent/s of 1.0 sent/s
TCPV6 60 of 116 segments resent not measurable
below the 1,000 segment floor - a percentage from that few
samples is noise, so no rate is reported
right now: 0.0 resent/s of 0.0 sent/s
Every resent segment is data that had to cross the network twice.
A live encoder cannot wait for it, so OBS calls it a dropped frame.
ADAPTER ERRORS AND DISCARDS since boot
Local Area Connection
outbound 0 discarded 0 errors
inbound 0 discarded 0 errors
send queue 0 packets waiting
totals 0 B sent 0 B received
Wi-Fi
outbound 0 discarded 0 errors
inbound 0 discarded 0 errors
send queue 0 packets waiting
totals 93.61 MiB sent 131.11 MiB received
CONNECTIONS since boot, IPv4 and IPv6 combined
opened 1,177
failed 58 4.9% of attempts
reset 149
open now 9
LIVE over 5.2 s
Local Area Connection up 0 B/s down 0 B/s
Wi-Fi up 188 B/s down 285 B/s
VERDICT
[info] TCPV6 has not sent enough traffic to measure
Only 116 segments since boot, below the 1,000 floor. A percentage
from that few samples is noise, so no rate is reported.
[info] Wi-Fi link speed is not what Windows is showing
Get-NetAdapter says 721.0 Mbps but the live performance counter
says 400.0 Mbps. On Wi-Fi the PHY rate renegotiates continuously,
so the number in the status dialog is a snapshot, not a property
of the link.
That is one unedited capture from the machine this was built on, not a mock-up and not stitched together from several runs.
Because throughput is an average and a stall is not. A 6 Mbps upload sitting on a 100 Mbps link looks like nothing on a graph. But a retransmitted segment is data that had to cross the network twice, and the sender waits for a timeout before it tries again. A file download absorbs that invisibly — it just finishes a moment later. A live encoder cannot wait. The frame is already stale by the time the retry lands, so it gets thrown away, and OBS increments the dropped-frame counter.
Because Windows measures retransmissions and shows them nowhere. The counter is
right there in Win32_PerfRawData_Tcpip_TCPv4. It is not in Task Manager, not in the
Wi-Fi status dialog, not in Settings, and not in Resource Monitor. netdrop is
mostly a window onto a number the operating system already has.
Because "link speed" is not a speed you will ever get. Wi-Fi is half duplex on a shared medium: every device on the channel takes turns, and the acknowledgements come back over the same air. Real TCP goodput lands near half the PHY rate before you account for a single neighbour. Windows shows one number for both and calls it "Speed".
In the capture above, Get-NetAdapter reported 721.0 Mbps while the live
performance counter said 400.0 Mbps at the same instant. Across probes minutes
apart on an idle machine the live value moved 865.0 → 648.5 → 600.5 → 576.5 →
432.0 → 721.0 → 400.0 Mbps, while Get-NetAdapter.Speed and
Win32_NetworkAdapter.Speed lagged behind it every time.
The Wi-Fi PHY rate renegotiates continuously with signal quality. The number in the
connection status dialog is a snapshot from some past moment, not a property of
the link. netdrop shows both and flags the gap.
| Source | Value |
|---|---|
Get-NetAdapter.Speed — what Windows shows you |
721,000,000 |
NetworkInterface.CurrentBandwidth — what it is right now |
400,000,000 |
netsh wlan show interfaces is a third, completely separate source, and it tracks the
live counter rather than the stale one — realcheck.ps1 reads all three every run and
prints them. It deliberately does not assert that they are equal: they are three
samples of a continuously renegotiating value taken at three different instants, so
demanding equality would be asserting a flaky claim rather than testing a parse.
In the capture above, IPv6 had sent 116 segments since boot and retransmitted 60 of them. Reported naively that is 51.7% packet loss — a screaming red alert on a perfectly healthy connection that has essentially never been used. An earlier probe on the same machine caught it at 40 of 76, which is 52.6%.
netdrop refuses to report a rate below 1,000 segments and says why. The field
is left null, never 0, because "unknown" must not be able to masquerade as
"zero, therefore healthy".
| API | Spelling |
|---|---|
Get-NetAdapter |
Intel(R) Wi-Fi 6 AX201 160MHz |
| WMI perf classes | Intel[R] Wi-Fi 6 AX201 160MHz |
PDH / Get-Counter samples |
intel[r] wi-fi 6 ax201 160mhz |
PDH instance names cannot contain ( ) # / \, so the performance subsystem rewrites
them — and lowercases them in returned samples while the counter set listing shows
original case. Route every name through one canonical formatter or half your data
silently attaches to nothing.
SegmentsSentPersec on this machine reads 706,532. That is not a rate — it is the
cumulative count since boot. Every *Persec field in Win32_PerfRawData_* is a raw
counter and you must difference two samples yourself. The name lies.
Three suites ship with the tool. All three are run before every release and all three must be green.
powershell -NoProfile -ExecutionPolicy Bypass -File selftest.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File mutate.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File realcheck.ps1
Every field of every fixture carries a different distinctive value, so a tool that mixes two fields up cannot pass by coincidence. Both sides of every threshold are tested, including the exact boundary. A healthy fixture must raise zero risks, so the tool cannot pass by crying wolf about everything.
A suite that passes proves nothing unless it would fail on a broken tool. This
introduces one real bug at a time and requires selftest.ps1 to catch every one.
mutation score: 58 / 58 killed
invalid controls: 0
excluded from the score:
- measurable flag always true (no-op by construction)
Three things it reports separately, because conflating them would be dishonest:
- survivors — the mutation was introduced and the suite still passed. An untested code path.
- invalid controls — the anchor text was not found in the source, so nothing was mutated. An unvalidated anchor silently tests nothing and would otherwise be reported as a pass. Two were caught this way during development.
- excluded no-ops — a mutation that provably cannot change behaviour is excluded from the score rather than counted as a pass.
It supports paired edits for equivalent mutants. Format-Bar clamps both the
fraction and the derived fill count; removing either alone changes nothing
observable, so the harness removes both together instead of the assertion being
deleted.
Synthetic fixtures are clean and predictable, which is exactly why they miss things. This runs the same parsers against genuine live data and cross-checks every claim against an independent reference obtained by a different technique.
R1b every network counter: PDH (pdh.dll) vs the WMI perf provider
[ok] compared 89 live counter fields across 6 counter sets, 0 mismatches
(85 static compared EXACTLY, 4 monotonic bracketed, 0 gauges excluded)
[ok] negative control on static fields: 11 of 11 mutations rejected
. [UNDETECTABLE] 32-bit truncated - no static field it changes on this machine
R2 rate arithmetic vs PDH cooked values
. SecondValue / 1e7: mine 30482.4092 PDH 30482.4092 relerr 0.00000000%
. Timestamp100NSec / 1e7: mine 30485.0598 PDH 30482.4092 relerr 0.00869522%
. Timestamp.Ticks / 1e7: mine 30485.0598 PDH 30482.4092 relerr 0.00869522%
[ok] negative control: 2 of 2 wrong timebases scored worse
[ok] negative control: 5 of 5 scale and sign errors rejected
51 passed, 0 failed, 0 skipped
Every comparison carries a negative control. A check that tolerates anything
proves nothing, so after the real comparison passes the identical check is replayed
against deliberately corrupted values — off by one, sign inverted, read as kilobytes,
read as a fraction, 32-bit truncated, first two digits swapped — and every one must be
rejected. 0 mismatches only means something next to 11 of 11 mutations killed.
Fields are classified empirically, not assumed. Three alternating WMI samples decide whether a field is static (never varied → compared exactly, full mutator set including a single-unit change), monotonic (only ever rose → interval- containment bracketing is valid) or a gauge (observed to fall). Gauges are excluded from the exact claim entirely, because an oscillating level can return to a byte-identical reading having genuinely moved, so bracketing cannot establish stillness for them. Claiming "0 mismatches" about data that cannot be compared would be a lie.
Ground truth is planted inside real data. A synthetic adapter record with a
distinctive value in every field — including a name containing ( ), # and /
specifically to exercise the canonicaliser — is inserted into the middle of a genuine
live counter snapshot. The parser must find exactly those values while surrounded by
real ones, no real adapter may be displaced, and no planted value may leak into a real
record.
The headline feature is verified by generating the condition it detects. Do not
trust a counter because it returns a number without erroring — a plausible constant is
exactly what a broken path returns. realcheck opens 7 loopback TCP connections
and requires ConnectionsActive to move by at least 7 (exactly 7 when the machine is
otherwise quiet), then confirms the segment counter moved too so it is not a stale
constant. Loopback uses no external bandwidth, touches no configuration and kills
nothing. -SkipLoad skips it.
The tool's own -Json output is fed back through a bounds check, including
aggregates — every percentage finite and within 0–100, no negative rates, retransmitted
never exceeding sent, and no adapter exceeding its own link capacity. Replay of a
replay must be byte-identical, a UTF-8 BOM on the capture must be tolerated, and the
error path must still emit valid JSON.
realcheck.ps1 scans the source for 33 mutating calls (Set-ItemProperty,
Set-NetAdapter, Invoke-CimMethod, Stop-Service, netsh interface set, …) and
requires none of them, then proves the scanner works by injecting one and requiring it
to be found. It then snapshots the real adapter and IP configuration before and after
a full run and requires the result to be byte-identical, with a negative control
proving the snapshot detects a one-byte change.
There is no undo because there is nothing to undo.
A single Get-Counter sample cannot measure a rate — and it is worse than
returning zero. PDH cooks a /sec counter against whatever previous raw sample it
happens to be holding, which came from an arbitrary earlier query. So -MaxSamples 1
gives you either a flat 0 or a rate over an interval you do not control, and the
second case is more dangerous because it looks perfectly plausible. Measured live:
single sample over the caller's own 4.0 s span:
PDH cooked 0.0000, caller's actual rate 2.4788 (100.00% off)
netdrop takes two explicit samples and does its own arithmetic.
The rate formula was derived empirically, not from documentation. PDH exposes
RawValue and SecondValue alongside CookedValue, so running your own arithmetic
over two consecutive raw samples covers the identical window PDH used and must agree
to the digit:
rate = Δ(RawValue) / (Δ(SecondValue) / 10,000,000)
CounterSample.SecondValue is the PerfTime timestamp, not a second data value.
That form reproduces PDH's own cooked value to 0.00000000%, while Timestamp100NSec
and Timestamp.Ticks are both measurably worse. realcheck.ps1 reruns that
comparison and prints all three every time, and requires the two wrong timebases to
score worse — otherwise the comparison is not discriminating and the result means
nothing.
| Flag | What it does |
|---|---|
-Seconds <n> |
Length of the live sampling window. Default 5. |
-Adapters |
Include every adapter, not just the ones carrying traffic. |
-Info |
Explain where each number comes from and how it is computed. |
-Json |
Machine-readable output. Nothing else is printed. |
-Quiet |
Suppress all output; use the exit code. |
-FromJson <path> |
Replay a saved -Json capture instead of reading the machine. |
Exit codes: 0 clean, 1 risks found, 2 error.
-FromJson is the useful one for helping someone else. Have them run
netdrop.ps1 -Json > net.json and send you the file — you get their entire network
picture, replayable, with no screenshots and no remote session.
Windows PowerShell 5.1 or later. Nothing else — no modules, no downloads, no admin rights.
Execution policy. Windows blocks unsigned .ps1 files by default. Run it with
-ExecutionPolicy Bypass as shown above; that is process-scoped and changes
nothing on your machine. Do not change your machine's policy for this.
Runs fine without administrator rights. Everything it reads is available to a standard user.
Built by the same hand, same rules: read-only where it can be, dry-run where it cannot, no dependencies, and every number cross-checked against a second source.
- obs-4k60-recorder — record gameplay at native resolution and 60 fps with hardware encoding
- framecheck — your recording already dropped frames; this finds them
- gpucheck — whether the GPU was why
- diskrate — whether the disk was why
- cpuclock — whether the CPU clock was why
- ramcheck — whether memory pressure was why
- miccheck — audio desync, quiet mics and ducking
- bootlag — what is slowing your boot, in milliseconds
- gamemode — free the machine for a session, then put it all back
- pathfix — why the wrong
ffmpegruns
MIT © 2026 appsmypass