The Moonlight bridge streams: video and input, on Moonshine's shoulders
The bridge now does the whole of part four. Video: the target's
Annex-B off 9805 is grouped into frames (nal), each
packetised into RTP with the NV video-packet header and Reed-Solomon
parity (video, fec-rs), and sent to the
client's 47998 (session::pump_video) - proven by a test
that runs a fake source through the real pipeline to a UDP sink. Input:
the client's controller packets arrive over an AES-128-GCM ENet channel
on 47999 (control, rusty_enet), and each
becomes a PPAD record forwarded to the target's 9806
(input) - which is exactly what the fake target prints. The
RTSP handshake (rtsp) ties launch to play, and PLAY starts
both.
This half is adapted from Moonshine (Hans Gaiser,
BSD-2-Clause), not merely informed by it, so the attribution grew to
match: its copyright notice is retained verbatim in
THIRD-PARTY-LICENSES.md, ACKNOWLEDGEMENTS.md
names it the base, and each streaming source file says which Moonshine
file it follows. BSD-2 permits the derivation and asks only that the
notice travel with it, which it now does.
Two surprises worth keeping. First, the protocol's own source
is the only reliable spec - the RTP fec_info word
packs shard index, data-shard count and FEC percentage at bit offsets
12, 22 and 4, and the control nonce is the sequence followed by zeros
and the bytes HC; none of that is guessable, and reading
Moonshine (which had already read Sunshine and moonlight-common-c) is
what made it exact. Second, the two halves need different
transports for a reason: video is fire-and-forget RTP/UDP with
FEC to survive loss, input is an ENet channel because a dropped button
is wrong forever - the same split part three drew between a state you
resend and a delta you cannot. What a headless test still cannot do is
show the final picture in a real client; that is the one step left, and
it needs a Moonlight app on the LAN rather than more code.
The Moonlight bridge, built up to pairing, in a crate of its own
Prosperous can now be a Moonlight host. The whole thing lives in a
new crate, pros-moonlight, reached by two pros
verbs - fake-target and moonlight - and it
stays out of pros-core and pros-link on
purpose: it needs a TLS stack, a self-signed X509 certificate,
AES/RSA/SHA, an mDNS responder and (for the streaming half still to
come) Reed-Solomon FEC and an ENet port, none of which orbistoun or
obSCEne should inherit by taking those two crates. So the heavy list
stops in the new crate, and the crate map is moonshine's
(BSD-2, credited): rustls with the ring
provider because it builds here with no cmake or nasm,
rcgen/x509-cert,
rsa/sha2/aes,
mdns-sd.
What is built and tested: the fake target (serves an
Annex-B clip on 9805, prints the PPAD records that arrive
on 9806 - the "fake input" the design's item 5 calls for); the
pairing crypto, mirroring Sunshine's
nvhttp.cpp byte for byte - a PIN-salted SHA-256 AES key,
ECB challenges, RSA-signed commit-and-reveal on both sides; and the
server - mDNS _nvstream._tcp,
serverinfo and the four pairing phases over HTTP (47989),
the final leg over HTTPS (47984) presenting the very certificate the
client pinned. Twenty-four tests, pedantic clippy clean.
The surprise worth keeping: the hard part was the crypto
exactness, not the plumbing. Every phase concatenates specific
fields in a specific order and hashes them, and a byte out of place
fails silently as "wrong PIN" with nothing to point at. Getting it right
meant reading the server's own source (Sunshine) rather than a client's
description of it, because the client and server are mirror images and
only one of them is the thing being written. Once the four-phase
handshake passed against a test client that plays Moonlight's exact
steps, standing it up over real sockets - HTTP parsing, TLS with the
pinned cert - was ordinary. The verification that a real client will
pair is that test: it is the Moonlight algorithm, driven
against the bridge's own routing over real TCP, plus the running binary
answering serverinfo on both ports. What is left is the
session: RTSP, RTP video with FEC on 47998, and the ENet input channel
on 47999 mapped to PPAD.
A quiet socket ended the watch on Windows, and the pump now knows both names for a timeout
Reviewing the Porthole wire contract against
pros-core::watch found the pump reading with a half-second
timeout and treating only WouldBlock as the pause between
frames. That is the name Unix gives a timed-out read. Windows gives it
TimedOut, so on Windows the first half-second with nothing
arriving ended the stream - and said so with a line about the connected
party failing to respond, which reads as a fault on the target's side.
The transport's own log reader had already learned this
(wire::is_quiet matches both names); the pump had not.
Two tests pin it. The unit test feeds a source that answers every read with one name and then the other, with no socket, and requires the pump to go round rather than settle. The standin test opens a silent fake with a short timeout and lets the platform choose the name, which is the reading that failed here before.
The surprise worth keeping: nothing in the suite had ever exercised the timeout arm. Every socket test read with a five-second timeout against a fake that answered within milliseconds, and the one test that did wait on silence read the socket directly and matched both names in its own assertion - the knowledge was in the test file and not in the code beside it.
The same review found the target side of the same seam blocking on its input socket, so that the video stalled whenever a pad was at rest; that is fixed in Porthole, in oops-apps, and recorded there.
Process control moved in from obSCEne, first-class in both programs
Restarting the user interface and closing a title were built inside
obscene-tool, each hand-parsing
ps/procstat and encoding the signal policy
inline. The transport was never the duplication - both already ran over
pros_link::shell - but the capability had leaked
into a consumer: which process is the interface, that the system
respawns it, that a stopped title needs a wake signal before a kill or
it leaves locked vnodes behind. That is target-management knowledge, so
it came here. (D027)
It sits in pros_core::system the way launch
does: pure builders and selectors - shell_ui,
of_title, kill, end, a
Signal enum - with the effect left to the shim.
end is where the wake-then-kill order lives, as data rather
than a branch at a call site, so it is tested against a ps
fixture with no target in the room.
First-class in the CLI as pros restart-ui and
pros close <id>, and in the window as a restart
UI button and a per-title close, both leaving the process
list showing what is running now. obSCEne's two functions became thin
calls into pros-core, so the recipe it used to carry is
gone and the knowledge has one home.
The surprise worth keeping: obSCEne found the
interface with procstat -a; this uses ps,
because ps is what system::processes already
parses. ps lists every process so the UI is in it, but that
its command column reads exactly SceShellUI under
ps is an expectation, not a measured fact - flagged in D027
rather than asserted, and the one thing a hardware run should
confirm.
contents(), which was made testable and then not tested
8 tests in crates/pros-core/tests/walking.rs.
transfer.rs went from 71.60% to 80.17% of
regions covered.
transfer::contents carries a doc comment saying it is
"separated from the sending so it can be tested, and so a caller can
show what is about to go before any of it does" - and nothing
tested it. A seam introduced for testability and left untested is the
cost of the seam without the benefit.
It is also the half of a restore that decides what a restore
is. upload takes a live session and cannot be
exercised without a target; contents decides the file list
that session is handed, so a folder missed here is a file that never
goes back.
What is pinned: paths come back relative (an absolute one would carry this machine's directory onto the target), the order is sorted rather than the filesystem's, directories are walked rather than listed, an unreadable folder is an error and not an empty list, and the depth bound stops a deep tree while still reporting what was inside it. The last one is asserted at the boundary in both directions - twelve levels found, thirty not - because an off-by-one there either loses a legitimate file or walks a level further than the rule says, and neither shows up on a shallow folder.
upload, configured and
temporary stay uncovered: they need a live session or the
user's own config directory, and a test that reached for either would be
testing this machine.
Why
run was greyed on every payload, and the two folders behind
it
Reported from the window: a payload ticked in the payloads pane, and
run refused. The refusal itself was correct -
Offer::Run sends bytes from here, and
Entry::here was None - but four things around
it made that unreadable, and one of them was a second directory nobody
had noticed was second.
open folder opened a different folder from the
one being judged. It revealed manifest::staging(),
which is cache_directory()/payloads, under a comment
stating that is "where the payloads table sends from". It is not, and
had not been for a while: the row action sends from
local_path, which is data_root()/payloads, and
so does the toolbar, and so is where a download is written. Somebody
opening the folder to find out why run was greyed was shown
a directory that has no bearing on the answer. Now it opens the one the
pane uses.
The table had twelve cells per row and eleven
headings. There is no "run" heading, so every
label sat one column left of its data - name over the run
buttons, running over the sizes, version over
the on/off marks. It reads as a table that is simply wrong about itself,
and it is invisible until somebody compares a value with the word above
it.
An empty local folder presented as thirty dead
buttons. library::here reports a folder that does
not exist as an empty one, which is right - a machine where nothing has
been downloaded is not a failure - but nothing said so, and the only
explanation was on the hover of a control that looked broken. It now
says it once, above the table.
run a file... opened wherever the last dialog
had been. It passes local_path to
rfd, which ignores a directory that is not there - and on a
first run that directory does not exist, because nothing has created it
yet. Made before the dialog opens.
The surprise worth keeping: none of these was the refusal being wrong. Every one was something around it disagreeing about which folder the question was about, and together they made a correct refusal look like a broken button. Two names for one directory is enough to do that on its own; a comment asserting the wrong one of the two is what made it survive.