Commit graph

898 commits

Author SHA1 Message Date
Meta Vens
c729dff2ee Pin the Windows MsQuic to the OpenSSL build, and both of its digests
Q9 ruled A. The Schannel MsQuic refuses QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12
with QUIC_STATUS_NOT_SUPPORTED, and PKCS12 is the only credential type a
provider has on both platforms, so a Windows provider could not open a
listener against the runtime this repo was shipping. The same bridge
binary passes all twenty smoke checks and the v1 gate against the OpenSSL
build, which is what Soul verified and what this now downloads.

Two digests, not one. The zip is pinned by FEE9A664 and 19,179,817 bytes;
the msquic.dll inside it by C17C6581 and 4,181,856 bytes. The earlier
report and the cut map's first record put the zip's digest on the DLL,
which is a pin that checks the archive and then ships a file nobody looked
at. Sizes ride along with both, because a digest that fails tells you less
than a digest and a length that fail together.

The CRT is linked statically. A Node or Unity host without the Visual C++
redistributable cannot load a DLL that imports vcruntime140.dll, and
Windows reports that as "the specified module could not be found" while
naming the module that was found. Confirmed on the rebuild: msquic.dll,
CRYPT32, ntdll, KERNEL32, and no CRT import at all. The DLL goes from
83,456 to 302,080 bytes, which is the price of the host not needing
anything installed.

The Unity plugin's committed msquic.dll is still the Schannel one and this
does not touch it; the next build-unity-package.ps1 run is what replaces
it, and that is not in this batch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:04:27 +02:00
Meta Vens
8c418961c4 Close the three ABI gaps the bridge left open
Event 7 was not one per frame. The bridge writes the stream's kind byte
itself, that send completed as an anonymous event 7, and a host counting
completions saw six for five frames with nothing to tell them apart. The
send request now says which send it was; the kind byte's completion is the
bridge's own business and is dropped. A send MsQuic released without
putting on the wire reports it: event 7 carries a send result in `code`,
completed or canceled, so an aborted send is accounted for rather than
silently missing. Measured: 32 sends queued under an abort, 32 event 7, 28
canceled and 4 completed.

The initiator of a shutdown learned nothing. MsQuic gives it only
shutdown-complete, and the bridge swallowed that, so a host could end its
own connection and never see the id end. Event 4 now has one emitter —
shutdown-complete — and the two initiated-by callbacks record why rather
than announcing it. Each connection id gets exactly one event 4, whoever
ended it, with the code its side passed, and it is emitted after the id
has stopped resolving, so a host told a connection is gone cannot still
resolve it on the next line.

Return codes inverted by platform. QUIC_STATUS is a negative HRESULT on
Windows and a positive errno on POSIX, so a Linux host checking for a
negative code saw every failure as success, against a README that promised
negative. A MsQuic refusal is now -(1000 + (status & 0xffff)) on both, and
cultmesh_quic_last_status hands over the raw bits for diagnostics. On
win32-x64 a garbage PKCS12 returns -12032 with last_status 0x80072B18.
The v1 exports move with it; the managed connector tests the value against
zero and formats it, so the contract it was shipped with holds.

Smaller ones, from the same pass: AppendTrace is v1-only and bounded,
because a long-lived v2 connection grew that string once per MsQuic event
for nothing to read; a listener configuration failure takes the connection
out of the map instead of leaving it naming a handle MsQuic is about to
close; and next_event refuses a null payload with a non-zero capacity
rather than treating the lie as a discard.

Soul's C consumer now compiles against the header and asserts its own
struct and its eleven bound prototypes against it, and the koffi smoke
checks its declarations and the thirteen exports the same way: 20 of 20,
seq clean, cancel clean, the v1 gate 3 passed 1 skipped as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:00:06 +02:00
Meta Vens
47aa7b668b Keep bridge objects alive across the call that names them
FindStream, FindConnection and FindListener took the lock, dropped it, and
handed back a raw pointer. MsQuic's worker freed the object on
shutdown-complete and the host thread dereferenced it on its next send.
One release run in three crashed on each target under an ordinary
peer-driven stream teardown; ASan caught it three times out of three, with
the free inside DestroyStream on a worker and the read inside
stream_send_frame on the host thread. A null check cannot fix that: the
pointer is not null, it is dead.

The maps hold shared_ptr now and a lookup returns a reference the caller
owns for the length of its call. The worker's destroy takes the object out
of its map and drops the map's reference only. The MsQuic handle follows
the object: the destructor closes it exactly once, under the exchange
guard, so the handle is alive for as long as any holder is. A stream holds
its connection, which orders every stream handle's close before its
connection's without a rule anywhere saying so.

runtime_close quiesces. It marks the runtime closing, wakes every blocked
poller, and waits for every in-flight call to return before it closes a
handle or frees a byte; a call that arrives after that is refused with -1.
Waking a poller without waiting for it was how close returned a poller
into freed memory while looking like it supported the pattern.

Two things fall out of the model rather than being fixed on top of it: a
listener that fails partway through opening leaves no map entry and no
handle, because every failure path just drops the local reference, and a
stream is in the map before StreamStart, because the shutdown-complete
that StreamStart can reach on a worker has to find it.

Cut with it: Listener::stopped, V1Client::connection (an unread raw
pointer into the same hazard), and FindListener.

Event 7 is still one too many and the shutdown initiator still learns
nothing; that is the next commit. The rest of Soul's C consumer passes on
win32-x64, including race at 400 streams, which crashed before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:50:48 +02:00
Meta Vens
9a01321a8a Give the QUIC bridge a host-facing header
Soul wrote a C consumer of this ABI from the spec text, because there was
nothing to include. The struct layout, the exports and the conventions
lived in the .cpp and in prose, which is three places for one contract and
no way to notice when they part.

include/cultmesh_quic_native.h is now the contract: the 64-byte event with
every field offset asserted, the exports with their signatures, the id and
return-code conventions, the object lifetime rules, and the threading
rules. The .cpp includes it and keeps none of its own copy. The README
points at it rather than restating it.

The lifetime and return-code sections describe where the bridge is going,
not where it is; the two commits after this one make them true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:43:01 +02:00
Meta Vens
7badce1e4e Record Soul's Cut 3 pass
The bridge's lifetimes are wrong: a lookup hands the host a pointer the
worker can free before the next call. Release crashes on both platforms
under an ordinary stream teardown. Three ABI gaps ride with it, and the
return-code contract inverts between Windows and POSIX. One digest in
this map was the zip's, not the DLL's. All in a fix batch before Cut 4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:34:24 +02:00
Meta Vens
27446e7761 Rule Q9: pin the OpenSSL MsQuic on Windows
One credential type on both platforms, the provider role works, the ABI
is unchanged. The tree pays four megabytes more and the Unity plugin's
copy diverges until its rebuild. The map's claim that PKCS12 was one type
for both providers is withdrawn.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:29:57 +02:00
Meta Vens
aa22b6141a Record the Cut 2 fixes and Cut 3's landing, and raise Q9
The bridge is a runtime-neutral two-role event queue, built and smoked
on both platforms with the Linux artifact named as development-loop only.
Two hangs the old shape never exercised are pinned. One fork stops the
Windows provider: the pinned Schannel flavour does not implement the
PKCS12 credential the spec assumed was one type everywhere, while the
same bridge passes beside the OpenSSL flavour. Recommended: pin OpenSSL
on Windows and pay the tree size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:07:55 +02:00
Meta Vens
23dc0939e0 Give the bridge a Linux build the deploy host's own distribution can produce
There is no Linux MsQuic binary in any NuGet package, and the v2.5.9 GitHub
release carries only test bundles, so the Windows script's approach does not
transfer. What does exist is Microsoft's Debian 13 pool, which is the same
distribution Yggdrasil runs, and the msquic source tree at the matching tag for
the POSIX headers the NuGet omits.

`scripts/build-quic-native.sh` fetches the pinned `libmsquic_2.5.9_amd64.deb`
and the three headers `msquic.h`, `msquic_posix.h` and `quic_sal_stub.h`, and
verifies all four by SHA-256 the same way `build-quic-native.ps1:14` verifies
the NuGet. A digest that stops matching is a changed dependency, not a warning.

Two details the deb forced. It carries three library files and no licence text,
so the MIT licence is copied from the one this repo already committed for the
Windows plugin — the same text for both platforms. And it ships only
`libmsquic.so.2.5.9`, with no `libmsquic.so` for `-lmsquic` to resolve against,
so the script makes that link in the extracted tree rather than in the image.

The Linux CMake branch links `-fvisibility=hidden`, so only the twelve v2
exports leave the `.so` and the Windows-only v1 five are absent by construction
rather than by discipline, and sets `RPATH $ORIGIN` so the `libmsquic.so.2`
shipped beside the bridge resolves without being installed system-wide.

`build-quic-native.ps1`'s default output directory becomes
`artifacts/quic-native/win32-x64` so the two platforms' outputs sit beside each
other instead of on top of each other. Both existing callers —
`build-unity-package.ps1:80-82` and the Native.Tests csproj `:20-22` — pass an
explicit `-OutputDirectory`, so neither is affected.

Built and smoked in the workstation's Docker `debian:13` engine pinned by
digest: g++ 14.2.0, glibc 2.41, `libgamecult_mesh_quic_native.so` 85,456 bytes
with `NEEDED libmsquic.so.2` and `RUNPATH $ORIGIN`, and the same 15-check v2 FFI
smoke that runs on Windows passes under Node 24 on that image. That is a Linux
build on a Linux host and nothing more: Q6 ruled the release artifact comes from
the Actions job, and this one is the development loop.

A Linux host still needs MsQuic's own runtime dependencies present — libssl3t64,
libnuma1, libxdp1, libnl-route-3-200 — which is now written down, because the
first smoke run failed on a missing libxdp.so.1 and the reason was not obvious
from the error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:04:14 +02:00
Meta Vens
6a091ef65c Make the MsQuic bridge a two-role event queue instead of a Unity client
The bridge was one platform, one role and one host: Windows by construction
(`windows.h`, `__declspec`, `CryptHashCertificate2`), client-only, and shaped
around the managed connector that polls it every 2 ms. Nothing about MsQuic
required that; the shape came from having exactly one caller.

It now carries a C ABI v2 of twelve exports over a single per-runtime event
queue: open a runtime, open listeners and connections on it, open streams and
send frames, and take typed events out of one blocking wait. The event struct is
64 bytes described by layout rather than by a shared header, so a host declares
it in its own FFI and no header travels with the ABI. `next_event` keeps the v1
two-phase convention — return 2, leave the event at the head, report the size
the caller needs — because that is the one part of v1 that was already
host-agnostic.

The rule that shapes everything else is that nothing calls out. MsQuic's worker
threads only push onto the queue and wake that wait, so no host runtime is
entered from a thread it does not own, and the bridge needs to know nothing
about what is on the other side. That is what makes one binary serve both a
Node process through FFI and a Unity player through P/Invoke.

Trust moved out. A v2 client connection defers validation, emits the DER as
event 3 and answers MsQuic only when the host calls
`connection_certificate_complete`; if the host never answers, MsQuic's handshake
timeout closes the connection and event 4 says so. The C code no longer has an
opinion about certificates — except in the v1 shim, where it must.

The five v1 exports are unchanged in signature and behaviour and are now
`#ifdef _WIN32`, matching `CanConnect`'s own Windows gate. A v1 client is a
private v2 runtime holding one connection, and the pin comparison is the single
thing it still decides in C, because that is the contract the shipped Unity
package was built against. `windows.h`, `wincrypt.h` and `CryptHashCertificate2`
appear nowhere outside that block.

Two things the rewrite had to get right that the old shape never exercised.
The v1 pin must be attached before `ConnectionStart`, not after it returns: the
handshake runs on an MsQuic worker thread and reaches the certificate event
immediately on loopback, so a pin applied afterwards loses the race and the
connection silently takes the host path nobody is listening on. And teardown
must close the connection handles itself — `RegistrationClose` blocks until the
app has closed every child handle, and a silent shutdown delivers no shutdown
events at all, so leaning on the callbacks to do it deadlocks the caller
forever. Both were found by the tests hanging, and both now have a mutation that
reproduces them.

Verified: the four v1 tests in tests/GameCult.Mesh.Quic.Native.Tests pass
unchanged (3 passed, 1 skipped for want of an external endpoint), including the
wrong-pin refusal and the departed-client broadcast test. A 15-check FFI smoke
drives the whole v2 path — listener, deferred certificate, both sides connected,
latest-only stream, one frame with FIN, identical bytes out — and passes on
Windows x64 and on Linux x64. Ten source mutations, ten killed, two green
no-op controls.

One open fork, reported separately and not decided here: the pinned Windows
MsQuic is the Schannel build, which refuses
QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12 with QUIC_STATUS_NOT_SUPPORTED, so
`listener_open` cannot open a provider against it. The map assumed PKCS12 was
"one credential type for Schannel and OpenSSL"; the header declares it but the
Schannel provider does not implement it. The identical bridge binary passes the
full provider smoke against OpenSSL MsQuic 2.5.9, so this is a question about
which flavour Windows pins, not about the bridge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:03:01 +02:00
Meta Vens
7a0749b8e2 Say what the eleven stricter endpoint spellings actually are
Both comments around `STRICTER_THAN_CSHARP` called the divergences host
repairs. Most are not. Of the eleven, three carry a backslash and two have no
authority for `System.Uri` to accept; `refusedByCSharpUriShape` refuses those
five on the raw string, before there is a host to repair. Four are genuine host
repairs `URL` would make and C# would not — two trailing dots, two fullwidth
hosts — and the last two throw in `URL` before any rule of ours runs.

The same slip sits in `isProtectedEndpoint`'s own doc comment, over a different
eleven: it says `URL` repairs eleven spellings `System.Uri` refuses, when only
the four carrying a tab or CR/LF are rewritten and the other seven are admitted
as written.

Wording only; no rule changes. It matters because the next reader deciding
whether the pre-check earns its keep will reach for the reason the comment
gives, and "host repairs" points at the wrong half of the list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 20:21:42 +02:00
Meta Vens
6ed0ff75af Run both halves of the realtime frame parity in CI
The codec's byte parity rests on two vector files written from opposite sides:
`realtime-frame-vectors.json` by the C# reference and decoded by TypeScript,
and `realtime-frame-vectors.ts-written.json` by the TypeScript codec and
decoded by C#. Neither half ran in CI. The TypeScript suite ran nowhere —
cultmesh-ts was not in any job — and the C# test ran nowhere either, because
`cultnet-interop.yml` is windows-latest and main-only and does not filter for
it. A codec that agreed only with itself would have gone green on every push,
which is the exact failure the two-file scheme exists to prevent.

Both now run in the `reactive-and-resource-lifetime` job beside the authority
steps, on windows-latest and ubuntu-latest, for main, codex/** and pull
requests. That job was already the one place the TypeScript side of CultMesh
gets exercised on Linux at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 20:21:27 +02:00
Meta Vens
9a4c9396b4 Stop the realtime decoder handing back a view of its own frame
Two mutants the cut-2 pass recorded as equivalent were not. Both notes were
reasoning, and both were wrong in the same way: they argued from the shape of
the code instead of trying the input that breaks it.

The decoder's length refusal reads `payloadLength < 0 || expected !== length`.
The note said the first clause could never fire on its own, because a negative
payload length makes the sum smaller than the frame. It does not: the sum also
carries the three identity lengths, and a negative payload cancels against
them. A 37-byte frame declaring `channelLength=65535, payloadLength=-65535`
sums to exactly 37, matches its own byte length, and without the clause decodes
to three blank identities and an empty payload where `CultMeshRealtimeWireProtocol`
refuses it. Two smaller spellings do the same, `ch=1, pl=-1` over 37 bytes and
`ch=2, pl=-1` over 38. No test covered that shape; one does now, and the
mutation is listed and killed rather than excused.

The payload copy was worse, because the fix and the bug looked identical. The
decoder used `bytes.slice(...)`, which copies a `Uint8Array` — and the existing
test passes one, so it was green — but Node's `Buffer` overrides `slice` with
an alias of `subarray`. A `Buffer` is what a socket read hands a Node caller,
which is exactly how the QUIC plane in cut 4 will reach this function, so every
decoded payload would have been a live view of a frame buffer the transport is
free to reuse.

`Uint8Array.prototype.slice.call(bytes, ...)` was the obvious repair and does
copy, but `%TypedArray%.prototype.slice` takes its result constructor from
`@@species`, and `Buffer`'s is `FastBuffer`: the payload then comes back a
`Buffer` from a `Buffer` frame and a `Uint8Array` from a `Uint8Array` one, so
the same frame compares equal or unequal under `deepStrictEqual` depending on
how it arrived. `new Uint8Array(bytes.subarray(...))` copies and is one type on
both. Three mutations now guard the line — return a view, return a `Buffer`
view, return a species-typed copy — and the first two die only on their own
input type.

Mutation runner: 75 mutations, 75 killed, 0 survivors, three green controls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 20:20:16 +02:00
Meta Vens
65f2b666ef Run the TypeScript workspaces by the name cultcache-ts actually has
`npm run build:ts` and `test:ts` have been dead since 8cb3b72, not 8dd5a45 as
first recorded. That commit renamed the package to @gamecult/cultcache-ts and
left `run-typescript-workspaces.mjs` asking npm for a workspace called
`cultcache-ts`, which no longer exists; npm refused at the first entry in the
list, so neither script reached any workspace at all. npm resolves a workspace
by manifest name, not directory, so the list moves whenever a package is scoped
— said out loud in the source now, since nothing else will say it.

The mutation runner is renamed `scripts/mutate-cultmesh.mjs`. It stopped being
about the route verifier alone when cut 2 gave it a third target, the realtime
frame codec, and a file named for one of its three targets invites the reader to
assume the other two are somebody else's problem.

`docs/typescript-quic-realtime-cut.md:46-47` names the old path; the map is
Self's and is updated separately.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 20:19:49 +02:00
Meta Vens
343dffc1de Record Soul's Cut 2 pass and correct one attribution
Byte parity held both ways under Soul's own frames. Three small things
did not: a mutant declared equivalent was not, the payload copy is a view
for a Node buffer, and the frame parity has no CI executor. The breakage
of the aggregate scripts dates from the scoped rename, not the version
bump this map first named.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:58:50 +02:00
Meta Vens
fef767d34a Record Cut 2's landing
The frame codec exists with parity proven both ways from its first
commit, and its test found a real divergence before any consumer could:
the JavaScript decoder deletes a leading byte-order mark the .NET decoder
keeps. Two housekeeping rulings taken: the aggregate TypeScript scripts
have been broken by a rename for two days and get their one-word fix, and
the mutation runner is renamed for what it now covers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:24:11 +02:00
Meta Vens
ae29910b30 Mutate the realtime frame codec, one mutation per rule
A third target in the committed runner, `realtime`, over
`packages/cultmesh-ts/src/realtime-wire.ts`, killed by that package's own
suite. It gets the machinery the other two already have: a no-op control
through the same write path, the sidecar and its verified restore, and the
post-restore rebuild that proves `dist/` no longer carries a mutant, with
`0x31545343` as the sentinel its last mutation removes.

Thirty-seven mutations, one per rule the codec owns: each header field's
offset, width and endianness; the field order the reference fixed; the magic,
the stamped header size and wire version; the delivery ordinal; UTF-8
identities and byte-counting length prefixes; the identity and payload
ceilings; the ALPN, the two QUIC error codes and the two stream kinds; the
four encode refusals from the transport contract; each decode refusal and the
order they fire in; the byte-order mark; and the two aliasing rules, that
decode reads only the viewed bytes of an offset view and that the decoded
payload does not alias the frame. A mutation that changes the encoding
symmetrically still dies, because the vector bytes were written on the other
side.

All seventy-two mutations across the three targets are killed and all three
controls are green.

One mutant is not listed and says why in the table, as the P1363 length guard
already did: removing `payloadLength < 0` from the decoder's length refusal is
equivalent. The clause mirrors the reference but cannot fire alone, because
reaching it means the frame is already 37 bytes and a negative length can then
never make the sum match. It survived when tried.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:20:57 +02:00
Meta Vens
6cbc73fb17 Encode a CultMesh realtime frame in TypeScript
One codec, `packages/cultmesh-ts/src/realtime-wire.ts`, rule for rule what
`CultMeshRealtimeWireProtocol` does: the 37-byte little-endian fixed header
with magic 0x31545343, the delivery byte as the C# enum ordinal, the i64
epoch and sequence, three u16 identity lengths, the i32 payload length, the
stamped header size and wire version, then UTF-8 identities and the payload.
It touches no socket; QUIC stream framing stays transport-owned. The blank
reading in `Validate` is `IsNullOrWhiteSpace`, whose whitespace set is not
`String.prototype.trim`'s, so it borrows `isNullOrWhiteSpaceCSharp` from
`cultnet-ts` rather than growing a second reading of the same rule.

Byte parity is proven in both directions and in two processes, which is what
the authority vectors cost us to learn. `contracts/cultmesh/realtime-frame-vectors.json`
is written by the new C# test under CULTMESH_WRITE_VECTORS=1 and decoded and
re-encoded here; `realtime-frame-vectors.ts-written.json` is written by
`scripts/write-cultmesh-realtime-frame-vectors.mjs` with this codec, over
different identities and epochs, and decoded by the same C# test with the
reference implementation alone. Both files share one field shape, so each
side reads both with one reader. The two cases that would put megabytes into
a committed contract file, identities at exactly 65,535 UTF-8 bytes and a
64 MiB payload, are recorded as a build rule plus the digest of the whole
encoding; both sides build them the same way.

Three things the C# reference gets from its type system and TypeScript does
not, each refused rather than left to surface as a stray runtime error: a
`bigint` outside the i64 range, a delivery string the union cannot spell, and
a leading U+FEFF, which `TextDecoder` deletes by default and
`Encoding.UTF8.GetString` keeps. The last one is a real divergence the test
caught; the decoder now sets `ignoreBOM`. U+FEFF is not whitespace to .NET,
so such an identity passes the transport contract and reaches the wire.

There was no C# test for this codec before; this is also its first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:20:41 +02:00
Meta Vens
fe2555da63 Repair only the file states the mutation runner itself wrote
A SIGKILL leaves the sidecar behind, and the next run wrote it over whatever
the file then held. That is right after a kill and destructive after one: a fix
written to the source between the kill and the next run was reverted to bytes
from before the kill, with a line claiming a repair, and the run then reported
mutation results for code nobody had written.

The mutation table is the record of what a live run can leave behind: the
original (which is the sidecar, and also what the no-op control writes) or the
original with exactly one of that target's mutations applied. Repair now
recognises those and nothing else. A file matching neither stops the run with
its own digest and the sidecar's and changes nothing.

Reproduced both halves against the shared target. Killed mid-mutation, then
appended a line to the source: the rerun exits 1, prints both sha256s, and the
hand-written line is still there afterwards. With the mutant put back and the
sidecar unchanged, the rerun repairs as before and now names the mutation the
dead run was inside.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 18:35:44 +02:00
Meta Vens
6c933d034b Spell the transcript separator as an escape, not a raw byte
The unit separator that joins protocol ids sat in the source as a literal
U+001F: once in `cultmesh-authority.ts` and twice in its test. Every editor,
every diff and every review renders it as nothing, so `join("")` reads as a
join on the empty string and the transcript looks wrong to anyone checking it
against the C# reference, which spells the same separator as an escape.
The bytes were right, which is exactly why nothing caught it.

The three become escapes. The bytes do not move: the C#-written and the
TypeScript-written route vectors both still verify through the shared module,
and the sorted-transcript test still builds the same expected bytes.

A new test reads both files and refuses any byte below 0x20 that is not LF or
CR, so the class recurs loudly rather than silently. It earned its keep on the
first run by catching a literal U+001F in the comment introducing it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 18:32:07 +02:00
Meta Vens
c64a2da81d Refuse the URL-repaired spellings in the protection gate too
`isLoopbackEndpoint` hands its endpoint to `refusedByCSharpUriShape` before
`URL` sees it; `isProtectedEndpoint` did not, and `URL` repairs eleven
spellings `System.Uri` refuses outright. Every one of them answered protected
here and unprotected in the C# reference, which is the dangerous direction:
the channel-protection gate opened on a route `IsProtected` would have closed.
`wss:x`, `wss:host`, `wss:host:8443`, `wss:/host` and `wss:///host` have no
authority for `System.Uri`; `wss://host\path` and `wss://host:8443\` carry a
backslash; `wss://ho\tst/m`, `wss://host\t/m` and `quic://ho\tst` a tab, and
`wss://host\r\n/m` a CR/LF, all of which `URL` deletes.

Soul's 71 raw-shape spellings go in as a table with the answer the C#
`IsProtected` gave for each on 2026-09-16, and the test asserts the invariant
rather than equality: TypeScript may never call an endpoint protected that C#
refuses. Eleven of the 71 now differ the safe way round, C# protected and this
not, and they are pinned by name rather than excluded, so neither a twelfth
joining them nor one of them flipping passes quietly. Two throw in `URL` before
any rule of ours runs (an IPv6 zone id, a non-breaking-space host); the other
nine are host repairs `refusedByCSharpUriShape` refuses on purpose, because the
same repair would make `isLoopbackEndpoint` lie. None is dialable as a browser
CultMesh route.

The mutation that drops the pre-check from `isProtectedEndpoint` alone, leaving
`isLoopbackEndpoint`'s in place, is killed by that test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 18:29:13 +02:00
Meta Vens
565ce79a4a Close Cut 1 and start Cut 2
Three Soul passes and three fix batches leave one verifier whose rules
match the C# reference as executed, parity pinned both ways and run in
CI on both operating systems. Three residuals ride with Cut 2. Two
statements this map made about its own batch were overstated and are
corrected: the NUL byte never caused a rewrite, and CI was not green
throughout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 18:21:52 +02:00
Meta Vens
74fee16003 Record the Cut 1 edge-alignment batch
TypeScript now cleans a route the way the C# constructors do, reads
whitespace with C#'s alphabet, refuses the endpoint spellings the URL
parser would otherwise repair, and names the two signature refusals as
C# names them. Every probe reruns with zero reachable disagreements. Two
scars kept for the postmortem: a sidecar that reverted fixes it was meant
to protect, and a NUL byte that made git read source as binary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:51:25 +02:00
Meta Vens
0dfef0e5eb Run the TypeScript authority tests in CI, on both operating systems
Nothing ran `npm run test --workspace packages/cultnet-ts` in CI. The shared
route verifier's tests, which are the whole TypeScript side of the CultMesh
authority contract, ran only on a developer's machine, and the browser client's
tests ran nowhere. `cultnet-interop.yml` is windows-latest and triggers on main
only, so even that would not have covered Linux or a pull request.

Both now run in `cultmesh-portability.yml`'s `reactive-and-resource-lifetime`
job, beside the step that verifies the same vectors with the C# reference. That
job already matrixes windows-latest and ubuntu-latest, already triggers on
main, codex/** and pull requests, and already installs the TypeScript workspace
dependencies, so the steps cost two npm runs and no new setup.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:40:39 +02:00
Meta Vens
dfe582edbc Kill the browser's local reading of "unsigned", and mutate the browser too
Soul replaced the shared `isUnsignedCertificate` call in the browser's
session-acceptance check with a local empty-string comparison and every one of
the 18 browser tests still passed, because every test used an empty signature.
A whitespace signature is where the two readings part: the shared route
verifier accepts such a route as unsigned on a loopback local-development
endpoint, and an inline comparison then demands a provider proof for the
session the verifier just let through. The test now opens that session with
three spaces, U+0085 and a no-break-space/tab/newline mix as well as the empty
string, and asserts U+FEFF the other way, since it is whitespace to JavaScript
and a signature to C#.

The mutation runner grew a second target. `shared` is the one verifier, killed
by the cultnet-ts tests; `browser` is `packages/cultmesh-browser/src/index.ts`,
killed by the browser tests. A browser mutation cannot be killed by cultnet-ts
tests, so the killer is the target's own workspace, and each target carries its
own sidecar, control, sentinel and post-restore dist assertion.

Two repairs this pass earned:

- The sidecar is now removed inside the `finally`, right after the verified
  restore. A throw mid-run left it behind holding the pre-run bytes, and the
  next run "repaired" the file from them, silently reverting everything fixed
  in between. That happened here, twice, before it was noticed.
- The NUL in the loopback pre-check's strip regex had been written as a literal
  byte rather than an escape. It read the same, but git classified the file as
  binary and stopped normalising its line endings, so the whole file diffed as
  rewritten. Escaped, along with the ASCII host range beside it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:39:50 +02:00
Meta Vens
803ee12a7a Refuse the endpoint spellings WHATWG URL repairs and System.Uri does not
`isLoopbackEndpoint` handed the raw endpoint to `URL` and read the host it got
back. `URL` is a repairing parser and `System.Uri` is not, so seven spellings
were loopback here and remote in C#: a trailing dot on the host, which `URL`
drops; `ws:127.0.0.1`, `ws:/127.0.0.1` and `ws:///127.0.0.1`, whose authority
`URL` infers; a backslash path separator, which `URL` reads as a separator;
fullwidth digits and letters in the host, which `URL` folds to ASCII; and a tab
in the host, which `URL` deletes. Each of those is a local-development bypass:
a route the C# consumer would have demanded a certificate for opened unsigned
here.

`refusedByCSharpUriShape` refuses exactly those, on the raw string, before
`URL` sees it. Leading and trailing C0 controls and spaces around the whole
endpoint are stripped first, because both sides accept them. The spellings
Hands probed earlier and C# accepts -- `127.1`, hex, decimal,
`[::FFFF:7F00:1]`, userinfo, `localhost:8443` -- are untouched.

The doc comment claimed equivalence with `System.Uri.IsLoopback`. It is not
equivalent and now says so: two divergences remain, both unreachable from a
browser CultMesh route, and both named. An IPv6 zone id throws in `URL` and is
a host-local interface name no browser socket can dial; `file:` and `mailto:`
have no `URL` host and are refused by `openSocket`, which takes `ws:` and
`wss:` only.

The test is Soul's endpoint list with the C# answer as the expectation: 82 of
the 88 it probed, the six unreachable ones named in the doc comment instead.

Mutations: the trailing-dot refusal dropped, the scheme-and-authority shape
inverted, the backslash refusal dropped, and the non-ASCII host refusal
dropped -- each killed by the table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:22:06 +02:00
Meta Vens
c4c2330805 Clean the route fields before the transcript, as the C# constructors do
The C# reference signs a transcript over a *constructed* route, and the
constructors clean on the way in: `CultMeshAuthorityRoute.Clean` trims each
protocol id, drops the blank ones, keeps one of each ordinally and sorts
ordinally; `RequireNonEmpty` trims the runtime id and endpoint; `Require`
trims the provider key id and coordinates; `CultMeshRouteCertificate` trims
the Odin key id; a blank generation becomes the runtime id and endpoint
joined by "@". TypeScript cleaned none of it and transcribed the caller's
string, so a route C# signed and accepted was refused here the moment any
field carried padding or a duplicate.

Soul's tamper loop is the evidence: C# accepted a duplicated protocol id, a
padded generation and a padded Odin key id against the committed vector's own
signature, and TypeScript refused all three. The test now reruns those, plus a
padded protocol id, a blank one, a reversed list, a padded endpoint and a
padded provider key id, against the C#-written vector's signature. A padded
Odin key id must also find its root, so the trust map keys on the trimmed id
as the C# dictionary does.

`verseId` is left alone on purpose: it is not a route field, and
`CanonicalRoute` passes the caller's string into `Canonical` untrimmed. The
test pins that too.

`protocolId ?? "cultmesh.documents.v1"` is gone. A C# route with no protocol
ids is `Array.Empty<string>()` and transcribes as the empty string; reading
the handshake's `protocolId` as a one-element list wrote bytes the reference
never writes. `canonicalSession` matches its C# counterpart the other way:
it cleans nothing on the request, because the handshake message is what the
wire carried, and only the endpoint is cleaned, because the one C# passes is
`Route.Endpoint`.

Mutations: the dedupe removed, the blank-id filter removed, the generation
trim removed, the provider key id trim removed, and the Odin key id trim
removed from the lookup -- each killed by its test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:14:35 +02:00
Meta Vens
e67e695a63 Read whitespace and the signature refusals as C# does
Two edge alignments that have to land together, because the test for the
first asserts the messages of the second.

`String.prototype.trim` is not `char.IsWhiteSpace`. JavaScript counts U+FEFF,
which .NET does not; .NET counts U+0085, which JavaScript does not. Every
`trim()` in the shared verifier stood in for a C# `IsNullOrWhiteSpace` or
`Trim()`, so both were wrong at those two code points: a U+0085 signature was
an unsigned route to C# and a bad signature to TypeScript, and a U+FEFF
signature the other way round. `isCSharpWhiteSpace`, `trimCSharp` and
`isNullOrWhiteSpaceCSharp` are that set, and `isUnsignedCertificate` and the
signature trim now use it. The test pins all 34 code points Soul probed on
both sides against the answers .NET 10 gave on 2026-09-16.

The C# `Verify` also has three signature refusals, not one: a signature that
is not base64, a decoded length that is not 64, and a signature that decodes
but does not verify. TypeScript collapsed the first two into the third and
lost the distinction the reference draws. Split, with each message pinned by
its exact C# string. `verifyP256` keeps its own checks: it is the silent
`VerifySession` path, which returns false for all three.

Mutations: the whitespace-set site reverted to `String.prototype.trim`,
U+0085 dropped from the set, and the two new messages merged back into
"invalid" -- each killed by its test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:09:48 +02:00
Meta Vens
b7bb483989 Record Soul's second Cut 1 pass
The cut closes on its invariants. What is left is the gap between the C#
rules as written and as executed: the route is cleaned before the
transcript on one side and transcribed raw on the other, whitespace and
loopback are decided by different alphabets at the edges, and the browser
guard is enforced by name only. All in a final Hands batch before Cut 4
makes a TypeScript signer real.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:02:03 +02:00
Meta Vens
04087c6ab6 Record the browser session alignment and widen the name grep
The browser's local-development short-circuit reads "unsigned" through the
shared predicate, so the two TypeScript sides cannot disagree with each
other or with C#. The negative grep now names the two functions the fix
batch added. The generated contracts file's line-ending churn is recorded
where unassigned findings live.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:45:00 +02:00
Meta Vens
a801e0a441 Share one reading of an unsigned certificate between verifier and browser
The browser's local-development session-proof short-circuit tested
`!route.certificate`, while the shared verifier already treated an empty or
whitespace signature as unsigned, matching C# `Verify` (`:120`). A loopback
route carrying a blank-signature certificate therefore passed the verifier
and was then refused for lacking a provider key, where C# returns
`IsLocalDevelopment` and skips the session proof.

`isUnsignedCertificate` in `cultnet-ts/authority` is now the one owner of
that reading; the verifier and the browser both call it. A browser test pins
the local-development loopback open without a provider proof, the signed
loopback certificate still demanding it, and the remote counterpart refused
in both trust modes. The mutation runner gains two predicate mutations
(whitespace treated as signed; only a missing certificate treated as
unsigned), both killed by the existing whitespace-signature test in
cultnet-ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:43:09 +02:00
Meta Vens
e3f56c183f Record the Cut 1 fix batch
Both parity directions are now pinned in the repo and run in CI, the
public verifier copies its bytes, and the four edge rules match the C#
reference, with the loopback set probed rather than assumed. One scar: the
runner's multi-line anchors had never matched on a CRLF checkout, so two
earlier kill claims rested on an LF-on-disk state. One browser-side
divergence remains and is in Hands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:37:04 +02:00
Meta Vens
0e4f479ac5 Make the mutation runner leave no mutant behind
Every test run inside the runner rebuilds `packages/cultnet-ts/dist/`
from the mutant under test, so after the final restore of the source the
compiled module still carried the last mutant. The runner now rebuilds
the package after the restore and asserts `dist/cultmesh-authority.js`
contains a sentinel the last listed mutation removes, checked against the
list itself so a reordering cannot make the assertion vacuous.

The original bytes are also written to `<target>.mutation-original`
before the first write and removed after the verified restore. A run
killed mid-mutation is repaired from that sidecar at the next run's
start, instead of by whoever notices git status.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:34:31 +02:00
Meta Vens
cf1203a607 Run the C# authority vector checks in CI
`CultMeshAuthorityProofTests` verifies both vector files with the C#
reference, but no workflow ran it. The portability workflow's
reactive-and-resource-lifetime job is the one that already builds and
runs `GameCult.Mesh.Tests` on every push to main and codex branches and
on every pull request, on Windows and Ubuntu, so it gains one step:
`dotnet test --filter FullyQualifiedName~AuthorityProof`. The interop
workflow was not chosen: it runs on Windows only, on main pushes only,
and runs no Mesh tests. It already covers the TypeScript direction
through `npm test --workspace packages/cultnet-ts`.

CULTMESH_WRITE_VECTORS stays unset in CI; the committed files are the
contract.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:28:29 +02:00
Meta Vens
1590145a81 Pin the TypeScript-signed direction of the authority parity check
`contracts/cultmesh/authority-route-vectors.json` proves C# bytes verify
in TypeScript. Nothing in the repo proved the reverse; Soul's probe did it
once in a scratch directory. It is now a committed check in the same shape,
mirrored:

- `scripts/sign-cultmesh-authority-vectors.mjs` builds the route and
  session transcripts with the shared module, signs them with fixed
  TypeScript-owned test keys over awkward inputs (a quic scheme with a
  query, three unsorted protocol ids, non-ASCII generation text, priority
  13), self-verifies, and writes
  `contracts/cultmesh/authority-route-vectors.ts-signed.json`.
- `CultMeshAuthorityProofTests.AuthorityRouteVectorsSignedByTypeScriptVerifyHere`
  reads that file with keys taken from the file, asserts the protocol ids
  are unsorted, that `Validate` and `VerifySessionProof` accept, and that
  a priority-tampered route and a replayed nonce are refused.
- The TypeScript vector test runs over both files, so a transcript change
  on the TypeScript side fails here before C# could trust a stale file.

Checked negatively: one base64 character changed in the committed
signature fails the C# test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:27:06 +02:00
Meta Vens
c2ffc32ff6 Make the TypeScript route verifier the C# rules exactly
verifyP256 handed WebCrypto `view.slice().buffer`. For a Node Buffer,
`slice` is `subarray`, so `.buffer` is the shared pool: a pooled Buffer or
a subarray of a larger allocation verified false while the same bytes as a
plain Uint8Array verified true. Copy exactly the viewed bytes.

Trust rules that had drifted from `CultMeshAuthorityProof.cs`:

- A certificate whose signature is empty or whitespace is an unsigned
  route (C# `Verify` line 120): local-development loopback accepts it,
  everything else refuses with the unsigned message.
- Loopback is `System.Uri.IsLoopback`: any 127.0.0.0/8 host, `::1`, the
  IPv4-mapped `::ffff:127.0.0.1` (only `.1`), and the host names
  `localhost` and `loopback`. WHATWG URL normalises `127.1`, `0x7f000001`
  and `2130706433` the way System.Uri does. Probed on the C# side through
  the public policy before the rule was written; the test pins that set.
- The root lookup runs before the validity window, so an expired route
  under an unknown root is refused as untrusted on both sides.
- A policy listing one Odin key id twice is refused at first use, the
  closest a plain object gets to the C# constructor throwing.

One test per rule, one mutation per rule in the runner's list. A fixture
whose protocol ids are unsorted and whose expected transcript is the
sorted one means removing the sort is now killed.

The runner now matches anchors in the file's own line ending. On an
autocrlf checkout the two multi-line anchors it already had ("route
transcript field order", "session transcript binds the nonce") matched
nothing, so the run threw at those entries instead of testing them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:24:41 +02:00
Meta Vens
c00bed8575 Record Soul's Cut 1 pass
The verifier is sound in both directions, the second one by probe. What
can go wrong silently is the verification story: a mutant left compiled
in an ignored directory, a C# vector check with no CI owner, a public
function that reads a pooled buffer wrong, and four edge rules that drift
from the reference. One map claim about error texts is withdrawn.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:19:46 +02:00
Meta Vens
737e13286b Record Cut 1's landing
One route verifier in cultnet-ts, the browser copy deleted, a C#-written
vector file, and a mutation runner with a control. Soul is on it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:01:46 +02:00
Meta Vens
062a5c6678 Add a mutation check for the shared route verifier
One mutation per rule the cut map names, applied to the source bytes and
restored from the original bytes with a digest check; a no-op control
runs first through the same write path. A survivor is a rule no test
pins and fails the script.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 15:59:09 +02:00
Meta Vens
66286b9dfb Delete the browser copy of the route verifier
cultmesh-browser now verifies routes and provider session proofs through
cultnet-ts/authority. Its own verifyAuthorityRoute, canonicalRoute,
canonicalSession, canonicalFields, verifyP256, isLoopbackEndpoint and the
two base64 helpers are gone; the six route and trust types are re-exported
under their old Browser names as aliases of the shared types. randomNonce
stays: it is client behaviour, not verification. All 17 browser tests pass
unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 15:59:09 +02:00
Meta Vens
e234a64ae9 Add the shared CultMesh route verifier to cultnet-ts
Cut 1 of the TypeScript QUIC realtime plane: one TypeScript
implementation of Odin root lookup, the validity window, the canonical
route and session transcripts, P-256/P1363 verification, loopback
detection and the protected-scheme rule, published as
`cultnet-ts/authority`. Browser-safe by construction: WebCrypto,
TextEncoder, atob/btoa, URL, DataView; no node: import, and a test that
says so.

The protected-scheme rule is the C# rule (wss, https, any scheme
containing "quic") rather than wss-only, so the module is not wrong for
QUIC routes; the browser client still refuses non-ws(s) endpoints itself.

The transcript bytes are pinned by contracts/cultmesh/authority-route-
vectors.json, written by the C# reference under CULTMESH_WRITE_VECTORS=1
with fixed test keys and otherwise re-verified by both runtimes. The
vector carries a route certificate and a provider session proof.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 15:59:09 +02:00
Meta Vens
1c12136872 Rule Q8: the native binaries are committed
The deploy tarball is a git archive, so committed files are the only ones
that reach Yggdrasil without a second mechanism, and the Windows pair is
already committed for Unity. Four binaries, about eight megabytes, with a
manifest and digests beside them. No operator question is open on this map.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 15:57:33 +02:00
Meta Vens
4a2fdafa61 Remap Cut 6 under C: binaries in the tree, a release workflow, no registry
The deploy tarball is a git archive of two package directories, so only
committed files reach Yggdrasil and cultmesh-ts does not ride at all
today. Under C the binding resolves binaries from an environment variable
or the package's own native directory, a workflow builds the Linux artifact
in a debian:13 container and attaches it to a release with a manifest and
digests, and the operator smoke ships its own archive. Registry publication
is parked with its seam named. One question remains: whether the four
binaries are committed or fetched at deploy time.

The corrected npm premise is recorded in the operator's words: reachable,
publication deferred by choice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 15:28:31 +02:00
Meta Vens
8d8ad568aa Re-rule Q5 to C: no registry, because npmjs is unreachable here
Ruling A assumed a publish job whose token is minted by logging in to
npmjs.com, and the operator's subnet is blocked there. That is also why the
existing cultcache-ts publish job has never run. Consumers keep file: paths;
the binaries ship in the CultLib tree the deploy path carries; the Actions
build attaches the Linux artifact to a release. Cut 6 and the lines of Cuts
4 and 7 that assumed a registry are flagged for remapping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 15:12:08 +02:00
Meta Vens
3c29fc934d Record the Q5, Q6 and Q7 rulings in the QUIC cut map
All three recommendations were taken: unscoped cultnet-ts and cultmesh-ts
with only the native platform packages scoped; the Linux artifact built by
GitHub Actions in a debian:13 container and smoked on Yggdrasil; the lookup
source port plus a static source now, with the Node Odin rendezvous parked.

The status header now says no operator question is open and cut 1 has not
started, so a reader of the header alone knows where the campaign stands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 13:35:11 +02:00
Meta Vens
bf9f6f7874 Map the TypeScript QUIC realtime plane migration
Seven cuts against main at f2cd2eb4, with the largest liability priced first
rather than discovered later. The native binding was the open question: a probe
loaded the committed msquic.dll through koffi under Node 24, drove a handshake
on MsQuic's own threads and closed cleanly, with no addon build and no toolchain
at load time. It is one cut, not the campaign.

The consumer audit found no package at zero external consumers and every
external consumer on a file: path rather than the registry, which is why
registry naming is an operator question rather than an assumption.

Two corrections against the target are recorded in the map rather than absorbed
silently: a cited transport filename that does not exist under that name, and a
registry path the target assumes for cultmesh-ts that has never existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:16:56 +02:00
Meta Vens
f2cd2eb464 Add the TypeScript QUIC realtime plane migration target
Eureka target for giving cultmesh-ts a QUIC realtime provider and consumer,
byte-compatible with GameCult.Mesh.Quic and the Unity native MsQuic connector.
First consumer: the StreamPixels service publishing to its Unity overlay.

Operator rulings 2026-09-16: overlay character, subathon window and scene
intents travel as QUIC state frames and the costume catalog as an HTTPS
content artifact; shared TypeScript route verification moves to a
browser-safe module; Windows x64 and Linux x64 only; datagrams out of scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:02:33 +02:00
Meta Vens
a0813c6eed Release CultLib Unity 1.0.59 and the Studio 1.3.1 caching-unity-v1.3.1 cultlib-unity-v1.0.59
Corrects the 1.0.58/1.3.0 release. Rebuilds the tracked plugins with
scripts/build-unity-package.ps1 -UpdateTemplate so they carry "" as the
unset CultRecordRef wire form in every position (nil still reads as unset)
and the edit-start Record snapshot. The native QUIC connector changes once
to its /Brepro form; later rebuilds reproduce it byte for byte. The Studio
package moves to 1.3.1 and depends on cultlib 1.0.59.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:35:34 +02:00
Meta Vens
2053ab740d Build the native QUIC connector reproducibly with /Brepro
b85a828 changed x86_64/gamecult_mesh_quic_native.dll with no native
source change. The two copies differ in six bytes: the PE header
TimeDateStamp and the debug directory timestamp. The MSVC link stamps the
build time, so every -UpdateTemplate run rewrote the committed DLL.

/Brepro on compile and link replaces the timestamps with content hashes.
Two clean builds from an emptied build root now produce identical bytes,
and the DLL embeds no build path. The committed DLL changes once to the
reproducible form; after that the release byte check covers it like the
managed plugins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:34:23 +02:00
Meta Vens
b03118db2c Space the Studio drawer's ReplaceKey and FreshKey arguments
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:34:23 +02:00
Meta Vens
29bec57021 Decode an edit's Record from the bytes its Document came from
BeginEdit cloned Document eagerly but Record lazily from record.Document,
so a cache change in place before Record's first read made the two copies
come from different states. BeginEdit now serializes the document once and
deserializes Document from those bytes; Record deserializes the same bytes
on first read. Record is the state the edit began from, and costs one
deserialize only when a drawer reads it, with no second serialize.

Each commit still spends the edit, so a slider drag begins an edit per
frame; that serialize was already paid for Document, and Record adds a
deserialize only in frames where a drawer reads it.

The new test mutates the cached document between BeginEdit and the first
Record read; it fails on the lazy clone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:34:23 +02:00