feat(mediaplayer): HLS and LPCM (m2ts) playback on Android#927
Draft
towneh wants to merge 31 commits into
Draft
Conversation
…aPlayer split baseline The embedded copy had drifted into a partial mix of the standalone repo's merges. Reset it to the exact split-point tree so the standalone repository's post-split commits replay on top verbatim - preserving per-commit authorship, messages and diffs in this repository's history before that repository is retired. The replayed series ends at the standalone main tree, so the net change from this branch is identical to the previously reviewed snapshot.
HLS with fMP4/CMAF segments loaded a single video frame and played no audio. The fMP4 demuxer emitted a fragment track-major — every video sample of a moof, then every audio sample — and with delivery pacing on (always, for HLS) the paced video burst spans the whole fragment duration in wall time, so the fragment audio behind it arrives a full fragment late. The decoder trims that audio as stale and its audio-gated presentation clock never starts: one frame, silence. Consume a moof mdat as a decode-time-ordered merge across its trun runs instead, interleaving the tracks at frame granularity the way the MPEG-TS demuxer's output naturally is. Decode order within each run is preserved; single-track fragments (split-stream fMP4 legs) are delivered byte-for-byte as before. Verified against ffmpeg-generated CMAF (H.264+AAC, 2s fragments): worst delivery-order PTS gap between tracks drops from ~2.1s (the fragment duration) to ~190ms (B-frame reorder depth), same access-unit counts and timestamps; a video-only fMP4 stream demuxes identically before and after. Rebuilt basis_media_native.dll (win-x64) and libbasis_media_native.so (arm64-v8a).
basis_avcc_to_annexb is bounds-checked, so an output larger than the buffer fails the conversion rather than overrunning — but with a 1-3 byte NAL length prefix, an access unit made of many small NALs could outgrow the fixed 64-byte headroom (each NAL trades its length prefix for a 4-byte start code) and be dropped. Size the buffer from the worst-case growth instead: a NAL is at least nal_len_size + 1 bytes, so growth is bounded by (4 - nal_len_size) * (size / (nal_len_size + 1)). For the common 4-byte prefix this stays exactly size + 64. Rebuilt basis_media_native.dll (win-x64) and libbasis_media_native.so (arm64-v8a).
Consumer code had no way to answer "what is playing" (no title, filename or display name anywhere on the player surface) and no queue concept. BasisMediaMetadata carries SourceUrl/FileName/Title plus enrichment-only Uploader/ThumbnailUrl/Duration. The player derives defaults from the URL alone at every load (decoded filename, title fallback chain down to the host), so the surface works on every platform with no resolver package installed. Richer values layer in through BasisMediaSource.Metadata at LoadSource or BasisMediaPlayer.ApplyMetadata afterwards, and OnMetadataChanged reports every update. LoadUrl seeds the metadata for the requested URL up front, so a resolver-loaded stream keeps the page URL as its origin rather than titling as the CDN endpoint. Remote clients derive identical metadata from the already-synced input URL, so nothing new is networked. BasisMediaPlayerPlaylist is an optional component driving a player through ordered Url + DisplayName entries: PlayAt/Next/Previous, an advance policy (None/Sequential/LoopAll) acted on at OnEnded, and an OnEntryChanged event. Entries load through the player's normal routing, so resolver steering and the security gates apply per entry. On a networked player it routes loads through BasisMediaPlayerNetworking's SetUrl so entry changes reach remote clients via the existing URL sync, and auto-advance runs only on the owning client. The playlist itself is not networked; late joiners see the current entry, not the queue.
Dropping the playlist component on a player did nothing until code called PlayAt. Add PlayOnStart (default on) mirroring BasisMediaPlayerStreaming's ConfigureOnStart: the first entry loads locally at Start and the networking full-state flow settles who wins; manual PlayAt/Next/Previous keep routing through SetUrl. Also move the component beside BasisMediaPlayerStreaming under Runtime/Examples - it is orchestration on top of the player, not core surface - and document the ConfigureOnStart overlap.
…RL titles Persist the metadata origin URL onto the source instance at LoadSource, so reloading the same object (Reload, the native auto-reconnect) keeps the page-URL-derived origin after the one-shot LoadUrl seed is spent, instead of retitling from the resolved stream endpoint. OnMetadataChanged now fires on every load with the current snapshot rather than only on computed changes - simpler contract, documented on the event. Playlist auto-advance scans past blank/invalid entries instead of stalling on one (LoadEntry ignores them without moving CurrentIndex, so the old code retried the same dead index every OnEnded). FromUrl keeps Title non-null for a null/unparseable URL, preserving the documented invariant for a bare BasisMediaSource handed straight to LoadSource.
Matches the package's other component editors (shared MediaPlayerSDK.uss, header card, foldout cards): Playlist and Behaviour foldouts bound via PropertyFields, a status line (entry count in edit mode; current entry and name in play mode), and play-mode-only Previous/Next buttons wired to the component.
The rebuild decision compared only SourceUrl, so loading a NEW BasisMediaSource whose origin matched the previous load kept that load's enrichment and consumer-stamped fields (Uploader, ThumbnailUrl, ApplyMetadata titles). Existing metadata is now kept only for the load it belongs to: the seeded LoadUrl continuation, or a reload of the same source instance (Reload, the native auto-reconnect). Any other instance rebuilds URL-derived defaults and re-merges whatever the source itself carries.
Adds a Now Playing card to the BasisMediaPlayer inspector: the metadata Title, with uploader (or filename) beneath, refreshed while playing and hidden otherwise. Makes the metadata surface visible without consumer code.
Title/Uploader/File/Duration as label-value status rows (the Debug window idiom), each hidden when the field is empty; long titles wrap via a status-value-wrap variant.
A classic progressive MP4 (ftyp moov mdat with sample tables, no moof fragments; the most common recorded-file shape) played nothing: the demuxer was fragmented-MP4-only, so it announced the tracks from the moov and then found no trun runs to slice the mdat with. No error surfaced; the symptom was a silent black screen. Parse the classic sample tables (stts/ctts/stsc/stco/co64/stsz/stss) and walk the mdat as a forward stream, merging both tracks in file-offset order (the muxer's chunk interleave). The box loop now reads headers first and streams a progressive mdat sample-by-sample instead of buffering whole boxes, so file size is no longer bounded by the 256 MB box cap; fragment mdats keep the buffered path. Faststart layout (moov before mdat) is required by a one-way byte source; a trailing-moov file now reports a clear remux-with-faststart error instead of silence. Verified offline against the fragmented-only demuxer: the progressive recording that surfaced the bug goes from zero output to all 28 video + 42 audio samples; a generated 12 s faststart file demuxes fully with a worst A/V delivery-order gap of 233 ms (inside the paced clock's 250 ms audio floor); fMP4 output (CMAF HLS fixture and a fragmented video-only stream) is byte-identical before and after; a non-faststart file produces the remux error. Rebuilt basis_media_native.dll (win-x64) and libbasis_media_native.so (arm64-v8a).
A malformed (or crafted) stbl repeating stts/ctts/stsc/stss/stsz/stco/co64 overwrote the already-parsed table pointer, leaking the earlier allocation. First box of each kind now wins; the stsz guard keys on sample_count so the constant-size form (which allocates nothing) is covered too. Behaviour on well-formed files is unchanged - harness output is byte-identical on the progressive and CMAF fixtures. Rebuilt both binaries.
Remote MP4 reaches this parser (HLS/CMAF segments, VOD URLs), so treat every stream-supplied field as hostile: - parse_traf: validate tfhd/tfdt/trun field reads against the box body, and reject a trun whose declared per-sample table does not fit in the box (or exceeds a sane per-fragment sample cap). Unchecked counts could previously drive reads past the buffered moof. - Box walkers: rewrite child-size checks in subtraction form (sz > len - off) so a crafted size near INT_MAX cannot overflow the signed addition and bypass the bound. - consume_mdat / consume_progressive: widen run positions to int64 and make the sample fit checks overflow-safe; cap progressive sample sizes at the box cap so a crafted stsz entry cannot drive a multi-gigabyte allocation. - Timestamps: convert ticks to microseconds via quotient/remainder (ticks_to_us) instead of value * 1000000 / timescale, which overflows at stream-controlled timescales. - ctts: honour the FullBox version - version 0 offsets are unsigned, version 1 signed. Previously all offsets were cast signed. - stz2: detect the compact sample-size table and surface a clear unsupported-format error instead of starting playback with the affected track silently missing. - stsd: bound the mp4a fixed-header reads to the sample entry size. - Annex B sizing: move the worst-case AVCC-to-Annex-B output size into a shared helper (basis_avcc_annexb_cap) and use it from both MP4 paths and RTMP. RTMP previously allocated dlen + 64, which undersizes streams using 1-3 byte NAL length prefixes and silently dropped the access unit.
Remove the never-read next_dts track field, update the fMP4-only header comment (the demuxer has handled progressive MP4 since the classic-table work), refresh the top-of-file assumptions to match, and break the nested '/*' in basis_bitstream.h that warned on every translation unit.
Progressive playback previously ignored edts/elst, so every track started at presentation time zero regardless of its edit list. A track delayed by an initial empty edit (common muxer output for A/V alignment) played that much early, permanently desynced from the other track. Parse mvhd for the movie timescale and each track's elst, then map media time onto the movie timeline at emission: initial empty edits become a presentation-time delay, and the first normal edit's media_time becomes the media-time origin (encoder priming / initial trim). Samples ahead of the origin keep a negative presentation time rather than being dropped, since video there can still carry reference frames; multi-segment edits beyond the first normal entry are out of scope for a linear walk. Fragmented MP4 is unaffected - its timeline comes from tfdt.
Delivery pacing gated on the presentation timestamp of whatever the demux thread submitted last. A video AU whose composition offset puts its pts past the pacing lead therefore slept the demux thread out beyond its own decode turn - and any earlier samples of the other track queued behind it on the same thread arrived late enough for the audio queue to trim them as stale. Streams reordering less than the ~400 ms lead masked this. Carry the decode timestamp through the video sink alongside pts and gate on it, while the decoder and caption store keep receiving pts. Demuxers without decode timestamps (TS, RTSP) pass pts for both, which is the old behaviour; RTMP forwards the FLV tag timestamp it already had. The progressive MP4 walk had the file-order variant of the same stall: a muxer that interleaves chunks coarsely (a chunk of video ahead of the matching audio) blocked the forward reader on paced video before it could reach the audio bytes at all. Samples whose file order runs ahead of their delivery turn now park in bounded per-track queues and deliver in decode-time order across tracks; past the 32 MB bound (or once the mdat has nothing more to read) the earliest parked sample delivers regardless, degrading to the previous behaviour instead of buffering without limit. The fragmented path keeps its decode-time merge and hands the merge key to the sink as the decode timestamp.
basis_media_native.dll (win-x64, MSVC 19.51) and libbasis_media_native.so (android arm64-v8a, NDK clang 18) built from this branch.
The load-identity plumbing around metadata had three gaps, all variants of 'which load does this async continuation belong to': - LoadGeneration advanced only in LoadUrl, so a stale resolver continuation could pass its staleness guard after a LoadLocalPath, a direct LoadSource or a CPU Source assignment replaced the source. It now advances on every source replacement, whichever entry point. - The LoadUrl metadata seed was a bool that stayed armed when routing ended without a load (no resolver installed, or a resolver that errored before LoadSource), so the next unrelated load inherited the abandoned URL as its metadata origin. The seed is now keyed to the generation that armed it and is retired on the no-resolver path and by every non-continuation entry point. - LoadSource wrote the resolved metadata origin back into the caller's BasisMediaSource, where it went stale if the caller mutated the descriptor's Uri and loaded it again. The origin is now retained player-side and only trusted while the same instance reloads with an unchanged Uri; the caller's descriptor is no longer touched. Also: Metadata now returns a snapshot (and OnMetadataChanged delivers one), so external code can't mutate the player's live instance past the change event - ApplyMetadata is the mutation path. Assigning a CPU Source clears the previous media's metadata (event fires with null) instead of leaving it reported as current.
Adds basis_media_get_duration_us: 0 while unknown and for live sources, so a non-zero duration doubles as the 'this source has a seekable timeline' signal the managed layer and networking gate on. Demuxers report through a new optional on_duration sink callback: - Progressive MP4: the mvhd duration (now parsed alongside the movie timescale; the all-ones unknown sentinel is ignored), falling back to the longest track's stts total when mvhd carries none. Reported only when the classic tables are walkable - fragmented streams stay at 0 and get their duration from whatever layer knows the timeline. - HLS VOD (EXT-X-ENDLIST): the summed segment EXTINF durations, exposed as basis_hls_duration_ms. A playlist beyond the internal segment cap reports the truncated total, matching what actually plays.
…LS VOD basis_media_seek_us(engine, target_us): accepted on sources with a seekable timeline (duration > 0), asynchronous, lands on the preceding keyframe / segment boundary. Progressive MP4 over HTTP: the byte source gains a reposition hook - basis_win_http_reseek swaps the response for a ranged GET on the same connection (206 required; a server that ignores Range fails the call instead of silently restarting at byte 0). The engine posts seek requests through a new optional take_seek sink callback, one per demux leg so a split source repositions both; the demuxer maps the target to each track's table cursor (video backs up to the preceding stss sync sample), refetches at the earliest needed byte, and drops parked samples from before the jump. The paced read-ahead ring parks its reader around the refetch and stays alive at EOF so a backward seek after the file has fully buffered still works. HLS VOD with TS segments: the segment list (uri + duration) is retained at open; a seek rebuilds the fetch queue from the segment containing the target and flushes the stitched ring - the TS demuxer resyncs on the 0x47 sync byte, and the decoder's existing >1s hard-resync re-anchors the paced clock. fMP4-segment VOD is excluded (a mid-box ring flush can't be resynchronised) and therefore reports no duration: a non-zero duration is the managed layer's seekability signal, so the progressive path likewise only reports one when its source can actually reposition. Live sources are untouched: no pacing, no reseek hook, seek requests rejected at the ABI.
… native engine Duration consults the native engine's timeline when no CPU seekable source is active (0 stays TimeSpan.Zero, preserving the Duration-gated behaviour everywhere). Seek dispatches to the native asynchronous seek instead of throwing on the native path - it still throws NotSupportedException when the source has no seekable timeline - and resets the audio sync anchor so the scheduled-audio ring re-anchors on the post-seek timeline; OnSeekCompleted reports the requested target (the native landing position is the preceding keyframe). A deferred BasisMediaSource.StartPosition now applies on the native path at ready, best-effort: a live source skips silently, since a late joiner may sync a position into a stream that turns out to have no timeline. Old native libraries without the new exports read as duration 0, which is exactly the pre-feature behaviour.
The networking layer already carried everything needed - FullState positionTicks, the Seek message, drift correction, StartPosition on the late-join apply - all gated on Duration > 0, which was permanently zero for native sources. With the engine now reporting a timeline and honouring absolute seeks, two gaps remained: - Owner position heartbeat: a 9-byte latest-wins ping (Sequenced, like the framework's other position streams) every 5s (inspector-tunable, 0 disables) while playing seekable media, so passive clients re-converge between state events through the existing drift correction. Drift-only: it never starts or pauses playback. - Resolved page URLs skipped the owner's position/pause snapshot entirely (resolution is async, and the old backend couldn't seek anyway). The snapshot is now stashed with the load and applied on the resolved source's OnReady, aged by the local resolve time when the owner was playing - so a late joiner to a YouTube VOD lands near the owner's position instead of at zero, and the heartbeat refines the residual.
…ers panel Playback group gains a timeline scrubber beneath the transport buttons, shown only when the selected player reports a seekable timeline (Duration > 0). The slider API has no drag events, so a seek fires once the handle rests for 0.35s; the per-tick position refresh leaves the knob alone while a drag is pending. Seeks route through the networking component when present (ownership + broadcast), else straight to the player. The Status group now shows elapsed/total time next to the state word (ticking the repaint gate once per second, only while a timeline is showing) and the current metadata - title and uploader - refreshed via OnMetadataChanged. Player-supplied text is <noparse>-wrapped like the existing error strings.
basis_media_get_position_us returned the PTS of the most recently DECODED frame. The demuxer keeps feeding the decoder while presentation is paused or stopped (delivery pacing runs on its own wall anchor), so a stopped player's position kept counting up - visible now that the panel surfaces a time readout and a seek bar. Track the last presented PTS in a field that survives the presenter's resync sentinel resets and report that instead, falling back to the decode-side value before the first frame shows (start-up, audio-only) so early consumers still see the clock move. This also makes the networking position heartbeat broadcast what viewers actually see rather than the decode lead.
basis_media_native.dll (win-x64, MSVC 19.51) and libbasis_media_native.so (android arm64-v8a, NDK clang 18) built from this branch.
…markup The Status line wraps titles, uploaders and error strings in <noparse>, but TextMeshPro's <noparse> is not nestable: an embedded </noparse> in the supplied text terminates the block and everything after it parses as rich text again. Titles and uploaders can be resolver- or remote-supplied (they ride the networking layer) and error strings echo URLs, so a crafted value could inject arbitrary TMP markup - visual spoofing, <sprite>, <size>, <link> - into the panel. Break every '<' in player-supplied text with a zero-width space before appending: renders identically, and no tag (including a </noparse> closer) survives parsing. The <noparse> wrapping stays as the first line of defence for everything else.
The resolver already deserialises the page title, uploader, thumbnail and duration and then discarded them. Fill BasisMediaSource.Metadata from the extraction (SourceUrl = the page URL, matching what networking syncs), so the player surfaces the real title instead of URL-derived defaults. No extra fetch and no resolver-interface change; players without the metadata surface are unaffected because the field simply goes unread.
The HLS source (protocol/basis_hls.c) is portable C and already ships in the Android build; only the HTTP provider selection in run_hls was Windows-only. Wire the JNI HttpsURLConnection byte source in as the Android provider, so playlist and segment fetches ride the same code path the push-model https fallback already uses (TLS, redirects and thread attach handled there). The read timeout is 60s to sit out LL-HLS blocking playlist reloads. Also hand .m3u8 URLs to the HLS source ahead of the AMediaExtractor attempt: the extractor cannot stitch segments, so trying it first only added a rejected network round-trip before the fallback. No behaviour change on Windows, where basis_decoder_try_open_url always returns 0. Delivery semantics carry over unchanged from the portable core: live playlists present at the live edge with AU-level delivery pacing, ENDLIST playlists auto-detect as VOD and play once. fMP4/CMAF-segment HLS remains unsupported on every platform; TS-segment HLS is the working case. Rebuilt libbasis_media_native.so (arm64-v8a).
Ports the Windows LPCM lane to the Android backend so m2ts LPCM - the only full-7.1 audio carriage (AAC decoders cap at 5.1) - plays there too. The demux side was already portable: the TS demuxer recognises Blu-ray HDMV LPCM (stream_type 0x80) and delivers header-stripped big-endian frames through the shared sink on every platform; Android received them and had nowhere to put them. On the LPCM codec no MediaCodec is created: submit_audio converts big-endian 16/24-bit Blu-ray-order PCM to interleaved WAVE-order float (same channel-assignment remap tables as the Windows lane) and writes whole frames straight into the PCM ring, preserving the ring's channel-phase alignment contract. 48 kHz 16/24-bit only, matching what the demuxer announces; the config blob carries the channel assignment and bits code.
The extractor doesn't surface HDMV LPCM, so handing it an .m2ts URL plays the video with the audio silently missing. Keep m2ts on the portable TS demuxer + LPCM bypass path, which plays both. No change on Windows, where the extractor attempt is already a no-op.
Built from this branch (NDK clang 18); the Windows DLL is untouched.
27 tasks
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
Two Android audio/streaming gaps closed, one
.so:HLS playback. The HLS source (
protocol/basis_hls.c) is portable C and already ships in the Android build — the only gate wasrun_hlsconstructing its HTTP provider under#if defined(_WIN32). This wires the existing JNIHttpsURLConnectionbyte source in as the Android provider (same code path the push-model https fallback already uses: TLS, redirects and JVM thread-attach handled there), with a 60 s read timeout to sit out LL-HLS blocking playlist reloads..m3u8URLs are handed to the HLS source ahead of theAMediaExtractorattempt — the extractor can't stitch segments, so trying it first only added a rejected network round-trip. Delivery semantics carry over unchanged from the portable core: live playlists present at the live edge with AU-level delivery pacing, ENDLIST playlists auto-detect as VOD (TS-segment VOD additionally picks up #926's seek/duration support wherever the portable layer provides it).LPCM (m2ts). Brings the full-7.1 audio carriage to Android the way Windows has it (AAC decoders cap at 5.1). The demux side was already portable — the TS demuxer recognises Blu-ray HDMV LPCM (
stream_type 0x80) and delivers header-stripped big-endian frames through the shared sink; Android received them and had nowhere to put them. On the LPCM codec no MediaCodec is created:submit_audioconverts big-endian 16/24-bit Blu-ray-order PCM to interleaved WAVE-order float (the same channel-assignment remap tables as the Windows lane, which match ffmpeg's pcm_bluray remap and were verified by ear against a 7.1 channel-marker stream) straight into the PCM ring, whole frames only so the ring keeps its channel-phase alignment. 48 kHz / 16- or 24-bit, matching what the demuxer announces. One steering fix rides along:AMediaExtractoraccepts.m2tsURLs but doesn't surface HDMV LPCM — it would play the video with the audio silently missing — so m2ts stays on the portable TS demuxer + bypass path.No behaviour change on Windows for any of it (the extractor attempt is a no-op there and
run_hlskeeps its WinHTTP provider). Rebuiltlibbasis_media_native.so(arm64-v8a); the Windows DLL is untouched.Status — draft pending device validation
Code-complete, not yet Quest-tested — one headset pass covers both features:
mpegtsvariant).EXT-X-ENDLIST) — also exercises redirects through the JNI fetch.yuma_lpcm71.m2ts(16-bit) — channel-isolation listening pass, same method as the 5.1 AAC validation: all 8 lanes audible, WAVE order correct.blits_lpcm51.m2ts(24-bit) — exercises the 24-bit conversion path.Note for testers:
http://origins need a build with cleartext traffic allowed (Android policy);https://andrtspt://origins need nothing special. The m2ts assets can be served overrtspt://(no extractor involvement) orhttp://(exercises the new m2ts steer).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
Native C only, Android-guarded — no managed/per-frame code, so the driver/allocation/jobification items are N/A (the HLS work is network I/O on the existing threads; the LPCM conversion runs on the demux thread with a reused scratch buffer whose only allocation is one-time growth). "Tested" reflects the Windows no-behaviour-change leg (the guarded code compiles for both targets; Windows runs exactly as before — validated in-editor on the stacked branch, and the Windows LPCM lane validated this exact conversion and remap in its 7.1 listening pass when it shipped) — the Android device pass is precisely what the draft status above holds for. Kept as draft until the Quest pass listed under Status.