fix(mediaplayer): detect VOD seekability under Proton/Wine#975
Merged
dooly123 merged 3 commits intoJul 21, 2026
Conversation
VOD played at delivery speed for Linux users running the Windows build under
Proton, reported as roughly 4x through the content. The multiplier is not
fixed; it is download bandwidth over stream bitrate.
The live-vs-VOD probe established seekability from Content-Length queried with
WINHTTP_QUERY_FLAG_NUMBER64. Wine's winhttp does not implement that flag - its
QUERY_MODIFIER_MASK covers only REQUEST_HEADERS, SYSTEMTIME and NUMBER - so it
is left in the attribute index, the header lookup misses, and the query fails.
Under Proton the source therefore always looked non-seekable, delivery pacing
never engaged, and pace_gate() became a no-op, letting the demuxer consume the
file as fast as the CDN would serve it. The same failed query left
content_length at -1, so seeking was unavailable there too, which also cost
those clients the seek that syncs a late joiner to the owner's playhead.
Read the length headers as strings and parse them instead. That is the default
query path on native Windows and avoids the 4GB truncation the 32-bit
WINHTTP_QUERY_FLAG_NUMBER would bring.
Parsing them means validating them, since these are remote input:
- The whole field must be a plain in-range integer. _wcstoi64 would accept a
numeric prefix ("123junk" as 123) and saturate to the signed maximum on
overflow, either of which hands a bogus finite length to the seek and pacing
logic.
- On a 206 the Content-Length covers the part that came back, so a
range-capping proxy could present a truncated length as the whole file. Only
Content-Range can establish a total there.
- Content-Range must match the exact "bytes <first>-<last>/<complete>" grammar
with coherent bounds. The grammar carries no whitespace of its own, and a
value must not pass on the strength of its tail. Either "*" form is unknown.
- Its first byte must be 0, because that is what the probe requests. A response
describing any other start is not the body the caller believes it is reading.
Body length and representation total are distinct quantities, and they answer
different questions, so they are now tracked separately. Finite and rangeable
is enough to call a source on-demand and pace it; the complete length stays
unknown unless it can be proven. Conflating them would have meant a server that
states no total looking non-seekable, which would replay the same
fast-forward bug by another route.
The Windows build under a compatibility layer runs against reimplemented WinHTTP, Media Foundation and D3D11, so no native Windows lane covers it. Record what to check there and that it needs a real Proton user.
Built RIST-enabled against the Unity 6000.5.4f1 PluginAPI headers.
towneh
marked this pull request as ready for review
July 20, 2026 11:12
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
VOD played at delivery speed for Linux users running the Windows build under Proton, reported
after a test session as "playing at about 4x speed" through the content. The multiplier isn't
fixed: it's download bandwidth over stream bitrate, so it varies by connection.
The live-vs-VOD probe decides whether to pace delivery, and it establishes seekability from
Content-Length:
Wine's winhttp doesn't implement
WINHTTP_QUERY_FLAG_NUMBER64. ItsQUERY_MODIFIER_MASKcoversonly
REQUEST_HEADERS | SYSTEMTIME | NUMBER, so the flag isn't stripped from the attribute index,the header lookup misses, and the query returns FALSE. Under Proton
haveLenwas therefore alwaysfalse,
seekablestayed 0,pacedandpace_deliverystayed 0, andpace_gate()returnedimmediately, leaving the demuxer to consume the file as fast as the CDN would serve it. The comment
above the detection in
basis_media_core.cdescribes that outcome exactly.The same failed query left
content_lengthat -1, so seeking was unavailable for those users too.That has a knock-on in multiplayer: late joiners are synced by seeking to the owner's playhead, so
a Proton client joining an in-progress VOD couldn't take the seek and started from zero, while also
racing through the content.
Content-Length is now read as a string and parsed. That's the default query path on native Windows,
and it avoids the >4GB truncation that the 32-bit
WINHTTP_QUERY_FLAG_NUMBER(which Wine doesimplement) would bring. The two
STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBERqueries are untouched.Parsing headers means validating them, since they're remote input:
_wcstoi64accepts a numeric prefix(
"123junk"as 123) and saturates on overflow, either of which hands a bogus finite length tothe seek and pacing logic.
present a truncated length as the whole file. Only Content-Range can establish a total there.
bytes <first>-<last>/<complete>grammar with coherentbounds, and its first byte must be 0. That's what the probe asks for, so a response describing
any other start isn't the body the caller thinks it's reading.
Body length and representation total are different quantities answering different questions, so
they're tracked separately. Finite and rangeable is enough to call a source on-demand and pace it,
while the complete length stays unknown unless it can be proven.
Includes the rebuilt Windows x64 plugin (RIST-enabled, Unity 6000.5.4f1 PluginAPI headers). Android
is unaffected, since
basis_win_http.cppis Windows-only.Required checks
All boxes below must be ticked before this PR can merge. If a check is genuinely N/A, tick it anyway and explain under Notes.
TransformAccessArrayor are otherwise batched. I have not added per-frametransform.position/transform.rotation/transform.localPositioncalls inside loops. Whenever I need both position and rotation, I use the combined APIs —SetPositionAndRotation/SetLocalPositionAndRotationfor writes,GetPositionAndRotation/GetLocalPositionAndRotationfor reads — instead of two separate property accesses; the combined call does one local-to-world matrix traversal instead of two.Resources.Load, no direct asset references that pull large content into memory on scene load.GetComponent/AddComponentwhere avoidable — Where unavoidable, the result is cached on a field, and anyGetComponent<T>is replaced withTryGetComponent<T>(out var x)— bareGetComponentwill be denied.TryGetComponentis the modern API (Unity 2019.2+) and skips the Editor-only GC allocationGetComponentcauses when a component is missing: Unity wraps thenullreturn in a managed "fake null" object so its overloaded==operator can still detect destroyed C++ objects, and constructing that wrapper allocates;TryGetComponentreturns aboolplusoutparameter and never builds the wrapper. None of these calls run insideUpdate,LateUpdate,FixedUpdate, jobs, or other per-frame code paths.BasisEventDriver— Any new per-frame work hooks intoBasisEventDriverrather than adding standaloneUpdate/LateUpdate/FixedUpdatecallbacks on a MonoBehaviour.BasisEventDriveris bulletproof, or guarded bytry/catch—BasisEventDriverruns the single per-frame tick that drives the whole framework (network apply, local player sim, blendshapes, JigglePhysics, nameplates, and more) as one sequential chain. An unhandled exception anywhere in that chain aborts the rest of the tick, so every step after the throwing one is silently skipped for that frame. New work added to the driver must either be guaranteed not to throw, or be wrapped in atry/catchthat contains the failure and surfaces it throughBasisDebug— logged once / rate-limited, never every frame (see the existingHVRBasisBuiltInAddresses.Simulate()guard for the pattern). Expect this to be scrutinized closely in review.{ get; set; }properties or access lockdowns — Public fields are fine; Basis is meant to be read and modified freely, so don't wall things offprivate/internalwithout a real reason. Don't wrap a field in{ get; set; }when the accessors do nothing — property accessors have a real performance cost vs direct field access, and the lead maintainer prefers plain fields (or a method / setter-only property when only the setter needs logic) over a noop-getter pair. For.Instancesingletons, callers reassigningType.Instanceis allowed; if that would break your code, log a warning or throw — don't block the assignment. Locking down access is not your call.BasisLocalCameraDriver— Code that needs the local camera (transform, projection, rig data, etc.) pulls it fromBasisLocalCameraDriverrather than looking one up itself. Don't roll a separate camera discovery path.BasisDebug— All new logging calls go throughBasisDebug.Log/BasisDebug.LogWarning/BasisDebug.LogError(with an appropriateLogTag) instead ofUnityEngine.Debug.Log/Debug.LogWarning/Debug.LogError.BasisDebugroutes through Basis's tagged, color-coded logger and respects the project-wideLoggingDisabledtoggle so logging can be killed at runtime; bareDebug.Logcalls bypass that and will be denied.FindObjectOfType/FindObjectsOfType/GameObject.Find/FindGameObjectsWithTagto locate what it depends on. References are wired in — registered through an existing manager/driver, injected at init, or passed in by the caller — rather than discovered by scanning the scene at runtime. If a scene scan is genuinely unavoidable, justify it under Notes.newon reference types, no LINQ, nostringconcatenation/interpolation, no boxing, noforeachover interface-typed collections. Allocate once at init and reuse the buffer.BasisDebug. Hot-path logging floods the console and incurs cost on every frame regardless of whether the message is filtered out downstream. If a hot-path log is needed while iterating, gate it behind#if UNITY_EDITORand remove (or leave gated) before merge..Count(lists) /.Length(arrays) into a localintbefore the loop instead of re-reading the property each iteration. PreferT[](with a separate length int when the array is over-sized) overList<T>where the data is hot — Unity's mono BCL doesn't exposeCollectionsMarshal.AsSpan(List<T>), so a list can't be fed intoSpan<T>/ unsafe paths cleanly. Where the perf justifies it, drop intoSpan<T>/reflocals /Unsafe.As/unsafepointer code to skip bounds checks and copies, and call out the invariants you're relying on under Notes so reviewers can sanity-check them.Testing details
Tick the platforms you actually tested on. Leave the rest unticked — these are informational and do not block merge.
Input / control mode coverage:
Where applicable, confirm these flows still work after your changes:
Notes
Verified on Windows; the Proton path itself is not verified. Being straight about the split,
because it matters for how much weight to give this.
What was covered: a Windows x64 standalone build, playing HTTPS VOD across MP4 and audio-only
lanes. Every HTTPS VOD open goes through
basis_win_http_open, so the new parsing ran on everyone of them. The test endpoints answer 206 with
Content-Range, which means the Content-Rangepath was exercised against real servers and produced correct results: duration reported correctly,
seek works in both directions, and a late joiner syncs to the owner's playhead, which depends on
the same probe. So this is positive confirmation the new parsing computes the right total, not
just an absence of crashes.
What was not covered: Proton itself. I don't have a Linux machine running it, and there's no Linux
lane in the media player's TESTING.md to run against. The Wine behaviour behind the diagnosis is
from reading Wine's
dlls/winhttp/request.c, whereQUERY_MODIFIER_MASKomitsWINHTTP_QUERY_FLAG_NUMBER64, rather than from observing it on a device. If anyone here runsBasis under Proton, a single VOD playing at 1x rather than racing would confirm it, and the seek
bar working would confirm the knock-on fix.
Android is unaffected and was not tested for this:
basis_win_http.cppis Windows-only, and theAndroid path uses
basis_jni_https.c, which this PR doesn't touch.The header parsers were exercised directly rather than by inspection, using a harness that
#includes the translation unit under test so it runs the shipped statics rather than a copy thatcould drift. 30 cases pass, covering the ordinary nginx 206, capped ranges, both
*forms,incoherent bounds, wrong units, whitespace inside the grammar, non-zero start offsets, numeric
prefixes and
int64overflow. It isn't committed here: it's Windows-only, and the existing nativegates (
fuzz-demux,conformance-demux) run on Linux, so wiring it in belongs with the CI workrather than bolted onto this fix. Happy to add it if you'd prefer it landed together.
The gameplay and perf checks above are N/A: this is a native HTTP header query plus the rebuilt
binary. No C# is touched, so nothing here goes near transforms, Addressables, components,
BasisEventDriver, the camera driver, logging, scene discovery, or any per-frame path.One deliberate non-change, since it looks like an inconsistency on a close read.
Accept-Ranges: byteswithout a 206 still marks a source seekable for pacing purposes, and thatis intentional. Seeking itself is gated separately and more strictly:
basis_win_http_can_reseekand
basis_win_http_reseekboth additionally requirerange_ok, which is only set on a 206. So aserver that merely advertises ranges is treated as on-demand and paced correctly, but never has a
ranged re-request issued against it.