Nubix 0.3.0

This commit is contained in:
JBuildr committed 2026-10-07 18:21:13 +02:00
commit dee9e24842
95 files changed
+56257

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Keep Unix line endings for everything that runs on Linux/macOS, even when committed from Windows.
* text=auto eol=lf
*.sh text eol=lf
*.png binary
*.ttf binary
+26
View File
@@ -0,0 +1,26 @@
# Nubix — build and unit-test the host (macOS) version on every push and pull request.
# Copyright (C) 2026 Nubix contributors
# SPDX-License-Identifier: GPL-3.0-only
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
host-macos:
name: Host build + tests (macOS)
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies (Homebrew)
run: brew install cmake pkg-config sdl2 sdl2_ttf ffmpeg opus openssl@3
- name: Build and run unit tests
run: bash scripts/build-host.sh --test
+82
View File
@@ -0,0 +1,82 @@
# Nubix — build the PS5 release on GitHub and publish it.
#
# Push a tag like v0.3.0 (it must match project(nubix VERSION ...) in CMakeLists.txt). The job
# builds the PS5 homebrew zip inside the ps5dev Docker image, packs the GPL "Corresponding Source"
# of everything linked into the binary, and attaches both to a GitHub release for that tag.
# Started by hand (Actions tab -> Release -> Run workflow) it only builds and keeps the files as a
# workflow artifact, without creating a release.
# Copyright (C) 2026 Nubix contributors
# SPDX-License-Identifier: GPL-3.0-only
name: Release
on:
push:
tags: ['v*']
workflow_dispatch:
permissions:
contents: write
jobs:
ps5:
name: PS5 build + corresponding source
runs-on: ubuntu-24.04 # Docker is only available on Linux runners
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- name: Read version
id: ver
run: |
ver="$(sed -n 's/^project(nubix VERSION \([0-9.]*\).*/\1/p' CMakeLists.txt)"
[ -n "$ver" ] || { echo "version not found in CMakeLists.txt"; exit 1; }
echo "version=$ver" >> "$GITHUB_OUTPUT"
if [ "${GITHUB_REF_TYPE}" = "tag" ] && [ "${GITHUB_REF_NAME}" != "v$ver" ]; then
echo "::error::tag ${GITHUB_REF_NAME} does not match CMakeLists.txt version v$ver"; exit 1
fi
- name: Point SOURCE.txt at this repository
run: sed -i "s#<owner>/nubix#${GITHUB_REPOSITORY}#g" SOURCE.txt
# First, while dist/ still belongs to the runner user (the Docker steps below write as root).
- name: Corresponding source bundle
run: bash scripts/make-source-bundle.sh
- name: Build libdatachannel for PS5
run: bash scripts/build-deps.sh ps5
- name: Build PS5 homebrew
run: bash scripts/build-ps5.sh
- name: Collect release files
run: |
sudo chown -R "$(id -u):$(id -g)" .
v="${{ steps.ver.outputs.version }}"
mkdir -p release
cp "build-ps5/nubix-v$v-websrv.zip" release/
cp "dist/nubix-v$v-corresponding-source.tar.xz" "dist/nubix-v$v-corresponding-source.MANIFEST" release/
(cd release && sha256sum * > SHA256SUMS.txt)
ls -la release
- name: Upload as workflow artifact
uses: actions/upload-artifact@v4
with:
name: nubix-v${{ steps.ver.outputs.version }}
path: release/
retention-days: 14
- name: Publish GitHub release
if: github.ref_type == 'tag'
uses: softprops/action-gh-release@v2
with:
name: Nubix ${{ github.ref_name }}
prerelease: ${{ startsWith(steps.ver.outputs.version, '0.') }}
files: release/*
body: |
**Before your jailbroken PS5 goes online, block Sony's servers with a DNS server** (only Sony's domains, see the README section "Before you go online").
**Install:** download `nubix-${{ github.ref_name }}-websrv.zip`, unzip it and copy the `nubix` folder to `/data/homebrew/` on your PS5, then start **Nubix** from the websrv Homebrew Launcher. See the README for details.
`nubix-${{ github.ref_name }}-corresponding-source.tar.xz` is the complete source code of everything in the binary (GPL-3.0 requirement); you do not need it to use the app.
> Unofficial, non-commercial project. Not affiliated with Microsoft or Sony. Using third-party clients may violate their terms of service; use at your own risk.
+6
View File
@@ -0,0 +1,6 @@
build*/
deps/
dist/
.cache/
.DS_Store
compile_commands.json
+131
View File
@@ -0,0 +1,131 @@
# Nubix — native Xbox Cloud Gaming client for PS5 (homebrew payload) + host build.
# Copyright (C) 2026 Nubix contributors
# SPDX-License-Identifier: GPL-3.0-only
#
# Host: scripts/build-host.sh (or: cmake -S . -B build-host && cmake --build build-host)
# PS5: scripts/build-ps5.sh (inside docker "ps5dev" with the prospero toolchain)
cmake_minimum_required(VERSION 3.20)
project(nubix VERSION 0.3.0 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
include(XcPlatform)
option(XC_BUILD_TESTS "Build unit tests (host only)" ON)
option(XC_BUILD_CLI "Build the xc-cli tool (host only)" ON)
option(XC_BUILD_LINKCHECK "Build xc-linkcheck (links every third-party dependency)" OFF)
option(XC_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF)
include(XcDeps)
# ---- version ---------------------------------------------------------------
set(XC_VERSION "${PROJECT_VERSION}")
find_package(Git QUIET)
if(GIT_FOUND AND EXISTS "${CMAKE_SOURCE_DIR}/.git")
execute_process(COMMAND "${GIT_EXECUTABLE}" describe --always --dirty --tags
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" OUTPUT_VARIABLE _git_desc
OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET RESULT_VARIABLE _git_rc)
if(_git_rc EQUAL 0 AND _git_desc)
set(XC_VERSION "${PROJECT_VERSION}-${_git_desc}")
endif()
endif()
# ---- common compile settings ----------------------------------------------
add_library(xc_options INTERFACE)
target_include_directories(xc_options INTERFACE "${CMAKE_SOURCE_DIR}/src")
target_include_directories(xc_options SYSTEM INTERFACE "${CMAKE_SOURCE_DIR}/third_party")
target_compile_definitions(xc_options INTERFACE XC_VERSION="${XC_VERSION}")
target_compile_options(xc_options INTERFACE -Wall -Wextra -Wno-unused-parameter)
if(XC_WARNINGS_AS_ERRORS)
target_compile_options(xc_options INTERFACE -Werror)
endif()
if(XC_PS5)
target_compile_definitions(xc_options INTERFACE XC_PS5=1)
else()
target_compile_definitions(xc_options INTERFACE XC_SOURCE_ASSETS_DIR="${CMAKE_SOURCE_DIR}/assets")
endif()
# ---- xc_core: auth, http, config, catalog, gssv, protocol, log -------------
file(GLOB XC_CORE_SOURCES CONFIGURE_DEPENDS "${CMAKE_SOURCE_DIR}/src/core/*.cpp")
add_library(xc_core STATIC ${XC_CORE_SOURCES})
target_link_libraries(xc_core PUBLIC xc_options xc::curl xc::openssl Threads::Threads)
# ---- xc_platform: paths, notify, gamepad, PS5 shims ------------------------
set(XC_PLATFORM_SOURCES
src/platform/common.cpp
src/platform/gamepad.cpp
src/platform/net_compat.c)
if(XC_PS5)
list(APPEND XC_PLATFORM_SOURCES src/platform/ps5.cpp src/platform/ps5_shims.c)
else()
list(APPEND XC_PLATFORM_SOURCES src/platform/host.cpp)
endif()
add_library(xc_platform STATIC ${XC_PLATFORM_SOURCES})
target_link_libraries(xc_platform PUBLIC xc_core xc::sdl2)
# ---- xc_stream: webrtc, rtp, video, audio, streamer -------------------------
file(GLOB XC_STREAM_SOURCES CONFIGURE_DEPENDS "${CMAKE_SOURCE_DIR}/src/stream/*.cpp")
add_library(xc_stream STATIC ${XC_STREAM_SOURCES})
target_link_libraries(xc_stream PUBLIC xc_core xc_platform xc::datachannel xc::avcodec xc::avutil xc::opus xc::sdl2)
# ---- xc_ui: drawing, screens, app, QR ---------------------------------------
file(GLOB XC_UI_SOURCES CONFIGURE_DEPENDS "${CMAKE_SOURCE_DIR}/src/ui/*.cpp")
add_library(xc_ui STATIC ${XC_UI_SOURCES})
target_link_libraries(xc_ui PUBLIC xc_stream xc_platform xc::sdl2_ttf xc::sdl2)
# vendored Nayuki qrcodegen: keep its warnings out of our output
set_source_files_properties("${CMAKE_SOURCE_DIR}/src/ui/qrcodegen.cpp" PROPERTIES COMPILE_OPTIONS "-w")
# ---- app ----------------------------------------------------------------------
add_executable(nubix src/main.cpp)
target_link_libraries(nubix PRIVATE xc_ui)
if(XC_PS5)
# Unstripped ELF keeps symbols for crash analysis; the stripped one is what ships.
set_target_properties(nubix PROPERTIES OUTPUT_NAME "nubix-debug" SUFFIX ".elf")
# SDL2 PS5 port + payload system libraries (most already come from sdl2.pc --static)
target_link_libraries(nubix PRIVATE
SceSystemService SceUserService ScePad SceAudioOut SceVideoOut SceKeyboard SceImeDialog SceNet)
add_custom_command(TARGET nubix POST_BUILD
COMMAND "${CMAKE_STRIP}" -o "${CMAKE_BINARY_DIR}/nubix.elf" "$<TARGET_FILE:nubix>"
COMMENT "Stripping -> nubix.elf")
else()
# host: make assets reachable next to the binary
add_custom_command(TARGET nubix POST_BUILD
COMMAND "${CMAKE_COMMAND}" -E create_symlink "${CMAKE_SOURCE_DIR}/assets" "$<TARGET_FILE_DIR:nubix>/assets"
COMMENT "Linking assets/ next to nubix")
endif()
# ---- dependency link check (any target) ---------------------------------------------
if(XC_BUILD_LINKCHECK)
add_executable(xc-linkcheck tools/linkcheck.cpp)
target_link_libraries(xc-linkcheck PRIVATE xc_platform xc::datachannel xc::avcodec xc::avutil xc::opus
xc::curl xc::openssl xc::sdl2_ttf xc::sdl2)
if(XC_PS5)
set_target_properties(xc-linkcheck PROPERTIES SUFFIX ".elf")
target_link_libraries(xc-linkcheck PRIVATE
SceSystemService SceUserService ScePad SceAudioOut SceVideoOut SceKeyboard SceImeDialog SceNet)
endif()
endif()
# ---- host tools / tests ------------------------------------------------------------
if(NOT XC_PS5 AND XC_BUILD_CLI)
add_executable(xc-cli tools/xc-cli.cpp tools/cli_gssv.cpp)
target_link_libraries(xc-cli PRIVATE xc_stream xc_platform)
endif()
if(NOT XC_PS5 AND XC_BUILD_TESTS)
enable_testing()
file(GLOB XC_TEST_SOURCES CONFIGURE_DEPENDS "${CMAKE_SOURCE_DIR}/tests/*.cpp")
add_executable(xc-tests ${XC_TEST_SOURCES})
target_link_libraries(xc-tests PRIVATE xc_ui)
foreach(suite smoke protocol sdp rtp config ratelimit net)
add_test(NAME ${suite} COMMAND xc-tests ${suite})
endforeach()
endif()
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+218
View File
@@ -0,0 +1,218 @@
# Nubix
**Play Xbox Cloud Gaming online on your jailbroken PS5.**
An **unofficial**, open-source client for **Xbox Cloud Gaming** on jailbroken PS5 consoles.
It runs as a homebrew app (ELF payload) started from the
[websrv](https://github.com/ps5-payload-dev/websrv) Homebrew Launcher and streams cloud games
to your TV with a DualSense as the controller.
> **Not affiliated with, endorsed by, or sponsored by Microsoft or Sony.** "Xbox", "Xbox Cloud
> Gaming" and "Game Pass" are trademarks of Microsoft; "PlayStation" and "PS5" are trademarks of
> Sony Interactive Entertainment. They are used here only to describe what this software works with.
> Read [Disclaimer](#disclaimer) before you sign in.
## Status
| Area | State |
|---|---|
| Microsoft sign-in (code + QR, no browser on the console) | ✅ works on PS5 |
| Cloud library (Game Pass, owned, free-to-play), filters, box art | ✅ works on PS5 |
| Queue / waiting for a server | ✅ works on PS5 |
| Cloud streaming (video, audio, controller, rumble) | ✅ works on PS5 |
| Remote play of your own Xbox console (xHome) | ⚠️ implemented, not tested |
| Native PS5 title / home-screen tile | ❌ not supported (start it from the websrv launcher) |
Video is decoded in software (FFmpeg). If the picture stutters, set the resolution to 720p.
## Requirements
- A jailbroken PS5 that can run ELF payloads (`elfldr` on port 9021) and the
[websrv](https://github.com/ps5-payload-dev/websrv) Homebrew Launcher.
- A Microsoft account with a Game Pass plan that includes cloud gaming (Essential, Premium or
Ultimate, depending on your region), or access to the free, ad-supported cloud offering.
- A wired connection or good 5 GHz Wi-Fi.
- A protective DNS that blocks Sony's servers (see [below](#before-you-go-online-protective-dns)).
## Before you go online: protective DNS
**Do this before your jailbroken PS5 connects to the internet for the first time.** A jailbroken
console must never reach Sony's servers: it could download a system update (which can remove the
jailbreak for good) and it reports to Sony. Nubix itself needs the internet, so you cannot simply
cut the connection. Instead, point the PS5 at a DNS server that only lets through what you allow.
### The included DNS server: `tools/sony_dns.py`
[`tools/sony_dns.py`](tools/sony_dns.py) (Python 3.8+, no extra packages) works in **allowlist mode**:
- **Everything is blocked by default.**
- Allowed are only the domains Nubix needs for Xbox Cloud Gaming:
`login.microsoftonline.com`, `login.live.com`, `xboxlive.com`, `displaycatalog.mp.microsoft.com`,
`store-images.s-microsoft.com` and `stun.l.google.com` (each with its subdomains).
- **Sony / PlayStation domains are always blocked**, even if you allow them by mistake:
`playstation.com`, `playstation.net`, `playstation.org`, `sonyentertainmentnetwork.com`,
`scea.com`, `sbdnpd.com`, `ribob01.net`.
- If you want the PS5 to reach anything else, add the domain to
[`tools/allowlist.txt`](tools/allowlist.txt) (one per line). **You are responsible for every
domain you add.**
Run it on a PC in the same network as the PS5:
```
python tools\sony_dns.py (Windows, terminal opened with "Run as administrator")
sudo python3 tools/sony_dns.py (macOS / Linux)
```
It prints the IP address to enter on the PS5. On Windows, allow Python through the firewall for
**private networks** when asked, otherwise the PS5's queries never arrive. The PC has to stay on
while the PS5 is online, so give it a fixed IP in your router.
Useful options:
- `--log-all` shows every query the PS5 makes (ALLOW / BLOCK), so you can see what to add.
- `--allow example.com` allows a domain without editing the file.
- `--map host=IP` answers a name with a fixed address, for example if your jailbreak method
redirects `manuals.playstation.net` to your own exploit host. This works even for Sony names.
- `--upstream 9.9.9.9` uses another upstream DNS (default `1.1.1.1`).
### On the PS5
*Settings → Network → Settings → Set Up Internet Connection* → your connection →
*Advanced Settings* → *DNS Settings: Manual* → **Primary DNS** = the IP shown by the script,
**Secondary DNS** = the same IP (a public DNS as secondary would bypass the protection).
Never sign in to PSN on the jailbroken console and ignore any update prompt. The PS5's own
"Test Internet Connection" will report a PSN failure with this setup; that is expected.
### Using your own DNS server instead
With Pi-hole, AdGuard Home or NextDNS you can do the same: block all Sony domains listed above
(with subdomains) and ideally allow only the Nubix domains. **Do not** use generic PS5 blocklists
that also block CDNs such as `akamai.net`, `akamaiedge.net`, `akadns.net`, `edgekey.net` or
`edgesuite.net`, and do not set the DNS to `127.0.0.2`: Nubix would no longer be able to sign in,
load your library or stream.
## Installation
1. Download `nubix-v<version>-websrv.zip` (e.g. `nubix-v0.3.0-websrv.zip`) from the releases page.
2. Unzip it and copy the `nubix` folder to `/data/homebrew/` on the PS5 (FTP, or a USB drive
under `/mnt/usb0/homebrew/`). You should end up with
`/data/homebrew/nubix/{eboot.elf, homebrew.js, sce_sys/icon0.png, assets/}`.
3. Start websrv, open the Homebrew Launcher (the launcher PKG on the PS5, or
`http://<ps5-ip>:8080` from a phone or PC) and start **Nubix**.
*Start (verbose log)* writes a detailed log for troubleshooting.
To update, replace the folder. Your sign-in and settings are kept in `/data/nubix/`.
## First start
The app shows a code and a QR code. Open **https://www.microsoft.com/link** on your phone or PC
(or scan the QR code), enter the code and sign in. The app then stores a refresh token, so you
only do this once.
Library tabs: **Cloud library**, **My consoles**, **Settings** (switch with L1/R1).
△ cycles the filter: *All games*, *Game Pass*, *Owned* and *Free to play*.
## Controls
The PS5 system keeps the PS and Create buttons to itself, so the app never sees them.
| DualSense | Xbox controller |
|---|---|
| ✕ / ○ / □ / △ | A / B / X / Y |
| L1 / R1, L2 / R2, L3 / R3 | LB / RB, LT / RT, LS / RS |
| Options | Menu |
| Touchpad click | View |
| Hold **Options + touchpad** for 1 s | Stream menu (resume, Xbox button, statistics, refresh video, disconnect) |
In the menus: ✕ = select, ○ = back, L1/R1 = switch tabs, △ = filter.
## Settings
- **Stream resolution**: 720p, 1080p, 1080p (high quality). 720p decodes fastest.
- **Maximum bitrate**: 5–20 Mbps. Lower it on Wi-Fi or if the picture stutters.
- **Server region**: automatic or one of the regions Xbox offers your account.
- **Free-to-play fallback**: retry with the ad-supported offering when a title is not in your plan.
- **Extra stick deadzone**, **stream statistics**, **sign out**.
## Network behaviour
Nubix talks to the same Microsoft services as the Xbox web player, and paces itself
(`src/core/ratelimit.*`):
- at most 5 requests per second per Microsoft host;
- on HTTP 429 it waits for `Retry-After` (or backs off 2 → 120 s) and only repeats read-only requests;
- at most 4 cloud session starts per 5 minutes;
- the library is cached for 6 hours and box art permanently (`/data/nubix/cache/`); △ on *My consoles* reloads the console list.
## Troubleshooting
- Log: `/data/nubix/log.txt` (also viewable at `http://<ps5-ip>:8080/fs/data/nubix/log.txt`).
Start with *Start (verbose log)* for more detail.
- Stutter or growing delay: 720p and/or a lower bitrate; prefer a wired connection.
- "No cloud gaming access": your account has no plan with cloud gaming in your region.
- Sign-in, library or box art fail only with the protective DNS: start `tools/sony_dns.py --log-all`
and look for BLOCK lines while Nubix loads; please report them so the built-in list can be fixed.
- Close other running homebrew before starting Nubix (websrv can crash otherwise).
## Privacy and security
- Your password never reaches the app: sign-in uses Microsoft's device-code flow.
- The refresh token is stored **unencrypted** in `/data/nubix/config.json`. Anyone with
file access to the console (for example an open FTP payload on your network) can copy it. Do not
leave FTP running on untrusted networks. *Settings → Sign out* deletes it; you can also sign out of
all devices in your Microsoft account's security settings.
- The app only contacts Microsoft services (sign-in, Xbox Live, cloud gaming, Microsoft Store catalog)
plus Google's public STUN server (`stun.l.google.com`) to find its public address for WebRTC.
## Disclaimer
This is an unofficial, non-commercial hobby project. Microsoft does not authorise third-party clients. Using unofficial clients may violate the Microsoft
Services Agreement and Xbox terms of use, and running homebrew may violate Sony's terms. Your account
or console could be restricted. **Use it at your own risk**, ideally first with a Microsoft account you could afford to lose. The software is provided without any
warranty (see `LICENSE`). Do not use it to circumvent paywalls, regional restrictions or copy protection;
it only streams what your own account is entitled to.
## Building from source
Release builds are made by GitHub Actions: pushing a tag `vX.Y.Z` (matching the version in
`CMakeLists.txt`) runs `.github/workflows/release.yml`, which builds the PS5 zip and the
corresponding-source archive and attaches both to the GitHub release. Every push also builds and
tests the host version on macOS (`.github/workflows/ci.yml`).
To build locally: Docker is required for the PS5 build; the host build (macOS/Linux) is for development and testing.
```sh
# host (macOS): brew install cmake pkg-config sdl2 sdl2_ttf ffmpeg opus openssl@3
scripts/build-deps.sh host && scripts/build-host.sh --test # host app, xc-cli, unit tests
scripts/build-deps.sh ps5 && scripts/build-ps5.sh # build-ps5/nubix.elf + dist/nubix/
PS5_HOST=<ip> scripts/deploy.sh install|run|log # FTP install, send to elfldr, fetch log
```
Developer tools:
- `nubix --selftest` brings up every subsystem headless
(`SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy`) and exits 0 on success.
- `xc-cli` (host) drives the services without UI: `login`, `status`, `titles`, `consoles`,
`play <titleId>`, `stream cloud <titleId> [seconds]`.
- Protocol implementation: `src/core/` (sign-in, catalog, session API, input/message protocol) and
`src/stream/` (WebRTC, RTP/RTCP, decoding, session state machine).
## Credits
Nubix stands on the shoulders of the projects that documented the Xbox cloud streaming protocol:
[green-nx](https://github.com/rmrf404/green-nx), [GreenOvercast](https://github.com/Producdevity/GreenOvercast),
[Greenlight](https://github.com/unknownskl/greenlight), [xbox-xcloud-player](https://github.com/unknownskl/xbox-xcloud-player),
[xal-node](https://github.com/unknownskl/xal-node), [XStreaming](https://github.com/Geocld/XStreaming),
[green-vita](https://github.com/Day-OS/green-vita), and on the PS5 homebrew work of
[ps5-payload-dev](https://github.com/ps5-payload-dev) (SDK, websrv, SDL2 port) and
[ProsperoLight](https://github.com/xEasy4Breezy/ProsperoLight).
## License
**GPL-3.0-only** (see [`LICENSE`](LICENSE)). Parts of the code are derived from
[green-nx](https://github.com/rmrf404/green-nx) (GPL-3.0) and
[GreenOvercast](https://github.com/Producdevity/GreenOvercast) (MPL-2.0); the affected files say so in
their headers. Third-party components, their licenses and copyright notices:
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) and [`THIRD_PARTY_LICENSES.txt`](THIRD_PARTY_LICENSES.txt).
**Source code for binary releases.** Every release zip is accompanied on the same release page by
`nubix-v<version>-corresponding-source.tar.xz`, which contains the complete corresponding source of
everything linked into `eboot.elf` (built with `scripts/make-source-bundle.sh`). See `SOURCE.txt` in the
zip for exact component versions.
+152
View File
@@ -0,0 +1,152 @@
Nubix - Source Code Offer / Corresponding Source
=================================================
Nubix is free software, licensed under the GNU General Public License, version 3
only (GPL-3.0-only); see LICENSE. The PS5 binary (nubix.elf / homebrew package) is
statically linked against the third-party libraries listed below. As required by
section 6 of the GPLv3, the complete Corresponding Source for every binary release
is published next to the binary, at the same place and at no further charge:
https://github.com/JBuildr/nubix/releases
Release asset: nubix-v<version>-corresponding-source.tar.xz
(e.g. nubix-v0.3.0-corresponding-source.tar.xz for Nubix 0.3.0)
Checksum: nubix-v<version>-corresponding-source.tar.xz.sha256
Manifest: nubix-v<version>-corresponding-source.MANIFEST
The archive contains:
- nubix-v<version>-src.tar.xz : the Nubix source tree the binary was built from,
including patches/libdatachannel-*.patch, docker/Dockerfile, CMakeLists.txt,
cmake/ and scripts/ (the complete build and installation scripts);
- upstream/ : the unmodified upstream source of each library in the table below,
as release tarballs (sha256-verified against the pinned sums) or as
`git archive` exports of the exact commits;
- MANIFEST and SHA256SUMS : sha256 and size of every file in the archive.
The archive is produced by scripts/make-source-bundle.sh (in the Nubix source).
By default that script packages the current working tree
(`git ls-files -co --exclude-standard`, i.e. tracked files including uncommitted
changes plus non-ignored untracked files; docs/ is excluded), or a given git ref
(--nubix-ref <ref>) or directory (--nubix-dir <dir>).
How to rebuild the PS5 binary
-----------------------------
1. Build the toolchain image (Ubuntu 24.04 + clang/lld 18 + the prebuilt
ps5-payload-dev SDK and libraries from pacbrew-repo release v0.40.2):
docker build -t ps5dev docker/
2. Build libdatachannel v0.24.6 (+ submodules), with patches/ applied:
scripts/build-deps.sh ps5
(set LDC_LOCAL_SRC=<dir> to use the libdatachannel sources from this archive
instead of cloning from GitHub; extract all libdatachannel/libjuice/libsrtp/
usrsctp/plog/json archives from upstream/ into one directory first.)
3. Build Nubix:
scripts/build-ps5.sh
The result is written to build-ps5/.
The libraries in /opt/ps5-payload-sdk inside the image were built by the
ps5-payload-dev project from the PKGBUILD recipes in pacbrew-repo v0.40.2
(included in upstream/) with the ps5-payload-sdk toolchain (also included). To
rebuild one of them from the sources in this archive, use its PKGBUILD
(directory named after the port, e.g. ffmpeg/, SDL2/, curl/) with the
ps5-payload-sdk and pacbrew (makepkg) as described in pacbrew-repo/README.md,
pointing source= at the tarball/export from upstream/.
Components linked into the PS5 binary
-------------------------------------
Component Version / commit License (SPDX) Upstream source
----------------- --------------------------------------------- --------------------------------- ---------------------------------------------------------------
Nubix 0.3.0 (this release) GPL-3.0-only https://github.com/JBuildr/nubix
nlohmann/json bundled in third_party/ (Nubix source) MIT https://github.com/nlohmann/json
libdatachannel v0.24.6 6b1e2e620f1e37f0eafeee702eaea0043cb305fd MPL-2.0 (+ patches/ from Nubix) https://github.com/paullouisageneau/libdatachannel
libjuice v1.7.4 b89c792e3612faf2f12cf35bcc56857313a06be3 MPL-2.0 https://github.com/paullouisageneau/libjuice
libsrtp 24b3bf8f19b6f5ab4cd2bcceb4f4064efca86fd5 BSD-3-Clause https://github.com/cisco/libsrtp
usrsctp fec583d54493f879d2ae44a743423bf8a04371ab BSD-3-Clause https://github.com/paullouisageneau/usrsctp
plog 94899e0b926ac1b0f4750bfbd495167b4a6ae9ef MIT https://github.com/SergiusTheBest/plog
json (nlohmann) 55f93686c01528224f448c19128836e7df245f72 MIT https://github.com/nlohmann/json
FFmpeg 7.0.1 (libavcodec, libavutil, libswresample) GPL-3.0-or-later (--enable-gpl https://ffmpeg.org/releases/ffmpeg-7.0.1.tar.xz
--enable-version3 --enable-libx264)
x264 0.165.3222 b35605ace3ddf7c1a5d67a2eb553f034aef41d55 GPL-2.0-or-later https://code.videolan.org/videolan/x264 (branch stable)
libiconv 1.17 LGPL-2.1-or-later https://ftp.gnu.org/pub/gnu/libiconv/libiconv-1.17.tar.gz
OpenSSL 3.5.2 (libssl, libcrypto) Apache-2.0 https://github.com/openssl/openssl/releases/download/openssl-3.5.2/openssl-3.5.2.tar.gz
curl (libcurl) 8.18.0 curl https://curl.se/download/curl-8.18.0.tar.xz
libpsl 0.21.5 MIT https://github.com/rockdaboot/libpsl/releases/download/0.21.5/libpsl-0.21.5.tar.gz
SDL2 (PS5 fork) 2.30.12, a47182a20e741e4735d1fcd41e8656bf338b0c72 Zlib https://github.com/ps5-payload-dev/SDL (branch release-2.30.x-ps5)
SDL2_ttf 2.22.0 Zlib https://libsdl.org/projects/SDL_ttf/release/SDL2_ttf-2.22.0.tar.gz
FreeType 2.13.2 FTL OR GPL-2.0-or-later https://download.savannah.gnu.org/releases/freetype/freetype-2.13.2.tar.gz
libpng 1.6.43 libpng-2.0 https://download.sourceforge.net/libpng/libpng-1.6.43.tar.xz
zlib 1.3.2 Zlib https://github.com/madler/zlib/releases/download/v1.3.2/zlib-1.3.2.tar.gz
zstd 1.5.6 BSD-3-Clause OR GPL-2.0-only https://github.com/facebook/zstd/releases/download/v1.5.6/zstd-1.5.6.tar.gz
xz (liblzma) 5.4.6 public domain (liblzma 5.4.x) https://github.com/tukaani-project/xz/releases/download/v5.4.6/xz-5.4.6.tar.xz
bzip2 1.0.8 bzip2-1.0.6 https://sourceware.org/pub/bzip2/bzip2-1.0.8.tar.gz
openlibm (libm) 0.8.6 MIT AND BSD-2-Clause AND ISC https://github.com/JuliaMath/openlibm/archive/refs/tags/v0.8.6.tar.gz
Opus 1.5.2 BSD-3-Clause https://downloads.xiph.org/releases/opus/opus-1.5.2.tar.gz
libsamplerate 0.2.2 BSD-2-Clause https://github.com/libsndfile/libsamplerate/releases/download/0.2.2/libsamplerate-0.2.2.tar.xz
ps5-payload-sdk 4eb701204fc3f8d31e84cf8ca272974e2be9c867 GPL-3.0-or-later; include/freebsd https://github.com/ps5-payload-dev/sdk
(crt, libc/libpthread wrappers, headers, under BSD licenses
toolchain scripts)
pacbrew-repo v0.40.2 1687e827675c66e1b040751c7f0811222bd3aea0 GPL-3.0 (build recipes) https://github.com/ps5-payload-dev/pacbrew-repo
(PKGBUILD build scripts of every library above,
incl. FFmpeg's ffplay-with-game-controller-input.diff)
Not bundled (permissive license without a source-distribution requirement):
LLVM runtimes 18.1.8 (libc++, libc++abi, libunwind; built Apache-2.0 WITH LLVM-exception
by ps5-payload-sdk libcxx.sh)
https://github.com/llvm/llvm-project/releases/download/llvmorg-18.1.8/llvm-project-18.1.8.src.tar.xz
sha256 0b58557a6d32ceee97c8d533a59b9212d87e0fc4d2833924eb6c611247db2f2a
Not part of the Corresponding Source (GPLv3 section 1 "System Libraries" / generally
available tools): the PS5 system modules the binary is dynamically linked to at
runtime (libkernel, libSceLibcInternal, libSceNet, libSceVideoOut, libSceAudioOut,
libScePad, libSceUserService, libSceSystemService, libSceImeDialog, libSceKeyboard, ...;
the SDK only ships link stubs for them), and the compiler/linker used (clang/lld 18
from Ubuntu 24.04, see docker/Dockerfile).
Evidence for commits not pinned by a version tag
------------------------------------------------
pacbrew-repo v0.40.2 builds the SDK, the SDL2 fork and x264 from git branch heads
(sha256sums=SKIP). The exact commits were determined from the prebuilt
ps5-payload-dev.tar.gz of release v0.40.2 (as installed in the ps5dev image):
* pacbrew-repo: tag v0.40.2 = commit 1687e82 (2026-08-18); the next commit on the
repository is dated 2026-08-30, i.e. after the build, so the recipes used are
exactly those of the tag. The release asset ps5-payload-dev.tar.gz was
(re)built 2026-08-23 ~14:18-16:11 UTC (file mtimes in the SDK: 2026-08-23 14:18 UTC;
asset upload 16:11:56 UTC; release note "rebuild with latest SDL2 port").
* SDL2 PS5 fork: PROVEN. libSDL2.a contains the revision string
"SDL-2.30.12-ga47182a20" (SDL_GetRevision). Commit a47182a20e74... (2026-08-22,
"ps5: adjust remote control keyboard mappings") is on branch release-2.30.x-ps5;
the next commit is dated 2026-09-24, after the build.
* x264: PROVEN. libx264.a contains "r3222 b35605a" (X264_POINTVER 0.165.3222) and
x264.pc reports Version 0.165.3222; the stable branch head is
b35605ace3ddf7c1a5d67a2eb553f034aef41d55 (abbreviation b35605a is unique).
* ps5-payload-sdk: INFERRED, strongly supported. The SDK records no commit id. The
SDK's master branch at the build time (2026-08-23) was 4eb701204fc3 (2026-08-02,
"add support for firmware 13.60"); the next commit is 8e125899 (2026-08-28).
The installed crt1.o contains the firmware 13.60 offset added by 4eb7012
(data offsets 0x28C9E80, 0x0D9C064, 0x314B510, 0x2098228) and lacks kernel_iommu_* symbols introduced
by the 2026-08-28/29 commits; libc.a lacks killpg() (added 2026-09-03).
libc++/libc++abi/libunwind in the SDK are built by that commit's libcxx.sh from
LLVM 18.1.8.
* Release tarballs: versions from the v0.40.2 PKGBUILDs (pkgver=) and cross-checked
against the installed pkg-config files (FreeType reports libtool version 26.1.20 =
2.13.2; openlibm.pc reports 0.8.0 because upstream's Make.inc in the v0.8.6
tag still says VERSION = 0.8.0 - the PKGBUILD pins v0.8.6 with sha256). The archive's tarballs are
verified against the sha256sums in those PKGBUILDs; FFmpeg's PKGBUILD has
sha256sums=SKIP, so its sum (bce9eeb0f17ef8982390b1f37711a61b4290dc8c2a0c1a37b5857e85bfb0e4ff)
was computed on download and recorded in the MANIFEST.
* libdatachannel: v0.24.6 tag with the submodule commits recorded in that tag
(git submodule status of the build checkout). The Nubix patch
patches/libdatachannel-xbox-pli.patch is applied on top at build time.
Written offer: if the release page is unavailable, anyone in possession of a Nubix
binary may obtain the Corresponding Source by opening an issue at
https://github.com/JBuildr/nubix/issues; this offer is valid for as long as the
binary is distributed, and at least three years after the last distribution.
File diff suppressed because it is too large. Load diff
+137
View File
@@ -0,0 +1,137 @@
# Third-party notices
Nubix is free software, licensed under the **GNU General Public License, version 3 only
(GPL-3.0-only)**; see `LICENSE`. It is an unofficial client and is not affiliated with or
endorsed by Microsoft or Sony.
The PS5 binary (`eboot.elf` / `nubix.elf`) **statically links** every component listed under
"Linked into the PS5 binary" below. All of them are under licenses compatible with distribution of
the combined work under GPL-3.0. The complete, unmodified license text of each component, taken
from the exact upstream version used, is in `THIRD_PARTY_LICENSES.txt` (shipped next to the binary in
every release; the `[name]` after each component below is its section in that file).
Because FFmpeg is built with `--enable-gpl --enable-version3` and the ps5-payload-sdk C runtime
is GPL-3.0-or-later, the PS5 binary as a whole can only be distributed under GPL-3.0, which is
Nubix's own license.
## Linked into the PS5 binary
All prebuilt components except libdatachannel and its submodules come from the
[ps5-payload-dev pacbrew-repo](https://github.com/ps5-payload-dev/pacbrew-repo) bundle release
**v0.40.2** (commit `1687e827675c66e1b040751c7f0811222bd3aea0`), which `docker/Dockerfile`
installs into the build image.
### Multimedia
| Component | Version | License | Copyright | License file |
|---|---|---|---|---|
| [FFmpeg](https://ffmpeg.org) (libavcodec 61.3.100, libavutil, libswresample) | 7.0.1 | **GPL-3.0-or-later as built** (configured `--enable-gpl --enable-version3 --enable-openssl --enable-libx264`; FFmpeg is otherwise LGPL-2.1-or-later) | Copyright (c) 2000-2024 the FFmpeg developers | `THIRD_PARTY_LICENSES.txt` [ffmpeg], `LICENSE` |
| [x264](https://www.videolan.org/developers/x264.html) | 0.165.3222 (commit `b35605ace3ddf7c1a5d67a2eb553f034aef41d55`) | GPL-2.0-or-later | Copyright (C) 2003-2025 x264 project | `THIRD_PARTY_LICENSES.txt` [x264] |
| [Opus](https://opus-codec.org) | 1.5.2 | BSD-3-Clause | Copyright 2001-2023 Xiph.Org, Skype Limited, Octasic, Jean-Marc Valin, Timothy B. Terriberry, CSIRO, Gregory Maxwell, Mark Borgerding, Erik de Castro Lopo, Mozilla, Amazon | `THIRD_PARTY_LICENSES.txt` [opus] |
| [libsamplerate](https://github.com/libsndfile/libsamplerate) | 0.2.2 | BSD-2-Clause | Copyright (c) 2012-2016, Erik de Castro Lopo | `THIRD_PARTY_LICENSES.txt` [libsamplerate] |
### WebRTC
Built from source by `scripts/build-deps.sh` (libdatachannel tag `v0.24.6` with its pinned
submodules).
| Component | Version | License | Copyright | License file |
|---|---|---|---|---|
| [libdatachannel](https://github.com/paullouisageneau/libdatachannel) | v0.24.6, **modified** by `patches/libdatachannel-xbox-pli.patch` (the patch is MPL-2.0) | MPL-2.0 | Copyright (c) 2019-2025 Paul-Louis Ageneau; also Filip Klembara, Staz Modrzynski, Arda Cinar, Eric Gressman, Zita Liao, Robert Edmonds, Sean DuBois, Shigemasa Watanabe, Vladimir Voronin, Alex Potsides | `THIRD_PARTY_LICENSES.txt` [libdatachannel] |
| [libjuice](https://github.com/paullouisageneau/libjuice) | v1.7.4 (`b89c792`) | MPL-2.0 | Copyright (c) 2020-2022 Paul-Louis Ageneau | `THIRD_PARTY_LICENSES.txt` [libjuice] |
| [libsrtp](https://github.com/cisco/libsrtp) | 2.8.0 (commit `24b3bf8`) | BSD-3-Clause | Copyright (c) 2001-2017 Cisco Systems, Inc. | `THIRD_PARTY_LICENSES.txt` [libsrtp] |
| [usrsctp](https://github.com/sctplab/usrsctp) | commit `fec583d` | BSD-3-Clause | Copyright (c) 2015, Randall Stewart and Michael Tuexen; Copyright (c) 2001-2008 Cisco Systems, Inc.; portions Copyright The Regents of the University of California | `THIRD_PARTY_LICENSES.txt` [usrsctp] |
| [plog](https://github.com/SergiusTheBest/plog) (via libdatachannel) | commit `94899e0` | MIT | Copyright (c) 2022 Sergey Podobry | `THIRD_PARTY_LICENSES.txt` [plog] |
| [nlohmann/json](https://github.com/nlohmann/json) (header-only; Nubix compiles the vendored copy in `third_party/nlohmann/`) | 3.11.3 | MIT | Copyright (c) 2013-2022 Niels Lohmann | `THIRD_PARTY_LICENSES.txt` [nlohmann-json] |
### Networking and TLS
| Component | Version | License | Copyright | License file |
|---|---|---|---|---|
| [OpenSSL](https://www.openssl.org) (libssl, libcrypto) | 3.5.2 | Apache-2.0 (OpenSSL 3.5.2 ships no NOTICE file) | Copyright 1995-2025 The OpenSSL Project Authors | `THIRD_PARTY_LICENSES.txt` [openssl] |
| [libcurl](https://curl.se) | 8.18.0 | curl | Copyright (c) 1996 - 2025, Daniel Stenberg, <daniel@haxx.se>, and many contributors | `THIRD_PARTY_LICENSES.txt` [curl] |
| [libpsl](https://github.com/rockdaboot/libpsl) | 0.21.5 | MIT; `psl-make-dafsa` / `lookup_string_in_fixed_set.c` BSD-3-Clause (Chromium) | Copyright (C) 2014-2024 Tim Rühsen; Copyright 2015 The Chromium Authors | `THIRD_PARTY_LICENSES.txt` [libpsl], `THIRD_PARTY_LICENSES.txt` [libpsl-chromium] |
| [Public Suffix List](https://publicsuffix.org) data (compiled into libpsl) | snapshot bundled with libpsl 0.21.5 | MPL-2.0 | Mozilla Foundation and Public Suffix List contributors | `THIRD_PARTY_LICENSES.txt` [MPL-2.0] |
### Graphics, text and input
| Component | Version | License | Copyright | License file |
|---|---|---|---|---|
| [SDL2](https://www.libsdl.org) (PS5 port [ps5-payload-dev/SDL](https://github.com/ps5-payload-dev/SDL), branch `release-2.30.x-ps5`) | 2.30.12 | Zlib | Copyright (C) 1997-2025 Sam Lantinga | `THIRD_PARTY_LICENSES.txt` [SDL2] |
| [SDL2_ttf](https://github.com/libsdl-org/SDL_ttf) | 2.22.0 | Zlib | Copyright (C) 1997-2024 Sam Lantinga | `THIRD_PARTY_LICENSES.txt` [SDL2_ttf] |
| [FreeType](https://freetype.org) | 2.13.2 | FreeType License (FTL), chosen from FTL / GPL-2.0 | Copyright 1996-2002, 2006 by David Turner, Robert Wilhelm, and Werner Lemberg. Portions of this software are copyright © 2023 The FreeType Project (www.freetype.org). All rights reserved. | `THIRD_PARTY_LICENSES.txt` [freetype-FTL], `THIRD_PARTY_LICENSES.txt` [freetype] |
| [libpng](http://www.libpng.org) | 1.6.43 | libpng-2.0 | Copyright (c) 1995-2024 The PNG Reference Library Authors; Copyright (c) 2018-2024 Cosmin Truta; Copyright (c) 2000-2002, 2004, 2006-2018 Glenn Randers-Pehrson; Copyright (c) 1996-1997 Andreas Dilger; Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc. | `THIRD_PARTY_LICENSES.txt` [libpng] |
| [QR Code generator library](https://www.nayuki.io/page/qr-code-generator-library) (`src/ui/qrcodegen.*`) | vendored | MIT | Copyright (c) Project Nayuki | `THIRD_PARTY_LICENSES.txt` [qrcodegen] |
### Compression and character conversion
| Component | Version | License | Copyright | License file |
|---|---|---|---|---|
| [zlib](https://zlib.net) | 1.3.2 | Zlib | (C) 1995-2026 Jean-loup Gailly and Mark Adler | `THIRD_PARTY_LICENSES.txt` [zlib] |
| [zstd](https://github.com/facebook/zstd) | 1.5.6 | BSD-3-Clause (chosen from BSD-3-Clause / GPL-2.0) | Copyright (c) Meta Platforms, Inc. and affiliates | `THIRD_PARTY_LICENSES.txt` [zstd] |
| [xz / liblzma](https://tukaani.org/xz/) | 5.4.6 | public domain (liblzma) | Lasse Collin, Igor Pavlov and others; placed in the public domain | `THIRD_PARTY_LICENSES.txt` [xz] |
| [bzip2 / libbzip2](https://sourceware.org/bzip2/) | 1.0.8 | bzip2-1.0.6 | Copyright (C) 1996-2019 Julian R Seward | `THIRD_PARTY_LICENSES.txt` [bzip2] |
| [GNU libiconv + libcharset](https://www.gnu.org/software/libiconv/) | 1.17 | LGPL-2.1-or-later | Copyright (C) 1999-2022 Free Software Foundation, Inc. | `THIRD_PARTY_LICENSES.txt` [libiconv], `THIRD_PARTY_LICENSES.txt` [libiconv-NOTE] |
### C / C++ runtime
| Component | Version | License | Copyright | License file |
|---|---|---|---|---|
| [ps5-payload-sdk](https://github.com/ps5-payload-dev/sdk) `crt1.o` and `libc.a` (pacbrew v0.40.2; SDK sources at tag v0.42, `4eb7012`) | — | **GPL-3.0-or-later** (no linking exception); individual libc files carry FreeBSD / University of California BSD, OpenBSD / NetBSD ISC-style, musl MIT, public-domain and LLVM (Apache-2.0 WITH LLVM-exception, `emutls.c`) notices | Copyright (C) 2023-2026 John Törnblom (some crt files also idlesauce and sleirsgoevy); Copyright © 2005-2020 Rich Felker, et al.; Copyright The Regents of the University of California; others as listed | `THIRD_PARTY_LICENSES.txt` [ps5-payload-sdk], `THIRD_PARTY_LICENSES.txt` [ps5-payload-sdk-libc-notices], `THIRD_PARTY_LICENSES.txt` [musl] |
| [openlibm](https://github.com/JuliaMath/openlibm) | 0.8.6 | MIT / BSD-2-Clause / ISC / Sun FDLIBM permissive notice | Copyright (c) 2011-14 The Julia Project; Copyright 1992-2011 The FreeBSD Project; Copyright (C) 1993 by Sun Microsystems, Inc.; Copyright (c) 2008 Stephen L. Moshier; others in the file | `THIRD_PARTY_LICENSES.txt` [openlibm] |
| [LLVM](https://llvm.org) libc++, libc++abi, libunwind | 18.1.8 | Apache-2.0 WITH LLVM-exception | Copyright the LLVM Project contributors | `THIRD_PARTY_LICENSES.txt` [llvm-libcxx], `THIRD_PARTY_LICENSES.txt` [llvm-libcxxabi], `THIRD_PARTY_LICENSES.txt` [llvm-libunwind] |
PS5 system libraries (`libkernel`, `libSceLibcInternal`, `libScePad`, `libSceAudioOut`, ...) are
resolved dynamically at run time from the console's firmware; they are not included in the binary
and fall under the GPL-3.0 "System Libraries" exclusion.
The host (desktop) build used for development links the operating system's copies of the same
libraries dynamically and is not distributed.
## Shipped as data
| Component | License | Notes |
|---|---|---|
| [DejaVu fonts](https://dejavu-fonts.github.io) | Bitstream Vera / Arev fonts license, DejaVu changes public domain | `assets/fonts/`; license in `assets/fonts/LICENSE-DejaVu.txt` (copy in `assets/fonts/LICENSE-DejaVu.txt`). Shipped as separate files (mere aggregation), not compiled into the binary. |
| Mozilla CA certificate bundle `assets/cacert.pem` ([curl.se/ca](https://curl.se/docs/caextract.html)) | MPL-2.0 | Extracted from Mozilla's NSS certdata by the curl project; unmodified. Its source form is the file itself; license in `THIRD_PARTY_LICENSES.txt` [MPL-2.0]. |
## Code ported or adapted from other projects
| Project | License | Authors | What |
|---|---|---|---|
| [green-nx](https://github.com/rmrf404/green-nx) | GPL-3.0 | rmrf404, luishidalgoa, praneetreddy017, Dispnser | auth/session/catalog flow, SDP template, stream engine behaviour |
| [GreenOvercast](https://github.com/Producdevity/GreenOvercast) | MPL-2.0 | Producdevity | libdatachannel usage and the keyframe-request (PLI) fix in `patches/libdatachannel-xbox-pli.patch` |
| [ProsperoLight](https://github.com/xEasy4Breezy/ProsperoLight) | GPL-3.0 | xEasy4Breezy / blackbearreloaded | PS5 platform notes |
**GreenOvercast / MPL-2.0 statement.** Parts of Nubix are derived from GreenOvercast, which is
"Covered Software" under the Mozilla Public License 2.0. As permitted by MPL-2.0 section 3.3, those
parts are distributed as part of a Larger Work under the GNU General Public License v3.0. They
also remain available under the terms of the MPL-2.0 (`THIRD_PARTY_LICENSES.txt` [MPL-2.0]); their source form is
included in the Nubix Corresponding Source. `patches/libdatachannel-xbox-pli.patch` is a
Modification of libdatachannel and is itself licensed under MPL-2.0 only.
Protocol details were also cross-checked against (no code copied) [Greenlight](https://github.com/unknownskl/greenlight),
[xbox-xcloud-player](https://github.com/unknownskl/xbox-xcloud-player), [xal-node](https://github.com/unknownskl/xal-node),
[XStreaming](https://github.com/Geocld/XStreaming) (all MIT) and [green-vita](https://github.com/Day-OS/green-vita) (MPL-2.0).
## Source code
For every binary release, the complete **Corresponding Source** (GPL-3.0 section 6) is published
as a release asset right next to the binary, named
`nubix-v<version>-corresponding-source.tar.xz` (see `SOURCE.txt` for its exact layout). It contains the Nubix source at the release tag,
`patches/`, the build scripts and Dockerfile, and the exact source of every third-party component
listed above (including FFmpeg, x264, the ps5-payload-sdk and the libdatachannel tree with its
submodules) at the versions given here.
To rebuild the PS5 binary from that source:
1. Build the toolchain image: `docker build -t ps5dev docker/` — Ubuntu 24.04, clang-18 and the
ps5-payload-dev pacbrew bundle **v0.40.2** (`docker/Dockerfile`).
2. `scripts/build-deps.sh ps5` — fetches libdatachannel v0.24.6 with its submodules, applies
`patches/libdatachannel-*.patch`, and builds it into `deps/ps5/`.
3. `scripts/build-ps5.sh` — builds `build-ps5/nubix.elf` inside the `ps5dev` container, assembles
the `dist/nubix/` homebrew folder (binary, assets, `LICENSE`, this file, `THIRD_PARTY_LICENSES.txt`) and
packages it as `build-ps5/nubix-v<version>-websrv.zip`.
To replace a library (for example libiconv under LGPL-2.1 section 6), rebuild that library from
its source, install it into the toolchain sysroot and rerun steps 2 and 3.
+2950
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.
Binary file not shown.
+187
View File
@@ -0,0 +1,187 @@
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
Bitstream Vera Fonts Copyright
------------------------------
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and is distributed under the "Bitstream
Vera" names.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively. For further information, contact: fonts at gnome dot
org.
Arev Fonts Copyright
------------------------------
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
Permission is hereby granted, free of charge, to any person obtaining
a copy of the fonts accompanying this license ("Fonts") and
associated documentation files (the "Font Software"), to reproduce
and distribute the modifications to the Bitstream Vera Font Software,
including without limitation the rights to use, copy, merge, publish,
distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to
the following conditions:
The above copyright and trademark notices and this permission notice
shall be included in all copies of one or more of the Font Software
typefaces.
The Font Software may be modified, altered, or added to, and in
particular the designs of glyphs or characters in the Fonts may be
modified and additional glyphs or characters may be added to the
Fonts, only if the fonts are renamed to names not containing either
the words "Tavmjong Bah" or the word "Arev".
This License becomes null and void to the extent applicable to Fonts
or Font Software that has been modified and is distributed under the
"Tavmjong Bah Arev" names.
The Font Software may be sold as part of a larger software package but
no copy of one or more of the Font Software typefaces may be sold by
itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the name of Tavmjong Bah shall not
be used in advertising or otherwise to promote the sale, use or other
dealings in this Font Software without prior written authorization
from Tavmjong Bah. For further information, contact: tavmjong @ free
. fr.
TeX Gyre DJV Math
-----------------
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski
(on behalf of TeX users groups) are in public domain.
Letters imported from Euler Fraktur from AMSfonts are (c) American
Mathematical Society (see below).
Bitstream Vera Fonts Copyright
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera
is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license (“Fonts”) and associated
documentation
files (the “Font Software”), to reproduce and distribute the Font Software,
including without limitation the rights to use, copy, merge, publish,
distribute,
and/or sell copies of the Font Software, and to permit persons to whom
the Font Software is furnished to do so, subject to the following
conditions:
The above copyright and trademark notices and this permission notice
shall be
included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional
glyphs or characters may be added to the Fonts, only if the fonts are
renamed
to names not containing either the words “Bitstream” or the word “Vera”.
This License becomes null and void to the extent applicable to Fonts or
Font Software
that has been modified and is distributed under the “Bitstream Vera”
names.
The Font Software may be sold as part of a larger software package but
no copy
of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR
INABILITY TO USE
THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of GNOME, the GNOME
Foundation,
and Bitstream Inc., shall not be used in advertising or otherwise to promote
the sale, use or other dealings in this Font Software without prior written
authorization from the GNOME Foundation or Bitstream Inc., respectively.
For further information, contact: fonts at gnome dot org.
AMSFonts (v. 2.2) copyright
The PostScript Type 1 implementation of the AMSFonts produced by and
previously distributed by Blue Sky Research and Y&Y, Inc. are now freely
available for general use. This has been accomplished through the
cooperation
of a consortium of scientific publishers with Blue Sky Research and Y&Y.
Members of this consortium include:
Elsevier Science IBM Corporation Society for Industrial and Applied
Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS)
In order to assure the authenticity of these fonts, copyright will be
held by
the American Mathematical Society. This is not meant to restrict in any way
the legitimate use of the fonts, such as (but not limited to) electronic
distribution of documents containing these fonts, inclusion of these fonts
into other public domain or commercial font collections or computer
applications, use of the outline data to create derivative fonts and/or
faces, etc. However, the AMS does require that the AMS copyright notice be
removed from any derivative versions of the fonts which have been altered in
any way. In addition, to ensure the fidelity of TeX documents using Computer
Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces,
has requested that any alterations which yield different font metrics be
given a different name.
$Id$
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+56
View File
@@ -0,0 +1,56 @@
# Nubix — third-party dependencies.
# Copyright (C) 2026 Nubix contributors
# SPDX-License-Identifier: GPL-3.0-only
#
# pkg-config: sdl2, SDL2_ttf, libavcodec, libavutil, opus, libcurl, openssl
# (PS5: prospero-pkg-config, which always passes --static and points into the SDK sysroot)
# libdatachannel (static, + juice, srtp2, usrsctp) from ${XC_DEPS_DIR} (scripts/build-deps.sh)
#
# Provides imported targets:
# xc::sdl2 xc::sdl2_ttf xc::avcodec xc::avutil xc::opus xc::curl xc::openssl xc::datachannel
find_package(PkgConfig REQUIRED)
find_package(Threads REQUIRED)
# PS5: the toolchain file sets PKG_CONFIG_EXECUTABLE to prospero-pkg-config, which always
# passes --static and resolves inside the SDK sysroot.
macro(xc_pkg name module)
pkg_check_modules(XC_PC_${name} REQUIRED IMPORTED_TARGET GLOBAL ${module})
add_library(xc::${name} ALIAS PkgConfig::XC_PC_${name})
message(STATUS " ${module} ${XC_PC_${name}_VERSION}")
endmacro()
xc_pkg(sdl2 sdl2)
xc_pkg(sdl2_ttf SDL2_ttf)
xc_pkg(avcodec libavcodec)
xc_pkg(avutil libavutil)
xc_pkg(opus opus)
xc_pkg(curl libcurl)
xc_pkg(openssl openssl)
# ---- libdatachannel -------------------------------------------------------
set(XC_DEPS_DIR "${CMAKE_SOURCE_DIR}/deps/${XC_TARGET}" CACHE PATH "Prefix with libdatachannel (scripts/build-deps.sh)")
if(NOT EXISTS "${XC_DEPS_DIR}/include/rtc/rtc.hpp")
message(FATAL_ERROR "libdatachannel not found in ${XC_DEPS_DIR}. Run: scripts/build-deps.sh ${XC_TARGET}")
endif()
set(_xc_ldc_libs datachannel juice srtp2 usrsctp)
set(_xc_ldc_targets "")
foreach(lib IN LISTS _xc_ldc_libs)
set(_path "${XC_DEPS_DIR}/lib/lib${lib}.a")
if(NOT EXISTS "${_path}")
message(FATAL_ERROR "missing ${_path}. Run: scripts/build-deps.sh ${XC_TARGET}")
endif()
add_library(xc_ldc_${lib} STATIC IMPORTED GLOBAL)
set_target_properties(xc_ldc_${lib} PROPERTIES IMPORTED_LOCATION "${_path}")
list(APPEND _xc_ldc_targets xc_ldc_${lib})
endforeach()
add_library(xc_datachannel INTERFACE)
target_include_directories(xc_datachannel SYSTEM INTERFACE "${XC_DEPS_DIR}/include")
# static archives listed in dependency order, followed by OpenSSL and threads
target_link_libraries(xc_datachannel INTERFACE ${_xc_ldc_targets} xc::openssl Threads::Threads)
add_library(xc::datachannel ALIAS xc_datachannel)
message(STATUS " libdatachannel from ${XC_DEPS_DIR}")
+32
View File
@@ -0,0 +1,32 @@
# Nubix — target detection.
# Copyright (C) 2026 Nubix contributors
# SPDX-License-Identifier: GPL-3.0-only
# Sets XC_PS5 (BOOL) and XC_TARGET ("ps5" | "host").
#
# The pacbrew prospero toolchain file (prospero.cmake) sets PS5/PROSPERO = TRUE and
# CMAKE_SYSTEM_NAME = FreeBSD; the prospero-clang driver defines __PROSPERO__.
include(CheckCXXSourceCompiles)
set(_xc_ps5_default OFF)
if(PS5 OR PROSPERO)
set(_xc_ps5_default ON)
elseif(CMAKE_SYSTEM_NAME STREQUAL "FreeBSD" AND CMAKE_CROSSCOMPILING)
check_cxx_source_compiles("
#ifndef __PROSPERO__
#error not prospero
#endif
int main() { return 0; }" XC_HAVE_PROSPERO_MACRO)
if(XC_HAVE_PROSPERO_MACRO)
set(_xc_ps5_default ON)
endif()
endif()
option(XC_PS5 "Build the PS5 payload (auto-detected from the prospero toolchain)" ${_xc_ps5_default})
if(XC_PS5)
set(XC_TARGET "ps5")
else()
set(XC_TARGET "host")
endif()
message(STATUS "nubix target: ${XC_TARGET}")
+10
View File
@@ -0,0 +1,10 @@
# Nubix — PS5 build image: Ubuntu 24.04 + clang-18 + pacbrew ps5-payload-dev v0.40.2.
# Copyright (C) 2026 Nubix contributors
# SPDX-License-Identifier: GPL-3.0-only
FROM ubuntu:24.04
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
clang-18 lld-18 llvm-18 llvm-18-dev make cmake pkg-config python3 python3-pyelftools git wget ca-certificates unzip nasm perl file xz-utils \
&& rm -rf /var/lib/apt/lists/*
RUN wget -qO /tmp/p.tgz https://github.com/ps5-payload-dev/pacbrew-repo/releases/download/v0.40.2/ps5-payload-dev.tar.gz && tar -xzf /tmp/p.tgz -C / && rm /tmp/p.tgz
ENV PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk
ENV LLVM_CONFIG=/usr/bin/llvm-config-18
+48
View File
@@ -0,0 +1,48 @@
/* Nubix — websrv Homebrew Launcher entry.
Copyright (C) 2026 Nubix contributors
This program is free software; you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the
Free Software Foundation, version 3.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; see the file LICENSE. If not, see
<http://www.gnu.org/licenses/>.
SPDX-License-Identifier: GPL-3.0-only
Install layout (websrv): /data/homebrew/nubix/{eboot.elf, homebrew.js,
sce_sys/icon0.png, assets/}. websrv sets window.workingDir to that folder. */
async function main() {
const CWD = window.workingDir;
const ENV = {TMPDIR: '/user/temp'};
function launch(extraArgs) {
return {
path: CWD + '/eboot.elf',
args: extraArgs,
cwd: CWD,
env: ENV
};
}
return {
mainText: 'Nubix',
secondaryText: 'Unofficial client for Xbox Cloud Gaming & Remote Play',
onclick: async () => launch([]),
options: [{
text: 'Start',
onclick: async () => launch([])
}, {
text: 'Start (verbose log)',
onclick: async () => launch(['--verbose'])
}]
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+27
View File
@@ -0,0 +1,27 @@
Nubix patch for libdatachannel v0.24.6: send RTCP PLI with sender SSRC 0.
This patch is a Modification of libdatachannel
(https://github.com/paullouisageneau/libdatachannel), which is licensed under
the Mozilla Public License, v. 2.0. This patch is likewise licensed under the
Mozilla Public License, v. 2.0 (see licenses/MPL-2.0.txt, or obtain a copy at
https://mozilla.org/MPL/2.0/). The logic is taken from GreenOvercast
(https://github.com/Producdevity/GreenOvercast, MPL-2.0, by Producdevity).
SPDX-License-Identifier: MPL-2.0
diff --git a/src/rtcpreceivingsession.cpp b/src/rtcpreceivingsession.cpp
index 0584180..59a704b 100644
--- a/src/rtcpreceivingsession.cpp
+++ b/src/rtcpreceivingsession.cpp
@@ -179,7 +179,11 @@ bool RtcpReceivingSession::requestKeyframe(const message_callback &send) {
void RtcpReceivingSession::pushPLI(const message_callback &send) {
auto message = make_message(RtcpPli::Size(), Message::Control);
auto *pli = reinterpret_cast<RtcpPli *>(message->data());
- pli->preparePacket(mSsrc);
+ // Xbox (xCloud/xHome) ignores PLIs whose sender SSRC equals the media SSRC;
+ // browsers send sender SSRC 0 here. (nubix patch, from GreenOvercast)
+ pli->header.header.prepareHeader(206, 1, 2);
+ pli->header.setPacketSenderSSRC(0);
+ pli->header.setMediaSourceSSRC(mSsrc);
send(message);
}
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# Nubix — build third-party dependencies that are not part of the PS5 SDK.
# Copyright (C) 2026 Nubix contributors
# SPDX-License-Identifier: GPL-3.0-only
#
# Usage: scripts/build-deps.sh <ps5|host> [--clean]
#
# Fetches libdatachannel v0.24.6 (with submodules), applies patches/libdatachannel-*.patch
# and installs a static build into deps/<target>/ (include/, lib/).
#
# ps5 : runs inside the "ps5dev" docker image (built from docker/Dockerfile if missing),
# cross-compiled with the prospero toolchain against the SDK's OpenSSL.
# host : native build against Homebrew openssl@3 (macOS) or system OpenSSL (Linux).
#
# Environment:
# LDC_LOCAL_SRC optional path to an existing libdatachannel v0.24.6 checkout (with
# submodules) to copy instead of cloning from GitHub.
# PS5DEV_IMAGE docker image name (default: ps5dev)
# JOBS parallel build jobs
set -euo pipefail
LDC_VERSION="v0.24.6"
LDC_REPO="https://github.com/paullouisageneau/libdatachannel.git"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
TARGET="${1:-}"
shift || true
CLEAN=0
IN_DOCKER=0
for a in "$@"; do
case "$a" in
--clean) CLEAN=1 ;;
--in-docker) IN_DOCKER=1 ;;
*) echo "unknown option: $a" >&2; exit 2 ;;
esac
done
case "${TARGET}" in
ps5|host) ;;
*) echo "usage: $0 <ps5|host> [--clean]" >&2; exit 2 ;;
esac
ncpu() {
if [[ -n "${JOBS:-}" ]]; then echo "${JOBS}"; return; fi
getconf _NPROCESSORS_ONLN 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4
}
SRC="${ROOT}/.cache/libdatachannel-${LDC_VERSION}"
BUILD="${ROOT}/.cache/build-libdatachannel-${TARGET}"
DEPS_PREFIX="${ROOT}/deps/${TARGET}"
# ---------------------------------------------------------------- source ----
fetch_source() {
if [[ -f "${SRC}/.xc-ready" ]]; then
return
fi
rm -rf "${SRC}"
mkdir -p "${ROOT}/.cache"
if [[ -n "${LDC_LOCAL_SRC:-}" && -f "${LDC_LOCAL_SRC}/CMakeLists.txt" ]]; then
echo ">> copying libdatachannel from ${LDC_LOCAL_SRC}"
mkdir -p "${SRC}"
# copy everything except previous build trees
(cd "${LDC_LOCAL_SRC}" && tar cf - --exclude='./build*' .) | (cd "${SRC}" && tar xf -)
# discard local modifications so the patch applies to pristine sources
if [[ -d "${SRC}/.git" ]]; then
(cd "${SRC}" && git checkout -q -- . && git submodule foreach -q --recursive git checkout -q -- . ) || true
fi
else
echo ">> cloning libdatachannel ${LDC_VERSION}"
git clone --depth 1 --branch "${LDC_VERSION}" --recurse-submodules --shallow-submodules \
"${LDC_REPO}" "${SRC}"
fi
for p in "${ROOT}"/patches/libdatachannel-*.patch; do
[[ -f "$p" ]] || continue
echo ">> applying $(basename "$p")"
(cd "${SRC}" && git apply --whitespace=nowarn "$p")
done
touch "${SRC}/.xc-ready"
}
# ------------------------------------------------------------------ build ---
common_args=(
-DCMAKE_BUILD_TYPE=Release
-DBUILD_SHARED_LIBS=OFF
-DNO_WEBSOCKET=ON -DNO_EXAMPLES=ON -DNO_TESTS=ON
-DUSE_GNUTLS=OFF -DUSE_MBEDTLS=OFF -DUSE_NICE=OFF
-Dsctp_werror=OFF -DWARNINGS_AS_ERRORS=OFF
-DCMAKE_POSITION_INDEPENDENT_CODE=ON
-DCMAKE_INSTALL_PREFIX="${DEPS_PREFIX}"
-DCMAKE_INSTALL_LIBDIR=lib
)
build_host() {
local ossl=""
if command -v brew >/dev/null 2>&1; then
ossl="$(brew --prefix openssl@3 2>/dev/null || true)"
fi
local extra=()
[[ -n "${ossl}" ]] && extra+=(-DOPENSSL_ROOT_DIR="${ossl}")
cmake -S "${SRC}" -B "${BUILD}" "${common_args[@]}" ${extra[@]+"${extra[@]}"}
cmake --build "${BUILD}" -j"$(ncpu)" --target datachannel
cmake --install "${BUILD}"
}
build_ps5_in_container() {
# shellcheck disable=SC1091
source "${PS5_PAYLOAD_SDK}/toolchain/prospero.sh"
"${CMAKE}" -S "${SRC}" -B "${BUILD}" "${common_args[@]}" \
-DOPENSSL_USE_STATIC_LIBS=ON -DOPENSSL_ROOT_DIR="${PS5_SYSROOT}/user/homebrew" \
-DCMAKE_C_FLAGS="-DXC_NET_COMPAT_REDIRECT -include ${ROOT}/src/platform/net_compat.h" \
-DCMAKE_CXX_FLAGS="-DXC_NET_COMPAT_REDIRECT -include ${ROOT}/src/platform/net_compat.h"
cmake --build "${BUILD}" -j"$(ncpu)" --target datachannel
# prospero.sh exports DESTDIR=<sysroot>; install into our own prefix instead.
DESTDIR= cmake --install "${BUILD}"
}
docker_image_ensure() {
local img="${PS5DEV_IMAGE:-ps5dev}"
if ! docker image inspect "${img}" >/dev/null 2>&1; then
echo ">> building docker image ${img} from docker/Dockerfile"
docker build -t "${img}" "${ROOT}/docker"
fi
}
if [[ ${CLEAN} -eq 1 && ${IN_DOCKER} -eq 0 ]]; then
rm -rf "${BUILD}" "${DEPS_PREFIX}" "${SRC}"
fi
if [[ "${TARGET}" == "ps5" && -z "${PS5_PAYLOAD_SDK:-}" ]]; then
# Host side: fetch sources (needs git/network), then re-enter inside docker.
fetch_source
docker_image_ensure
exec docker run --rm -v "${ROOT}:/w" -w /w -e JOBS="$(ncpu)" "${PS5DEV_IMAGE:-ps5dev}" \
bash /w/scripts/build-deps.sh ps5 --in-docker
fi
fetch_source
if [[ "${TARGET}" == "ps5" ]]; then
# build directory may contain a cache from a host path; always reconfigure cleanly
[[ -f "${BUILD}/CMakeCache.txt" ]] && ! grep -q "CMAKE_HOME_DIRECTORY:INTERNAL=${SRC}$" "${BUILD}/CMakeCache.txt" && rm -rf "${BUILD}"
build_ps5_in_container
else
[[ -f "${BUILD}/CMakeCache.txt" ]] && ! grep -q "CMAKE_HOME_DIRECTORY:INTERNAL=${SRC}$" "${BUILD}/CMakeCache.txt" && rm -rf "${BUILD}"
build_host
fi
echo ">> libdatachannel ${LDC_VERSION} installed to ${DEPS_PREFIX}"
ls -1 "${DEPS_PREFIX}/lib"/*.a
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Nubix — host (macOS/Linux) build: deps (if missing) + app + xc-cli + tests.
# Copyright (C) 2026 Nubix contributors
# SPDX-License-Identifier: GPL-3.0-only
#
# Usage: scripts/build-host.sh [Release|Debug] [--test]
# Output: build-host/nubix, build-host/xc-cli, build-host/xc-tests
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
BUILD_TYPE="Release"
RUN_TESTS=0
for a in "$@"; do
case "$a" in
Release|Debug|RelWithDebInfo) BUILD_TYPE="$a" ;;
--test) RUN_TESTS=1 ;;
*) echo "usage: $0 [Release|Debug|RelWithDebInfo] [--test]" >&2; exit 2 ;;
esac
done
if [[ ! -f "${ROOT}/deps/host/lib/libdatachannel.a" ]]; then
bash "${SCRIPT_DIR}/build-deps.sh" host
fi
# Homebrew: keg-only openssl@3 must be visible to pkg-config
if command -v brew >/dev/null 2>&1; then
OSSL="$(brew --prefix openssl@3 2>/dev/null || true)"
[[ -n "${OSSL}" ]] && export PKG_CONFIG_PATH="${OSSL}/lib/pkgconfig${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}"
# prefer Homebrew curl (OpenSSL backend, same as PS5) over the macOS system curl if installed
CURLP="$(brew --prefix curl 2>/dev/null || true)"
[[ -n "${CURLP}" && -d "${CURLP}/lib/pkgconfig" ]] && export PKG_CONFIG_PATH="${CURLP}/lib/pkgconfig:${PKG_CONFIG_PATH}"
fi
JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)"
cmake -S "${ROOT}" -B "${ROOT}/build-host" -DCMAKE_BUILD_TYPE="${BUILD_TYPE}"
cmake --build "${ROOT}/build-host" -j"${JOBS}"
echo ">> built ${ROOT}/build-host/nubix"
if [[ ${RUN_TESTS} -eq 1 ]]; then
(cd "${ROOT}/build-host" && ctest --output-on-failure)
fi
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Nubix — PS5 payload build, run inside the "ps5dev" docker image (docker/Dockerfile:
# ubuntu 24.04 + clang-18 + pacbrew ps5-payload-dev v0.40.2 at $PS5_PAYLOAD_SDK).
# Copyright (C) 2026 Nubix contributors
# SPDX-License-Identifier: GPL-3.0-only
#
# Usage: scripts/build-ps5.sh [Release|Debug|RelWithDebInfo]
# Output:
# build-ps5/nubix.elf stripped payload (send to elfldr :9021)
# build-ps5/nubix-debug.elf unstripped, for symbolizing crashes
# dist/nubix/ websrv homebrew folder -> copy to /data/homebrew/nubix/
# eboot.elf, homebrew.js, sce_sys/icon0.png, assets/,
# LICENSE, THIRD_PARTY_NOTICES.md, THIRD_PARTY_LICENSES.txt, SOURCE.txt (if present)
# build-ps5/nubix-v<version>-websrv.zip the nubix/ folder, zipped for release
#
# The docker image is built from docker/Dockerfile on first use. Override with PS5DEV_IMAGE.
# XC_LINKCHECK=ON additionally builds build-ps5/xc-linkcheck.elf (full dependency link test).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
BUILD_TYPE="${1:-Release}"
IMAGE="${PS5DEV_IMAGE:-ps5dev}"
if [[ -z "${PS5_PAYLOAD_SDK:-}" ]]; then
# ---- host side: make sure image + deps exist, then re-run ourselves in docker ----
if ! docker image inspect "${IMAGE}" >/dev/null 2>&1; then
echo ">> building docker image ${IMAGE} from docker/Dockerfile"
docker build -t "${IMAGE}" "${ROOT}/docker"
fi
if [[ ! -f "${ROOT}/deps/ps5/lib/libdatachannel.a" ]]; then
bash "${SCRIPT_DIR}/build-deps.sh" ps5
fi
JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)"
exec docker run --rm -v "${ROOT}:/w" -w /w -e JOBS="${JOBS}" -e XC_LINKCHECK="${XC_LINKCHECK:-OFF}" "${IMAGE}" \
bash /w/scripts/build-ps5.sh "${BUILD_TYPE}"
fi
# ---- inside the container ----
# shellcheck disable=SC1091
source "${PS5_PAYLOAD_SDK}/toolchain/prospero.sh"
JOBS="${JOBS:-$(nproc)}"
if [[ ! -f "${ROOT}/deps/ps5/lib/libdatachannel.a" ]]; then
bash "${SCRIPT_DIR}/build-deps.sh" ps5
fi
# A build dir configured on another machine/path cannot be reused.
if [[ -f "${ROOT}/build-ps5/CMakeCache.txt" ]] && ! grep -q "CMAKE_HOME_DIRECTORY:INTERNAL=${ROOT}$" "${ROOT}/build-ps5/CMakeCache.txt"; then
rm -rf "${ROOT}/build-ps5"
fi
"${CMAKE}" -S "${ROOT}" -B "${ROOT}/build-ps5" -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \
-DCMAKE_VERBOSE_MAKEFILE=OFF -DXC_BUILD_TESTS=OFF -DXC_BUILD_CLI=OFF \
-DXC_BUILD_LINKCHECK="${XC_LINKCHECK:-OFF}"
cmake --build "${ROOT}/build-ps5" -j"${JOBS}"
# ---- websrv homebrew layout ----
DIST="${ROOT}/dist/nubix"
rm -rf "${DIST}"
mkdir -p "${DIST}/sce_sys" "${DIST}/assets"
cp "${ROOT}/build-ps5/nubix.elf" "${DIST}/eboot.elf"
cp "${ROOT}/homebrew/homebrew.js" "${DIST}/homebrew.js"
cp "${ROOT}/homebrew/sce_sys/icon0.png" "${DIST}/sce_sys/icon0.png"
cp -R "${ROOT}/assets/." "${DIST}/assets/"
rm -f "${DIST}/assets/icon0.png"
cp "${ROOT}/LICENSE" "${DIST}/LICENSE"
cp "${ROOT}/THIRD_PARTY_NOTICES.md" "${DIST}/THIRD_PARTY_NOTICES.md"
cp "${ROOT}/THIRD_PARTY_LICENSES.txt" "${DIST}/THIRD_PARTY_LICENSES.txt"
if [[ -f "${ROOT}/SOURCE.txt" ]]; then
cp "${ROOT}/SOURCE.txt" "${DIST}/SOURCE.txt"
fi
# ---- release zip: build-ps5/nubix-v<version>-websrv.zip containing nubix/ ----
VERSION="$(sed -nE 's/^[[:space:]]*project\([^)]*VERSION[[:space:]]+([0-9][0-9A-Za-z.+-]*).*/\1/p' "${ROOT}/CMakeLists.txt" | head -n1)"
if [[ -z "${VERSION}" ]]; then
echo "!! could not read project(VERSION) from CMakeLists.txt" >&2
exit 1
fi
ZIP="${ROOT}/build-ps5/nubix-v${VERSION}-websrv.zip"
rm -f "${ZIP}"
(cd "$(dirname "${DIST}")" && python3 - "${ZIP}" "$(basename "${DIST}")" <<'PY'
import os, sys, zipfile
out, top = sys.argv[1], sys.argv[2]
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
for d, dirs, files in os.walk(top):
dirs.sort()
z.write(d, d + "/")
for f in sorted(files):
z.write(os.path.join(d, f))
PY
)
echo ">> $(ls -l "${ROOT}/build-ps5/nubix.elf")"
echo ">> dist: ${DIST}"
echo ">> zip: ${ZIP}"
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Nubix — deploy to a jailbroken PS5.
# Copyright (C) 2026 Nubix contributors
# SPDX-License-Identifier: GPL-3.0-only
#
# Usage:
# PS5_HOST=192.168.1.50 scripts/deploy.sh run send build-ps5/nubix.elf to elfldr (port 9021)
# PS5_HOST=192.168.1.50 scripts/deploy.sh install upload dist/nubix/ to /data/homebrew/nubix/
# over FTP (ftpsrv payload, port $PS5_FTP_PORT, default 2121)
# PS5_HOST=... scripts/deploy.sh log fetch /data/nubix/log.txt over FTP
#
# "run" starts the payload directly (stdout appears on elfldr's log / klog). The websrv
# Homebrew Launcher (install) is the supported way to start it with video/pad/audio access.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
CMD="${1:-run}"
HOST="${PS5_HOST:-}"
ELF_PORT="${PS5_ELF_PORT:-9021}"
FTP_PORT="${PS5_FTP_PORT:-2121}"
if [[ -z "${HOST}" ]]; then
echo "error: set PS5_HOST to the console's IP address" >&2
exit 2
fi
send_elf() {
local elf="$1"
[[ -f "${elf}" ]] || { echo "error: ${elf} not found (run scripts/build-ps5.sh)" >&2; exit 1; }
echo ">> sending $(basename "${elf}") ($(wc -c <"${elf}" | tr -d ' ') bytes) to ${HOST}:${ELF_PORT}"
if command -v nc >/dev/null 2>&1; then
nc -w 3 "${HOST}" "${ELF_PORT}" <"${elf}"
else
exec 3<>"/dev/tcp/${HOST}/${ELF_PORT}"
cat "${elf}" >&3
exec 3>&-
fi
echo ">> sent"
}
ftp_upload_dir() {
local src="$1" dst="$2"
command -v curl >/dev/null 2>&1 || { echo "error: curl required for FTP upload" >&2; exit 1; }
(cd "${src}" && find . -type f | sed 's|^\./||') | while read -r f; do
echo " ${dst}/${f}"
curl -sS --ftp-create-dirs -T "${src}/${f}" "ftp://${HOST}:${FTP_PORT}${dst}/${f}"
done
}
case "${CMD}" in
run)
send_elf "${ROOT}/build-ps5/nubix.elf"
;;
install)
[[ -d "${ROOT}/dist/nubix" ]] || { echo "error: dist/nubix missing (run scripts/build-ps5.sh)" >&2; exit 1; }
echo ">> uploading dist/nubix to ftp://${HOST}:${FTP_PORT}/data/homebrew/nubix"
ftp_upload_dir "${ROOT}/dist/nubix" "/data/homebrew/nubix"
echo ">> done; start it from the websrv Homebrew Launcher"
;;
log)
curl -sS "ftp://${HOST}:${FTP_PORT}/data/nubix/log.txt"
;;
*)
echo "usage: $0 <run|install|log>" >&2
exit 2
;;
esac
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env bash
# Nubix — assemble the GPLv3 section 6 "Corresponding Source" archive for a binary release.
#
# Usage:
# scripts/make-source-bundle.sh [--nubix-ref <git-ref> | --nubix-dir <dir>] [--offline]
#
# Output (all under dist/):
# nubix-v<ver>-corresponding-source.tar.xz the archive to upload next to the binary
# nubix-v<ver>-corresponding-source.MANIFEST copy of the MANIFEST inside the archive
# nubix-v<ver>-corresponding-source.tar.xz.sha256 checksum of the archive
#
# <ver> is taken from `project(nubix VERSION x.y.z ...)` in CMakeLists.txt.
#
# Nubix's own source is taken from (in this order of precedence):
# --nubix-dir <dir> every file below <dir> (e.g. a clean export of the release tree),
# except .git/, build*/, deps/, dist/, .cache/ and docs/
# --nubix-ref <ref> `git archive <ref>` (only committed content; use for tagged releases)
# default the CURRENT WORKING TREE: `git ls-files -co --exclude-standard`, i.e.
# tracked files (incl. uncommitted modifications) plus untracked files that
# are not gitignored. docs/ and files deleted from the working tree are left
# out; build trees, deps/, dist/ and .cache/ are gitignored. Make sure the
# tree is exactly what the binary was built from.
#
# Third-party sources are downloaded once into .cache/sources/ (tarballs are verified against
# pinned sha256 sums; git commits are fetched by full hash and exported with `git archive`),
# then copied unmodified into the archive under upstream/. Every included file is listed in
# MANIFEST and SHA256SUMS. Re-running the script reuses the cache and rebuilds the archive.
#
# --offline never touch the network; fail if something is missing from the cache.
#
# Environment:
# LDC_LOCAL_SRC existing libdatachannel checkout whose git objects may be used instead of
# fetching (default: .cache/libdatachannel-v0.24.6; it is only read, never
# modified — exports come from the pinned commits, not from its working tree).
# XZ_OPT read by xz itself (e.g. XZ_OPT=-9e for a slightly smaller outer archive).
#
# Requirements: bash, git, curl, tar (GNU tar or bsdtar), xz, sha256sum or shasum.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
NUBIX_REF=""
NUBIX_DIR=""
OFFLINE=0
while [[ $# -gt 0 ]]; do
case "$1" in
--nubix-ref) NUBIX_REF="${2:?--nubix-ref needs a value}"; shift 2 ;;
--nubix-dir) NUBIX_DIR="${2:?--nubix-dir needs a value}"; shift 2 ;;
--offline) OFFLINE=1; shift ;;
-h|--help) sed -n '2,38p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
if [[ -n "${NUBIX_REF}" && -n "${NUBIX_DIR}" ]]; then
echo "--nubix-ref and --nubix-dir are mutually exclusive" >&2; exit 2
fi
VERSION="$(sed -nE 's/^[[:space:]]*project\(nubix[[:space:]]+VERSION[[:space:]]+([0-9][0-9.]*).*/\1/p' "${ROOT}/CMakeLists.txt" | head -n1)"
[[ -n "${VERSION}" ]] || { echo "cannot read project(nubix VERSION ...) from CMakeLists.txt" >&2; exit 1; }
NAME="nubix-v${VERSION}-corresponding-source"
CACHE="${ROOT}/.cache/sources"
GITCACHE="${CACHE}/git"
WORK="${ROOT}/.cache/source-bundle"
STAGE="${WORK}/${NAME}"
DIST="${ROOT}/dist"
OUT="${DIST}/${NAME}.tar.xz"
LDC_LOCAL_SRC="${LDC_LOCAL_SRC:-${ROOT}/.cache/libdatachannel-v0.24.6}"
export COPYFILE_DISABLE=1 # macOS bsdtar: no AppleDouble ._* files
export LC_ALL=C
log() { printf '>> %s\n' "$*" >&2; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
for t in git tar xz; do command -v "$t" >/dev/null 2>&1 || die "missing tool: $t"; done
[[ ${OFFLINE} -eq 1 ]] || command -v curl >/dev/null 2>&1 || die "missing tool: curl"
sha256() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
else shasum -a 256 "$1" | awk '{print $1}'; fi
}
fsize() { wc -c <"$1" | tr -d ' '; }
if tar --version 2>/dev/null | grep -q 'GNU tar'; then
TAR_NORM=(--owner=0 --group=0 --numeric-owner --format=pax --pax-option=delete=atime,delete=ctime)
else
TAR_NORM=(--uid 0 --gid 0 --uname root --gname root --format=pax)
fi
# ------------------------------------------------------------- components ---
# Release tarballs: id|version|file|sha256|url [mirror-url ...]
# sha256: from the pacbrew-repo v0.40.2 PKGBUILD (which pacbrew verified when building the
# SDK libraries) unless noted. FFmpeg's PKGBUILD has sha256sums=SKIP and ffmpeg.org publishes
# only PGP signatures, so that sum was computed on download (2026-10-07) and pinned here.
TARBALLS='
ffmpeg|7.0.1|ffmpeg-7.0.1.tar.xz|bce9eeb0f17ef8982390b1f37711a61b4290dc8c2a0c1a37b5857e85bfb0e4ff|https://ffmpeg.org/releases/ffmpeg-7.0.1.tar.xz
libiconv|1.17|libiconv-1.17.tar.gz|8f74213b56238c85a50a5329f77e06198771e70dd9a739779f4c02f65d971313|https://ftp.gnu.org/pub/gnu/libiconv/libiconv-1.17.tar.gz https://ftpmirror.gnu.org/libiconv/libiconv-1.17.tar.gz
openssl|3.5.2|openssl-3.5.2.tar.gz|c53a47e5e441c930c3928cf7bf6fb00e5d129b630e0aa873b08258656e7345ec|https://github.com/openssl/openssl/releases/download/openssl-3.5.2/openssl-3.5.2.tar.gz
curl|8.18.0|curl-8.18.0.tar.xz|40df79166e74aa20149365e11ee4c798a46ad57c34e4f68fd13100e2c9a91946|https://curl.se/download/curl-8.18.0.tar.xz https://curl.haxx.se/download/curl-8.18.0.tar.xz
libpsl|0.21.5|libpsl-0.21.5.tar.gz|1dcc9ceae8b128f3c0b3f654decd0e1e891afc6ff81098f227ef260449dae208|https://github.com/rockdaboot/libpsl/releases/download/0.21.5/libpsl-0.21.5.tar.gz
SDL2_ttf|2.22.0|SDL2_ttf-2.22.0.tar.gz|d48cbd1ce475b9e178206bf3b72d56b66d84d44f64ac05803328396234d67723|https://libsdl.org/projects/SDL_ttf/release/SDL2_ttf-2.22.0.tar.gz https://github.com/libsdl-org/SDL_ttf/releases/download/release-2.22.0/SDL2_ttf-2.22.0.tar.gz
freetype|2.13.2|freetype-2.13.2.tar.gz|1ac27e16c134a7f2ccea177faba19801131116fd682efc1f5737037c5db224b5|https://download.savannah.gnu.org/releases/freetype/freetype-2.13.2.tar.gz https://downloads.sourceforge.net/project/freetype/freetype2/2.13.2/freetype-2.13.2.tar.gz
libpng|1.6.43|libpng-1.6.43.tar.xz|6a5ca0652392a2d7c9db2ae5b40210843c0bbc081cbd410825ab00cc59f14a6c|https://download.sourceforge.net/libpng/libpng-1.6.43.tar.xz https://downloads.sourceforge.net/project/libpng/libpng16/1.6.43/libpng-1.6.43.tar.xz
zlib|1.3.2|zlib-1.3.2.tar.gz|bb329a0a2cd0274d05519d61c667c062e06990d72e125ee2dfa8de64f0119d16|https://github.com/madler/zlib/releases/download/v1.3.2/zlib-1.3.2.tar.gz
zstd|1.5.6|zstd-1.5.6.tar.gz|8c29e06cf42aacc1eafc4077ae2ec6c6fcb96a626157e0593d5e82a34fd403c1|https://github.com/facebook/zstd/releases/download/v1.5.6/zstd-1.5.6.tar.gz
xz|5.4.6|xz-5.4.6.tar.xz|b92d4e3a438affcf13362a1305cd9d94ed47ddda22e456a42791e630a5644f5c|https://github.com/tukaani-project/xz/releases/download/v5.4.6/xz-5.4.6.tar.xz
bzip2|1.0.8|bzip2-1.0.8.tar.gz|ab5a03176ee106d3f0fa90e381da478ddae405918153cca248e682cd0c4a2269|https://sourceware.org/pub/bzip2/bzip2-1.0.8.tar.gz
openlibm|0.8.6|openlibm-0.8.6.tar.gz|347998968cfeb2f9b91de6a8e85d2ba92dec0915d53500a4bc483e056f85b94c|https://github.com/JuliaMath/openlibm/archive/refs/tags/v0.8.6.tar.gz
opus|1.5.2|opus-1.5.2.tar.gz|65c1d2f78b9f2fb20082c38cbe47c951ad5839345876e46941612ee87f9a7ce1|https://downloads.xiph.org/releases/opus/opus-1.5.2.tar.gz https://github.com/xiph/opus/releases/download/v1.5.2/opus-1.5.2.tar.gz
libsamplerate|0.2.2|libsamplerate-0.2.2.tar.xz|3258da280511d24b49d6b08615bbe824d0cacc9842b0e4caf11c52cf2b043893|https://github.com/libsndfile/libsamplerate/releases/download/0.2.2/libsamplerate-0.2.2.tar.xz
'
# Git commits: id|version-label|repo-url|full-commit|archive-prefix
# Exported with `git archive --prefix=<archive-prefix>/ <commit> | xz -T1 -6`.
# The libdatachannel submodule archives use prefixes inside libdatachannel-v0.24.6/ so that
# extracting all six into one directory reproduces the recursive checkout.
GITSRCS='
libdatachannel|v0.24.6|https://github.com/paullouisageneau/libdatachannel.git|6b1e2e620f1e37f0eafeee702eaea0043cb305fd|libdatachannel-v0.24.6
libjuice|v1.7.4|https://github.com/paullouisageneau/libjuice.git|b89c792e3612faf2f12cf35bcc56857313a06be3|libdatachannel-v0.24.6/deps/libjuice
libsrtp|24b3bf8|https://github.com/cisco/libsrtp.git|24b3bf8f19b6f5ab4cd2bcceb4f4064efca86fd5|libdatachannel-v0.24.6/deps/libsrtp
usrsctp|fec583d|https://github.com/paullouisageneau/usrsctp.git|fec583d54493f879d2ae44a743423bf8a04371ab|libdatachannel-v0.24.6/deps/usrsctp
plog|94899e0|https://github.com/SergiusTheBest/plog.git|94899e0b926ac1b0f4750bfbd495167b4a6ae9ef|libdatachannel-v0.24.6/deps/plog
json|55f9368|https://github.com/nlohmann/json.git|55f93686c01528224f448c19128836e7df245f72|libdatachannel-v0.24.6/deps/json
x264|r3222-b35605a|https://code.videolan.org/videolan/x264.git|b35605ace3ddf7c1a5d67a2eb553f034aef41d55|x264-b35605a
SDL-ps5|2.30.12-a47182a20|https://github.com/ps5-payload-dev/SDL.git|a47182a20e741e4735d1fcd41e8656bf338b0c72|SDL-ps5-a47182a20
ps5-payload-sdk|4eb7012|https://github.com/ps5-payload-dev/sdk.git|4eb701204fc3f8d31e84cf8ca272974e2be9c867|ps5-payload-sdk-4eb7012
pacbrew-repo|v0.40.2|https://github.com/ps5-payload-dev/pacbrew-repo.git|1687e827675c66e1b040751c7f0811222bd3aea0|pacbrew-repo-v0.40.2
'
# ---------------------------------------------------------------- helpers ---
fetch_tarball() { # file sha256 urls...
local file="$1" want="$2"; shift 2
local dst="${CACHE}/${file}"
if [[ -f "${dst}" ]]; then
[[ "$(sha256 "${dst}")" == "${want}" ]] && return 0
log "cached ${file} has a wrong checksum, re-downloading"
rm -f "${dst}"
fi
[[ ${OFFLINE} -eq 0 ]] || die "${file} not in cache and --offline given"
local url got
for url in "$@"; do
log "downloading ${url}"
if curl -fsSL --retry 3 --connect-timeout 30 -o "${dst}.part" "${url}"; then
got="$(sha256 "${dst}.part")"
if [[ "${got}" == "${want}" ]]; then mv "${dst}.part" "${dst}"; return 0; fi
log "checksum mismatch for ${url}: got ${got}, want ${want}"
fi
rm -f "${dst}.part"
done
die "could not download ${file} with sha256 ${want}"
}
have_commit() { git -C "$1" cat-file -e "$2^{commit}" 2>/dev/null; }
# Find a git dir that already has <commit>: our bare cache, or the read-only libdatachannel checkout.
local_git_for() { # id commit -> prints dir or nothing
local id="$1" commit="$2" d
local cands=("${GITCACHE}/${id}.git")
if [[ -d "${LDC_LOCAL_SRC}" ]]; then
case "${id}" in
libdatachannel) cands+=("${LDC_LOCAL_SRC}") ;;
libjuice|libsrtp|usrsctp|plog|json) cands+=("${LDC_LOCAL_SRC}/deps/${id}") ;;
esac
fi
for d in "${cands[@]}"; do
[[ -e "${d}" ]] && have_commit "${d}" "${commit}" && { echo "${d}"; return 0; }
done
return 0
}
fetch_commit() { # id repo commit -> prints git dir containing commit
local id="$1" repo="$2" commit="$3" d
d="$(local_git_for "${id}" "${commit}")"
if [[ -n "${d}" ]]; then echo "${d}"; return 0; fi
[[ ${OFFLINE} -eq 0 ]] || die "commit ${commit} of ${id} not in cache and --offline given"
d="${GITCACHE}/${id}.git"
[[ -d "${d}" ]] || git init -q --bare "${d}"
log "fetching ${repo} ${commit}"
if ! git -C "${d}" fetch -q --depth 1 "${repo}" "${commit}" >&2; then
log "fetch by hash refused, fetching all branches and tags of ${repo}"
git -C "${d}" fetch -q "${repo}" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' >&2
fi
have_commit "${d}" "${commit}" || die "commit ${commit} not found in ${repo}"
echo "${d}"
}
export_commit() { # id label repo commit prefix -> prints archive file name (in CACHE)
local id="$1" label="$2" repo="$3" commit="$4" prefix="$5"
local file="${id}-${label}-${commit:0:12}.tar.xz" dst d
dst="${CACHE}/${file}"
if [[ ! -f "${dst}" ]]; then
d="$(fetch_commit "${id}" "${repo}" "${commit}")"
log "exporting ${id} ${commit} from ${d}"
git -C "${d}" archive --format=tar --prefix="${prefix}/" "${commit}" | xz -T1 -6 -c >"${dst}.part"
mv "${dst}.part" "${dst}"
fi
echo "${file}"
}
# ------------------------------------------------------------ nubix source ---
nubix_source() { # -> writes ${STAGE}/nubix-v${VERSION}-src.tar.xz, prints description
local top="nubix-v${VERSION}" tmp="${WORK}/nubix-tree"
local out="${STAGE}/${top}-src.tar.xz"
rm -rf "${tmp}"; mkdir -p "${tmp}/${top}"
if [[ -n "${NUBIX_REF}" ]]; then
git -C "${ROOT}" archive --format=tar "${NUBIX_REF}" | tar -xf - -C "${tmp}/${top}"
rm -rf "${tmp}/${top}/docs"
echo "git archive $(git -C "${ROOT}" rev-parse "${NUBIX_REF}^{commit}") (docs/ excluded)"
elif [[ -n "${NUBIX_DIR}" ]]; then
local src; src="$(cd "${NUBIX_DIR}" && pwd)"
(cd "${src}" && find . -type f \
! -path './.git/*' ! -path './.git' ! -path './build*' ! -path './deps/*' \
! -path './dist/*' ! -path './.cache/*' ! -path './docs/*' ! -name '.DS_Store' -print0 \
| tar -cf - --null -T -) | tar -xf - -C "${tmp}/${top}"
echo "directory ${src##*/}"
else
(cd "${ROOT}" && git ls-files -z -co --exclude-standard \
| while IFS= read -r -d '' f; do
case "$f" in docs/*|dist/*|deps/*|.cache/*|build*/*) continue ;; esac
[[ -f "$f" || -L "$f" ]] && printf '%s\0' "$f"
done \
| tar -cf - --null -T -) | tar -xf - -C "${tmp}/${top}"
local head dirty
head="$(git -C "${ROOT}" rev-parse HEAD 2>/dev/null || echo none)"
dirty="$(git -C "${ROOT}" status --porcelain 2>/dev/null | grep -vc '^?? docs/' || true)"
echo "working tree of ${ROOT##*/} at HEAD ${head} (${dirty} uncommitted/untracked paths; docs/ excluded)"
fi
(cd "${tmp}" && find "${top}" -print0 | LC_ALL=C sort -z \
| tar -cf - "${TAR_NORM[@]}" --no-recursion --null -T -) | xz -T1 -6 -c >"${out}"
rm -rf "${tmp}"
}
# ------------------------------------------------------------------ main ----
mkdir -p "${CACHE}" "${GITCACHE}" "${DIST}"
rm -rf "${WORK}"; mkdir -p "${STAGE}/upstream"
MANIFEST_ROWS=() # path|component|version|origin
log "Nubix ${VERSION}: packaging own source"
NUBIX_DESC="$(nubix_source)"
MANIFEST_ROWS+=("nubix-v${VERSION}-src.tar.xz|nubix|${VERSION}|${NUBIX_DESC}")
while IFS='|' read -r id ver file sum urls; do
[[ -n "${id}" ]] || continue
# shellcheck disable=SC2086
fetch_tarball "${file}" "${sum}" ${urls}
cp "${CACHE}/${file}" "${STAGE}/upstream/${file}"
MANIFEST_ROWS+=("upstream/${file}|${id}|${ver}|${urls%% *}")
done <<<"${TARBALLS}"
while IFS='|' read -r id label repo commit prefix; do
[[ -n "${id}" ]] || continue
file="$(export_commit "${id}" "${label}" "${repo}" "${commit}" "${prefix}")"
cp "${CACHE}/${file}" "${STAGE}/upstream/${file}"
MANIFEST_ROWS+=("upstream/${file}|${id}|${label}|${repo} @ ${commit}")
done <<<"${GITSRCS}"
[[ -f "${ROOT}/SOURCE.txt" ]] && cp "${ROOT}/SOURCE.txt" "${STAGE}/SOURCE.txt"
[[ -f "${ROOT}/LICENSE" ]] && cp "${ROOT}/LICENSE" "${STAGE}/LICENSE"
cat >"${STAGE}/README.txt" <<EOF
Nubix ${VERSION} - Corresponding Source (GNU GPL v3, section 6)
nubix-v${VERSION}-src.tar.xz Nubix source code (includes patches/ applied to libdatachannel,
docker/Dockerfile and scripts/ used to build the release)
upstream/ unmodified upstream sources of every library linked into the
PS5 binary, plus the build recipes (pacbrew-repo PKGBUILDs, SDK)
SOURCE.txt component list, exact versions/commits and rebuild instructions
MANIFEST / SHA256SUMS checksums of everything in this archive
libdatachannel: extract upstream/libdatachannel-* , libjuice-*, libsrtp-*, usrsctp-*, plog-*
and json-* into the same directory; they recreate libdatachannel-v0.24.6/ with its submodules.
Then apply nubix-v${VERSION}/patches/libdatachannel-*.patch (scripts/build-deps.sh does this).
EOF
# MANIFEST + SHA256SUMS
{
echo "# Nubix ${VERSION} Corresponding Source - MANIFEST"
echo "# generated $(date -u +%Y-%m-%dT%H:%M:%SZ) by scripts/make-source-bundle.sh"
echo "# columns: sha256 size-bytes path component version origin"
for row in "${MANIFEST_ROWS[@]}"; do
IFS='|' read -r p comp ver origin <<<"${row}"
printf '%s %s %s %s %s %s\n' "$(sha256 "${STAGE}/${p}")" "$(fsize "${STAGE}/${p}")" "${p}" "${comp}" "${ver}" "${origin}"
done
for p in SOURCE.txt LICENSE README.txt; do
[[ -f "${STAGE}/${p}" ]] || continue
printf '%s %s %s %s %s %s\n' "$(sha256 "${STAGE}/${p}")" "$(fsize "${STAGE}/${p}")" "${p}" "nubix" "${VERSION}" "documentation"
done
} >"${STAGE}/MANIFEST"
(cd "${STAGE}" && find . -type f ! -name SHA256SUMS ! -name MANIFEST | sed 's|^\./||' | LC_ALL=C sort \
| while IFS= read -r p; do printf '%s %s\n' "$(sha256 "$p")" "$p"; done) >"${STAGE}/SHA256SUMS"
log "writing ${OUT}"
rm -f "${OUT}.part"
(cd "${WORK}" && find "${NAME}" -print0 | LC_ALL=C sort -z \
| tar -cf - "${TAR_NORM[@]}" --no-recursion --null -T -) | xz -6 -T0 -c >"${OUT}.part"
mv "${OUT}.part" "${OUT}"
cp "${STAGE}/MANIFEST" "${DIST}/${NAME}.MANIFEST"
(cd "${DIST}" && printf '%s %s\n' "$(sha256 "${NAME}.tar.xz")" "${NAME}.tar.xz" >"${NAME}.tar.xz.sha256")
rm -rf "${WORK}"
log "done: ${OUT} ($(fsize "${OUT}") bytes)"
cat "${DIST}/${NAME}.MANIFEST"
+646
View File
@@ -0,0 +1,646 @@
// Nubix — Microsoft account + Xbox Live + gssv authentication.
// Flow and request shapes mirror green-nx src/core/auth.cpp (GPL-3.0) and xal-node src/msal.ts:
// MSAL v2 device code (consumers tenant, xbox.com web client id) -> XBL user token (RPS "d=")
// -> XSTS (http://gssv.xboxlive.com/ and http://xboxlive.com) -> /v2/login/user per offering,
// and the login.live.com transfer token (LPT) for xCloud /connect.
// Portions derived from green-nx (https://github.com/rmrf404/green-nx), Copyright (C) the green-nx authors, GPL-3.0; modified by Nubix contributors, 2026.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include "core/auth.hpp"
#include <algorithm>
#include <climits>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <ctime>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
#include "core/http.hpp"
#include "core/http_internal.hpp"
#include "core/log.hpp"
using nlohmann::json;
namespace xc {
namespace {
// Public client id of the xbox.com web app (Azure AD v2, consumers), used by green-nx,
// greenlight/xal-node and XStreaming. The same id is used for the login.live.com LPT request.
constexpr const char* kClientId = "1f907974-e22b-4810-a9de-d9647380c97e";
constexpr const char* kScope = "xboxlive.signin openid profile offline_access";
constexpr const char* kDeviceCodeUrl = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode";
constexpr const char* kTokenUrl = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
constexpr const char* kUserAuthUrl = "https://user.auth.xboxlive.com/user/authenticate";
constexpr const char* kXstsUrl = "https://xsts.auth.xboxlive.com/xsts/authorize";
constexpr const char* kLptUrl = "https://login.live.com/oauth20_token.srf";
constexpr const char* kLptScope = "service::http://Passport.NET/purpose::PURPOSE_XBOX_CLOUD_CONSOLE_TRANSFER_TOKEN";
constexpr const char* kProfileUrl =
"https://profile.xboxlive.com/users/me/profile/settings?settings=Gamertag";
constexpr const char* kBrowserUa =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 "
"Safari/537.36";
const std::vector<std::string> kFormHeaders = {
"Content-Type: application/x-www-form-urlencoded",
"Accept: application/json",
};
const std::vector<std::string> kFormNoCacheHeaders = {
"Content-Type: application/x-www-form-urlencoded",
"Accept: application/json",
"Cache-Control: no-store, must-revalidate, no-cache",
};
// xal-node msal.ts doXstsAuthorization headers (also fine for user/authenticate).
const std::vector<std::string> kXblHeaders = {
"Content-Type: application/json",
"Accept: */*",
"x-xbl-contract-version: 1",
"Cache-Control: no-cache",
"Origin: https://www.xbox.com",
"Referer: https://www.xbox.com/",
"ms-cv: 0",
std::string("User-Agent: ") + kBrowserUa,
};
int64_t now() { return static_cast<int64_t>(std::time(nullptr)); }
json parseJson(const std::string& body) {
if (body.empty()) return json();
return json::parse(body, nullptr, /*allow_exceptions=*/false);
}
std::string jStr(const json& j, const char* key) {
if (!j.is_object()) return std::string();
auto it = j.find(key);
return (it != j.end() && it->is_string()) ? it->get<std::string>() : std::string();
}
int64_t jInt(const json& j, const char* key, int64_t def) {
if (!j.is_object()) return def;
auto it = j.find(key);
if (it == j.end()) return def;
if (it->is_number_integer()) return it->get<int64_t>();
if (it->is_number()) return static_cast<int64_t>(it->get<double>());
if (it->is_string()) {
const std::string s = it->get<std::string>();
char* end = nullptr;
long long v = std::strtoll(s.c_str(), &end, 10);
if (end && end != s.c_str()) return v;
}
return def;
}
std::string firstLine(std::string s, size_t maxLen = 200) {
const size_t nl = s.find_first_of("\r\n");
if (nl != std::string::npos) s.resize(nl);
if (s.size() > maxLen) s = s.substr(0, maxLen) + "…";
return s;
}
// Human-readable reason for a failed HTTP call; never includes request bodies (tokens).
std::string describe(const HttpResponse& r, const std::string& label) {
if (!r.error.empty()) return label + ": network error: " + r.error;
const json j = parseJson(r.body);
std::string detail = jStr(j, "error_description");
if (detail.empty()) detail = jStr(j, "message");
if (detail.empty()) detail = jStr(j, "code");
if (detail.empty()) detail = jStr(j, "error");
if (detail.empty() && !r.body.empty() && !j.is_object()) detail = r.body;
std::string out = label + " failed (HTTP " + std::to_string(r.status) + ")";
if (!detail.empty()) out += ": " + firstLine(detail);
return out;
}
// ISO 8601 UTC ("2026-10-05T10:00:00.1234567Z") -> unix seconds; 0 if unparsable.
int64_t parseIso8601(const std::string& s) {
int y = 0, mo = 0, d = 0, h = 0, mi = 0, se = 0;
if (std::sscanf(s.c_str(), "%d-%d-%dT%d:%d:%d", &y, &mo, &d, &h, &mi, &se) != 6) return 0;
if (mo < 1 || mo > 12 || d < 1 || d > 31) return 0;
// days_from_civil (Howard Hinnant), avoids timegm() availability questions on the PS5 libc.
y -= mo <= 2 ? 1 : 0;
const int era = (y >= 0 ? y : y - 399) / 400;
const unsigned yoe = static_cast<unsigned>(y - era * 400);
const unsigned doy = static_cast<unsigned>((153 * (mo + (mo > 2 ? -3 : 9)) + 2) / 5 + d - 1);
const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
const int64_t days = static_cast<int64_t>(era) * 146097 + static_cast<int64_t>(doe) - 719468;
return days * 86400 + h * 3600 + mi * 60 + se;
}
// XSTS 401 "XErr" codes (xbox-webapi / xal docs) -> actionable text.
std::string xstsErrorText(const HttpResponse& r, const std::string& label) {
const json j = parseJson(r.body);
const int64_t xerr = jInt(j, "XErr", 0);
switch (xerr) {
case 2148916227LL: return label + ": this account is banned from Xbox Live";
case 2148916229LL: return label + ": account restricted by parental controls (online play not allowed)";
case 2148916233LL: return label + ": this Microsoft account has no Xbox profile yet - sign in once at xbox.com";
case 2148916234LL: return label + ": the account must accept the Xbox terms of use at xbox.com";
case 2148916235LL: return label + ": Xbox Live is not available in this account's country";
case 2148916236LL:
case 2148916237LL: return label + ": the account requires age verification at login.live.com";
case 2148916238LL: return label + ": child account - an adult must add it to a Microsoft family";
default: break;
}
std::string out = describe(r, label);
if (xerr) out += " (XErr " + std::to_string(xerr) + ")";
return out;
}
struct XboxToken {
std::string token, uhs, gamertag;
int64_t notAfter = 0;
};
bool parseXboxToken(const HttpResponse& r, XboxToken& out) {
const json j = parseJson(r.body);
out.token = jStr(j, "Token");
if (out.token.empty()) return false;
out.notAfter = parseIso8601(jStr(j, "NotAfter"));
auto dc = j.find("DisplayClaims");
if (dc != j.end() && dc->is_object()) {
auto xui = dc->find("xui");
if (xui != dc->end() && xui->is_array() && !xui->empty()) {
out.uhs = jStr(xui->front(), "uhs");
out.gamertag = jStr(xui->front(), "gtg");
}
}
return true;
}
// Outcome of an MSA token-endpoint call.
enum class MsaResult { Ok, Dead, Failed };
void clearXboxTokens(Tokens& t) {
t.xhomeGs.clear();
t.xcloudGs.clear();
t.xcloudF2pGs.clear();
t.xhomeBase.clear();
t.xcloudBase.clear();
t.xcloudF2pBase.clear();
t.xhomeRegions.clear();
t.xcloudRegions.clear();
t.xcloudF2pRegions.clear();
t.gsExpiry = 0;
t.uhs.clear();
t.xblToken.clear();
t.xblExpiry = 0;
t.market.clear();
}
// Store an MSA token-endpoint response (device code success or refresh). Keeps the old
// refresh token if the response did not rotate it.
bool storeMsaTokens(Tokens& t, const json& j) {
const std::string access = jStr(j, "access_token");
if (access.empty()) return false;
t.msaAccess = access;
const std::string rt = jStr(j, "refresh_token");
if (!rt.empty()) t.msaRefresh = rt;
t.msaExpiry = now() + jInt(j, "expires_in", 3600);
return true;
}
// t is the caller's working copy of the tokens; it is updated and the MSA fields are committed
// to cfg (and persisted). Callers hold Auth::mu_, the only writer of tokens.
MsaResult refreshMsa(Config& cfg, Tokens& t, std::string& err) {
if (t.msaRefresh.empty()) {
err = "not signed in";
return MsaResult::Dead;
}
// xal-node msal.ts refreshUserToken / green-nx refresh_user_token.
const std::string body = std::string("client_id=") + kClientId + "&grant_type=refresh_token&refresh_token=" +
Http::urlEncode(t.msaRefresh) + "&scope=" + Http::urlEncode(kScope);
HttpResponse r = Http::request("POST", kTokenUrl, kFormNoCacheHeaders, body);
if (r.ok()) {
if (!storeMsaTokens(t, parseJson(r.body))) {
err = "token refresh: response without access_token";
return MsaResult::Failed;
}
cfg.updateTokens([&t](Tokens& c) {
c.msaAccess = t.msaAccess;
c.msaRefresh = t.msaRefresh;
c.msaExpiry = t.msaExpiry;
});
cfg.save();
XC_LOGI("auth: MSA token refreshed (valid %lld s)", (long long)(t.msaExpiry - now()));
return MsaResult::Ok;
}
if (r.error.empty() && r.status >= 400 && r.status < 500) {
const json j = parseJson(r.body);
const std::string kind = jStr(j, "error");
// invalid_grant: revoked/expired/rotated-away refresh token. interaction_required:
// consent/password change. Either way the stored login is unusable (msal.ts
// TokenRefreshError); forget it so the UI shows the device-code screen again.
if (kind == "invalid_grant" || kind == "interaction_required" || kind == "invalid_client" ||
kind == "unauthorized_client") {
XC_LOGW("auth: refresh token rejected (%s); signing out", kind.c_str());
const std::string gamertag = t.gamertag;
t = Tokens();
cfg.setTokens(t);
cfg.save();
err = "saved login expired; please sign in again" + (gamertag.empty() ? "" : " (" + gamertag + ")");
return MsaResult::Dead;
}
}
err = describe(r, "token refresh");
return MsaResult::Failed;
}
struct OfferingLogin {
std::string gsToken, market;
std::vector<Region> regions;
int64_t duration = 0;
};
// POST https://{offering}.gssv-play-prod.xboxlive.com/v2/login/user (green-nx streaming_login).
bool gssvLogin(const std::string& xsts, const std::string& offering, OfferingLogin& out, std::string& err) {
const json body = {{"token", xsts}, {"offeringId", offering}};
const std::vector<std::string> headers = {
"Content-Type: application/json",
"Accept: application/json",
"Cache-Control: no-store, must-revalidate, no-cache",
"x-gssv-client: XboxComBrowser",
};
HttpResponse r;
{
// 403 OfferingAccessDenied is a normal outcome (no Game Pass / no console).
httpdetail::QuietErrors quiet;
r = Http::request("POST", "https://" + offering + ".gssv-play-prod.xboxlive.com/v2/login/user", headers,
body.dump());
}
if (!r.ok()) {
err = describe(r, offering);
return false;
}
const json j = parseJson(r.body);
out.gsToken = jStr(j, "gsToken");
out.market = jStr(j, "market");
out.duration = jInt(j, "durationInSeconds", 14400);
out.regions.clear();
auto os = j.find("offeringSettings");
if (j.is_object() && os != j.end() && os->is_object()) {
auto regs = os->find("regions");
if (regs != os->end() && regs->is_array()) {
for (const auto& e : *regs) {
if (!e.is_object()) continue;
Region reg;
reg.name = jStr(e, "name");
reg.baseUri = jStr(e, "baseUri");
auto def = e.find("isDefault");
reg.isDefault = def != e.end() && def->is_boolean() && def->get<bool>();
while (!reg.baseUri.empty() && reg.baseUri.back() == '/') reg.baseUri.pop_back();
if (!reg.baseUri.empty()) out.regions.push_back(std::move(reg));
}
}
}
if (out.gsToken.empty()) {
err = offering + ": response without gsToken";
return false;
}
if (out.regions.empty()) {
err = offering + ": no regions in response";
return false;
}
return true;
}
} // namespace
Auth::Auth(Config& cfg) : cfg_(cfg) {}
bool Auth::requestDeviceCode(DeviceCode& out, std::string& err) {
std::lock_guard<std::recursive_mutex> lk(mu_);
const std::string body = std::string("client_id=") + kClientId + "&scope=" + Http::urlEncode(kScope);
HttpResponse r = Http::request("POST", kDeviceCodeUrl, kFormHeaders, body);
if (!r.ok()) {
err = describe(r, "device code request");
return false;
}
const json j = parseJson(r.body);
out = DeviceCode();
out.userCode = jStr(j, "user_code");
out.deviceCode = jStr(j, "device_code");
out.verificationUri = jStr(j, "verification_uri");
out.message = jStr(j, "message");
out.interval = static_cast<int>(std::max<int64_t>(1, std::min<int64_t>(60, jInt(j, "interval", 5))));
out.expiresIn = static_cast<int>(std::max<int64_t>(30, std::min<int64_t>(3600, jInt(j, "expires_in", 900))));
if (out.userCode.empty() || out.deviceCode.empty()) {
err = "device code request: malformed response";
return false;
}
if (out.verificationUri.empty()) out.verificationUri = "https://www.microsoft.com/link";
// The consumers endpoint has no verification_uri_complete; build the documented ?otc= form
// so a phone scanning the QR lands on the code screen pre-filled (green-nx).
out.qrUrl = jStr(j, "verification_uri_complete");
if (out.qrUrl.empty()) {
std::string bare;
for (char c : out.userCode)
if (c != '-' && c != ' ') bare += c;
out.qrUrl = out.verificationUri + (out.verificationUri.find('?') == std::string::npos ? "?" : "&") +
"otc=" + Http::urlEncode(bare);
}
XC_LOGI("auth: device code %s at %s (expires in %d s, poll every %d s)", out.userCode.c_str(),
out.verificationUri.c_str(), out.expiresIn, out.interval);
return true;
}
PollResult Auth::pollToken(const DeviceCode& dc, std::string& err) {
std::lock_guard<std::recursive_mutex> lk(mu_);
const std::string body = std::string("grant_type=") + Http::urlEncode("urn:ietf:params:oauth:grant-type:device_code") +
"&client_id=" + kClientId + "&device_code=" + Http::urlEncode(dc.deviceCode);
HttpResponse r;
{
httpdetail::QuietErrors quiet; // 400 authorization_pending every interval is expected
r = Http::request("POST", kTokenUrl, kFormHeaders, body);
}
if (!r.error.empty() || r.status >= 500 || r.status == 429) {
// Transient (network blip, server hiccup): keep polling until the code expires.
err = describe(r, "device code poll");
XC_LOGW("auth: %s (will retry)", err.c_str());
return PollResult::Pending;
}
const json j = parseJson(r.body);
if (r.ok()) {
Tokens t = cfg_.tokens();
if (!storeMsaTokens(t, j) || jStr(j, "refresh_token").empty()) {
err = "device code poll: response without access/refresh token";
return PollResult::Error;
}
// A new login may be a different account: drop all derived tokens and the profile.
clearXboxTokens(t);
t.gamertag.clear();
cfg_.setTokens(t);
cfg_.save();
XC_LOGI("auth: device code login complete");
err.clear();
return PollResult::Success;
}
const std::string kind = jStr(j, "error");
if (kind == "authorization_pending") return PollResult::Pending;
if (kind == "slow_down") {
// RFC 8628: the caller should add 5 s to its interval.
err = "slow_down";
return PollResult::Pending;
}
if (kind == "expired_token" || kind == "code_expired" || kind == "bad_verification_code") {
err = "the sign-in code expired";
return PollResult::Expired;
}
if (kind == "authorization_declined" || kind == "access_denied") {
err = "sign-in was declined";
return PollResult::Denied;
}
err = describe(r, "device code poll");
return PollResult::Error;
}
bool Auth::refresh(std::string& err) {
std::lock_guard<std::recursive_mutex> lk(mu_);
Tokens t = cfg_.tokens();
return refreshMsa(cfg_, t, err) == MsaResult::Ok;
}
bool Auth::authorizeStreaming(std::string& err) {
std::lock_guard<std::recursive_mutex> lk(mu_);
// Work on a copy and publish it in one step on every exit (the UI thread reads the token
// lists while this runs). Auth::mu_ makes this the only writer meanwhile.
Tokens t = cfg_.tokens();
const Settings settings = cfg_.settings();
struct Commit {
Config& cfg;
const Tokens& t;
~Commit() { cfg.setTokens(t); }
} commit{cfg_, t};
if (t.msaRefresh.empty()) {
err = "not signed in";
return false;
}
bool refreshed = false;
if (t.msaAccess.empty() || t.msaExpiry - now() < 120) {
if (refreshMsa(cfg_, t, err) != MsaResult::Ok) return false;
refreshed = true;
}
// 1. XBL user token (XASU). "d=" prefix for MSAL v2 access tokens.
XboxToken user;
for (int attempt = 0; attempt < 2; ++attempt) {
const json body = {
{"Properties",
{{"AuthMethod", "RPS"}, {"RpsTicket", "d=" + t.msaAccess}, {"SiteName", "user.auth.xboxlive.com"}}},
{"RelyingParty", "http://auth.xboxlive.com"},
{"TokenType", "JWT"},
};
HttpResponse r = Http::request("POST", kUserAuthUrl, kXblHeaders, body.dump());
if (r.ok() && parseXboxToken(r, user)) break;
// A rejected RPS ticket usually means the cached MSA access token went stale: refresh once.
if (!refreshed && r.error.empty() && (r.status == 400 || r.status == 401 || r.status == 403)) {
XC_LOGW("auth: XBL user token rejected (HTTP %ld); refreshing MSA token", r.status);
if (refreshMsa(cfg_, t, err) != MsaResult::Ok) return false;
refreshed = true;
continue;
}
err = r.ok() ? "Xbox Live user authentication: response without Token" : describe(r, "Xbox Live user authentication");
return false;
}
// 2. XSTS for gssv (trailing slash matters).
auto xsts = [&](const char* rp, XboxToken& out, std::string& e) {
const json body = {
{"Properties", {{"SandboxId", "RETAIL"}, {"UserTokens", {user.token}}}},
{"RelyingParty", rp},
{"TokenType", "JWT"},
};
HttpResponse r = Http::request("POST", kXstsUrl, kXblHeaders, body.dump());
if (r.ok() && parseXboxToken(r, out)) return true;
e = r.ok() ? std::string("XSTS ") + rp + ": response without Token" : xstsErrorText(r, std::string("XSTS ") + rp);
return false;
};
XboxToken gssvXsts;
if (!xsts("http://gssv.xboxlive.com/", gssvXsts, err)) return false;
// 3. XSTS for http://xboxlive.com: web token for profile/smartglass/titlehub (optional).
XboxToken web;
std::string webErr;
if (xsts("http://xboxlive.com", web, webErr)) {
t.xblToken = web.token;
t.uhs = !web.uhs.empty() ? web.uhs : gssvXsts.uhs;
t.xblExpiry = web.notAfter;
} else {
XC_LOGW("auth: %s (profile/console features may be limited)", webErr.c_str());
t.xblToken.clear();
t.xblExpiry = 0;
t.uhs = gssvXsts.uhs;
}
// 4. Gamertag via profile (optional; green-nx fetch_profile).
if (!t.xblToken.empty() && !t.uhs.empty()) {
HttpResponse r = Http::request(
"GET", kProfileUrl,
{"x-xbl-contract-version: 3", "Accept: application/json", "Accept-Language: " + settings.locale,
"Authorization: XBL3.0 x=" + t.uhs + ";" + t.xblToken});
std::string gamertag;
if (r.ok()) {
const json j = parseJson(r.body);
auto users = j.is_object() ? j.find("profileUsers") : j.end();
if (j.is_object() && users != j.end() && users->is_array()) {
for (const auto& u : *users) {
auto settings = u.is_object() ? u.find("settings") : u.end();
if (!u.is_object() || settings == u.end() || !settings->is_array()) continue;
for (const auto& s : *settings)
if (jStr(s, "id") == "Gamertag") gamertag = jStr(s, "value");
}
}
}
if (gamertag.empty()) gamertag = !web.gamertag.empty() ? web.gamertag : gssvXsts.gamertag;
if (!gamertag.empty()) t.gamertag = gamertag;
else XC_LOGW("auth: could not read gamertag");
}
// 5. gsToken per offering, each optional (green-nx fetch_streaming_credentials; xgpuweb is
// optional here because F2P-only and xHome-only accounts are valid users).
struct Offering {
const char* id;
std::string* gs;
std::string* base;
std::vector<Region>* regions;
bool cloud;
};
const Offering offerings[] = {
{"xhome", &t.xhomeGs, &t.xhomeBase, &t.xhomeRegions, false},
{"xgpuweb", &t.xcloudGs, &t.xcloudBase, &t.xcloudRegions, true},
{"xgpuwebf2p", &t.xcloudF2pGs, &t.xcloudF2pBase, &t.xcloudF2pRegions, true},
};
int64_t minDuration = INT64_MAX;
int got = 0;
std::string reasons;
for (const auto& o : offerings) {
OfferingLogin login;
std::string e;
if (gssvLogin(gssvXsts.token, o.id, login, e)) {
*o.gs = login.gsToken;
*o.regions = login.regions;
*o.base = pickRegionBase(login.regions, o.cloud ? settings.region : std::string());
if (!login.market.empty()) t.market = login.market;
minDuration = std::min(minDuration, login.duration);
++got;
XC_LOGI("auth: %s ok, %zu regions, using %s", o.id, login.regions.size(), o.base->c_str());
} else {
o.gs->clear();
o.base->clear();
o.regions->clear();
XC_LOGI("auth: %s unavailable: %s", o.id, e.c_str());
if (!reasons.empty()) reasons += "; ";
reasons += e;
}
}
if (got == 0) {
t.gsExpiry = 0;
cfg_.setTokens(t);
cfg_.save();
err = "no streaming access (xhome, xgpuweb, xgpuwebf2p all refused): " + reasons;
return false;
}
t.gsExpiry = now() + minDuration;
cfg_.setTokens(t);
cfg_.save();
XC_LOGI("auth: streaming authorized for %s (market %s, gs valid %lld s)", t.gamertag.c_str(), t.market.c_str(),
(long long)minDuration);
err.clear();
return true;
}
bool Auth::fetchLpt(std::string& outLpt, std::string& err) {
std::lock_guard<std::recursive_mutex> lk(mu_);
outLpt.clear();
// green-nx fetch_passport_token refreshes first, so the freshest refresh token is used (and the
// rotated one persisted). A transient refresh failure is not fatal: the stored one still works.
std::string rerr;
Tokens t = cfg_.tokens();
if (refreshMsa(cfg_, t, rerr) == MsaResult::Dead) {
err = rerr;
return false;
}
if (t.msaRefresh.empty()) {
err = "not signed in";
return false;
}
const std::string body = std::string("client_id=") + kClientId + "&scope=" + Http::urlEncode(kLptScope) +
"&grant_type=refresh_token&refresh_token=" + Http::urlEncode(t.msaRefresh);
HttpResponse r = Http::request("POST", kLptUrl, kFormHeaders, body);
if (!r.ok()) {
err = describe(r, "transfer token (LPT)");
return false;
}
outLpt = jStr(parseJson(r.body), "access_token");
if (outLpt.empty()) {
err = "transfer token (LPT): response without access_token";
return false;
}
// The response also carries a login.live.com refresh token for this purpose scope; it is not a
// replacement for the MSAL v2 refresh token, so it is deliberately not stored (xal-node, green-nx).
XC_LOGI("auth: transfer token obtained");
return true;
}
bool Auth::isLoggedIn() const {
// No Auth::mu_ here: the UI asks this while a long refresh may hold it.
return !cfg_.tokens().msaRefresh.empty();
}
void Auth::logout() {
std::lock_guard<std::recursive_mutex> lk(mu_);
cfg_.setTokens(Tokens());
cfg_.save();
XC_LOGI("auth: signed out");
}
void Auth::invalidateStreaming() {
// Deliberately no Auth::mu_: a session thread reports the rejection while another thread may
// be inside a long re-authorization (which then publishes a fresh expiry anyway).
cfg_.updateTokens([](Tokens& t) { t.gsExpiry = 0; });
XC_LOGW("auth: streaming token rejected by the server; it will be re-authorized");
}
bool Auth::ensureFresh(std::string& err, int marginSec) {
std::lock_guard<std::recursive_mutex> lk(mu_);
Tokens t = cfg_.tokens();
if (t.msaRefresh.empty()) {
err = "not signed in";
return false;
}
const int64_t n = now();
const bool noGs = t.xhomeGs.empty() && t.xcloudGs.empty() && t.xcloudF2pGs.empty();
// gsExpiry <= 0: never authorized, or invalidateStreaming() after a server rejection.
if (noGs || t.gsExpiry <= 0 || t.gsExpiry - n < marginSec) {
// authorizeStreaming refreshes the MSA token itself when needed.
if (t.msaExpiry - n < marginSec) {
if (refreshMsa(cfg_, t, err) != MsaResult::Ok) return false;
}
return authorizeStreaming(err);
}
if (t.msaAccess.empty() || t.msaExpiry - n < marginSec) {
if (refreshMsa(cfg_, t, err) != MsaResult::Ok) return false;
}
err.clear();
return true;
}
void Auth::applyRegion() {
std::lock_guard<std::recursive_mutex> lk(mu_);
const std::string region = cfg_.settings().region;
std::string cloudBase;
cfg_.updateTokens([&](Tokens& t) {
t.xcloudBase = pickRegionBase(t.xcloudRegions, region);
t.xcloudF2pBase = pickRegionBase(t.xcloudF2pRegions, region);
t.xhomeBase = pickRegionBase(t.xhomeRegions, std::string());
cloudBase = t.xcloudBase;
});
cfg_.save();
XC_LOGI("auth: region '%s' -> cloud %s", region.c_str(), cloudBase.c_str());
}
} // namespace xc
+79
View File
@@ -0,0 +1,79 @@
// Nubix — Microsoft account + Xbox Live + gssv authentication.
// Flow: MSAL device code (client 1f907974-…) -> XBL user token -> XSTS (gssv relying party)
// -> /v2/login/user gsToken per offering (xhome, xgpuweb, xgpuwebf2p) + LPT for xCloud /connect.
// Flow as implemented by green-nx (src/core/auth.cpp) and xal-node (src/msal.ts).
// Portions derived from green-nx (https://github.com/rmrf404/green-nx), Copyright (C) the green-nx authors, GPL-3.0; modified by Nubix contributors, 2026.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <mutex>
#include <string>
#include "core/config.hpp"
namespace xc {
struct DeviceCode {
std::string userCode; // short code the user types at verificationUri
std::string deviceCode; // opaque code used for polling
std::string verificationUri; // e.g. https://www.microsoft.com/link
std::string qrUrl; // verificationUri with ?otc=<userCode> for the QR code
int interval = 5; // poll interval in seconds
int expiresIn = 900; // lifetime in seconds
std::string message; // server's human-readable instruction text (English)
};
enum class PollResult { Pending, Success, Expired, Denied, Error };
class Auth {
public:
// Tokens are read from and written to cfg (snapshots / locked updates); cfg.save() is
// called on changes.
explicit Auth(Config& cfg);
// Start device-code login. Returns false and sets err on failure.
bool requestDeviceCode(DeviceCode& out, std::string& err);
// Poll the token endpoint once; the caller sleeps dc.interval between calls.
// On Success the MSA tokens are stored in the config.
PollResult pollToken(const DeviceCode& dc, std::string& err);
// Refresh the MSA access token with the stored refresh token (persists the new refresh token).
bool refresh(std::string& err);
// XBL user -> XSTS(gssv) -> gsToken for xhome, xgpuweb, xgpuwebf2p (each optional), plus
// gamertag/uhs. Succeeds if at least one offering returned a token.
bool authorizeStreaming(std::string& err);
// Fetch the passport long-lived token (LPT) needed by xCloud POST /connect.
bool fetchLpt(std::string& outLpt, std::string& err);
// True if a refresh token is stored. Never blocks on a running refresh.
bool isLoggedIn() const;
// A gssv/catalog request was rejected with an auth error (401/403) although the stored
// gsExpiry says the tokens are valid (revocation, entitlement change, wall clock jump):
// mark the streaming tokens expired so the next ensureFresh() re-authorizes.
void invalidateStreaming();
// Forget all tokens and persist.
void logout();
// Ensure MSA + gs tokens are valid for at least marginSec (refresh / re-authorize as needed).
bool ensureFresh(std::string& err, int marginSec = 300);
// Re-select xcloudBase / xcloudF2pBase from the stored regions after Settings::region changed
// (no network), and persist. xhomeBase always uses the server default region.
void applyRegion();
// All public methods (except isLoggedIn / invalidateStreaming) are serialised by an internal
// mutex, so the UI thread and the streamer thread may share one Auth. Config is itself
// thread-safe (snapshots), so token readers never race these writers.
private:
Config& cfg_;
mutable std::recursive_mutex mu_;
};
} // namespace xc
+625
View File
@@ -0,0 +1,625 @@
// Nubix — cloud title library and xHome console list.
// Ported from green-nx src/core/catalog.cpp (GPL-3.0, (c) green-nx authors).
// Portions derived from green-nx (https://github.com/rmrf404/green-nx), Copyright (C) the green-nx authors, GPL-3.0; modified by Nubix contributors, 2026.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include "core/catalog.hpp"
#include <dirent.h>
#include <sys/stat.h>
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <ctime>
#include <atomic>
#include <cctype>
#include <exception>
#include <map>
#include <mutex>
#include <thread>
#include <unordered_map>
#include <nlohmann/json.hpp>
#include "core/gssv.hpp"
#include "core/http.hpp"
#include "core/log.hpp"
using nlohmann::json;
namespace xc {
namespace {
// displaycatalog accepts comma-separated bigIds; the Details template runs to ~53 KB per
// product, so 20 already means a ~1 MB response to hold in memory (green-nx).
constexpr size_t kHydrateBatch = 20;
constexpr int kHydrateWorkers = 4;
// One /v2/titles entry with the full green-nx access breakdown.
struct Entry {
Title t;
bool subscription = false; // offered under some Game Pass tier
bool inSubscription = false; // ... a tier this account actually holds
bool adSupported = false; // F2P program, no subscription needed
bool adGranted = false; // userPrograms holds F2P for this title
bool adPlayable = false; // the xgpuwebf2p offering will stream it
};
std::string fold(std::string id) {
for (char& c : id) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return id;
}
std::string lower(std::string s) {
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
std::string str(const json& j, const char* key) {
if (!j.is_object()) return {};
auto it = j.find(key);
return it != j.end() && it->is_string() ? it->get<std::string>() : std::string();
}
std::string snippet(const std::string& body) {
std::string s = body.substr(0, 300);
for (char& c : s)
if (c == '\r' || c == '\n') c = ' ';
return s;
}
std::string httpFailure(const char* label, const HttpResponse& r) {
if (!r.error.empty()) return std::string(label) + ": " + r.error;
return std::string(label) + ": HTTP " + std::to_string(r.status) + (r.body.empty() ? "" : ": " + snippet(r.body));
}
// GET {base}/v2/titles with the given offering token (green-nx fetch_catalog).
bool fetchOffering(const std::string& base, const std::string& token, const Settings& settings,
std::vector<Entry>& out, std::string& err, bool& authRejected) {
out.clear();
std::string url = base;
while (!url.empty() && url.back() == '/') url.pop_back();
url += "/v2/titles";
HttpResponse r = Http::request("GET", url, gssv::requestHeaders(token, settings.resolution), "", 30);
if (!r.ok()) {
authRejected = r.error.empty() && gssv::isAuthRejection(r.status, r.body);
err = httpFailure("titles", r);
return false;
}
json parsed = json::parse(r.body, nullptr, false);
if (parsed.is_discarded()) {
err = "titles: invalid JSON";
return false;
}
const json* results = nullptr;
if (parsed.is_array()) results = &parsed;
else if (parsed.is_object() && parsed.contains("results") && parsed["results"].is_array())
results = &parsed["results"];
if (!results) {
err = "titles: unexpected response shape";
return false;
}
// Diagnostics: which program / subscription codenames this account and catalog use, so
// filter problems ("Game Pass shows nothing") can be read straight from the log.
std::map<std::string, int> programCounts, subCounts, userProgramCounts;
int entitled = 0, entitledWithSubProgram = 0;
for (const json& item : *results) {
Entry e;
e.t.titleId = str(item, "titleId");
if (e.t.titleId.empty()) continue;
const json details = item.is_object() ? item.value("details", json::object()) : json::object();
if (!details.is_object()) continue;
e.t.productId = str(details, "productId");
auto ent = details.find("hasEntitlement");
e.t.hasEntitlement = ent != details.end() && ent->is_boolean() && ent->get<bool>();
for (const char* key : {"maxSessionLengthInSeconds", "maxGameplayTimeInSeconds"}) {
auto it = details.find(key);
if (it != details.end() && it->is_number()) {
e.t.maxSessionSeconds = static_cast<int>(it->get<double>());
break;
}
}
// "programs" lists what the title is offered under, "userSubscriptions" what the account
// holds; their intersection is Game Pass access. Subscription programs are backend
// codenames that change over time, so anything not a known non-subscription program
// counts as one (green-nx).
const json programs = details.value("programs", json::array());
const json subs = details.value("userSubscriptions", json::array());
if (programs.is_array()) {
for (const json& program : programs) {
if (!program.is_string()) continue;
const std::string name = program.get<std::string>();
if (name == "F2P") e.adSupported = true;
else if (name != "BYOG" && name != "EARLYACCESS") e.subscription = true;
if (subs.is_array())
for (const json& sub : subs)
if (program == sub) e.inSubscription = true;
}
}
// "userPrograms" = the program(s) through which THIS account may play the title. F2P means
// ad-supported; any other non-purchase program means the subscription covers it. This is
// the reliable signal: the account's own subscription code (e.g. XGPCORE for Game Pass
// Essential) need not appear in "programs" at all (observed on an Essential account:
// userSubscriptions {XGPCORE}, userPrograms {EUROPA, F2P}).
const json userPrograms = details.value("userPrograms", json::array());
if (userPrograms.is_array())
for (const json& p : userPrograms) {
if (!p.is_string()) continue;
const std::string name = p.get<std::string>();
if (name == "F2P") e.adGranted = true;
else if (name != "BYOG" && name != "EARLYACCESS") e.inSubscription = true;
}
for (const json* arr : {&programs, &subs, &userPrograms}) {
if (!arr->is_array()) continue;
auto& counts = arr == &programs ? programCounts : arr == &subs ? subCounts : userProgramCounts;
for (const json& v : *arr)
if (v.is_string()) ++counts[v.get<std::string>()];
}
if (e.t.hasEntitlement) {
++entitled;
if (e.subscription) ++entitledWithSubProgram;
}
out.push_back(std::move(e));
}
auto fmt = [](const std::map<std::string, int>& m) {
std::string r;
for (const auto& kv : m) r += (r.empty() ? "" : ", ") + kv.first + "=" + std::to_string(kv.second);
return r.empty() ? std::string("(none)") : r;
};
XC_LOGI("catalog: %s: %zu titles, programs {%s}", base.c_str(), out.size(), fmt(programCounts).c_str());
XC_LOGI("catalog: %s: userSubscriptions {%s}, userPrograms {%s}", base.c_str(), fmt(subCounts).c_str(),
fmt(userProgramCounts).c_str());
XC_LOGI("catalog: %s: %d entitled (%d of them also in a subscription program)", base.c_str(), entitled,
entitledWithSubProgram);
std::sort(out.begin(), out.end(), [](const Entry& a, const Entry& b) { return a.t.titleId < b.t.titleId; });
out.erase(std::unique(out.begin(), out.end(),
[](const Entry& a, const Entry& b) { return a.t.titleId == b.t.titleId; }),
out.end());
return true;
}
// Fold the ad-supported offering's catalog into the cloud one (green-nx merge_ad_supported).
void mergeAdSupported(std::vector<Entry>& catalog, const std::vector<Entry>& ads) {
std::unordered_map<std::string, size_t> byTitle;
for (size_t i = 0; i < catalog.size(); ++i) byTitle[catalog[i].t.titleId] = i;
std::vector<Entry> extras;
for (const Entry& ad : ads) {
// Everything the offering returns is ad-supported by definition, but only what the
// account is entitled to there will actually start.
if (!ad.t.hasEntitlement && !ad.inSubscription && !ad.adGranted) continue;
auto found = byTitle.find(ad.t.titleId);
if (found == byTitle.end()) {
Entry e = ad;
e.adSupported = true;
e.adPlayable = true;
// The offering only answers for itself; ownership / plan coverage is the cloud
// catalog's business, and it does not list this title at all.
e.t.hasEntitlement = false;
e.inSubscription = false;
extras.push_back(std::move(e));
continue;
}
Entry& e = catalog[found->second];
e.adSupported = true;
e.adPlayable = true;
e.adGranted = ad.adGranted;
if (ad.t.maxSessionSeconds > 0) e.t.maxSessionSeconds = ad.t.maxSessionSeconds;
if (e.t.productId.empty()) e.t.productId = ad.t.productId;
}
catalog.insert(catalog.end(), std::make_move_iterator(extras.begin()), std::make_move_iterator(extras.end()));
}
std::string marketFromLocale(const std::string& locale) {
size_t dash = locale.find_first_of("-_");
if (dash == std::string::npos || dash + 1 >= locale.size()) return "US";
return fold(locale.substr(dash + 1));
}
std::string absoluteImageUrl(const std::string& uri) {
if (uri.empty()) return uri;
if (uri.rfind("//", 0) == 0) return "https:" + uri;
if (uri.rfind("http://", 0) == 0) return "https://" + uri.substr(7);
return uri;
}
// ---- disk caches (title list, box art) ---------------------------------------------------------
// Bump to invalidate caches written by older builds (2: catalog diagnostics in the log,
// 3: Game Pass flag from userPrograms).
constexpr int kTitleCacheVersion = 3;
std::mutex g_cacheMu;
std::string g_cacheDir;
std::string cacheDir() {
std::lock_guard<std::mutex> lk(g_cacheMu);
return g_cacheDir;
}
std::string fnv1aHex(const std::string& s) {
uint64_t h = 1469598103934665603ULL;
for (unsigned char c : s) {
h ^= c;
h *= 1099511628211ULL;
}
char buf[17];
std::snprintf(buf, sizeof buf, "%016llx", static_cast<unsigned long long>(h));
return buf;
}
void mkdirs(const std::string& path) {
for (size_t i = 1; i <= path.size(); ++i)
if (i == path.size() || path[i] == '/') ::mkdir(path.substr(0, i).c_str(), 0755);
}
bool readFile(const std::string& path, std::vector<uint8_t>& out) {
FILE* f = std::fopen(path.c_str(), "rb");
if (!f) return false;
uint8_t buf[16384];
size_t n;
while ((n = std::fread(buf, 1, sizeof buf, f)) > 0) out.insert(out.end(), buf, buf + n);
const bool ok = !std::ferror(f);
std::fclose(f);
return ok;
}
bool writeFileAtomic(const std::string& path, const std::string& data) {
const size_t slash = path.rfind('/');
if (slash != std::string::npos) mkdirs(path.substr(0, slash));
const std::string tmp = path + ".tmp" + std::to_string(reinterpret_cast<uintptr_t>(&data) & 0xffff);
FILE* f = std::fopen(tmp.c_str(), "wb");
if (!f) return false;
const bool ok = std::fwrite(data.data(), 1, data.size(), f) == data.size();
if (std::fclose(f) != 0 || !ok || std::rename(tmp.c_str(), path.c_str()) != 0) {
std::remove(tmp.c_str());
return false;
}
return true;
}
// Keep the box-art cache bounded (~600 posters ≈ 30 MB): wipe it when it grows past that.
void pruneImageCache(const std::string& dir) {
static std::atomic<int> sinceCheck{1000};
if (++sinceCheck < 50) return;
sinceCheck = 0;
DIR* d = ::opendir(dir.c_str());
if (!d) return;
std::vector<std::string> files;
while (dirent* e = ::readdir(d))
if (e->d_name[0] != '.') files.push_back(dir + "/" + e->d_name);
::closedir(d);
if (files.size() <= 600) return;
XC_LOGI("catalog: pruning box-art cache (%zu files)", files.size());
for (const auto& f : files) std::remove(f.c_str());
}
std::string titleCachePath(const Tokens& tk, const Settings& st) {
const std::string dir = cacheDir();
if (dir.empty()) return "";
const std::string account = !tk.uhs.empty() ? tk.uhs : tk.gamertag;
if (account.empty()) return "";
return dir + "/titles-" +
fnv1aHex(account + "|" + tk.xcloudBase + "|" + tk.xcloudF2pBase + "|" + st.locale) + ".json";
}
bool loadTitleCache(const std::string& path, long maxAgeSec, std::vector<Title>& out) {
std::vector<uint8_t> raw;
if (!readFile(path, raw) || raw.empty()) return false;
json j = json::parse(raw.begin(), raw.end(), nullptr, false);
if (j.is_discarded() || !j.is_object() || !j.contains("titles") || !j["titles"].is_array()) return false;
if (j.value("version", 0) != kTitleCacheVersion) return false; // older format: refetch
const int64_t savedAt = j.value("savedAt", int64_t(0));
const int64_t now = static_cast<int64_t>(std::time(nullptr));
if (maxAgeSec >= 0 && (savedAt > now || now - savedAt > maxAgeSec)) return false;
std::vector<Title> list;
for (const json& e : j["titles"]) {
if (!e.is_object()) continue;
Title t;
t.titleId = str(e, "titleId");
if (t.titleId.empty()) continue;
t.productId = str(e, "productId");
t.name = str(e, "name");
t.publisher = str(e, "publisher");
t.imageUrl = str(e, "imageUrl");
auto flag = [&](const char* k) { return e.contains(k) && e[k].is_boolean() && e[k].get<bool>(); };
t.hasEntitlement = flag("hasEntitlement");
t.f2p = flag("f2p");
t.gamePass = flag("gamePass");
t.f2pOnly = flag("f2pOnly");
if (e.contains("maxSessionSeconds") && e["maxSessionSeconds"].is_number_integer())
t.maxSessionSeconds = e["maxSessionSeconds"].get<int>();
list.push_back(std::move(t));
}
if (list.empty()) return false;
out = std::move(list);
return true;
}
void saveTitleCache(const std::string& path, const std::vector<Title>& titles) {
json arr = json::array();
for (const Title& t : titles)
arr.push_back({{"titleId", t.titleId},
{"productId", t.productId},
{"name", t.name},
{"publisher", t.publisher},
{"imageUrl", t.imageUrl},
{"hasEntitlement", t.hasEntitlement},
{"f2p", t.f2p},
{"gamePass", t.gamePass},
{"f2pOnly", t.f2pOnly},
{"maxSessionSeconds", t.maxSessionSeconds}});
json j = {{"version", kTitleCacheVersion}, {"savedAt", static_cast<int64_t>(std::time(nullptr))}, {"titles", std::move(arr)}};
if (!writeFileAtomic(path, j.dump())) XC_LOGW("catalog: could not write %s", path.c_str());
}
} // namespace
Catalog::Catalog(Config& cfg) : cfg_(cfg) {}
bool Catalog::fetchCloudTitles(std::vector<Title>& out, std::string& err, bool* authRejected, bool forceRefresh) {
out.clear();
if (authRejected) *authRejected = false;
// Snapshots: the UI thread may change settings / Auth may publish new tokens meanwhile.
const Tokens tk = cfg_.tokens();
const Settings st = cfg_.settings();
const std::string cacheFile = titleCachePath(tk, st);
if (!forceRefresh && !cacheFile.empty() && loadTitleCache(cacheFile, kTitleCacheTtlSec, out)) {
XC_LOGI("catalog: %zu titles from cache", out.size());
return true;
}
bool rejected = false;
if (fetchCloudTitlesNetwork(tk, st, out, err, rejected)) {
if (!cacheFile.empty() && !rejected) saveTitleCache(cacheFile, out);
if (authRejected && rejected) *authRejected = true;
return true;
}
if (authRejected) *authRejected = rejected;
if (!rejected && !cacheFile.empty() && loadTitleCache(cacheFile, -1, out)) {
XC_LOGW("catalog: network fetch failed (%s); showing the cached library", err.c_str());
err.clear();
return true;
}
return false;
}
bool Catalog::fetchCloudTitlesNetwork(const Tokens& tk, const Settings& st, std::vector<Title>& out,
std::string& err, bool& authRejectedOut) {
out.clear();
authRejectedOut = false;
bool rejectedLocal = false;
bool* authRejected = &rejectedLocal;
const bool haveCloud = !tk.xcloudGs.empty() && !tk.xcloudBase.empty();
const bool haveF2p = !tk.xcloudF2pGs.empty() && !tk.xcloudF2pBase.empty();
if (!haveCloud && !haveF2p) {
err = "no xCloud offering available for this account";
return false;
}
std::vector<Entry> cloud, ads;
std::string cloudErr, f2pErr;
bool cloudOk = false, f2pOk = false, cloudAuth = false, f2pAuth = false;
// Both offerings in parallel: each /v2/titles is ~2400 entries.
const std::atomic<bool>* abortFlag = Http::threadAbortFlag();
std::thread f2pThread;
if (haveF2p) {
try {
f2pThread = std::thread([&] {
Http::AbortScope abort(abortFlag);
f2pOk = fetchOffering(tk.xcloudF2pBase, tk.xcloudF2pGs, st, ads, f2pErr, f2pAuth);
});
} catch (const std::exception& e) { // thread limit: fetch it on this thread instead
XC_LOGW("catalog: cannot start a fetch thread (%s)", e.what());
f2pOk = fetchOffering(tk.xcloudF2pBase, tk.xcloudF2pGs, st, ads, f2pErr, f2pAuth);
}
}
if (haveCloud) cloudOk = fetchOffering(tk.xcloudBase, tk.xcloudGs, st, cloud, cloudErr, cloudAuth);
if (f2pThread.joinable()) f2pThread.join();
if (haveCloud && !cloudOk) XC_LOGW("catalog: xgpuweb %s", cloudErr.c_str());
if (haveF2p && !f2pOk) XC_LOGW("catalog: xgpuwebf2p %s", f2pErr.c_str());
if (!cloudOk && !f2pOk) {
err = !cloudErr.empty() ? cloudErr : f2pErr;
authRejectedOut = cloudAuth || f2pAuth;
return false;
}
// One offering refused the token while the other answered: still a stale token.
if (authRejected && (cloudAuth || f2pAuth)) *authRejected = true;
XC_LOGI("catalog: %zu cloud + %zu f2p titles", cloud.size(), ads.size());
if (f2pOk) mergeAdSupported(cloud, ads);
// The library lists what this account can actually start (green-nx Game::playable()).
for (Entry& e : cloud) {
const bool playable = e.t.hasEntitlement || e.inSubscription || e.adPlayable;
if (!playable) continue;
e.t.gamePass = e.inSubscription;
e.t.f2p = e.adPlayable;
e.t.f2pOnly = e.adPlayable && !e.t.hasEntitlement && !e.inSubscription;
out.push_back(std::move(e.t));
}
hydrate(out);
for (Title& t : out)
if (t.name.empty()) t.name = t.titleId;
std::sort(out.begin(), out.end(), [](const Title& a, const Title& b) {
const std::string la = lower(a.name), lb = lower(b.name);
return la != lb ? la < lb : a.titleId < b.titleId;
});
XC_LOGI("catalog: %zu playable titles", out.size());
authRejectedOut = rejectedLocal;
return true;
}
void Catalog::hydrate(std::vector<Title>& titles) {
// displaycatalog echoes ProductId upper-cased whatever case the bigId was sent in, while a
// few titles carry a lower-case productId; fold case on both sides (green-nx).
std::unordered_map<std::string, std::vector<Title*>> byProduct;
for (Title& t : titles)
if (!t.productId.empty() && t.name.empty()) byProduct[fold(t.productId)].push_back(&t);
if (byProduct.empty()) return;
std::vector<std::string> ids;
ids.reserve(byProduct.size());
for (const auto& kv : byProduct) ids.push_back(kv.first);
std::sort(ids.begin(), ids.end());
const Settings settings = cfg_.settings();
const std::string locale = settings.locale.empty() ? std::string("en-US") : settings.locale;
const std::string market = marketFromLocale(locale);
const size_t batches = (ids.size() + kHydrateBatch - 1) / kHydrateBatch;
std::atomic<size_t> next{0};
std::atomic<size_t> failed{0};
std::mutex mu;
// Helper threads inherit the caller's cancellation (App shutdown).
const std::atomic<bool>* abortFlag = Http::threadAbortFlag();
auto worker = [&] {
Http::AbortScope abort(abortFlag);
for (size_t b = next++; b < batches; b = next++) {
std::string bigIds;
for (size_t i = b * kHydrateBatch; i < std::min((b + 1) * kHydrateBatch, ids.size()); ++i) {
if (!bigIds.empty()) bigIds += ",";
bigIds += ids[i];
}
const std::string url = "https://displaycatalog.mp.microsoft.com/v7.0/products?bigIds=" +
bigIds + "&market=" + Http::urlEncode(market) +
"&languages=" + Http::urlEncode(locale) + "&fieldsTemplate=Details";
HttpResponse r = Http::request("GET", url, {"Accept: application/json"}, "", 30);
if (!r.ok()) { // metadata is best-effort
XC_LOGW("%s", httpFailure("displaycatalog", r).c_str());
++failed;
continue;
}
json parsed = json::parse(r.body, nullptr, false);
if (parsed.is_discarded() || !parsed.is_object()) {
++failed;
continue;
}
const json products = parsed.value("Products", json::array());
if (!products.is_array()) continue;
for (const json& product : products) {
const std::string pid = fold(str(product, "ProductId"));
auto found = byProduct.find(pid);
if (found == byProduct.end()) continue;
const json localized = product.value("LocalizedProperties", json::array());
if (!localized.is_array() || localized.empty()) continue;
const json& props = localized.front();
const std::string name = str(props, "ProductTitle");
std::string publisher = str(props, "PublisherName");
if (publisher.empty()) publisher = str(props, "DeveloperName");
std::string poster, boxArt, tile;
const json images = props.value("Images", json::array());
if (images.is_array()) {
for (const json& image : images) {
const std::string purpose = str(image, "ImagePurpose");
if (purpose == "Poster" && poster.empty()) poster = str(image, "Uri");
else if (purpose == "BoxArt" && boxArt.empty()) boxArt = str(image, "Uri");
else if (purpose == "Tile" && tile.empty()) tile = str(image, "Uri");
}
}
const std::string& uri = !poster.empty() ? poster : (!boxArt.empty() ? boxArt : tile);
std::lock_guard<std::mutex> lock(mu);
for (Title* t : found->second) {
if (!name.empty()) t->name = name;
if (!publisher.empty()) t->publisher = publisher;
if (!uri.empty()) t->imageUrl = absoluteImageUrl(uri);
}
}
}
};
const int n = static_cast<int>(std::min<size_t>(kHydrateWorkers, batches));
std::vector<std::thread> pool;
for (int i = 1; i < n; ++i) {
try {
pool.emplace_back(worker);
} catch (const std::exception& e) { // thread limit: the remaining workers share the load
XC_LOGW("catalog: hydrate thread %d not started (%s)", i, e.what());
break;
}
}
worker();
for (auto& th : pool) th.join();
XC_LOGI("catalog: hydrated %zu products in %zu batches (%zu failed)", ids.size(), batches, failed.load());
}
bool Catalog::fetchConsoles(std::vector<Console>& out, std::string& err, bool* authRejected) {
out.clear();
if (authRejected) *authRejected = false;
const Tokens tk = cfg_.tokens();
if (tk.xhomeGs.empty() || tk.xhomeBase.empty()) {
err = "remote play (xHome) is not available for this account";
return false;
}
std::string url = tk.xhomeBase;
while (!url.empty() && url.back() == '/') url.pop_back();
url += "/v6/servers/home?mr=50";
// xhome: always the android/720 fingerprint (green-nx kDeviceInfo for the console list).
HttpResponse r = Http::request("GET", url, gssv::requestHeaders(tk.xhomeGs, gssv::deviceTierFor(true, "")), "", 20);
if (!r.ok()) {
if (authRejected) *authRejected = r.error.empty() && gssv::isAuthRejection(r.status, r.body);
err = httpFailure("console list", r);
return false;
}
json parsed = json::parse(r.body, nullptr, false);
if (parsed.is_discarded()) {
err = "console list: invalid JSON";
return false;
}
const json* results = nullptr;
if (parsed.is_array()) results = &parsed;
else if (parsed.is_object() && parsed.contains("results") && parsed["results"].is_array())
results = &parsed["results"];
if (!results) return true; // no consoles
for (const json& entry : *results) {
Console c;
c.serverId = str(entry, "serverId");
if (c.serverId.empty()) continue;
c.name = str(entry, "deviceName");
if (c.name.empty()) c.name = str(entry, "serverName");
if (c.name.empty()) c.name = c.serverId;
c.powerState = str(entry, "powerState");
c.consoleType = str(entry, "consoleType");
out.push_back(std::move(c));
}
XC_LOGI("catalog: %zu consoles", out.size());
return true;
}
bool Catalog::fetchImage(const std::string& url, std::vector<uint8_t>& bytes) {
bytes.clear();
if (url.empty()) return false;
const std::string dir = cacheDir();
const std::string cached = dir.empty() ? std::string() : dir + "/img/" + fnv1aHex(url) + ".bin";
if (!cached.empty() && readFile(cached, bytes) && !bytes.empty()) return true;
bytes.clear();
std::string u = absoluteImageUrl(url);
// store-images.s-microsoft.com scales server-side; ask for a grid-sized poster (green-nx h=300)
// instead of the multi-megabyte original.
if (u.find("store-images.s-microsoft.com") != std::string::npos && u.find('?') == std::string::npos)
u += "?h=300";
HttpResponse r = Http::request("GET", u, {"Accept: image/png,image/jpeg,image/*;q=0.8"}, "", 20);
if (!r.ok() || r.body.empty()) {
XC_LOGD("%s", httpFailure("image", r).c_str());
return false;
}
bytes.assign(r.body.begin(), r.body.end());
if (!cached.empty()) {
pruneImageCache(dir + "/img");
writeFileAtomic(cached, r.body);
}
return true;
}
void Catalog::setCacheDir(const std::string& dir) {
std::lock_guard<std::mutex> lk(g_cacheMu);
g_cacheDir = dir;
}
} // namespace xc
+70
View File
@@ -0,0 +1,70 @@
// Nubix — cloud title library and xHome console list.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "core/config.hpp"
namespace xc {
struct Title {
std::string titleId; // gssv titleId (used for /play and /waittime)
std::string productId; // Store product id (displaycatalog)
std::string name, publisher;
std::string imageUrl; // box art (poster) URL
bool hasEntitlement = false; // owned / playable with current offering
bool f2p = false; // from the xgpuwebf2p offering
bool gamePass = false; // part of Game Pass
bool f2pOnly = false; // playable only via the xgpuwebf2p offering (not owned, not in plan):
// start the session with xcloudF2pGs/xcloudF2pBase
int maxSessionSeconds = 0; // session cap (ad-supported runs); 0 = no cap
};
struct Console {
std::string serverId; // xHome serverId (used for /play)
std::string name; // user-given console name
std::string powerState; // "On", "ConnectedStandby", ...
std::string consoleType; // "XboxSeriesX", "XboxOne", ...
};
class Catalog {
public:
explicit Catalog(Config& cfg);
// GET {xcloudBase}/v2/titles (+ f2p offering), merged, names/art hydrated via displaycatalog
// (batched 20 product ids per request). Sorted by name. authRejected (optional) is set when
// the failure was the gsToken being refused (401/403): re-authorize and retry.
// The calling thread's Http abort flag also cancels the helper threads.
// Results are cached on disk (setCacheDir) for kTitleCacheTtlSec per account/offering: within
// that time the library is served from the cache unless forceRefresh. When the network fetch
// fails for a reason other than a rejected token, a stale cache is returned instead.
bool fetchCloudTitles(std::vector<Title>& out, std::string& err, bool* authRejected = nullptr,
bool forceRefresh = false);
// GET {xhomeBase}/v6/servers/home — consoles registered for remote play (android/720
// device fingerprint, as for every xhome call). authRejected as above.
bool fetchConsoles(std::vector<Console>& out, std::string& err, bool* authRejected = nullptr);
// Download an image (box art). Appends size hints (?w=…) where the CDN supports it. Served
// from / stored in <cacheDir>/img when a cache dir is set (box art URLs are immutable).
static bool fetchImage(const std::string& url, std::vector<uint8_t>& bytes);
// Directory for the title list and box-art caches (created on demand). Empty = no caching.
static void setCacheDir(const std::string& dir);
static constexpr long kTitleCacheTtlSec = 6 * 3600;
// Fill name/publisher/imageUrl for titles that have a productId but no name yet
// (displaycatalog, batched 20, no auth). Best effort; titles keep titleId as name on failure.
void hydrate(std::vector<Title>& titles);
private:
bool fetchCloudTitlesNetwork(const Tokens& tk, const Settings& st, std::vector<Title>& out, std::string& err,
bool& authRejected);
Config& cfg_;
};
} // namespace xc
+345
View File
@@ -0,0 +1,345 @@
// Nubix — persisted settings and tokens (JSON file).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include "core/config.hpp"
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <cerrno>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <mutex>
#include <random>
#include <sstream>
#include <utility>
#include <nlohmann/json.hpp>
#include "core/log.hpp"
#if !defined(__APPLE__) && !defined(__FreeBSD__)
#include <openssl/rand.h>
#endif
using nlohmann::json;
namespace xc {
namespace {
constexpr int kConfigVersion = 1;
// Serialises the file write of save() across threads (the UI thread, the App worker and the
// streamer may all persist). Serialising happens inside it, so the last writer always writes
// the newest state.
std::mutex g_saveMutex;
// Type-checked readers: a hand-edited or older config with a wrong type must never throw.
void readStr(const json& j, const char* key, std::string& out) {
auto it = j.find(key);
if (it != j.end() && it->is_string()) out = it->get<std::string>();
}
void readInt(const json& j, const char* key, int& out) {
auto it = j.find(key);
if (it != j.end() && it->is_number_integer()) out = it->get<int>();
}
void readI64(const json& j, const char* key, int64_t& out) {
auto it = j.find(key);
if (it != j.end() && it->is_number_integer()) out = it->get<int64_t>();
}
void readBool(const json& j, const char* key, bool& out) {
auto it = j.find(key);
if (it != j.end() && it->is_boolean()) out = it->get<bool>();
}
json regionsToJson(const std::vector<Region>& regions) {
json arr = json::array();
for (const auto& r : regions) arr.push_back({{"name", r.name}, {"baseUri", r.baseUri}, {"isDefault", r.isDefault}});
return arr;
}
void readRegions(const json& j, const char* key, std::vector<Region>& out) {
out.clear();
auto it = j.find(key);
if (it == j.end() || !it->is_array()) return;
for (const auto& e : *it) {
if (!e.is_object()) continue;
Region r;
readStr(e, "name", r.name);
readStr(e, "baseUri", r.baseUri);
readBool(e, "isDefault", r.isDefault);
if (!r.baseUri.empty()) out.push_back(std::move(r));
}
}
void fillRandom(uint8_t* buf, size_t n) {
#if defined(__APPLE__) || defined(__FreeBSD__)
// macOS and the PS5 (FreeBSD-based libc) both provide arc4random_buf.
arc4random_buf(buf, n);
#else
if (RAND_bytes(buf, static_cast<int>(n)) == 1) return;
std::random_device rd;
std::mt19937_64 gen((static_cast<uint64_t>(rd()) << 32) ^ rd() ^
static_cast<uint64_t>(std::chrono::high_resolution_clock::now().time_since_epoch().count()));
for (size_t i = 0; i < n; ++i) buf[i] = static_cast<uint8_t>(gen());
#endif
}
bool clampSettings(Settings& s) {
bool changed = false;
if (s.resolution != "720" && s.resolution != "1080" && s.resolution != "1080HQ") {
s.resolution = "1080";
changed = true;
}
if (s.bitrateKbps < 5000) { s.bitrateKbps = 5000; changed = true; }
if (s.bitrateKbps > 20000) { s.bitrateKbps = 20000; changed = true; }
if (s.stickDeadzone < 0) { s.stickDeadzone = 0; changed = true; }
if (s.stickDeadzone > 50) { s.stickDeadzone = 50; changed = true; }
if (s.locale.empty()) { s.locale = "en-US"; changed = true; }
return changed;
}
} // namespace
Config::Config(std::string path) : path_(std::move(path)) {}
bool Config::load() {
Settings settings;
Tokens tokens;
std::string installId;
bool ok = true;
std::string text;
if (!path_.empty()) {
std::ifstream in(path_, std::ios::binary);
if (in) {
std::ostringstream ss;
ss << in.rdbuf();
text = ss.str();
}
}
if (!text.empty()) {
json root = json::parse(text, nullptr, /*allow_exceptions=*/false);
if (root.is_discarded() || !root.is_object()) {
XC_LOGE("config: %s is not valid JSON; using defaults (kept as .bad)", path_.c_str());
const std::string bad = path_ + ".bad";
std::rename(path_.c_str(), bad.c_str());
ok = false;
} else {
readStr(root, "installId", installId);
auto sIt = root.find("settings");
if (sIt != root.end() && sIt->is_object()) {
const json& s = *sIt;
readStr(s, "resolution", settings.resolution);
readInt(s, "bitrateKbps", settings.bitrateKbps);
readStr(s, "region", settings.region);
readStr(s, "locale", settings.locale);
readBool(s, "f2pFallback", settings.f2pFallback);
readInt(s, "stickDeadzone", settings.stickDeadzone);
if (clampSettings(settings)) XC_LOGW("config: out-of-range settings were reset");
}
auto tIt = root.find("tokens");
if (tIt != root.end() && tIt->is_object()) {
const json& t = *tIt;
readStr(t, "msaRefresh", tokens.msaRefresh);
readStr(t, "msaAccess", tokens.msaAccess);
readI64(t, "msaExpiry", tokens.msaExpiry);
readStr(t, "xhomeGs", tokens.xhomeGs);
readStr(t, "xcloudGs", tokens.xcloudGs);
readStr(t, "xcloudF2pGs", tokens.xcloudF2pGs);
readStr(t, "xhomeBase", tokens.xhomeBase);
readStr(t, "xcloudBase", tokens.xcloudBase);
readStr(t, "xcloudF2pBase", tokens.xcloudF2pBase);
readI64(t, "gsExpiry", tokens.gsExpiry);
readStr(t, "gamertag", tokens.gamertag);
readStr(t, "uhs", tokens.uhs);
readStr(t, "xblToken", tokens.xblToken);
readI64(t, "xblExpiry", tokens.xblExpiry);
readRegions(t, "xhomeRegions", tokens.xhomeRegions);
readRegions(t, "xcloudRegions", tokens.xcloudRegions);
readRegions(t, "xcloudF2pRegions", tokens.xcloudF2pRegions);
readStr(t, "market", tokens.market);
}
}
}
if (installId.size() != 36) installId = generateUuid();
{
std::lock_guard<std::mutex> lk(mu_);
settings_ = std::move(settings);
tokens_ = std::move(tokens);
installId_ = std::move(installId);
}
++revision_;
return ok;
}
Settings Config::settings() const {
std::lock_guard<std::mutex> lk(mu_);
return settings_;
}
Tokens Config::tokens() const {
std::lock_guard<std::mutex> lk(mu_);
return tokens_;
}
std::string Config::gamertag() const {
std::lock_guard<std::mutex> lk(mu_);
return tokens_.gamertag;
}
std::string Config::installId() const {
std::lock_guard<std::mutex> lk(mu_);
return installId_;
}
void Config::setSettings(const Settings& s) {
{
std::lock_guard<std::mutex> lk(mu_);
settings_ = s;
}
++revision_;
}
void Config::updateSettings(const std::function<void(Settings&)>& fn) {
{
std::lock_guard<std::mutex> lk(mu_);
fn(settings_);
}
++revision_;
}
void Config::setTokens(const Tokens& t) {
{
std::lock_guard<std::mutex> lk(mu_);
tokens_ = t;
}
++revision_;
}
void Config::updateTokens(const std::function<void(Tokens&)>& fn) {
{
std::lock_guard<std::mutex> lk(mu_);
fn(tokens_);
}
++revision_;
}
bool Config::save() {
if (path_.empty()) {
XC_LOGE("config: save() without a path");
return false;
}
std::lock_guard<std::mutex> saveLock(g_saveMutex);
Settings settings;
Tokens tokens;
std::string installId;
{
std::lock_guard<std::mutex> lk(mu_);
settings = settings_;
tokens = tokens_;
installId = installId_;
}
json root;
root["version"] = kConfigVersion;
root["installId"] = installId;
root["settings"] = {
{"resolution", settings.resolution}, {"bitrateKbps", settings.bitrateKbps},
{"region", settings.region}, {"locale", settings.locale},
{"f2pFallback", settings.f2pFallback}, {"stickDeadzone", settings.stickDeadzone},
};
root["tokens"] = {
{"msaRefresh", tokens.msaRefresh},
{"msaAccess", tokens.msaAccess},
{"msaExpiry", tokens.msaExpiry},
{"xhomeGs", tokens.xhomeGs},
{"xcloudGs", tokens.xcloudGs},
{"xcloudF2pGs", tokens.xcloudF2pGs},
{"xhomeBase", tokens.xhomeBase},
{"xcloudBase", tokens.xcloudBase},
{"xcloudF2pBase", tokens.xcloudF2pBase},
{"gsExpiry", tokens.gsExpiry},
{"gamertag", tokens.gamertag},
{"uhs", tokens.uhs},
{"xblToken", tokens.xblToken},
{"xblExpiry", tokens.xblExpiry},
{"xhomeRegions", regionsToJson(tokens.xhomeRegions)},
{"xcloudRegions", regionsToJson(tokens.xcloudRegions)},
{"xcloudF2pRegions", regionsToJson(tokens.xcloudF2pRegions)},
{"market", tokens.market},
};
// Replace invalid UTF-8 instead of throwing (values come from the network).
const std::string text = root.dump(2, ' ', false, json::error_handler_t::replace) + "\n";
const std::string tmp = path_ + ".tmp";
// 0600: the file holds the MSA refresh token.
int fd = ::open(tmp.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0) {
XC_LOGE("config: cannot create %s: %s", tmp.c_str(), std::strerror(errno));
return false;
}
size_t off = 0;
while (off < text.size()) {
ssize_t n = ::write(fd, text.data() + off, text.size() - off);
if (n < 0) {
if (errno == EINTR) continue;
XC_LOGE("config: write %s failed: %s", tmp.c_str(), std::strerror(errno));
::close(fd);
::unlink(tmp.c_str());
return false;
}
off += static_cast<size_t>(n);
}
::fsync(fd);
if (::close(fd) != 0) {
XC_LOGE("config: close %s failed: %s", tmp.c_str(), std::strerror(errno));
::unlink(tmp.c_str());
return false;
}
if (std::rename(tmp.c_str(), path_.c_str()) != 0) {
XC_LOGE("config: rename %s -> %s failed: %s", tmp.c_str(), path_.c_str(), std::strerror(errno));
::unlink(tmp.c_str());
return false;
}
return true;
}
std::string pickRegionBase(const std::vector<Region>& regions, const std::string& preferred) {
if (regions.empty()) return std::string();
if (!preferred.empty())
for (const auto& r : regions)
if (r.name == preferred) return r.baseUri;
for (const auto& r : regions)
if (r.isDefault) return r.baseUri;
return regions.front().baseUri;
}
std::string generateUuid() {
uint8_t b[16];
fillRandom(b, sizeof(b));
b[6] = static_cast<uint8_t>((b[6] & 0x0F) | 0x40); // version 4
b[8] = static_cast<uint8_t>((b[8] & 0x3F) | 0x80); // RFC 4122 variant
static const char* hex = "0123456789abcdef";
std::string out;
out.reserve(36);
for (int i = 0; i < 16; ++i) {
if (i == 4 || i == 6 || i == 8 || i == 10) out += '-';
out += hex[b[i] >> 4];
out += hex[b[i] & 0x0F];
}
return out;
}
} // namespace xc
+101
View File
@@ -0,0 +1,101 @@
// Nubix — persisted settings and tokens (JSON file).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <atomic>
#include <cstdint>
#include <functional>
#include <mutex>
#include <string>
#include <vector>
namespace xc {
struct Settings {
std::string resolution = "1080"; // "720" | "1080" | "1080HQ"
int bitrateKbps = 12000; // requested max bitrate (REMB / clientdevicecapabilities), 5000..20000
std::string region = ""; // gssv region name; empty = server default
std::string locale = "en-US"; // used for catalog + session start
bool f2pFallback = true; // retry cloud start with the F2P offering on OfferingDoesNotContainTitle
int stickDeadzone = 0; // extra radial deadzone in percent (0 = rely on server)
};
// One gssv region of an offering, from /v2/login/user offeringSettings.regions[].
struct Region {
std::string name; // e.g. "WestEurope"
std::string baseUri; // e.g. https://weu.core.gssv-play-prod.xboxlive.com
bool isDefault = false;
};
struct Tokens {
std::string msaRefresh, msaAccess; // MSA (login.live.com) refresh + access token
int64_t msaExpiry = 0; // unix seconds when msaAccess expires
std::string xhomeGs, xcloudGs, xcloudF2pGs; // gssv gsToken per offering (empty = not entitled)
std::string xhomeBase, xcloudBase, xcloudF2pBase; // base URI per offering, e.g. https://weu.core.gssv-play-prod.xboxlive.com
int64_t gsExpiry = 0; // unix seconds when the gs tokens expire
std::string gamertag, uhs, xblToken; // profile + XBL user token (for XSTS / displaycatalog)
// xblToken is the XSTS token for RelyingParty http://xboxlive.com and uhs its user hash:
// use as "Authorization: XBL3.0 x=<uhs>;<xblToken>" (profile, smartglass, titlehub, ...).
int64_t xblExpiry = 0; // unix seconds when xblToken expires (XSTS NotAfter)
// Regions per offering (empty = offering not entitled). The *Base fields above hold the
// selected region's baseUri (Settings::region for cloud offerings, else isDefault).
std::vector<Region> xhomeRegions, xcloudRegions, xcloudF2pRegions;
std::string market; // gssv "market" (country), e.g. "DE"
};
// Thread-safe: settings and tokens are shared by the UI thread, the App worker (Auth, Catalog)
// and the Streamer worker, so they are only reachable through copies (snapshots) and locked
// updates. Never hold a reference into them across threads.
class Config {
public:
// path: JSON file location, normally platform::dataDir() + "/config.json".
explicit Config(std::string path = "");
// Load from path. Missing file = defaults (returns true); malformed JSON = defaults + false.
// Generates installId if absent.
bool load();
// Atomically write settings + tokens to path (write temp file, then rename). The state is
// serialised under the lock; the file I/O runs outside it (serialised among savers).
bool save();
// File location set at construction / via setPath() (set before threads start).
const std::string& path() const { return path_; }
void setPath(const std::string& p) { path_ = p; }
// Snapshots (copies taken under the lock).
Settings settings() const;
Tokens tokens() const;
std::string gamertag() const;
// Random UUID v4, generated once and persisted.
std::string installId() const;
// Locked writes. The callback runs with the lock held: it must not call back into Config.
// None of these persist; call save() afterwards.
void setSettings(const Settings& s);
void updateSettings(const std::function<void(Settings&)>& fn);
void setTokens(const Tokens& t);
void updateTokens(const std::function<void(Tokens&)>& fn);
// Incremented on every change of settings or tokens (load included): lets per-frame UI code
// refresh its cached snapshot only when something changed.
uint64_t revision() const { return revision_.load(); }
private:
std::string path_;
mutable std::mutex mu_;
Settings settings_;
Tokens tokens_;
std::string installId_;
std::atomic<uint64_t> revision_{0};
};
// Pick a region baseUri: the region named `preferred` if present, else the isDefault one,
// else the first; "" if regions is empty.
std::string pickRegionBase(const std::vector<Region>& regions, const std::string& preferred);
// Generate a random RFC 4122 v4 UUID string (lower-case, with dashes).
std::string generateUuid();
} // namespace xc
+566
View File
@@ -0,0 +1,566 @@
// Nubix — gssv session REST API.
// Ported from green-nx src/core/session.cpp (GPL-3.0, (c) green-nx authors). Protocol behaviour
// (/configuration, transferUri, error codes) cross-checked against XStreaming and green-vita.
// Portions derived from green-nx (https://github.com/rmrf404/green-nx), Copyright (C) the green-nx authors, GPL-3.0; modified by Nubix contributors, 2026.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include "core/gssv.hpp"
#include <algorithm>
#include <cstdlib>
#include <ctime>
#include <functional>
#include <nlohmann/json.hpp>
#include "core/http.hpp"
#include "core/log.hpp"
#include "core/ratelimit.hpp"
#include "core/protocol.hpp"
using nlohmann::json;
namespace xc {
namespace {
constexpr long kRequestTimeoutSec = 20;
// keepalive can block server-side for a while (reports §4: up to 15 s).
constexpr long kKeepAliveTimeoutSec = 25;
std::string trimSlashes(std::string s, bool leading, bool trailing) {
if (trailing)
while (!s.empty() && s.back() == '/') s.pop_back();
if (leading) {
size_t i = 0;
while (i < s.size() && s[i] == '/') ++i;
s.erase(0, i);
}
return s;
}
std::string snippet(const std::string& body, size_t max = 300) {
std::string out = body.substr(0, max);
for (char& c : out)
if (c == '\r' || c == '\n') c = ' ';
return out;
}
// "HTTP 403: {...}" / "transport: <curl error>".
std::string describeFailure(const char* label, const HttpResponse& r) {
if (!r.error.empty()) return std::string(label) + ": " + r.error;
std::string s = std::string(label) + ": HTTP " + std::to_string(r.status);
if (!r.body.empty()) s += ": " + snippet(r.body);
return s;
}
std::string jsonString(const json& j, const char* key) {
auto it = j.find(key);
if (it == j.end() || !it->is_string()) return {};
return it->get<std::string>();
}
// Pull {code, message} out of a gssv error body. Shapes seen: {"code":..,"message":..},
// {"errorCode":..,"errorMessage":..}, {"errorDetails":{"code":..,"message":..}}. green-vita
// simply substring-matches the body, so fall back to that for the codes we act on.
void extractError(const std::string& body, std::string& code, std::string& message) {
json j = json::parse(body, nullptr, false);
if (!j.is_discarded() && j.is_object()) {
const json* src = &j;
auto ed = j.find("errorDetails");
if (ed != j.end() && ed->is_object()) src = &*ed;
code = jsonString(*src, "code");
if (code.empty()) code = jsonString(*src, "errorCode");
message = jsonString(*src, "message");
if (message.empty()) message = jsonString(*src, "errorMessage");
if (src != &j) {
if (code.empty()) code = jsonString(j, "code");
if (message.empty()) message = jsonString(j, "message");
}
}
if (code.empty() && body.find(kErrOfferingDoesNotContainTitle) != std::string::npos)
code = kErrOfferingDoesNotContainTitle;
}
// Home consoles attach an errorDetails object with all-null fields to perfectly good
// responses (the answer rides right alongside it); only a non-null field is a real failure.
// (green-nx is_real_exchange_error)
bool isRealExchangeError(const json& value) {
if (!value.is_object()) return false;
auto it = value.find("errorDetails");
if (it == value.end() || it->is_null()) return false;
if (!it->is_object()) return true;
for (const auto& item : it->items())
if (!item.value().is_null()) return true;
return false;
}
// Body is "pending" when the server answers 204 or with nothing at all.
bool isPending(const HttpResponse& r) {
if (r.status == 204) return true;
for (char c : r.body)
if (c != ' ' && c != '\r' && c != '\n' && c != '\t') return false;
return true;
}
// Local UTC offset in minutes (east positive), as the browser's -getTimezoneOffset().
int timezoneOffsetMinutes() {
std::time_t now = std::time(nullptr);
std::tm local{}, utc{};
#if defined(_WIN32)
localtime_s(&local, &now);
gmtime_s(&utc, &now);
#else
localtime_r(&now, &local);
gmtime_r(&now, &utc);
#endif
int diff = (local.tm_hour - utc.tm_hour) * 60 + (local.tm_min - utc.tm_min);
int dayDiff = local.tm_yday - utc.tm_yday;
if (local.tm_year != utc.tm_year) dayDiff = local.tm_year > utc.tm_year ? 1 : -1;
diff += dayDiff * 24 * 60;
if (diff < -12 * 60 || diff > 14 * 60) diff = 0;
return diff;
}
} // namespace
// ---- headers ---------------------------------------------------------------------------------
namespace gssv {
const char* osNameForResolution(const std::string& resolution) {
if (resolution == "720") return "android";
if (resolution == "1080HQ") return "tizen";
return "windows";
}
std::string deviceInfoJson(const std::string& resolution) {
const bool p720 = resolution == "720";
// green-nx: report the tier's real display size; a fabricated 4K size risks landing in an
// unknown-device profile server-side.
json info = {
{"appInfo",
{{"env",
{{"clientAppId", "www.xbox.com"},
{"clientAppType", "browser"},
{"clientAppVersion", "26.1.97"},
{"clientSdkVersion", "10.3.7"},
{"httpEnvironment", "prod"},
{"sdkInstallId", ""}}}}},
{"dev",
{{"hw", {{"make", "Microsoft"}, {"model", "unknown"}, {"sdktype", "web"}}},
{"os", {{"name", osNameForResolution(resolution)}, {"ver", "22631.2715"}, {"platform", "desktop"}}},
{"displayInfo",
{{"dimensions", {{"widthInPixels", p720 ? 1280 : 1920}, {"heightInPixels", p720 ? 720 : 1080}}},
{"pixelDensity", {{"dpiX", 1}, {"dpiY", 1}}}}},
{"browser", {{"browserName", "chrome"}, {"browserVersion", "140.0.3485.54"}}}}},
};
return info.dump();
}
std::string deviceTierFor(bool home, const std::string& resolution) { return home ? std::string("720") : resolution; }
bool isAuthRejection(long status, const std::string& body) {
if (status == 401) return true;
if (status != 403) return false;
std::string code, message;
extractError(body, code, message);
return code.empty();
}
std::vector<std::string> requestHeaders(const std::string& gsToken, const std::string& resolution) {
return {
"Accept: application/json",
"Content-Type: application/json",
"X-Gssv-Client: XboxComBrowser",
"X-MS-Device-Info: " + deviceInfoJson(resolution),
"Authorization: Bearer " + gsToken,
};
}
} // namespace gssv
// ---- GssvClient ------------------------------------------------------------------------------
GssvClient::GssvClient(const std::string& baseUri, const std::string& gsToken, const Settings& settings)
: base_(trimSlashes(baseUri, false, true)), token_(gsToken), settings_(settings) {}
std::vector<std::string> GssvClient::headers() const { return gssv::requestHeaders(token_, settings_.resolution); }
void GssvClient::noteResponse(const HttpResponse& r) {
if (!r.error.empty() || !gssv::isAuthRejection(r.status, r.body)) return;
if (!authRejected_.exchange(true)) XC_LOGW("gssv: request rejected as unauthorized (HTTP %ld)", r.status);
}
std::string GssvClient::apiUrl(const std::string& path) const {
return base_ + "/" + trimSlashes(path, true, false);
}
std::string GssvClient::sessionUrl(const SessionInfo& s, const std::string& suffix) const {
const std::string host = s.host.empty() ? base_ : trimSlashes(s.host, false, true);
std::string url = host + "/" + trimSlashes(s.sessionPath, true, true);
if (!suffix.empty()) url += "/" + suffix;
return url;
}
bool GssvClient::cleanupActive(SessionKind kind) {
if (base_.empty() || token_.empty()) return false;
const char* platform = kind == SessionKind::Home ? "home" : "cloud";
HttpResponse r = Http::request("GET", apiUrl(std::string("v5/sessions/") + platform + "/active"), headers(), "",
kRequestTimeoutSec);
noteResponse(r);
if (r.status == 404 || isPending(r)) {
XC_LOGD("gssv: no active %s sessions (HTTP %ld)", platform, r.status);
return r.error.empty();
}
if (!r.ok()) {
XC_LOGW("%s", describeFailure("gssv active sessions", r).c_str());
return false;
}
json parsed = json::parse(r.body, nullptr, false);
if (parsed.is_discarded()) {
XC_LOGW("gssv active sessions: invalid JSON");
return false;
}
// Collect any "sessionPath"/"path" strings anywhere in the response (green-nx).
std::vector<std::string> paths;
std::function<void(const json&)> walk = [&](const json& node) {
if (node.is_array()) {
for (const json& item : node) walk(item);
} else if (node.is_object()) {
for (const auto& item : node.items()) {
if ((item.key() == "sessionPath" || item.key() == "path") && item.value().is_string())
paths.push_back(item.value().get<std::string>());
walk(item.value());
}
}
};
walk(parsed);
std::sort(paths.begin(), paths.end());
paths.erase(std::unique(paths.begin(), paths.end()), paths.end());
bool allOk = true;
for (const std::string& p : paths) {
if (p.empty()) continue;
SessionInfo s;
s.sessionPath = p;
XC_LOGI("gssv: stopping stale session %s", p.c_str());
if (!stop(s)) allOk = false;
}
return allOk;
}
bool GssvClient::play(SessionKind kind, const std::string& titleOrServerId, SessionInfo& out, std::string& err) {
out = SessionInfo{};
if (base_.empty() || token_.empty()) {
err = kind == SessionKind::Home ? "no xHome token (remote play not available for this account)"
: "no xCloud token for this offering";
return false;
}
const bool home = kind == SessionKind::Home;
// Client-side cap on session starts (ratelimit.hpp): a retry loop or impatient re-starts must
// never turn into a burst of /play requests.
int waitSec = 0;
if (!SessionStartLimiter::global().allow(home, steadyNowMs(), waitSec)) {
out.state = "Failed";
out.errorCode = "ClientRateLimited";
err = "Too many session starts in a short time. Please wait " + std::to_string(waitSec) +
" s and try again.";
XC_LOGW("gssv: play blocked by the session start limiter (%d s)", waitSec);
return false;
}
// Field values mirror green-nx/green-vita exactly; useIceConnection must stay false (true makes
// the console agent reject the start with AgentCommandError).
json body = {
{"clientSessionId", ""},
{"titleId", home ? "" : titleOrServerId},
{"systemUpdateGroup", ""},
{"settings",
{{"nanoVersion", "V3;WebrtcTransport.dll"},
{"enableOptionalDataCollection", false},
{"enableTextToSpeech", false},
{"highContrast", 0},
{"locale", settings_.locale.empty() ? std::string("en-US") : settings_.locale},
{"useIceConnection", false},
{"timezoneOffsetMinutes", timezoneOffsetMinutes()},
{"sdkType", "web"},
{"osName", gssv::osNameForResolution(settings_.resolution)}}},
{"serverId", home ? titleOrServerId : ""},
{"fallbackRegionNames", json::array()},
};
const std::string url = apiUrl(home ? "v5/sessions/home/play" : "v5/sessions/cloud/play");
XC_LOGI("gssv: play %s %s (osName %s)", home ? "home" : "cloud", titleOrServerId.c_str(),
gssv::osNameForResolution(settings_.resolution));
HttpResponse r = Http::request("POST", url, headers(), body.dump(), kRequestTimeoutSec);
noteResponse(r);
if (!r.ok()) {
extractError(r.body, out.errorCode, out.errorMessage);
out.state = "Failed";
err = describeFailure("session start", r);
XC_LOGE("%s", err.c_str());
return false;
}
json parsed = json::parse(r.body, nullptr, false);
if (parsed.is_discarded() || !parsed.is_object()) {
err = "session start: invalid JSON response";
return false;
}
out.sessionPath = trimSlashes(jsonString(parsed, "sessionPath"), true, true);
if (out.sessionPath.empty()) {
err = "session start: response has no sessionPath: " + snippet(r.body);
return false;
}
out.sessionId = jsonString(parsed, "sessionId");
if (out.sessionId.empty()) {
size_t slash = out.sessionPath.rfind('/');
out.sessionId = slash == std::string::npos ? out.sessionPath : out.sessionPath.substr(slash + 1);
}
out.state = jsonString(parsed, "state");
if (out.state.empty()) out.state = "New";
XC_LOGI("gssv: session %s started (%s)", out.sessionId.c_str(), out.state.c_str());
return true;
}
bool GssvClient::pollState(SessionInfo& io, std::string& err) {
if (io.sessionPath.empty()) {
err = "no session";
return false;
}
HttpResponse r = Http::request("GET", sessionUrl(io, "state"), headers(), "", kRequestTimeoutSec);
noteResponse(r);
if (!r.ok()) {
err = describeFailure("session state", r);
return false;
}
json parsed = json::parse(r.body, nullptr, false);
if (parsed.is_discarded() || !parsed.is_object()) {
err = "session state: invalid JSON response";
return false;
}
const std::string transfer = jsonString(parsed, "transferUri");
if (!transfer.empty() && trimSlashes(transfer, false, true) != (io.host.empty() ? base_ : io.host)) {
XC_LOGI("gssv: session transferred to %s", transfer.c_str());
io.host = trimSlashes(transfer, false, true);
}
std::string state = jsonString(parsed, "state");
if (state == "Error") state = "Failed";
if (state != io.state) XC_LOGD("gssv: state %s -> %s", io.state.c_str(), state.c_str());
io.state = state;
if (state == "Failed") {
extractError(r.body, io.errorCode, io.errorMessage);
if (io.errorCode.empty() && io.errorMessage.empty()) io.errorMessage = snippet(r.body);
XC_LOGE("gssv: session failed: %s %s", io.errorCode.c_str(), io.errorMessage.c_str());
}
return true;
}
bool GssvClient::connect(const SessionInfo& s, const std::string& lpt, std::string& err) {
if (lpt.empty()) {
err = "connect: empty user token";
return false;
}
json body = {{"userToken", lpt}};
HttpResponse r = Http::request("POST", sessionUrl(s, "connect"), headers(), body.dump(), kRequestTimeoutSec);
noteResponse(r);
if (!r.ok()) {
err = describeFailure("session connect", r);
return false;
}
XC_LOGI("gssv: connect accepted (HTTP %ld)", r.status);
return true;
}
int GssvClient::keepAliveSeconds(const SessionInfo& s) {
constexpr int kDefault = 20;
HttpResponse r = Http::request("GET", sessionUrl(s, "configuration"), headers(), "", kRequestTimeoutSec);
noteResponse(r);
if (!r.ok()) {
XC_LOGW("%s", describeFailure("session configuration", r).c_str());
return kDefault;
}
json parsed = json::parse(r.body, nullptr, false);
if (parsed.is_discarded() || !parsed.is_object()) return kDefault;
XC_LOGD("gssv: configuration %s", snippet(r.body, 600).c_str());
auto it = parsed.find("keepAlivePulseInSeconds");
if (it == parsed.end()) return kDefault;
double v = 0;
if (it->is_number()) v = it->get<double>();
else if (it->is_string()) v = std::atof(it->get<std::string>().c_str());
if (!(v > 0)) return kDefault;
// XStreaming: max(pulse, 5).
return std::max(5, static_cast<int>(v));
}
int GssvClient::waitTimeSeconds(const std::string& titleId) {
if (titleId.empty()) return -1;
HttpResponse r = Http::request("GET", apiUrl("v1/waittime/" + Http::urlEncode(titleId)), headers(), "",
kRequestTimeoutSec);
if (!r.ok()) {
XC_LOGD("%s", describeFailure("waittime", r).c_str());
return -1;
}
json parsed = json::parse(r.body, nullptr, false);
if (parsed.is_discarded() || !parsed.is_object()) return -1;
for (const char* key : {"estimatedTotalWaitTimeInSeconds", "estimatedProvisioningTimeInSeconds"}) {
auto it = parsed.find(key);
if (it != parsed.end() && it->is_number()) return std::max(0, static_cast<int>(it->get<double>()));
}
return -1;
}
bool GssvClient::sendSdpOffer(const SessionInfo& s, const std::string& sdp, std::string& err) {
HttpResponse r =
Http::request("POST", sessionUrl(s, "sdp"), headers(), proto::sdpPostBody(sdp), kRequestTimeoutSec);
noteResponse(r);
if (!r.ok()) {
err = describeFailure("sdp offer", r);
return false;
}
return true;
}
bool GssvClient::pollSdpAnswer(const SessionInfo& s, std::string& answerSdp, bool& ready, std::string& err) {
answerSdp.clear();
ready = false;
HttpResponse r = Http::request("GET", sessionUrl(s, "sdp"), headers(), "", kRequestTimeoutSec);
noteResponse(r);
if (!r.ok()) {
err = describeFailure("sdp poll", r);
return false;
}
if (isPending(r)) return true;
json parsed = json::parse(r.body, nullptr, false);
if (parsed.is_discarded() || !parsed.is_object()) {
err = "sdp poll: invalid JSON response";
return false;
}
if (isRealExchangeError(parsed)) {
err = "sdp exchange: " + snippet(parsed.dump(), 400);
return false;
}
// exchangeResponse is a JSON *string* holding {"sdp":"v=0...","sdpType":"answer",...}.
json exchange;
auto it = parsed.find("exchangeResponse");
if (it == parsed.end() || it->is_null()) return true; // not answered yet
if (it->is_string()) exchange = json::parse(it->get<std::string>(), nullptr, false);
else exchange = *it;
if (exchange.is_discarded() || !exchange.is_object()) {
err = "sdp exchange: unparsable exchangeResponse";
return false;
}
const std::string status = jsonString(exchange, "status");
auto sdpIt = exchange.find("sdp");
if (sdpIt == exchange.end() || !sdpIt->is_string() || sdpIt->get<std::string>().empty()) {
err = "sdp exchange: answer has no sdp" + (status.empty() ? std::string() : " (status " + status + ")") +
": " + snippet(exchange.dump(), 400);
return false;
}
// Keep the answer verbatim (CRLF intact) — re-serialising corrupted ice-ufrag/pwd (green-nx).
answerSdp = sdpIt->get<std::string>();
ready = true;
return true;
}
bool GssvClient::sendIce(const SessionInfo& s, const std::vector<std::string>& candidateLines,
const std::string& ufrag, std::string& err) {
HttpResponse r = Http::request("POST", sessionUrl(s, "ice"), headers(), proto::icePostBody(candidateLines, ufrag),
kRequestTimeoutSec);
noteResponse(r);
if (!r.ok()) {
err = describeFailure("ice send", r);
return false;
}
if (!isPending(r)) {
json parsed = json::parse(r.body, nullptr, false);
if (!parsed.is_discarded() && isRealExchangeError(parsed)) {
err = "ice send: " + snippet(parsed.dump(), 400);
return false;
}
}
return true;
}
bool GssvClient::pollIce(const SessionInfo& s, std::vector<std::string>& remoteCandidates, bool& ready,
std::string& err) {
remoteCandidates.clear();
ready = false;
HttpResponse r = Http::request("GET", sessionUrl(s, "ice"), headers(), "", kRequestTimeoutSec);
noteResponse(r);
if (!r.ok()) {
err = describeFailure("ice poll", r);
return false;
}
if (isPending(r)) return true;
json parsed = json::parse(r.body, nullptr, false);
if (parsed.is_discarded() || !parsed.is_object()) {
err = "ice poll: invalid JSON response";
return false;
}
if (isRealExchangeError(parsed)) {
err = "ice exchange: " + snippet(parsed.dump(), 400);
return false;
}
auto it = parsed.find("exchangeResponse");
if (it == parsed.end() || it->is_null()) return true;
json exchange = it->is_string() ? json::parse(it->get<std::string>(), nullptr, false) : *it;
if (exchange.is_discarded()) {
err = "ice exchange: unparsable exchangeResponse";
return false;
}
const json* items = nullptr;
if (exchange.is_array()) items = &exchange;
else if (exchange.is_object() && exchange.contains("candidates") && exchange["candidates"].is_array())
items = &exchange["candidates"];
else if (exchange.is_object() && exchange.contains("candidate") && exchange["candidate"].is_array())
items = &exchange["candidate"];
ready = true;
if (!items) return true;
for (const json& item : *items) {
json entry = item;
if (entry.is_string()) entry = json::parse(entry.get<std::string>(), nullptr, false);
if (entry.is_discarded() || !entry.is_object()) continue;
std::string c = jsonString(entry, "candidate");
if (c.rfind("a=", 0) == 0) c.erase(0, 2);
if (c.find("end-of-candidates") != std::string::npos) continue;
if (c.rfind("candidate:", 0) != 0) continue;
while (!c.empty() && (c.back() == ' ' || c.back() == '\r' || c.back() == '\n')) c.pop_back();
remoteCandidates.push_back(c);
}
XC_LOGD("gssv: %zu remote candidates", remoteCandidates.size());
return true;
}
bool GssvClient::keepAlive(const SessionInfo& s) {
if (s.sessionPath.empty()) return false;
HttpResponse r = Http::request("POST", sessionUrl(s, "keepalive"), headers(), "", kKeepAliveTimeoutSec);
noteResponse(r);
if (!r.ok()) {
XC_LOGW("%s", describeFailure("keepalive", r).c_str());
return false;
}
if (!isPending(r)) {
json parsed = json::parse(r.body, nullptr, false);
if (!parsed.is_discarded() && parsed.is_object()) {
const std::string code = jsonString(parsed, "code");
if (code == "SessionNotActive" || code == "SessionNotFound") {
XC_LOGW("gssv: keepalive: %s", code.c_str());
return false;
}
}
}
return true;
}
bool GssvClient::stop(const SessionInfo& s, long timeoutSec) {
if (s.sessionPath.empty()) return false;
HttpResponse r = Http::request("DELETE", sessionUrl(s, ""), headers(), "", timeoutSec > 0 ? timeoutSec : kRequestTimeoutSec);
if (r.ok() || r.status == 404 || r.status == 410) {
XC_LOGI("gssv: session %s stopped (HTTP %ld)", s.sessionPath.c_str(), r.status);
return true;
}
XC_LOGW("%s", describeFailure("session stop", r).c_str());
return false;
}
} // namespace xc
+112
View File
@@ -0,0 +1,112 @@
// Nubix — gssv session REST API (xCloud "xgpuweb"/"xgpuwebf2p" and xHome "xhome").
// Endpoints and bodies as used by green-nx, Greenlight and xbox-xcloud-player.
// Portions derived from green-nx (https://github.com/rmrf404/green-nx), Copyright (C) the green-nx authors, GPL-3.0; modified by Nubix contributors, 2026.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <atomic>
#include <string>
#include <vector>
#include "core/config.hpp"
namespace xc {
struct HttpResponse;
enum class SessionKind { Cloud, Home };
struct SessionInfo {
std::string sessionPath; // e.g. "v5/sessions/cloud/<id>" (relative to base URI)
std::string sessionId;
std::string state; // WaitingForResources | ReadyToConnect | Provisioning | Provisioned | Failed
std::string errorCode, errorMessage; // set when state == Failed or the start request failed
// Host the session lives on. Empty = the client's base URI. Set when /state returns a
// transferUri (session migrated, XStreaming honours it); all later calls follow it.
std::string host;
};
// Well-known gssv error codes (SessionInfo::errorCode).
constexpr const char* kErrOfferingDoesNotContainTitle = "OfferingDoesNotContainTitle";
namespace gssv {
// Quality tier -> X-MS-Device-Info os.name and play settings.osName:
// "720" -> "android", "1080" -> "windows", "1080HQ" -> "tizen" (anything else -> "windows").
const char* osNameForResolution(const std::string& resolution);
// X-MS-Device-Info JSON (green-nx 2026 fingerprint) for the given quality tier.
std::string deviceInfoJson(const std::string& resolution);
// Standard gssv request headers (Accept, Content-Type, X-Gssv-Client, X-MS-Device-Info, Bearer).
std::vector<std::string> requestHeaders(const std::string& gsToken, const std::string& resolution);
// Quality tier for gssv calls of a session kind. The xHome console agent only accepts the
// android (720) fingerprint - windows/tizen get AgentCommandError (green-nx engine.cpp, green-vita)
// - so every xhome request uses "720" whatever the user picked; cloud uses the setting.
std::string deviceTierFor(bool home, const std::string& resolution);
// True when an HTTP answer means the gsToken itself was refused: 401, or 403 whose body carries
// no gssv error code (a coded 403 such as OfferingAccessDenied is a real answer).
bool isAuthRejection(long status, const std::string& body);
} // namespace gssv
// All methods are const-safe w.r.t. internal state and may be called from different threads
// concurrently (e.g. keepAlive() from a keepalive thread while the worker polls /ice).
class GssvClient {
public:
// baseUri: offering base (Tokens::xcloudBase etc.), gsToken: matching Bearer token.
GssvClient(const std::string& baseUri, const std::string& gsToken, const Settings& settings);
// Stop any session still running for this account (GET /v5/sessions/{kind}/active + DELETE).
bool cleanupActive(SessionKind kind);
// POST /v5/sessions/{cloud|home}/play. titleOrServerId = titleId (Cloud) or serverId (Home).
bool play(SessionKind kind, const std::string& titleOrServerId, SessionInfo& out, std::string& err);
// GET {sessionPath}/state; updates io.state (+ error fields).
bool pollState(SessionInfo& io, std::string& err);
// POST {sessionPath}/connect with the user LPT (xCloud, at ReadyToConnect).
bool connect(const SessionInfo& s, const std::string& lpt, std::string& err);
// Seconds between keepalives (from /configuration keepAlivePulseInSeconds; default 20).
int keepAliveSeconds(const SessionInfo& s);
// Queue wait time in seconds for a cloud title (GET /v1/waittime/{titleId}); -1 if unknown.
int waitTimeSeconds(const std::string& titleId);
// POST {sessionPath}/sdp with proto::sdpPostBody(offer).
bool sendSdpOffer(const SessionInfo& s, const std::string& sdp, std::string& err);
// GET {sessionPath}/sdp. ready=false while the server has not answered yet (HTTP 204).
bool pollSdpAnswer(const SessionInfo& s, std::string& answerSdp, bool& ready, std::string& err);
// POST {sessionPath}/ice with proto::icePostBody(candidateLines, ufrag).
bool sendIce(const SessionInfo& s, const std::vector<std::string>& candidateLines, const std::string& ufrag,
std::string& err);
// GET {sessionPath}/ice. remoteCandidates = "candidate:..." lines (Teredo not yet expanded).
bool pollIce(const SessionInfo& s, std::vector<std::string>& remoteCandidates, bool& ready, std::string& err);
// POST {sessionPath}/keepalive.
bool keepAlive(const SessionInfo& s);
// DELETE {sessionPath}. timeoutSec bounds the whole request (shutdown uses a short one).
bool stop(const SessionInfo& s, long timeoutSec = 20);
// True once any request of this client was rejected as unauthorized (isAuthRejection):
// the gsToken is no longer accepted and must be re-authorized (Auth::invalidateStreaming).
bool authRejected() const { return authRejected_.load(); }
private:
// Absolute URL for a session sub-resource ("" = the session itself, "state", "sdp", ...).
std::string sessionUrl(const SessionInfo& s, const std::string& suffix) const;
// Absolute URL on the base host for an API path ("v5/sessions/cloud/play").
std::string apiUrl(const std::string& path) const;
std::vector<std::string> headers() const;
// Records an auth rejection (see authRejected()).
void noteResponse(const HttpResponse& r);
std::string base_, token_;
Settings settings_;
std::atomic<bool> authRejected_{false};
};
} // namespace xc
+381
View File
@@ -0,0 +1,381 @@
// Nubix — minimal blocking HTTPS client (libcurl + OpenSSL).
// Request setup follows green-nx src/core/http.cpp (GPL-3.0, error-buffer diagnostics, one TLS
// connection per transfer) adapted to a stateless, thread-safe API.
// Portions derived from green-nx (https://github.com/rmrf404/green-nx), Copyright (C) the green-nx authors, GPL-3.0; modified by Nubix contributors, 2026.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include "core/http.hpp"
#include <curl/curl.h>
#include <sys/stat.h>
#include <algorithm>
#include <cctype>
#include <chrono>
#include <ctime>
#include <mutex>
#include <string>
#include <thread>
#include "core/http_internal.hpp"
#include "core/log.hpp"
#include "core/ratelimit.hpp"
namespace xc {
namespace {
std::mutex g_mutex; // guards g_caBundle, g_forwardedFor, g_inited
std::string g_caBundle;
std::string g_forwardedFor;
bool g_inited = false;
thread_local bool t_quiet = false;
thread_local const std::atomic<bool>* t_abort = nullptr;
// CURLOPT_XFERINFOFUNCTION: non-zero aborts the transfer (CURLE_ABORTED_BY_CALLBACK).
int xferInfoCb(void* clientp, curl_off_t, curl_off_t, curl_off_t, curl_off_t) {
const auto* flag = static_cast<const std::atomic<bool>*>(clientp);
return (flag && flag->load()) ? 1 : 0;
}
constexpr size_t kMaxBody = 64u * 1024u * 1024u; // refuse absurd responses (memory on PS5)
struct Transfer {
HttpResponse* resp;
bool overflow = false;
};
size_t writeCb(char* ptr, size_t size, size_t nmemb, void* userdata) {
auto* t = static_cast<Transfer*>(userdata);
const size_t n = size * nmemb;
if (t->resp->body.size() + n > kMaxBody) {
t->overflow = true;
return 0; // aborts with CURLE_WRITE_ERROR
}
t->resp->body.append(ptr, n);
return n;
}
std::string trim(const std::string& s) {
size_t b = 0, e = s.size();
while (b < e && (s[b] == ' ' || s[b] == '\t')) ++b;
while (e > b && (s[e - 1] == ' ' || s[e - 1] == '\t' || s[e - 1] == '\r' || s[e - 1] == '\n')) --e;
return s.substr(b, e - b);
}
size_t headerCb(char* buf, size_t size, size_t nitems, void* userdata) {
auto* t = static_cast<Transfer*>(userdata);
const size_t n = size * nitems;
std::string line(buf, n);
// A new status line starts a new response (redirect / 100-continue): drop older headers.
if (line.compare(0, 5, "HTTP/") == 0) {
t->resp->headers.clear();
return n;
}
const size_t colon = line.find(':');
if (colon == std::string::npos) return n;
std::string key = trim(line.substr(0, colon));
std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
std::string value = trim(line.substr(colon + 1));
auto it = t->resp->headers.find(key);
if (it == t->resp->headers.end()) t->resp->headers.emplace(std::move(key), std::move(value));
else it->second += ", " + value;
return n;
}
// URL without query string, for logs (queries may carry ids we do not need in log files).
std::string logUrl(const std::string& url) {
const size_t q = url.find('?');
return q == std::string::npos ? url : url.substr(0, q) + "?…";
}
std::string snippet(const std::string& body) {
std::string s = body.substr(0, 300);
for (char& c : s)
if (c == '\n' || c == '\r' || c == '\t') c = ' ';
if (body.size() > 300) s += "…";
return s;
}
bool hasHeader(const std::vector<std::string>& headers, const char* name) {
const size_t len = std::char_traits<char>::length(name);
for (const auto& h : headers) {
if (h.size() <= len || h[len] != ':') continue;
bool eq = true;
for (size_t i = 0; i < len && eq; ++i)
eq = std::tolower(static_cast<unsigned char>(h[i])) == std::tolower(static_cast<unsigned char>(name[i]));
if (eq) return true;
}
return false;
}
} // namespace
namespace httpdetail {
QuietErrors::QuietErrors() : prev_(t_quiet) { t_quiet = true; }
QuietErrors::~QuietErrors() { t_quiet = prev_; }
} // namespace httpdetail
void Http::globalInit(const std::string& caBundlePath) {
std::lock_guard<std::mutex> lk(g_mutex);
if (!g_inited) {
CURLcode rc = curl_global_init(CURL_GLOBAL_DEFAULT);
if (rc != CURLE_OK) XC_LOGE("http: curl_global_init failed: %s", curl_easy_strerror(rc));
g_inited = true;
const curl_version_info_data* v = curl_version_info(CURLVERSION_NOW);
if (v) XC_LOGI("http: libcurl %s (%s)", v->version, v->ssl_version ? v->ssl_version : "no TLS");
}
g_caBundle.clear();
if (!caBundlePath.empty()) {
struct stat st {};
if (::stat(caBundlePath.c_str(), &st) == 0 && st.st_size > 0) {
g_caBundle = caBundlePath;
XC_LOGD("http: CA bundle %s (%lld bytes)", caBundlePath.c_str(), (long long)st.st_size);
} else {
XC_LOGW("http: CA bundle %s not found; using libcurl's built-in CA path", caBundlePath.c_str());
}
}
}
void Http::globalCleanup() {
std::lock_guard<std::mutex> lk(g_mutex);
if (g_inited) {
curl_global_cleanup();
g_inited = false;
}
}
const std::atomic<bool>* Http::setThreadAbortFlag(const std::atomic<bool>* flag) {
const std::atomic<bool>* prev = t_abort;
t_abort = flag;
return prev;
}
const std::atomic<bool>* Http::threadAbortFlag() { return t_abort; }
void Http::setForwardedFor(const std::string& ip) {
std::lock_guard<std::mutex> lk(g_mutex);
g_forwardedFor = ip;
}
namespace {
HttpResponse performOnce(const std::string& method, const std::string& url,
const std::vector<std::string>& headers, const std::string& body, long timeoutSec) {
HttpResponse resp;
std::string caBundle, forwardedFor;
{
std::lock_guard<std::mutex> lk(g_mutex);
if (!g_inited) {
// Be forgiving if a caller forgot globalInit(); curl_global_init is not thread-safe
// on old libcurl, hence under the mutex.
curl_global_init(CURL_GLOBAL_DEFAULT);
g_inited = true;
}
caBundle = g_caBundle;
forwardedFor = g_forwardedFor;
}
const std::atomic<bool>* abortFlag = t_abort;
if (abortFlag && abortFlag->load()) {
resp.error = "aborted";
XC_LOGD("http: %s %s skipped: aborted", method.c_str(), logUrl(url).c_str());
return resp;
}
CURL* curl = curl_easy_init();
if (!curl) {
resp.error = "curl_easy_init failed";
XC_LOGE("http: %s %s: %s", method.c_str(), logUrl(url).c_str(), resp.error.c_str());
return resp;
}
// curl_easy_strerror() only names the error class; the TLS backend's actual reason goes to
// the error buffer (green-nx).
char errBuf[CURL_ERROR_SIZE] = {0};
Transfer xfer{&resp};
curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, errBuf);
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); // required for multi-threaded use
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &xfer);
curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, headerCb);
curl_easy_setopt(curl, CURLOPT_HEADERDATA, &xfer);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, ""); // all built-in encodings (gzip, deflate, ...)
const long total = timeoutSec > 0 ? timeoutSec : 20L;
curl_easy_setopt(curl, CURLOPT_TIMEOUT, total);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, std::min(total, 10L));
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
#if LIBCURL_VERSION_NUM >= 0x075500 // 7.85.0
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "http,https");
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
#endif
// One TLS connection per transfer, closed afterwards (green-nx: consoles have few TLS sessions).
curl_easy_setopt(curl, CURLOPT_MAXCONNECTS, 1L);
curl_easy_setopt(curl, CURLOPT_FORBID_REUSE, 1L);
if (!caBundle.empty()) curl_easy_setopt(curl, CURLOPT_CAINFO, caBundle.c_str());
if (abortFlag) {
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xferInfoCb);
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, const_cast<std::atomic<bool>*>(abortFlag));
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
}
if (method == "GET") {
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
} else if (method == "POST") {
// Always send a body (possibly empty, Content-Length: 0) — gssv keepalive posts nothing.
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str());
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, static_cast<curl_off_t>(body.size()));
} else {
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method.c_str());
if (!body.empty() || method == "PUT" || method == "PATCH") {
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str());
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, static_cast<curl_off_t>(body.size()));
}
}
curl_slist* list = nullptr;
if (!forwardedFor.empty()) list = curl_slist_append(list, ("X-Forwarded-For: " + forwardedFor).c_str());
if (!hasHeader(headers, "User-Agent"))
list = curl_slist_append(list, "User-Agent: nubix/" XC_VERSION);
if (method == "POST" && !hasHeader(headers, "Expect"))
list = curl_slist_append(list, "Expect:"); // no 100-continue round trip
for (const auto& h : headers) list = curl_slist_append(list, h.c_str());
if (list) curl_easy_setopt(curl, CURLOPT_HTTPHEADER, list);
const auto t0 = std::chrono::steady_clock::now();
const CURLcode code = curl_easy_perform(curl);
const long ms = static_cast<long>(
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - t0).count());
if (code == CURLE_ABORTED_BY_CALLBACK && abortFlag && abortFlag->load()) {
resp.error = "aborted";
resp.status = 0;
XC_LOGD("http: %s %s aborted after %ld ms", method.c_str(), logUrl(url).c_str(), ms);
} else if (code != CURLE_OK) {
std::string msg = curl_easy_strerror(code);
if (xfer.overflow) msg += " [response too large]";
else if (errBuf[0]) msg += std::string(" [") + errBuf + "]";
if (code == CURLE_PEER_FAILED_VERIFICATION || code == CURLE_SSL_CONNECT_ERROR ||
code == CURLE_SSL_CACERT_BADFILE) {
// Name the address actually reached: a DNS block presenting its own certificate reads
// as "via 127.0.0.1" instead of a guessing game (green-nx #58).
char* ip = nullptr;
curl_easy_getinfo(curl, CURLINFO_PRIMARY_IP, &ip);
if (ip && *ip) msg += std::string(" via ") + ip;
}
long osErrno = 0;
curl_easy_getinfo(curl, CURLINFO_OS_ERRNO, &osErrno);
if (osErrno) msg += " errno=" + std::to_string(osErrno);
resp.error = msg;
resp.status = 0;
XC_LOGW("http: %s %s failed after %ld ms: %s", method.c_str(), logUrl(url).c_str(), ms, msg.c_str());
} else {
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &resp.status);
if (resp.status >= 200 && resp.status < 300) {
XC_LOGD("http: %s %s -> %ld (%zu B, %ld ms)", method.c_str(), logUrl(url).c_str(), resp.status,
resp.body.size(), ms);
} else if (t_quiet) {
XC_LOGD("http: %s %s -> %ld (%ld ms): %s", method.c_str(), logUrl(url).c_str(), resp.status, ms,
snippet(resp.body).c_str());
} else {
XC_LOGW("http: %s %s -> HTTP %ld (%ld ms): %s", method.c_str(), logUrl(url).c_str(), resp.status, ms,
snippet(resp.body).c_str());
}
}
if (list) curl_slist_free_all(list);
curl_easy_cleanup(curl);
return resp;
}
// Abortable sleep in 100 ms slices. False when the thread's abort flag was raised.
bool pacedSleep(int64_t ms, const std::atomic<bool>* abortFlag) {
const int64_t until = steadyNowMs() + ms;
for (;;) {
if (abortFlag && abortFlag->load()) return false;
const int64_t left = until - steadyNowMs();
if (left <= 0) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(std::min<int64_t>(left, 100)));
}
}
} // namespace
// Pacing wrapper around performOnce (see ratelimit.hpp):
// * every request takes a token from its host's bucket (waits briefly when the bucket is empty);
// * a host answering 429 (or 503 + Retry-After) is put into cooldown; requests to it wait the
// cooldown out when it is short, or fail fast ("rate limited") when it is long, instead of
// hammering the server;
// * idempotent requests (GET/DELETE) are retried after a short Retry-After, at most twice.
// POSTs (session start, SDP, ICE, keepalive, token polls) are never repeated here.
HttpResponse Http::request(const std::string& method, const std::string& url,
const std::vector<std::string>& headers, const std::string& body,
long timeoutSec) {
constexpr int64_t kMaxCooldownWaitMs = 15000; // wait out shorter cooldowns, fail on longer ones
constexpr int kMaxRetries = 2;
const std::atomic<bool>* abortFlag = t_abort;
const std::string host = urlHost(url);
const bool idempotent = method == "GET" || method == "DELETE" || method == "HEAD";
RateLimiter& limiter = RateLimiter::global();
for (int attempt = 0;; ++attempt) {
for (;;) {
bool cooldown = false;
const int64_t wait = limiter.reserve(host, steadyNowMs(), &cooldown);
if (wait <= 0) break;
if (cooldown && wait > kMaxCooldownWaitMs) {
HttpResponse r;
const int sec = static_cast<int>((wait + 999) / 1000);
r.status = 429;
r.headers["retry-after"] = std::to_string(sec);
r.error = "rate limited by the server, retry in " + std::to_string(sec) + " s";
XC_LOGW("http: %s %s not sent: %s is cooling down for %d s", method.c_str(), logUrl(url).c_str(),
host.c_str(), sec);
return r;
}
if (cooldown)
XC_LOGI("http: waiting %lld ms for %s (rate limit cooldown)", static_cast<long long>(wait), host.c_str());
if (!pacedSleep(wait, abortFlag)) {
HttpResponse r;
r.error = "aborted";
return r;
}
}
HttpResponse resp = performOnce(method, url, headers, body, timeoutSec);
int retryAfter = -1;
auto it = resp.headers.find("retry-after");
if (it != resp.headers.end()) retryAfter = parseRetryAfter(it->second, static_cast<int64_t>(std::time(nullptr)));
limiter.onResponse(host, resp.status, retryAfter, steadyNowMs());
const bool limited = resp.status == 429 || (resp.status == 503 && retryAfter >= 0);
if (!limited) return resp;
const int64_t cool = limiter.cooldownRemaining(host, steadyNowMs());
XC_LOGW("http: %s rate limited (HTTP %ld), backing off %lld ms", host.c_str(), resp.status,
static_cast<long long>(cool));
if (!idempotent || attempt >= kMaxRetries || cool > kMaxCooldownWaitMs) return resp;
// The cooldown loop above waits before the retry.
}
}
std::string Http::urlEncode(const std::string& s) {
static const char* hex = "0123456789ABCDEF";
std::string out;
out.reserve(s.size() * 3);
for (unsigned char c : s) {
const bool alnum = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9');
if (alnum || c == '-' || c == '_' || c == '.' || c == '~') {
out += static_cast<char>(c);
} else {
out += '%';
out += hex[c >> 4];
out += hex[c & 0x0F];
}
}
return out;
}
} // namespace xc
+68
View File
@@ -0,0 +1,68 @@
// Nubix — minimal blocking HTTPS client (libcurl + OpenSSL).
// Portions derived from green-nx (https://github.com/rmrf404/green-nx), Copyright (C) the green-nx authors, GPL-3.0; modified by Nubix contributors, 2026.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <atomic>
#include <map>
#include <string>
#include <vector>
namespace xc {
struct HttpResponse {
long status = 0; // HTTP status code (0 on transport error)
std::string body; // response body
std::map<std::string, std::string> headers; // response headers, keys lower-cased
std::string error; // transport error (curl), empty on success
// True when the transfer succeeded and the status is 2xx.
bool ok() const { return error.empty() && status >= 200 && status < 300; }
};
class Http {
public:
// Call once at startup (curl_global_init). caBundlePath is used as CURLOPT_CAINFO for
// every request (assets/cacert.pem); empty = curl's built-in default.
static void globalInit(const std::string& caBundlePath);
// Call once at shutdown (curl_global_cleanup).
static void globalCleanup();
// Perform a blocking request. method: GET/POST/PUT/DELETE. headers: "Name: value" lines.
// Follows redirects, accepts gzip, thread-safe (one easy handle per call).
static HttpResponse request(const std::string& method, const std::string& url,
const std::vector<std::string>& headers, const std::string& body = "",
long timeoutSec = 20);
// Optional "X-Forwarded-For: <ip>" added to every request (geo/region override used by
// greenlight/XStreaming for unsupported countries). Empty = off. Thread-safe.
static void setForwardedFor(const std::string& ip);
// Per-thread cancellation (green-nx style): while a flag is installed on the calling thread,
// requests made on that thread fail fast with error "aborted" once *flag becomes true
// (checked before the transfer and from curl's progress callback, i.e. within ~1 s).
// nullptr removes it. Returns the previously installed flag. The flag must outlive its use.
static const std::atomic<bool>* setThreadAbortFlag(const std::atomic<bool>* flag);
// Flag currently installed on the calling thread (nullptr = none). Lets helper threads
// spawned for one job inherit its cancellation.
static const std::atomic<bool>* threadAbortFlag();
// RAII: install `flag` (may be nullptr to temporarily disable cancellation, e.g. for a
// mandatory cleanup DELETE) for the current scope, restoring the previous one afterwards.
class AbortScope {
public:
explicit AbortScope(const std::atomic<bool>* flag) : prev_(setThreadAbortFlag(flag)) {}
~AbortScope() { setThreadAbortFlag(prev_); }
AbortScope(const AbortScope&) = delete;
AbortScope& operator=(const AbortScope&) = delete;
private:
const std::atomic<bool>* prev_;
};
// Percent-encode a string for application/x-www-form-urlencoded bodies / query strings.
static std::string urlEncode(const std::string& s);
};
} // namespace xc
+23
View File
@@ -0,0 +1,23 @@
// Nubix — internal helpers of the Http module (used only by core/http.cpp and core/auth.cpp).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
namespace xc {
namespace httpdetail {
// While alive, non-2xx responses on the current thread are logged at Debug instead of Warn.
// Used for expected errors such as the 400 "authorization_pending" device-code poll replies.
class QuietErrors {
public:
QuietErrors();
~QuietErrors();
QuietErrors(const QuietErrors&) = delete;
QuietErrors& operator=(const QuietErrors&) = delete;
private:
bool prev_;
};
} // namespace httpdetail
} // namespace xc
+108
View File
@@ -0,0 +1,108 @@
// Nubix — logging.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include "core/log.hpp"
#include <chrono>
#include <cstdio>
#include <ctime>
#include <mutex>
#include <sys/stat.h>
namespace xc {
namespace {
// log.txt lives on the console's /data partition: once it grows past this it is moved to
// log.prev.txt at the next start (one generation kept), so the file never grows unbounded.
constexpr long long kMaxLogBytes = 2LL * 1024 * 1024;
std::string previousLogPath(const std::string& path) {
const size_t slash = path.find_last_of('/');
const size_t dot = path.find_last_of('.');
if (dot != std::string::npos && (slash == std::string::npos || dot > slash))
return path.substr(0, dot) + ".prev" + path.substr(dot);
return path + ".prev";
}
std::mutex g_mutex;
FILE* g_file = nullptr;
LogLevel g_level = LogLevel::Info;
const auto g_start = std::chrono::steady_clock::now();
const char* tag(LogLevel l) {
switch (l) {
case LogLevel::Debug: return "D";
case LogLevel::Info: return "I";
case LogLevel::Warn: return "W";
case LogLevel::Error: return "E";
}
return "?";
}
} // namespace
void logInit(const std::string& filePath, LogLevel minLevel) {
std::lock_guard<std::mutex> lk(g_mutex);
g_level = minLevel;
if (g_file) {
std::fclose(g_file);
g_file = nullptr;
}
if (!filePath.empty()) {
struct stat st{};
if (::stat(filePath.c_str(), &st) == 0 && static_cast<long long>(st.st_size) > kMaxLogBytes) {
const std::string prev = previousLogPath(filePath);
std::remove(prev.c_str());
if (std::rename(filePath.c_str(), prev.c_str()) != 0) std::remove(filePath.c_str());
}
g_file = std::fopen(filePath.c_str(), "a");
if (g_file) std::setvbuf(g_file, nullptr, _IOLBF, 0);
}
}
void logSetLevel(LogLevel minLevel) {
std::lock_guard<std::mutex> lk(g_mutex);
g_level = minLevel;
}
LogLevel logLevel() {
std::lock_guard<std::mutex> lk(g_mutex);
return g_level;
}
void logv(LogLevel level, const char* fmt, va_list ap) {
char msg[2048];
std::vsnprintf(msg, sizeof(msg), fmt, ap);
const double t = std::chrono::duration<double>(std::chrono::steady_clock::now() - g_start).count();
std::lock_guard<std::mutex> lk(g_mutex);
if (level < g_level) return;
std::fprintf(stdout, "[%9.3f] %s %s\n", t, tag(level), msg);
std::fflush(stdout);
if (g_file) {
std::time_t now = std::time(nullptr);
char ts[32];
std::tm tmv{};
localtime_r(&now, &tmv);
std::strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", &tmv);
std::fprintf(g_file, "%s [%9.3f] %s %s\n", ts, t, tag(level), msg);
}
}
void log(LogLevel level, const char* fmt, ...) {
va_list ap;
va_start(ap, fmt);
logv(level, fmt, ap);
va_end(ap);
}
void logShutdown() {
std::lock_guard<std::mutex> lk(g_mutex);
if (g_file) {
std::fclose(g_file);
g_file = nullptr;
}
}
} // namespace xc
+41
View File
@@ -0,0 +1,41 @@
// Nubix — logging.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstdarg>
#include <string>
namespace xc {
enum class LogLevel { Debug = 0, Info = 1, Warn = 2, Error = 3 };
// Initialise logging. Messages >= minLevel go to stdout; if filePath is non-empty they are
// also appended to that file (used on PS5: <dataDir>/log.txt). Safe to call more than once.
void logInit(const std::string& filePath, LogLevel minLevel = LogLevel::Info);
// Change the minimum level at runtime (e.g. --verbose).
void logSetLevel(LogLevel minLevel);
// Current minimum level.
LogLevel logLevel();
// printf-style log line, thread-safe; a timestamp, level tag and newline are added.
void log(LogLevel level, const char* fmt, ...)
#if defined(__GNUC__) || defined(__clang__)
__attribute__((format(printf, 2, 3)))
#endif
;
// va_list variant of log().
void logv(LogLevel level, const char* fmt, va_list ap);
// Flush and close the log file.
void logShutdown();
} // namespace xc
#define XC_LOGD(...) ::xc::log(::xc::LogLevel::Debug, __VA_ARGS__)
#define XC_LOGI(...) ::xc::log(::xc::LogLevel::Info, __VA_ARGS__)
#define XC_LOGW(...) ::xc::log(::xc::LogLevel::Warn, __VA_ARGS__)
#define XC_LOGE(...) ::xc::log(::xc::LogLevel::Error, __VA_ARGS__)
+490
View File
@@ -0,0 +1,490 @@
// Nubix — xCloud wire protocol helpers.
//
// Input packet layout, control/message JSON and the /sdp + /ice bodies are ported from
// green-nx (GPL-3.0): src/core/xcloud_protocol.cpp and
// src/core/session.cpp. The offer template combines GreenOvercast's buildCompatibleOffer
// (src/session/webrtc_session.zig, MPL-2.0; session header + data-channel m-line, proven with
// libdatachannel) with green-nx's media m-lines (deps/patches/libpeer-switch.patch,
// sdp_append_h264/sdp_append_opus + session.cpp sdp_scale_video_caps_1080).
// Portions derived from green-nx (https://github.com/rmrf404/green-nx), Copyright (C) the green-nx authors, GPL-3.0; modified by Nubix contributors, 2026.
// Portions derived from GreenOvercast (https://github.com/Producdevity/GreenOvercast), MPL-2.0, used here under GPL-3.0 (MPL-2.0 section 3.3).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include "core/protocol.hpp"
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <cstdio>
#include <cstring>
#include <mutex>
#include <random>
#include <sstream>
#include <nlohmann/json.hpp>
namespace xc {
namespace proto {
using nlohmann::json;
// Serialize without ever throwing: invalid UTF-8 (e.g. from a hostile server payload echoed
// back) is replaced with U+FFFD instead of raising json::type_error.
constexpr json::error_handler_t kReplace = json::error_handler_t::replace;
namespace {
// ---- little-endian writers (explicit byte order, independent of host endianness) ----------
void putU8(std::vector<uint8_t>& out, uint8_t v) { out.push_back(v); }
void putU16(std::vector<uint8_t>& out, uint16_t v) {
out.push_back(uint8_t(v & 0xFF));
out.push_back(uint8_t(v >> 8));
}
void putU32(std::vector<uint8_t>& out, uint32_t v) {
for (int i = 0; i < 4; ++i) out.push_back(uint8_t((v >> (8 * i)) & 0xFF));
}
void putU32BE(std::vector<uint8_t>& out, uint32_t v) {
for (int i = 3; i >= 0; --i) out.push_back(uint8_t((v >> (8 * i)) & 0xFF));
}
void putF64(std::vector<uint8_t>& out, double v) {
uint64_t bits;
static_assert(sizeof(bits) == sizeof(v), "double must be 64-bit");
std::memcpy(&bits, &v, sizeof(bits));
for (int i = 0; i < 8; ++i) out.push_back(uint8_t((bits >> (8 * i)) & 0xFF));
}
uint16_t getU16(const uint8_t* p) { return uint16_t(p[0] | (uint16_t(p[1]) << 8)); }
uint32_t getU32(const uint8_t* p) {
return uint32_t(p[0]) | (uint32_t(p[1]) << 8) | (uint32_t(p[2]) << 16) | (uint32_t(p[3]) << 24);
}
// ---- string helpers -----------------------------------------------------------------------
std::string trimmed(const std::string& s) {
size_t b = 0, e = s.size();
while (b < e && (s[b] == ' ' || s[b] == '\t' || s[b] == '\r' || s[b] == '\n')) ++b;
while (e > b && (s[e - 1] == ' ' || s[e - 1] == '\t' || s[e - 1] == '\r' || s[e - 1] == '\n')) --e;
return s.substr(b, e - b);
}
std::vector<std::string> splitWs(const std::string& s) {
std::istringstream is(s);
std::vector<std::string> out;
std::string tok;
while (is >> tok) out.push_back(tok);
return out;
}
bool startsWith(const std::string& s, const char* prefix) { return s.rfind(prefix, 0) == 0; }
bool iequals(const std::string& a, const char* b) {
size_t n = std::strlen(b);
if (a.size() != n) return false;
for (size_t i = 0; i < n; ++i) {
char x = a[i], y = b[i];
if (x >= 'A' && x <= 'Z') x = char(x - 'A' + 'a');
if (y >= 'A' && y <= 'Z') y = char(y - 'A' + 'a');
if (x != y) return false;
}
return true;
}
} // namespace
// ============================================================================================
// Input channel
// ============================================================================================
InputSerializer::InputSerializer() : start_(std::chrono::steady_clock::now()) {}
void InputSerializer::reset() {
seq_ = 0;
start_ = std::chrono::steady_clock::now();
}
double InputSerializer::elapsedMs() const {
auto d = std::chrono::steady_clock::now() - start_;
double ms = std::chrono::duration<double, std::milli>(d).count();
return ms < 0.0 ? 0.0 : ms;
}
std::vector<uint8_t> InputSerializer::clientMetadata(uint8_t maxTouch) {
std::vector<uint8_t> out;
out.reserve(kClientMetadataPacketSize);
putU16(out, report::ClientMetadata);
putU32(out, ++seq_);
putF64(out, elapsedMs());
putU8(out, maxTouch);
return out;
}
std::vector<uint8_t> InputSerializer::gamepad(const GamepadState& st, uint8_t index) {
std::vector<uint8_t> out;
out.reserve(kGamepadPacketSize);
// 14-byte header
putU16(out, report::Gamepad);
putU32(out, ++seq_);
putF64(out, elapsedMs());
// Gamepad section: u8 count, then one 23-byte frame (offsets relative to the frame start).
putU8(out, 1);
putU8(out, index); // +0 gamepadIndex
putU16(out, st.buttons); // +1 buttons
putU16(out, uint16_t(st.lx)); // +3 LeftThumbX
putU16(out, uint16_t(st.ly)); // +5 LeftThumbY (Xbox convention, up = +)
putU16(out, uint16_t(st.rx)); // +7 RightThumbX
putU16(out, uint16_t(st.ry)); // +9 RightThumbY
putU16(out, st.lt); // +11 LeftTrigger
putU16(out, st.rt); // +13 RightTrigger
putU32(out, 1); // +15 PhysicalPhysicality, little-endian 1
putU32BE(out, 1); // +19 VirtualPhysicality, big-endian 1 (packet.ts setUint32(.., 1, false))
return out;
}
void InputSerializer::rollback() {
if (seq_ > 0) --seq_;
}
// ============================================================================================
// Control / message channel JSON
// ============================================================================================
std::string newUuid() {
static std::mutex m;
static std::mt19937_64 rng = [] {
std::vector<uint32_t> seed;
try {
std::random_device rd; // arc4random on FreeBSD/PS5 libc++, /dev/urandom elsewhere
for (int i = 0; i < 8; ++i) seed.push_back(rd());
} catch (...) {
// no entropy source: fall back to clocks + addresses (uniqueness, not secrecy, matters)
}
const auto now = uint64_t(std::chrono::steady_clock::now().time_since_epoch().count());
const auto wall = uint64_t(std::chrono::system_clock::now().time_since_epoch().count());
const auto addr = uint64_t(reinterpret_cast<uintptr_t>(&seed));
for (uint64_t v : {now, wall, addr}) {
seed.push_back(uint32_t(v));
seed.push_back(uint32_t(v >> 32));
}
std::seed_seq seq(seed.begin(), seed.end());
return std::mt19937_64(seq);
}();
uint8_t b[16];
{
std::lock_guard<std::mutex> lock(m);
uint64_t hi = rng(), lo = rng();
for (int i = 0; i < 8; ++i) {
b[i] = uint8_t(hi >> (8 * i));
b[8 + i] = uint8_t(lo >> (8 * i));
}
}
b[6] = uint8_t((b[6] & 0x0F) | 0x40); // version 4
b[8] = uint8_t((b[8] & 0x3F) | 0x80); // RFC 4122 variant
char s[37];
std::snprintf(s, sizeof(s), "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", b[0], b[1],
b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]);
return std::string(s);
}
std::string messageHandshake() {
return json{{"type", "Handshake"}, {"version", "messageV1"}, {"id", newUuid()}, {"cv", "0"}}.dump(-1, ' ', false, kReplace);
}
std::string controlAuthorization() {
return json{{"message", "authorizationRequest"}, {"accessKey", kControlAccessKey}}.dump(-1, ' ', false, kReplace);
}
std::string controlGamepadChanged(int idx, bool added) {
return json{{"message", "gamepadChanged"}, {"gamepadIndex", idx}, {"wasAdded", added}}.dump(-1, ' ', false, kReplace);
}
std::string controlResolution(const std::string& alias) {
return json{{"message", "userRequestedResolutionUpdate"}, {"resolutionAlias", alias}}.dump(-1, ' ', false, kReplace);
}
std::string controlKeyframeRequest() {
return json{{"message", "videoKeyframeRequested"}, {"ifrRequested", true}}.dump(-1, ' ', false, kReplace);
}
std::string messageEnvelope(const std::string& target, const std::string& contentJson) {
return json{{"type", "Message"}, {"content", contentJson}, {"id", newUuid()}, {"target", target}, {"cv", ""}}
.dump(-1, ' ', false, kReplace);
}
std::vector<std::string> messageInitSequence(const Settings& s, const std::string& installId, int w, int h) {
const int fps = 60;
const int kbps = s.bitrateKbps > 0 ? s.bitrateKbps : 12000;
const std::string install = installId.empty() ? newUuid() : installId;
std::vector<std::string> out;
out.push_back(messageEnvelope("/streaming/systemUi/configuration",
json{{"version", {0, 2, 0}}, {"systemUis", json::array()}}.dump(-1, ' ', false, kReplace)));
out.push_back(messageEnvelope("/streaming/properties/clientappinstallidchanged",
json{{"clientAppInstallId", install}}.dump(-1, ' ', false, kReplace)));
out.push_back(messageEnvelope("/streaming/characteristics/orientationchanged", json{{"orientation", 0}}.dump(-1, ' ', false, kReplace)));
out.push_back(messageEnvelope("/streaming/characteristics/touchinputenabledchanged",
json{{"touchInputEnabled", false}}.dump(-1, ' ', false, kReplace)));
out.push_back(messageEnvelope("/streaming/characteristics/clientdevicecapabilities",
json{{"supportsCustomResolution", true},
{"supportsHevc", false},
{"supportsHdr", false},
{"supportsFps", fps},
{"maxWidth", w},
{"maxHeight", h},
{"maxBitrateKbps", kbps},
{"video",
{{"width", w},
{"height", h},
{"maxWidth", w},
{"maxHeight", h},
{"maxBitrateKbps", kbps}}}}
.dump(-1, ' ', false, kReplace)));
out.push_back(messageEnvelope("/streaming/characteristics/dimensionschanged",
json{{"horizontal", w},
{"vertical", h},
{"preferredWidth", w},
{"preferredHeight", h},
{"safeAreaLeft", 0},
{"safeAreaTop", 0},
{"safeAreaRight", w},
{"safeAreaBottom", h},
{"supportsCustomResolution", true}}
.dump(-1, ' ', false, kReplace)));
return out;
}
std::string transactionComplete(const std::string& id, const std::string& contentJson, const std::string& cv) {
return json{{"type", "TransactionComplete"}, {"id", id}, {"content", contentJson}, {"cv", cv}}.dump(-1, ' ', false, kReplace);
}
std::string transactionComplete(const std::string& id, const std::string& contentJson) {
return transactionComplete(id, contentJson, "");
}
std::string messageUnhandled(const std::string& id, const std::string& target, const std::string& cv) {
return json{{"type", "Unhandled"}, {"id", id}, {"target", target}, {"cv", cv}}.dump(-1, ' ', false, kReplace);
}
bool parseChannelMessage(const std::string& text, ChannelMessage& out) {
out = ChannelMessage{};
json j = json::parse(text, nullptr, false);
if (j.is_discarded() || !j.is_object()) return false;
auto it = j.find("type");
if (it == j.end() || !it->is_string()) return false;
out.type = it->get<std::string>();
auto str = [&](const char* key) -> std::string {
auto f = j.find(key);
if (f == j.end() || f->is_null()) return std::string();
if (f->is_string()) return f->get<std::string>();
return f->dump();
};
out.id = str("id");
out.target = str("target");
out.content = str("content");
out.cv = str("cv");
return true;
}
bool isHandshakeAck(const std::string& text) {
ChannelMessage m;
return parseChannelMessage(text, m) && m.type == "HandshakeAck";
}
// ============================================================================================
// Server -> client input packets
// ============================================================================================
bool parseVibration(const uint8_t* d, size_t n, Vibration& out) {
if (!d || n < 2) return false;
const uint16_t type = getU16(d);
if (!(type & report::Vibration)) return false;
size_t off = 2;
if (type & report::ServerMetadata) off += 8; // sections follow in bit order
if (n < off + 11) return false;
const uint8_t* p = d + off;
// p[0] = rumbleType (0 = FourMotorRumble), ignored like every reference client.
out.pad = p[1];
out.left = p[2];
out.right = p[3];
out.lt = p[4];
out.rt = p[5];
out.durationMs = getU16(p + 6);
out.delayMs = getU16(p + 8);
out.repeat = p[10];
return true;
}
bool parseServerMetadata(const uint8_t* d, size_t n, uint32_t& width, uint32_t& height) {
width = height = 0;
if (!d || n < 10) return false;
if (!(getU16(d) & report::ServerMetadata)) return false;
height = getU32(d + 2); // height first
width = getU32(d + 6);
return true;
}
// ============================================================================================
// Teredo
// ============================================================================================
bool teredoDecode(const std::string& ipv6, std::string& ipv4, uint16_t& port) {
ipv4.clear();
port = 0;
std::string addr = trimmed(ipv6);
if (!addr.empty() && addr.front() == '[' && addr.back() == ']') addr = addr.substr(1, addr.size() - 2);
size_t pct = addr.find('%');
if (pct != std::string::npos) addr.resize(pct);
if (addr.find(':') == std::string::npos) return false;
struct in6_addr a;
if (inet_pton(AF_INET6, addr.c_str(), &a) != 1) return false;
const uint8_t* b = reinterpret_cast<const uint8_t*>(&a);
// Teredo prefix 2001:0000::/32
if (b[0] != 0x20 || b[1] != 0x01 || b[2] != 0x00 || b[3] != 0x00) return false;
port = uint16_t(((b[10] ^ 0xFF) << 8) | (b[11] ^ 0xFF));
char buf[16];
std::snprintf(buf, sizeof(buf), "%u.%u.%u.%u", unsigned(b[12] ^ 0xFF), unsigned(b[13] ^ 0xFF),
unsigned(b[14] ^ 0xFF), unsigned(b[15] ^ 0xFF));
ipv4 = buf;
return true;
}
std::vector<std::string> expandTeredoCandidates(const std::vector<std::string>& candidates) {
std::vector<std::string> out;
out.reserve(candidates.size() * 3);
int foundation = 20; // synthetic foundations, as in green-nx receive_ice_candidates
for (const std::string& line : candidates) {
std::string body = trimmed(line);
const bool hasA = startsWith(body, "a=");
if (hasA) body = body.substr(2);
if (!startsWith(body, "candidate:")) {
out.push_back(line);
continue;
}
// candidate:<foundation> <component> <transport> <priority> <address> <port> typ <type> ...
std::vector<std::string> f = splitWs(body);
std::string ipv4;
uint16_t port = 0;
if (f.size() >= 8 && iequals(f[2], "udp") && teredoDecode(f[4], ipv4, port)) {
const std::string prefix = hasA ? "a=candidate:" : "candidate:";
for (uint16_t p : {port, uint16_t(9002)}) {
out.push_back(prefix + std::to_string(foundation++) + " " + f[1] + " UDP 1 " + ipv4 + " " +
std::to_string(p) + " typ host");
}
}
out.push_back(line);
}
return out;
}
// ============================================================================================
// SDP offer + signalling bodies
// ============================================================================================
std::string buildOffer(const std::string& ufrag, const std::string& pwd, const std::string& fingerprint, bool home,
const std::string& resolution) {
const std::string u = trimmed(ufrag), p = trimmed(pwd);
std::string fp = trimmed(fingerprint);
if (startsWith(fp, "sha-256 ")) fp = trimmed(fp.substr(8));
if (u.empty() || p.empty() || fp.empty()) return std::string();
// Video caps: green-nx ships the 720p template verbatim for the 720 tier and for xHome
// (where the console agent also wants H.264 level 3.2, 42e020); 1080 tiers declare
// 1920x1080@60 decode capability (8160 MBs, 489600 MB/s).
const bool is720 = startsWith(resolution, "720");
const char* profile = home ? "42e020" : "42e01f";
const char* caps = (home || is720) ? "max-fs=3600;max-mbps=108000" : "max-fs=8160;max-mbps=489600";
std::string s;
s.reserve(1600);
auto L = [&s](const std::string& line) {
s += line;
s += "\r\n";
};
L("v=0");
L("o=- 4611731400430051 2 IN IP4 127.0.0.1");
L("s=-");
L("t=0 0");
L("a=group:BUNDLE video audio 0");
L("a=ice-ufrag:" + u);
L("a=ice-pwd:" + p);
L("a=fingerprint:sha-256 " + fp);
L("a=setup:actpass");
// video (green-nx sdp_append_h264)
L("m=video 9 UDP/TLS/RTP/SAVPF 102");
L("c=IN IP4 0.0.0.0");
L("a=mid:video");
L("a=rtcp-mux");
L("a=rtcp-rsize");
L("a=rtpmap:102 H264/90000");
L(std::string("a=fmtp:102 level-asymmetry-allowed=0;packetization-mode=1;profile-level-id=") + profile + ";" +
caps);
L("a=rtcp-fb:102 goog-remb");
L("a=rtcp-fb:102 ccm fir");
L("a=rtcp-fb:102 nack");
L("a=rtcp-fb:102 nack pli");
L("a=recvonly");
// audio (green-nx sdp_append_opus)
L("m=audio 9 UDP/TLS/RTP/SAVPF 111");
L("c=IN IP4 0.0.0.0");
L("a=mid:audio");
L("a=rtcp-mux");
L("a=rtcp-rsize");
L("a=rtpmap:111 opus/48000/2");
L("a=fmtp:111 minptime=10;useinbandfec=1;stereo=1");
L("a=recvonly");
// data channels (GreenOvercast; libdatachannel assigns the SCTP m-line mid "0")
L("m=application 9 UDP/DTLS/SCTP webrtc-datachannel");
L("c=IN IP4 0.0.0.0");
L("a=mid:0");
L("a=sctp-port:5000");
L("a=max-message-size:262144");
return s;
}
std::string sdpPostBody(const std::string& offer) {
json body = {
{"messageType", "offer"},
{"sdp", offer},
{"requestId", "1"},
{"configuration",
{{"chatConfiguration",
{{"bytesPerSample", 2},
{"expectedClipDurationMs", 20},
{"format", {{"codec", "opus"}, {"container", "webm"}}},
{"numChannels", 1},
{"sampleFrequencyHz", 24000}}},
{"chat", {{"minVersion", 1}, {"maxVersion", 1}}},
{"control", {{"minVersion", 1}, {"maxVersion", 3}}},
{"input", {{"minVersion", 1}, {"maxVersion", 9}}},
{"message", {{"minVersion", 1}, {"maxVersion", 1}}},
{"reliableinput", {{"minVersion", 9}, {"maxVersion", 9}}},
{"unreliableinput", {{"minVersion", 9}, {"maxVersion", 9}}}}},
};
return body.dump(-1, ' ', false, kReplace);
}
std::string icePostBody(const std::vector<std::string>& candidates, const std::string& ufrag) {
auto entry = [&ufrag](const std::string& cand) {
return json{{"candidate", cand}, {"sdpMid", "0"}, {"sdpMLineIndex", 0}, {"usernameFragment", ufrag}}.dump(-1, ' ', false, kReplace);
};
json list = json::array();
for (const std::string& raw : candidates) {
std::string c = trimmed(raw);
if (startsWith(c, "a=")) c = c.substr(2);
if (!startsWith(c, "candidate:")) continue;
std::vector<std::string> f = splitWs(c);
if (f.size() < 8) continue;
if (iequals(f[2], "tcp")) continue; // xCloud only does UDP (nano-rs filters TCP too)
list.push_back(entry(c));
}
list.push_back(entry("a=end-of-candidates"));
return json{{"messageType", "iceCandidate"}, {"candidate", list}}.dump(-1, ' ', false, kReplace);
}
} // namespace proto
} // namespace xc
+184
View File
@@ -0,0 +1,184 @@
// Nubix — xCloud wire protocol helpers: input packets, control/message JSON,
// vibration parsing, Teredo decoding, SDP offer template, /sdp and /ice POST bodies.
// Layouts cross-checked against green-nx (xcloud_protocol.cpp) and xbox-xcloud-player
// (src/channel/input/packet.ts, src/channel/message.ts).
// Byte layouts and message shapes ported from green-nx (GPL-3.0,
// src/core/xcloud_protocol.cpp, src/core/session.cpp) and cross-checked against
// xbox-xcloud-player (src/channel/input/packet.ts, src/lib/teredo.ts).
// Portions derived from green-nx (https://github.com/rmrf404/green-nx), Copyright (C) the green-nx authors, GPL-3.0; modified by Nubix contributors, 2026.
// Portions derived from GreenOvercast (https://github.com/Producdevity/GreenOvercast), MPL-2.0, used here under GPL-3.0 (MPL-2.0 section 3.3).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#include "core/config.hpp"
namespace xc {
// Absolute pad state in Xbox conventions. ly/ry already Xbox convention (up = +).
struct GamepadState {
uint16_t buttons = 0; // btn:: bitmask
int16_t lx = 0, ly = 0, rx = 0, ry = 0;
uint16_t lt = 0, rt = 0; // 0..65535
bool operator==(const GamepadState& o) const {
return buttons == o.buttons && lx == o.lx && ly == o.ly && rx == o.rx && ry == o.ry && lt == o.lt &&
rt == o.rt;
}
bool operator!=(const GamepadState& o) const { return !(*this == o); }
};
namespace btn {
enum : uint16_t {
Nexus = 2, Menu = 4, View = 8, A = 16, B = 32, X = 64, Y = 128,
DUp = 256, DDown = 512, DLeft = 1024, DRight = 2048,
LB = 4096, RB = 8192, LS = 16384, RS = 32768
};
} // namespace btn
// Server -> client rumble request (input channel, reportType bit 128). Motor values 0..100 %.
struct Vibration {
uint8_t pad = 0, left = 0, right = 0, lt = 0, rt = 0;
uint16_t durationMs = 0, delayMs = 0;
uint8_t repeat = 0;
};
namespace proto {
// input channel reportType bits.
namespace report {
enum : uint16_t {
Metadata = 1, Gamepad = 2, Pointer = 4, ClientMetadata = 8, ServerMetadata = 16,
Mouse = 32, Keyboard = 64, Vibration = 128
};
} // namespace report
// Sizes of the fixed input packets (header 14 bytes).
constexpr size_t kInputHeaderSize = 14;
constexpr size_t kClientMetadataPacketSize = 15; // header + u8 maxTouchpoints
constexpr size_t kGamepadFrameSize = 23;
constexpr size_t kGamepadPacketSize = 38; // header + u8 count + one 23-byte frame
// Fixed accessKey of the control-channel authorizationRequest (same in every client).
constexpr const char* kControlAccessKey = "4BDB3609-C1F1-4195-9B37-FEFF45DA8B8E";
// Builds binary packets for the "input" data channel (protocol "1.0"). Header: u16 reportType,
// u32 sequence (+1 per packet, no gaps), f64 timestamp ms. All little-endian except
// VirtualPhysicality (big-endian 1, as xbox-xcloud-player writes it).
//
// Sequence numbering follows green-nx: pre-incremented, so the first packet (ClientMetadata)
// carries 1. Build each packet right before sending it and call rollback() if the send fails,
// otherwise the server sees a gap and stops applying input. Not thread-safe; guard externally.
class InputSerializer {
public:
InputSerializer();
// First packet after the channel opens: reportType 8 + u8 maxTouchpoints (15 bytes).
std::vector<uint8_t> clientMetadata(uint8_t maxTouch = 1);
// One gamepad frame (38 bytes).
std::vector<uint8_t> gamepad(const GamepadState& st, uint8_t index = 0);
// Undo the last sequence increment (call when a send failed so no gap is created).
void rollback();
// Sequence number the next packet will carry.
uint32_t nextSequence() const { return seq_ + 1; }
// Start over (new session): sequence back to 0, timestamp reference = now.
void reset();
private:
double elapsedMs() const;
uint32_t seq_ = 0; // sequence of the last packet built
std::chrono::steady_clock::time_point start_; // monotonic reference for the f64 timestamp
};
// "message" channel: {"type":"Handshake","version":"messageV1","id":<uuid>,"cv":"0"} sent when
// the channel opens.
std::string messageHandshake();
// "control" channel: authorizationRequest (accessKey 4BDB3609-C1F1-4195-9B37-FEFF45DA8B8E).
std::string controlAuthorization();
// "control" channel: gamepadChanged {gamepadIndex, wasAdded}.
std::string controlGamepadChanged(int idx, bool added);
// "control" channel: {"message":"userRequestedResolutionUpdate","resolutionAlias":alias}
// (alias "720", "720HQ", "1080", "1080HQ", "1440", "Auto"; unknown values are sent as-is).
std::string controlResolution(const std::string& alias);
// "control" channel: {"message":"videoKeyframeRequested","ifrRequested":true}.
std::string controlKeyframeRequest();
// "message" channel startup messages after HandshakeAck, in send order (green-nx
// startup_messages): systemUi/configuration, clientappinstallidchanged, orientationchanged,
// touchinputenabledchanged, clientdevicecapabilities (w/h, maxBitrateKbps = s.bitrateKbps,
// 60 fps), dimensionschanged (w/h). Each is a "Message" envelope with a fresh uuid.
// An empty installId is replaced by a random uuid.
std::vector<std::string> messageInitSequence(const Settings& s, const std::string& installId, int w, int h);
// "message" channel reply to a TransactionStart with the given id. contentJson is the reply
// object serialized as JSON (embedded as a JSON *string*, e.g. "{\"Result\":0}"); cv "".
std::string transactionComplete(const std::string& id, const std::string& contentJson);
// ---- additional message-channel helpers (additive) --------------------------------------
// Random RFC 4122 v4 UUID, lower-case with dashes (independent of xc::generateUuid).
std::string newUuid();
// One-way "Message" envelope: {"type":"Message","content":contentJson,"id":<uuid>,"target":target,"cv":""}.
std::string messageEnvelope(const std::string& target, const std::string& contentJson);
// Reply for a Message/TransactionStart we do not handle: {"type":"Unhandled","id","target","cv"}.
std::string messageUnhandled(const std::string& id, const std::string& target, const std::string& cv = "");
// TransactionComplete with an explicit correlation vector.
std::string transactionComplete(const std::string& id, const std::string& contentJson, const std::string& cv);
// Parsed "message"-channel frame.
struct ChannelMessage {
std::string type; // Handshake, HandshakeAck, Message, TransactionStart, TransactionComplete, ...
std::string id;
std::string target; // e.g. /streaming/sessionLifetimeManagement/serverInitiatedDisconnect
std::string content; // raw content (itself JSON text when the server sent a string)
std::string cv;
};
// Parse a message-channel text frame. False if not a JSON object with a string "type".
bool parseChannelMessage(const std::string& text, ChannelMessage& out);
// True for {"type":"HandshakeAck",...}.
bool isHandshakeAck(const std::string& text);
namespace target {
constexpr const char* kServerInitiatedDisconnect = "/streaming/sessionLifetimeManagement/serverInitiatedDisconnect";
constexpr const char* kShowMessageDialog = "/streaming/systemUi/messages/ShowMessageDialog";
constexpr const char* kShowVirtualKeyboard = "/streaming/systemUi/messages/ShowVirtualKeyboard";
constexpr const char* kTitleInfo = "/streaming/properties/titleinfo";
} // namespace target
// Parse a server->client input packet carrying a Vibration section. False if not vibration.
bool parseVibration(const uint8_t* d, size_t n, Vibration& out);
// Parse a ServerMetadata section (reportType 16): stream video size. False if absent.
bool parseServerMetadata(const uint8_t* d, size_t n, uint32_t& width, uint32_t& height);
// Teredo (2001:0::/32) address -> client IPv4 + port (bytes 10..15 XOR 0xFF). False if not Teredo.
bool teredoDecode(const std::string& ipv6, std::string& ipv4, uint16_t& port);
// Rewrite remote "a=candidate"/"candidate:" lines: for each Teredo candidate append an IPv4
// candidate with the decoded address and one with port 9002 (xHome). Other lines unchanged.
// Synthetic lines ("candidate:<20+n> <component> UDP 1 <ipv4> <port> typ host", same "a="
// prefix as the source line) come right before the original Teredo line, decoded port first.
std::vector<std::string> expandTeredoCandidates(const std::vector<std::string>& candidates);
// Hand-written browser-compatible offer (GreenOvercast buildCompatibleOffer / xCloud template):
// BUNDLE video audio 0, H.264 profiles, Opus, data channel m-line mid 0, with the given ICE
// credentials and sha-256 fingerprint ("AB:CD:..."). home selects xHome tweaks.
// Session header + application m-line from GreenOvercast (proven with libdatachannel); video and
// audio m-lines are green-nx's known-good template: PT 102 H.264 with
// cloud 720: profile-level-id=42e01f;max-fs=3600;max-mbps=108000
// cloud 1080/1080HQ: profile-level-id=42e01f;max-fs=8160;max-mbps=489600
// home (any): profile-level-id=42e020;max-fs=3600;max-mbps=108000
// and Opus PT 111 "minptime=10;useinbandfec=1;stereo=1". All lines CRLF. Returns "" if
// ufrag, pwd or fingerprint is empty (a leading "sha-256 " on the fingerprint is stripped).
std::string buildOffer(const std::string& ufrag, const std::string& pwd, const std::string& fingerprint, bool home,
const std::string& resolution);
// JSON body for POST /sdp: {"messageType":"offer","sdp":..., "requestId":"1","configuration":{...}}.
std::string sdpPostBody(const std::string& offer);
// JSON body for POST /ice (green-nx shape: stringified {candidate,sdpMid:"0",sdpMLineIndex:0,
// usernameFragment} entries + end-of-candidates). Candidate lines may carry an "a=" prefix
// and trailing CR/LF (both stripped); TCP candidates are skipped.
std::string icePostBody(const std::vector<std::string>& candidates, const std::string& ufrag);
} // namespace proto
} // namespace xc
+199
View File
@@ -0,0 +1,199 @@
// Nubix — client-side request pacing (see ratelimit.hpp).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include "core/ratelimit.hpp"
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
namespace xc {
namespace {
bool endsWith(const std::string& s, const char* suffix) {
const size_t n = std::strlen(suffix);
return s.size() >= n && s.compare(s.size() - n, n, suffix) == 0;
}
// Days since 1970-01-01 for a proleptic Gregorian date (timegm is not portable to the PS5 libc).
int64_t daysFromCivil(int64_t y, unsigned m, unsigned d) {
y -= m <= 2;
const int64_t era = (y >= 0 ? y : y - 399) / 400;
const unsigned yoe = static_cast<unsigned>(y - era * 400);
const unsigned doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
return era * 146097 + static_cast<int64_t>(doe) - 719468;
}
// "Sun, 06 Nov 1994 08:49:37 GMT" (IMF-fixdate, the only form servers send today).
bool parseHttpDate(const std::string& v, int64_t& unixSec) {
static const char* kMonths[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
const size_t comma = v.find(',');
if (comma == std::string::npos) return false;
int day = 0, year = 0, hh = 0, mm = 0, ss = 0;
char mon[4] = {0};
if (std::sscanf(v.c_str() + comma + 1, " %d %3s %d %d:%d:%d", &day, mon, &year, &hh, &mm, &ss) != 6) return false;
int month = -1;
for (int i = 0; i < 12; ++i)
if (std::strcmp(mon, kMonths[i]) == 0) month = i + 1;
if (month < 0 || day < 1 || day > 31 || hh > 23 || mm > 59 || ss > 60) return false;
unixSec = daysFromCivil(year, static_cast<unsigned>(month), static_cast<unsigned>(day)) * 86400 + hh * 3600 +
mm * 60 + ss;
return true;
}
} // namespace
int64_t steadyNowMs() {
return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch())
.count();
}
std::string urlHost(const std::string& url) {
size_t start = url.find("://");
start = start == std::string::npos ? 0 : start + 3;
size_t end = url.find_first_of("/?#", start);
std::string host = url.substr(start, end == std::string::npos ? std::string::npos : end - start);
const size_t at = host.rfind('@');
if (at != std::string::npos) host = host.substr(at + 1);
if (!host.empty() && host[0] != '[') {
const size_t colon = host.find(':');
if (colon != std::string::npos) host = host.substr(0, colon);
}
std::transform(host.begin(), host.end(), host.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return host;
}
int parseRetryAfter(const std::string& value, int64_t nowUnixSec) {
size_t b = 0, e = value.size();
while (b < e && std::isspace(static_cast<unsigned char>(value[b]))) ++b;
while (e > b && std::isspace(static_cast<unsigned char>(value[e - 1]))) --e;
if (b == e) return -1;
const std::string v = value.substr(b, e - b);
if (std::all_of(v.begin(), v.end(), [](unsigned char c) { return std::isdigit(c); })) {
if (v.size() > 6) return 3600;
return std::min(3600, std::atoi(v.c_str()));
}
int64_t at = 0;
if (!parseHttpDate(v, at)) return -1;
return static_cast<int>(std::clamp<int64_t>(at - nowUnixSec, 0, 3600));
}
// ---------------------------------------------------------------------------------------------
// RateLimiter
RateLimiter::Policy RateLimiter::policyFor(const std::string& host) {
// Box art / store images and the public, unauthenticated store catalog (title names, batched
// 20 per request): many small requests on a cold library load, CDN-backed. Cached on disk.
if (endsWith(host, "store-images.s-microsoft.com") || endsWith(host, ".akamaized.net") ||
host.find("images") != std::string::npos || host == "displaycatalog.mp.microsoft.com")
return {10.0, 30.0};
// Microsoft APIs (gssv, auth, catalog). Session signalling peaks at ~3 req/s (state + sdp/ice
// polls); the catalog hydration bursts ~10 requests once.
return {5.0, 12.0};
}
int64_t RateLimiter::reserve(const std::string& host, int64_t nowMs, bool* inCooldown) {
if (inCooldown) *inCooldown = false;
std::lock_guard<std::mutex> lk(mu_);
HostState& h = hosts_[host];
if (nowMs < h.cooldownUntilMs) {
if (inCooldown) *inCooldown = true;
return h.cooldownUntilMs - nowMs;
}
const Policy p = policyFor(host);
if (h.tokens < 0) {
h.tokens = p.burst;
h.lastRefillMs = nowMs;
} else if (nowMs > h.lastRefillMs) {
h.tokens = std::min(p.burst, h.tokens + (nowMs - h.lastRefillMs) * p.ratePerSec / 1000.0);
h.lastRefillMs = nowMs;
}
if (h.tokens >= 1.0) {
h.tokens -= 1.0;
return 0;
}
const double missing = 1.0 - h.tokens;
return std::max<int64_t>(1, static_cast<int64_t>(missing * 1000.0 / p.ratePerSec + 0.999));
}
void RateLimiter::onResponse(const std::string& host, long status, int retryAfterSec, int64_t nowMs) {
if (status <= 0) return; // transport error: says nothing about server-side limits
std::lock_guard<std::mutex> lk(mu_);
HostState& h = hosts_[host];
const bool limited = status == 429 || (status == 503 && retryAfterSec >= 0);
if (!limited) {
h.consecutive429 = 0;
return;
}
++h.consecutive429;
int64_t backoff;
if (retryAfterSec >= 0) {
backoff = static_cast<int64_t>(retryAfterSec) * 1000;
} else {
const int shift = std::min(h.consecutive429 - 1, 6);
backoff = std::min<int64_t>(kMaxBackoffMs, int64_t(2000) << shift);
}
h.cooldownUntilMs = std::max(h.cooldownUntilMs, nowMs + backoff);
h.tokens = 0; // restart slowly after the cooldown
h.lastRefillMs = h.cooldownUntilMs;
}
int64_t RateLimiter::cooldownRemaining(const std::string& host, int64_t nowMs) {
std::lock_guard<std::mutex> lk(mu_);
auto it = hosts_.find(host);
if (it == hosts_.end() || nowMs >= it->second.cooldownUntilMs) return 0;
return it->second.cooldownUntilMs - nowMs;
}
void RateLimiter::reset() {
std::lock_guard<std::mutex> lk(mu_);
hosts_.clear();
}
RateLimiter& RateLimiter::global() {
static RateLimiter instance;
return instance;
}
// ---------------------------------------------------------------------------------------------
// SessionStartLimiter
bool SessionStartLimiter::allow(bool home, int64_t nowMs, int& waitSec) {
std::lock_guard<std::mutex> lk(mu_);
std::deque<int64_t>& q = home ? home_ : cloud_;
const int maxStarts = home ? kMaxHome : kMaxCloud;
while (!q.empty() && nowMs - q.front() >= kWindowMs) q.pop_front();
int64_t waitMs = 0;
if (static_cast<int>(q.size()) >= maxStarts) waitMs = q.front() + kWindowMs - nowMs;
if (last_ >= 0 && nowMs - last_ < kMinSpacingMs) waitMs = std::max(waitMs, last_ + kMinSpacingMs - nowMs);
if (waitMs > 0) {
waitSec = static_cast<int>(std::max<int64_t>(1, (waitMs + 999) / 1000));
return false;
}
q.push_back(nowMs);
last_ = nowMs;
waitSec = 0;
return true;
}
void SessionStartLimiter::reset() {
std::lock_guard<std::mutex> lk(mu_);
cloud_.clear();
home_.clear();
last_ = -1;
}
SessionStartLimiter& SessionStartLimiter::global() {
static SessionStartLimiter instance;
return instance;
}
} // namespace xc
+89
View File
@@ -0,0 +1,89 @@
// Nubix — client-side request pacing: per-host token buckets, 429/Retry-After cooldowns with
// exponential backoff, and a sliding-window cap on session starts. Keeps the client's traffic
// pattern at or below what the official browser client produces.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstdint>
#include <deque>
#include <map>
#include <mutex>
#include <string>
namespace xc {
class RateLimiter {
public:
struct Policy {
double ratePerSec; // sustained requests per second
double burst; // bucket size
};
// Policy for a host: image CDNs (box art) get a larger budget than API hosts.
static Policy policyFor(const std::string& host);
// Try to start a request to `host` at time nowMs. Returns 0 when the request may go now (a
// token is consumed), otherwise the milliseconds to wait before asking again. When the host
// is in a 429 cooldown, *inCooldown is set and the remaining cooldown is returned.
int64_t reserve(const std::string& host, int64_t nowMs, bool* inCooldown = nullptr);
// Feed a response back. HTTP 429 (and 503 carrying Retry-After) puts the host into cooldown
// for Retry-After seconds, or an exponential backoff (2, 4, 8 … 120 s) when the header is
// missing. Any other completed response resets the backoff. retryAfterSec < 0 = no header.
void onResponse(const std::string& host, long status, int retryAfterSec, int64_t nowMs);
// Remaining cooldown for host (0 = none).
int64_t cooldownRemaining(const std::string& host, int64_t nowMs);
void reset();
// Process-wide instance used by Http::request.
static RateLimiter& global();
static constexpr int64_t kMaxBackoffMs = 120000;
private:
struct HostState {
double tokens = -1; // < 0 = not initialised (full bucket)
int64_t lastRefillMs = 0;
int64_t cooldownUntilMs = 0;
int consecutive429 = 0;
};
std::mutex mu_;
std::map<std::string, HostState> hosts_;
};
// Caps how often game sessions are requested (POST …/play), independent of HTTP 429s:
// cloud at most 4 starts per 5 minutes, home (console wake-up retries) at most 8, and at least
// 2 s between any two starts.
class SessionStartLimiter {
public:
static constexpr int64_t kWindowMs = 5 * 60 * 1000;
static constexpr int kMaxCloud = 4;
static constexpr int kMaxHome = 8;
static constexpr int64_t kMinSpacingMs = 2000;
// Records the start and returns true when allowed; otherwise sets waitSec (>= 1).
bool allow(bool home, int64_t nowMs, int& waitSec);
void reset();
static SessionStartLimiter& global();
private:
std::mutex mu_;
std::deque<int64_t> cloud_, home_;
int64_t last_ = -1;
};
// Parse a Retry-After header value: delta-seconds or an HTTP-date. Returns -1 when absent or
// unparseable; clamps to [0, 3600].
int parseRetryAfter(const std::string& value, int64_t nowUnixSec);
// Lower-cased host of an http(s) URL ("" when none).
std::string urlHost(const std::string& url);
// Monotonic milliseconds (steady clock).
int64_t steadyNowMs();
} // namespace xc
+55
View File
@@ -0,0 +1,55 @@
// Nubix — native Xbox Cloud Gaming / Remote Play client for PS5 (and host builds).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include <cstdio>
#include <cstring>
#include <string>
#include "core/log.hpp"
#include "platform/platform.hpp"
#include "ui/app.hpp"
#ifndef XC_VERSION
#define XC_VERSION "dev"
#endif
int main(int argc, char** argv) {
bool verbose = false;
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "--verbose") == 0 || std::strcmp(argv[i], "-v") == 0) {
verbose = true;
} else if (std::strcmp(argv[i], "--version") == 0) {
std::printf("nubix %s\n", XC_VERSION);
return 0;
} else if (std::strcmp(argv[i], "--help") == 0 || std::strcmp(argv[i], "-h") == 0) {
std::printf("usage: %s [--verbose|-v] [--version] [--selftest]\n"
" --verbose debug logging (log.txt in the data directory)\n"
" --selftest init all subsystems, render a few Login frames, exit 0 on success\n"
" (headless: SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy)\n"
"Host environment: XC_DATA_DIR, XC_ASSETS_DIR, XC_FULLSCREEN=1\n"
"Keys: arrows/Enter/Esc navigate, Q/E switch tabs, Y filter/refresh, F1 stream menu,\n"
" F12 screenshot (saved to the data directory)\n",
argc > 0 ? argv[0] : "nubix");
return 0;
}
}
if (!xc::platform::init(argc, argv)) {
std::fprintf(stderr, "platform init failed\n");
return 1;
}
xc::logInit(xc::platform::dataDir() + "/log.txt", verbose ? xc::LogLevel::Debug : xc::LogLevel::Info);
XC_LOGI("nubix %s starting (%s), data=%s assets=%s", XC_VERSION, xc::platform::isPs5() ? "PS5" : "host",
xc::platform::dataDir().c_str(), xc::platform::assetsDir().c_str());
int rc = 0;
{
xc::App app;
rc = app.run(argc, argv);
}
XC_LOGI("exit code %d", rc);
xc::logShutdown();
xc::platform::shutdown();
return rc;
}
+62
View File
@@ -0,0 +1,62 @@
// Nubix — platform functions shared by PS5 and host builds (POSIX).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include "platform/platform.hpp"
#include "platform/net_compat.h"
#include <arpa/inet.h>
#include <ifaddrs.h>
#include <net/if.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <chrono>
#include <cstdint>
#include <cstring>
#include <ctime>
namespace xc {
namespace platform {
std::string assetPath(const std::string& rel) { return assetsDir() + "/" + rel; }
// Usable unicast address: not INADDR_ANY (PS5 lists idle interfaces as UP with 0.0.0.0, which
// websrv/elfldr skip for the same reason), not loopback, not link-local 169.254/16 (no DHCP lease).
bool usableIpv4(uint32_t hostOrder) {
if ((hostOrder >> 24) == 0) return false; // 0.0.0.0/8
if ((hostOrder >> 24) == 127) return false; // loopback
if ((hostOrder >> 16) == 0xA9FE) return false; // 169.254/16
return true;
}
std::string localIpv4() {
struct ifaddrs* list = nullptr;
if (xc_getifaddrs(&list) != 0) return std::string(); // with default-route fallback (PS5)
std::string result;
for (struct ifaddrs* it = list; it; it = it->ifa_next) {
if (!it->ifa_addr || it->ifa_addr->sa_family != AF_INET) continue;
if (!(it->ifa_flags & IFF_UP) || (it->ifa_flags & IFF_LOOPBACK)) continue;
if (it->ifa_name && std::strncmp(it->ifa_name, "lo", 2) == 0) continue;
auto* sin = reinterpret_cast<struct sockaddr_in*>(it->ifa_addr);
if (!usableIpv4(ntohl(sin->sin_addr.s_addr))) continue;
char buf[INET_ADDRSTRLEN] = {0};
if (inet_ntop(AF_INET, &sin->sin_addr, buf, sizeof(buf))) {
result = buf;
break;
}
}
xc_freeifaddrs(list);
return result;
}
bool networkAvailable() { return !localIpv4().empty(); }
uint64_t monotonicMs() {
using namespace std::chrono;
return static_cast<uint64_t>(duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count());
}
int64_t unixTime() { return static_cast<int64_t>(std::time(nullptr)); }
} // namespace platform
} // namespace xc
+522
View File
@@ -0,0 +1,522 @@
// Nubix — SDL GameController input mapping (DualSense / any SDL pad -> Xbox layout),
// UI navigation edges with auto-repeat, stream-menu chord, rumble, host keyboard fallback.
//
// PS5 notes (SDL-ps5 joystick driver, src/joystick/ps5/SDL_ps5joystick.c):
// - the PS and Create buttons are not reported (the system owns them), the touchpad click is
// SDL_CONTROLLER_BUTTON_TOUCHPAD -> the menu chord on PS5 is Options + touchpad;
// - L2/R2 are reported as full-range axes that only emit an event once they change, so an
// untouched trigger would read as half-pressed through the GameController mapping until
// its first motion event. We therefore report 0 until a motion event was seen.
// Portions derived from green-nx (https://github.com/rmrf404/green-nx), Copyright (C) the green-nx authors, GPL-3.0; modified by Nubix contributors, 2026.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include "platform/gamepad.hpp"
#include <SDL.h>
#include <algorithm>
#include <cmath>
#include <vector>
#include "core/log.hpp"
#include "platform/platform.hpp"
namespace xc {
namespace {
constexpr uint32_t kRepeatDelayMs = 380;
constexpr uint32_t kRepeatIntervalMs = 110;
constexpr int kStickNavThreshold = 20000; // ~61 % deflection counts as a direction press
constexpr int kStickNavRelease = 12000; // hysteresis
constexpr uint32_t kNexusPulseMs = 120; // short PS press -> Nexus held this long
constexpr int kTriggerNavThreshold = 16000;
constexpr uint32_t kMaxRumbleMs = 4000;
// Options (Menu) / Create+touchpad (View) are held back this long after being pressed: if the
// chord partner follows within it, neither reaches the game (nobody presses both in one frame).
constexpr uint32_t kChordGraceMs = 140;
constexpr uint32_t kTapPulseMs = 80; // a tap shorter than the grace is replayed this long // cap for one (approximated) rumble envelope
struct Pad {
SDL_GameController* gc = nullptr;
SDL_JoystickID id = -1;
bool triggerSeen[2] = {false, false};
bool hasTriggerRumble = false;
std::string name;
};
int16_t clampAxis(int v) { return static_cast<int16_t>(std::max(-32767, std::min(32767, v))); }
// Radial deadzone in percent with rescaling so the output still reaches full deflection.
void applyDeadzone(int16_t& x, int16_t& y, int percent) {
if (percent <= 0) return;
const double dz = std::min(95, percent) / 100.0;
const double fx = x / 32767.0, fy = y / 32767.0;
const double mag = std::sqrt(fx * fx + fy * fy);
if (mag < dz || mag <= 0.0) {
x = 0;
y = 0;
return;
}
const double scaled = std::min(1.0, (mag - dz) / (1.0 - dz));
const double k = scaled / mag;
x = clampAxis(static_cast<int>(std::lround(fx * k * 32767.0)));
y = clampAxis(static_cast<int>(std::lround(fy * k * 32767.0)));
}
uint16_t triggerToXbox(int16_t v) {
if (v <= 0) return 0;
return static_cast<uint16_t>(std::min<int>(65535, (static_cast<int>(v) * 65535) / 32767));
}
} // namespace
struct Gamepad::Impl {
std::vector<Pad> pads;
SDL_JoystickID active = -1;
bool inited = false;
bool lastKeyboard = false;
// nav auto-repeat (D-pad / stick)
NavKey heldDir = NavKey::None;
uint64_t heldSince = 0, lastRepeat = 0;
int stickDirX = 0, stickDirY = 0; // -1/0/1 per axis (left stick as D-pad)
bool ltNav = false, rtNav = false;
// menu chord / PS short press
uint64_t guideDownAt = 0; // 0 = not held
bool guideConsumed = false; // chord fired while held: do not send Nexus on release
uint64_t comboSince = 0; // Options + Create/touchpad held since
bool comboConsumed = false;
uint64_t nexusPulseUntil = 0;
bool chordFired = false; // edge for menuChordTriggered()
// Menu/View hold-back (state() is const but polled once per frame: mutable bookkeeping).
struct HoldGate {
uint64_t downAt = 0; // 0 = released
bool forwarded = false; // grace elapsed without the chord partner: sent to the game
bool consumed = false; // became part of the chord: never sent until released
};
mutable HoldGate startGate, viewGate;
mutable bool chordLatch = false; // both were held together; cleared when both are up
mutable uint64_t menuPulseUntil = 0, viewPulseUntil = 0;
void resetGates() {
startGate = HoldGate();
viewGate = HoldGate();
chordLatch = false;
menuPulseUntil = viewPulseUntil = 0;
}
// Returns whether the gated button is forwarded to the game this frame.
bool gate(HoldGate& g, bool down, uint64_t& pulseUntil, uint64_t now) const {
if (!down) {
// Short tap that never got past the grace period and was not part of the chord:
// replay it as a brief press so a quick Options tap still pauses the game.
if (g.downAt != 0 && !g.forwarded && !g.consumed) pulseUntil = now + kTapPulseMs;
g = HoldGate();
return now < pulseUntil;
}
if (g.downAt == 0) g.downAt = now;
if (chordLatch) g.consumed = true;
if (g.consumed) return false;
if (!g.forwarded && now - g.downAt >= kChordGraceMs) g.forwarded = true;
return g.forwarded;
}
Pad* find(SDL_JoystickID id) {
for (auto& p : pads)
if (p.id == id) return &p;
return nullptr;
}
const Pad* activePad() const {
for (auto& p : pads)
if (p.id == active) return &p;
return pads.empty() ? nullptr : &pads.front();
}
void open(int deviceIndex) {
if (!SDL_IsGameController(deviceIndex)) {
XC_LOGI("gamepad: joystick %d (%s) has no controller mapping, ignored", deviceIndex,
SDL_JoystickNameForIndex(deviceIndex) ? SDL_JoystickNameForIndex(deviceIndex) : "?");
return;
}
SDL_JoystickID id = SDL_JoystickGetDeviceInstanceID(deviceIndex);
if (find(id)) return;
SDL_GameController* gc = SDL_GameControllerOpen(deviceIndex);
if (!gc) {
XC_LOGW("gamepad: open %d failed: %s", deviceIndex, SDL_GetError());
return;
}
Pad p;
p.gc = gc;
p.id = SDL_JoystickInstanceID(SDL_GameControllerGetJoystick(gc));
const char* n = SDL_GameControllerName(gc);
p.name = n ? n : "Controller";
#if SDL_VERSION_ATLEAST(2, 0, 18)
p.hasTriggerRumble = SDL_GameControllerHasRumbleTriggers(gc) == SDL_TRUE;
#endif
// Non-PS5 drivers report triggers correctly from the start.
if (!platform::isPs5()) p.triggerSeen[0] = p.triggerSeen[1] = true;
#if SDL_VERSION_ATLEAST(2, 0, 14)
if (SDL_GameControllerHasLED(gc)) SDL_GameControllerSetLED(gc, 36, 110, 240); // Nubix blue
#endif
XC_LOGI("gamepad: connected \"%s\" (instance %d, trigger rumble %s)", p.name.c_str(), (int)p.id,
p.hasTriggerRumble ? "yes" : "no");
pads.push_back(p);
if (active < 0) active = p.id;
}
void close(SDL_JoystickID id) {
for (auto it = pads.begin(); it != pads.end(); ++it) {
if (it->id != id) continue;
XC_LOGI("gamepad: disconnected \"%s\"", it->name.c_str());
SDL_GameControllerClose(it->gc);
pads.erase(it);
break;
}
if (active == id) {
active = pads.empty() ? -1 : pads.front().id;
guideDownAt = 0;
comboSince = 0;
resetGates();
heldDir = NavKey::None;
stickDirX = stickDirY = 0;
}
}
bool button(SDL_GameControllerButton b) const {
const Pad* p = activePad();
return p && SDL_GameControllerGetButton(p->gc, b) == 1;
}
// Options + (Create or touchpad) currently held on the active pad.
bool comboHeld() const {
return button(SDL_CONTROLLER_BUTTON_START) &&
(button(SDL_CONTROLLER_BUTTON_BACK) || button(SDL_CONTROLLER_BUTTON_TOUCHPAD));
}
void updateCombo(uint64_t now) {
if (comboHeld()) {
if (comboSince == 0) comboSince = now;
} else {
comboSince = 0;
comboConsumed = false;
}
}
NavKey startHeld(NavKey k, uint64_t now) {
heldDir = k;
heldSince = now;
lastRepeat = now;
return k;
}
void releaseHeld(NavKey k) {
if (heldDir == k) heldDir = NavKey::None;
}
};
Gamepad::Gamepad() : d_(new Impl) {}
Gamepad::~Gamepad() { shutdown(); }
bool Gamepad::init() {
if (SDL_WasInit(SDL_INIT_GAMECONTROLLER) == 0) {
if (SDL_InitSubSystem(SDL_INIT_GAMECONTROLLER) != 0) {
XC_LOGE("gamepad: SDL_INIT_GAMECONTROLLER failed: %s", SDL_GetError());
return false;
}
}
SDL_GameControllerEventState(SDL_ENABLE);
d_->inited = true;
const int n = SDL_NumJoysticks();
for (int i = 0; i < n; ++i) d_->open(i);
XC_LOGI("gamepad: %d joystick(s), %zu controller(s) open", n, d_->pads.size());
return true;
}
void Gamepad::shutdown() {
if (!d_) return;
for (auto& p : d_->pads)
if (p.gc) SDL_GameControllerClose(p.gc);
d_->pads.clear();
d_->active = -1;
d_->inited = false;
}
NavKey Gamepad::handleEvent(const SDL_Event& ev) {
const uint64_t now = platform::monotonicMs();
Impl& d = *d_;
switch (ev.type) {
case SDL_CONTROLLERDEVICEADDED:
d.open(ev.cdevice.which);
return NavKey::None;
case SDL_CONTROLLERDEVICEREMOVED:
d.close(ev.cdevice.which);
return NavKey::None;
case SDL_CONTROLLERBUTTONDOWN: {
if (!d.find(ev.cbutton.which)) return NavKey::None;
d.active = ev.cbutton.which;
d.lastKeyboard = false;
d.updateCombo(now);
switch (ev.cbutton.button) {
case SDL_CONTROLLER_BUTTON_A: return NavKey::Accept;
case SDL_CONTROLLER_BUTTON_B: return NavKey::Back;
case SDL_CONTROLLER_BUTTON_Y: return NavKey::Option;
case SDL_CONTROLLER_BUTTON_LEFTSHOULDER: return NavKey::TabLeft;
case SDL_CONTROLLER_BUTTON_RIGHTSHOULDER: return NavKey::TabRight;
// START is deliberately not a NavKey: while streaming it is the game's Menu
// button; the stream overlay opens via menuChordTriggered() / keyboard F1.
case SDL_CONTROLLER_BUTTON_GUIDE:
d.guideDownAt = now;
d.guideConsumed = false;
return NavKey::None;
case SDL_CONTROLLER_BUTTON_DPAD_UP: return d.startHeld(NavKey::Up, now);
case SDL_CONTROLLER_BUTTON_DPAD_DOWN: return d.startHeld(NavKey::Down, now);
case SDL_CONTROLLER_BUTTON_DPAD_LEFT: return d.startHeld(NavKey::Left, now);
case SDL_CONTROLLER_BUTTON_DPAD_RIGHT: return d.startHeld(NavKey::Right, now);
default: return NavKey::None;
}
}
case SDL_CONTROLLERBUTTONUP: {
if (!d.find(ev.cbutton.which)) return NavKey::None;
d.updateCombo(now);
switch (ev.cbutton.button) {
case SDL_CONTROLLER_BUTTON_GUIDE:
if (d.guideDownAt != 0 && !d.guideConsumed) d.nexusPulseUntil = now + kNexusPulseMs;
d.guideDownAt = 0;
d.guideConsumed = false;
break;
case SDL_CONTROLLER_BUTTON_DPAD_UP: d.releaseHeld(NavKey::Up); break;
case SDL_CONTROLLER_BUTTON_DPAD_DOWN: d.releaseHeld(NavKey::Down); break;
case SDL_CONTROLLER_BUTTON_DPAD_LEFT: d.releaseHeld(NavKey::Left); break;
case SDL_CONTROLLER_BUTTON_DPAD_RIGHT: d.releaseHeld(NavKey::Right); break;
default: break;
}
return NavKey::None;
}
case SDL_CONTROLLERAXISMOTION: {
Pad* p = d.find(ev.caxis.which);
if (!p) return NavKey::None;
const int v = ev.caxis.value;
if (ev.caxis.axis == SDL_CONTROLLER_AXIS_TRIGGERLEFT) p->triggerSeen[0] = true;
if (ev.caxis.axis == SDL_CONTROLLER_AXIS_TRIGGERRIGHT) p->triggerSeen[1] = true;
if (ev.caxis.which != d.active && std::abs(v) > kStickNavThreshold &&
ev.caxis.axis != SDL_CONTROLLER_AXIS_TRIGGERLEFT && ev.caxis.axis != SDL_CONTROLLER_AXIS_TRIGGERRIGHT)
d.active = ev.caxis.which;
if (ev.caxis.which != d.active) return NavKey::None;
if (ev.caxis.axis == SDL_CONTROLLER_AXIS_LEFTX) {
int dir = v > kStickNavThreshold ? 1 : v < -kStickNavThreshold ? -1 : (std::abs(v) < kStickNavRelease ? 0 : d.stickDirX);
if (dir != d.stickDirX) {
NavKey old = d.stickDirX > 0 ? NavKey::Right : NavKey::Left;
if (d.stickDirX != 0) d.releaseHeld(old);
d.stickDirX = dir;
if (dir != 0) {
d.lastKeyboard = false;
return d.startHeld(dir > 0 ? NavKey::Right : NavKey::Left, now);
}
}
} else if (ev.caxis.axis == SDL_CONTROLLER_AXIS_LEFTY) {
int dir = v > kStickNavThreshold ? 1 : v < -kStickNavThreshold ? -1 : (std::abs(v) < kStickNavRelease ? 0 : d.stickDirY);
if (dir != d.stickDirY) {
NavKey old = d.stickDirY > 0 ? NavKey::Down : NavKey::Up;
if (d.stickDirY != 0) d.releaseHeld(old);
d.stickDirY = dir;
if (dir != 0) {
d.lastKeyboard = false;
return d.startHeld(dir > 0 ? NavKey::Down : NavKey::Up, now);
}
}
} else if (ev.caxis.axis == SDL_CONTROLLER_AXIS_TRIGGERLEFT) {
bool on = v > kTriggerNavThreshold;
if (on != d.ltNav) {
d.ltNav = on;
if (on) return NavKey::TabLeft;
}
} else if (ev.caxis.axis == SDL_CONTROLLER_AXIS_TRIGGERRIGHT) {
bool on = v > kTriggerNavThreshold;
if (on != d.rtNav) {
d.rtNav = on;
if (on) return NavKey::TabRight;
}
}
return NavKey::None;
}
case SDL_KEYDOWN: {
d.lastKeyboard = true;
switch (ev.key.keysym.sym) {
case SDLK_UP: return NavKey::Up; // OS key repeat handles holding
case SDLK_DOWN: return NavKey::Down;
case SDLK_LEFT: return NavKey::Left;
case SDLK_RIGHT: return NavKey::Right;
default: break;
}
if (ev.key.repeat) return NavKey::None;
switch (ev.key.keysym.sym) {
case SDLK_RETURN:
case SDLK_KP_ENTER:
case SDLK_SPACE: return NavKey::Accept;
case SDLK_ESCAPE:
case SDLK_BACKSPACE: return NavKey::Back;
case SDLK_q:
case SDLK_PAGEUP: return NavKey::TabLeft;
case SDLK_e:
case SDLK_PAGEDOWN: return NavKey::TabRight;
case SDLK_TAB:
case SDLK_y: return NavKey::Option;
case SDLK_F1: return NavKey::Menu;
default: return NavKey::None;
}
}
default:
return NavKey::None;
}
}
NavKey Gamepad::pollRepeat() {
Impl& d = *d_;
if (d.heldDir == NavKey::None) return NavKey::None;
const uint64_t now = platform::monotonicMs();
if (now - d.heldSince < kRepeatDelayMs) return NavKey::None;
if (now - d.lastRepeat < kRepeatIntervalMs) return NavKey::None;
d.lastRepeat = now;
return d.heldDir;
}
bool Gamepad::connected() const { return !d_->pads.empty(); }
std::string Gamepad::name() const {
const Pad* p = d_->activePad();
return p ? p->name : std::string();
}
bool Gamepad::lastInputKeyboard() const { return d_->lastKeyboard || d_->pads.empty(); }
GamepadState Gamepad::state(int deadzonePercent) const {
const Impl& d = *d_;
GamepadState st;
const uint64_t now = platform::monotonicMs();
if (const Pad* p = d.activePad()) {
SDL_GameController* gc = p->gc;
auto b = [gc](SDL_GameControllerButton x) { return SDL_GameControllerGetButton(gc, x) == 1; };
uint16_t m = 0;
if (b(SDL_CONTROLLER_BUTTON_A)) m |= btn::A;
if (b(SDL_CONTROLLER_BUTTON_B)) m |= btn::B;
if (b(SDL_CONTROLLER_BUTTON_X)) m |= btn::X;
if (b(SDL_CONTROLLER_BUTTON_Y)) m |= btn::Y;
if (b(SDL_CONTROLLER_BUTTON_LEFTSHOULDER)) m |= btn::LB;
if (b(SDL_CONTROLLER_BUTTON_RIGHTSHOULDER)) m |= btn::RB;
if (b(SDL_CONTROLLER_BUTTON_LEFTSTICK)) m |= btn::LS;
if (b(SDL_CONTROLLER_BUTTON_RIGHTSTICK)) m |= btn::RS;
if (b(SDL_CONTROLLER_BUTTON_DPAD_UP)) m |= btn::DUp;
if (b(SDL_CONTROLLER_BUTTON_DPAD_DOWN)) m |= btn::DDown;
if (b(SDL_CONTROLLER_BUTTON_DPAD_LEFT)) m |= btn::DLeft;
if (b(SDL_CONTROLLER_BUTTON_DPAD_RIGHT)) m |= btn::DRight;
const bool start = b(SDL_CONTROLLER_BUTTON_START);
const bool view = b(SDL_CONTROLLER_BUTTON_BACK) || b(SDL_CONTROLLER_BUTTON_TOUCHPAD);
// Options + Create/touchpad is the stream-menu chord. Each half is held back for
// kChordGraceMs so the game never sees the first button of the chord; once both were
// down together neither is forwarded until it is released (also after the menu fired).
if (start && view) d.chordLatch = true;
else if (!start && !view) d.chordLatch = false;
if (d.gate(d.startGate, start, d.menuPulseUntil, now)) m |= btn::Menu;
if (d.gate(d.viewGate, view, d.viewPulseUntil, now)) m |= btn::View;
st.buttons = m;
st.lx = clampAxis(SDL_GameControllerGetAxis(gc, SDL_CONTROLLER_AXIS_LEFTX));
st.ly = clampAxis(-static_cast<int>(SDL_GameControllerGetAxis(gc, SDL_CONTROLLER_AXIS_LEFTY)));
st.rx = clampAxis(SDL_GameControllerGetAxis(gc, SDL_CONTROLLER_AXIS_RIGHTX));
st.ry = clampAxis(-static_cast<int>(SDL_GameControllerGetAxis(gc, SDL_CONTROLLER_AXIS_RIGHTY)));
if (p->triggerSeen[0]) st.lt = triggerToXbox(SDL_GameControllerGetAxis(gc, SDL_CONTROLLER_AXIS_TRIGGERLEFT));
if (p->triggerSeen[1]) st.rt = triggerToXbox(SDL_GameControllerGetAxis(gc, SDL_CONTROLLER_AXIS_TRIGGERRIGHT));
applyDeadzone(st.lx, st.ly, deadzonePercent);
applyDeadzone(st.rx, st.ry, deadzonePercent);
}
if (now < d.nexusPulseUntil) st.buttons |= btn::Nexus;
// Keyboard fallback (host development; also works with a USB keyboard on PS5).
int numKeys = 0;
const Uint8* ks = SDL_GetKeyboardState(&numKeys);
if (ks && numKeys > SDL_SCANCODE_F1) {
auto k = [ks](SDL_Scancode sc) { return ks[sc] != 0; };
if (k(SDL_SCANCODE_RETURN) || k(SDL_SCANCODE_SPACE)) st.buttons |= btn::A;
if (k(SDL_SCANCODE_BACKSPACE)) st.buttons |= btn::B;
if (k(SDL_SCANCODE_X)) st.buttons |= btn::X;
if (k(SDL_SCANCODE_Y)) st.buttons |= btn::Y;
if (k(SDL_SCANCODE_Q)) st.buttons |= btn::LB;
if (k(SDL_SCANCODE_E)) st.buttons |= btn::RB;
if (k(SDL_SCANCODE_F)) st.buttons |= btn::LS;
if (k(SDL_SCANCODE_H)) st.buttons |= btn::RS;
if (k(SDL_SCANCODE_V) || k(SDL_SCANCODE_TAB)) st.buttons |= btn::View;
if (k(SDL_SCANCODE_N)) st.buttons |= btn::Menu;
if (k(SDL_SCANCODE_G)) st.buttons |= btn::Nexus;
if (k(SDL_SCANCODE_UP)) st.buttons |= btn::DUp;
if (k(SDL_SCANCODE_DOWN)) st.buttons |= btn::DDown;
if (k(SDL_SCANCODE_LEFT)) st.buttons |= btn::DLeft;
if (k(SDL_SCANCODE_RIGHT)) st.buttons |= btn::DRight;
if (k(SDL_SCANCODE_Z)) st.lt = 65535;
if (k(SDL_SCANCODE_C)) st.rt = 65535;
auto axis = [&](SDL_Scancode neg, SDL_Scancode pos, int16_t& out) {
int v = (k(pos) ? 32767 : 0) - (k(neg) ? 32767 : 0);
if (v != 0) out = static_cast<int16_t>(v);
};
axis(SDL_SCANCODE_A, SDL_SCANCODE_D, st.lx);
axis(SDL_SCANCODE_S, SDL_SCANCODE_W, st.ly); // Xbox convention: up = +
axis(SDL_SCANCODE_J, SDL_SCANCODE_L, st.rx);
axis(SDL_SCANCODE_K, SDL_SCANCODE_I, st.ry);
}
return st;
}
bool Gamepad::menuChordTriggered(uint32_t holdMs) {
Impl& d = *d_;
const uint64_t now = platform::monotonicMs();
// Re-check the chord against live button state (events may have been consumed elsewhere).
d.updateCombo(now);
if (d.guideDownAt != 0 && !d.guideConsumed && !d.button(SDL_CONTROLLER_BUTTON_GUIDE)) d.guideDownAt = 0;
if (d.guideDownAt != 0 && !d.guideConsumed && now - d.guideDownAt >= holdMs) {
d.guideConsumed = true;
return true;
}
if (d.comboSince != 0 && !d.comboConsumed && now - d.comboSince >= holdMs) {
d.comboConsumed = true;
return true;
}
return false;
}
void Gamepad::rumble(const Vibration& v) {
const Pad* p = d_->activePad();
if (!p) return;
auto scale = [](uint8_t pct) -> Uint16 { return static_cast<Uint16>(std::min<int>(100, pct) * 65535 / 100); };
// The server describes a pulse train (duration on, delay off, repeated `repeat` more times).
// SDL cannot play the gaps, so approximate the whole envelope as one effect like green-nx
// (handle_input_report): duration + repeat * (duration + delay), capped at 4 s.
uint32_t total = v.durationMs;
if (v.durationMs && v.repeat)
total += static_cast<uint32_t>(v.repeat) * (static_cast<uint32_t>(v.durationMs) + v.delayMs);
const Uint32 dur = std::min<uint32_t>(total, kMaxRumbleMs);
if (dur == 0 || (v.left == 0 && v.right == 0 && v.lt == 0 && v.rt == 0)) {
SDL_GameControllerRumble(p->gc, 0, 0, 0);
#if SDL_VERSION_ATLEAST(2, 0, 14)
if (p->hasTriggerRumble) SDL_GameControllerRumbleTriggers(p->gc, 0, 0, 0);
#endif
return;
}
// Xbox: left = low-frequency (heavy) motor, right = high-frequency motor.
SDL_GameControllerRumble(p->gc, scale(v.left), scale(v.right), dur);
#if SDL_VERSION_ATLEAST(2, 0, 14)
if (p->hasTriggerRumble) SDL_GameControllerRumbleTriggers(p->gc, scale(v.lt), scale(v.rt), dur);
#endif
}
void Gamepad::stopRumble() {
for (auto& p : d_->pads) {
SDL_GameControllerRumble(p.gc, 0, 0, 0);
#if SDL_VERSION_ATLEAST(2, 0, 14)
if (p.hasTriggerRumble) SDL_GameControllerRumbleTriggers(p.gc, 0, 0, 0);
#endif
}
}
} // namespace xc
+81
View File
@@ -0,0 +1,81 @@
// Nubix — SDL GameController -> xc::GamepadState (DualSense -> Xbox mapping), rumble,
// hotplug, and UI navigation edges.
// Mapping: Cross=A, Circle=B, Square=X, Triangle=Y, L1=LB, R1=RB, L2=LT, R2=RT, L3=LS, R3=RS,
// Options=Menu, touchpad click=View, D-pad=D-pad. Host only (the PS5 pad driver does not report
// these buttons): Create=View, PS=Nexus (short press).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstdint>
#include <memory>
#include <string>
#include "core/protocol.hpp"
union SDL_Event;
namespace xc {
// Edge-triggered navigation keys for the UI (from pad or keyboard on host).
// Pad: A=Accept, B=Back, Y=Option, LB/LT=TabLeft, RB/RT=TabRight, D-pad/left stick=directions.
// Keyboard: arrows, Enter/Space=Accept, Esc/Backspace=Back, Q/E=tabs, Y/Tab=Option, F1=Menu.
// The pad's Options button is never a NavKey (it is the game's Menu button while streaming).
enum class NavKey { None, Up, Down, Left, Right, Accept, Back, TabLeft, TabRight, Option, Menu };
class Gamepad {
public:
Gamepad();
~Gamepad();
// Init SDL_INIT_GAMECONTROLLER subsystem state and open already connected pads.
bool init();
// Close all controllers.
void shutdown();
// Feed every SDL event (controller add/remove, buttons, axes; keyboard on host).
// Returns a navigation key for UI use (None if the event is not a navigation press).
NavKey handleEvent(const SDL_Event& ev);
// True if at least one controller is connected.
bool connected() const;
// Current mapped state of the active controller (deadzone percent applied radially).
// Menu (Options) and View (Create/touchpad) are held back ~140 ms after being pressed and
// dropped entirely while they form the stream-menu chord (a tap shorter than that is
// replayed as a short press). Call once per frame while streaming.
GamepadState state(int deadzonePercent = 0) const;
// True once when the stream-menu chord was held long enough: Options + touchpad (PS5 and
// host), Options + Create or the PS button (host only), held for holdMs. While held, the PS
// button is NOT forwarded as Nexus.
bool menuChordTriggered(uint32_t holdMs = 1000);
// Apply a server rumble command (SDL_GameControllerRumble + trigger rumble if supported).
void rumble(const Vibration& v);
// Auto-repeat for held directions (D-pad, left stick, arrow keys are repeated by the OS):
// call once per frame; returns a direction NavKey when a repeat is due, else None.
NavKey pollRepeat();
// Name of the active controller ("" if none), for diagnostics / UI.
std::string name() const;
// True if the last navigation input came from the keyboard (UI shows key names then).
bool lastInputKeyboard() const;
// Stop all rumble immediately (e.g. when a stream ends or the overlay opens).
void stopRumble();
// Host keyboard -> pad mapping used while streaming (merged into state()):
// arrows = D-pad, WASD = left stick, IJKL = right stick, Enter/Space = A, Backspace = B,
// X = X, Y = Y, Q = LB, E = RB, Z = LT, C = RT, V/Tab = View, N = Menu, G = Nexus,
// F = LS, H = RS. F1 opens the stream menu (NavKey::Menu).
private:
struct Impl;
std::unique_ptr<Impl> d_;
};
} // namespace xc
+127
View File
@@ -0,0 +1,127 @@
// Nubix — host (macOS/Linux) platform implementation.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#ifndef XC_PS5
#include <sys/stat.h>
#include <cstdio>
#include <unistd.h>
#include <cerrno>
#include <climits>
#include <cstdlib>
#include <string>
#if defined(__APPLE__)
#include <mach-o/dyld.h>
#endif
#include "core/log.hpp"
#include "platform/platform.hpp"
namespace xc {
namespace platform {
namespace {
std::string g_exeDir = ".";
bool mkdirs(const std::string& path) {
if (path.empty()) return false;
std::string cur;
for (size_t i = 0; i < path.size(); ++i) {
cur.push_back(path[i]);
if (path[i] == '/' || i + 1 == path.size()) {
if (cur == "/") continue;
if (mkdir(cur.c_str(), 0755) != 0 && errno != EEXIST) return false;
}
}
return true;
}
bool isDir(const std::string& p) {
struct stat st{};
return stat(p.c_str(), &st) == 0 && S_ISDIR(st.st_mode);
}
std::string dirName(const std::string& p) {
auto pos = p.find_last_of('/');
if (pos == std::string::npos) return ".";
if (pos == 0) return "/";
return p.substr(0, pos);
}
std::string resolveExePath(const char* argv0) {
char buf[PATH_MAX] = {0};
#if defined(__APPLE__)
uint32_t size = sizeof(buf);
if (_NSGetExecutablePath(buf, &size) == 0) {
char real[PATH_MAX] = {0};
if (realpath(buf, real)) return real;
return buf;
}
#else
ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = 0;
return buf;
}
#endif
if (argv0) {
char real[PATH_MAX] = {0};
if (realpath(argv0, real)) return real;
return argv0;
}
return "./nubix";
}
} // namespace
bool init(int argc, char** argv) {
g_exeDir = dirName(resolveExePath(argc > 0 ? argv[0] : nullptr));
// Keep the sign-in and settings of installs from before the rename (0.3.0).
const std::string dir = dataDir();
const size_t slash = dir.rfind('/');
if (slash != std::string::npos && dir.compare(slash + 1, std::string::npos, "nubix") == 0) {
const std::string legacy = dir.substr(0, slash + 1) + "ps5-xcloud";
struct stat st {};
if (::stat(dir.c_str(), &st) != 0 && ::stat(legacy.c_str(), &st) == 0) std::rename(legacy.c_str(), dir.c_str());
}
return mkdirs(dir);
}
void shutdown() {}
std::string dataDir() {
if (const char* env = std::getenv("XC_DATA_DIR"); env && *env) return env;
const char* home = std::getenv("HOME");
std::string base = home && *home ? home : ".";
#if defined(__APPLE__)
return base + "/Library/Application Support/nubix";
#else
if (const char* xdg = std::getenv("XDG_CONFIG_HOME"); xdg && *xdg) return std::string(xdg) + "/nubix";
return base + "/.config/nubix";
#endif
}
std::string assetsDir() {
if (const char* env = std::getenv("XC_ASSETS_DIR"); env && *env) return env;
std::string local = g_exeDir + "/assets";
if (isDir(local)) return local;
#ifdef XC_SOURCE_ASSETS_DIR
return XC_SOURCE_ASSETS_DIR;
#else
return local;
#endif
}
std::string exeDir() { return g_exeDir; }
void notify(const std::string& msg) { XC_LOGI("[notify] %s", msg.c_str()); }
void onFirstFrame() {}
} // namespace platform
} // namespace xc
#endif // !XC_PS5
+306
View File
@@ -0,0 +1,306 @@
/* Nubix — resolver / interface-list fallbacks (see net_compat.h).
* Copyright (C) 2026 Nubix contributors
* SPDX-License-Identifier: GPL-3.0-only */
#define XC_NET_COMPAT_IMPL 1
#include "platform/net_compat.h"
#include <arpa/inet.h>
#include <errno.h>
#include <net/if.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
/* ---- getaddrinfo ------------------------------------------------------------------------- */
/* Each result node is one allocation: addrinfo followed by its sockaddr_storage. */
struct xc_ai_node {
struct addrinfo ai;
struct sockaddr_storage ss;
};
static struct addrinfo *xc_ai_new(const struct sockaddr *sa, socklen_t len, int socktype, int protocol) {
struct xc_ai_node *n = (struct xc_ai_node *)calloc(1, sizeof(*n));
if (!n) return NULL;
if (len > (socklen_t)sizeof(n->ss)) len = (socklen_t)sizeof(n->ss);
memcpy(&n->ss, sa, (size_t)len);
n->ai.ai_family = sa->sa_family;
n->ai.ai_socktype = socktype;
n->ai.ai_protocol = protocol;
n->ai.ai_addrlen = len;
n->ai.ai_addr = (struct sockaddr *)&n->ss;
return &n->ai;
}
void xc_freeaddrinfo(struct addrinfo *ai) {
while (ai) {
struct addrinfo *next = ai->ai_next;
free(ai->ai_canonname);
free(ai); /* ai is the first member of its xc_ai_node */
ai = next;
}
}
/* Numeric service (or NULL = 0). Returns -1 for a service name. */
static int xc_parse_port(const char *service) {
if (!service || !*service) return 0;
char *end = NULL;
long v = strtol(service, &end, 10);
if (*end != '\0' || v < 0 || v > 65535) return -1;
return (int)v;
}
static void xc_hint_types(const struct addrinfo *hints, int *socktype, int *protocol) {
*socktype = hints ? hints->ai_socktype : 0;
*protocol = hints ? hints->ai_protocol : 0;
if (*socktype == 0) *socktype = SOCK_DGRAM;
if (*protocol == 0) *protocol = *socktype == SOCK_STREAM ? IPPROTO_TCP : IPPROTO_UDP;
}
/* Copy a system result into our own nodes so xc_freeaddrinfo can free every list uniformly. */
static int xc_copy_list(const struct addrinfo *src, struct addrinfo **res) {
struct addrinfo *head = NULL, **tail = &head;
for (const struct addrinfo *a = src; a; a = a->ai_next) {
if (!a->ai_addr || (a->ai_family != AF_INET && a->ai_family != AF_INET6)) continue;
struct addrinfo *n = xc_ai_new(a->ai_addr, a->ai_addrlen, a->ai_socktype, a->ai_protocol);
if (!n) {
xc_freeaddrinfo(head);
return EAI_MEMORY;
}
n->ai_flags = a->ai_flags;
if (a->ai_canonname) n->ai_canonname = strdup(a->ai_canonname);
*tail = n;
tail = &n->ai_next;
}
if (!head) return EAI_NONAME;
*res = head;
return 0;
}
int xc_getaddrinfo(const char *node, const char *service, const struct addrinfo *hints, struct addrinfo **res) {
if (!res) return EAI_FAIL;
*res = NULL;
const int family = hints ? hints->ai_family : AF_UNSPEC;
const int flags = hints ? hints->ai_flags : 0;
const int port = xc_parse_port(service);
int socktype, protocol;
xc_hint_types(hints, &socktype, &protocol);
if (port >= 0) {
/* node == NULL: wildcard (AI_PASSIVE) or loopback. IPv4 unless IPv6 was asked for:
* the PS5 network stack is used IPv4-only here (Teredo candidates are decoded to IPv4). */
if (!node) {
if (family == AF_INET6) {
struct sockaddr_in6 sin6;
memset(&sin6, 0, sizeof(sin6));
sin6.sin6_family = AF_INET6;
sin6.sin6_port = htons((uint16_t)port);
sin6.sin6_addr = (flags & AI_PASSIVE) ? in6addr_any : in6addr_loopback;
#ifdef SIN6_LEN
sin6.sin6_len = sizeof(sin6);
#endif
*res = xc_ai_new((const struct sockaddr *)&sin6, sizeof(sin6), socktype, protocol);
} else {
struct sockaddr_in sin;
memset(&sin, 0, sizeof(sin));
sin.sin_family = AF_INET;
sin.sin_port = htons((uint16_t)port);
sin.sin_addr.s_addr = htonl((flags & AI_PASSIVE) ? INADDR_ANY : INADDR_LOOPBACK);
#if defined(__FreeBSD__) || defined(__APPLE__)
sin.sin_len = sizeof(sin);
#endif
*res = xc_ai_new((const struct sockaddr *)&sin, sizeof(sin), socktype, protocol);
}
return *res ? 0 : EAI_MEMORY;
}
struct in_addr a4;
if ((family == AF_UNSPEC || family == AF_INET) && inet_pton(AF_INET, node, &a4) == 1) {
struct sockaddr_in sin;
memset(&sin, 0, sizeof(sin));
sin.sin_family = AF_INET;
sin.sin_port = htons((uint16_t)port);
sin.sin_addr = a4;
#if defined(__FreeBSD__) || defined(__APPLE__)
sin.sin_len = sizeof(sin);
#endif
*res = xc_ai_new((const struct sockaddr *)&sin, sizeof(sin), socktype, protocol);
return *res ? 0 : EAI_MEMORY;
}
struct in6_addr a6;
const char *pct = strchr(node, '%'); /* scoped literal: let the system handle it */
if (!pct && (family == AF_UNSPEC || family == AF_INET6) && inet_pton(AF_INET6, node, &a6) == 1) {
struct sockaddr_in6 sin6;
memset(&sin6, 0, sizeof(sin6));
sin6.sin6_family = AF_INET6;
sin6.sin6_port = htons((uint16_t)port);
sin6.sin6_addr = a6;
#ifdef SIN6_LEN
sin6.sin6_len = sizeof(sin6);
#endif
*res = xc_ai_new((const struct sockaddr *)&sin6, sizeof(sin6), socktype, protocol);
return *res ? 0 : EAI_MEMORY;
}
if (flags & AI_NUMERICHOST) return EAI_NONAME;
}
/* A real host name (or a service name): system resolver. Retry without AI_ADDRCONFIG,
* which minimal resolvers may not support. */
struct addrinfo h;
memset(&h, 0, sizeof(h));
if (hints) h = *hints;
h.ai_addr = NULL;
h.ai_canonname = NULL;
h.ai_next = NULL;
struct addrinfo *sys = NULL;
int rc = getaddrinfo(node, service, &h, &sys);
#ifdef AI_ADDRCONFIG
if (rc != 0 && (h.ai_flags & AI_ADDRCONFIG)) {
h.ai_flags &= ~AI_ADDRCONFIG;
rc = getaddrinfo(node, service, &h, &sys);
}
#endif
if (rc != 0) return rc;
rc = xc_copy_list(sys, res);
freeaddrinfo(sys);
return rc;
}
/* ---- getnameinfo ------------------------------------------------------------------------- */
int xc_getnameinfo(const struct sockaddr *sa, socklen_t salen, char *host, socklen_t hostlen, char *serv,
socklen_t servlen, int flags) {
if (!sa) return EAI_FAIL;
const int wantNumericHost = !host || hostlen == 0 || (flags & NI_NUMERICHOST);
const int wantNumericServ = !serv || servlen == 0 || (flags & NI_NUMERICSERV);
if (!wantNumericHost || !wantNumericServ || (sa->sa_family != AF_INET && sa->sa_family != AF_INET6))
return getnameinfo(sa, salen, host, hostlen, serv, servlen, flags);
unsigned port;
if (sa->sa_family == AF_INET) {
if (salen < (socklen_t)sizeof(struct sockaddr_in)) return EAI_FAIL;
const struct sockaddr_in *sin = (const struct sockaddr_in *)sa;
if (host && hostlen && !inet_ntop(AF_INET, &sin->sin_addr, host, hostlen)) return EAI_OVERFLOW;
port = ntohs(sin->sin_port);
} else {
if (salen < (socklen_t)sizeof(struct sockaddr_in6)) return EAI_FAIL;
const struct sockaddr_in6 *sin6 = (const struct sockaddr_in6 *)sa;
if (host && hostlen && !inet_ntop(AF_INET6, &sin6->sin6_addr, host, hostlen)) return EAI_OVERFLOW;
port = ntohs(sin6->sin6_port);
}
if (serv && servlen) {
int n = snprintf(serv, servlen, "%u", port);
if (n < 0 || (socklen_t)n >= servlen) return EAI_OVERFLOW;
}
return 0;
}
/* ---- getifaddrs -------------------------------------------------------------------------- */
struct xc_ifa_node {
struct ifaddrs ifa;
struct sockaddr_storage addr, mask;
char name[IFNAMSIZ];
};
static struct ifaddrs *xc_ifa_new(const char *name, unsigned flags, const struct sockaddr *addr,
const struct sockaddr *mask) {
struct xc_ifa_node *n = (struct xc_ifa_node *)calloc(1, sizeof(*n));
if (!n) return NULL;
strncpy(n->name, name ? name : "eth0", sizeof(n->name) - 1);
n->ifa.ifa_name = n->name;
n->ifa.ifa_flags = flags;
if (addr) {
const size_t len = addr->sa_family == AF_INET6 ? sizeof(struct sockaddr_in6) : sizeof(struct sockaddr_in);
memcpy(&n->addr, addr, len);
n->ifa.ifa_addr = (struct sockaddr *)&n->addr;
}
if (mask) {
const size_t len = mask->sa_family == AF_INET6 ? sizeof(struct sockaddr_in6) : sizeof(struct sockaddr_in);
memcpy(&n->mask, mask, len);
n->ifa.ifa_netmask = (struct sockaddr *)&n->mask;
}
return &n->ifa;
}
void xc_freeifaddrs(struct ifaddrs *ifa) {
while (ifa) {
struct ifaddrs *next = ifa->ifa_next;
free(ifa); /* ifa is the first member of its xc_ifa_node */
ifa = next;
}
}
/* Address the kernel would use for the default route: connect() a UDP socket (sends nothing). */
static int xc_default_route_ipv4(struct sockaddr_in *out) {
int s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (s < 0) return -1;
struct sockaddr_in dst;
memset(&dst, 0, sizeof(dst));
dst.sin_family = AF_INET;
dst.sin_port = htons(53);
dst.sin_addr.s_addr = htonl(0x08080808); /* 8.8.8.8 — never contacted */
#if defined(__FreeBSD__) || defined(__APPLE__)
dst.sin_len = sizeof(dst);
#endif
int rc = -1;
if (connect(s, (const struct sockaddr *)&dst, sizeof(dst)) == 0) {
socklen_t len = sizeof(*out);
if (getsockname(s, (struct sockaddr *)out, &len) == 0 && out->sin_family == AF_INET &&
out->sin_addr.s_addr != htonl(INADDR_ANY))
rc = 0;
}
close(s);
return rc;
}
static int xc_usable_ipv4(const struct sockaddr *sa, unsigned flags) {
if (!sa || sa->sa_family != AF_INET) return 0;
if (!(flags & IFF_UP) || (flags & IFF_LOOPBACK)) return 0;
const uint32_t a = ntohl(((const struct sockaddr_in *)sa)->sin_addr.s_addr);
return a != 0 && (a >> 24) != 127 && (a >> 16) != 0xA9FE; /* not 0/8-any, loopback, link-local */
}
int xc_getifaddrs(struct ifaddrs **ifap) {
if (!ifap) {
errno = EINVAL;
return -1;
}
*ifap = NULL;
struct ifaddrs *head = NULL, **tail = &head;
int haveIpv4 = 0;
struct ifaddrs *sys = NULL;
if (getifaddrs(&sys) == 0) {
for (struct ifaddrs *i = sys; i; i = i->ifa_next) {
if (!i->ifa_addr || (i->ifa_addr->sa_family != AF_INET && i->ifa_addr->sa_family != AF_INET6)) continue;
struct ifaddrs *n = xc_ifa_new(i->ifa_name, i->ifa_flags, i->ifa_addr, i->ifa_netmask);
if (!n) break;
if (xc_usable_ipv4(i->ifa_addr, i->ifa_flags)) haveIpv4 = 1;
*tail = n;
tail = &n->ifa_next;
}
freeifaddrs(sys);
}
if (!haveIpv4) {
struct sockaddr_in sin;
memset(&sin, 0, sizeof(sin));
if (xc_default_route_ipv4(&sin) == 0) {
struct ifaddrs *n = xc_ifa_new("eth0", IFF_UP | IFF_RUNNING, (const struct sockaddr *)&sin, NULL);
if (n) {
((struct sockaddr_in *)n->ifa_addr)->sin_port = 0;
*tail = n;
tail = &n->ifa_next;
}
}
}
if (!head) {
errno = ENETDOWN;
return -1;
}
*ifap = head;
return 0;
}
+45
View File
@@ -0,0 +1,45 @@
/* Nubix — resolver / interface-list fallbacks for the PS5 libc.
*
* The PS5 libc resolves real host names (curl works), but getaddrinfo() fails for the special
* cases libjuice relies on: node == NULL with AI_PASSIVE ("any" address to bind a UDP socket) and
* numeric hosts with AI_NUMERICHOST. xc_getaddrinfo() answers those itself (inet_pton) and only
* hands real names to the system resolver. xc_getifaddrs() falls back to the address of the
* default route when getifaddrs() fails or lists no usable IPv4 interface.
*
* The PS5 build of libdatachannel force-includes this header into its C sources (libjuice) with
* XC_NET_COMPAT_REDIRECT defined (scripts/build-deps.sh), so their calls land here.
* Copyright (C) 2026 Nubix contributors
* SPDX-License-Identifier: GPL-3.0-only */
#ifndef XC_NET_COMPAT_H
#define XC_NET_COMPAT_H
#include <sys/types.h>
#include <sys/socket.h>
#include <ifaddrs.h>
#include <netdb.h>
#ifdef __cplusplus
extern "C" {
#endif
int xc_getaddrinfo(const char *node, const char *service, const struct addrinfo *hints, struct addrinfo **res);
void xc_freeaddrinfo(struct addrinfo *ai);
int xc_getifaddrs(struct ifaddrs **ifap);
void xc_freeifaddrs(struct ifaddrs *ifa);
/* Numeric conversions (NI_NUMERICHOST/NI_NUMERICSERV) via inet_ntop; others go to the system. */
int xc_getnameinfo(const struct sockaddr *sa, socklen_t salen, char *host, socklen_t hostlen, char *serv,
socklen_t servlen, int flags);
#ifdef __cplusplus
}
#endif
#if defined(XC_NET_COMPAT_REDIRECT) && !defined(XC_NET_COMPAT_IMPL)
#define getaddrinfo xc_getaddrinfo
#define freeaddrinfo xc_freeaddrinfo
#define getifaddrs xc_getifaddrs
#define freeifaddrs xc_freeifaddrs
#define getnameinfo xc_getnameinfo
#endif
#endif /* XC_NET_COMPAT_H */
+68
View File
@@ -0,0 +1,68 @@
// Nubix — platform abstraction (PS5 payload vs. host build).
// Implemented in platform/ps5.cpp (XC_PS5) or platform/host.cpp.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstdint>
#include <string>
namespace xc {
namespace platform {
// Early process init: detect the install / executable dir (PS5: absolute argv[0], else the cwd
// when it holds assets/, else /data/homebrew/nubix) and create dataDir(). Returns false
// on fatal error (PS5: also shows a system notification, since stderr is not visible).
// System modules (libScePad, AudioOut, VideoOut, ...) are not loaded here: the payload ELF's
// DT_NEEDED entries make the loader map them before main().
bool init(int argc, char** argv);
// Undo init().
void shutdown();
// Writable data dir, created on demand. PS5: /data/nubix; host: $XC_DATA_DIR or
// ~/.config/nubix (macOS: ~/Library/Application Support/nubix).
std::string dataDir();
// Read-only assets dir: <exe dir>/assets, fallback /data/homebrew/nubix/assets (PS5)
// or the source tree's assets/ (host, XC_SOURCE_ASSETS_DIR).
std::string assetsDir();
// assetsDir() + "/" + rel.
std::string assetPath(const std::string& rel);
// Directory containing the running executable.
std::string exeDir();
// Show a system notification (PS5: sceKernelSendNotificationRequest), host: log only.
void notify(const std::string& msg);
// True if an UP interface has a usable IPv4 address (see localIpv4()).
bool networkAvailable();
// First usable IPv4 address of an UP interface ("" if none): skips loopback, 0.0.0.0 (PS5
// lists idle interfaces that way) and link-local 169.254/16. Calls getifaddrs(): cache the
// result in per-frame code.
std::string localIpv4();
// Called by the UI once the first frame has been presented. PS5: hides the system splash
// screen shown while a (fake) app starts; host: no-op.
void onFirstFrame();
// Milliseconds from a monotonic clock.
uint64_t monotonicMs();
// Seconds since the Unix epoch (wall clock).
int64_t unixTime();
// True when built for / running on PS5.
constexpr bool isPs5() {
#ifdef XC_PS5
return true;
#else
return false;
#endif
}
} // namespace platform
} // namespace xc
+119
View File
@@ -0,0 +1,119 @@
// Nubix — PS5 payload platform implementation.
// Notification request layout from the ps5-payload-sdk samples (hello_world / notify_debug).
// Notification layout from the ps5-payload-sdk samples (John Toernblom, GPL-3.0-or-later) and ProsperoLight (GPL-3.0).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#ifdef XC_PS5
#include <sys/stat.h>
#include <unistd.h>
#include <atomic>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <string>
#include "core/log.hpp"
#include "platform/platform.hpp"
// Notification request as used by the PS5 payload SDK samples / ProsperoLight.
typedef struct notify_request {
char useless1[45];
char message[3075];
} notify_request_t;
extern "C" int sceKernelSendNotificationRequest(int, notify_request_t*, size_t, int);
extern "C" int sceSystemServiceHideSplashScreen(void);
namespace xc {
namespace platform {
namespace {
const char* kDataDir = "/data/nubix";
const char* kLegacyDataDir = "/data/ps5-xcloud"; // name before 0.3.0: migrated on first start
const char* kInstallDir = "/data/homebrew/nubix";
std::string g_exeDir = kInstallDir;
std::atomic<bool> g_splashHidden{false};
bool isDir(const std::string& p) {
struct stat st{};
return stat(p.c_str(), &st) == 0 && S_ISDIR(st.st_mode);
}
// mkdir -p
bool mkdirs(const std::string& path) {
if (path.empty()) return false;
std::string cur;
for (size_t i = 0; i < path.size(); ++i) {
cur.push_back(path[i]);
if (path[i] == '/' || i + 1 == path.size()) {
if (cur == "/") continue;
if (mkdir(cur.c_str(), 0777) != 0 && errno != EEXIST) return false;
}
}
return isDir(path);
}
std::string dirName(const std::string& p) {
auto pos = p.find_last_of('/');
if (pos == std::string::npos) return ".";
if (pos == 0) return "/";
return p.substr(0, pos);
}
} // namespace
bool init(int argc, char** argv) {
// websrv launches eboot.elf with an absolute argv[0] and cwd = install dir; elfldr payloads
// have no meaningful argv[0], so fall back to the cwd (if it has assets/) or the install dir.
char cwd[1024] = {0};
if (argc > 0 && argv && argv[0] && std::strchr(argv[0], '/') && isDir(dirName(argv[0]))) {
g_exeDir = dirName(argv[0]);
} else if (getcwd(cwd, sizeof(cwd)) && isDir(std::string(cwd) + "/assets")) {
g_exeDir = cwd;
} else {
g_exeDir = kInstallDir;
}
// Keep the sign-in and settings of installs from before the rename.
if (!isDir(kDataDir) && isDir(kLegacyDataDir)) std::rename(kLegacyDataDir, kDataDir);
if (!mkdirs(kDataDir)) {
const int e = errno;
std::fprintf(stderr, "cannot create %s: %s\n", kDataDir, std::strerror(e));
// stderr goes to the launcher pipe only: tell the user why the app closes right away.
notify(std::string("nubix: cannot create ") + kDataDir + " (" + std::strerror(e) + ")");
return false;
}
return true;
}
void shutdown() {}
std::string dataDir() { return kDataDir; }
std::string assetsDir() {
std::string local = g_exeDir + "/assets";
if (isDir(local)) return local;
return std::string(kInstallDir) + "/assets";
}
std::string exeDir() { return g_exeDir; }
void notify(const std::string& msg) {
XC_LOGI("[notify] %s", msg.c_str());
notify_request_t req;
std::memset(&req, 0, sizeof(req));
std::snprintf(req.message, sizeof(req.message), "%s", msg.c_str());
sceKernelSendNotificationRequest(0, &req, sizeof(req), 0);
}
void onFirstFrame() {
if (g_splashHidden.exchange(true)) return;
int rc = sceSystemServiceHideSplashScreen();
if (rc != 0) XC_LOGD("sceSystemServiceHideSplashScreen: 0x%08x", static_cast<unsigned>(rc));
}
} // namespace platform
} // namespace xc
#endif // XC_PS5
+17
View File
@@ -0,0 +1,17 @@
/* Nubix — libc functions missing from the PS5 payload SDK.
* Copyright (C) 2026 Nubix contributors
* SPDX-License-Identifier: GPL-3.0-only */
#ifdef XC_PS5
#include <stddef.h>
/* usrsctp (sctp_auth.c, sctp_input.c) needs the BSD constant-time compare. */
int timingsafe_bcmp(const void *b1, const void *b2, size_t n) {
const unsigned char *p1 = (const unsigned char *)b1, *p2 = (const unsigned char *)b2;
int ret = 0;
for (; n > 0; n--) ret |= *p1++ ^ *p2++;
return (ret != 0);
}
#else
/* Host builds get timingsafe_bcmp from libc (or usrsctp's own fallback). */
typedef int xc_ps5_shims_empty_translation_unit;
#endif
+440
View File
@@ -0,0 +1,440 @@
// Nubix — Opus audio playback.
// Portions derived from green-nx (https://github.com/rmrf404/green-nx), Copyright (C) the green-nx authors, GPL-3.0; modified by Nubix contributors, 2026.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
//
// RTP (or pre-depacketised Opus) -> libopus 48 kHz stereo s16 -> ring buffer -> SDL audio callback.
//
// Jitter handling: playback starts once ~60 ms are buffered; on underrun the callback plays
// silence and re-primes; if the buffer grows past a high watermark the oldest audio is dropped
// back to the target. A small clock-skew servo (linear-interpolation stretch of at most 0.2 %)
// steers the smoothed depth towards the target so sender/receiver crystal drift never builds up.
// Ring, prebuffer, servo and resampler design are ported from green-nx
// (src/switch/stream/audio_player.cpp, GPL-3.0); the output side uses SDL instead of audout.
//
// Threads: pushRtp()/pushOpus()/reset() may be called from any thread (normally the
// libdatachannel track callback); the SDL audio thread only touches the ring under ringMu.
#include "stream/audio.hpp"
#include "core/log.hpp"
#include <SDL.h>
#include <opus.h>
#include <algorithm>
#include <atomic>
#include <cstring>
#include <mutex>
#include <vector>
namespace xc {
namespace {
constexpr int kSampleRate = 48000;
constexpr int kChannels = 2;
constexpr int kMaxFrameSamples = 5760; // 120 ms per channel
constexpr int kInt16PerMs = kSampleRate * kChannels / 1000; // 96 interleaved samples per ms
constexpr int kRingMs = 500; // ring capacity
constexpr int kPrebufferMs = 60; // start playback at this depth
constexpr int kResumeMs = 40; // re-prime depth after an underrun
constexpr int kHighWatermarkMs = 150; // above this, shed audio...
constexpr int kShedTargetMs = 60; // ...down to here
constexpr int kMaxConcealFrames = 5; // PLC at most this many lost frames
constexpr Uint16 kDeviceSamples = 512; // ~10.7 ms; PS5 needs multiples of 256
// Clock-skew servo (see green-nx): ~1 s EMA of ring depth, 30 ppm per ms of error, clamp 0.2 %.
constexpr float kServoTargetMs = 60.0f;
constexpr float kServoEmaAlpha = 0.02f;
constexpr float kServoGain = 30e-6f;
constexpr float kServoMaxAdj = 2000e-6f;
} // namespace
struct AudioPlayer::Impl {
// ---- decoder side (guarded by decMu) ----
std::mutex decMu;
OpusDecoder* dec = nullptr;
bool haveSeq = false;
uint16_t expectedSeq = 0;
int lastFrameSamples = kSampleRate / 50; // 20 ms until the first packet tells us otherwise
std::vector<int16_t> pcm;
std::vector<int16_t> pcmOut;
float depthEma = 0.0f;
float servoAdj = 0.0f;
float resamplePos = 0.0f;
int16_t carry[2] = {0, 0};
// ---- output side (guarded by ringMu) ----
std::mutex ringMu;
std::vector<int16_t> ring;
int ringSize = 0;
int readPos = 0, writePos = 0, count = 0;
bool primed = false;
int primeTarget = kPrebufferMs * kInt16PerMs;
SDL_AudioDeviceID dev = 0;
bool weInitedSdlAudio = false;
std::atomic<bool> muted{false};
// ---- stats ----
std::atomic<uint64_t> packets{0}, lost{0}, late{0}, errors{0}, underruns{0}, droppedSamples{0};
std::atomic<int> bufferedSamples{0};
static void sdlCallback(void* user, Uint8* stream, int len) {
static_cast<Impl*>(user)->fill(reinterpret_cast<int16_t*>(stream),
len / static_cast<int>(sizeof(int16_t)));
}
void resetDecoderStateLocked() {
if (dec) opus_decoder_ctl(dec, OPUS_RESET_STATE);
haveSeq = false;
expectedSeq = 0;
lastFrameSamples = kSampleRate / 50;
depthEma = 0.0f;
servoAdj = 0.0f;
resamplePos = 0.0f;
carry[0] = carry[1] = 0;
}
void resetRing() {
std::lock_guard<std::mutex> lk(ringMu);
readPos = writePos = count = 0;
primed = false;
primeTarget = kPrebufferMs * kInt16PerMs;
bufferedSamples.store(0, std::memory_order_relaxed);
}
// Decode one packet (data == nullptr -> PLC, fec -> in-band FEC of the *previous* frame)
// and queue the PCM. Called with decMu held.
void decodeAndQueue(const uint8_t* data, size_t n, int frameSamples, bool fec) {
if (!dec) return;
const int maxSamples = (data && !fec) ? kMaxFrameSamples : frameSamples;
const int got = opus_decode(dec, data, static_cast<opus_int32>(data ? n : 0), pcm.data(),
maxSamples, fec ? 1 : 0);
if (got <= 0) {
errors.fetch_add(1, std::memory_order_relaxed);
if (errors.load(std::memory_order_relaxed) <= 5)
XC_LOGW("audio: opus_decode failed: %s", opus_strerror(got));
return;
}
if (data && !fec) lastFrameSamples = got;
queuePcm(got);
}
// Servo + resample `samples` frames from pcm into the ring. Called with decMu held.
void queuePcm(int samples) {
int depthSamples;
{
std::lock_guard<std::mutex> lk(ringMu);
depthSamples = count;
}
const float depthMs = static_cast<float>(depthSamples) / kInt16PerMs;
depthEma += kServoEmaAlpha * (depthMs - depthEma);
servoAdj = std::clamp((kServoTargetMs - depthEma) * kServoGain, -kServoMaxAdj, kServoMaxAdj);
const int outSamples = servoResample(pcm.data(), samples, pcmOut.data(),
static_cast<int>(pcmOut.size()) / kChannels);
pushRing(pcmOut.data(), outSamples * kChannels);
}
// Linear-interpolation stretch, phase-continuous across frames (green-nx servo_resample).
// step < 1 stretches (more output), step > 1 shrinks.
int servoResample(const int16_t* in, int inSamples, int16_t* out, int maxOut) {
const float step = 1.0f - servoAdj;
float pos = resamplePos;
int outN = 0;
while (outN < maxOut) {
const int i = static_cast<int>(pos);
if (i >= inSamples) break;
const float frac = pos - static_cast<float>(i);
const int16_t l0 = (i == 0) ? carry[0] : in[(i - 1) * kChannels];
const int16_t r0 = (i == 0) ? carry[1] : in[(i - 1) * kChannels + 1];
const int16_t l1 = in[i * kChannels];
const int16_t r1 = in[i * kChannels + 1];
out[outN * kChannels] = static_cast<int16_t>(l0 + static_cast<float>(l1 - l0) * frac);
out[outN * kChannels + 1] = static_cast<int16_t>(r0 + static_cast<float>(r1 - r0) * frac);
++outN;
pos += step;
}
// Keep the fractional phase relative to the next frame (pos is >= inSamples here unless
// the output buffer filled up, which cannot happen with the +1 % headroom).
resamplePos = std::max(0.0f, pos - static_cast<float>(inSamples));
carry[0] = in[(inSamples - 1) * kChannels];
carry[1] = in[(inSamples - 1) * kChannels + 1];
return outN;
}
void pushRing(const int16_t* data, int n) {
if (n <= 0) return;
const int high = kHighWatermarkMs * kInt16PerMs;
const int target = kShedTargetMs * kInt16PerMs;
std::lock_guard<std::mutex> lk(ringMu);
if (ringSize == 0) return;
if (n > ringSize) {
data += n - ringSize;
n = ringSize;
}
// Overflow: drop the oldest audio down to the target to bound latency.
if (count + n > high) {
int shed = std::min(count + n - target, count);
if (shed > 0) {
shed -= shed % kChannels; // keep L/R alignment
readPos = (readPos + shed) % ringSize;
count -= shed;
droppedSamples.fetch_add(static_cast<uint64_t>(shed), std::memory_order_relaxed);
}
}
if (count + n > ringSize) {
const int over = count + n - ringSize;
readPos = (readPos + over) % ringSize;
count -= over;
droppedSamples.fetch_add(static_cast<uint64_t>(over), std::memory_order_relaxed);
}
const int first = std::min(n, ringSize - writePos);
std::memcpy(&ring[writePos], data, static_cast<size_t>(first) * sizeof(int16_t));
if (n > first)
std::memcpy(&ring[0], data + first, static_cast<size_t>(n - first) * sizeof(int16_t));
writePos = (writePos + n) % ringSize;
count += n;
if (!primed && count >= primeTarget) primed = true;
bufferedSamples.store(count, std::memory_order_relaxed);
}
// SDL audio thread.
void fill(int16_t* out, int need) {
int given = 0;
{
std::lock_guard<std::mutex> lk(ringMu);
if (primed) {
const int give = std::min(need, count);
const int first = std::min(give, ringSize - readPos);
std::memcpy(out, &ring[readPos], static_cast<size_t>(first) * sizeof(int16_t));
if (give > first)
std::memcpy(out + first, &ring[0], static_cast<size_t>(give - first) * sizeof(int16_t));
readPos = (readPos + give) % ringSize;
count -= give;
given = give;
if (given < need) {
// Ran dry: play silence and wait for a shallower cushion before resuming.
primed = false;
primeTarget = kResumeMs * kInt16PerMs;
underruns.fetch_add(1, std::memory_order_relaxed);
}
}
bufferedSamples.store(count, std::memory_order_relaxed);
}
if (given < need)
std::memset(out + given, 0, static_cast<size_t>(need - given) * sizeof(int16_t));
if (muted.load(std::memory_order_relaxed))
std::memset(out, 0, static_cast<size_t>(need) * sizeof(int16_t));
}
// Handle one Opus payload with `lostBefore` missing packets in front of it.
void handleOpus(const uint8_t* data, size_t n, int lostBefore) {
if (!dec) return;
int frameSamples = lastFrameSamples;
const int ns = opus_packet_get_nb_samples(data, static_cast<opus_int32>(n), kSampleRate);
if (ns > 0 && ns <= kMaxFrameSamples) frameSamples = ns;
if (lostBefore > 0) {
lost.fetch_add(static_cast<uint64_t>(lostBefore), std::memory_order_relaxed);
const int conceal = std::min(lostBefore, kMaxConcealFrames);
// PLC for all but the last missing frame; the last one is recovered from this
// packet's in-band FEC (LBRR) when present (libopus falls back to PLC otherwise).
for (int i = 0; i < conceal - 1; ++i) decodeAndQueue(nullptr, 0, lastFrameSamples, false);
decodeAndQueue(data, n, frameSamples, true);
}
decodeAndQueue(data, n, frameSamples, false);
packets.fetch_add(1, std::memory_order_relaxed);
}
};
AudioPlayer::AudioPlayer() : d_(new Impl) {}
AudioPlayer::~AudioPlayer() { close(); }
bool AudioPlayer::init() {
close();
Impl& d = *d_;
{
std::lock_guard<std::mutex> lk(d.decMu);
int err = OPUS_OK;
d.dec = opus_decoder_create(kSampleRate, kChannels, &err);
if (err != OPUS_OK || !d.dec) {
XC_LOGE("audio: opus_decoder_create failed: %s", opus_strerror(err));
d.dec = nullptr;
return false;
}
d.pcm.assign(static_cast<size_t>(kMaxFrameSamples) * kChannels, 0);
// +1 % headroom: the servo stretches a frame by at most 0.2 %.
d.pcmOut.assign(static_cast<size_t>(kMaxFrameSamples + kMaxFrameSamples / 100 + 2) * kChannels, 0);
d.resetDecoderStateLocked();
}
{
std::lock_guard<std::mutex> lk(d.ringMu);
d.ringSize = kRingMs * kInt16PerMs;
d.ring.assign(static_cast<size_t>(d.ringSize), 0);
}
d.resetRing();
d.packets = 0;
d.lost = 0;
d.late = 0;
d.errors = 0;
d.underruns = 0;
d.droppedSamples = 0;
if (!SDL_WasInit(SDL_INIT_AUDIO)) {
if (SDL_InitSubSystem(SDL_INIT_AUDIO) != 0) {
XC_LOGE("audio: SDL_InitSubSystem(AUDIO) failed: %s", SDL_GetError());
close();
return false;
}
d.weInitedSdlAudio = true;
}
SDL_AudioSpec want;
SDL_zero(want);
want.freq = kSampleRate;
want.format = AUDIO_S16SYS;
want.channels = kChannels;
want.samples = kDeviceSamples;
want.callback = &Impl::sdlCallback;
want.userdata = &d;
SDL_AudioSpec have;
SDL_zero(have);
// Only the buffer size may change; SDL converts anything else to our s16/48k/stereo.
d.dev = SDL_OpenAudioDevice(nullptr, 0, &want, &have, SDL_AUDIO_ALLOW_SAMPLES_CHANGE);
if (d.dev == 0) {
XC_LOGE("audio: SDL_OpenAudioDevice failed: %s", SDL_GetError());
close();
return false;
}
XC_LOGI("audio: device open (%s, %d Hz, %d ch, %u samples/buffer), prebuffer %d ms",
SDL_GetCurrentAudioDriver() ? SDL_GetCurrentAudioDriver() : "?", have.freq,
have.channels, static_cast<unsigned>(have.samples), kPrebufferMs);
SDL_PauseAudioDevice(d.dev, 0);
return true;
}
void AudioPlayer::pushRtp(const uint8_t* rtp, size_t n) {
Impl& d = *d_;
// RFC 3550 fixed header (12 bytes) + CSRC list + optional header extension + padding.
if (!rtp || n < 12 || (rtp[0] >> 6) != 2) {
d.errors.fetch_add(1, std::memory_order_relaxed);
return;
}
const bool padding = (rtp[0] & 0x20) != 0;
const bool extension = (rtp[0] & 0x10) != 0;
const size_t csrc = rtp[0] & 0x0f;
const uint8_t pt = rtp[1] & 0x7f;
if (pt >= 72 && pt <= 76) return; // RTCP muxed on the same port (SR/RR/...), not media
size_t off = 12 + 4 * csrc;
size_t end = n;
if (off > end) {
d.errors.fetch_add(1, std::memory_order_relaxed);
return;
}
if (extension) {
if (off + 4 > end) {
d.errors.fetch_add(1, std::memory_order_relaxed);
return;
}
const size_t extWords = (static_cast<size_t>(rtp[off + 2]) << 8) | rtp[off + 3];
off += 4 + extWords * 4;
if (off > end) {
d.errors.fetch_add(1, std::memory_order_relaxed);
return;
}
}
if (padding) {
const size_t pad = rtp[n - 1];
if (pad == 0 || pad > end - off) {
d.errors.fetch_add(1, std::memory_order_relaxed);
return;
}
end -= pad;
}
if (end == off) return; // no payload (keep-alive)
const uint16_t seq = static_cast<uint16_t>((rtp[2] << 8) | rtp[3]);
std::lock_guard<std::mutex> lk(d.decMu);
if (!d.dec) return;
int lostBefore = 0;
if (d.haveSeq) {
const int16_t diff = static_cast<int16_t>(seq - d.expectedSeq);
if (diff < 0) {
// Duplicate or arrived after we already concealed it: too late to play.
d.late.fetch_add(1, std::memory_order_relaxed);
return;
}
lostBefore = diff;
}
d.haveSeq = true;
d.expectedSeq = static_cast<uint16_t>(seq + 1);
d.handleOpus(rtp + off, end - off, lostBefore);
}
void AudioPlayer::pushOpus(const uint8_t* opus, size_t n, int lostBefore) {
if (!opus || n == 0) return;
std::lock_guard<std::mutex> lk(d_->decMu);
if (!d_->dec) return;
d_->handleOpus(opus, n, lostBefore < 0 ? 0 : lostBefore);
}
void AudioPlayer::reset() {
{
std::lock_guard<std::mutex> lk(d_->decMu);
d_->resetDecoderStateLocked();
}
d_->resetRing();
}
int AudioPlayer::bufferedMs() const {
return d_->bufferedSamples.load(std::memory_order_relaxed) / kInt16PerMs;
}
void AudioPlayer::setMuted(bool muted) { d_->muted.store(muted, std::memory_order_relaxed); }
AudioPlayer::Stats AudioPlayer::stats() const {
Stats s;
s.packets = d_->packets.load(std::memory_order_relaxed);
s.lost = d_->lost.load(std::memory_order_relaxed);
s.late = d_->late.load(std::memory_order_relaxed);
s.decodeErrors = d_->errors.load(std::memory_order_relaxed);
s.underruns = d_->underruns.load(std::memory_order_relaxed);
s.droppedMs = d_->droppedSamples.load(std::memory_order_relaxed) / kInt16PerMs;
s.bufferedMs = bufferedMs();
return s;
}
void AudioPlayer::close() {
if (!d_) return;
Impl& d = *d_;
// Close the device first: SDL joins its audio thread, so fill() is no longer running.
if (d.dev) {
SDL_CloseAudioDevice(d.dev);
d.dev = 0;
XC_LOGI("audio: closed (%llu packets, %llu lost, %llu late, %llu underruns, %llu ms dropped)",
static_cast<unsigned long long>(d.packets.load()),
static_cast<unsigned long long>(d.lost.load()),
static_cast<unsigned long long>(d.late.load()),
static_cast<unsigned long long>(d.underruns.load()),
static_cast<unsigned long long>(d.droppedSamples.load() / kInt16PerMs));
}
if (d.weInitedSdlAudio) {
SDL_QuitSubSystem(SDL_INIT_AUDIO);
d.weInitedSdlAudio = false;
}
{
std::lock_guard<std::mutex> lk(d.decMu);
if (d.dec) {
opus_decoder_destroy(d.dec);
d.dec = nullptr;
}
d.resetDecoderStateLocked();
}
d.resetRing();
}
} // namespace xc
+61
View File
@@ -0,0 +1,61 @@
// Nubix — Opus audio: RTP -> libopus (48 kHz stereo) -> ring buffer -> SDL audio device.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstddef>
#include <cstdint>
#include <memory>
namespace xc {
class AudioPlayer {
public:
AudioPlayer();
~AudioPlayer();
AudioPlayer(const AudioPlayer&) = delete;
AudioPlayer& operator=(const AudioPlayer&) = delete;
// Create the Opus decoder and open the SDL audio device (48 kHz, s16, stereo).
// SDL_INIT_AUDIO must already be initialised.
bool init();
// Push one raw audio RTP packet (header included); decodes and queues PCM. Thread-safe.
void pushRtp(const uint8_t* rtp, size_t n);
// Buffered audio latency in milliseconds (stats overlay).
int bufferedMs() const;
// Mute/unmute output without closing the device.
void setMuted(bool muted);
// Close the audio device and decoder. Idempotent.
void close();
// ---- additive API (media module) ----
// Push one Opus payload that was already de-packetised (e.g. by RtpOpusReceiver).
// lostBefore = packets missing immediately before this one; they are concealed with
// in-band FEC (last one) and PLC. Thread-safe. Do not mix with pushRtp() in one session.
void pushOpus(const uint8_t* opus, size_t n, int lostBefore = 0);
// Drop all buffered audio and decoder/sequence state (stream reconnect). Thread-safe.
void reset();
struct Stats {
uint64_t packets = 0; // Opus packets decoded
uint64_t lost = 0; // packets concealed (gap in sequence numbers)
uint64_t late = 0; // duplicate / out-of-order packets dropped
uint64_t decodeErrors = 0; // opus_decode failures / malformed RTP
uint64_t underruns = 0; // callback ran dry after playback had started
uint64_t droppedMs = 0; // audio discarded to bound latency (overflow)
int bufferedMs = 0;
};
Stats stats() const;
private:
struct Impl;
std::unique_ptr<Impl> d_;
};
} // namespace xc
+1056
View File
File diff suppressed because it is too large. Load diff
+168
View File
@@ -0,0 +1,168 @@
// Nubix — RTP receive side: H.264 depacketizer (RFC 6184: single NAL, STAP-A, FU-A) with
// reorder buffer, loss tracking and NACK list; Opus RTP receiver; RFC 3550 receiver statistics.
//
// Loss handling follows the lessons documented in green-nx (src/switch/stream/video_jitter.cpp,
// GPL-3.0): complete frames are emitted immediately, incomplete ones are held only while a
// retransmission can still arrive, RTP padding packets are not loss, and after an unrecoverable
// loss nothing but a real IDR access unit is passed to the decoder.
// Portions derived from green-nx (https://github.com/rmrf404/green-nx), Copyright (C) the green-nx authors, GPL-3.0; modified by Nubix contributors, 2026.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstddef>
#include <cstdint>
#include <memory>
#include <mutex>
#include <vector>
namespace xc {
// Receiver-side statistics for one SSRC, as needed for an RTCP RR report block (RFC 3550 §6.4.1).
struct RtpReceiveStats {
uint32_t ssrc = 0;
uint8_t fractionLost = 0; // since previous report, fixed point /256
int32_t cumulativeLost = 0; // 24-bit signed on the wire
uint32_t extHighestSeq = 0; // cycles << 16 | highest seq
uint32_t jitter = 0; // interarrival jitter in RTP timestamp units
uint32_t lastSr = 0; // middle 32 bits of the last SR NTP timestamp (LSR)
uint32_t delaySinceLastSr = 0; // DLSR in 1/65536 s
uint64_t packets = 0, bytes = 0;
};
// Monotonic milliseconds used by the RTP classes when no explicit time is passed.
uint64_t rtpNowMs();
// RFC 3550 Appendix A.1/A.8 sequence + jitter tracker, shared by video and audio receivers.
// Thread-safe (internally locked).
class RtpStatsTracker {
public:
RtpStatsTracker() = default;
// Copyable / assignable (state is copied under the source's lock; the mutex is not).
RtpStatsTracker(const RtpStatsTracker& o);
RtpStatsTracker& operator=(const RtpStatsTracker& o);
// Account one received RTP packet (seq, rtp timestamp, clockRate Hz, payload bytes).
void onPacket(uint32_t ssrc, uint16_t seq, uint32_t rtpTs, uint32_t clockRate, size_t bytes);
// Same, with an explicit arrival time in microseconds (monotonic; for tests / replay).
void onPacketAt(uint32_t ssrc, uint16_t seq, uint32_t rtpTs, uint32_t clockRate, size_t bytes,
uint64_t arrivalUs);
// Record an incoming RTCP sender report (NTP timestamp) to fill LSR/DLSR.
void onSenderReport(uint64_t ntpTimestamp);
void onSenderReportAt(uint64_t ntpTimestamp, uint64_t nowMs);
// Snapshot for an RR block; resets the "since last report" interval counters.
RtpReceiveStats snapshotForReport();
RtpReceiveStats snapshotForReportAt(uint64_t nowMs);
// True once at least one packet was accounted.
bool hasData() const;
// Forget everything (new SSRC / reconnect).
void reset();
private:
void initSeq(uint16_t seq);
mutable std::mutex mu_;
RtpReceiveStats st_;
bool init_ = false;
uint16_t maxSeq_ = 0;
uint32_t cycles_ = 0, baseSeq_ = 0, received_ = 0, expectedPrior_ = 0, receivedPrior_ = 0;
uint32_t badSeq_ = 0x10001;
double transit_ = 0, jitter_ = 0;
bool haveTransit_ = false;
uint64_t lastSrLocalMs_ = 0;
bool haveSr_ = false;
};
// Counters of the H.264 depacketizer (monotonic since construction / reset()).
struct RtpVideoCounters {
uint64_t packets = 0; // RTP packets accepted (incl. padding)
uint64_t framesEmitted = 0; // complete access units handed out
uint64_t framesDropped = 0; // incomplete or non-decodable access units discarded
uint64_t nacksSent = 0; // sequence numbers returned by takeNackList (incl. retries)
uint64_t recovered = 0; // NACKed packets that arrived in time
uint64_t lateOrDuplicate = 0; // packets that arrived after their slot was consumed
uint64_t keyframeRequests = 0; // times needKeyframe() returned true
uint32_t rttMs = 0; // NACK round-trip estimate (0 = none yet)
bool waitingForKeyframe = true;
};
class RtpH264Depacketizer {
public:
RtpH264Depacketizer();
~RtpH264Depacketizer();
// Push one raw RTP packet (header included). Packets may arrive out of order; they are
// reordered in a small window. Complete access units are emitted per marker bit / timestamp change.
void push(const uint8_t* pkt, size_t n);
// Same with explicit monotonic time in ms (tests / replay). Do not mix clocks.
void pushAt(const uint8_t* pkt, size_t n, uint64_t nowMs);
// Pop the next complete access unit as Annex-B (00 00 00 01 start codes). isKeyframe = contains IDR.
bool popFrame(std::vector<uint8_t>& annexB, uint32_t& rtpTs, bool& isKeyframe);
// Sequence numbers detected missing since the last call (for RTCP NACK). Clears the list.
// Missing packets are re-requested a limited number of times while still useful.
std::vector<uint16_t> takeNackList();
std::vector<uint16_t> takeNackListAt(uint64_t nowMs);
// True once after unrecoverable loss (frame dropped); caller should send PLI / keyframe request.
// While still waiting for an IDR it becomes true again every keyframeRetryMs (default 1000).
bool needKeyframe();
bool needKeyframeAt(uint64_t nowMs);
// Receiver statistics (for RTCP RR).
RtpStatsTracker& stats();
// Drop all buffered state (e.g. after reconnect).
void reset();
// Tuning: minimum time an incomplete frame is held for retransmission (default 40 ms; the
// effective value grows with the measured NACK round trip, capped at maxLossWaitMs),
// maximum hold (default 250 ms) and keyframe re-request interval (default 1000 ms).
void setLossWait(uint32_t minLossWaitMs, uint32_t maxLossWaitMs);
void setKeyframeRetryMs(uint32_t ms);
// Payload type filter (0 = accept any, default 0). Packets with another PT are ignored.
void setPayloadType(uint8_t pt);
// Counters snapshot.
RtpVideoCounters counters() const;
// Latched media SSRC (0 = none yet).
uint32_t ssrc() const;
private:
struct Impl;
std::unique_ptr<Impl> d_;
};
// Extracts Opus payloads from RTP, with a short reorder window and loss detection.
class RtpOpusReceiver {
public:
RtpOpusReceiver();
~RtpOpusReceiver();
// Push one raw RTP packet (header included).
void push(const uint8_t* pkt, size_t n);
void pushAt(const uint8_t* pkt, size_t n, uint64_t nowMs);
// Pop the next Opus packet in sequence order. lostBefore = number of packets missing
// immediately before this one (decoder should run PLC/FEC for them).
bool pop(std::vector<uint8_t>& opus, uint32_t& rtpTs, int& lostBefore);
bool popAt(std::vector<uint8_t>& opus, uint32_t& rtpTs, int& lostBefore, uint64_t nowMs);
// Receiver statistics (for RTCP RR).
RtpStatsTracker& stats();
// Drop all buffered state.
void reset();
// Number of packets currently buffered (in order or ahead of a gap).
size_t buffered() const;
// Latched media SSRC (0 = none yet).
uint32_t ssrc() const;
private:
struct Impl;
std::unique_ptr<Impl> d_;
};
} // namespace xc
File diff suppressed because it is too large. Load diff
+91
View File
@@ -0,0 +1,91 @@
// Nubix — orchestrates one streaming session: auth refresh, gssv start/queue/provision,
// SDP/ICE exchange, WebRTC, control/message/input channels, decode, audio, RTCP, keepalive.
// Runs on its own worker thread; the main thread polls state and pulls frames.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstdint>
#include <memory>
#include <string>
#include "core/auth.hpp"
#include "core/config.hpp"
#include "core/gssv.hpp"
#include "core/protocol.hpp"
struct AVFrame;
namespace xc {
enum class StreamState { Idle, Starting, Queued, Provisioning, Connecting, Streaming, Ended, Failed };
// Human readable name for a state (logs / UI).
const char* streamStateName(StreamState s);
// Live statistics for the stream overlay.
struct StreamStats {
int width = 0, height = 0;
double fps = 0; // decoded frames per second
double bitrateKbps = 0; // received video bitrate
double decodeMs = 0; // average decode time
double lossPercent = 0; // video packet loss over the last second
int rttMs = -1; // unknown = -1
int audioBufferMs = 0;
};
class Streamer {
public:
Streamer(Config& cfg, Auth& auth);
~Streamer();
Streamer(const Streamer&) = delete;
Streamer& operator=(const Streamer&) = delete;
// Start a session. Non-blocking: the work (releasing a previous session that is still
// winding down, decoder/audio init, the worker threads) runs on the Streamer's own lifecycle
// thread; state() reads Starting right away. False if a session is already active (started
// and neither stopped nor finished on its own).
// f2pOnly (cloud): the title is only playable through the free-to-play offering
// (Title::f2pOnly) - start with xgpuwebf2p instead of xgpuweb, regardless of f2pFallback.
bool start(SessionKind kind, const std::string& titleOrServerId, bool f2pOnly = false);
// Current state, status line and queue position (-1 when not queued). Thread-safe.
StreamState state() const;
std::string statusText() const;
int queuePosition() const;
// Estimated queue wait in seconds while state() == Queued (gssv /v1/waittime, cloud only),
// -1 when unknown or not queued. Thread-safe.
int queueWaitSeconds() const;
// Latest gamepad state; called each frame from the main thread.
void setGamepad(const GamepadState& st);
// Pop a pending rumble command from the server. Thread-safe.
bool takeVibration(Vibration& out);
// Newest decoded frame not yet returned (new reference, caller av_frame_free's it), or nullptr.
AVFrame* currentFrame();
// Snapshot of stream statistics.
StreamStats stats() const;
// Send a keyframe request (PLI + control message), rate-limited.
void requestKeyframe();
// Request the session to stop without blocking: signals the worker (in-flight requests
// abort within ~1 s) and queues the teardown (DELETE on gssv, close PC, release
// decoder/audio) on the lifecycle thread. A later start() runs after it. Idempotent.
void stopAsync();
// Stop the session synchronously and shut the lifecycle thread down (app exit / destructor):
// cancels queued starts, joins every thread. The final gssv DELETE is bounded to ~5 s.
// Safe to call in any state; idempotent. start() works again afterwards.
void stop();
private:
struct Impl;
std::unique_ptr<Impl> d_;
};
} // namespace xc
+347
View File
@@ -0,0 +1,347 @@
// Nubix — H.264 decoder (FFmpeg).
// Portions derived from green-nx (https://github.com/rmrf404/green-nx), Copyright (C) the green-nx authors, GPL-3.0; modified by Nubix contributors, 2026.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
//
// Software H.264 decode with libavcodec: slice threads, AV_CODEC_FLAG_LOW_DELAY + FLAG2_FAST,
// default error concealment (guess MVs + deblock). One access unit in, at most one frame out.
// Decoder setup and the corrupt-frame detection follow green-nx
// (src/switch/stream/video_decoder.cpp, GPL-3.0).
//
// Threads: decode()/flush() run on the stream worker thread; latest() is called by the render
// thread. Frame handoff is double-buffered: the decoder writes into its private `work` frame and
// then moves the reference into the shared `pending` slot (replacing an unconsumed older frame);
// latest() moves `pending` out into a fresh AVFrame for the caller. Only refcounted buffers move,
// no pixel data is copied.
#include "stream/video.hpp"
#include "core/log.hpp"
#include <atomic>
#include <climits>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <mutex>
#include <string>
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/error.h>
#include <libavutil/frame.h>
#include <libavutil/log.h>
}
namespace xc {
namespace {
using Clock = std::chrono::steady_clock;
// After this many consecutive access units that produced no picture (without an explicit
// error), assume the decoder lost its references and ask for a keyframe.
constexpr int kMaxSilentAus = 5;
// FFmpeg log forwarding. The h264 decoder prints one line per damaged slice when packets are
// lost, so everything goes to Debug and is rate-limited.
std::atomic<int64_t> g_avLogWindowStart{0};
std::atomic<int> g_avLogCount{0};
constexpr int kAvLogMaxPerSecond = 20;
void avLogCallback(void* avcl, int level, const char* fmt, va_list vl) {
if (level > AV_LOG_WARNING) return;
if (level > AV_LOG_ERROR && logLevel() > LogLevel::Debug) return;
const int64_t nowS =
std::chrono::duration_cast<std::chrono::seconds>(Clock::now().time_since_epoch()).count();
int64_t start = g_avLogWindowStart.load(std::memory_order_relaxed);
if (nowS != start && g_avLogWindowStart.compare_exchange_strong(start, nowS)) {
const int suppressed = g_avLogCount.exchange(0) - kAvLogMaxPerSecond;
if (suppressed > 0) XC_LOGD("ffmpeg: %d log lines suppressed", suppressed);
}
if (g_avLogCount.fetch_add(1, std::memory_order_relaxed) >= kAvLogMaxPerSecond) return;
char line[512];
int prefix = 1;
av_log_format_line2(avcl, level, fmt, vl, line, sizeof(line), &prefix);
size_t len = std::strlen(line);
while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) line[--len] = '\0';
if (len == 0) return;
if (level <= AV_LOG_FATAL)
XC_LOGE("ffmpeg: %s", line);
else
XC_LOGD("ffmpeg: %s", line);
}
void installAvLog() {
static std::once_flag once;
std::call_once(once, [] {
av_log_set_level(AV_LOG_WARNING);
av_log_set_callback(avLogCallback);
});
}
std::string avErr(int err) {
char buf[AV_ERROR_MAX_STRING_SIZE] = {0};
av_strerror(err, buf, sizeof(buf));
return buf;
}
} // namespace
struct VideoDecoder::Impl {
// ---- decoder (guarded by decodeMu) ----
std::mutex decodeMu;
AVCodecContext* ctx = nullptr;
AVPacket* pkt = nullptr;
AVFrame* work = nullptr;
int silentAus = 0;
bool gotFrame = false;
Clock::time_point windowStart{};
double windowSumMs = 0;
uint64_t windowCount = 0;
bool windowPublished = false;
// ---- handoff (guarded by handoffMu) ----
std::mutex handoffMu;
AVFrame* pending = nullptr;
bool hasPending = false;
// ---- stats (lock-free reads) ----
std::atomic<double> avgMs{0.0};
std::atomic<uint64_t> frames{0};
std::atomic<uint64_t> errors{0};
std::atomic<uint64_t> replaced{0};
std::atomic<bool> needKey{false};
std::atomic<int> width{0};
std::atomic<int> height{0};
void markError() {
errors.fetch_add(1, std::memory_order_relaxed);
needKey.store(true, std::memory_order_relaxed);
}
// Move `work` into the handoff slot. Called with decodeMu held.
void publish() {
width.store(work->width, std::memory_order_relaxed);
height.store(work->height, std::memory_order_relaxed);
std::lock_guard<std::mutex> lk(handoffMu);
if (hasPending) {
replaced.fetch_add(1, std::memory_order_relaxed);
av_frame_unref(pending);
}
av_frame_move_ref(pending, work);
hasPending = true;
}
void recordTiming(double ms) {
const auto now = Clock::now();
if (windowCount == 0 && windowSumMs == 0) windowStart = now;
windowSumMs += ms;
++windowCount;
if (now - windowStart >= std::chrono::seconds(1)) {
avgMs.store(windowSumMs / static_cast<double>(windowCount), std::memory_order_relaxed);
windowPublished = true;
windowSumMs = 0;
windowCount = 0;
} else if (!windowPublished) {
// First second of the stream: report the running average.
avgMs.store(windowSumMs / static_cast<double>(windowCount), std::memory_order_relaxed);
}
}
void resetCounters() {
silentAus = 0;
gotFrame = false;
windowSumMs = 0;
windowCount = 0;
windowPublished = false;
avgMs.store(0.0);
frames.store(0);
errors.store(0);
replaced.store(0);
needKey.store(false);
width.store(0);
height.store(0);
}
void dropPending() {
std::lock_guard<std::mutex> lk(handoffMu);
if (pending) av_frame_unref(pending);
hasPending = false;
}
void freeAll() {
if (ctx) avcodec_free_context(&ctx);
if (pkt) av_packet_free(&pkt);
if (work) av_frame_free(&work);
std::lock_guard<std::mutex> lk(handoffMu);
if (pending) av_frame_free(&pending);
hasPending = false;
}
};
VideoDecoder::VideoDecoder() : d_(new Impl) {}
VideoDecoder::~VideoDecoder() { close(); }
bool VideoDecoder::init(int threads) {
installAvLog();
std::lock_guard<std::mutex> lk(d_->decodeMu);
d_->freeAll();
d_->resetCounters();
const AVCodec* codec = avcodec_find_decoder(AV_CODEC_ID_H264);
if (!codec) {
XC_LOGE("video: FFmpeg has no H.264 decoder");
return false;
}
d_->ctx = avcodec_alloc_context3(codec);
d_->pkt = av_packet_alloc();
d_->work = av_frame_alloc();
{
std::lock_guard<std::mutex> hl(d_->handoffMu);
d_->pending = av_frame_alloc();
}
if (!d_->ctx || !d_->pkt || !d_->work || !d_->pending) {
XC_LOGE("video: out of memory allocating decoder");
d_->freeAll();
return false;
}
AVCodecContext* c = d_->ctx;
c->flags |= AV_CODEC_FLAG_LOW_DELAY;
c->flags2 |= AV_CODEC_FLAG2_FAST;
// Slice threading adds no latency (frame threading would add threads-1 frames of delay).
c->thread_type = FF_THREAD_SLICE;
c->thread_count = threads > 0 ? threads : 1;
c->error_concealment = FF_EC_GUESS_MVS | FF_EC_DEBLOCK;
c->workaround_bugs = FF_BUG_AUTODETECT;
const int rc = avcodec_open2(c, codec, nullptr);
if (rc < 0) {
XC_LOGE("video: avcodec_open2 failed: %s", avErr(rc).c_str());
d_->freeAll();
return false;
}
XC_LOGI("video: %s decoder ready (slice threads=%d, low delay)", codec->name, c->thread_count);
return true;
}
bool VideoDecoder::decode(const uint8_t* au, size_t n, int64_t pts) {
std::lock_guard<std::mutex> lk(d_->decodeMu);
Impl& d = *d_;
if (!d.ctx) return false;
if (!au || n == 0) return true;
if (n > static_cast<size_t>(INT32_MAX - AV_INPUT_BUFFER_PADDING_SIZE)) {
d.markError();
return false;
}
const auto t0 = Clock::now();
bool ok = true;
av_packet_unref(d.pkt);
if (av_new_packet(d.pkt, static_cast<int>(n)) < 0) { // zero-fills the input padding
XC_LOGE("video: av_new_packet(%zu) failed", n);
return false;
}
std::memcpy(d.pkt->data, au, n);
d.pkt->pts = pts;
d.pkt->dts = pts;
int produced = 0;
auto drain = [&]() {
for (;;) {
const int r = avcodec_receive_frame(d.ctx, d.work);
if (r == AVERROR(EAGAIN) || r == AVERROR_EOF) break;
if (r < 0) {
XC_LOGD("video: receive_frame: %s", avErr(r).c_str());
d.markError();
ok = false;
break;
}
if (d.work->decode_error_flags != 0 || (d.work->flags & AV_FRAME_FLAG_CORRUPT)) {
// Concealed frame: still shown (better than freezing), but ask for a keyframe.
d.markError();
ok = false;
}
++produced;
d.frames.fetch_add(1, std::memory_order_relaxed);
d.publish(); // leaves `work` empty
}
};
int r = avcodec_send_packet(d.ctx, d.pkt);
if (r == AVERROR(EAGAIN)) {
// Should not happen with low delay since we always drain, but be safe.
drain();
r = avcodec_send_packet(d.ctx, d.pkt);
}
if (r < 0) {
XC_LOGD("video: send_packet (%zu bytes): %s", n, avErr(r).c_str());
d.markError();
ok = false;
}
drain();
av_packet_unref(d.pkt);
if (produced > 0) {
d.gotFrame = true;
d.silentAus = 0;
} else if (ok && ++d.silentAus >= kMaxSilentAus) {
// SPS/PPS-only AUs legitimately produce nothing, but a run of them means the decoder
// is discarding pictures (missing references / no IDR yet).
d.silentAus = 0;
d.needKey.store(true, std::memory_order_relaxed);
if (d.gotFrame) ok = false;
}
const double ms = std::chrono::duration<double, std::milli>(Clock::now() - t0).count();
d.recordTiming(ms);
return ok;
}
AVFrame* VideoDecoder::latest() {
std::lock_guard<std::mutex> lk(d_->handoffMu);
if (!d_->hasPending || !d_->pending) return nullptr;
AVFrame* out = av_frame_alloc();
if (!out) return nullptr;
av_frame_move_ref(out, d_->pending);
d_->hasPending = false;
return out;
}
double VideoDecoder::avgDecodeMs() const { return d_->avgMs.load(std::memory_order_relaxed); }
uint64_t VideoDecoder::framesDecoded() const { return d_->frames.load(std::memory_order_relaxed); }
bool VideoDecoder::needKeyframe() { return d_->needKey.exchange(false, std::memory_order_relaxed); }
uint64_t VideoDecoder::decodeErrors() const { return d_->errors.load(std::memory_order_relaxed); }
int VideoDecoder::width() const { return d_->width.load(std::memory_order_relaxed); }
int VideoDecoder::height() const { return d_->height.load(std::memory_order_relaxed); }
void VideoDecoder::flush() {
std::lock_guard<std::mutex> lk(d_->decodeMu);
if (d_->ctx) avcodec_flush_buffers(d_->ctx);
if (d_->work) av_frame_unref(d_->work);
d_->silentAus = 0;
d_->gotFrame = false;
d_->dropPending();
}
void VideoDecoder::close() {
if (!d_) return;
std::lock_guard<std::mutex> lk(d_->decodeMu);
if (d_->ctx) {
XC_LOGI("video: closing decoder (%llu frames, %llu errors, %llu unconsumed)",
static_cast<unsigned long long>(d_->frames.load()),
static_cast<unsigned long long>(d_->errors.load()),
static_cast<unsigned long long>(d_->replaced.load()));
}
d_->freeAll();
}
} // namespace xc
+63
View File
@@ -0,0 +1,63 @@
// Nubix — H.264 decoder (FFmpeg libavcodec, software, slice threads, low delay).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstddef>
#include <cstdint>
#include <memory>
struct AVFrame;
namespace xc {
class VideoDecoder {
public:
VideoDecoder();
~VideoDecoder();
VideoDecoder(const VideoDecoder&) = delete;
VideoDecoder& operator=(const VideoDecoder&) = delete;
// Open the H.264 decoder (thread_type = FF_THREAD_SLICE, AV_CODEC_FLAG_LOW_DELAY, FLAG2_FAST).
bool init(int threads = 4);
// Decode one Annex-B access unit. False on decode error (caller requests a keyframe).
// Called from the stream worker thread.
bool decode(const uint8_t* au, size_t n, int64_t pts);
// Thread-safe frame handoff: returns a NEW reference to the most recent decoded frame that
// has not been returned before, or nullptr if none. Caller owns it (av_frame_free).
AVFrame* latest();
// Average decode time in milliseconds over the last second (stats overlay).
double avgDecodeMs() const;
// Decoded frames since init (stats overlay).
uint64_t framesDecoded() const;
// Free decoder and buffered frames. Idempotent.
void close();
// ---- additive API (media module) ----
// True if a decode error / corrupt frame happened since the last call (flag is cleared).
// The caller should send PLI + control keyframe request (rate-limited).
bool needKeyframe();
// Number of decode errors (send/receive failures + frames flagged corrupt) since init.
uint64_t decodeErrors() const;
// Dimensions of the most recently decoded frame (0 before the first frame).
int width() const;
int height() const;
// Drop decoder state and any pending frame (e.g. after a stream reconnect). Keeps the
// decoder open; the next frame must be a keyframe.
void flush();
private:
struct Impl;
std::unique_ptr<Impl> d_;
};
} // namespace xc
+858
View File
@@ -0,0 +1,858 @@
// Nubix — libdatachannel wrapper.
//
// PeerConnection layout follows GreenOvercast (src/session/webrtc_session.zig, MPL-2.0): recvonly
// H.264 track with mid "video", then Opus with mid "audio", then the data channels (whose m-line
// therefore gets mid "0"), all created before the offer; ICE gathering is awaited, the ICE
// credentials and DTLS fingerprint are taken from libdatachannel's own local description, and
// the server answer is applied verbatim with remote candidates on mid "0".
//
// RTCP feedback (PLI with sender SSRC 0, generic NACK, compound RR+SDES with LSR/DLSR, REMB) is
// built by hand following green-nx deps/patches/libpeer-switch.patch (GPL-3.0) and sent through
// the video track: a track without media handler passes RTCP straight to the SRTP transport
// regardless of its direction (libdatachannel impl/track.cpp Track::outgoing).
//
// Incoming media: a PeerConnection-wide media handler sees every decrypted packet first. It
// parses sender reports, latches the media SSRCs by payload type, and delivers RTP whose SSRC the
// answer did not announce (libdatachannel would drop it — home consoles omit a=ssrc for audio,
// see green-nx). Everything else is delivered through the tracks' onMessage callbacks.
// Portions derived from green-nx (https://github.com/rmrf404/green-nx), Copyright (C) the green-nx authors, GPL-3.0; modified by Nubix contributors, 2026.
// Portions derived from GreenOvercast (https://github.com/Producdevity/GreenOvercast), MPL-2.0, used here under GPL-3.0 (MPL-2.0 section 3.3).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include "stream/webrtc.hpp"
#include <rtc/rtc.hpp>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstring>
#include <map>
#include <mutex>
#include <random>
#include <set>
#include <sstream>
#include "core/log.hpp"
namespace xc {
namespace {
constexpr uint8_t kDefaultVideoPt = 102;
constexpr uint8_t kDefaultAudioPt = 111;
const char* const kStunServer = "stun:stun.l.google.com:19302";
// Channel label / sub-protocol pairs (as in green-nx; reliable + ordered).
struct ChannelSpec {
const char* label;
const char* protocol;
};
constexpr ChannelSpec kChannels[] = {
{"chat", "chatV1"},
{"control", "controlV1"},
{"input", "1.0"},
{"message", "messageV1"},
};
uint16_t be16(const uint8_t* p) { return static_cast<uint16_t>((p[0] << 8) | p[1]); }
uint32_t be32(const uint8_t* p) {
return (static_cast<uint32_t>(p[0]) << 24) | (static_cast<uint32_t>(p[1]) << 16) |
(static_cast<uint32_t>(p[2]) << 8) | p[3];
}
void put16(std::vector<uint8_t>& v, uint16_t x) {
v.push_back(static_cast<uint8_t>(x >> 8));
v.push_back(static_cast<uint8_t>(x));
}
void put32(std::vector<uint8_t>& v, uint32_t x) {
v.push_back(static_cast<uint8_t>(x >> 24));
v.push_back(static_cast<uint8_t>(x >> 16));
v.push_back(static_cast<uint8_t>(x >> 8));
v.push_back(static_cast<uint8_t>(x));
}
void set16(std::vector<uint8_t>& v, size_t at, uint16_t x) {
v[at] = static_cast<uint8_t>(x >> 8);
v[at + 1] = static_cast<uint8_t>(x);
}
bool isRtcpPacket(const uint8_t* p, size_t n) {
if (n < 8) return false;
const uint8_t pt = p[1] & 0x7F;
return pt >= 64 && pt <= 95; // RFC 5761 §4
}
const char* stateName(rtc::PeerConnection::State s) {
switch (s) {
case rtc::PeerConnection::State::New: return "new";
case rtc::PeerConnection::State::Connecting: return "connecting";
case rtc::PeerConnection::State::Connected: return "connected";
case rtc::PeerConnection::State::Disconnected: return "disconnected";
case rtc::PeerConnection::State::Failed: return "failed";
case rtc::PeerConnection::State::Closed: return "closed";
}
return "unknown";
}
std::string trim(const std::string& s) {
size_t b = 0, e = s.size();
while (b < e && (s[b] == ' ' || s[b] == '\t' || s[b] == '\r' || s[b] == '\n')) ++b;
while (e > b && (s[e - 1] == ' ' || s[e - 1] == '\t' || s[e - 1] == '\r' || s[e - 1] == '\n')) --e;
return s.substr(b, e - b);
}
void initRtcLoggerOnce() {
static std::once_flag once;
std::call_once(once, [] {
const rtc::LogLevel lvl = logLevel() == LogLevel::Debug ? rtc::LogLevel::Info : rtc::LogLevel::Warning;
rtc::InitLogger(lvl, [](rtc::LogLevel level, std::string message) {
LogLevel l = LogLevel::Debug;
switch (level) {
case rtc::LogLevel::Fatal:
case rtc::LogLevel::Error: l = LogLevel::Error; break;
case rtc::LogLevel::Warning: l = LogLevel::Warn; break;
case rtc::LogLevel::Info: l = LogLevel::Info; break;
default: l = LogLevel::Debug; break;
}
log(l, "rtc: %s", message.c_str());
});
});
}
uint32_t randomSsrc() {
std::random_device rd;
uint32_t v = 0;
while (v == 0) v = (static_cast<uint32_t>(rd()) << 1) ^ static_cast<uint32_t>(rd());
return v;
}
// What the server answer says about the media sections.
struct AnswerInfo {
std::set<uint8_t> videoPts, audioPts;
std::set<uint32_t> videoSsrcs, audioSsrcs;
};
AnswerInfo parseAnswer(const std::string& sdp) {
AnswerInfo info;
std::istringstream in(sdp);
std::string line;
enum class Sec { None, Video, Audio, Other } sec = Sec::None;
std::string kind;
while (std::getline(in, line)) {
line = trim(line);
if (line.rfind("m=", 0) == 0) {
kind = line.substr(2, line.find(' ') == std::string::npos ? std::string::npos : line.find(' ') - 2);
sec = kind == "video" ? Sec::Video : kind == "audio" ? Sec::Audio : Sec::Other;
continue;
}
if (sec != Sec::Video && sec != Sec::Audio) continue;
if (line.rfind("a=mid:", 0) == 0) {
const std::string mid = line.substr(6);
if (mid == "video") sec = Sec::Video;
else if (mid == "audio") sec = Sec::Audio;
} else if (line.rfind("a=rtpmap:", 0) == 0) {
const size_t sp = line.find(' ');
if (sp == std::string::npos) continue;
const int pt = std::atoi(line.substr(9, sp - 9).c_str());
std::string codec = line.substr(sp + 1);
std::transform(codec.begin(), codec.end(), codec.begin(), [](unsigned char c) { return std::tolower(c); });
if (pt < 0 || pt > 127) continue;
if (sec == Sec::Video && codec.rfind("h264/", 0) == 0) info.videoPts.insert(static_cast<uint8_t>(pt));
if (sec == Sec::Audio && codec.rfind("opus/", 0) == 0) info.audioPts.insert(static_cast<uint8_t>(pt));
} else if (line.rfind("a=ssrc:", 0) == 0) {
const uint32_t ssrc = static_cast<uint32_t>(std::strtoul(line.c_str() + 7, nullptr, 10));
if (ssrc == 0) continue;
(sec == Sec::Video ? info.videoSsrcs : info.audioSsrcs).insert(ssrc);
}
}
return info;
}
// State shared with every libdatachannel callback. Callbacks hold a shared_ptr to it, never to
// WebRtc, and are gated by `mu` + `closed` so that none fires after close() returned.
struct Shared {
std::recursive_mutex mu; // held while a user callback runs; recursive so callbacks may call back in
bool closed = false;
WebRtc::Callbacks cb;
std::atomic<uint32_t> videoSsrc{0}, audioSsrc{0};
std::mutex infoMu; // answer-derived demux info
std::set<uint8_t> videoPts{kDefaultVideoPt}, audioPts{kDefaultAudioPt};
std::set<uint32_t> routable; // SSRCs libdatachannel routes to a track (announced by a=ssrc)
std::set<uint32_t> answerVideoSsrcs, answerAudioSsrcs;
std::mutex gatherMu;
std::condition_variable gatherCv;
bool gatherDone = false;
std::vector<std::string> trickled;
enum class Kind { Unknown, Video, Audio };
Kind classifyPt(uint8_t pt) {
std::lock_guard<std::mutex> lk(infoMu);
if (videoPts.count(pt)) return Kind::Video;
if (audioPts.count(pt)) return Kind::Audio;
return Kind::Unknown;
}
bool isRoutable(uint32_t ssrc) {
std::lock_guard<std::mutex> lk(infoMu);
return routable.count(ssrc) != 0;
}
Kind classifySsrc(uint32_t ssrc) {
if (ssrc != 0 && ssrc == videoSsrc.load()) return Kind::Video;
if (ssrc != 0 && ssrc == audioSsrc.load()) return Kind::Audio;
std::lock_guard<std::mutex> lk(infoMu);
if (answerVideoSsrcs.count(ssrc)) return Kind::Video;
if (answerAudioSsrcs.count(ssrc)) return Kind::Audio;
return Kind::Unknown;
}
void latch(Kind k, uint32_t ssrc) {
std::atomic<uint32_t>& a = k == Kind::Video ? videoSsrc : audioSsrc;
const uint32_t prev = a.exchange(ssrc);
if (prev != ssrc)
XC_LOGI("webrtc: %s SSRC %08x%s", k == Kind::Video ? "video" : "audio", ssrc, prev ? " (changed)" : "");
}
void media(Kind k, const uint8_t* d, size_t n) {
std::lock_guard<std::recursive_mutex> lk(mu);
if (closed) return;
if (k == Kind::Video && cb.onVideoRtp) cb.onVideoRtp(d, n);
else if (k == Kind::Audio && cb.onAudioRtp) cb.onAudioRtp(d, n);
}
void senderReport(bool video, uint32_t ssrc, uint64_t ntp) {
std::lock_guard<std::recursive_mutex> lk(mu);
if (!closed && cb.onSenderReport) cb.onSenderReport(video, ssrc, ntp);
}
void text(const std::string& ch, const std::string& t) {
std::lock_guard<std::recursive_mutex> lk(mu);
if (!closed && cb.onText) cb.onText(ch, t);
}
void binary(const std::string& ch, const uint8_t* d, size_t n) {
std::lock_guard<std::recursive_mutex> lk(mu);
if (!closed && cb.onBinary) cb.onBinary(ch, d, n);
}
void open(const std::string& ch) {
std::lock_guard<std::recursive_mutex> lk(mu);
if (!closed && cb.onChannelOpen) cb.onChannelOpen(ch);
}
void state(const std::string& s) {
std::lock_guard<std::recursive_mutex> lk(mu);
if (!closed && cb.onState) cb.onState(s);
}
// Walk a (compound) RTCP packet and report every sender report.
void parseRtcp(const uint8_t* p, size_t n) {
size_t off = 0;
while (off + 8 <= n) {
const uint8_t* h = p + off;
if ((h[0] >> 6) != 2) return;
const size_t len = (static_cast<size_t>(be16(h + 2)) + 1) * 4;
if (off + len > n) return;
if (h[1] == 200 && len >= 28) { // SR: sender SSRC, NTP (64), RTP ts, counts
const uint32_t ssrc = be32(h + 4);
const uint64_t ntp = (static_cast<uint64_t>(be32(h + 8)) << 32) | be32(h + 12);
const Kind k = classifySsrc(ssrc);
if (k != Kind::Unknown) senderReport(k == Kind::Video, ssrc, ntp);
} else if (h[1] == 203) {
XC_LOGI("webrtc: RTCP BYE received");
}
off += len;
}
}
};
// PeerConnection-wide handler: runs on every decrypted SRTP/SRTCP packet before track dispatch.
class GlobalReceiver final : public rtc::MediaHandler {
public:
explicit GlobalReceiver(std::shared_ptr<Shared> s) : s_(std::move(s)) {}
void incoming(rtc::message_vector& messages, const rtc::message_callback&) override {
rtc::message_vector keep;
keep.reserve(messages.size());
for (auto& m : messages) {
if (!m) continue;
const auto* d = reinterpret_cast<const uint8_t*>(m->data());
const size_t n = m->size();
if (m->type == rtc::Message::Control) {
// RTCP is consumed here; the tracks only carry RTP to our callbacks.
s_->parseRtcp(d, n);
continue;
}
if (m->type != rtc::Message::Binary || n < 12 || (d[0] >> 6) != 2) {
keep.push_back(std::move(m));
continue;
}
const uint8_t pt = d[1] & 0x7F;
const uint32_t ssrc = be32(d + 8);
const Shared::Kind k = s_->classifyPt(pt);
if (k == Shared::Kind::Unknown) {
keep.push_back(std::move(m));
continue;
}
const uint32_t latched = k == Shared::Kind::Video ? s_->videoSsrc.load() : s_->audioSsrc.load();
if (latched != ssrc) s_->latch(k, ssrc);
if (s_->isRoutable(ssrc)) {
keep.push_back(std::move(m)); // libdatachannel routes it to the track -> onMessage
} else {
s_->media(k, d, n); // unannounced SSRC: libdatachannel would drop it
}
}
messages.swap(keep);
}
private:
std::shared_ptr<Shared> s_;
};
} // namespace
struct WebRtc::Impl {
mutable std::mutex mu; // guards the pointers below
std::shared_ptr<Shared> sh;
std::shared_ptr<rtc::PeerConnection> pc;
std::shared_ptr<rtc::Track> video, audio;
std::map<std::string, std::shared_ptr<rtc::DataChannel>> channels;
std::shared_ptr<GlobalReceiver> rx;
uint32_t localSsrc = 0;
std::string cname;
bool offered = false;
std::shared_ptr<rtc::DataChannel> channel(const std::string& label) const {
std::lock_guard<std::mutex> lk(mu);
auto it = channels.find(label);
return it == channels.end() ? nullptr : it->second;
}
bool sendRtcp(const std::vector<uint8_t>& pkt) {
std::shared_ptr<rtc::Track> t;
{
std::lock_guard<std::mutex> lk(mu);
t = video ? video : audio;
}
if (!t || pkt.size() < 8) return false;
try {
if (!t->isOpen()) return false;
return t->send(reinterpret_cast<const std::byte*>(pkt.data()), pkt.size());
} catch (const std::exception& e) {
XC_LOGD("webrtc: RTCP send failed: %s", e.what());
return false;
}
}
};
WebRtc::WebRtc() : d_(new Impl) {}
WebRtc::~WebRtc() { close(); }
bool WebRtc::init(const Callbacks& cb) {
close();
initRtcLoggerOnce();
auto sh = std::make_shared<Shared>();
sh->cb = cb;
const uint32_t localSsrc = randomSsrc();
char cname[32];
std::snprintf(cname, sizeof(cname), "xc%08x", localSsrc);
std::shared_ptr<rtc::PeerConnection> pc;
std::shared_ptr<rtc::Track> video, audio;
std::map<std::string, std::shared_ptr<rtc::DataChannel>> channels;
auto rx = std::make_shared<GlobalReceiver>(sh);
try {
rtc::Configuration cfg;
cfg.iceServers.emplace_back(kStunServer);
cfg.disableAutoNegotiation = true; // one explicit setLocalDescription(Offer) in gatherLocal()
pc = std::make_shared<rtc::PeerConnection>(cfg);
pc->setMediaHandler(rx);
std::weak_ptr<Shared> wsh = sh;
pc->onStateChange([wsh](rtc::PeerConnection::State st) {
XC_LOGI("webrtc: peer connection %s", stateName(st));
if (auto s = wsh.lock()) s->state(stateName(st));
});
pc->onIceStateChange([](rtc::PeerConnection::IceState st) {
XC_LOGD("webrtc: ICE state %d", static_cast<int>(st));
});
pc->onGatheringStateChange([wsh](rtc::PeerConnection::GatheringState st) {
XC_LOGD("webrtc: gathering state %d", static_cast<int>(st));
if (st != rtc::PeerConnection::GatheringState::Complete) return;
if (auto s = wsh.lock()) {
std::lock_guard<std::mutex> lk(s->gatherMu);
s->gatherDone = true;
s->gatherCv.notify_all();
}
});
pc->onLocalCandidate([wsh](rtc::Candidate c) {
if (auto s = wsh.lock()) {
std::lock_guard<std::mutex> lk(s->gatherMu);
s->trickled.push_back(c.candidate());
}
});
pc->onDataChannel([](std::shared_ptr<rtc::DataChannel> dc) {
XC_LOGW("webrtc: unexpected remote data channel '%s'", dc->label().c_str());
});
// 1) video (mid "video"), 2) audio (mid "audio") — order matters, see header.
rtc::Description::Video vdesc("video", rtc::Description::Direction::RecvOnly);
vdesc.addH264Codec(kDefaultVideoPt,
std::string("level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f"));
video = pc->addTrack(vdesc);
rtc::Description::Audio adesc("audio", rtc::Description::Direction::RecvOnly);
adesc.addOpusCodec(kDefaultAudioPt, std::string("minptime=10;useinbandfec=1"));
audio = pc->addTrack(adesc);
auto onTrackRtp = [wsh](Shared::Kind k) {
return [wsh, k](rtc::binary b) {
const auto* d = reinterpret_cast<const uint8_t*>(b.data());
if (b.size() < 12 || isRtcpPacket(d, b.size())) return;
if (auto s = wsh.lock()) s->media(k, d, b.size());
};
};
video->onMessage(onTrackRtp(Shared::Kind::Video), [](rtc::string) {});
audio->onMessage(onTrackRtp(Shared::Kind::Audio), [](rtc::string) {});
// 3) data channels, in-band (DCEP), reliable + ordered, before the offer -> m-line mid "0".
for (const ChannelSpec& spec : kChannels) {
rtc::DataChannelInit init;
init.protocol = spec.protocol;
auto dc = pc->createDataChannel(spec.label, init);
const std::string label = spec.label;
dc->onOpen([wsh, label]() {
XC_LOGI("webrtc: channel '%s' open", label.c_str());
if (auto s = wsh.lock()) s->open(label);
});
dc->onClosed([label]() { XC_LOGI("webrtc: channel '%s' closed", label.c_str()); });
dc->onError([label](std::string err) { XC_LOGW("webrtc: channel '%s' error: %s", label.c_str(), err.c_str()); });
dc->onMessage(
[wsh, label](rtc::binary b) {
if (auto s = wsh.lock()) s->binary(label, reinterpret_cast<const uint8_t*>(b.data()), b.size());
},
[wsh, label](rtc::string t) {
if (auto s = wsh.lock()) s->text(label, t);
});
channels[label] = dc;
}
} catch (const std::exception& e) {
XC_LOGE("webrtc: init failed: %s", e.what());
if (pc) {
try {
pc->close();
} catch (...) {
}
}
return false;
}
std::lock_guard<std::mutex> lk(d_->mu);
d_->sh = sh;
d_->pc = pc;
d_->video = video;
d_->audio = audio;
d_->channels = std::move(channels);
d_->rx = rx;
d_->localSsrc = localSsrc;
d_->cname = cname;
d_->offered = false;
return true;
}
bool WebRtc::gatherLocal(std::string& ufrag, std::string& pwd, std::string& fingerprint,
std::vector<std::string>& candidates, int timeoutMs, const std::atomic<bool>* abort) {
ufrag.clear();
pwd.clear();
fingerprint.clear();
candidates.clear();
std::shared_ptr<rtc::PeerConnection> pc;
std::shared_ptr<Shared> sh;
bool alreadyOffered;
{
std::lock_guard<std::mutex> lk(d_->mu);
pc = d_->pc;
sh = d_->sh;
alreadyOffered = d_->offered;
d_->offered = true;
}
if (!pc || !sh) {
XC_LOGE("webrtc: gatherLocal before init");
return false;
}
try {
if (!alreadyOffered) pc->setLocalDescription(rtc::Description::Type::Offer);
} catch (const std::exception& e) {
XC_LOGE("webrtc: setLocalDescription(offer) failed: %s", e.what());
return false;
}
bool complete = false;
{
// Wait in short slices so a cancelled session does not sit out the whole timeout.
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(std::max(timeoutMs, 0));
std::unique_lock<std::mutex> lk(sh->gatherMu);
for (;;) {
if (abort && abort->load()) {
XC_LOGD("webrtc: ICE gathering aborted");
return false;
}
const auto now = std::chrono::steady_clock::now();
if (sh->gatherDone || now >= deadline) {
complete = sh->gatherDone;
break;
}
sh->gatherCv.wait_for(lk, std::min<std::chrono::steady_clock::duration>(deadline - now,
std::chrono::milliseconds(100)));
}
}
if (!complete) XC_LOGW("webrtc: ICE gathering not complete after %d ms, using what we have", timeoutMs);
std::optional<rtc::Description> desc;
try {
desc = pc->localDescription();
} catch (const std::exception& e) {
XC_LOGE("webrtc: localDescription failed: %s", e.what());
return false;
}
if (!desc) {
XC_LOGE("webrtc: no local description");
return false;
}
ufrag = desc->iceUfrag().value_or("");
pwd = desc->icePwd().value_or("");
if (auto fp = desc->fingerprint()) {
if (fp->algorithm == rtc::CertificateFingerprint::Algorithm::Sha256) fingerprint = fp->value;
else XC_LOGE("webrtc: local fingerprint is not sha-256");
}
std::set<std::string> seen;
auto add = [&](const std::string& c) {
std::string s = trim(c);
if (s.rfind("a=", 0) == 0) s = s.substr(2);
if (s.rfind("candidate:", 0) != 0) return;
if (seen.insert(s).second) candidates.push_back(s);
};
for (const rtc::Candidate& c : desc->candidates()) add(c.candidate());
{
std::lock_guard<std::mutex> lk(sh->gatherMu);
for (const auto& c : sh->trickled) add(c);
}
XC_LOGI("webrtc: local ICE ufrag=%s, %zu candidate(s)%s", ufrag.c_str(), candidates.size(),
complete ? "" : " (gathering incomplete)");
for (const auto& c : candidates) XC_LOGD("webrtc: local %s", c.c_str());
if (ufrag.empty() || pwd.empty() || fingerprint.empty()) {
XC_LOGE("webrtc: local description lacks ICE credentials or sha-256 fingerprint");
return false;
}
if (candidates.empty()) {
XC_LOGE("webrtc: no local ICE candidates gathered");
return false;
}
return true;
}
std::string WebRtc::localDescriptionSdp() const {
std::shared_ptr<rtc::PeerConnection> pc;
{
std::lock_guard<std::mutex> lk(d_->mu);
pc = d_->pc;
}
if (!pc) return std::string();
try {
if (auto desc = pc->localDescription()) return std::string(*desc);
} catch (const std::exception& e) {
XC_LOGW("webrtc: localDescription failed: %s", e.what());
}
return std::string();
}
bool WebRtc::setRemote(const std::string& answerSdp, const std::vector<std::string>& remoteCandidates) {
std::shared_ptr<rtc::PeerConnection> pc;
std::shared_ptr<Shared> sh;
{
std::lock_guard<std::mutex> lk(d_->mu);
pc = d_->pc;
sh = d_->sh;
}
if (!pc || !sh) {
XC_LOGE("webrtc: setRemote before init");
return false;
}
const AnswerInfo info = parseAnswer(answerSdp);
{
std::lock_guard<std::mutex> lk(sh->infoMu);
if (!info.videoPts.empty()) sh->videoPts = info.videoPts;
if (!info.audioPts.empty()) sh->audioPts = info.audioPts;
sh->answerVideoSsrcs = info.videoSsrcs;
sh->answerAudioSsrcs = info.audioSsrcs;
sh->routable.clear();
sh->routable.insert(info.videoSsrcs.begin(), info.videoSsrcs.end());
sh->routable.insert(info.audioSsrcs.begin(), info.audioSsrcs.end());
}
if (!info.videoSsrcs.empty() && sh->videoSsrc.load() == 0) sh->videoSsrc = *info.videoSsrcs.begin();
if (!info.audioSsrcs.empty() && sh->audioSsrc.load() == 0) sh->audioSsrc = *info.audioSsrcs.begin();
XC_LOGI("webrtc: answer: video pt %d ssrcs %zu, audio pt %d ssrcs %zu",
info.videoPts.empty() ? -1 : *info.videoPts.begin(), info.videoSsrcs.size(),
info.audioPts.empty() ? -1 : *info.audioPts.begin(), info.audioSsrcs.size());
try {
pc->setRemoteDescription(rtc::Description(answerSdp, rtc::Description::Type::Answer));
} catch (const std::exception& e) {
XC_LOGE("webrtc: setRemoteDescription failed: %s", e.what());
return false;
}
size_t added = 0, tried = 0;
for (const std::string& raw : remoteCandidates) {
std::string c = trim(raw);
if (c.rfind("a=", 0) == 0) c = c.substr(2);
if (c.empty() || c.rfind("candidate:", 0) != 0) continue; // also skips end-of-candidates
++tried;
try {
pc->addRemoteCandidate(rtc::Candidate(c, "0"));
++added;
XC_LOGD("webrtc: remote %s", c.c_str());
} catch (const std::exception& e) {
XC_LOGW("webrtc: remote candidate rejected (%s): %s", e.what(), c.c_str());
}
}
XC_LOGI("webrtc: added %zu/%zu remote candidate(s)", added, tried);
return tried == 0 || added > 0;
}
bool WebRtc::sendText(const std::string& ch, const std::string& text) {
auto dc = d_->channel(ch);
if (!dc) return false;
try {
if (!dc->isOpen()) return false;
dc->send(std::string(text)); // string message -> WebRTC DOMString PPID
return true;
} catch (const std::exception& e) {
XC_LOGW("webrtc: send on '%s' failed: %s", ch.c_str(), e.what());
return false;
}
}
bool WebRtc::sendBinary(const std::string& ch, const std::vector<uint8_t>& data) {
auto dc = d_->channel(ch);
if (!dc) return false;
try {
if (!dc->isOpen()) return false;
dc->send(reinterpret_cast<const std::byte*>(data.data()), data.size());
return true;
} catch (const std::exception& e) {
XC_LOGW("webrtc: send on '%s' failed: %s", ch.c_str(), e.what());
return false;
}
}
bool WebRtc::isChannelOpen(const std::string& ch) const {
auto dc = d_->channel(ch);
if (!dc) return false;
try {
return dc->isOpen();
} catch (...) {
return false;
}
}
void WebRtc::requestKeyframe() {
const uint32_t media = videoSsrc();
if (!media) return;
// RFC 4585 §6.3.1 PLI: PSFB (206), FMT 1, length 2. Sender SSRC 0 like browsers — Xbox
// ignores PLIs whose sender SSRC equals the media SSRC (GreenOvercast).
std::vector<uint8_t> p;
p.reserve(12);
p.push_back(0x80 | 1);
p.push_back(206);
put16(p, 2);
put32(p, 0);
put32(p, media);
d_->sendRtcp(p);
}
void WebRtc::sendNack(uint32_t ssrc, const std::vector<uint16_t>& seqs) {
if (!ssrc) ssrc = videoSsrc();
if (!ssrc || seqs.empty()) return;
uint32_t sender;
{
std::lock_guard<std::mutex> lk(d_->mu);
sender = d_->localSsrc;
}
// Order wrap-aware relative to the first entry, de-duplicate.
std::vector<uint16_t> s(seqs);
const uint16_t ref = static_cast<uint16_t>(s.front() - 0x4000);
std::sort(s.begin(), s.end(), [ref](uint16_t a, uint16_t b) {
return static_cast<uint16_t>(a - ref) < static_cast<uint16_t>(b - ref);
});
s.erase(std::unique(s.begin(), s.end()), s.end());
// RFC 4585 §6.2.1 generic NACK: RTPFB (205), FMT 1, FCI = PID + BLP (16 following packets).
constexpr size_t kMaxFciPerPacket = 64;
std::vector<std::pair<uint16_t, uint16_t>> fci;
for (size_t i = 0; i < s.size();) {
const uint16_t pid = s[i];
uint16_t blp = 0;
size_t j = i + 1;
while (j < s.size()) {
const uint16_t d = static_cast<uint16_t>(s[j] - pid);
if (d == 0 || d > 16) break;
blp |= static_cast<uint16_t>(1u << (d - 1));
++j;
}
fci.emplace_back(pid, blp);
i = j;
}
for (size_t off = 0; off < fci.size(); off += kMaxFciPerPacket) {
const size_t cnt = std::min(kMaxFciPerPacket, fci.size() - off);
std::vector<uint8_t> p;
p.reserve(12 + 4 * cnt);
p.push_back(0x80 | 1);
p.push_back(205);
put16(p, static_cast<uint16_t>(2 + cnt));
put32(p, sender);
put32(p, ssrc);
for (size_t k = 0; k < cnt; ++k) {
put16(p, fci[off + k].first);
put16(p, fci[off + k].second);
}
d_->sendRtcp(p);
}
}
void WebRtc::sendReceiverReport(const std::vector<RtpReceiveStats>& blocks) {
uint32_t sender;
std::string cname;
{
std::lock_guard<std::mutex> lk(d_->mu);
sender = d_->localSsrc;
cname = d_->cname;
}
if (!sender) return;
std::vector<const RtpReceiveStats*> valid;
for (const auto& b : blocks)
if (b.ssrc && valid.size() < 31) valid.push_back(&b);
// RFC 3550 §6.4.2 RR + §6.5 SDES (CNAME) in one compound packet (green-nx: senders ignore
// bare RRs). DLSR/LSR come from RtpStatsTracker.
std::vector<uint8_t> p;
p.reserve(8 + 24 * valid.size() + 32);
p.push_back(static_cast<uint8_t>(0x80 | valid.size()));
p.push_back(201);
put16(p, static_cast<uint16_t>(1 + 6 * valid.size()));
put32(p, sender);
for (const RtpReceiveStats* b : valid) {
put32(p, b->ssrc);
int32_t lost = std::max<int32_t>(-0x800000, std::min<int32_t>(0x7FFFFF, b->cumulativeLost));
put32(p, (static_cast<uint32_t>(b->fractionLost) << 24) | (static_cast<uint32_t>(lost) & 0x00FFFFFFu));
put32(p, b->extHighestSeq);
put32(p, b->jitter);
put32(p, b->lastSr);
put32(p, b->lastSr ? b->delaySinceLastSr : 0);
}
const size_t sdes = p.size();
p.push_back(0x80 | 1);
p.push_back(202);
put16(p, 0); // patched below
put32(p, sender);
p.push_back(1); // CNAME
p.push_back(static_cast<uint8_t>(cname.size()));
p.insert(p.end(), cname.begin(), cname.end());
p.push_back(0); // end of items
while ((p.size() - sdes) % 4) p.push_back(0);
set16(p, sdes + 2, static_cast<uint16_t>((p.size() - sdes) / 4 - 1));
d_->sendRtcp(p);
}
void WebRtc::sendRemb(uint32_t bps) {
const uint32_t v = videoSsrc(), a = audioSsrc();
if (!v) return;
uint32_t sender;
{
std::lock_guard<std::mutex> lk(d_->mu);
sender = d_->localSsrc;
}
// draft-alvestrand-rmcat-remb: PSFB (206), FMT 15, media SSRC 0, "REMB", num SSRC,
// 6-bit exponent + 18-bit mantissa, SSRC list.
uint32_t mantissa = bps, exp = 0;
while (mantissa > 0x3FFFF) {
mantissa >>= 1;
++exp;
}
const uint8_t num = a ? 2 : 1;
std::vector<uint8_t> p;
p.reserve(28);
p.push_back(0x80 | 15);
p.push_back(206);
put16(p, static_cast<uint16_t>(4 + num));
put32(p, sender);
put32(p, 0);
p.push_back('R');
p.push_back('E');
p.push_back('M');
p.push_back('B');
p.push_back(num);
p.push_back(static_cast<uint8_t>((exp << 2) | ((mantissa >> 16) & 0x03)));
p.push_back(static_cast<uint8_t>(mantissa >> 8));
p.push_back(static_cast<uint8_t>(mantissa));
put32(p, v);
if (a) put32(p, a);
d_->sendRtcp(p);
}
uint32_t WebRtc::videoSsrc() const {
std::lock_guard<std::mutex> lk(d_->mu);
return d_->sh ? d_->sh->videoSsrc.load() : 0;
}
uint32_t WebRtc::audioSsrc() const {
std::lock_guard<std::mutex> lk(d_->mu);
return d_->sh ? d_->sh->audioSsrc.load() : 0;
}
void WebRtc::close() {
if (!d_) return;
std::shared_ptr<Shared> sh;
std::shared_ptr<rtc::PeerConnection> pc;
std::shared_ptr<rtc::Track> video, audio;
std::map<std::string, std::shared_ptr<rtc::DataChannel>> channels;
{
std::lock_guard<std::mutex> lk(d_->mu);
sh = std::move(d_->sh);
pc = std::move(d_->pc);
video = std::move(d_->video);
audio = std::move(d_->audio);
channels.swap(d_->channels);
d_->rx.reset();
d_->offered = false;
}
if (sh) {
// Wait for a running callback to finish, then block all further ones. Must not be called
// while holding a lock that a callback also takes.
{
std::lock_guard<std::recursive_mutex> lk(sh->mu);
sh->closed = true;
}
{
std::lock_guard<std::mutex> lk(sh->gatherMu);
sh->gatherDone = true;
}
sh->gatherCv.notify_all();
}
try {
for (auto& kv : channels) {
kv.second->resetCallbacks();
kv.second->close();
}
if (video) video->resetCallbacks();
if (audio) audio->resetCallbacks();
if (pc) {
pc->setMediaHandler(nullptr);
pc->resetCallbacks();
pc->close();
}
} catch (const std::exception& e) {
XC_LOGW("webrtc: close: %s", e.what());
}
}
} // namespace xc
+89
View File
@@ -0,0 +1,89 @@
// Nubix — libdatachannel wrapper: recvonly H.264 + Opus tracks (mids "video", "audio"),
// four reliable/ordered data channels (control/controlV1, input/1.0, message/messageV1,
// chat/chatV1, created before the offer so the data m-line gets mid "0"), raw RTP delivery
// and hand-built RTCP (PLI with sender SSRC 0, NACK, RR, REMB).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "stream/rtp.hpp"
namespace xc {
class WebRtc {
public:
struct Callbacks {
// Raw RTP packets (header included) of the video / audio track. Called on libdatachannel threads.
std::function<void(const uint8_t*, size_t)> onVideoRtp, onAudioRtp;
// Incoming RTCP sender report on a track: media SSRC + 64-bit NTP timestamp (for LSR).
std::function<void(bool video, uint32_t ssrc, uint64_t ntp)> onSenderReport;
// Text / binary message on data channel ch ("control", "input", "message", "chat").
std::function<void(const std::string& ch, const std::string& text)> onText;
std::function<void(const std::string& ch, const uint8_t*, size_t)> onBinary;
// Data channel ch opened.
std::function<void(const std::string& ch)> onChannelOpen;
// PeerConnection state changes: "new", "connecting", "connected", "disconnected", "failed", "closed".
std::function<void(const std::string& state)> onState;
};
WebRtc();
~WebRtc();
WebRtc(const WebRtc&) = delete;
WebRtc& operator=(const WebRtc&) = delete;
// Create the PeerConnection, tracks and data channels. Must be called first.
bool init(const Callbacks& cb);
// Create the local offer, wait for ICE gathering (timeoutMs) and return the ICE credentials,
// sha-256 fingerprint and gathered candidate lines ("candidate:..."). The real local
// description stays inside libdatachannel; the caller sends proto::buildOffer(...) instead.
// abort (optional): checked every 100 ms; once true the wait ends and false is returned.
bool gatherLocal(std::string& ufrag, std::string& pwd, std::string& fingerprint,
std::vector<std::string>& candidates, int timeoutMs,
const std::atomic<bool>* abort = nullptr);
// Full SDP of libdatachannel's own local offer (empty before gatherLocal). Diagnostics / tests.
std::string localDescriptionSdp() const;
// Apply the server answer SDP and the remote candidates (added with mid "0").
bool setRemote(const std::string& answerSdp, const std::vector<std::string>& remoteCandidates);
// Send on a data channel. False if the channel is not open.
bool sendText(const std::string& ch, const std::string& text);
bool sendBinary(const std::string& ch, const std::vector<uint8_t>& data);
// True if data channel ch is open.
bool isChannelOpen(const std::string& ch) const;
// Send RTCP PLI (sender SSRC 0, media SSRC = video SSRC).
void requestKeyframe();
// Send RTCP generic NACK (RFC 4585) for the given sequence numbers.
void sendNack(uint32_t ssrc, const std::vector<uint16_t>& seqs);
// Send an RTCP compound RR (+ SDES) with one report block per entry.
void sendReceiverReport(const std::vector<RtpReceiveStats>& blocks);
// Send RTCP REMB with the given max bitrate for the video SSRC.
void sendRemb(uint32_t bps);
// Media SSRCs latched from the first received RTP packet (0 if none yet).
uint32_t videoSsrc() const;
uint32_t audioSsrc() const;
// Close channels and the PeerConnection. Idempotent; no callbacks fire afterwards.
// Waits for a callback that is currently running, so do not call it while holding a lock that
// your callbacks also take, and preferably not from inside a callback (post to another thread).
void close();
private:
struct Impl;
std::unique_ptr<Impl> d_;
};
} // namespace xc
+489
View File
@@ -0,0 +1,489 @@
// Nubix — application object and main loop.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include "ui/app.hpp"
#include <SDL.h>
#include <algorithm>
#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <condition_variable>
#include <deque>
#include <exception>
#include <mutex>
#include <thread>
#include <vector>
#include "core/http.hpp"
#include "core/log.hpp"
#include "core/protocol.hpp"
#include "platform/platform.hpp"
#include "stream/audio.hpp"
#include "stream/video.hpp"
#include "stream/webrtc.hpp"
#include "ui/qr.hpp"
#include "ui/screens_internal.hpp"
namespace xc {
namespace {
constexpr uint64_t kToastMs = 3500;
constexpr uint64_t kToastFadeMs = 400;
constexpr size_t kMaxToasts = 3;
struct Toast {
std::string msg;
uint64_t until = 0;
};
struct StackOp {
enum Kind { Push, Pop, Replace, Reset } kind;
std::unique_ptr<Screen> screen;
};
// --selftest: bring up every subsystem the stream path needs, tear it down again and report.
// Returns the number of failed checks (each is logged). Audio is optional (headless hosts and
// SDL_AUDIODRIVER=dummy may lack a device) and only warns.
int runSubsystemSelftest() {
int failed = 0;
auto check = [&](const char* what, bool ok) {
xc::log(ok ? LogLevel::Info : LogLevel::Error, "selftest: %-28s %s", what, ok ? "ok" : "FAILED");
if (!ok) ++failed;
};
for (const char* a : {"cacert.pem", "fonts/DejaVuSans.ttf", "fonts/DejaVuSans-Bold.ttf"}) {
std::string path = platform::assetPath(a);
FILE* f = std::fopen(path.c_str(), "rb");
check(("asset " + std::string(a)).c_str(), f != nullptr);
if (f) std::fclose(f);
}
{
std::vector<bool> modules;
int size = 0;
check("qr encode", qr::encode("https://www.microsoft.com/link?otc=ABCD1234", modules, size) && size > 0 &&
modules.size() == static_cast<size_t>(size) * static_cast<size_t>(size));
}
{
proto::InputSerializer ser;
check("input serializer", ser.clientMetadata().size() == proto::kClientMetadataPacketSize &&
ser.gamepad(GamepadState{}).size() == proto::kGamepadPacketSize);
}
{
VideoDecoder dec;
check("h264 decoder init", dec.init(4));
dec.close();
}
{
AudioPlayer ap;
const bool ok = ap.init();
xc::log(ok ? LogLevel::Info : LogLevel::Warn, "selftest: %-28s %s", "opus + SDL audio init",
ok ? "ok" : "unavailable (non-fatal)");
ap.close();
}
{
WebRtc rtc;
WebRtc::Callbacks cb;
bool ok = rtc.init(cb);
std::string ufrag, pwd, fp;
std::vector<std::string> cands;
if (ok) ok = rtc.gatherLocal(ufrag, pwd, fp, cands, 3000) && !ufrag.empty() && !fp.empty();
if (ok) ok = !proto::buildOffer(ufrag, pwd, fp, false, "1080").empty();
check("webrtc init + gather + offer", ok);
rtc.close();
}
return failed;
}
} // namespace
struct App::Impl {
Impl() : cfg(platform::dataDir() + "/config.json"), auth(cfg), catalog(cfg), streamer(cfg, auth) {
Catalog::setCacheDir(platform::dataDir() + "/cache"); // title list + box art
}
Config cfg;
Auth auth;
Catalog catalog;
Streamer streamer;
Ui ui;
Gamepad gamepad;
std::atomic<bool> quit{false};
// Set when the app exits: HTTP requests of worker jobs (catalog, auth) abort within ~1 s.
std::atomic<bool> cancel{false};
bool statsOverlay = false;
std::vector<std::unique_ptr<Screen>> stack;
std::vector<StackOp> ops;
// background worker (FIFO)
std::thread worker;
std::mutex workMx;
std::condition_variable workCv;
std::deque<std::function<void()>> work;
bool workerStop = false;
// main-thread queue
std::mutex mainMx;
std::vector<std::function<void()>> mainQueue;
std::mutex toastMx;
std::deque<Toast> toasts;
bool screenshotRequested = false;
int screenshotCount = 0;
// Development automation (host): XC_SHOT_FRAMES="60,120" saves screenshots at those frame
// numbers, XC_EXIT_FRAME=N quits after frame N.
std::vector<uint64_t> shotFrames;
uint64_t exitFrame = 0;
void takeScreenshot() {
char name[64];
std::snprintf(name, sizeof(name), "/screenshot-%03d.bmp", screenshotCount++);
if (ui.saveScreenshot(platform::dataDir() + name)) {
std::lock_guard<std::mutex> lk(toastMx);
toasts.push_back({std::string("Screenshot saved: ") + (name + 1), platform::monotonicMs() + kToastMs});
}
}
void workerLoop() {
// Every job's requests (and the catalog helper threads, which inherit the flag) end
// quickly once the app is closing instead of running into 20-30 s timeouts.
Http::AbortScope abortOnExit(&cancel);
for (;;) {
std::function<void()> job;
{
std::unique_lock<std::mutex> lk(workMx);
workCv.wait(lk, [&] { return workerStop || !work.empty(); });
if (workerStop) return;
job = std::move(work.front());
work.pop_front();
}
if (!job) continue;
try {
job();
} catch (const std::exception& e) {
// A failed job must not take the app down (std::terminate); its UI simply does
// not get its result.
XC_LOGE("app: background job failed: %s", e.what());
std::lock_guard<std::mutex> lk(toastMx);
toasts.push_back({"Something went wrong, please try again", platform::monotonicMs() + kToastMs});
} catch (...) {
XC_LOGE("app: background job failed with an unknown exception");
}
}
}
void stopWorker() {
if (!worker.joinable()) return;
{
std::lock_guard<std::mutex> lk(workMx);
workerStop = true;
work.clear();
}
cancel = true;
workCv.notify_all();
worker.join(); // waits for the job in progress (bounded by HTTP timeouts)
}
void drainMainQueue() {
std::vector<std::function<void()>> q;
{
std::lock_guard<std::mutex> lk(mainMx);
q.swap(mainQueue);
}
for (auto& fn : q)
if (fn) fn();
}
void applyOps(App& app) {
// Ops queued from onEnter/onLeave are processed in the same pass.
for (int guard = 0; guard < 32 && !ops.empty(); ++guard) {
std::vector<StackOp> batch;
batch.swap(ops);
for (auto& op : batch) {
switch (op.kind) {
case StackOp::Push:
if (!stack.empty()) stack.back()->onLeave(app);
stack.push_back(std::move(op.screen));
stack.back()->onEnter(app);
break;
case StackOp::Pop:
if (stack.empty()) break;
stack.back()->onLeave(app);
stack.pop_back();
if (!stack.empty()) stack.back()->onEnter(app);
break;
case StackOp::Replace:
if (!stack.empty()) {
stack.back()->onLeave(app);
stack.pop_back();
}
stack.push_back(std::move(op.screen));
stack.back()->onEnter(app);
break;
case StackOp::Reset:
if (!stack.empty()) stack.back()->onLeave(app);
stack.clear();
stack.push_back(std::move(op.screen));
stack.back()->onEnter(app);
break;
}
}
}
}
void renderToasts(Ui& ui, uint64_t now) {
std::lock_guard<std::mutex> lk(toastMx);
while (!toasts.empty() && toasts.front().until <= now) toasts.pop_front();
int y = Ui::kHeight - 150;
for (auto it = toasts.rbegin(); it != toasts.rend(); ++it) {
const uint64_t left = it->until - now;
const uint8_t a = left < kToastFadeMs ? static_cast<uint8_t>(255 * left / kToastFadeMs) : 255;
const int size = 26;
const int w = std::min(1500, ui.textWidth(it->msg, size) + 64);
const int h = 64;
const int x = (Ui::kWidth - w) / 2;
ui.rect(x, y - h, w, h, withAlpha(colors::PanelHi, static_cast<uint8_t>(a * 240 / 255)), true, h / 2);
ui.rect(x, y - h, 8, h, withAlpha(colors::AccentBright, a), true, 4);
ui.textEllipsized(it->msg, Ui::kWidth / 2, y - h + (h - ui.lineHeight(size)) / 2, w - 64, size,
withAlpha(colors::Text, a), Align::Center);
y -= h + 12;
}
}
};
App::App() : d_(new Impl) {}
App::~App() {
d_->stopWorker();
}
int App::run(int argc, char** argv) {
Impl& d = *d_;
// --selftest: init all subsystems, render the Login screen for kSelftestFrames frames, exit
// 0 when everything came up (used headless with SDL_VIDEODRIVER=dummy).
constexpr uint64_t kSelftestFrames = 30;
bool selftest = false;
for (int i = 1; i < argc; ++i)
if (argv[i] && std::string(argv[i]) == "--selftest") selftest = true;
SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1");
#ifdef SDL_HINT_JOYSTICK_HIDAPI_PS5_RUMBLE
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS5_RUMBLE, "1");
#endif
#ifdef SDL_HINT_JOYSTICK_HIDAPI_PS4_RUMBLE
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS4_RUMBLE, "1");
#endif
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER | SDL_INIT_EVENTS | SDL_INIT_TIMER) != 0) {
XC_LOGE("SDL_Init failed: %s", SDL_GetError());
platform::notify("Nubix: SDL init failed");
return 1;
}
if (SDL_InitSubSystem(SDL_INIT_AUDIO) != 0) XC_LOGW("SDL audio init failed: %s (no sound)", SDL_GetError());
// Stream video is BT.709 at HD sizes, BT.601 at SD: let SDL pick per resolution.
SDL_SetYUVConversionMode(SDL_YUV_CONVERSION_AUTOMATIC);
Http::globalInit(platform::assetPath("cacert.pem"));
if (!d.cfg.load()) XC_LOGW("config: %s is malformed, using defaults", d.cfg.path().c_str());
if (!d.ui.init(platform::assetPath("fonts/DejaVuSans.ttf"), platform::assetPath("fonts/DejaVuSans-Bold.ttf"),
"Nubix")) {
platform::notify("Nubix: cannot start UI (assets missing?)");
d.ui.shutdown();
Http::globalCleanup();
SDL_Quit();
return 1;
}
if (platform::isPs5()) SDL_ShowCursor(SDL_DISABLE);
{
SDL_version v;
SDL_GetVersion(&v);
XC_LOGI("SDL %d.%d.%d, video driver %s, network %s (%s)", v.major, v.minor, v.patch,
SDL_GetCurrentVideoDriver() ? SDL_GetCurrentVideoDriver() : "?",
platform::networkAvailable() ? "up" : "DOWN", platform::localIpv4().c_str());
}
d.gamepad.init();
int selftestFailures = 0;
if (selftest) {
XC_LOGI("selftest: starting");
selftestFailures = runSubsystemSelftest();
d.exitFrame = kSelftestFrames;
}
try {
d.worker = std::thread([this] { d_->workerLoop(); });
} catch (const std::exception& e) {
XC_LOGE("app: cannot start the worker thread: %s", e.what());
platform::notify("Nubix: cannot start (out of threads)");
d.gamepad.shutdown();
d.ui.shutdown();
Http::globalCleanup();
SDL_Quit();
return 1;
}
push((d.auth.isLoggedIn() && !selftest) ? makeHomeScreen() : makeLoginScreen());
if (!platform::networkAvailable()) toast("No network connection detected");
if (const char* e = std::getenv("XC_SHOT_FRAMES")) {
for (const char* p = e; *p;) {
char* end = nullptr;
unsigned long long v = std::strtoull(p, &end, 10);
if (end == p) break;
d.shotFrames.push_back(v);
p = (*end == ',') ? end + 1 : end;
}
}
if (const char* e = std::getenv("XC_EXIT_FRAME"); e && !selftest) d.exitFrame = std::strtoull(e, nullptr, 10);
SDL_RendererInfo rinfo{};
SDL_GetRendererInfo(d.ui.renderer(), &rinfo);
const bool vsync = (rinfo.flags & SDL_RENDERER_PRESENTVSYNC) != 0 || platform::isPs5(); // PS5 flip waits
uint64_t last = platform::monotonicMs();
bool firstFrame = true;
uint64_t lastFrame = 0;
while (!d.quit.load()) {
const uint64_t frameStart = platform::monotonicMs();
const uint32_t dt = static_cast<uint32_t>(std::min<uint64_t>(250, frameStart - last));
last = frameStart;
SDL_Event ev;
while (SDL_PollEvent(&ev)) {
if (ev.type == SDL_QUIT) {
d.quit = true;
break;
}
if (ev.type == SDL_KEYDOWN && ev.key.keysym.sym == SDLK_F12 && !ev.key.repeat) d.screenshotRequested = true;
NavKey k = d.gamepad.handleEvent(ev);
if (k == NavKey::None || d.stack.empty()) continue;
Screen* top = d.stack.back().get();
if (top->capturesGamepad() && k != NavKey::Menu) continue;
top->onNav(*this, k);
}
if (NavKey r = d.gamepad.pollRepeat(); r != NavKey::None && !d.stack.empty()) {
Screen* top = d.stack.back().get();
if (!top->capturesGamepad()) top->onNav(*this, r);
}
d.drainMainQueue();
d.applyOps(*this);
if (d.stack.empty()) break;
d.stack.back()->update(*this, dt);
d.applyOps(*this);
if (d.stack.empty()) break;
d.ui.beginFrame();
size_t first = d.stack.size() - 1;
while (first > 0 && d.stack[first]->isOverlay()) --first;
for (size_t i = first; i < d.stack.size(); ++i) d.stack[i]->render(*this, d.ui);
const uint64_t frameNo = d.ui.frameCount();
lastFrame = frameNo;
if (std::find(d.shotFrames.begin(), d.shotFrames.end(), frameNo) != d.shotFrames.end())
d.screenshotRequested = true;
if (d.screenshotRequested) {
d.screenshotRequested = false;
d.takeScreenshot(); // before toasts so the "saved" toast is not in the picture
}
d.renderToasts(d.ui, platform::monotonicMs());
d.ui.endFrame();
if (d.exitFrame && frameNo >= d.exitFrame) d.quit = true;
if (firstFrame) {
platform::onFirstFrame();
firstFrame = false;
}
// Present normally blocks on vsync; when it does not (hidden/occluded window, no vsync
// support, high-refresh displays) cap the loop at ~60 fps to keep CPU use sane.
const uint64_t spent = platform::monotonicMs() - frameStart;
if (spent < (vsync ? 15u : 16u)) SDL_Delay(static_cast<Uint32>((vsync ? 15u : 16u) - spent));
}
XC_LOGI("shutting down");
d.cancel = true; // running worker job: abort its requests now
if (!d.stack.empty()) d.stack.back()->onLeave(*this);
d.ops.clear();
d.gamepad.stopRumble();
// Stopping a stream (final DELETE, bounded to ~5 s) and joining the worker can take a moment:
// show that the app is closing instead of a frozen last frame.
d.ui.beginFrame();
ui::background(d.ui);
d.ui.spinner(Ui::kWidth / 2, Ui::kHeight / 2 - 40, 48, platform::monotonicMs());
d.ui.text("Closing\xE2\x80\xA6", Ui::kWidth / 2, Ui::kHeight / 2 + 40, 32, colors::Text, Align::Center);
d.ui.endFrame();
d.streamer.stop();
d.stopWorker();
d.stack.clear();
{
std::lock_guard<std::mutex> lk(d.mainMx);
d.mainQueue.clear();
}
d.gamepad.shutdown();
d.ui.shutdown();
Http::globalCleanup();
SDL_Quit();
if (selftest) {
const bool framesOk = lastFrame >= kSelftestFrames;
if (!framesOk) ++selftestFailures;
XC_LOGI("selftest: rendered %llu frames; %s", static_cast<unsigned long long>(lastFrame),
selftestFailures ? "FAILED" : "PASSED");
return selftestFailures ? 1 : 0;
}
return 0;
}
void App::push(std::unique_ptr<Screen> s) {
if (s) d_->ops.push_back({StackOp::Push, std::move(s)});
}
void App::pop() { d_->ops.push_back({StackOp::Pop, nullptr}); }
void App::replace(std::unique_ptr<Screen> s) {
if (s) d_->ops.push_back({StackOp::Replace, std::move(s)});
}
void App::resetTo(std::unique_ptr<Screen> s) {
if (s) d_->ops.push_back({StackOp::Reset, std::move(s)});
}
void App::quit() {
d_->quit = true;
d_->cancel = true;
}
void App::runAsync(std::function<void()> work) {
if (!work) return;
{
std::lock_guard<std::mutex> lk(d_->workMx);
if (d_->workerStop) return;
d_->work.push_back(std::move(work));
}
d_->workCv.notify_one();
}
void App::postToMain(std::function<void()> fn) {
if (!fn) return;
std::lock_guard<std::mutex> lk(d_->mainMx);
d_->mainQueue.push_back(std::move(fn));
}
void App::toast(const std::string& msg) {
XC_LOGI("toast: %s", msg.c_str());
std::lock_guard<std::mutex> lk(d_->toastMx);
d_->toasts.push_back({msg, platform::monotonicMs() + kToastMs});
while (d_->toasts.size() > kMaxToasts) d_->toasts.pop_front();
}
Config& App::config() { return d_->cfg; }
Auth& App::auth() { return d_->auth; }
Catalog& App::catalog() { return d_->catalog; }
Streamer& App::streamer() { return d_->streamer; }
Ui& App::ui() { return d_->ui; }
Gamepad& App::gamepad() { return d_->gamepad; }
bool App::statsOverlay() const { return d_->statsOverlay; }
void App::setStatsOverlay(bool on) { d_->statsOverlay = on; }
} // namespace xc
+66
View File
@@ -0,0 +1,66 @@
// Nubix — application object: owns config/auth/catalog/streamer/ui/gamepad, the screen
// stack and the SDL event loop; runs blocking work on a background worker.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <functional>
#include <memory>
#include <string>
#include "core/auth.hpp"
#include "core/catalog.hpp"
#include "core/config.hpp"
#include "platform/gamepad.hpp"
#include "stream/streamer.hpp"
#include "ui/screens.hpp"
#include "ui/ui.hpp"
namespace xc {
class App {
public:
App();
~App();
App(const App&) = delete;
App& operator=(const App&) = delete;
// Full lifecycle: load config, init SDL/UI/pad/HTTP, push Login or Home, run the loop until
// quit(), shut down. Returns the process exit code.
int run(int argc, char** argv);
// Screen stack (applied between frames, so safe to call from screen callbacks).
void push(std::unique_ptr<Screen> s);
void pop();
void replace(std::unique_ptr<Screen> s); // pop + push
// Clear the whole stack and push s (e.g. after logout).
void resetTo(std::unique_ptr<Screen> s);
// Request the main loop to exit.
void quit();
// Run work on the background worker thread (HTTP etc.), FIFO.
void runAsync(std::function<void()> work);
// Queue a function to run on the main thread before the next frame (thread-safe).
void postToMain(std::function<void()> fn);
// Show a transient toast message at the bottom of the screen.
void toast(const std::string& msg);
Config& config();
Auth& auth();
Catalog& catalog();
Streamer& streamer();
Ui& ui();
Gamepad& gamepad();
// Stream statistics overlay toggle (kept for the app's lifetime).
bool statsOverlay() const;
void setStatsOverlay(bool on);
private:
struct Impl;
std::unique_ptr<Impl> d_;
};
} // namespace xc
+265
View File
@@ -0,0 +1,265 @@
// Nubix — still-image decoding via libavcodec + resampling.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include "ui/image.hpp"
#include <algorithm>
#include <cmath>
#include <cstring>
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/frame.h>
#include <libavutil/pixdesc.h>
}
#include "core/log.hpp"
namespace xc {
namespace image {
namespace {
AVCodecID sniff(const uint8_t* d, size_t n) {
if (n >= 8 && d[0] == 0x89 && d[1] == 'P' && d[2] == 'N' && d[3] == 'G') return AV_CODEC_ID_PNG;
if (n >= 3 && d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF) return AV_CODEC_ID_MJPEG;
if (n >= 12 && std::memcmp(d, "RIFF", 4) == 0 && std::memcmp(d + 8, "WEBP", 4) == 0) return AV_CODEC_ID_WEBP;
if (n >= 6 && (std::memcmp(d, "GIF87a", 6) == 0 || std::memcmp(d, "GIF89a", 6) == 0)) return AV_CODEC_ID_GIF;
if (n >= 2 && d[0] == 'B' && d[1] == 'M') return AV_CODEC_ID_BMP;
return AV_CODEC_ID_NONE;
}
inline uint8_t clamp8(int v) { return static_cast<uint8_t>(v < 0 ? 0 : (v > 255 ? 255 : v)); }
// BT.601 YCbCr -> RGB (still images are JPEG/WebP: BT.601), limited or full range.
inline void yuvToRgb(int y, int u, int v, bool full, uint8_t* o) {
int c = full ? y : (y - 16);
int dd = u - 128, e = v - 128;
if (full) {
o[0] = clamp8((256 * c + 359 * e + 128) >> 8);
o[1] = clamp8((256 * c - 88 * dd - 183 * e + 128) >> 8);
o[2] = clamp8((256 * c + 454 * dd + 128) >> 8);
} else {
o[0] = clamp8((298 * c + 409 * e + 128) >> 8);
o[1] = clamp8((298 * c - 100 * dd - 208 * e + 128) >> 8);
o[2] = clamp8((298 * c + 516 * dd + 128) >> 8);
}
}
bool frameToRgba(const AVFrame* f, Rgba& out) {
const int w = f->width, h = f->height;
if (w <= 0 || h <= 0 || w > 8192 || h > 8192) return false;
const AVPixelFormat fmt = static_cast<AVPixelFormat>(f->format);
out.width = w;
out.height = h;
out.pixels.assign(static_cast<size_t>(w) * h * 4, 255);
uint8_t* dst = out.pixels.data();
auto rows = [&](auto fn) {
for (int y = 0; y < h; ++y) {
const uint8_t* s = f->data[0] + static_cast<ptrdiff_t>(y) * f->linesize[0];
uint8_t* o = dst + static_cast<size_t>(y) * w * 4;
for (int x = 0; x < w; ++x, o += 4) fn(s, x, o);
}
};
switch (fmt) {
case AV_PIX_FMT_RGBA:
rows([](const uint8_t* s, int x, uint8_t* o) { std::memcpy(o, s + x * 4, 4); });
return true;
case AV_PIX_FMT_BGRA:
rows([](const uint8_t* s, int x, uint8_t* o) { o[0] = s[x * 4 + 2]; o[1] = s[x * 4 + 1]; o[2] = s[x * 4]; o[3] = s[x * 4 + 3]; });
return true;
case AV_PIX_FMT_ARGB:
rows([](const uint8_t* s, int x, uint8_t* o) { o[0] = s[x * 4 + 1]; o[1] = s[x * 4 + 2]; o[2] = s[x * 4 + 3]; o[3] = s[x * 4]; });
return true;
case AV_PIX_FMT_ABGR:
rows([](const uint8_t* s, int x, uint8_t* o) { o[0] = s[x * 4 + 3]; o[1] = s[x * 4 + 2]; o[2] = s[x * 4 + 1]; o[3] = s[x * 4]; });
return true;
case AV_PIX_FMT_RGB0:
rows([](const uint8_t* s, int x, uint8_t* o) { std::memcpy(o, s + x * 4, 3); });
return true;
case AV_PIX_FMT_BGR0:
rows([](const uint8_t* s, int x, uint8_t* o) { o[0] = s[x * 4 + 2]; o[1] = s[x * 4 + 1]; o[2] = s[x * 4]; });
return true;
case AV_PIX_FMT_RGB24:
rows([](const uint8_t* s, int x, uint8_t* o) { std::memcpy(o, s + x * 3, 3); });
return true;
case AV_PIX_FMT_BGR24:
rows([](const uint8_t* s, int x, uint8_t* o) { o[0] = s[x * 3 + 2]; o[1] = s[x * 3 + 1]; o[2] = s[x * 3]; });
return true;
case AV_PIX_FMT_GRAY8:
rows([](const uint8_t* s, int x, uint8_t* o) { o[0] = o[1] = o[2] = s[x]; });
return true;
case AV_PIX_FMT_YA8:
rows([](const uint8_t* s, int x, uint8_t* o) { o[0] = o[1] = o[2] = s[x * 2]; o[3] = s[x * 2 + 1]; });
return true;
case AV_PIX_FMT_GRAY16BE:
rows([](const uint8_t* s, int x, uint8_t* o) { o[0] = o[1] = o[2] = s[x * 2]; });
return true;
case AV_PIX_FMT_YA16BE:
rows([](const uint8_t* s, int x, uint8_t* o) { o[0] = o[1] = o[2] = s[x * 4]; o[3] = s[x * 4 + 2]; });
return true;
case AV_PIX_FMT_RGB48BE:
rows([](const uint8_t* s, int x, uint8_t* o) { o[0] = s[x * 6]; o[1] = s[x * 6 + 2]; o[2] = s[x * 6 + 4]; });
return true;
case AV_PIX_FMT_RGBA64BE:
rows([](const uint8_t* s, int x, uint8_t* o) { o[0] = s[x * 8]; o[1] = s[x * 8 + 2]; o[2] = s[x * 8 + 4]; o[3] = s[x * 8 + 6]; });
return true;
case AV_PIX_FMT_PAL8: {
const uint32_t* pal = reinterpret_cast<const uint32_t*>(f->data[1]); // native-endian ARGB
if (!pal) return false;
rows([pal](const uint8_t* s, int x, uint8_t* o) {
uint32_t c = pal[s[x]];
o[0] = (c >> 16) & 0xFF; o[1] = (c >> 8) & 0xFF; o[2] = c & 0xFF; o[3] = (c >> 24) & 0xFF;
});
return true;
}
default: break;
}
// Generic 8-bit planar YUV(A) (yuv420p, yuvj422p, yuv444p, yuva420p, ...).
const AVPixFmtDescriptor* desc = av_pix_fmt_desc_get(fmt);
if (desc && !(desc->flags & (AV_PIX_FMT_FLAG_RGB | AV_PIX_FMT_FLAG_PAL | AV_PIX_FMT_FLAG_BITSTREAM)) &&
(desc->flags & AV_PIX_FMT_FLAG_PLANAR) && desc->nb_components >= 3 && desc->comp[0].depth == 8 &&
desc->comp[1].depth == 8 && desc->comp[2].depth == 8) {
const bool full = f->color_range == AVCOL_RANGE_JPEG || fmt == AV_PIX_FMT_YUVJ420P ||
fmt == AV_PIX_FMT_YUVJ422P || fmt == AV_PIX_FMT_YUVJ444P || fmt == AV_PIX_FMT_YUVJ440P ||
fmt == AV_PIX_FMT_YUVJ411P;
const int sx = desc->log2_chroma_w, sy = desc->log2_chroma_h;
const bool alpha = (desc->flags & AV_PIX_FMT_FLAG_ALPHA) && desc->nb_components == 4;
const int pY = desc->comp[0].plane, pU = desc->comp[1].plane, pV = desc->comp[2].plane;
const int pA = alpha ? desc->comp[3].plane : 0;
for (int y = 0; y < h; ++y) {
const uint8_t* ry = f->data[pY] + static_cast<ptrdiff_t>(y) * f->linesize[pY];
const uint8_t* ru = f->data[pU] + static_cast<ptrdiff_t>(y >> sy) * f->linesize[pU];
const uint8_t* rv = f->data[pV] + static_cast<ptrdiff_t>(y >> sy) * f->linesize[pV];
const uint8_t* ra = alpha ? f->data[pA] + static_cast<ptrdiff_t>(y) * f->linesize[pA] : nullptr;
uint8_t* o = dst + static_cast<size_t>(y) * w * 4;
for (int x = 0; x < w; ++x, o += 4) {
yuvToRgb(ry[x], ru[x >> sx], rv[x >> sx], full, o);
if (ra) o[3] = ra[x];
}
}
return true;
}
XC_LOGW("image: unsupported pixel format %s", av_get_pix_fmt_name(fmt) ? av_get_pix_fmt_name(fmt) : "?");
return false;
}
} // namespace
bool decode(const uint8_t* data, size_t size, Rgba& out) {
out = Rgba();
if (!data || size < 8 || size > (64u << 20)) return false;
const AVCodecID id = sniff(data, size);
if (id == AV_CODEC_ID_NONE) {
XC_LOGD("image: unknown format (%zu bytes)", size);
return false;
}
const AVCodec* codec = avcodec_find_decoder(id);
if (!codec) {
XC_LOGW("image: no decoder for %s", avcodec_get_name(id));
return false;
}
AVCodecContext* ctx = avcodec_alloc_context3(codec);
AVPacket* pkt = av_packet_alloc();
AVFrame* frame = av_frame_alloc();
bool ok = false;
if (ctx && pkt && frame) {
ctx->thread_count = 1;
if (avcodec_open2(ctx, codec, nullptr) == 0 && av_new_packet(pkt, static_cast<int>(size)) == 0) {
std::memcpy(pkt->data, data, size);
int rc = avcodec_send_packet(ctx, pkt);
if (rc >= 0) {
rc = avcodec_receive_frame(ctx, frame);
if (rc == AVERROR(EAGAIN)) {
avcodec_send_packet(ctx, nullptr); // flush (some decoders emit on drain)
rc = avcodec_receive_frame(ctx, frame);
}
if (rc == 0) ok = frameToRgba(frame, out);
}
}
}
av_frame_free(&frame);
av_packet_free(&pkt);
avcodec_free_context(&ctx);
if (!ok) out = Rgba();
return ok;
}
void resize(const Rgba& src, int w, int h, bool cover, Rgba& out) {
out = Rgba();
if (src.empty() || w <= 0 || h <= 0) return;
// Source window (cover = centred crop with the target aspect).
double cx0 = 0, cy0 = 0, cw = src.width, ch = src.height;
if (cover) {
const double ta = static_cast<double>(w) / h, sa = static_cast<double>(src.width) / src.height;
if (sa > ta) {
cw = src.height * ta;
cx0 = (src.width - cw) / 2;
} else {
ch = src.width / ta;
cy0 = (src.height - ch) / 2;
}
}
out.width = w;
out.height = h;
out.pixels.resize(static_cast<size_t>(w) * h * 4);
const double fx = cw / w, fy = ch / h;
const uint8_t* sp = src.pixels.data();
const int sw = src.width, sh = src.height;
for (int y = 0; y < h; ++y) {
uint8_t* o = out.pixels.data() + static_cast<size_t>(y) * w * 4;
for (int x = 0; x < w; ++x, o += 4) {
if (fx > 1.0 || fy > 1.0) {
// Box filter over the source footprint (alpha-weighted to avoid dark fringes).
int x0 = static_cast<int>(cx0 + x * fx), x1 = static_cast<int>(cx0 + (x + 1) * fx);
int y0 = static_cast<int>(cy0 + y * fy), y1 = static_cast<int>(cy0 + (y + 1) * fy);
x0 = std::clamp(x0, 0, sw - 1);
y0 = std::clamp(y0, 0, sh - 1);
x1 = std::clamp(std::max(x1, x0 + 1), 1, sw);
y1 = std::clamp(std::max(y1, y0 + 1), 1, sh);
uint64_t r = 0, g = 0, b = 0, a = 0, n = 0;
for (int yy = y0; yy < y1; ++yy) {
const uint8_t* p = sp + (static_cast<size_t>(yy) * sw + x0) * 4;
for (int xx = x0; xx < x1; ++xx, p += 4) {
r += p[0] * p[3];
g += p[1] * p[3];
b += p[2] * p[3];
a += p[3];
++n;
}
}
if (a > 0) {
o[0] = static_cast<uint8_t>(r / a);
o[1] = static_cast<uint8_t>(g / a);
o[2] = static_cast<uint8_t>(b / a);
} else {
o[0] = o[1] = o[2] = 0;
}
o[3] = static_cast<uint8_t>(n ? a / n : 0);
} else {
// Bilinear.
double sxf = cx0 + (x + 0.5) * fx - 0.5, syf = cy0 + (y + 0.5) * fy - 0.5;
sxf = std::clamp(sxf, 0.0, sw - 1.0);
syf = std::clamp(syf, 0.0, sh - 1.0);
const int ix = static_cast<int>(sxf), iy = static_cast<int>(syf);
const int ix1 = std::min(ix + 1, sw - 1), iy1 = std::min(iy + 1, sh - 1);
const double ax = sxf - ix, ay = syf - iy;
const uint8_t* p00 = sp + (static_cast<size_t>(iy) * sw + ix) * 4;
const uint8_t* p10 = sp + (static_cast<size_t>(iy) * sw + ix1) * 4;
const uint8_t* p01 = sp + (static_cast<size_t>(iy1) * sw + ix) * 4;
const uint8_t* p11 = sp + (static_cast<size_t>(iy1) * sw + ix1) * 4;
for (int c = 0; c < 4; ++c) {
const double top = p00[c] + (p10[c] - p00[c]) * ax;
const double bot = p01[c] + (p11[c] - p01[c]) * ax;
o[c] = clamp8(static_cast<int>(std::lround(top + (bot - top) * ay)));
}
}
}
}
}
} // namespace image
} // namespace xc
+29
View File
@@ -0,0 +1,29 @@
// Nubix — still-image decoding (PNG/JPEG/WebP/GIF/BMP via libavcodec) into RGBA8 pixels,
// plus a CPU resampler, so box art can be decoded + scaled off the main thread.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>
namespace xc {
namespace image {
// Tightly packed RGBA8 (byte order R,G,B,A), width * height * 4 bytes.
struct Rgba {
int width = 0, height = 0;
std::vector<uint8_t> pixels;
bool empty() const { return width <= 0 || height <= 0; }
};
// Decode an encoded image (format sniffed from magic bytes). Thread-safe.
bool decode(const uint8_t* data, size_t size, Rgba& out);
// Scale src to exactly w x h. cover = true crops to the target aspect ratio first (centred,
// like CSS object-fit: cover); false stretches. Box filter when shrinking, bilinear otherwise.
void resize(const Rgba& src, int w, int h, bool cover, Rgba& out);
} // namespace image
} // namespace xc
+31
View File
@@ -0,0 +1,31 @@
// Nubix — QR code encoding.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include "ui/qr.hpp"
#include <exception>
#include "ui/qrcodegen.hpp"
namespace xc {
namespace qr {
bool encode(const std::string& text, std::vector<bool>& modules, int& size) {
modules.clear();
size = 0;
try {
const qrcodegen::QrCode code = qrcodegen::QrCode::encodeText(text.c_str(), qrcodegen::QrCode::Ecc::MEDIUM);
size = code.getSize();
modules.resize(static_cast<size_t>(size) * static_cast<size_t>(size));
for (int y = 0; y < size; ++y)
for (int x = 0; x < size; ++x) modules[static_cast<size_t>(y) * size + x] = code.getModule(x, y);
return true;
} catch (const std::exception&) {
modules.clear();
size = 0;
return false;
}
}
} // namespace qr
} // namespace xc
+17
View File
@@ -0,0 +1,17 @@
// Nubix — QR code encoding (thin wrapper over Nayuki qrcodegen, MIT; see qrcodegen.hpp).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <string>
#include <vector>
namespace xc {
namespace qr {
// Encode text (byte mode, ECC level M with boost). On success size = modules per side and
// modules[y * size + x] == true for dark modules. Returns false if the text is too long.
bool encode(const std::string& text, std::vector<bool>& modules, int& size);
} // namespace qr
} // namespace xc
+831
View File
@@ -0,0 +1,831 @@
// SPDX-License-Identifier: MIT
/*
* QR Code generator library (C++)
*
* Copyright (c) Project Nayuki. (MIT License)
* https://www.nayuki.io/page/qr-code-generator-library
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
* - The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
* - The Software is provided "as is", without warranty of any kind, express or
* implied, including but not limited to the warranties of merchantability,
* fitness for a particular purpose and noninfringement. In no event shall the
* authors or copyright holders be liable for any claim, damages or other
* liability, whether in an action of contract, tort or otherwise, arising from,
* out of or in connection with the Software or the use or other dealings in the
* Software.
*/
#include <algorithm>
#include <cassert>
#include <climits>
#include <cstddef>
#include <cstdlib>
#include <cstring>
#include <sstream>
#include <utility>
#include "qrcodegen.hpp"
using std::int8_t;
using std::uint8_t;
using std::size_t;
using std::vector;
namespace qrcodegen {
/*---- Class QrSegment ----*/
QrSegment::Mode::Mode(int mode, int cc0, int cc1, int cc2) :
modeBits(mode) {
numBitsCharCount[0] = cc0;
numBitsCharCount[1] = cc1;
numBitsCharCount[2] = cc2;
}
int QrSegment::Mode::getModeBits() const {
return modeBits;
}
int QrSegment::Mode::numCharCountBits(int ver) const {
return numBitsCharCount[(ver + 7) / 17];
}
const QrSegment::Mode QrSegment::Mode::NUMERIC (0x1, 10, 12, 14);
const QrSegment::Mode QrSegment::Mode::ALPHANUMERIC(0x2, 9, 11, 13);
const QrSegment::Mode QrSegment::Mode::BYTE (0x4, 8, 16, 16);
const QrSegment::Mode QrSegment::Mode::KANJI (0x8, 8, 10, 12);
const QrSegment::Mode QrSegment::Mode::ECI (0x7, 0, 0, 0);
QrSegment QrSegment::makeBytes(const vector<uint8_t> &data) {
if (data.size() > static_cast<unsigned int>(INT_MAX))
throw std::length_error("Data too long");
BitBuffer bb;
for (uint8_t b : data)
bb.appendBits(b, 8);
return QrSegment(Mode::BYTE, static_cast<int>(data.size()), std::move(bb));
}
QrSegment QrSegment::makeNumeric(const char *digits) {
BitBuffer bb;
int accumData = 0;
int accumCount = 0;
int charCount = 0;
for (; *digits != '\0'; digits++, charCount++) {
char c = *digits;
if (c < '0' || c > '9')
throw std::domain_error("String contains non-numeric characters");
accumData = accumData * 10 + (c - '0');
accumCount++;
if (accumCount == 3) {
bb.appendBits(static_cast<uint32_t>(accumData), 10);
accumData = 0;
accumCount = 0;
}
}
if (accumCount > 0) // 1 or 2 digits remaining
bb.appendBits(static_cast<uint32_t>(accumData), accumCount * 3 + 1);
return QrSegment(Mode::NUMERIC, charCount, std::move(bb));
}
QrSegment QrSegment::makeAlphanumeric(const char *text) {
BitBuffer bb;
int accumData = 0;
int accumCount = 0;
int charCount = 0;
for (; *text != '\0'; text++, charCount++) {
const char *temp = std::strchr(ALPHANUMERIC_CHARSET, *text);
if (temp == nullptr)
throw std::domain_error("String contains unencodable characters in alphanumeric mode");
accumData = accumData * 45 + static_cast<int>(temp - ALPHANUMERIC_CHARSET);
accumCount++;
if (accumCount == 2) {
bb.appendBits(static_cast<uint32_t>(accumData), 11);
accumData = 0;
accumCount = 0;
}
}
if (accumCount > 0) // 1 character remaining
bb.appendBits(static_cast<uint32_t>(accumData), 6);
return QrSegment(Mode::ALPHANUMERIC, charCount, std::move(bb));
}
vector<QrSegment> QrSegment::makeSegments(const char *text) {
// Select the most efficient segment encoding automatically
vector<QrSegment> result;
if (*text == '\0'); // Leave result empty
else if (isNumeric(text))
result.push_back(makeNumeric(text));
else if (isAlphanumeric(text))
result.push_back(makeAlphanumeric(text));
else {
vector<uint8_t> bytes;
for (; *text != '\0'; text++)
bytes.push_back(static_cast<uint8_t>(*text));
result.push_back(makeBytes(bytes));
}
return result;
}
QrSegment QrSegment::makeEci(long assignVal) {
BitBuffer bb;
if (assignVal < 0)
throw std::domain_error("ECI assignment value out of range");
else if (assignVal < (1 << 7))
bb.appendBits(static_cast<uint32_t>(assignVal), 8);
else if (assignVal < (1 << 14)) {
bb.appendBits(2, 2);
bb.appendBits(static_cast<uint32_t>(assignVal), 14);
} else if (assignVal < 1000000L) {
bb.appendBits(6, 3);
bb.appendBits(static_cast<uint32_t>(assignVal), 21);
} else
throw std::domain_error("ECI assignment value out of range");
return QrSegment(Mode::ECI, 0, std::move(bb));
}
QrSegment::QrSegment(const Mode &md, int numCh, const std::vector<bool> &dt) :
mode(&md),
numChars(numCh),
data(dt) {
if (numCh < 0)
throw std::domain_error("Invalid value");
}
QrSegment::QrSegment(const Mode &md, int numCh, std::vector<bool> &&dt) :
mode(&md),
numChars(numCh),
data(std::move(dt)) {
if (numCh < 0)
throw std::domain_error("Invalid value");
}
int QrSegment::getTotalBits(const vector<QrSegment> &segs, int version) {
int result = 0;
for (const QrSegment &seg : segs) {
int ccbits = seg.mode->numCharCountBits(version);
if (seg.numChars >= (1L << ccbits))
return -1; // The segment's length doesn't fit the field's bit width
if (4 + ccbits > INT_MAX - result)
return -1; // The sum will overflow an int type
result += 4 + ccbits;
if (seg.data.size() > static_cast<unsigned int>(INT_MAX - result))
return -1; // The sum will overflow an int type
result += static_cast<int>(seg.data.size());
}
return result;
}
bool QrSegment::isNumeric(const char *text) {
for (; *text != '\0'; text++) {
char c = *text;
if (c < '0' || c > '9')
return false;
}
return true;
}
bool QrSegment::isAlphanumeric(const char *text) {
for (; *text != '\0'; text++) {
if (std::strchr(ALPHANUMERIC_CHARSET, *text) == nullptr)
return false;
}
return true;
}
const QrSegment::Mode &QrSegment::getMode() const {
return *mode;
}
int QrSegment::getNumChars() const {
return numChars;
}
const std::vector<bool> &QrSegment::getData() const {
return data;
}
const char *QrSegment::ALPHANUMERIC_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
/*---- Class QrCode ----*/
int QrCode::getFormatBits(Ecc ecl) {
switch (ecl) {
case Ecc::LOW : return 1;
case Ecc::MEDIUM : return 0;
case Ecc::QUARTILE: return 3;
case Ecc::HIGH : return 2;
default: throw std::logic_error("Unreachable");
}
}
QrCode QrCode::encodeText(const char *text, Ecc ecl) {
vector<QrSegment> segs = QrSegment::makeSegments(text);
return encodeSegments(segs, ecl);
}
QrCode QrCode::encodeBinary(const vector<uint8_t> &data, Ecc ecl) {
vector<QrSegment> segs{QrSegment::makeBytes(data)};
return encodeSegments(segs, ecl);
}
QrCode QrCode::encodeSegments(const vector<QrSegment> &segs, Ecc ecl,
int minVersion, int maxVersion, int mask, bool boostEcl) {
if (!(MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= MAX_VERSION) || mask < -1 || mask > 7)
throw std::invalid_argument("Invalid value");
// Find the minimal version number to use
int version, dataUsedBits;
for (version = minVersion; ; version++) {
int dataCapacityBits = getNumDataCodewords(version, ecl) * 8; // Number of data bits available
dataUsedBits = QrSegment::getTotalBits(segs, version);
if (dataUsedBits != -1 && dataUsedBits <= dataCapacityBits)
break; // This version number is found to be suitable
if (version >= maxVersion) { // All versions in the range could not fit the given data
std::ostringstream sb;
if (dataUsedBits == -1)
sb << "Segment too long";
else {
sb << "Data length = " << dataUsedBits << " bits, ";
sb << "Max capacity = " << dataCapacityBits << " bits";
}
throw data_too_long(sb.str());
}
}
assert(dataUsedBits != -1);
// Increase the error correction level while the data still fits in the current version number
for (Ecc newEcl : {Ecc::MEDIUM, Ecc::QUARTILE, Ecc::HIGH}) { // From low to high
if (boostEcl && dataUsedBits <= getNumDataCodewords(version, newEcl) * 8)
ecl = newEcl;
}
// Concatenate all segments to create the data bit string
BitBuffer bb;
for (const QrSegment &seg : segs) {
bb.appendBits(static_cast<uint32_t>(seg.getMode().getModeBits()), 4);
bb.appendBits(static_cast<uint32_t>(seg.getNumChars()), seg.getMode().numCharCountBits(version));
bb.insert(bb.end(), seg.getData().begin(), seg.getData().end());
}
assert(bb.size() == static_cast<unsigned int>(dataUsedBits));
// Add terminator and pad up to a byte if applicable
size_t dataCapacityBits = static_cast<size_t>(getNumDataCodewords(version, ecl)) * 8;
assert(bb.size() <= dataCapacityBits);
bb.appendBits(0, std::min(4, static_cast<int>(dataCapacityBits - bb.size())));
bb.appendBits(0, (8 - static_cast<int>(bb.size() % 8)) % 8);
assert(bb.size() % 8 == 0);
// Pad with alternating bytes until data capacity is reached
for (uint8_t padByte = 0xEC; bb.size() < dataCapacityBits; padByte ^= 0xEC ^ 0x11)
bb.appendBits(padByte, 8);
// Pack bits into bytes in big endian
vector<uint8_t> dataCodewords(bb.size() / 8);
for (size_t i = 0; i < bb.size(); i++)
dataCodewords.at(i >> 3) |= (bb.at(i) ? 1 : 0) << (7 - (i & 7));
// Create the QR Code object
return QrCode(version, ecl, dataCodewords, mask);
}
QrCode::QrCode(int ver, Ecc ecl, const vector<uint8_t> &dataCodewords, int msk) :
// Initialize fields and check arguments
version(ver),
errorCorrectionLevel(ecl) {
if (ver < MIN_VERSION || ver > MAX_VERSION)
throw std::domain_error("Version value out of range");
if (msk < -1 || msk > 7)
throw std::domain_error("Mask value out of range");
size = ver * 4 + 17;
size_t sz = static_cast<size_t>(size);
modules = vector<vector<bool> >(sz, vector<bool>(sz)); // Initially all light
isFunction = vector<vector<bool> >(sz, vector<bool>(sz));
// Compute ECC, draw modules
drawFunctionPatterns();
const vector<uint8_t> allCodewords = addEccAndInterleave(dataCodewords);
drawCodewords(allCodewords);
// Do masking
if (msk == -1) { // Automatically choose best mask
long minPenalty = LONG_MAX;
for (int i = 0; i < 8; i++) {
applyMask(i);
drawFormatBits(i);
long penalty = getPenaltyScore();
if (penalty < minPenalty) {
msk = i;
minPenalty = penalty;
}
applyMask(i); // Undoes the mask due to XOR
}
}
assert(0 <= msk && msk <= 7);
mask = msk;
applyMask(msk); // Apply the final choice of mask
drawFormatBits(msk); // Overwrite old format bits
isFunction.clear();
isFunction.shrink_to_fit();
}
int QrCode::getVersion() const {
return version;
}
int QrCode::getSize() const {
return size;
}
QrCode::Ecc QrCode::getErrorCorrectionLevel() const {
return errorCorrectionLevel;
}
int QrCode::getMask() const {
return mask;
}
bool QrCode::getModule(int x, int y) const {
return 0 <= x && x < size && 0 <= y && y < size && module(x, y);
}
void QrCode::drawFunctionPatterns() {
// Draw horizontal and vertical timing patterns
for (int i = 0; i < size; i++) {
setFunctionModule(6, i, i % 2 == 0);
setFunctionModule(i, 6, i % 2 == 0);
}
// Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules)
drawFinderPattern(3, 3);
drawFinderPattern(size - 4, 3);
drawFinderPattern(3, size - 4);
// Draw numerous alignment patterns
const vector<int> alignPatPos = getAlignmentPatternPositions();
size_t numAlign = alignPatPos.size();
for (size_t i = 0; i < numAlign; i++) {
for (size_t j = 0; j < numAlign; j++) {
// Don't draw on the three finder corners
if (!((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0)))
drawAlignmentPattern(alignPatPos.at(i), alignPatPos.at(j));
}
}
// Draw configuration data
drawFormatBits(0); // Dummy mask value; overwritten later in the constructor
drawVersion();
}
void QrCode::drawFormatBits(int msk) {
// Calculate error correction code and pack bits
int data = getFormatBits(errorCorrectionLevel) << 3 | msk; // errCorrLvl is uint2, msk is uint3
int rem = data;
for (int i = 0; i < 10; i++)
rem = (rem << 1) ^ ((rem >> 9) * 0x537);
int bits = (data << 10 | rem) ^ 0x5412; // uint15
assert(bits >> 15 == 0);
// Draw first copy
for (int i = 0; i <= 5; i++)
setFunctionModule(8, i, getBit(bits, i));
setFunctionModule(8, 7, getBit(bits, 6));
setFunctionModule(8, 8, getBit(bits, 7));
setFunctionModule(7, 8, getBit(bits, 8));
for (int i = 9; i < 15; i++)
setFunctionModule(14 - i, 8, getBit(bits, i));
// Draw second copy
for (int i = 0; i < 8; i++)
setFunctionModule(size - 1 - i, 8, getBit(bits, i));
for (int i = 8; i < 15; i++)
setFunctionModule(8, size - 15 + i, getBit(bits, i));
setFunctionModule(8, size - 8, true); // Always dark
}
void QrCode::drawVersion() {
if (version < 7)
return;
// Calculate error correction code and pack bits
int rem = version; // version is uint6, in the range [7, 40]
for (int i = 0; i < 12; i++)
rem = (rem << 1) ^ ((rem >> 11) * 0x1F25);
long bits = static_cast<long>(version) << 12 | rem; // uint18
assert(bits >> 18 == 0);
// Draw two copies
for (int i = 0; i < 18; i++) {
bool bit = getBit(bits, i);
int a = size - 11 + i % 3;
int b = i / 3;
setFunctionModule(a, b, bit);
setFunctionModule(b, a, bit);
}
}
void QrCode::drawFinderPattern(int x, int y) {
for (int dy = -4; dy <= 4; dy++) {
for (int dx = -4; dx <= 4; dx++) {
int dist = std::max(std::abs(dx), std::abs(dy)); // Chebyshev/infinity norm
int xx = x + dx, yy = y + dy;
if (0 <= xx && xx < size && 0 <= yy && yy < size)
setFunctionModule(xx, yy, dist != 2 && dist != 4);
}
}
}
void QrCode::drawAlignmentPattern(int x, int y) {
for (int dy = -2; dy <= 2; dy++) {
for (int dx = -2; dx <= 2; dx++)
setFunctionModule(x + dx, y + dy, std::max(std::abs(dx), std::abs(dy)) != 1);
}
}
void QrCode::setFunctionModule(int x, int y, bool isDark) {
size_t ux = static_cast<size_t>(x);
size_t uy = static_cast<size_t>(y);
modules .at(uy).at(ux) = isDark;
isFunction.at(uy).at(ux) = true;
}
bool QrCode::module(int x, int y) const {
return modules.at(static_cast<size_t>(y)).at(static_cast<size_t>(x));
}
vector<uint8_t> QrCode::addEccAndInterleave(const vector<uint8_t> &data) const {
if (data.size() != static_cast<unsigned int>(getNumDataCodewords(version, errorCorrectionLevel)))
throw std::invalid_argument("Invalid argument");
// Calculate parameter numbers
int numBlocks = NUM_ERROR_CORRECTION_BLOCKS[static_cast<int>(errorCorrectionLevel)][version];
int blockEccLen = ECC_CODEWORDS_PER_BLOCK [static_cast<int>(errorCorrectionLevel)][version];
int rawCodewords = getNumRawDataModules(version) / 8;
int numShortBlocks = numBlocks - rawCodewords % numBlocks;
int shortBlockLen = rawCodewords / numBlocks;
// Split data into blocks and append ECC to each block
vector<vector<uint8_t> > blocks;
const vector<uint8_t> rsDiv = reedSolomonComputeDivisor(blockEccLen);
for (int i = 0, k = 0; i < numBlocks; i++) {
vector<uint8_t> dat(data.cbegin() + k, data.cbegin() + (k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1)));
k += static_cast<int>(dat.size());
const vector<uint8_t> ecc = reedSolomonComputeRemainder(dat, rsDiv);
if (i < numShortBlocks)
dat.push_back(0);
dat.insert(dat.end(), ecc.cbegin(), ecc.cend());
blocks.push_back(std::move(dat));
}
// Interleave (not concatenate) the bytes from every block into a single sequence
vector<uint8_t> result;
for (size_t i = 0; i < blocks.at(0).size(); i++) {
for (size_t j = 0; j < blocks.size(); j++) {
// Skip the padding byte in short blocks
if (i != static_cast<unsigned int>(shortBlockLen - blockEccLen) || j >= static_cast<unsigned int>(numShortBlocks))
result.push_back(blocks.at(j).at(i));
}
}
assert(result.size() == static_cast<unsigned int>(rawCodewords));
return result;
}
void QrCode::drawCodewords(const vector<uint8_t> &data) {
if (data.size() != static_cast<unsigned int>(getNumRawDataModules(version) / 8))
throw std::invalid_argument("Invalid argument");
size_t i = 0; // Bit index into the data
// Do the funny zigzag scan
for (int right = size - 1; right >= 1; right -= 2) { // Index of right column in each column pair
if (right == 6)
right = 5;
for (int vert = 0; vert < size; vert++) { // Vertical counter
for (int j = 0; j < 2; j++) {
size_t x = static_cast<size_t>(right - j); // Actual x coordinate
bool upward = ((right + 1) & 2) == 0;
size_t y = static_cast<size_t>(upward ? size - 1 - vert : vert); // Actual y coordinate
if (!isFunction.at(y).at(x) && i < data.size() * 8) {
modules.at(y).at(x) = getBit(data.at(i >> 3), 7 - static_cast<int>(i & 7));
i++;
}
// If this QR Code has any remainder bits (0 to 7), they were assigned as
// 0/false/light by the constructor and are left unchanged by this method
}
}
}
assert(i == data.size() * 8);
}
void QrCode::applyMask(int msk) {
if (msk < 0 || msk > 7)
throw std::domain_error("Mask value out of range");
size_t sz = static_cast<size_t>(size);
for (size_t y = 0; y < sz; y++) {
for (size_t x = 0; x < sz; x++) {
bool invert;
switch (msk) {
case 0: invert = (x + y) % 2 == 0; break;
case 1: invert = y % 2 == 0; break;
case 2: invert = x % 3 == 0; break;
case 3: invert = (x + y) % 3 == 0; break;
case 4: invert = (x / 3 + y / 2) % 2 == 0; break;
case 5: invert = x * y % 2 + x * y % 3 == 0; break;
case 6: invert = (x * y % 2 + x * y % 3) % 2 == 0; break;
case 7: invert = ((x + y) % 2 + x * y % 3) % 2 == 0; break;
default: throw std::logic_error("Unreachable");
}
modules.at(y).at(x) = modules.at(y).at(x) ^ (invert & !isFunction.at(y).at(x));
}
}
}
long QrCode::getPenaltyScore() const {
long result = 0;
// Adjacent modules in row having same color, and finder-like patterns
for (int y = 0; y < size; y++) {
bool runColor = false;
int runX = 0;
std::array<int,7> runHistory = {};
for (int x = 0; x < size; x++) {
if (module(x, y) == runColor) {
runX++;
if (runX == 5)
result += PENALTY_N1;
else if (runX > 5)
result++;
} else {
finderPenaltyAddHistory(runX, runHistory);
if (!runColor)
result += finderPenaltyCountPatterns(runHistory) * PENALTY_N3;
runColor = module(x, y);
runX = 1;
}
}
result += finderPenaltyTerminateAndCount(runColor, runX, runHistory) * PENALTY_N3;
}
// Adjacent modules in column having same color, and finder-like patterns
for (int x = 0; x < size; x++) {
bool runColor = false;
int runY = 0;
std::array<int,7> runHistory = {};
for (int y = 0; y < size; y++) {
if (module(x, y) == runColor) {
runY++;
if (runY == 5)
result += PENALTY_N1;
else if (runY > 5)
result++;
} else {
finderPenaltyAddHistory(runY, runHistory);
if (!runColor)
result += finderPenaltyCountPatterns(runHistory) * PENALTY_N3;
runColor = module(x, y);
runY = 1;
}
}
result += finderPenaltyTerminateAndCount(runColor, runY, runHistory) * PENALTY_N3;
}
// 2*2 blocks of modules having same color
for (int y = 0; y < size - 1; y++) {
for (int x = 0; x < size - 1; x++) {
bool color = module(x, y);
if ( color == module(x + 1, y) &&
color == module(x, y + 1) &&
color == module(x + 1, y + 1))
result += PENALTY_N2;
}
}
// Balance of dark and light modules
int dark = 0;
for (const vector<bool> &row : modules) {
for (bool color : row) {
if (color)
dark++;
}
}
int total = size * size; // Note that size is odd, so dark/total != 1/2
// Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)%
int k = static_cast<int>((std::abs(dark * 20L - total * 10L) + total - 1) / total) - 1;
assert(0 <= k && k <= 9);
result += k * PENALTY_N4;
assert(0 <= result && result <= 2568888L); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4
return result;
}
vector<int> QrCode::getAlignmentPatternPositions() const {
if (version == 1)
return vector<int>();
else {
int numAlign = version / 7 + 2;
int step = (version == 32) ? 26 :
(version * 4 + numAlign * 2 + 1) / (numAlign * 2 - 2) * 2;
vector<int> result;
for (int i = 0, pos = size - 7; i < numAlign - 1; i++, pos -= step)
result.insert(result.begin(), pos);
result.insert(result.begin(), 6);
return result;
}
}
int QrCode::getNumRawDataModules(int ver) {
if (ver < MIN_VERSION || ver > MAX_VERSION)
throw std::domain_error("Version number out of range");
int result = (16 * ver + 128) * ver + 64;
if (ver >= 2) {
int numAlign = ver / 7 + 2;
result -= (25 * numAlign - 10) * numAlign - 55;
if (ver >= 7)
result -= 36;
}
assert(208 <= result && result <= 29648);
return result;
}
int QrCode::getNumDataCodewords(int ver, Ecc ecl) {
return getNumRawDataModules(ver) / 8
- ECC_CODEWORDS_PER_BLOCK [static_cast<int>(ecl)][ver]
* NUM_ERROR_CORRECTION_BLOCKS[static_cast<int>(ecl)][ver];
}
vector<uint8_t> QrCode::reedSolomonComputeDivisor(int degree) {
if (degree < 1 || degree > 255)
throw std::domain_error("Degree out of range");
// Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1.
// For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array {255, 8, 93}.
vector<uint8_t> result(static_cast<size_t>(degree));
result.at(result.size() - 1) = 1; // Start off with the monomial x^0
// Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}),
// and drop the highest monomial term which is always 1x^degree.
// Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D).
uint8_t root = 1;
for (int i = 0; i < degree; i++) {
// Multiply the current product by (x - r^i)
for (size_t j = 0; j < result.size(); j++) {
result.at(j) = reedSolomonMultiply(result.at(j), root);
if (j + 1 < result.size())
result.at(j) ^= result.at(j + 1);
}
root = reedSolomonMultiply(root, 0x02);
}
return result;
}
vector<uint8_t> QrCode::reedSolomonComputeRemainder(const vector<uint8_t> &data, const vector<uint8_t> &divisor) {
vector<uint8_t> result(divisor.size());
for (uint8_t b : data) { // Polynomial division
uint8_t factor = b ^ result.at(0);
result.erase(result.begin());
result.push_back(0);
for (size_t i = 0; i < result.size(); i++)
result.at(i) ^= reedSolomonMultiply(divisor.at(i), factor);
}
return result;
}
uint8_t QrCode::reedSolomonMultiply(uint8_t x, uint8_t y) {
// Russian peasant multiplication
int z = 0;
for (int i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >> 7) * 0x11D);
z ^= ((y >> i) & 1) * x;
}
assert(z >> 8 == 0);
return static_cast<uint8_t>(z);
}
int QrCode::finderPenaltyCountPatterns(const std::array<int,7> &runHistory) const {
int n = runHistory.at(1);
assert(n <= size * 3);
bool core = n > 0 && runHistory.at(2) == n && runHistory.at(3) == n * 3 && runHistory.at(4) == n && runHistory.at(5) == n;
return (core && runHistory.at(0) >= n * 4 && runHistory.at(6) >= n ? 1 : 0)
+ (core && runHistory.at(6) >= n * 4 && runHistory.at(0) >= n ? 1 : 0);
}
int QrCode::finderPenaltyTerminateAndCount(bool currentRunColor, int currentRunLength, std::array<int,7> &runHistory) const {
if (currentRunColor) { // Terminate dark run
finderPenaltyAddHistory(currentRunLength, runHistory);
currentRunLength = 0;
}
currentRunLength += size; // Add light border to final run
finderPenaltyAddHistory(currentRunLength, runHistory);
return finderPenaltyCountPatterns(runHistory);
}
void QrCode::finderPenaltyAddHistory(int currentRunLength, std::array<int,7> &runHistory) const {
if (runHistory.at(0) == 0)
currentRunLength += size; // Add light border to initial run
std::copy_backward(runHistory.cbegin(), runHistory.cend() - 1, runHistory.end());
runHistory.at(0) = currentRunLength;
}
bool QrCode::getBit(long x, int i) {
return ((x >> i) & 1) != 0;
}
/*---- Tables of constants ----*/
const int QrCode::PENALTY_N1 = 3;
const int QrCode::PENALTY_N2 = 3;
const int QrCode::PENALTY_N3 = 40;
const int QrCode::PENALTY_N4 = 10;
const int8_t QrCode::ECC_CODEWORDS_PER_BLOCK[4][41] = {
// Version: (note that index 0 is for padding, and is set to an illegal value)
//0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
{-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, // Low
{-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28}, // Medium
{-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, // Quartile
{-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, // High
};
const int8_t QrCode::NUM_ERROR_CORRECTION_BLOCKS[4][41] = {
// Version: (note that index 0 is for padding, and is set to an illegal value)
//0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
{-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25}, // Low
{-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49}, // Medium
{-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68}, // Quartile
{-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81}, // High
};
data_too_long::data_too_long(const std::string &msg) :
std::length_error(msg) {}
/*---- Class BitBuffer ----*/
BitBuffer::BitBuffer()
: std::vector<bool>() {}
void BitBuffer::appendBits(std::uint32_t val, int len) {
if (len < 0 || len > 31 || val >> len != 0)
throw std::domain_error("Value out of range");
for (int i = len - 1; i >= 0; i--) // Append bit by bit
this->push_back(((val >> i) & 1) != 0);
}
}
+550
View File
@@ -0,0 +1,550 @@
// SPDX-License-Identifier: MIT
/*
* QR Code generator library (C++)
*
* Copyright (c) Project Nayuki. (MIT License)
* https://www.nayuki.io/page/qr-code-generator-library
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
* - The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
* - The Software is provided "as is", without warranty of any kind, express or
* implied, including but not limited to the warranties of merchantability,
* fitness for a particular purpose and noninfringement. In no event shall the
* authors or copyright holders be liable for any claim, damages or other
* liability, whether in an action of contract, tort or otherwise, arising from,
* out of or in connection with the Software or the use or other dealings in the
* Software.
*/
#pragma once
#include <array>
#include <cstdint>
#include <stdexcept>
#include <string>
#include <vector>
namespace qrcodegen {
/*
* A segment of character/binary/control data in a QR Code symbol.
* Instances of this class are immutable.
* The mid-level way to create a segment is to take the payload data
* and call a static factory function such as QrSegment::makeNumeric().
* The low-level way to create a segment is to custom-make the bit buffer
* and call the QrSegment() constructor with appropriate values.
* This segment class imposes no length restrictions, but QR Codes have restrictions.
* Even in the most favorable conditions, a QR Code can only hold 7089 characters of data.
* Any segment longer than this is meaningless for the purpose of generating QR Codes.
*/
class QrSegment final {
/*---- Public helper enumeration ----*/
/*
* Describes how a segment's data bits are interpreted. Immutable.
*/
public: class Mode final {
/*-- Constants --*/
public: static const Mode NUMERIC;
public: static const Mode ALPHANUMERIC;
public: static const Mode BYTE;
public: static const Mode KANJI;
public: static const Mode ECI;
/*-- Fields --*/
// The mode indicator bits, which is a uint4 value (range 0 to 15).
private: int modeBits;
// Number of character count bits for three different version ranges.
private: int numBitsCharCount[3];
/*-- Constructor --*/
private: Mode(int mode, int cc0, int cc1, int cc2);
/*-- Methods --*/
/*
* (Package-private) Returns the mode indicator bits, which is an unsigned 4-bit value (range 0 to 15).
*/
public: int getModeBits() const;
/*
* (Package-private) Returns the bit width of the character count field for a segment in
* this mode in a QR Code at the given version number. The result is in the range [0, 16].
*/
public: int numCharCountBits(int ver) const;
};
/*---- Static factory functions (mid level) ----*/
/*
* Returns a segment representing the given binary data encoded in
* byte mode. All input byte vectors are acceptable. Any text string
* can be converted to UTF-8 bytes and encoded as a byte mode segment.
*/
public: static QrSegment makeBytes(const std::vector<std::uint8_t> &data);
/*
* Returns a segment representing the given string of decimal digits encoded in numeric mode.
*/
public: static QrSegment makeNumeric(const char *digits);
/*
* Returns a segment representing the given text string encoded in alphanumeric mode.
* The characters allowed are: 0 to 9, A to Z (uppercase only), space,
* dollar, percent, asterisk, plus, hyphen, period, slash, colon.
*/
public: static QrSegment makeAlphanumeric(const char *text);
/*
* Returns a list of zero or more segments to represent the given text string. The result
* may use various segment modes and switch modes to optimize the length of the bit stream.
*/
public: static std::vector<QrSegment> makeSegments(const char *text);
/*
* Returns a segment representing an Extended Channel Interpretation
* (ECI) designator with the given assignment value.
*/
public: static QrSegment makeEci(long assignVal);
/*---- Public static helper functions ----*/
/*
* Tests whether the given string can be encoded as a segment in numeric mode.
* A string is encodable iff each character is in the range 0 to 9.
*/
public: static bool isNumeric(const char *text);
/*
* Tests whether the given string can be encoded as a segment in alphanumeric mode.
* A string is encodable iff each character is in the following set: 0 to 9, A to Z
* (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon.
*/
public: static bool isAlphanumeric(const char *text);
/*---- Instance fields ----*/
/* The mode indicator of this segment. Accessed through getMode(). */
private: const Mode *mode;
/* The length of this segment's unencoded data. Measured in characters for
* numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode.
* Always zero or positive. Not the same as the data's bit length.
* Accessed through getNumChars(). */
private: int numChars;
/* The data bits of this segment. Accessed through getData(). */
private: std::vector<bool> data;
/*---- Constructors (low level) ----*/
/*
* Creates a new QR Code segment with the given attributes and data.
* The character count (numCh) must agree with the mode and the bit buffer length,
* but the constraint isn't checked. The given bit buffer is copied and stored.
*/
public: QrSegment(const Mode &md, int numCh, const std::vector<bool> &dt);
/*
* Creates a new QR Code segment with the given parameters and data.
* The character count (numCh) must agree with the mode and the bit buffer length,
* but the constraint isn't checked. The given bit buffer is moved and stored.
*/
public: QrSegment(const Mode &md, int numCh, std::vector<bool> &&dt);
/*---- Methods ----*/
/*
* Returns the mode field of this segment.
*/
public: const Mode &getMode() const;
/*
* Returns the character count field of this segment.
*/
public: int getNumChars() const;
/*
* Returns the data bits of this segment.
*/
public: const std::vector<bool> &getData() const;
// (Package-private) Calculates the number of bits needed to encode the given segments at
// the given version. Returns a non-negative number if successful. Otherwise returns -1 if a
// segment has too many characters to fit its length field, or the total bits exceeds INT_MAX.
public: static int getTotalBits(const std::vector<QrSegment> &segs, int version);
/*---- Private constant ----*/
/* The set of all legal characters in alphanumeric mode, where
* each character value maps to the index in the string. */
private: static const char *ALPHANUMERIC_CHARSET;
};
/*
* A QR Code symbol, which is a type of two-dimension barcode.
* Invented by Denso Wave and described in the ISO/IEC 18004 standard.
* Instances of this class represent an immutable square grid of dark and light cells.
* The class provides static factory functions to create a QR Code from text or binary data.
* The class covers the QR Code Model 2 specification, supporting all versions (sizes)
* from 1 to 40, all 4 error correction levels, and 4 character encoding modes.
*
* Ways to create a QR Code object:
* - High level: Take the payload data and call QrCode::encodeText() or QrCode::encodeBinary().
* - Mid level: Custom-make the list of segments and call QrCode::encodeSegments().
* - Low level: Custom-make the array of data codeword bytes (including
* segment headers and final padding, excluding error correction codewords),
* supply the appropriate version number, and call the QrCode() constructor.
* (Note that all ways require supplying the desired error correction level.)
*/
class QrCode final {
/*---- Public helper enumeration ----*/
/*
* The error correction level in a QR Code symbol.
*/
public: enum class Ecc {
LOW = 0 , // The QR Code can tolerate about 7% erroneous codewords
MEDIUM , // The QR Code can tolerate about 15% erroneous codewords
QUARTILE, // The QR Code can tolerate about 25% erroneous codewords
HIGH , // The QR Code can tolerate about 30% erroneous codewords
};
// Returns a value in the range 0 to 3 (unsigned 2-bit integer).
private: static int getFormatBits(Ecc ecl);
/*---- Static factory functions (high level) ----*/
/*
* Returns a QR Code representing the given Unicode text string at the given error correction level.
* As a conservative upper bound, this function is guaranteed to succeed for strings that have 2953 or fewer
* UTF-8 code units (not Unicode code points) if the low error correction level is used. The smallest possible
* QR Code version is automatically chosen for the output. The ECC level of the result may be higher than
* the ecl argument if it can be done without increasing the version.
*/
public: static QrCode encodeText(const char *text, Ecc ecl);
/*
* Returns a QR Code representing the given binary data at the given error correction level.
* This function always encodes using the binary segment mode, not any text mode. The maximum number of
* bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output.
* The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version.
*/
public: static QrCode encodeBinary(const std::vector<std::uint8_t> &data, Ecc ecl);
/*---- Static factory functions (mid level) ----*/
/*
* Returns a QR Code representing the given segments with the given encoding parameters.
* The smallest possible QR Code version within the given range is automatically
* chosen for the output. Iff boostEcl is true, then the ECC level of the result
* may be higher than the ecl argument if it can be done without increasing the
* version. The mask number is either between 0 to 7 (inclusive) to force that
* mask, or -1 to automatically choose an appropriate mask (which may be slow).
* This function allows the user to create a custom sequence of segments that switches
* between modes (such as alphanumeric and byte) to encode text in less space.
* This is a mid-level API; the high-level API is encodeText() and encodeBinary().
*/
public: static QrCode encodeSegments(const std::vector<QrSegment> &segs, Ecc ecl,
int minVersion=1, int maxVersion=40, int mask=-1, bool boostEcl=true); // All optional parameters
/*---- Instance fields ----*/
// Immutable scalar parameters:
/* The version number of this QR Code, which is between 1 and 40 (inclusive).
* This determines the size of this barcode. */
private: int version;
/* The width and height of this QR Code, measured in modules, between
* 21 and 177 (inclusive). This is equal to version * 4 + 17. */
private: int size;
/* The error correction level used in this QR Code. */
private: Ecc errorCorrectionLevel;
/* The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive).
* Even if a QR Code is created with automatic masking requested (mask = -1),
* the resulting object still has a mask value between 0 and 7. */
private: int mask;
// Private grids of modules/pixels, with dimensions of size*size:
// The modules of this QR Code (false = light, true = dark).
// Immutable after constructor finishes. Accessed through getModule().
private: std::vector<std::vector<bool> > modules;
// Indicates function modules that are not subjected to masking. Discarded when constructor finishes.
private: std::vector<std::vector<bool> > isFunction;
/*---- Constructor (low level) ----*/
/*
* Creates a new QR Code with the given version number,
* error correction level, data codeword bytes, and mask number.
* This is a low-level API that most users should not use directly.
* A mid-level API is the encodeSegments() function.
*/
public: QrCode(int ver, Ecc ecl, const std::vector<std::uint8_t> &dataCodewords, int msk);
/*---- Public instance methods ----*/
/*
* Returns this QR Code's version, in the range [1, 40].
*/
public: int getVersion() const;
/*
* Returns this QR Code's size, in the range [21, 177].
*/
public: int getSize() const;
/*
* Returns this QR Code's error correction level.
*/
public: Ecc getErrorCorrectionLevel() const;
/*
* Returns this QR Code's mask, in the range [0, 7].
*/
public: int getMask() const;
/*
* Returns the color of the module (pixel) at the given coordinates, which is false
* for light or true for dark. The top left corner has the coordinates (x=0, y=0).
* If the given coordinates are out of bounds, then false (light) is returned.
*/
public: bool getModule(int x, int y) const;
/*---- Private helper methods for constructor: Drawing function modules ----*/
// Reads this object's version field, and draws and marks all function modules.
private: void drawFunctionPatterns();
// Draws two copies of the format bits (with its own error correction code)
// based on the given mask and this object's error correction level field.
private: void drawFormatBits(int msk);
// Draws two copies of the version bits (with its own error correction code),
// based on this object's version field, iff 7 <= version <= 40.
private: void drawVersion();
// Draws a 9*9 finder pattern including the border separator,
// with the center module at (x, y). Modules can be out of bounds.
private: void drawFinderPattern(int x, int y);
// Draws a 5*5 alignment pattern, with the center module
// at (x, y). All modules must be in bounds.
private: void drawAlignmentPattern(int x, int y);
// Sets the color of a module and marks it as a function module.
// Only used by the constructor. Coordinates must be in bounds.
private: void setFunctionModule(int x, int y, bool isDark);
// Returns the color of the module at the given coordinates, which must be in range.
private: bool module(int x, int y) const;
/*---- Private helper methods for constructor: Codewords and masking ----*/
// Returns a new byte string representing the given data with the appropriate error correction
// codewords appended to it, based on this object's version and error correction level.
private: std::vector<std::uint8_t> addEccAndInterleave(const std::vector<std::uint8_t> &data) const;
// Draws the given sequence of 8-bit codewords (data and error correction) onto the entire
// data area of this QR Code. Function modules need to be marked off before this is called.
private: void drawCodewords(const std::vector<std::uint8_t> &data);
// XORs the codeword modules in this QR Code with the given mask pattern.
// The function modules must be marked and the codeword bits must be drawn
// before masking. Due to the arithmetic of XOR, calling applyMask() with
// the same mask value a second time will undo the mask. A final well-formed
// QR Code needs exactly one (not zero, two, etc.) mask applied.
private: void applyMask(int msk);
// Calculates and returns the penalty score based on state of this QR Code's current modules.
// This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score.
private: long getPenaltyScore() const;
/*---- Private helper functions ----*/
// Returns an ascending list of positions of alignment patterns for this version number.
// Each position is in the range [0,177), and are used on both the x and y axes.
// This could be implemented as lookup table of 40 variable-length lists of unsigned bytes.
private: std::vector<int> getAlignmentPatternPositions() const;
// Returns the number of data bits that can be stored in a QR Code of the given version number, after
// all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8.
// The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table.
private: static int getNumRawDataModules(int ver);
// Returns the number of 8-bit data (i.e. not error correction) codewords contained in any
// QR Code of the given version number and error correction level, with remainder bits discarded.
// This stateless pure function could be implemented as a (40*4)-cell lookup table.
private: static int getNumDataCodewords(int ver, Ecc ecl);
// Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be
// implemented as a lookup table over all possible parameter values, instead of as an algorithm.
private: static std::vector<std::uint8_t> reedSolomonComputeDivisor(int degree);
// Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials.
private: static std::vector<std::uint8_t> reedSolomonComputeRemainder(const std::vector<std::uint8_t> &data, const std::vector<std::uint8_t> &divisor);
// Returns the product of the two given field elements modulo GF(2^8/0x11D).
// All inputs are valid. This could be implemented as a 256*256 lookup table.
private: static std::uint8_t reedSolomonMultiply(std::uint8_t x, std::uint8_t y);
// Can only be called immediately after a light run is added, and
// returns either 0, 1, or 2. A helper function for getPenaltyScore().
private: int finderPenaltyCountPatterns(const std::array<int,7> &runHistory) const;
// Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore().
private: int finderPenaltyTerminateAndCount(bool currentRunColor, int currentRunLength, std::array<int,7> &runHistory) const;
// Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore().
private: void finderPenaltyAddHistory(int currentRunLength, std::array<int,7> &runHistory) const;
// Returns true iff the i'th bit of x is set to 1.
private: static bool getBit(long x, int i);
/*---- Constants and tables ----*/
// The minimum version number supported in the QR Code Model 2 standard.
public: static constexpr int MIN_VERSION = 1;
// The maximum version number supported in the QR Code Model 2 standard.
public: static constexpr int MAX_VERSION = 40;
// For use in getPenaltyScore(), when evaluating which mask is best.
private: static const int PENALTY_N1;
private: static const int PENALTY_N2;
private: static const int PENALTY_N3;
private: static const int PENALTY_N4;
private: static const std::int8_t ECC_CODEWORDS_PER_BLOCK[4][41];
private: static const std::int8_t NUM_ERROR_CORRECTION_BLOCKS[4][41];
};
/*---- Public exception class ----*/
/*
* Thrown when the supplied data does not fit any QR Code version. Ways to handle this exception include:
* - Decrease the error correction level if it was greater than Ecc::LOW.
* - If the encodeSegments() function was called with a maxVersion argument, then increase
* it if it was less than QrCode::MAX_VERSION. (This advice does not apply to the other
* factory functions because they search all versions up to QrCode::MAX_VERSION.)
* - Split the text data into better or optimal segments in order to reduce the number of bits required.
* - Change the text or binary data to be shorter.
* - Change the text to fit the character set of a particular segment mode (e.g. alphanumeric).
* - Propagate the error upward to the caller/user.
*/
class data_too_long : public std::length_error {
public: explicit data_too_long(const std::string &msg);
};
/*
* An appendable sequence of bits (0s and 1s). Mainly used by QrSegment.
*/
class BitBuffer final : public std::vector<bool> {
/*---- Constructor ----*/
// Creates an empty bit buffer (length 0).
public: BitBuffer();
/*---- Method ----*/
// Appends the given number of low-order bits of the given value
// to this buffer. Requires 0 <= len <= 31 and val < 2^len.
public: void appendBits(std::uint32_t val, int len);
};
}
+609
View File
@@ -0,0 +1,609 @@
// Nubix — Home screen: tabs Cloud library (box-art grid) / My consoles (xHome) / Settings.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include <algorithm>
#include <cmath>
#include <ctime>
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "core/log.hpp"
#include "platform/platform.hpp"
#include "ui/app.hpp"
#include "ui/screens.hpp"
#include "ui/screens_internal.hpp"
#include "ui/ui.hpp"
namespace xc {
namespace {
// grid geometry (logical px)
constexpr int kGridLeft = 96;
constexpr int kGridTop = 300;
constexpr int kCols = 8;
constexpr int kGap = 30;
constexpr int kTileW = (Ui::kWidth - 2 * kGridLeft - (kCols - 1) * kGap) / kCols; // 189
constexpr int kImageH = kTileW * 4 / 3; // poster 3:4 (252)
constexpr int kTileH = kImageH + 58;
constexpr int kRowPitch = kTileH + 30; // 340
constexpr int kViewH = 2 * kRowPitch; // two full rows
constexpr int kGridBottom = kGridTop + kViewH;
enum Tab { TabLibrary, TabConsoles, TabSettings, TabCount };
enum Filter { FilterAll, FilterGamePass, FilterOwned, FilterFree, FilterCount };
const char* tabName(int t) {
switch (t) {
case TabLibrary: return "Cloud library";
case TabConsoles: return "My consoles";
default: return "Settings";
}
}
const char* filterName(int f) {
switch (f) {
case FilterGamePass: return "Game Pass";
case FilterOwned: return "Owned";
case FilterFree: return "Free to play";
default: return "All games";
}
}
std::string normalizeUrl(const std::string& u) {
if (u.rfind("//", 0) == 0) return "https:" + u;
return u;
}
struct HomeState {
// session / tokens
enum Phase { Idle, Authorizing, Ready, AuthFailed } phase = Idle;
std::string authError;
bool hasCloud = false, hasHome = false;
// library
bool titlesLoading = false, titlesLoaded = false;
std::string titlesError;
std::vector<Title> titles;
uint64_t titlesVersion = 0; // bumped whenever `titles` is replaced
// consoles
bool consolesLoading = false, consolesLoaded = false;
std::string consolesError;
std::vector<Console> consoles;
};
class HomeScreen : public Screen {
public:
void onEnter(App& app) override {
if (st_->phase == HomeState::Idle || st_->phase == HomeState::AuthFailed) authorize(app);
}
void onNav(App& app, NavKey key) override {
if (key == NavKey::TabLeft || key == NavKey::TabRight) {
tab_ = (tab_ + (key == NavKey::TabRight ? 1 : TabCount - 1)) % TabCount;
if (tab_ == TabSettings) settings_.resetFocus();
return;
}
if (st_->phase == HomeState::AuthFailed) {
if (key == NavKey::Accept) authorize(app);
else if (key == NavKey::Option) app.resetTo(makeLoginScreen());
else if (key == NavKey::Back) app.push(ui::makeExitConfirm());
return;
}
switch (tab_) {
case TabLibrary: navLibrary(app, key); break;
case TabConsoles: navConsoles(app, key); break;
case TabSettings:
if (!settings_.onNav(app, key) && key == NavKey::Back) app.push(ui::makeExitConfirm());
break;
}
}
void update(App& app, uint32_t dtMs) override {
(void)app;
// smooth grid scroll
const auto& list = visible();
const int rows = static_cast<int>((list.size() + kCols - 1) / kCols);
const int row = focusTitle_ / kCols;
// minimal scrolling: keep the focused row fully visible, rows snap to the grid
const float rowTop = static_cast<float>(row * kRowPitch);
if (rowTop < targetScroll_) targetScroll_ = rowTop;
if (rowTop + kRowPitch > targetScroll_ + kViewH) targetScroll_ = rowTop + kRowPitch - kViewH;
const float maxScroll = static_cast<float>(std::max(0, rows * kRowPitch - kViewH));
targetScroll_ = std::max(0.0f, std::min(maxScroll, targetScroll_));
const float k = std::min(1.0f, dtMs * 0.014f);
scroll_ += (targetScroll_ - scroll_) * k;
if (std::fabs(targetScroll_ - scroll_) < 0.5f) scroll_ = targetScroll_;
}
void render(App& app, Ui& ui) override {
ui::background(ui);
renderHeader(app, ui);
const uint64_t now = platform::monotonicMs();
if (st_->phase == HomeState::Authorizing || st_->phase == HomeState::Idle) {
ui.spinner(Ui::kWidth / 2, 500, 52, now);
ui.text("Connecting to Xbox\xE2\x80\xA6", Ui::kWidth / 2, 590, 32, colors::Text, Align::Center);
ui::hintBar(app, ui, {{PadIcon::Circle, "Exit"}});
return;
}
if (st_->phase == HomeState::AuthFailed) {
ui::errorBadge(ui, Ui::kWidth / 2, 380, 48);
ui.text("Couldn't connect to Xbox", Ui::kWidth / 2, 460, 42, colors::Text, Align::Center, true);
ui.textWrapped(st_->authError, (Ui::kWidth - 1100) / 2, 532, 1100, 28, colors::TextDim);
ui::hintBar(app, ui,
{{PadIcon::Cross, "Retry"}, {PadIcon::Triangle, "Sign in again"}, {PadIcon::Circle, "Exit"}});
return;
}
switch (tab_) {
case TabLibrary: renderLibrary(app, ui, now); break;
case TabConsoles: renderConsoles(app, ui, now); break;
case TabSettings:
settings_.render(app, ui, 200);
ui::hintBar(app, ui, {{PadIcon::L1, ""}, {PadIcon::R1, "Switch tab"}, {PadIcon::Cross, "Change"},
{PadIcon::Circle, "Exit"}});
break;
}
}
private:
// ---- data loading -----------------------------------------------------------------------
void authorize(App& app) {
st_->phase = HomeState::Authorizing;
std::weak_ptr<HomeState> w = st_;
App* a = &app;
app.runAsync([a, w] {
std::string err;
const bool ok = a->auth().ensureFresh(err);
const bool loggedIn = a->auth().isLoggedIn();
const Tokens t = a->config().tokens();
const bool hasCloud = !t.xcloudGs.empty() || !t.xcloudF2pGs.empty();
const bool hasHome = !t.xhomeGs.empty();
a->postToMain([a, w, ok, loggedIn, hasCloud, hasHome, err] {
auto s = w.lock();
if (!s) return;
if (!ok && !loggedIn) {
a->toast("Your sign-in expired. Please sign in again.");
a->resetTo(makeLoginScreen());
return;
}
if (!ok) {
s->phase = HomeState::AuthFailed;
s->authError = err.empty() ? std::string("Token refresh failed.") : err;
if (!platform::networkAvailable()) s->authError += "\nNo network connection detected.";
return;
}
s->phase = HomeState::Ready;
s->hasCloud = hasCloud;
s->hasHome = hasHome;
});
if (ok) {
loadTitles(a, w);
loadConsoles(a, w);
}
});
}
// Worker thread: the server refused a gsToken the stored expiry still called valid. Mark it
// stale, re-authorize and run `retry` once. False (err set) if re-authorizing failed.
static bool reauthorizeAndRetry(App* a, std::string& err, const std::function<bool()>& retry) {
a->auth().invalidateStreaming();
std::string aerr;
if (!a->auth().ensureFresh(aerr)) {
err = "Sign-in failed: " + aerr;
return false;
}
err.clear();
return retry();
}
// Main thread: the stored login died on a worker (refresh token rejected).
static void goToLogin(App* a) {
a->toast("Your sign-in expired. Please sign in again.");
a->resetTo(makeLoginScreen());
}
// Runs on the worker (called from a worker job) or schedules itself.
static void loadTitles(App* a, std::weak_ptr<HomeState> w, bool force = false) {
a->postToMain([w] {
if (auto s = w.lock()) {
s->titlesLoading = true;
s->titlesError.clear();
}
});
a->runAsync([a, w, force] {
std::vector<Title> list;
std::string err;
bool ok = true;
auto fetch = [&](bool* authRejected) {
const Tokens t = a->config().tokens();
if (t.xcloudGs.empty() && t.xcloudF2pGs.empty()) return true;
return a->catalog().fetchCloudTitles(list, err, authRejected, force || authRejected == nullptr);
};
bool authRejected = false;
ok = fetch(&authRejected);
if (authRejected) ok = reauthorizeAndRetry(a, err, [&] { return fetch(nullptr); });
const bool signedOut = !a->auth().isLoggedIn();
a->postToMain([a, w, ok, signedOut, list = std::move(list), err]() mutable {
auto s = w.lock();
if (!s) return;
if (signedOut) {
goToLogin(a);
return;
}
s->titlesLoading = false;
s->titlesLoaded = true;
if (ok) {
s->titles = std::move(list);
++s->titlesVersion;
} else {
s->titlesError = err.empty() ? std::string("Could not load the cloud library.") : err;
}
});
});
}
static void loadConsoles(App* a, std::weak_ptr<HomeState> w) {
a->postToMain([w] {
if (auto s = w.lock()) {
s->consolesLoading = true;
s->consolesError.clear();
}
});
a->runAsync([a, w] {
std::vector<Console> list;
std::string err;
auto fetch = [&](bool* authRejected) {
if (a->config().tokens().xhomeGs.empty()) return true;
return a->catalog().fetchConsoles(list, err, authRejected);
};
bool authRejected = false;
bool ok = fetch(&authRejected);
if (authRejected) ok = reauthorizeAndRetry(a, err, [&] { return fetch(nullptr); });
const bool signedOut = !a->auth().isLoggedIn();
a->postToMain([a, w, ok, signedOut, list = std::move(list), err]() mutable {
auto s = w.lock();
if (!s) return;
if (signedOut) {
goToLogin(a);
return;
}
s->consolesLoading = false;
s->consolesLoaded = true;
if (ok) {
s->consoles = std::move(list);
} else {
s->consolesError = err.empty() ? std::string("Could not load your consoles.") : err;
}
});
});
}
void refresh(App& app) {
std::weak_ptr<HomeState> w = st_;
App* a = &app;
if (tab_ == TabLibrary && !st_->titlesLoading) {
st_->titlesLoading = true;
app.runAsync([a, w] {
std::string err;
a->auth().ensureFresh(err);
loadTitles(a, w, /*force=*/true);
});
} else if (tab_ == TabConsoles && !st_->consolesLoading) {
st_->consolesLoading = true;
app.runAsync([a, w] {
std::string err;
a->auth().ensureFresh(err);
loadConsoles(a, w);
});
}
}
// ---- library ------------------------------------------------------------------------------
const std::vector<const Title*>& visible() {
// rebuild the filtered view when the source or filter changed
if (viewVersion_ != st_->titlesVersion || viewFilter_ != filter_) {
view_.clear();
for (const auto& t : st_->titles) {
if (filter_ == FilterGamePass && !t.gamePass) continue;
// Owned: playable through a purchase ("stream your own game"), not via the plan or ads.
if (filter_ == FilterOwned && !(t.hasEntitlement && !t.gamePass && !t.f2pOnly)) continue;
if (filter_ == FilterFree && !t.f2p) continue;
view_.push_back(&t);
}
viewVersion_ = st_->titlesVersion;
viewFilter_ = filter_;
if (focusTitle_ >= static_cast<int>(view_.size())) focusTitle_ = std::max(0, static_cast<int>(view_.size()) - 1);
}
return view_;
}
void navLibrary(App& app, NavKey key) {
const auto& list = visible();
const int n = static_cast<int>(list.size());
switch (key) {
case NavKey::Left:
if (n && focusTitle_ % kCols > 0) --focusTitle_;
break;
case NavKey::Right:
if (n && focusTitle_ % kCols < kCols - 1 && focusTitle_ + 1 < n) ++focusTitle_;
break;
case NavKey::Up:
if (focusTitle_ >= kCols) focusTitle_ -= kCols;
break;
case NavKey::Down:
if (focusTitle_ + kCols < n) focusTitle_ += kCols;
else if (focusTitle_ / kCols < (n - 1) / kCols) focusTitle_ = n - 1; // partial last row
break;
case NavKey::Accept:
if (n > 0) {
const Title& t = *list[focusTitle_];
app.push(makeConnectingScreen(SessionKind::Cloud, t.titleId, t.name.empty() ? t.titleId : t.name, t.f2pOnly));
} else if (!st_->titlesError.empty()) {
refresh(app);
}
break;
case NavKey::Option:
filter_ = (filter_ + 1) % FilterCount;
focusTitle_ = 0;
scroll_ = targetScroll_ = 0;
break;
case NavKey::Back: app.push(ui::makeExitConfirm()); break;
default: break;
}
}
void renderLibrary(App& app, Ui& ui, uint64_t now) {
const HomeState& s = *st_;
const auto& list = visible();
// sub header: filter chips + count
int x = kGridLeft;
for (int f = 0; f < FilterCount; ++f) {
const bool on = f == filter_;
const int w = ui.textWidth(filterName(f), 24, on) + 44;
ui.rect(x, 150, w, 48, on ? colors::Accent : colors::Panel, true, 24);
ui.text(filterName(f), x + w / 2, 150 + (48 - ui.lineHeight(24, on)) / 2, 24, on ? colors::White : colors::TextDim,
Align::Center, on);
x += w + 14;
}
if (s.titlesLoaded && s.titlesError.empty()) {
const std::string count = std::to_string(list.size()) + (list.size() == 1 ? " game" : " games");
ui.text(count, x + 16, 150 + (48 - ui.lineHeight(24)) / 2, 24, colors::TextDim);
}
if (s.titlesLoading && !s.titles.empty()) ui.spinner(Ui::kWidth - kGridLeft - 24, 174, 18, now);
// focused title details
if (!list.empty()) {
const Title& t = *list[focusTitle_];
int w = ui.textEllipsized(t.name, kGridLeft, 220, 1300, 34, colors::Text, Align::Left, true);
if (!t.publisher.empty())
ui.textEllipsized(t.publisher, kGridLeft + w + 20, 228, 1700 - w - 20, 24, colors::TextDim);
}
if (!s.hasCloud && s.titlesLoaded) {
emptyState(ui, "No cloud gaming access",
"This account has no Xbox Cloud Gaming access (a Game Pass plan with cloud gaming, or the "
"free-to-play offering in your region). You can still stream from your own console under My consoles.");
hintsLibrary(app, ui, false);
return;
}
if (!s.titlesLoaded || (s.titlesLoading && s.titles.empty())) {
ui.spinner(Ui::kWidth / 2, 560, 48, now);
ui.text("Loading your cloud library\xE2\x80\xA6", Ui::kWidth / 2, 640, 30, colors::TextDim, Align::Center);
hintsLibrary(app, ui, false);
return;
}
if (!s.titlesError.empty()) {
emptyState(ui, "Couldn't load the library", s.titlesError);
ui::hintBar(app, ui, {{PadIcon::Cross, "Retry"}, {PadIcon::R1, "Next tab"}, {PadIcon::Circle, "Exit"}});
return;
}
if (list.empty()) {
emptyState(ui, "Nothing here", filter_ == FilterAll ? "No cloud titles are available for this account."
: "No titles match this filter. Press Y / \xE2\x96\xB3 to change it.");
hintsLibrary(app, ui, false);
return;
}
// grid
ui.setClip(0, kGridTop - 12, Ui::kWidth, kViewH + 8);
const int scroll = static_cast<int>(std::lround(scroll_));
const int firstRow = std::max(0, scroll / kRowPitch);
const int lastRow = (scroll + kViewH) / kRowPitch + 1; // +1 row prefetch
const int n = static_cast<int>(list.size());
for (int row = firstRow; row <= lastRow; ++row) {
for (int col = 0; col < kCols; ++col) {
const int i = row * kCols + col;
if (i >= n) break;
const Title& t = *list[i];
const int tx = kGridLeft + col * (kTileW + kGap);
const int ty = kGridTop + row * kRowPitch - scroll;
SDL_Texture* img = ui.remoteImage(normalizeUrl(t.imageUrl), kTileW, kImageH);
if (ty >= kGridBottom) continue; // prefetch only
std::string badge;
if (t.gamePass) badge = "Game Pass";
else if (t.hasEntitlement && !t.f2pOnly) badge = "Owned";
else if (t.f2p) badge = "Free";
ui.tile(img, t.name, badge, tx, ty, kTileW, kTileH, i == focusTitle_);
}
}
ui.clearClip();
// scroll indicator
const int rows = (n + kCols - 1) / kCols;
const int totalH = rows * kRowPitch;
const int viewH = kViewH - 30;
if (totalH > kViewH) {
const int barH = std::max(60, viewH * kViewH / totalH);
const int barY = kGridTop + static_cast<int>((viewH - barH) * (scroll_ / std::max(1, totalH - kViewH)));
ui.rect(Ui::kWidth - 40, kGridTop, 6, viewH, withAlpha(colors::Panel, 200), true, 3);
ui.rect(Ui::kWidth - 40, std::min(kGridTop + viewH - barH, barY), 6, barH, colors::AccentBright, true, 3);
}
hintsLibrary(app, ui, true);
}
void hintsLibrary(App& app, Ui& ui, bool canPlay) {
std::vector<ui::Hint> h;
h.push_back({PadIcon::L1, ""});
h.push_back({PadIcon::R1, "Switch tab"});
h.push_back({PadIcon::Triangle, "Filter"});
if (canPlay) h.push_back({PadIcon::Cross, "Play"});
h.push_back({PadIcon::Circle, "Exit"});
ui::hintBar(app, ui, h);
}
void emptyState(Ui& ui, const std::string& title, const std::string& msg) {
ui.text(title, Ui::kWidth / 2, 470, 40, colors::Text, Align::Center, true);
const int w = 1100;
ui.textWrapped(msg, (Ui::kWidth - w) / 2, 540, w, 28, colors::TextDim);
}
// ---- consoles -----------------------------------------------------------------------------
void navConsoles(App& app, NavKey key) {
const int n = static_cast<int>(st_->consoles.size());
switch (key) {
case NavKey::Left:
if (focusConsole_ > 0) --focusConsole_;
break;
case NavKey::Right:
if (focusConsole_ + 1 < n) ++focusConsole_;
break;
case NavKey::Up:
if (focusConsole_ >= 3) focusConsole_ -= 3;
break;
case NavKey::Down:
if (focusConsole_ + 3 < n) focusConsole_ += 3;
break;
case NavKey::Accept:
if (n > 0) {
const Console& c = st_->consoles[focusConsole_];
app.push(makeConnectingScreen(SessionKind::Home, c.serverId, c.name.empty() ? "your console" : c.name));
} else {
refresh(app);
}
break;
case NavKey::Option: refresh(app); break;
case NavKey::Back: app.push(ui::makeExitConfirm()); break;
default: break;
}
}
void renderConsoles(App& app, Ui& ui, uint64_t now) {
const HomeState& s = *st_;
ui.text("Stream from your own Xbox (Remote Play)", kGridLeft, 160, 28, colors::TextDim);
if (s.consolesLoading && !s.consoles.empty()) ui.spinner(Ui::kWidth - kGridLeft - 24, 176, 18, now);
std::vector<ui::Hint> hints = {{PadIcon::L1, ""}, {PadIcon::R1, "Switch tab"}, {PadIcon::Triangle, "Refresh"}};
if (!s.hasHome && s.consolesLoaded) {
emptyState(ui, "Remote Play unavailable",
"This account did not receive an Xbox Remote Play token. Sign in with the account that owns "
"the console.");
} else if (!s.consolesLoaded || (s.consolesLoading && s.consoles.empty())) {
ui.spinner(Ui::kWidth / 2, 520, 48, now);
ui.text("Looking for your consoles\xE2\x80\xA6", Ui::kWidth / 2, 600, 30, colors::TextDim, Align::Center);
} else if (!s.consolesError.empty()) {
emptyState(ui, "Couldn't load your consoles", s.consolesError);
hints.push_back({PadIcon::Cross, "Retry"});
} else if (s.consoles.empty()) {
emptyState(ui, "No consoles found",
"Turn on remote features on your Xbox: Settings \xE2\x80\xBA Devices & connections \xE2\x80\xBA "
"Remote features, and use the \"Sleep\" power mode.");
} else {
const int cw = 520, ch = 300, gap = 40;
for (size_t i = 0; i < s.consoles.size(); ++i) {
const Console& c = s.consoles[i];
const int col = static_cast<int>(i % 3), row = static_cast<int>(i / 3);
const int x = kGridLeft + col * (cw + gap), y = 240 + row * (ch + gap);
if (y > 1000) break;
const bool f = static_cast<int>(i) == focusConsole_;
if (f) ui.outline(x - 6, y - 6, cw + 12, ch + 12, colors::AccentBright, 4, 26);
ui.rect(x, y, cw, ch, f ? colors::PanelHi : colors::Panel, true, 20);
// console glyph: tower with a power light
const int gx = x + 48, gy = y + 60;
ui.rect(gx, gy, 96, 150, Color{20, 22, 21, 255}, true, 12);
ui.outline(gx, gy, 96, 150, Color{70, 78, 74, 255}, 2, 12);
ui.circle(gx + 48, gy + 26, 14, Color{40, 44, 42, 255});
const bool on = c.powerState == "On";
const bool standby = c.powerState == "ConnectedStandby";
const Color dot = on ? colors::AccentBright : standby ? colors::Warning : colors::TextDim;
ui.circle(gx + 18, gy + 130, 6, dot);
const int tx = x + 180;
ui.textEllipsized(c.name.empty() ? "Xbox" : c.name, tx, y + 64, cw - 200, 34, colors::Text, Align::Left,
true);
ui.text(ui::prettyConsoleType(c.consoleType), tx, y + 116, 26, colors::TextDim);
const std::string ps = on ? "On" : standby ? "Standby (instant-on)" : (c.powerState.empty() ? "Unknown" : c.powerState);
ui.circle(tx + 8, y + 186, 7, dot);
ui.text(ps, tx + 26, y + 186 - ui.lineHeight(24) / 2, 24, dot);
if (f) ui.text("Press to stream", tx, y + 230, 22, withAlpha(colors::TextDim, 200));
}
hints.push_back({PadIcon::Cross, "Stream"});
}
hints.push_back({PadIcon::Circle, "Exit"});
ui::hintBar(app, ui, hints);
}
// ---- header -------------------------------------------------------------------------------
void renderHeader(App& app, Ui& ui) {
ui::logo(ui, 96, 40, true);
// tabs centred
const int ts = 30;
int total = 0;
for (int t = 0; t < TabCount; ++t) total += ui.textWidth(tabName(t), ts, true) + (t ? 64 : 0);
int x = (Ui::kWidth - total) / 2;
const bool kb = app.gamepad().lastInputKeyboard();
if (kb) ui.keyCap(ui::keyFor(PadIcon::L1), x - 76, 66, 40);
else ui.padIcon(PadIcon::L1, x - 76, 66, 40);
for (int t = 0; t < TabCount; ++t) {
const bool on = t == tab_;
const int w = ui.textWidth(tabName(t), ts, true);
ui.text(tabName(t), x + w / 2, 66 - ui.lineHeight(ts, true) / 2, ts, on ? colors::White : colors::TextDim,
Align::Center, true);
if (on) ui.rect(x, 96, w, 5, colors::AccentBright, true, 2);
x += w + 64;
}
x -= 64;
if (kb) ui.keyCap(ui::keyFor(PadIcon::R1), x + 24, 66, 40);
else ui.padIcon(PadIcon::R1, x + 24, 66, 40);
// gamertag + clock
char clock[16] = {0};
std::time_t tt = std::time(nullptr);
std::tm lt{};
if (localtime_r(&tt, &lt)) std::strftime(clock, sizeof(clock), "%H:%M", &lt);
int rx = Ui::kWidth - 96;
rx -= ui.text(clock, rx, 66 - ui.lineHeight(28) / 2, 28, colors::Text, Align::Right);
const std::string tag = app.config().gamertag();
if (!tag.empty()) {
rx -= 32;
const int tw = ui.textWidth(tag, 26, true);
ui.text(tag, rx, 66 - ui.lineHeight(26, true) / 2, 26, colors::Text, Align::Right, true);
const int ax = rx - tw - 38;
ui.circle(ax, 66, 22, colors::Accent);
ui.text(tag.substr(0, 1), ax, 66 - ui.lineHeight(24, true) / 2, 24, colors::White, Align::Center, true);
}
}
std::shared_ptr<HomeState> st_ = std::make_shared<HomeState>();
ui::SettingsPanel settings_;
int tab_ = TabLibrary;
int filter_ = FilterAll;
int focusTitle_ = 0, focusConsole_ = 0;
float scroll_ = 0, targetScroll_ = 0;
std::vector<const Title*> view_;
uint64_t viewVersion_ = static_cast<uint64_t>(-1);
int viewFilter_ = -1;
};
} // namespace
std::unique_ptr<Screen> makeHomeScreen() { return std::make_unique<HomeScreen>(); }
} // namespace xc
+261
View File
@@ -0,0 +1,261 @@
// Nubix — Login screen: Microsoft device-code flow with QR code and countdown.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include <algorithm>
#include <memory>
#include "core/log.hpp"
#include "platform/platform.hpp"
#include "ui/app.hpp"
#include "ui/screens.hpp"
#include "ui/screens_internal.hpp"
#include "ui/ui.hpp"
namespace xc {
namespace {
constexpr int kMaxTransientPollErrors = 5;
struct LoginState {
enum Phase { Idle, Requesting, Code, Authorizing, Error } phase = Idle;
DeviceCode dc;
uint64_t expiresAt = 0, nextPoll = 0;
bool polling = false;
int transientErrors = 0;
std::string error;
};
std::string displayUri(const std::string& uri) {
std::string s = uri;
for (const char* p : {"https://", "http://"})
if (s.rfind(p, 0) == 0) s = s.substr(std::char_traits<char>::length(p));
if (s.rfind("www.", 0) == 0) s = s.substr(4);
while (!s.empty() && s.back() == '/') s.pop_back();
return s;
}
class LoginScreen : public Screen {
public:
void onEnter(App& app) override {
if (st_->phase == LoginState::Idle) requestCode(app);
}
void onNav(App& app, NavKey key) override {
switch (key) {
case NavKey::Accept:
if (st_->phase == LoginState::Error || st_->phase == LoginState::Code) requestCode(app);
break;
case NavKey::Back: app.push(ui::makeExitConfirm()); break;
default: break;
}
}
void update(App& app, uint32_t dtMs) override {
(void)dtMs;
LoginState& s = *st_;
if (s.phase != LoginState::Code) return;
const uint64_t now = platform::monotonicMs();
if (now >= s.expiresAt && !s.polling) {
XC_LOGI("login: device code expired, requesting a new one");
requestCode(app);
return;
}
if (!s.polling && now >= s.nextPoll) poll(app);
}
void render(App& app, Ui& ui) override {
const LoginState& s = *st_;
const uint64_t now = platform::monotonicMs();
ui::background(ui);
ui::logo(ui, 96, 56);
// Shown on every sign-in screen: this is a third-party client.
ui.text("Unofficial client \xC2\xB7 not affiliated with Microsoft or Sony", 96,
1022 - ui.lineHeight(20) / 2, 20, withAlpha(colors::TextDim, 200)); // hint bar row
switch (s.phase) {
case LoginState::Idle:
case LoginState::Requesting:
ui.spinner(Ui::kWidth / 2, 470, 56, now);
ui.text("Contacting Microsoft\xE2\x80\xA6", Ui::kWidth / 2, 570, 34, colors::Text, Align::Center);
ui::hintBar(app, ui, {{PadIcon::Circle, "Exit"}});
return;
case LoginState::Authorizing:
ui.spinner(Ui::kWidth / 2, 470, 56, now);
ui.text("Signing in to Xbox\xE2\x80\xA6", Ui::kWidth / 2, 570, 34, colors::Text, Align::Center);
ui.text("Getting your cloud gaming and remote play access", Ui::kWidth / 2, 626, 26, colors::TextDim,
Align::Center);
ui::hintBar(app, ui, {{PadIcon::Circle, "Exit"}});
return;
case LoginState::Error: {
ui::errorBadge(ui, Ui::kWidth / 2, 380, 52);
ui.text("Sign-in failed", Ui::kWidth / 2, 470, 44, colors::Text, Align::Center, true);
const int w = 1100;
ui.textWrapped(s.error, (Ui::kWidth - w) / 2, 548, w, 28, colors::TextDim);
ui::hintBar(app, ui, {{PadIcon::Cross, "Try again"}, {PadIcon::Circle, "Exit"}});
return;
}
case LoginState::Code: break;
}
// ---- code view ----
const int lx = 160;
ui.text("Sign in", lx, 190, 64, colors::Text, Align::Left, true);
ui.text("Use the Microsoft account with your Game Pass plan or your Xbox consoles.", lx, 278, 26,
colors::TextDim);
auto step = [&](int n, int y, const std::string& caption) {
ui.circle(lx + 24, y + 24, 24, colors::Accent);
ui.text(std::to_string(n), lx + 24, y + 24 - ui.lineHeight(26, true) / 2, 26, colors::White, Align::Center,
true);
ui.text(caption, lx + 72, y + 24 - ui.lineHeight(28) / 2, 28, colors::TextDim);
};
step(1, 370, "On your phone or computer, go to");
ui.text(displayUri(s.dc.verificationUri), lx + 72, 420, 50, colors::White, Align::Left, true);
step(2, 530, "Enter this code");
// code box with letter-spaced characters
const int cs = 84, spacing = 16;
int codeW = 0;
for (char c : s.dc.userCode) codeW += ui.textWidth(std::string(1, c), cs, true) + spacing;
codeW = std::max(0, codeW - spacing);
const int boxX = lx + 72, boxY = 586, boxW = std::max(520, codeW + 96), boxH = 148;
ui.rect(boxX, boxY, boxW, boxH, colors::Panel, true, 20);
ui.outline(boxX, boxY, boxW, boxH, withAlpha(colors::AccentBright, 140), 3, 20);
int cx = boxX + (boxW - codeW) / 2;
const int cy = boxY + (boxH - ui.lineHeight(cs, true)) / 2;
for (char c : s.dc.userCode) {
const std::string ch(1, c);
ui.text(ch, cx, cy, cs, colors::White, Align::Left, true);
cx += ui.textWidth(ch, cs, true) + spacing;
}
// waiting + countdown
ui.spinner(lx + 96, 820, 24, now);
ui.text("Waiting for you to sign in\xE2\x80\xA6", lx + 140, 820 - ui.lineHeight(28) / 2, 28, colors::Text);
const int64_t left = s.expiresAt > now ? static_cast<int64_t>((s.expiresAt - now) / 1000) : 0;
ui.text("Code expires in " + ui::formatDuration(left) + " \xC2\xB7 a new one is created automatically",
lx + 72, 868, 24, colors::TextDim);
// QR
const int qs = 420, qx = 1260, qy = 300;
ui.qr(s.dc.qrUrl.empty() ? s.dc.verificationUri : s.dc.qrUrl, qx, qy, qs);
ui.text("Scan to open the sign-in page", qx + qs / 2, qy + qs + 40, 26, colors::TextDim, Align::Center);
ui.text("(the code is filled in for you)", qx + qs / 2, qy + qs + 78, 22, withAlpha(colors::TextDim, 190),
Align::Center);
ui::hintBar(app, ui, {{PadIcon::Cross, "New code"}, {PadIcon::Circle, "Exit"}});
}
private:
void requestCode(App& app) {
LoginState& s = *st_;
s.phase = LoginState::Requesting;
s.error.clear();
s.transientErrors = 0;
const uint64_t gen = ++generation_;
std::weak_ptr<LoginState> w = st_;
App* a = &app;
app.runAsync([a, w, gen, this] {
DeviceCode dc;
std::string err;
const bool ok = a->auth().requestDeviceCode(dc, err);
a->postToMain([a, w, gen, ok, dc, err, this] {
auto s = w.lock();
if (!s || gen != generation_) return; // screen gone or superseded
if (ok) {
const uint64_t now = platform::monotonicMs();
s->dc = dc;
s->phase = LoginState::Code;
s->expiresAt = now + static_cast<uint64_t>(std::max(30, dc.expiresIn)) * 1000;
s->nextPoll = now + static_cast<uint64_t>(std::max(1, dc.interval)) * 1000;
s->polling = false;
XC_LOGI("login: user code %s at %s", dc.userCode.c_str(), dc.verificationUri.c_str());
} else {
s->phase = LoginState::Error;
s->error = "Could not get a sign-in code: " + (err.empty() ? std::string("unknown error") : err);
if (!platform::networkAvailable()) s->error += "\nThe console does not seem to be connected to a network.";
(void)a;
}
});
});
}
void poll(App& app) {
LoginState& s = *st_;
s.polling = true;
const uint64_t gen = generation_;
const DeviceCode dc = s.dc;
std::weak_ptr<LoginState> w = st_;
App* a = &app;
app.runAsync([a, w, gen, dc, this] {
std::string err;
const PollResult r = a->auth().pollToken(dc, err);
a->postToMain([a, w, gen, r, err, this] {
auto s = w.lock();
if (!s || gen != generation_) return;
s->polling = false;
const uint64_t now = platform::monotonicMs();
switch (r) {
case PollResult::Pending:
s->transientErrors = 0;
if (err == "slow_down") s->dc.interval += 5; // RFC 8628 §3.5
s->nextPoll = now + static_cast<uint64_t>(std::max(1, s->dc.interval)) * 1000;
break;
case PollResult::Success: authorize(*a); break;
case PollResult::Expired: requestCode(*a); break;
case PollResult::Denied:
s->phase = LoginState::Error;
s->error = "The sign-in request was declined.";
break;
case PollResult::Error:
if (++s->transientErrors <= kMaxTransientPollErrors) {
XC_LOGW("login: poll error (%d): %s", s->transientErrors, err.c_str());
s->nextPoll = now + static_cast<uint64_t>(std::max(2, s->dc.interval * 2)) * 1000;
} else {
s->phase = LoginState::Error;
s->error = err.empty() ? std::string("Polling the sign-in status failed.") : err;
}
break;
}
});
});
}
void authorize(App& app) {
st_->phase = LoginState::Authorizing;
const uint64_t gen = generation_;
std::weak_ptr<LoginState> w = st_;
App* a = &app;
app.runAsync([a, w, gen, this] {
std::string err;
const bool ok = a->auth().authorizeStreaming(err);
const std::string tag = ok ? a->config().gamertag() : std::string();
a->postToMain([a, w, gen, ok, err, tag, this] {
auto s = w.lock();
if (!s || gen != generation_) return;
if (ok) {
const std::string msg = tag.empty() ? std::string("Signed in") : "Signed in as " + tag;
a->toast(msg);
platform::notify("Nubix: " + msg);
a->replace(makeHomeScreen());
} else {
s->phase = LoginState::Error;
s->error = "Your Microsoft account is signed in, but Xbox streaming access could not be "
"obtained: " +
(err.empty() ? std::string("unknown error") : err);
}
});
});
}
std::shared_ptr<LoginState> st_ = std::make_shared<LoginState>();
// Bumped on every new code request; stale async results are ignored. Only touched on the
// main thread (inside postToMain callbacks after the weak_ptr check, the screen is alive).
uint64_t generation_ = 0;
};
} // namespace
std::unique_ptr<Screen> makeLoginScreen() { return std::make_unique<LoginScreen>(); }
} // namespace xc
+560
View File
@@ -0,0 +1,560 @@
// Nubix — Connecting screen (session start / queue / provisioning) and Stream screen
// (full-screen video, input forwarding, rumble, hold-PS overlay menu, statistics).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include <SDL.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <memory>
extern "C" {
#include <libavutil/frame.h>
#include <libavutil/pixfmt.h>
}
#include "core/log.hpp"
#include "platform/platform.hpp"
#include "ui/app.hpp"
#include "ui/screens.hpp"
#include "ui/screens_internal.hpp"
#include "ui/ui.hpp"
namespace xc {
namespace {
constexpr uint32_t kMenuHoldMs = 1000;
constexpr uint64_t kNexusPulseMs = 150;
constexpr uint64_t kEndedReturnMs = 6000;
constexpr uint64_t kStartHintMs = 7000;
constexpr uint64_t kStatsRefreshMs = 500;
constexpr uint16_t kTriggerReleased = 4000; // a suppressed trigger counts as released below this
// The account was signed out while a session was starting/running (the refresh token was
// rejected on the streamer thread): go to the Login screen instead of a stale Home.
bool leaveIfSignedOut(App& app) {
if (app.auth().isLoggedIn()) return false;
app.toast("Your sign-in expired. Please sign in again.");
app.resetTo(makeLoginScreen());
return true;
}
bool isTerminal(StreamState s) { return s == StreamState::Ended || s == StreamState::Failed; }
// ============================================================================================
// Connecting
// ============================================================================================
class ConnectingScreen : public Screen {
public:
ConnectingScreen(SessionKind kind, std::string id, std::string name, bool f2pOnly)
: kind_(kind), id_(std::move(id)), name_(std::move(name)), f2pOnly_(f2pOnly) {}
void onEnter(App& app) override {
if (!issued_) begin(app);
}
void onNav(App& app, NavKey key) override {
if (key == NavKey::Back) {
cancel(app);
return;
}
if (key == NavKey::Accept && failed_) begin(app);
}
void update(App& app, uint32_t dtMs) override {
(void)dtMs;
if (!started_ || failed_) return;
Streamer& s = app.streamer();
const StreamState st = s.state();
lastState_ = st;
if (st == StreamState::Streaming) {
XC_LOGI("connecting: stream is up after %llu ms",
static_cast<unsigned long long>(platform::monotonicMs() - startedAt_));
app.replace(ui::makeStreamScreenNamed(name_));
return;
}
if (isTerminal(st)) {
failed_ = true;
error_ = s.statusText();
if (error_.empty()) error_ = st == StreamState::Ended ? "The session ended." : "The session could not be started.";
XC_LOGW("connecting: %s: %s", streamStateName(st), error_.c_str());
app.streamer().stopAsync(); // release resources in the background
leaveIfSignedOut(app);
}
}
void render(App& app, Ui& ui) override {
ui::background(ui);
ui::logo(ui, 96, 56);
const uint64_t now = platform::monotonicMs();
const int cx = Ui::kWidth / 2;
if (failed_) {
ui::errorBadge(ui, cx, 360, 52);
ui.text("Couldn't start " + name_, cx, 450, 44, colors::Text, Align::Center, true);
const int w = 1100;
ui.textWrapped(error_, (Ui::kWidth - w) / 2, 528, w, 28, colors::TextDim);
ui::hintBar(app, ui, {{PadIcon::Cross, "Try again"}, {PadIcon::Circle, "Back"}});
return;
}
const StreamState st = started_ ? lastState_ : StreamState::Starting;
ui.spinner(cx, 330, 58, now);
ui.textEllipsized((kind_ == SessionKind::Home ? "Connecting to " : "Starting ") + name_, cx, 430, 1500, 50,
colors::Text, Align::Center, true);
std::string status = started_ ? app.streamer().statusText() : std::string("Preparing\xE2\x80\xA6");
if (status.empty()) status = streamStateName(st);
ui.textEllipsized(status, cx, 506, 1500, 28, colors::TextDim, Align::Center);
if (st == StreamState::Queued) {
const int pos = app.streamer().queuePosition();
if (pos > 0) {
ui.rect(cx - 260, 570, 520, 120, colors::Panel, true, 20);
ui.text("Position in queue", cx, 586, 24, colors::TextDim, Align::Center);
ui.text(std::to_string(pos), cx, 620, 50, colors::AccentBright, Align::Center, true);
}
}
// progress stepper
struct Step {
const char* label;
StreamState state;
};
const Step steps[] = {{"Session", StreamState::Starting},
{kind_ == SessionKind::Home ? "Console" : "Queue", StreamState::Queued},
{kind_ == SessionKind::Home ? "Waking up" : "Provisioning", StreamState::Provisioning},
{"Connecting", StreamState::Connecting}};
const int n = 4, sw = 300, sx = cx - (n - 1) * sw / 2, sy = 790;
int cur = 0;
for (int i = 0; i < n; ++i)
if (static_cast<int>(st) >= static_cast<int>(steps[i].state)) cur = i;
for (int i = 0; i < n - 1; ++i)
ui.rect(sx + i * sw + 16, sy - 2, sw - 32, 4, i < cur ? colors::AccentBright : colors::PanelHi, true, 2);
for (int i = 0; i < n; ++i) {
const int x = sx + i * sw;
if (i < cur) {
ui.circle(x, sy, 12, colors::AccentBright);
} else if (i == cur) {
const double pulse = 0.5 + 0.5 * std::sin(now / 180.0);
ui.circle(x, sy, 12 + static_cast<int>(6 * pulse), withAlpha(colors::AccentBright, 70));
ui.circle(x, sy, 12, colors::AccentBright);
} else {
ui.circle(x, sy, 10, colors::PanelHi);
}
ui.text(steps[i].label, x, sy + 28, 22, i <= cur ? colors::Text : colors::TextDim, Align::Center, i == cur);
}
const int64_t elapsed = started_ ? static_cast<int64_t>((now - startedAt_) / 1000) : 0;
ui.text("Elapsed " + ui::formatDuration(elapsed), cx, 900, 24, withAlpha(colors::TextDim, 200), Align::Center);
ui::hintBar(app, ui, {{PadIcon::Circle, "Cancel"}});
}
private:
void begin(App& app) {
issued_ = true;
started_ = false;
failed_ = false;
error_.clear();
lastState_ = StreamState::Starting;
startedAt_ = platform::monotonicMs();
XC_LOGI("connecting: %s session for %s", kind_ == SessionKind::Home ? "home" : "cloud", id_.c_str());
// Non-blocking: the Streamer releases a session that is still winding down on its own
// lifecycle thread before this one starts.
if (app.streamer().start(kind_, id_, f2pOnly_)) {
started_ = true;
startedAt_ = platform::monotonicMs();
} else {
failed_ = true;
error_ = "A previous streaming session is still active. Please try again.";
}
}
void cancel(App& app) {
if (started_ || issued_) app.streamer().stopAsync();
app.pop();
}
SessionKind kind_;
std::string id_, name_;
bool f2pOnly_ = false;
bool issued_ = false, started_ = false, failed_ = false;
std::string error_;
StreamState lastState_ = StreamState::Starting;
uint64_t startedAt_ = 0;
};
// ============================================================================================
// Stream
// ============================================================================================
class StreamScreen : public Screen {
public:
explicit StreamScreen(std::string name) : name_(std::move(name)) {}
~StreamScreen() override {
if (tex_) SDL_DestroyTexture(tex_);
}
bool capturesGamepad() const override { return !menuOpen_ && !ended_; }
void onEnter(App& app) override {
if (enteredAt_ == 0) enteredAt_ = platform::monotonicMs();
deadzone_ = app.config().settings().stickDeadzone;
captureHeld(app); // e.g. Cross still held from starting the title in the library
}
void onLeave(App& app) override {
app.gamepad().stopRumble();
app.streamer().setGamepad(GamepadState{});
}
void onNav(App& app, NavKey key) override {
if (ended_) {
if (key == NavKey::Accept || key == NavKey::Back) leave(app);
return;
}
if (!menuOpen_) {
if (key == NavKey::Menu) openMenu(app);
return;
}
switch (key) {
case NavKey::Up: menuFocus_ = (menuFocus_ + kMenuItems - 1) % kMenuItems; break;
case NavKey::Down: menuFocus_ = (menuFocus_ + 1) % kMenuItems; break;
case NavKey::Back:
case NavKey::Menu: closeMenu(app); break;
case NavKey::Accept: activate(app, menuFocus_); break;
default: break;
}
}
void update(App& app, uint32_t dtMs) override {
(void)dtMs;
const uint64_t now = platform::monotonicMs();
Streamer& s = app.streamer();
Gamepad& pad = app.gamepad();
// session end
const StreamState st = s.state();
if (!ended_ && isTerminal(st)) {
ended_ = true;
menuOpen_ = false;
endedAt_ = now;
endMsg_ = s.statusText();
failedEnd_ = st == StreamState::Failed;
pad.stopRumble();
XC_LOGI("stream: %s (%s)", streamStateName(st), endMsg_.c_str());
if (failedEnd_) platform::notify("Nubix: stream ended: " + (endMsg_.empty() ? std::string("error") : endMsg_));
app.streamer().stopAsync();
}
if (ended_) {
if (now - endedAt_ > kEndedReturnMs) leave(app);
return;
}
// video
if (AVFrame* f = s.currentFrame()) {
upload(app.ui(), f);
av_frame_free(&f);
++videoFrames_;
}
// input (state() every frame: it also advances the Menu/View chord hold-back)
GamepadState gs = pad.state(deadzone_);
const bool chord = pad.menuChordTriggered(kMenuHoldMs);
if (!menuOpen_) {
if (chord) {
openMenu(app);
} else {
suppressHeld(gs);
if (now < nexusUntil_) gs.buttons |= btn::Nexus;
s.setGamepad(gs);
}
} else {
if (chord) closeMenu(app);
s.setGamepad(GamepadState{});
}
// rumble
Vibration v;
while (s.takeVibration(v))
if (!menuOpen_) pad.rumble(v);
// stats
++displayFrames_;
if (now - statsAt_ >= kStatsRefreshMs) {
const double secs = (now - statsAt_) / 1000.0;
if (statsAt_ != 0 && secs > 0) displayFps_ = displayFrames_ / secs;
displayFrames_ = 0;
statsAt_ = now;
stats_ = s.stats();
}
}
void render(App& app, Ui& ui) override {
const uint64_t now = platform::monotonicMs();
SDL_Renderer* r = ui.renderer();
if (tex_ && texW_ > 0 && texH_ > 0) {
const float sc = std::min(static_cast<float>(Ui::kWidth) / texW_, static_cast<float>(Ui::kHeight) / texH_);
const int w = static_cast<int>(std::lround(texW_ * sc)), h = static_cast<int>(std::lround(texH_ * sc));
const int x = (Ui::kWidth - w) / 2, y = (Ui::kHeight - h) / 2;
// letterbox bars only (the video covers the rest)
if (x > 0) {
ui.rect(0, 0, x, Ui::kHeight, colors::Black);
ui.rect(x + w, 0, Ui::kWidth - x - w, Ui::kHeight, colors::Black);
}
if (y > 0) {
ui.rect(0, 0, Ui::kWidth, y, colors::Black);
ui.rect(0, y + h, Ui::kWidth, Ui::kHeight - y - h, colors::Black);
}
SDL_Rect dst{x, y, w, h};
SDL_RenderCopy(r, tex_, nullptr, &dst);
} else {
ui.rect(0, 0, Ui::kWidth, Ui::kHeight, colors::Black);
if (!ended_) {
ui.spinner(Ui::kWidth / 2, Ui::kHeight / 2 - 30, 48, now);
ui.text("Waiting for video\xE2\x80\xA6", Ui::kWidth / 2, Ui::kHeight / 2 + 50, 30, colors::TextDim,
Align::Center);
}
}
if (app.statsOverlay() && !ended_) renderStats(ui);
if (!ended_ && !menuOpen_ && now - enteredAt_ < kStartHintMs) {
const bool kb = app.gamepad().lastInputKeyboard();
const std::string msg = kb ? "Press F1 for the stream menu"
: (platform::isPs5() ? "Hold Options + touchpad for 1 s for the stream menu"
: "Hold PS (or Options + Create) for 1 s for the stream menu");
const uint64_t left = kStartHintMs - (now - enteredAt_);
const uint8_t a = left < 500 ? static_cast<uint8_t>(left * 255 / 500) : 255;
const int w = ui.textWidth(msg, 24) + 60;
ui.rect((Ui::kWidth - w) / 2, 40, w, 56, withAlpha(colors::Panel, static_cast<uint8_t>(a * 220 / 255)), true, 28);
ui.text(msg, Ui::kWidth / 2, 40 + (56 - ui.lineHeight(24)) / 2, 24, withAlpha(colors::Text, a), Align::Center);
}
if (menuOpen_) renderMenu(app, ui);
if (ended_) renderEnded(app, ui, now);
}
private:
enum MenuItem { ItemResume, ItemNexus, ItemStats, ItemKeyframe, ItemDisconnect, kMenuItems };
void openMenu(App& app) {
menuOpen_ = true;
menuFocus_ = ItemResume;
app.gamepad().stopRumble();
app.streamer().setGamepad(GamepadState{});
captureHeld(app);
}
// Close the overlay. The button that closed it (Cross on an item, Circle, the chord) is
// still physically down: it must not reach the game when forwarding resumes.
void closeMenu(App& app) {
menuOpen_ = false;
captureHeld(app);
}
// Remember every button / trigger held right now; suppressHeld() keeps each one away from
// the game until it has been released once.
void captureHeld(App& app) {
const GamepadState cur = app.gamepad().state(deadzone_);
suppressMask_ = cur.buttons;
suppressLt_ = cur.lt >= kTriggerReleased;
suppressRt_ = cur.rt >= kTriggerReleased;
}
void suppressHeld(GamepadState& gs) {
if (suppressMask_) {
suppressMask_ &= gs.buttons; // released bits are forwarded normally from now on
gs.buttons = static_cast<uint16_t>(gs.buttons & ~suppressMask_);
}
if (suppressLt_) {
if (gs.lt < kTriggerReleased) suppressLt_ = false;
else gs.lt = 0;
}
if (suppressRt_) {
if (gs.rt < kTriggerReleased) suppressRt_ = false;
else gs.rt = 0;
}
}
// Back to the previous screen, or to Login when the account got signed out meanwhile.
void leave(App& app) {
if (!leaveIfSignedOut(app)) app.pop();
}
void activate(App& app, int item) {
switch (item) {
case ItemResume: closeMenu(app); break;
case ItemNexus:
closeMenu(app); // Cross is suppressed: the game gets Nexus alone
nexusUntil_ = platform::monotonicMs() + kNexusPulseMs;
break;
case ItemStats: app.setStatsOverlay(!app.statsOverlay()); break;
case ItemKeyframe:
app.streamer().requestKeyframe();
app.toast("Requested a fresh video frame");
closeMenu(app);
break;
case ItemDisconnect: {
XC_LOGI("stream: disconnect requested by user");
app.streamer().stopAsync();
leave(app);
break;
}
default: break;
}
}
void upload(Ui& ui, const AVFrame* f) {
const int fmt = f->format;
Uint32 sdlFmt = 0;
if (fmt == AV_PIX_FMT_YUV420P || fmt == AV_PIX_FMT_YUVJ420P) sdlFmt = SDL_PIXELFORMAT_IYUV;
else if (fmt == AV_PIX_FMT_NV12) sdlFmt = SDL_PIXELFORMAT_NV12;
if (sdlFmt == 0 || f->width <= 0 || f->height <= 0) {
if (!warnedFormat_) {
XC_LOGE("stream: unsupported frame format %d (%dx%d)", fmt, f->width, f->height);
warnedFormat_ = true;
}
return;
}
if (!tex_ || f->width != texW_ || f->height != texH_ || sdlFmt != texFmt_) {
if (tex_) SDL_DestroyTexture(tex_);
const bool full = fmt == AV_PIX_FMT_YUVJ420P || f->color_range == AVCOL_RANGE_JPEG;
SDL_SetYUVConversionMode(full ? SDL_YUV_CONVERSION_JPEG : SDL_YUV_CONVERSION_AUTOMATIC);
tex_ = SDL_CreateTexture(ui.renderer(), sdlFmt, SDL_TEXTUREACCESS_STREAMING, f->width, f->height);
if (!tex_) {
XC_LOGE("stream: SDL_CreateTexture %dx%d failed: %s", f->width, f->height, SDL_GetError());
texW_ = texH_ = 0;
return;
}
texW_ = f->width;
texH_ = f->height;
texFmt_ = sdlFmt;
XC_LOGI("stream: video %dx%d %s", texW_, texH_, sdlFmt == SDL_PIXELFORMAT_NV12 ? "NV12" : "IYUV");
}
int rc;
if (sdlFmt == SDL_PIXELFORMAT_IYUV)
rc = SDL_UpdateYUVTexture(tex_, nullptr, f->data[0], f->linesize[0], f->data[1], f->linesize[1], f->data[2],
f->linesize[2]);
else
rc = SDL_UpdateNVTexture(tex_, nullptr, f->data[0], f->linesize[0], f->data[1], f->linesize[1]);
if (rc != 0 && !warnedUpload_) {
XC_LOGE("stream: texture upload failed: %s", SDL_GetError());
warnedUpload_ = true;
}
}
void renderStats(Ui& ui) {
char l1[96], l2[96], l3[128], l4[64];
const StreamStats& s = stats_;
std::snprintf(l1, sizeof(l1), "%d \xC3\x97 %d \xC2\xB7 %.1f fps", s.width ? s.width : texW_,
s.height ? s.height : texH_, s.fps);
std::snprintf(l2, sizeof(l2), "%.1f Mbps \xC2\xB7 loss %.1f %%", s.bitrateKbps / 1000.0, s.lossPercent);
if (s.rttMs >= 0)
std::snprintf(l3, sizeof(l3), "decode %.1f ms \xC2\xB7 RTT %d ms \xC2\xB7 audio %d ms", s.decodeMs, s.rttMs,
s.audioBufferMs);
else
std::snprintf(l3, sizeof(l3), "decode %.1f ms \xC2\xB7 audio %d ms", s.decodeMs, s.audioBufferMs);
std::snprintf(l4, sizeof(l4), "display %.0f fps", displayFps_);
const char* lines[] = {l1, l2, l3, l4};
int w = 0;
for (auto* l : lines) w = std::max(w, ui.textWidth(l, 22));
const int lh = ui.lineHeight(22);
ui.rect(28, 28, w + 48, lh * 4 + 32, withAlpha(colors::Black, 160), true, 14);
ui.rect(28, 44, 4, lh * 4, colors::AccentBright, true, 2);
int y = 44;
for (int i = 0; i < 4; ++i) {
ui.text(lines[i], 52, y, 22, i == 0 ? colors::White : colors::Text, Align::Left, i == 0);
y += lh;
}
}
void renderMenu(App& app, Ui& ui) {
ui.rect(0, 0, Ui::kWidth, Ui::kHeight, withAlpha(colors::Black, 150));
const int w = 640, itemH = 76, gap = 12;
const int h = 150 + kMenuItems * (itemH + gap) + 20;
const int x = (Ui::kWidth - w) / 2, y = (Ui::kHeight - h) / 2;
ui.rect(x, y, w, h, colors::Panel, true, 26);
ui.outline(x, y, w, h, withAlpha(colors::AccentBright, 80), 2, 26);
ui.textEllipsized(name_.empty() ? std::string("Stream") : name_, x + 48, y + 40, w - 96, 36, colors::Text,
Align::Left, true);
const StreamStats& s = stats_;
char sub[96];
std::snprintf(sub, sizeof(sub), "%dx%d \xC2\xB7 %.0f fps \xC2\xB7 %.1f Mbps", s.width ? s.width : texW_,
s.height ? s.height : texH_, s.fps, s.bitrateKbps / 1000.0);
if (s.width > 0 || texW_ > 0) ui.text(sub, x + 48, y + 92, 22, colors::TextDim);
int iy = y + 150;
for (int i = 0; i < kMenuItems; ++i) {
std::string label;
switch (i) {
case ItemResume: label = "Resume"; break;
case ItemNexus: label = "Press Xbox button"; break;
case ItemStats: label = app.statsOverlay() ? "Hide statistics" : "Show statistics"; break;
case ItemKeyframe: label = "Refresh video"; break;
case ItemDisconnect: label = "Disconnect"; break;
}
const bool f = i == menuFocus_;
if (f) {
ui.rect(x + 32, iy, w - 64, itemH, i == ItemDisconnect ? Color{150, 45, 40, 255} : colors::Accent, true, 14);
}
ui.text(label, x + 64, iy + (itemH - ui.lineHeight(28, f)) / 2, 28,
f ? colors::White : (i == ItemDisconnect ? Color{255, 140, 130, 255} : colors::Text), Align::Left, f);
iy += itemH + gap;
}
ui::hintBar(app, ui, {{PadIcon::Cross, "Select"}, {PadIcon::Circle, "Resume"}});
}
void renderEnded(App& app, Ui& ui, uint64_t now) {
ui.rect(0, 0, Ui::kWidth, Ui::kHeight, withAlpha(colors::Black, 170));
const int w = 1000, h = 330;
const int x = (Ui::kWidth - w) / 2, y = (Ui::kHeight - h) / 2;
ui.rect(x, y, w, h, colors::Panel, true, 24);
if (failedEnd_) ui::errorBadge(ui, x + 80, y + 82, 30);
ui.text(failedEnd_ ? "Stream interrupted" : "Stream ended", x + (failedEnd_ ? 132 : 56), y + 58, 40,
colors::Text, Align::Left, true);
if (!endMsg_.empty()) ui.textWrapped(endMsg_, x + 56, y + 130, w - 112, 26, colors::TextDim);
const int64_t left = static_cast<int64_t>((kEndedReturnMs - std::min(kEndedReturnMs, now - endedAt_)) / 1000) + 1;
ui.text("Returning in " + std::to_string(left) + " s", x + 56, y + h - 70, 24, withAlpha(colors::TextDim, 200));
ui::hintBar(app, ui, {{PadIcon::Cross, "Back now"}});
}
std::string name_;
SDL_Texture* tex_ = nullptr;
int texW_ = 0, texH_ = 0;
Uint32 texFmt_ = 0;
bool warnedFormat_ = false, warnedUpload_ = false;
bool menuOpen_ = false;
int menuFocus_ = 0;
uint64_t nexusUntil_ = 0;
int deadzone_ = 0;
uint16_t suppressMask_ = 0;
bool suppressLt_ = false, suppressRt_ = false;
bool ended_ = false, failedEnd_ = false;
uint64_t endedAt_ = 0;
std::string endMsg_;
uint64_t enteredAt_ = 0;
StreamStats stats_;
uint64_t statsAt_ = 0;
uint64_t videoFrames_ = 0;
int displayFrames_ = 0;
double displayFps_ = 0;
};
} // namespace
namespace ui {
std::unique_ptr<Screen> makeStreamScreenNamed(const std::string& displayName) {
return std::make_unique<StreamScreen>(displayName);
}
} // namespace ui
std::unique_ptr<Screen> makeConnectingScreen(SessionKind kind, const std::string& titleOrServerId,
const std::string& displayName, bool f2pOnly) {
return std::make_unique<ConnectingScreen>(kind, titleOrServerId, displayName, f2pOnly);
}
} // namespace xc
+501
View File
@@ -0,0 +1,501 @@
// Nubix — UI screens: shared widgets, confirmation dialog, settings panel / screen and
// the public factories. Login, Home, Connecting and Stream live in screen_*.cpp.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include "ui/screens.hpp"
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <string>
#include <vector>
#include "core/log.hpp"
#include "platform/platform.hpp"
#include "ui/app.hpp"
#include "ui/screens_internal.hpp"
#include "ui/ui.hpp"
namespace xc {
namespace ui {
namespace {
constexpr int kHintIcon = 40;
constexpr int kHintText = 24;
constexpr int kHintY = 1022;
// Width padIcon() will use for an icon (mirrors Ui::padIcon's metrics).
int padIconWidth(Ui& ui, PadIcon icon, int size) {
auto pill = [&](const char* label) {
const int fs = std::max(12, size * 9 / 20);
return std::max(size + size / 3, ui.textWidth(label, fs, true) + size / 2);
};
switch (icon) {
case PadIcon::L1: return pill("L1");
case PadIcon::R1: return pill("R1");
case PadIcon::PS: return pill("PS");
case PadIcon::Options: return size + size / 3;
case PadIcon::Touchpad: return size * 2;
default: return size;
}
}
int keyCapWidth(Ui& ui, const std::string& label, int size) {
const int fs = std::max(12, size * 9 / 20);
return std::max(size, ui.textWidth(label, fs, true) + size / 2);
}
int hintWidth(App& app, Ui& ui, const Hint& h) {
const bool kb = app.gamepad().lastInputKeyboard();
const int iw = kb ? keyCapWidth(ui, keyFor(h.icon), kHintIcon) : padIconWidth(ui, h.icon, kHintIcon);
return h.label.empty() ? iw : iw + 12 + ui.textWidth(h.label, kHintText);
}
} // namespace
const char* keyFor(PadIcon icon) {
switch (icon) {
case PadIcon::Cross: return "Enter";
case PadIcon::Circle: return "Esc";
case PadIcon::Square: return "X";
case PadIcon::Triangle: return "Y";
case PadIcon::L1: return "Q";
case PadIcon::R1: return "E";
case PadIcon::Options: return "N";
case PadIcon::Touchpad: return "V";
case PadIcon::PS: return "F1";
}
return "?";
}
int hintItem(App& app, Ui& ui, const Hint& h, int x, int cy) {
const bool kb = app.gamepad().lastInputKeyboard();
int w = kb ? ui.keyCap(keyFor(h.icon), x, cy, kHintIcon) : ui.padIcon(h.icon, x, cy, kHintIcon);
if (h.label.empty()) return w; // glyph paired with the next hint (e.g. "L1 R1 Switch tab")
w += 12;
w += ui.text(h.label, x + w, cy - ui.lineHeight(kHintText) / 2, kHintText, colors::TextDim);
return w;
}
void hintBar(App& app, Ui& ui, const std::vector<Hint>& hints) {
if (hints.empty()) return;
auto gapAfter = [](const Hint& h) { return h.label.empty() ? 10 : 40; };
int total = 0;
for (auto& h : hints) total += hintWidth(app, ui, h) + gapAfter(h);
total -= gapAfter(hints.back());
int x = Ui::kWidth - 96 - total;
for (auto& h : hints) x += hintItem(app, ui, h, x, kHintY) + gapAfter(h);
}
void background(Ui& ui) {
ui.gradient(0, 0, Ui::kWidth, 420, Color{16, 32, 70, 255}, colors::Background);
}
void logo(Ui& ui, int x, int y, bool compact) {
const int r = 26;
// app icon in miniature: navy rounded square with a two-tone blue X
ui.rect(x, y, 2 * r, 2 * r, Color{18, 38, 82, 255}, true, 14);
const float c = r, k = r * 0.48f;
ui.line(x + c + k, y + c - k, x + c - k, y + c + k, 9.0f, colors::Accent);
ui.line(x + c - k, y + c - k, x + c + k, y + c + k, 9.0f, colors::AccentBright);
const int tx = x + 2 * r + 16;
int w = ui.text("Nubix", tx, y + r - ui.lineHeight(30, true) / 2, 30, colors::Text, Align::Left, true);
if (!compact) ui.text("for Xbox Cloud Gaming", tx + w + 14, y + r - ui.lineHeight(30) / 2, 30, colors::TextDim);
}
void errorBadge(Ui& ui, int cx, int cy, int radius) {
ui.circle(cx, cy, radius, colors::Error);
const int s = radius * 13 / 10;
ui.text("!", cx, cy - ui.lineHeight(s, true) / 2, s, colors::White, Align::Center, true);
}
std::string formatDuration(int64_t seconds) {
if (seconds < 0) seconds = 0;
char buf[32];
if (seconds >= 3600)
std::snprintf(buf, sizeof(buf), "%d:%02d:%02d", static_cast<int>(seconds / 3600),
static_cast<int>((seconds / 60) % 60), static_cast<int>(seconds % 60));
else
std::snprintf(buf, sizeof(buf), "%d:%02d", static_cast<int>(seconds / 60), static_cast<int>(seconds % 60));
return buf;
}
std::string prettyConsoleType(const std::string& t) {
if (t == "XboxSeriesX") return "Xbox Series X";
if (t == "XboxSeriesS") return "Xbox Series S";
if (t == "XboxOne") return "Xbox One";
if (t == "XboxOneS") return "Xbox One S";
if (t == "XboxOneX") return "Xbox One X";
if (t.empty()) return "Xbox";
// generic: insert spaces before capitals ("XboxFooBar" -> "Xbox Foo Bar")
std::string out;
for (size_t i = 0; i < t.size(); ++i) {
if (i > 0 && std::isupper(static_cast<unsigned char>(t[i])) &&
!std::isupper(static_cast<unsigned char>(t[i - 1])))
out.push_back(' ');
out.push_back(t[i]);
}
return out;
}
// ---- confirmation dialog ------------------------------------------------------------------
namespace {
class ConfirmScreen : public Screen {
public:
ConfirmScreen(std::string title, std::string message, std::string confirm, std::function<void(App&)> fn)
: title_(std::move(title)), message_(std::move(message)), confirm_(std::move(confirm)), fn_(std::move(fn)) {}
bool isOverlay() const override { return true; }
void onNav(App& app, NavKey key) override {
switch (key) {
case NavKey::Left: focus_ = 0; break;
case NavKey::Right: focus_ = 1; break;
case NavKey::Back: app.pop(); break;
case NavKey::Accept:
app.pop();
if (focus_ == 1 && fn_) fn_(app);
break;
default: break;
}
}
void update(App& app, uint32_t dtMs) override {
(void)app;
(void)dtMs;
}
void render(App& app, Ui& ui) override {
ui.rect(0, 0, Ui::kWidth, Ui::kHeight, withAlpha(colors::Black, 170));
// cover the (dimmed) hint bar of the screen below so only ours is readable
ui.rect(0, 990, Ui::kWidth, 70, Color{5, 7, 6, 255});
const int w = 900, h = 400;
const int x = (Ui::kWidth - w) / 2, y = (Ui::kHeight - h) / 2;
ui.rect(x, y, w, h, colors::Panel, true, 24);
ui.outline(x, y, w, h, withAlpha(colors::AccentBright, 90), 2, 24);
ui.text(title_, x + 56, y + 48, 42, colors::Text, Align::Left, true);
ui.textWrapped(message_, x + 56, y + 122, w - 112, 28, colors::TextDim);
const int bw = 300, bh = 72, by = y + h - bh - 48;
ui.button("Cancel", x + w - 56 - 2 * bw - 24, by, bw, bh, focus_ == 0);
ui.button(confirm_, x + w - 56 - bw, by, bw, bh, focus_ == 1);
hintBar(app, ui, {{PadIcon::Cross, "Select"}, {PadIcon::Circle, "Cancel"}});
}
private:
std::string title_, message_, confirm_;
std::function<void(App&)> fn_;
int focus_ = 1;
};
} // namespace
std::unique_ptr<Screen> makeConfirmScreen(const std::string& title, const std::string& message,
const std::string& confirmLabel, std::function<void(App&)> onConfirm) {
return std::make_unique<ConfirmScreen>(title, message, confirmLabel, std::move(onConfirm));
}
std::unique_ptr<Screen> makeExitConfirm() {
return makeConfirmScreen("Exit Nubix?", "Any running stream will be stopped.", "Exit",
[](App& app) { app.quit(); });
}
// ---- settings panel -----------------------------------------------------------------------
namespace {
// Fallback gssv region names (offeringSettings.regions[].name), used only until Auth has stored
// the account's real region list (Tokens::xcloudRegions, filled by authorizeStreaming). Auth
// falls back to the offering's default region when the stored name is not offered.
const char* const kFallbackRegions[] = {"WestEurope", "NorthEurope", "UKSouth", "FranceCentral",
"GermanyWestCentral", "SwedenCentral", "EastUS", "EastUS2",
"NorthCentralUS", "SouthCentralUS", "WestUS", "WestUS2",
"CanadaCentral", "BrazilSouth", "JapanEast", "KoreaCentral",
"SoutheastAsia", "AustraliaEast", "AustraliaSouthEast"};
// Region choices for the Settings row: "" (Automatic = server default) followed by the account's
// xgpuweb regions (or the f2p ones, or the fallback list).
std::vector<std::string> regionChoices(const Tokens& t) {
std::vector<std::string> out{std::string()};
const std::vector<Region>& src = !t.xcloudRegions.empty() ? t.xcloudRegions : t.xcloudF2pRegions;
for (const auto& r : src)
if (!r.name.empty() && std::find(out.begin(), out.end(), r.name) == out.end()) out.push_back(r.name);
if (out.size() == 1)
for (const char* n : kFallbackRegions) out.emplace_back(n);
return out;
}
const char* const kResolutions[] = {"720", "1080", "1080HQ"};
enum Row { RowResolution, RowBitrate, RowRegion, RowF2p, RowDeadzone, RowStats, RowSignOut, RowExit, RowCount };
} // namespace
struct SettingsPanel::Impl {
int focus = 0;
// Per-frame reads go through cached snapshots (Config is shared with the worker threads);
// refreshed when the config revision changes.
uint64_t cachedRev = ~uint64_t(0);
Settings settings;
std::vector<Region> cloudRegions;
std::vector<std::string> regions; // regionChoices()
std::string gamertag;
// getifaddrs() is not a per-frame call: refresh the IP about once per second.
std::string ip;
uint64_t ipAt = 0;
void refresh(App& app) {
const uint64_t rev = app.config().revision();
if (rev == cachedRev) return;
cachedRev = rev;
settings = app.config().settings();
const Tokens t = app.config().tokens();
cloudRegions = t.xcloudRegions;
regions = regionChoices(t);
gamertag = t.gamertag;
}
const std::string& localIp() {
const uint64_t now = platform::monotonicMs();
if (ipAt == 0 || now - ipAt >= 1000) {
ip = platform::localIpv4();
ipAt = now;
}
return ip;
}
static void save(App& app) {
Config* cfg = &app.config();
app.runAsync([cfg] {
if (!cfg->save()) XC_LOGW("settings: saving %s failed", cfg->path().c_str());
});
}
std::string label(int row) const {
switch (row) {
case RowResolution: return "Stream resolution";
case RowBitrate: return "Maximum bitrate";
case RowRegion: return "Server region";
case RowF2p: return "Free-to-play fallback";
case RowDeadzone: return "Extra stick deadzone";
case RowStats: return "Show stream statistics";
case RowSignOut: return "Sign out";
case RowExit: return "Exit Nubix";
}
return "";
}
std::string value(App& app, int row) const {
const Settings& s = settings;
char buf[64];
switch (row) {
case RowResolution:
if (s.resolution == "720") return "720p";
if (s.resolution == "1080HQ") return "1080p (high quality)";
return "1080p";
case RowBitrate:
std::snprintf(buf, sizeof(buf), "%d Mbps", (s.bitrateKbps + 500) / 1000);
return buf;
case RowRegion: {
if (s.region.empty()) return "Automatic";
for (const auto& r : cloudRegions)
if (r.name == s.region) return s.region;
// Not (yet) known to be offered: Auth uses the server default in that case.
return cloudRegions.empty() ? s.region : s.region + " (unavailable)";
}
case RowF2p: return s.f2pFallback ? "On" : "Off";
case RowDeadzone:
if (s.stickDeadzone <= 0) return "Off";
std::snprintf(buf, sizeof(buf), "%d %%", s.stickDeadzone);
return buf;
case RowStats: return app.statsOverlay() ? "On" : "Off";
default: return "";
}
}
std::string help(int row) const {
switch (row) {
case RowResolution: return "720p decodes fastest. 1080p (high quality) asks the server for a higher-quality profile.";
case RowBitrate: return "Upper limit the server may use. Lower it on Wi-Fi or if the picture stutters.";
case RowRegion: return "Automatic uses the region Xbox assigns to your account.";
case RowF2p: return "Retry with the free-to-play offering when a title is not in your Game Pass library.";
case RowDeadzone: return "Ignore small stick movements (helps with drifting sticks).";
case RowStats: return "Resolution, frame rate, bitrate and decode time while streaming.";
case RowSignOut: return "Forget the stored Microsoft account on this console.";
case RowExit: return "Close the app.";
}
return "";
}
// dir: -1 / +1 (Left / Right), 0 = Accept
void change(App& app, int row, int dir) {
refresh(app);
Settings s = settings; // edit a copy, publish it with one locked write
const int step = dir == 0 ? 1 : dir;
auto commit = [&] {
app.config().setSettings(s);
refresh(app);
};
switch (row) {
case RowResolution: {
int i = 1;
for (int k = 0; k < 3; ++k)
if (s.resolution == kResolutions[k]) i = k;
i = (i + step + 3) % 3;
s.resolution = kResolutions[i];
commit();
save(app);
break;
}
case RowBitrate: {
int mb = (s.bitrateKbps + 500) / 1000 + step;
if (dir == 0 && mb > 20) mb = 5;
s.bitrateKbps = std::max(5, std::min(20, mb)) * 1000;
commit();
save(app);
break;
}
case RowRegion: {
const std::vector<std::string> choices = regions;
const int n = static_cast<int>(choices.size());
int i = 0;
for (int k = 0; k < n; ++k)
if (s.region == choices[static_cast<size_t>(k)]) i = k;
i = (i + step + n) % n;
s.region = choices[static_cast<size_t>(i)];
commit();
// Re-pick the cloud base URIs for the new region (no network) and persist.
Auth* auth = &app.auth();
app.runAsync([auth] { auth->applyRegion(); });
break;
}
case RowF2p:
s.f2pFallback = !s.f2pFallback;
commit();
save(app);
break;
case RowDeadzone: {
int v = s.stickDeadzone + step * 5;
if (dir == 0 && v > 30) v = 0;
s.stickDeadzone = std::max(0, std::min(30, v));
commit();
save(app);
break;
}
case RowStats: app.setStatsOverlay(!app.statsOverlay()); break;
case RowSignOut:
if (dir != 0) break;
app.push(makeConfirmScreen("Sign out?", "You will need to sign in again with a code to stream.",
"Sign out", [](App& a) {
a.runAsync([&a] {
a.auth().logout();
a.postToMain([&a] {
a.toast("Signed out");
a.resetTo(makeLoginScreen());
});
});
}));
break;
case RowExit:
if (dir == 0) app.push(makeExitConfirm());
break;
}
}
};
SettingsPanel::SettingsPanel() : d_(new Impl) {}
SettingsPanel::~SettingsPanel() = default;
void SettingsPanel::resetFocus() { d_->focus = 0; }
bool SettingsPanel::onNav(App& app, NavKey key) {
d_->refresh(app);
switch (key) {
case NavKey::Up: d_->focus = (d_->focus + RowCount - 1) % RowCount; return true;
case NavKey::Down: d_->focus = (d_->focus + 1) % RowCount; return true;
case NavKey::Left: d_->change(app, d_->focus, -1); return true;
case NavKey::Right: d_->change(app, d_->focus, +1); return true;
case NavKey::Accept: d_->change(app, d_->focus, 0); return true;
default: return false;
}
}
void SettingsPanel::render(App& app, Ui& ui, int top) {
d_->refresh(app);
const int x = 360, w = 1200, h = 74, gap = 8;
int y = top;
for (int row = 0; row < RowCount; ++row) {
const bool f = row == d_->focus;
const bool action = row == RowSignOut || row == RowExit;
if (row == RowSignOut) y += 16; // separate actions from settings
ui.rect(x, y, w, h, f ? colors::PanelHi : colors::Panel, true, 14);
if (f) {
ui.outline(x - 4, y - 4, w + 8, h + 8, colors::AccentBright, 3, 18);
ui.rect(x, y + 14, 6, h - 28, colors::AccentBright, true, 3);
}
const int ty = y + (h - ui.lineHeight(28)) / 2;
const Color lc = action && row == RowSignOut ? Color{255, 140, 130, 255} : colors::Text;
ui.text(d_->label(row), x + 36, ty, 28, lc, Align::Left, f);
if (!action) {
const std::string v = d_->value(app, row);
const int vx = x + w - 36;
if (f) {
const int vw = ui.textWidth(v, 28, true);
ui.text("\xE2\x80\xBA", vx, ty, 28, colors::AccentBright, Align::Right, true); // ›
ui.text(v, vx - 34, ty, 28, colors::White, Align::Right, true);
ui.text("\xE2\x80\xB9", vx - 34 - vw - 18, ty, 28, colors::AccentBright, Align::Right, true); // ‹
} else {
ui.text(v, vx, ty, 28, colors::TextDim, Align::Right);
}
}
y += h + gap;
}
// help line for the focused row + account info
ui.text(d_->help(d_->focus), x, y + 16, 24, colors::TextDim);
std::string info;
const std::string& tag = d_->gamertag;
info = tag.empty() ? std::string("Signed in") : "Signed in as " + tag;
info += " \xC2\xB7 v" XC_VERSION;
const std::string ip = d_->localIp();
if (!ip.empty()) info += " \xC2\xB7 IP " + ip;
if (app.gamepad().connected()) info += " \xC2\xB7 " + app.gamepad().name();
ui.text(info, x, y + 60, 22, withAlpha(colors::TextDim, 200));
}
} // namespace ui
// ---- standalone settings screen -----------------------------------------------------------
namespace {
class SettingsScreen : public Screen {
public:
void onNav(App& app, NavKey key) override {
if (key == NavKey::Back) {
app.pop();
return;
}
panel_.onNav(app, key);
}
void update(App& app, uint32_t dtMs) override {
(void)app;
(void)dtMs;
}
void render(App& app, Ui& ui) override {
ui::background(ui);
ui::logo(ui, 96, 56);
ui.text("Settings", 360, 150, 48, colors::Text, Align::Left, true);
panel_.render(app, ui, 236);
ui::hintBar(app, ui, {{PadIcon::Cross, "Change"}, {PadIcon::Circle, "Back"}});
}
private:
ui::SettingsPanel panel_;
};
} // namespace
std::unique_ptr<Screen> makeSettingsScreen() { return std::make_unique<SettingsScreen>(); }
std::unique_ptr<Screen> makeStreamScreen() { return ui::makeStreamScreenNamed(""); }
} // namespace xc
+52
View File
@@ -0,0 +1,52 @@
// Nubix — UI screens: Login, Home (Cloud library / My consoles / Settings tabs),
// Settings, Connecting, Stream (video + overlay menu + stats).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <memory>
#include <string>
#include "core/gssv.hpp"
#include "platform/gamepad.hpp"
namespace xc {
class App;
class Ui;
// Base class for a screen on the App's stack. All methods run on the main thread.
class Screen {
public:
virtual ~Screen() = default;
// Became the top screen.
virtual void onEnter(App& app) { (void)app; }
// Another screen was pushed on top / this one is popped.
virtual void onLeave(App& app) { (void)app; }
// A navigation key was pressed.
virtual void onNav(App& app, NavKey key) = 0;
// Per-frame logic (poll async results, streamer state, ...). dtMs = frame time.
virtual void update(App& app, uint32_t dtMs) = 0;
// Draw the screen.
virtual void render(App& app, Ui& ui) = 0;
// True while the screen forwards raw pad state to the stream instead of NavKeys.
virtual bool capturesGamepad() const { return false; }
// True for modal dialogs: the screen below is rendered first (dimmed by the dialog).
virtual bool isOverlay() const { return false; }
};
// Device-code login with QR; on success replaces itself with the Home screen.
std::unique_ptr<Screen> makeLoginScreen();
// Tabbed home: Cloud library grid, My consoles list, Settings.
std::unique_ptr<Screen> makeHomeScreen();
// Settings list (resolution, bitrate, region, F2P fallback, logout).
std::unique_ptr<Screen> makeSettingsScreen();
// Starts the Streamer and shows status/queue until Streaming, then replaces itself with the
// Stream screen. displayName is shown in the status text.
// f2pOnly: cloud title playable only via the free-to-play offering (Title::f2pOnly).
std::unique_ptr<Screen> makeConnectingScreen(SessionKind kind, const std::string& titleOrServerId,
const std::string& displayName, bool f2pOnly = false);
// Full-screen video, input forwarding, hold-PS overlay menu and stats.
std::unique_ptr<Screen> makeStreamScreen();
} // namespace xc
+78
View File
@@ -0,0 +1,78 @@
// Nubix — helpers shared by the screen implementations (not part of the public UI API).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstdint>
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "platform/gamepad.hpp"
#include "ui/screens.hpp"
#include "ui/ui.hpp"
namespace xc {
class App;
namespace ui {
// One entry of the bottom hint bar: a controller glyph (or the matching key on keyboard).
struct Hint {
PadIcon icon;
std::string label;
};
// Bottom-right hint bar ("✕ Select ○ Back"), keyboard key names when no pad is in use.
void hintBar(App& app, Ui& ui, const std::vector<Hint>& hints);
// Keyboard key that corresponds to a pad glyph in the UI ("Enter", "Esc", ...).
const char* keyFor(PadIcon icon);
// Draw one glyph-or-key + label pair; returns the width used.
int hintItem(App& app, Ui& ui, const Hint& h, int x, int cy);
// Background with a subtle green glow at the top.
void background(Ui& ui);
// Product logo (navy square with a blue X + "Nubix" wordmark, plus "for Xbox Cloud Gaming" unless compact).
void logo(Ui& ui, int x, int y, bool compact = false);
// Error badge (red roundel with "!") centred at (cx, cy).
void errorBadge(Ui& ui, int cx, int cy, int radius);
// "m:ss" / "h:mm:ss".
std::string formatDuration(int64_t seconds);
// Pretty console model name ("XboxSeriesX" -> "Xbox Series X").
std::string prettyConsoleType(const std::string& t);
// Modal confirmation dialog over the current screen. onConfirm runs on the main thread.
std::unique_ptr<Screen> makeConfirmScreen(const std::string& title, const std::string& message,
const std::string& confirmLabel, std::function<void(App&)> onConfirm);
// Confirm + quit the app.
std::unique_ptr<Screen> makeExitConfirm();
// Stream screen with a title for the overlay menu.
std::unique_ptr<Screen> makeStreamScreenNamed(const std::string& displayName);
// Settings list used by the Home "Settings" tab and the standalone Settings screen.
class SettingsPanel {
public:
SettingsPanel();
~SettingsPanel();
// Returns true if the key was consumed.
bool onNav(App& app, NavKey key);
void render(App& app, Ui& ui, int top);
void resetFocus();
private:
struct Impl;
std::unique_ptr<Impl> d_;
};
} // namespace ui
} // namespace xc
+1051
View File
File diff suppressed because it is too large. Load diff
+163
View File
@@ -0,0 +1,163 @@
// Nubix — immediate-mode drawing helpers on SDL_Renderer + SDL2_ttf: text, rects,
// buttons, grid tiles, spinner, QR code, images. Logical resolution 1920x1080.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
struct SDL_Renderer;
struct SDL_Texture;
struct SDL_Window;
namespace xc {
struct Color {
uint8_t r = 255, g = 255, b = 255, a = 255;
};
namespace colors {
constexpr Color Background{16, 20, 18, 255};
constexpr Color Panel{32, 38, 35, 255};
constexpr Color Accent{36, 110, 240, 255}; // Nubix blue
constexpr Color Text{240, 245, 240, 255};
constexpr Color TextDim{150, 160, 155, 255};
constexpr Color Error{220, 70, 60, 255};
// additional palette
constexpr Color PanelHi{44, 52, 48, 255}; // hovered / raised panel
constexpr Color PanelLo{24, 29, 27, 255}; // sunken panel, header bar
constexpr Color AccentBright{92, 170, 255, 255}; // focus ring, highlights
constexpr Color Warning{232, 170, 40, 255};
constexpr Color Black{0, 0, 0, 255};
constexpr Color White{255, 255, 255, 255};
} // namespace colors
// Returns c with alpha replaced.
constexpr Color withAlpha(Color c, uint8_t a) { return Color{c.r, c.g, c.b, a}; }
enum class Align { Left, Center, Right };
// Controller / keyboard hint glyphs drawn by Ui::padIcon.
enum class PadIcon { Cross, Circle, Square, Triangle, L1, R1, Options, Touchpad, PS };
class Ui {
public:
static constexpr int kWidth = 1920; // logical width
static constexpr int kHeight = 1080; // logical height
Ui();
~Ui();
Ui(const Ui&) = delete;
Ui& operator=(const Ui&) = delete;
// Create the window + renderer (fullscreen on PS5, resizable window on host), set logical
// size, init SDL_ttf and load the regular + bold fonts.
bool init(const std::string& fontPath, const std::string& boldFontPath, const std::string& title);
// Destroy textures, fonts, renderer and window.
void shutdown();
SDL_Renderer* renderer() const;
SDL_Window* window() const;
// Clear to the background color / present the frame.
void beginFrame();
void endFrame();
// Draw UTF-8 text with its top edge at y. Glyph textures are cached per (text, size, bold, color).
// Returns the rendered width.
int text(const std::string& s, int x, int y, int size, Color c = colors::Text, Align align = Align::Left,
bool bold = false);
// Draw text wrapped to maxWidth; returns the total height used.
int textWrapped(const std::string& s, int x, int y, int maxWidth, int size, Color c = colors::Text,
bool bold = false);
// Width in pixels of s at the given size.
int textWidth(const std::string& s, int size, bool bold = false);
// Filled or outlined rectangle; radius > 0 rounds the corners.
void rect(int x, int y, int w, int h, Color c, bool filled = true, int radius = 0);
// Button with label; focused draws the highlight.
void button(const std::string& label, int x, int y, int w, int h, bool focused);
// Grid tile with optional image (box art), caption and badge text (e.g. "Game Pass").
void tile(SDL_Texture* image, const std::string& caption, const std::string& badge, int x, int y, int w, int h,
bool focused);
// Animated loading spinner centred at (cx, cy); tMs = monotonic time.
void spinner(int cx, int cy, int radius, uint64_t tMs);
// QR code of data inside a white square of side `size` at (x, y). Cached per data string.
void qr(const std::string& data, int x, int y, int size);
// Draw a texture scaled to the rectangle (aspect-fit if fit == true).
void image(SDL_Texture* tex, int x, int y, int w, int h, bool fit = true);
// Decode PNG/JPEG bytes (via libavcodec) into a new texture; nullptr on failure. Caller owns it.
SDL_Texture* textureFromImage(const std::vector<uint8_t>& bytes);
// Drop cached text/QR textures (call on screen change if memory is tight).
void clearCache();
// ---- additions -------------------------------------------------------------------------
// Outlined rectangle with the given line thickness (logical px); radius > 0 rounds corners.
void outline(int x, int y, int w, int h, Color c, int thickness, int radius = 0);
// Vertical gradient fill (top color -> bottom color).
void gradient(int x, int y, int w, int h, Color top, Color bottom);
// Filled circle centred at (cx, cy).
void circle(int cx, int cy, int radius, Color c);
// Thick line segment (anti-aliasing not guaranteed).
void line(float x0, float y0, float x1, float y1, float thickness, Color c);
// Text truncated with "…" to fit maxWidth. Returns the drawn width.
int textEllipsized(const std::string& s, int x, int y, int maxWidth, int size, Color c = colors::Text,
Align align = Align::Left, bool bold = false);
// Line height (distance between baselines) for the font size.
int lineHeight(int size, bool bold = false);
// Controller glyph (PlayStation style) in a circle/pill of height `size`, centred
// vertically at cy, starting at x. Returns the width used.
int padIcon(PadIcon icon, int x, int cy, int size);
// Keyboard key cap with a label (host hints), same metrics as padIcon. Returns width used.
int keyCap(const std::string& label, int x, int cy, int size);
// Box art / remote image, decoded + scaled to exactly (w x h) logical px (cover crop) on a
// background loader thread and cached (LRU). Returns nullptr until loaded (or on failure).
// Call every frame for visible items; requests not repeated for a while are dropped.
SDL_Texture* remoteImage(const std::string& url, int w, int h);
// Number of remote images queued or downloading.
int remoteImagesPending() const;
// Mask the corners of a rectangle with `bg` so that content drawn inside looks rounded.
void roundCorners(int x, int y, int w, int h, int radius, Color bg);
// Clip subsequent drawing to the rectangle (logical coords); clearClip() resets.
void setClip(int x, int y, int w, int h);
void clearClip();
// Save the current back buffer (call before endFrame) as a BMP file.
bool saveScreenshot(const std::string& path);
// Monotonic frame counter (incremented by endFrame) and the output scale (pixels per
// logical px; 1.0 on PS5).
uint64_t frameCount() const;
float scale() const;
private:
struct Impl;
std::unique_ptr<Impl> d_;
};
} // namespace xc
+79
View File
@@ -0,0 +1,79 @@
// Nubix — tiny self-contained unit test framework (host only).
//
// XC_TEST(suite, name) { XC_CHECK(cond); XC_CHECK_EQ(a, b); XC_REQUIRE(cond); }
//
// Run: xc-tests [suite ...] (no args = all suites). Exit code = number of failed tests.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstdio>
#include <functional>
#include <sstream>
#include <string>
#include <vector>
namespace xctest {
struct Failure {}; // thrown by XC_REQUIRE to abort the current test
struct TestCase {
const char* suite;
const char* name;
std::function<void()> fn;
};
std::vector<TestCase>& registry();
// Record a failed check for the currently running test.
void fail(const char* file, int line, const std::string& msg);
struct Registrar {
Registrar(const char* suite, const char* name, std::function<void()> fn) {
registry().push_back({suite, name, std::move(fn)});
}
};
template <typename T>
std::string show(const T& v) {
std::ostringstream os;
os << v;
return os.str();
}
inline std::string show(const std::string& v) { return "\"" + v + "\""; }
inline std::string show(const char* v) { return v ? "\"" + std::string(v) + "\"" : "(null)"; }
inline std::string show(unsigned char v) { return std::to_string(static_cast<unsigned>(v)); }
inline std::string show(signed char v) { return std::to_string(static_cast<int>(v)); }
inline std::string show(bool v) { return v ? "true" : "false"; }
} // namespace xctest
#define XC_CAT2(a, b) a##b
#define XC_CAT(a, b) XC_CAT2(a, b)
#define XC_TEST(suite, name) \
static void XC_CAT(xc_test_##suite##_, name)(); \
static ::xctest::Registrar XC_CAT(xc_reg_##suite##_, name)(#suite, #name, \
&XC_CAT(xc_test_##suite##_, name)); \
static void XC_CAT(xc_test_##suite##_, name)()
#define XC_CHECK(cond) \
do { \
if (!(cond)) ::xctest::fail(__FILE__, __LINE__, "CHECK(" #cond ")"); \
} while (0)
#define XC_REQUIRE(cond) \
do { \
if (!(cond)) { \
::xctest::fail(__FILE__, __LINE__, "REQUIRE(" #cond ")"); \
throw ::xctest::Failure{}; \
} \
} while (0)
#define XC_CHECK_EQ(a, b) \
do { \
const auto& xc_a_ = (a); \
const auto& xc_b_ = (b); \
if (!(xc_a_ == xc_b_)) \
::xctest::fail(__FILE__, __LINE__, \
"CHECK_EQ(" #a ", " #b "): " + ::xctest::show(xc_a_) + " != " + ::xctest::show(xc_b_)); \
} while (0)
+123
View File
@@ -0,0 +1,123 @@
// Nubix — config persistence tests.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include <unistd.h>
#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <thread>
#include <vector>
#include "core/config.hpp"
#include "core/gssv.hpp"
#include "test.hpp"
using namespace xc;
namespace {
std::string tempPath() {
char tmpl[] = "/tmp/xc-config-test-XXXXXX";
int fd = mkstemp(tmpl);
if (fd >= 0) close(fd);
std::remove(tmpl);
return tmpl;
}
} // namespace
XC_TEST(config, missing_file_gives_defaults_and_install_id) {
Config c(tempPath());
XC_CHECK(c.load());
XC_CHECK_EQ(c.settings().resolution, std::string("1080"));
XC_CHECK_EQ(c.installId().size(), size_t(36));
}
XC_TEST(config, roundtrip) {
const std::string path = tempPath();
{
Config c(path);
c.load();
c.updateSettings([](Settings& s) {
s.resolution = "720";
s.bitrateKbps = 8000;
s.f2pFallback = false;
});
c.updateTokens([](Tokens& t) {
t.msaRefresh = "refresh-token";
t.gsExpiry = 1234567890123LL;
t.gamertag = "Tester";
});
XC_REQUIRE(c.save());
}
Config d(path);
XC_REQUIRE(d.load());
XC_CHECK_EQ(d.settings().resolution, std::string("720"));
XC_CHECK_EQ(d.settings().bitrateKbps, 8000);
XC_CHECK(!d.settings().f2pFallback);
XC_CHECK_EQ(d.tokens().msaRefresh, std::string("refresh-token"));
XC_CHECK_EQ(d.tokens().gsExpiry, int64_t(1234567890123LL));
XC_CHECK_EQ(d.tokens().gamertag, std::string("Tester"));
std::remove(path.c_str());
}
XC_TEST(config, uuid_format) {
const std::string a = generateUuid(), b = generateUuid();
XC_REQUIRE(a.size() == 36u);
XC_CHECK(a != b);
XC_CHECK_EQ(a[8], '-');
XC_CHECK_EQ(a[14], '4');
}
// Readers take snapshots while another thread replaces the region lists and saves: no torn
// state, every snapshot is one of the published versions.
XC_TEST(config, concurrent_snapshots_and_updates) {
const std::string path = tempPath();
Config c(path);
XC_REQUIRE(c.load());
const uint64_t rev0 = c.revision();
std::atomic<bool> stop{false};
std::atomic<int> bad{0};
std::thread writer([&] {
for (int i = 0; i < 300; ++i) {
c.updateTokens([i](Tokens& t) {
t.xcloudRegions.assign(static_cast<size_t>(1 + i % 7), Region{"R" + std::to_string(i), "https://x", false});
t.gamertag = "G" + std::to_string(i);
});
c.updateSettings([i](Settings& s) { s.region = "R" + std::to_string(i); });
if (i % 50 == 0) c.save();
}
stop = true;
});
while (!stop) {
const Tokens t = c.tokens();
for (const auto& r : t.xcloudRegions)
if (r.name != t.xcloudRegions.front().name) ++bad;
if (!t.xcloudRegions.empty() && "G" + t.xcloudRegions.front().name.substr(1) != t.gamertag) ++bad;
(void)c.settings().region.size();
}
writer.join();
XC_CHECK_EQ(bad.load(), 0);
XC_CHECK(c.revision() >= rev0 + 600);
XC_REQUIRE(c.save());
Config d(path);
XC_REQUIRE(d.load());
XC_CHECK_EQ(d.tokens().gamertag, std::string("G299"));
XC_CHECK_EQ(d.settings().region, std::string("R299"));
std::remove(path.c_str());
}
XC_TEST(config, gssv_device_tier_and_auth_rejection) {
// xhome: always the android (720) fingerprint, cloud: the user's tier
XC_CHECK_EQ(gssv::deviceTierFor(true, "1080"), std::string("720"));
XC_CHECK_EQ(gssv::deviceTierFor(true, "1080HQ"), std::string("720"));
XC_CHECK_EQ(gssv::deviceTierFor(false, "1080HQ"), std::string("1080HQ"));
XC_CHECK_EQ(std::string(gssv::osNameForResolution(gssv::deviceTierFor(true, "1080"))), std::string("android"));
XC_CHECK(gssv::deviceInfoJson(gssv::deviceTierFor(true, "1080")).find("\"android\"") != std::string::npos);
XC_CHECK(gssv::isAuthRejection(401, ""));
XC_CHECK(gssv::isAuthRejection(403, ""));
XC_CHECK(gssv::isAuthRejection(403, "<html>Forbidden</html>"));
XC_CHECK(!gssv::isAuthRejection(403, "{\"code\":\"OfferingAccessDenied\",\"message\":\"no\"}"));
XC_CHECK(!gssv::isAuthRejection(400, ""));
XC_CHECK(!gssv::isAuthRejection(200, ""));
}
+63
View File
@@ -0,0 +1,63 @@
// Nubix — unit test runner.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include <cstring>
#include <exception>
#include <set>
#include "test.hpp"
namespace xctest {
namespace {
int g_currentFailures = 0;
}
std::vector<TestCase>& registry() {
static std::vector<TestCase> r;
return r;
}
void fail(const char* file, int line, const std::string& msg) {
++g_currentFailures;
std::fprintf(stderr, " %s:%d: %s\n", file, line, msg.c_str());
}
} // namespace xctest
int main(int argc, char** argv) {
std::set<std::string> suites;
for (int i = 1; i < argc; ++i) suites.insert(argv[i]);
if (suites.count("--list")) {
for (const auto& t : xctest::registry()) std::printf("%s.%s\n", t.suite, t.name);
return 0;
}
int run = 0, failed = 0;
for (const auto& t : xctest::registry()) {
if (!suites.empty() && !suites.count(t.suite)) continue;
++run;
xctest::g_currentFailures = 0;
std::printf("[ RUN ] %s.%s\n", t.suite, t.name);
try {
t.fn();
} catch (const xctest::Failure&) {
} catch (const std::exception& e) {
xctest::fail(__FILE__, __LINE__, std::string("unexpected exception: ") + e.what());
} catch (...) {
xctest::fail(__FILE__, __LINE__, "unexpected non-std exception");
}
if (xctest::g_currentFailures) {
++failed;
std::printf("[ FAIL ] %s.%s\n", t.suite, t.name);
} else {
std::printf("[ OK ] %s.%s\n", t.suite, t.name);
}
}
std::printf("%d tests, %d failed\n", run, failed);
if (run == 0) {
std::fprintf(stderr, "no tests matched\n");
return 1;
}
return failed > 255 ? 255 : failed;
}
+73
View File
@@ -0,0 +1,73 @@
// Nubix — resolver fallbacks used by libjuice/libdatachannel on PS5 (net_compat.c).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include <arpa/inet.h>
#include <net/if.h>
#include <netinet/in.h>
#include <cstring>
#include <string>
#include "platform/net_compat.h"
#include "test.hpp"
XC_TEST(net, passive_null_node_gives_ipv4_any) {
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_DGRAM;
hints.ai_protocol = IPPROTO_UDP;
hints.ai_flags = AI_PASSIVE | AI_NUMERICSERV;
addrinfo* res = nullptr;
XC_CHECK_EQ(xc_getaddrinfo(nullptr, "0", &hints, &res), 0);
XC_CHECK(res != nullptr);
XC_CHECK_EQ(res->ai_family, AF_INET);
XC_CHECK_EQ(res->ai_socktype, SOCK_DGRAM);
auto* sin = reinterpret_cast<sockaddr_in*>(res->ai_addr);
XC_CHECK_EQ(sin->sin_addr.s_addr, htonl(INADDR_ANY));
XC_CHECK_EQ(sin->sin_port, htons(0));
XC_CHECK(res->ai_next == nullptr);
xc_freeaddrinfo(res);
}
XC_TEST(net, numeric_hosts) {
addrinfo hints{};
hints.ai_flags = AI_NUMERICHOST | AI_NUMERICSERV;
hints.ai_socktype = SOCK_DGRAM;
addrinfo* res = nullptr;
XC_CHECK_EQ(xc_getaddrinfo("20.40.60.80", "9002", &hints, &res), 0);
XC_CHECK_EQ(res->ai_family, AF_INET);
XC_CHECK_EQ(reinterpret_cast<sockaddr_in*>(res->ai_addr)->sin_port, htons(9002));
char host[64], serv[16];
XC_CHECK_EQ(xc_getnameinfo(res->ai_addr, res->ai_addrlen, host, sizeof host, serv, sizeof serv,
NI_NUMERICHOST | NI_NUMERICSERV), 0);
XC_CHECK_EQ(std::string(host), std::string("20.40.60.80"));
XC_CHECK_EQ(std::string(serv), std::string("9002"));
xc_freeaddrinfo(res);
res = nullptr;
XC_CHECK_EQ(xc_getaddrinfo("2001:0:4137:9e76::1", "1", &hints, &res), 0);
XC_CHECK_EQ(res->ai_family, AF_INET6);
xc_freeaddrinfo(res);
res = nullptr;
XC_CHECK(xc_getaddrinfo("example.com", "1", &hints, &res) != 0); // AI_NUMERICHOST: no lookup
XC_CHECK(res == nullptr);
}
XC_TEST(net, family_filter) {
addrinfo hints{};
hints.ai_family = AF_INET6;
hints.ai_flags = AI_NUMERICHOST;
addrinfo* res = nullptr;
XC_CHECK(xc_getaddrinfo("1.2.3.4", "1", &hints, &res) != 0);
}
XC_TEST(net, ifaddrs_has_usable_ipv4) {
ifaddrs* list = nullptr;
XC_CHECK_EQ(xc_getifaddrs(&list), 0);
bool found = false;
for (ifaddrs* i = list; i; i = i->ifa_next)
if (i->ifa_addr && i->ifa_addr->sa_family == AF_INET && !(i->ifa_flags & IFF_LOOPBACK)) found = true;
XC_CHECK(found);
xc_freeifaddrs(list);
}
+544
View File
@@ -0,0 +1,544 @@
// Nubix — protocol byte-level tests (input packets, vibration, teredo, JSON messages).
// Expected values derived from green-nx and xbox-xcloud-player.
// Portions derived from green-nx (https://github.com/rmrf404/green-nx), Copyright (C) the green-nx authors, GPL-3.0; modified by Nubix contributors, 2026.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include <cstring>
#include <nlohmann/json.hpp>
#include "core/protocol.hpp"
#include "test.hpp"
using namespace xc;
using nlohmann::json;
namespace {
uint16_t u16(const std::vector<uint8_t>& b, size_t o) { return uint16_t(b[o] | (b[o + 1] << 8)); }
uint32_t u32(const std::vector<uint8_t>& b, size_t o) {
return uint32_t(b[o]) | (uint32_t(b[o + 1]) << 8) | (uint32_t(b[o + 2]) << 16) | (uint32_t(b[o + 3]) << 24);
}
} // namespace
XC_TEST(protocol, client_metadata_layout) {
proto::InputSerializer s;
auto p = s.clientMetadata(1);
XC_REQUIRE(p.size() == 15u);
XC_CHECK_EQ(u16(p, 0), uint16_t(proto::report::ClientMetadata));
XC_CHECK_EQ(p[14], uint8_t(1));
}
XC_TEST(protocol, gamepad_layout) {
proto::InputSerializer s;
s.clientMetadata(1);
GamepadState st;
st.buttons = btn::A | btn::DUp | btn::RS;
st.lx = 1000; st.ly = -2000; st.rx = 32767; st.ry = -32767;
st.lt = 65535; st.rt = 1234;
auto p = s.gamepad(st, 0);
XC_REQUIRE(p.size() == 38u);
XC_CHECK_EQ(u16(p, 0), uint16_t(proto::report::Gamepad));
XC_CHECK_EQ(p[14], uint8_t(1)); // count
XC_CHECK_EQ(p[15], uint8_t(0)); // gamepad index
XC_CHECK_EQ(u16(p, 16), uint16_t(btn::A | btn::DUp | btn::RS));
XC_CHECK_EQ(int16_t(u16(p, 18)), int16_t(1000));
XC_CHECK_EQ(int16_t(u16(p, 20)), int16_t(-2000));
XC_CHECK_EQ(int16_t(u16(p, 22)), int16_t(32767));
XC_CHECK_EQ(int16_t(u16(p, 24)), int16_t(-32767));
XC_CHECK_EQ(u16(p, 26), uint16_t(65535));
XC_CHECK_EQ(u16(p, 28), uint16_t(1234));
XC_CHECK_EQ(u32(p, 30), 1u); // PhysicalPhysicality, LE 1
// VirtualPhysicality: big-endian 1 -> bytes 00 00 00 01
XC_CHECK_EQ(p[34], uint8_t(0));
XC_CHECK_EQ(p[35], uint8_t(0));
XC_CHECK_EQ(p[36], uint8_t(0));
XC_CHECK_EQ(p[37], uint8_t(1));
}
XC_TEST(protocol, sequence_increments_and_rollback) {
proto::InputSerializer s;
auto a = s.clientMetadata(1);
auto b = s.gamepad(GamepadState{}, 0);
auto c = s.gamepad(GamepadState{}, 0);
XC_REQUIRE(a.size() >= 6 && b.size() >= 6 && c.size() >= 6);
XC_CHECK_EQ(u32(b, 2), u32(a, 2) + 1);
XC_CHECK_EQ(u32(c, 2), u32(b, 2) + 1);
s.rollback();
auto d = s.gamepad(GamepadState{}, 0);
XC_REQUIRE(d.size() >= 6);
XC_CHECK_EQ(u32(d, 2), u32(c, 2));
}
XC_TEST(protocol, timestamp_is_monotonic_double) {
proto::InputSerializer s;
auto a = s.gamepad(GamepadState{}, 0);
auto b = s.gamepad(GamepadState{}, 0);
XC_REQUIRE(a.size() >= 14 && b.size() >= 14);
double ta, tb;
std::memcpy(&ta, &a[6], 8);
std::memcpy(&tb, &b[6], 8);
XC_CHECK(ta >= 0.0);
XC_CHECK(tb >= ta);
}
XC_TEST(protocol, parse_vibration) {
const uint8_t pkt[] = {0x80, 0x00, 0x00, 0x00, 100, 50, 10, 20, 0xE8, 0x03, 0x05, 0x00, 0x02};
Vibration v;
XC_REQUIRE(proto::parseVibration(pkt, sizeof(pkt), v));
XC_CHECK_EQ(v.pad, uint8_t(0));
XC_CHECK_EQ(v.left, uint8_t(100));
XC_CHECK_EQ(v.right, uint8_t(50));
XC_CHECK_EQ(v.lt, uint8_t(10));
XC_CHECK_EQ(v.rt, uint8_t(20));
XC_CHECK_EQ(v.durationMs, uint16_t(1000));
XC_CHECK_EQ(v.delayMs, uint16_t(5));
XC_CHECK_EQ(v.repeat, uint8_t(2));
}
XC_TEST(protocol, parse_vibration_rejects_other_reports) {
const uint8_t pkt[] = {0x10, 0x00, 0x38, 0x04, 0x00, 0x00, 0x80, 0x07, 0x00, 0x00};
Vibration v;
XC_CHECK(!proto::parseVibration(pkt, sizeof(pkt), v));
XC_CHECK(!proto::parseVibration(pkt, 1, v));
}
XC_TEST(protocol, parse_server_metadata) {
// reportType 16, u32 height @2, u32 width @6
const uint8_t pkt[] = {0x10, 0x00, 0x38, 0x04, 0x00, 0x00, 0x80, 0x07, 0x00, 0x00};
uint32_t w = 0, h = 0;
XC_REQUIRE(proto::parseServerMetadata(pkt, sizeof(pkt), w, h));
XC_CHECK_EQ(w, 1920u);
XC_CHECK_EQ(h, 1080u);
}
XC_TEST(protocol, teredo_rfc4380_example) {
std::string ip;
uint16_t port = 0;
XC_REQUIRE(proto::teredoDecode("2001:0000:4136:e378:8000:63bf:3fff:fdd2", ip, port));
XC_CHECK_EQ(ip, std::string("192.0.2.45"));
XC_CHECK_EQ(port, uint16_t(40000));
XC_REQUIRE(proto::teredoDecode("2001:0:4136:e378:8000:63bf:3fff:fdd2", ip, port));
XC_CHECK_EQ(ip, std::string("192.0.2.45"));
XC_CHECK_EQ(port, uint16_t(40000));
}
XC_TEST(protocol, teredo_rejects_non_teredo) {
std::string ip;
uint16_t port = 0;
XC_CHECK(!proto::teredoDecode("2a01:4f8::1", ip, port));
XC_CHECK(!proto::teredoDecode("192.168.1.1", ip, port));
XC_CHECK(!proto::teredoDecode("garbage", ip, port));
}
XC_TEST(protocol, teredo_candidate_expansion) {
std::vector<std::string> in = {
"a=candidate:1 1 UDP 100 2001:0:4136:e378:8000:63bf:3fff:fdd2 1234 typ host",
"a=candidate:2 1 UDP 50 20.1.2.3 1000 typ host"};
auto out = proto::expandTeredoCandidates(in);
bool sawOrig = false, saw40000 = false, saw9002 = false, sawPlain = false;
for (const auto& c : out) {
if (c.find("2001:0:4136:e378") != std::string::npos) sawOrig = true;
if (c.find(" 192.0.2.45 40000 ") != std::string::npos) saw40000 = true;
if (c.find(" 192.0.2.45 9002 ") != std::string::npos) saw9002 = true;
if (c.find(" 20.1.2.3 1000 ") != std::string::npos) sawPlain = true;
}
XC_CHECK(sawOrig);
XC_CHECK(saw40000);
XC_CHECK(saw9002);
XC_CHECK(sawPlain);
}
XC_TEST(protocol, control_messages_are_json) {
auto kf = json::parse(proto::controlKeyframeRequest(), nullptr, false);
XC_REQUIRE(!kf.is_discarded());
XC_CHECK_EQ(kf.value("message", std::string()), std::string("videoKeyframeRequested"));
XC_CHECK(kf.value("ifrRequested", false));
XC_CHECK(!json::parse(proto::controlAuthorization(), nullptr, false).is_discarded());
XC_CHECK(!json::parse(proto::controlGamepadChanged(0, true), nullptr, false).is_discarded());
XC_CHECK(!json::parse(proto::controlResolution("1080"), nullptr, false).is_discarded());
XC_CHECK(!json::parse(proto::messageHandshake(), nullptr, false).is_discarded());
}
XC_TEST(protocol, message_init_sequence) {
Settings s;
auto msgs = proto::messageInitSequence(s, "00000000-0000-4000-8000-000000000000", 1920, 1080);
XC_REQUIRE(!msgs.empty());
for (const auto& m : msgs) XC_CHECK(!json::parse(m, nullptr, false).is_discarded());
auto tc = json::parse(proto::transactionComplete("abc", "{}"), nullptr, false);
XC_CHECK(!tc.is_discarded());
}
XC_TEST(protocol, ice_post_body_green_nx_shape) {
std::vector<std::string> cands = {"candidate:1 1 UDP 2122260223 192.168.1.2 50000 typ host"};
auto body = json::parse(proto::icePostBody(cands, "UFRAG"), nullptr, false);
XC_REQUIRE(!body.is_discarded());
XC_CHECK_EQ(body.value("messageType", std::string()), std::string("iceCandidate"));
XC_REQUIRE(body.contains("candidate") && body["candidate"].is_array());
XC_REQUIRE(body["candidate"].size() == 2u); // candidate + end-of-candidates
auto first = json::parse(body["candidate"][0].get<std::string>(), nullptr, false);
XC_REQUIRE(!first.is_discarded());
XC_CHECK_EQ(first.value("candidate", std::string()), cands[0]);
XC_CHECK_EQ(first.value("sdpMid", std::string()), std::string("0"));
XC_CHECK_EQ(first.value("sdpMLineIndex", -1), 0);
XC_CHECK_EQ(first.value("usernameFragment", std::string()), std::string("UFRAG"));
auto last = json::parse(body["candidate"][1].get<std::string>(), nullptr, false);
XC_CHECK_EQ(last.value("candidate", std::string()), std::string("a=end-of-candidates"));
}
// ---------------------------------------------------------------------------------------------
// Byte-exact vectors (derived from green-nx xcloud_protocol.cpp / xcloud-player packet.ts)
// ---------------------------------------------------------------------------------------------
namespace {
// Compare everything except the 8 timestamp bytes at [6, 14).
bool sameIgnoringTimestamp(const std::vector<uint8_t>& got, const std::vector<uint8_t>& want) {
if (got.size() != want.size()) return false;
for (size_t i = 0; i < got.size(); ++i) {
if (i >= 6 && i < 14) continue;
if (got[i] != want[i]) return false;
}
return true;
}
std::string hex(const std::vector<uint8_t>& b) {
static const char* d = "0123456789abcdef";
std::string s;
for (uint8_t c : b) {
s += d[c >> 4];
s += d[c & 15];
s += ' ';
}
return s;
}
} // namespace
XC_TEST(protocol, client_metadata_exact_bytes_green_nx_sequence) {
proto::InputSerializer s;
XC_CHECK_EQ(s.nextSequence(), 1u);
auto p = s.clientMetadata(0);
// u16 8 | u32 seq=1 (green-nx pre-increments) | f64 ts | u8 maxTouchpoints
const std::vector<uint8_t> want = {0x08, 0x00, 0x01, 0x00, 0x00, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0x00};
XC_CHECK(sameIgnoringTimestamp(p, want));
if (!sameIgnoringTimestamp(p, want)) std::printf(" got %s\n want %s\n", hex(p).c_str(), hex(want).c_str());
XC_CHECK_EQ(s.nextSequence(), 2u);
}
XC_TEST(protocol, gamepad_exact_bytes) {
proto::InputSerializer s;
s.clientMetadata(1); // seq 1
GamepadState st;
st.buttons = btn::Nexus | btn::A | btn::LB | btn::RS; // 0x9012
st.lx = 0x1234; st.ly = -1; st.rx = -32767; st.ry = 32767;
st.lt = 0xABCD; st.rt = 0x0001;
auto p = s.gamepad(st, 2);
const std::vector<uint8_t> want = {
0x02, 0x00, // reportType Gamepad
0x02, 0x00, 0x00, 0x00, // seq 2
0, 0, 0, 0, 0, 0, 0, 0, // timestamp (ignored)
0x01, // count
0x02, // +0 index
0x12, 0x90, // +1 buttons
0x34, 0x12, // +3 lx
0xFF, 0xFF, // +5 ly = -1
0x01, 0x80, // +7 rx = -32767
0xFF, 0x7F, // +9 ry = 32767
0xCD, 0xAB, // +11 lt
0x01, 0x00, // +13 rt
0x01, 0x00, 0x00, 0x00, // +15 PhysicalPhysicality LE 1
0x00, 0x00, 0x00, 0x01, // +19 VirtualPhysicality BE 1
};
XC_REQUIRE(want.size() == proto::kGamepadPacketSize);
XC_CHECK(sameIgnoringTimestamp(p, want));
if (!sameIgnoringTimestamp(p, want)) std::printf(" got %s\n want %s\n", hex(p).c_str(), hex(want).c_str());
}
XC_TEST(protocol, rollback_never_underflows_and_reset) {
proto::InputSerializer s;
s.rollback();
XC_CHECK_EQ(s.nextSequence(), 1u);
s.gamepad(GamepadState{}, 0);
s.gamepad(GamepadState{}, 0);
XC_CHECK_EQ(s.nextSequence(), 3u);
s.rollback();
XC_CHECK_EQ(s.nextSequence(), 2u);
auto p = s.gamepad(GamepadState{}, 0);
XC_CHECK_EQ(u32(p, 2), 2u);
s.reset();
XC_CHECK_EQ(s.nextSequence(), 1u);
XC_CHECK_EQ(u32(s.clientMetadata(), 2), 1u);
}
XC_TEST(protocol, control_messages_exact) {
XC_CHECK_EQ(proto::controlAuthorization(),
std::string(R"({"accessKey":"4BDB3609-C1F1-4195-9B37-FEFF45DA8B8E","message":"authorizationRequest"})"));
XC_CHECK_EQ(proto::controlGamepadChanged(0, true),
std::string(R"({"gamepadIndex":0,"message":"gamepadChanged","wasAdded":true})"));
XC_CHECK_EQ(proto::controlGamepadChanged(1, false),
std::string(R"({"gamepadIndex":1,"message":"gamepadChanged","wasAdded":false})"));
XC_CHECK_EQ(proto::controlResolution("1080HQ"),
std::string(R"({"message":"userRequestedResolutionUpdate","resolutionAlias":"1080HQ"})"));
XC_CHECK_EQ(proto::controlKeyframeRequest(),
std::string(R"({"ifrRequested":true,"message":"videoKeyframeRequested"})"));
}
namespace {
bool isUuidV4(const std::string& u) {
if (u.size() != 36) return false;
for (size_t i = 0; i < u.size(); ++i) {
if (i == 8 || i == 13 || i == 18 || i == 23) {
if (u[i] != '-') return false;
} else if (!((u[i] >= '0' && u[i] <= '9') || (u[i] >= 'a' && u[i] <= 'f'))) {
return false;
}
}
return u[14] == '4' && (u[19] == '8' || u[19] == '9' || u[19] == 'a' || u[19] == 'b');
}
} // namespace
XC_TEST(protocol, uuid_and_handshake) {
auto a = proto::newUuid(), b = proto::newUuid();
XC_CHECK(isUuidV4(a));
XC_CHECK(isUuidV4(b));
XC_CHECK(a != b);
auto h = json::parse(proto::messageHandshake());
XC_CHECK_EQ(h["type"].get<std::string>(), std::string("Handshake"));
XC_CHECK_EQ(h["version"].get<std::string>(), std::string("messageV1"));
XC_CHECK_EQ(h["cv"].get<std::string>(), std::string("0"));
XC_CHECK(isUuidV4(h["id"].get<std::string>()));
XC_CHECK_EQ(h.size(), size_t(4));
}
XC_TEST(protocol, message_init_sequence_exact_content) {
Settings s;
s.bitrateKbps = 15000;
const std::string install = "c97d7ee0-73b2-4239-bf1d-9d805a338429";
auto msgs = proto::messageInitSequence(s, install, 1280, 720);
XC_REQUIRE(msgs.size() == 6u);
const char* targets[] = {"/streaming/systemUi/configuration",
"/streaming/properties/clientappinstallidchanged",
"/streaming/characteristics/orientationchanged",
"/streaming/characteristics/touchinputenabledchanged",
"/streaming/characteristics/clientdevicecapabilities",
"/streaming/characteristics/dimensionschanged"};
const char* contents[] = {
R"({"systemUis":[],"version":[0,2,0]})",
R"({"clientAppInstallId":"c97d7ee0-73b2-4239-bf1d-9d805a338429"})",
R"({"orientation":0})",
R"({"touchInputEnabled":false})",
R"({"maxBitrateKbps":15000,"maxHeight":720,"maxWidth":1280,"supportsCustomResolution":true,"supportsFps":60,"supportsHdr":false,"supportsHevc":false,"video":{"height":720,"maxBitrateKbps":15000,"maxHeight":720,"maxWidth":1280,"width":1280}})",
R"({"horizontal":1280,"preferredHeight":720,"preferredWidth":1280,"safeAreaBottom":720,"safeAreaLeft":0,"safeAreaRight":1280,"safeAreaTop":0,"supportsCustomResolution":true,"vertical":720})",
};
std::vector<std::string> ids;
for (size_t i = 0; i < msgs.size(); ++i) {
auto j = json::parse(msgs[i]);
XC_CHECK_EQ(j.size(), size_t(5));
XC_CHECK_EQ(j["type"].get<std::string>(), std::string("Message"));
XC_CHECK_EQ(j["target"].get<std::string>(), std::string(targets[i]));
XC_CHECK_EQ(j["cv"].get<std::string>(), std::string(""));
XC_REQUIRE(j["content"].is_string()); // content is a JSON *string*
XC_CHECK_EQ(j["content"].get<std::string>(), std::string(contents[i]));
XC_CHECK(isUuidV4(j["id"].get<std::string>()));
ids.push_back(j["id"].get<std::string>());
}
for (size_t i = 0; i < ids.size(); ++i)
for (size_t k = i + 1; k < ids.size(); ++k) XC_CHECK(ids[i] != ids[k]);
// empty install id -> generated uuid
auto gen = proto::messageInitSequence(s, "", 1920, 1080);
auto c = json::parse(json::parse(gen[1])["content"].get<std::string>());
XC_CHECK(isUuidV4(c["clientAppInstallId"].get<std::string>()));
}
XC_TEST(protocol, transaction_complete_and_unhandled_exact) {
XC_CHECK_EQ(proto::transactionComplete("abc", R"({"Result":0})"),
std::string(R"({"content":"{\"Result\":0}","cv":"","id":"abc","type":"TransactionComplete"})"));
XC_CHECK_EQ(proto::transactionComplete("x", "{}", "cv.1"),
std::string(R"({"content":"{}","cv":"cv.1","id":"x","type":"TransactionComplete"})"));
XC_CHECK_EQ(proto::messageUnhandled("i", "/t", "c"),
std::string(R"({"cv":"c","id":"i","target":"/t","type":"Unhandled"})"));
auto env = json::parse(proto::messageEnvelope("/a/b", R"({"k":1})"));
XC_CHECK_EQ(env["content"].get<std::string>(), std::string(R"({"k":1})"));
XC_CHECK_EQ(env["target"].get<std::string>(), std::string("/a/b"));
}
XC_TEST(protocol, parse_channel_message) {
proto::ChannelMessage m;
XC_REQUIRE(proto::parseChannelMessage(
R"({"type":"TransactionStart","id":"42","target":"/streaming/systemUi/messages/ShowMessageDialog","content":"{\"TitleText\":\"Hi\"}","cv":"abc.1"})",
m));
XC_CHECK_EQ(m.type, std::string("TransactionStart"));
XC_CHECK_EQ(m.id, std::string("42"));
XC_CHECK_EQ(m.target, std::string(proto::target::kShowMessageDialog));
XC_CHECK_EQ(m.content, std::string(R"({"TitleText":"Hi"})"));
XC_CHECK_EQ(m.cv, std::string("abc.1"));
XC_REQUIRE(proto::parseChannelMessage(R"({"type":"Message","content":{"reason":"x"}})", m));
XC_CHECK_EQ(m.content, std::string(R"({"reason":"x"})"));
XC_CHECK(m.id.empty());
XC_CHECK(proto::isHandshakeAck(R"({"type":"HandshakeAck","version":"messageV1","id":"x","cv":"0"})"));
XC_CHECK(!proto::isHandshakeAck(R"({"type":"Handshake"})"));
XC_CHECK(!proto::isHandshakeAck("not json"));
XC_CHECK(!proto::parseChannelMessage("[1,2]", m));
XC_CHECK(!proto::parseChannelMessage(R"({"type":5})", m));
}
XC_TEST(protocol, parse_vibration_after_server_metadata) {
// reportType 0x90 = ServerMetadata | Vibration: 8 bytes of size, then the vibration section
const uint8_t pkt[] = {0x90, 0x00, 0xD0, 0x02, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00,
0x00, 0x01, 7, 8, 9, 10, 0x10, 0x00, 0x20, 0x00, 3};
Vibration v;
XC_REQUIRE(proto::parseVibration(pkt, sizeof(pkt), v));
XC_CHECK_EQ(v.pad, uint8_t(1));
XC_CHECK_EQ(v.left, uint8_t(7));
XC_CHECK_EQ(v.right, uint8_t(8));
XC_CHECK_EQ(v.lt, uint8_t(9));
XC_CHECK_EQ(v.rt, uint8_t(10));
XC_CHECK_EQ(v.durationMs, uint16_t(16));
XC_CHECK_EQ(v.delayMs, uint16_t(32));
XC_CHECK_EQ(v.repeat, uint8_t(3));
uint32_t w = 0, h = 0;
XC_REQUIRE(proto::parseServerMetadata(pkt, sizeof(pkt), w, h));
XC_CHECK_EQ(w, 1280u);
XC_CHECK_EQ(h, 720u);
XC_CHECK(!proto::parseVibration(pkt, sizeof(pkt) - 1, v)); // truncated
const uint8_t only[] = {0x80, 0x00, 0, 0, 1, 2, 3, 4, 0, 0, 0, 0}; // 12 bytes, one short
XC_CHECK(!proto::parseVibration(only, sizeof(only), v));
XC_CHECK(!proto::parseServerMetadata(only, sizeof(only), w, h));
XC_CHECK(!proto::parseVibration(nullptr, 0, v));
}
XC_TEST(protocol, teredo_forms) {
std::string ip;
uint16_t port = 0;
// compressed zero run + upper-case + zone id
XC_REQUIRE(proto::teredoDecode("2001::4136:E378:8000:63BF:3FFF:FDD2", ip, port));
XC_CHECK_EQ(ip, std::string("192.0.2.45"));
XC_CHECK_EQ(port, uint16_t(40000));
XC_REQUIRE(proto::teredoDecode(" 2001:0:4136:e378:8000:63bf:3fff:fdd2%en0 ", ip, port));
XC_CHECK_EQ(ip, std::string("192.0.2.45"));
// all-ones fields invert to zero
XC_REQUIRE(proto::teredoDecode("2001:0:0:0:0:ffff:ffff:ffff", ip, port));
XC_CHECK_EQ(ip, std::string("0.0.0.0"));
XC_CHECK_EQ(port, uint16_t(0));
// 2001:db8::/32 is documentation space, not Teredo
XC_CHECK(!proto::teredoDecode("2001:db8::1", ip, port));
XC_CHECK(ip.empty());
XC_CHECK(!proto::teredoDecode("", ip, port));
}
XC_TEST(protocol, teredo_expansion_exact) {
std::vector<std::string> in = {
"a=candidate:1 1 UDP 100 13.104.1.2 1234 typ host",
"a=candidate:2 1 UDP 2130706431 2001:0:4136:e378:8000:63bf:3fff:fdd2 9002 typ host",
"candidate:3 1 udp 50 2001:0:4136:e378:8000:63bf:3fff:fdd2 9003 typ srflx raddr 0.0.0.0 rport 0",
"a=end-of-candidates",
};
auto out = proto::expandTeredoCandidates(in);
const std::vector<std::string> want = {
"a=candidate:1 1 UDP 100 13.104.1.2 1234 typ host",
"a=candidate:20 1 UDP 1 192.0.2.45 40000 typ host",
"a=candidate:21 1 UDP 1 192.0.2.45 9002 typ host",
"a=candidate:2 1 UDP 2130706431 2001:0:4136:e378:8000:63bf:3fff:fdd2 9002 typ host",
"candidate:22 1 UDP 1 192.0.2.45 40000 typ host",
"candidate:23 1 UDP 1 192.0.2.45 9002 typ host",
"candidate:3 1 udp 50 2001:0:4136:e378:8000:63bf:3fff:fdd2 9003 typ srflx raddr 0.0.0.0 rport 0",
"a=end-of-candidates",
};
XC_REQUIRE(out.size() == want.size());
for (size_t i = 0; i < want.size(); ++i) XC_CHECK_EQ(out[i], want[i]);
}
XC_TEST(protocol, ice_post_body_exact) {
std::vector<std::string> cands = {
"a=candidate:1 1 UDP 2122260223 192.168.1.2 50000 typ host\r\n",
"candidate:2 1 TCP 1518280447 192.168.1.2 9 typ host tcptype active",
"garbage",
};
const std::string want =
R"({"candidate":[)"
R"("{\"candidate\":\"candidate:1 1 UDP 2122260223 192.168.1.2 50000 typ host\",\"sdpMLineIndex\":0,\"sdpMid\":\"0\",\"usernameFragment\":\"Uf\"}",)"
R"("{\"candidate\":\"a=end-of-candidates\",\"sdpMLineIndex\":0,\"sdpMid\":\"0\",\"usernameFragment\":\"Uf\"}"],)"
R"("messageType":"iceCandidate"})";
XC_CHECK_EQ(proto::icePostBody(cands, "Uf"), want);
}
XC_TEST(protocol, sdp_post_body_exact) {
const std::string body = proto::sdpPostBody("v=0\r\n");
const std::string want =
R"({"configuration":{"chat":{"maxVersion":1,"minVersion":1},)"
R"("chatConfiguration":{"bytesPerSample":2,"expectedClipDurationMs":20,"format":{"codec":"opus","container":"webm"},"numChannels":1,"sampleFrequencyHz":24000},)"
R"("control":{"maxVersion":3,"minVersion":1},"input":{"maxVersion":9,"minVersion":1},"message":{"maxVersion":1,"minVersion":1},)"
R"("reliableinput":{"maxVersion":9,"minVersion":9},"unreliableinput":{"maxVersion":9,"minVersion":9}},)"
R"("messageType":"offer","requestId":"1","sdp":"v=0\r\n"})";
XC_CHECK_EQ(body, want);
}
namespace {
std::string expectedOffer(const char* profile, const char* caps) {
return std::string("v=0\r\n"
"o=- 4611731400430051 2 IN IP4 127.0.0.1\r\n"
"s=-\r\n"
"t=0 0\r\n"
"a=group:BUNDLE video audio 0\r\n"
"a=ice-ufrag:uFrAg\r\n"
"a=ice-pwd:pWd\r\n"
"a=fingerprint:sha-256 AB:CD\r\n"
"a=setup:actpass\r\n"
"m=video 9 UDP/TLS/RTP/SAVPF 102\r\n"
"c=IN IP4 0.0.0.0\r\n"
"a=mid:video\r\n"
"a=rtcp-mux\r\n"
"a=rtcp-rsize\r\n"
"a=rtpmap:102 H264/90000\r\n"
"a=fmtp:102 level-asymmetry-allowed=0;packetization-mode=1;profile-level-id=") +
profile + ";" + caps +
"\r\n"
"a=rtcp-fb:102 goog-remb\r\n"
"a=rtcp-fb:102 ccm fir\r\n"
"a=rtcp-fb:102 nack\r\n"
"a=rtcp-fb:102 nack pli\r\n"
"a=recvonly\r\n"
"m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n"
"c=IN IP4 0.0.0.0\r\n"
"a=mid:audio\r\n"
"a=rtcp-mux\r\n"
"a=rtcp-rsize\r\n"
"a=rtpmap:111 opus/48000/2\r\n"
"a=fmtp:111 minptime=10;useinbandfec=1;stereo=1\r\n"
"a=recvonly\r\n"
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n"
"c=IN IP4 0.0.0.0\r\n"
"a=mid:0\r\n"
"a=sctp-port:5000\r\n"
"a=max-message-size:262144\r\n";
}
} // namespace
XC_TEST(protocol, offer_exact_template) {
XC_CHECK_EQ(proto::buildOffer("uFrAg", "pWd", "AB:CD", false, "1080"),
expectedOffer("42e01f", "max-fs=8160;max-mbps=489600"));
XC_CHECK_EQ(proto::buildOffer("uFrAg", "pWd", "AB:CD", false, "1080HQ"),
expectedOffer("42e01f", "max-fs=8160;max-mbps=489600"));
XC_CHECK_EQ(proto::buildOffer("uFrAg", "pWd", "AB:CD", false, "720"),
expectedOffer("42e01f", "max-fs=3600;max-mbps=108000"));
XC_CHECK_EQ(proto::buildOffer("uFrAg", "pWd", "AB:CD", true, "1080"),
expectedOffer("42e020", "max-fs=3600;max-mbps=108000"));
XC_CHECK_EQ(proto::buildOffer("uFrAg\r\n", " pWd", "sha-256 AB:CD", true, "720"),
expectedOffer("42e020", "max-fs=3600;max-mbps=108000"));
XC_CHECK(proto::buildOffer("", "p", "AB", false, "720").empty());
XC_CHECK(proto::buildOffer("u", "", "AB", false, "720").empty());
XC_CHECK(proto::buildOffer("u", "p", "", false, "720").empty());
}
XC_TEST(protocol, invalid_utf8_never_throws) {
bool threw = false;
std::string out;
try {
out = proto::transactionComplete(std::string("id\xff"), std::string("{\"x\":\"\xc3\"}"));
out += proto::messageUnhandled(std::string("\xfe"), "/t");
out += proto::controlResolution(std::string("\xff"));
out += proto::icePostBody({std::string("candidate:1 1 UDP 1 1.2.3.4 5 typ host \xff")}, std::string("\xff"));
} catch (...) {
threw = true;
}
XC_CHECK(!threw);
XC_CHECK(!out.empty());
}
+96
View File
@@ -0,0 +1,96 @@
// Nubix — request pacing tests (token bucket, 429 cooldown/backoff, session start cap).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include <string>
#include "core/ratelimit.hpp"
#include "test.hpp"
using namespace xc;
XC_TEST(ratelimit, url_host) {
XC_CHECK_EQ(urlHost("https://WEU.core.gssv-play-prod.xboxlive.com/v5/sessions?x=1"),
std::string("weu.core.gssv-play-prod.xboxlive.com"));
XC_CHECK_EQ(urlHost("https://login.live.com:443/oauth20_token.srf"), std::string("login.live.com"));
XC_CHECK_EQ(urlHost("http://u:p@host.example#frag"), std::string("host.example"));
}
XC_TEST(ratelimit, retry_after_parsing) {
XC_CHECK_EQ(parseRetryAfter("", 0), -1);
XC_CHECK_EQ(parseRetryAfter(" 7 ", 0), 7);
XC_CHECK_EQ(parseRetryAfter("999999999", 0), 3600);
XC_CHECK_EQ(parseRetryAfter("soon", 0), -1);
// Sun, 06 Nov 1994 08:49:37 GMT = 784111777
XC_CHECK_EQ(parseRetryAfter("Sun, 06 Nov 1994 08:49:37 GMT", 784111777 - 30), 30);
XC_CHECK_EQ(parseRetryAfter("Sun, 06 Nov 1994 08:49:37 GMT", 784111777 + 30), 0);
}
XC_TEST(ratelimit, bucket_allows_burst_then_paces) {
RateLimiter rl;
const std::string h = "api.example";
const auto p = RateLimiter::policyFor(h);
int granted = 0;
for (int i = 0; i < 100; ++i)
if (rl.reserve(h, 1000) == 0) ++granted;
XC_CHECK_EQ(granted, static_cast<int>(p.burst));
const int64_t wait = rl.reserve(h, 1000);
XC_CHECK(wait > 0 && wait <= static_cast<int64_t>(1000 / p.ratePerSec) + 1);
XC_CHECK_EQ(rl.reserve(h, 1000 + wait), int64_t(0));
// Other hosts are independent.
XC_CHECK_EQ(rl.reserve("other.example", 1000), int64_t(0));
}
XC_TEST(ratelimit, cooldown_honours_retry_after) {
RateLimiter rl;
const std::string h = "api.example";
rl.onResponse(h, 429, 10, 5000);
bool cooldown = false;
XC_CHECK_EQ(rl.reserve(h, 5000, &cooldown), int64_t(10000));
XC_CHECK(cooldown);
XC_CHECK_EQ(rl.cooldownRemaining(h, 14000), int64_t(1000));
XC_CHECK(rl.reserve(h, 15000, &cooldown) > 0); // bucket restarts empty after a 429
XC_CHECK(!cooldown);
}
XC_TEST(ratelimit, exponential_backoff_without_header) {
RateLimiter rl;
const std::string h = "api.example";
rl.onResponse(h, 429, -1, 0);
XC_CHECK_EQ(rl.cooldownRemaining(h, 0), int64_t(2000));
rl.onResponse(h, 429, -1, 2000);
XC_CHECK_EQ(rl.cooldownRemaining(h, 2000), int64_t(4000));
rl.onResponse(h, 429, -1, 6000);
XC_CHECK_EQ(rl.cooldownRemaining(h, 6000), int64_t(8000));
for (int i = 0; i < 20; ++i) rl.onResponse(h, 429, -1, 100000);
XC_CHECK_EQ(rl.cooldownRemaining(h, 100000), RateLimiter::kMaxBackoffMs);
// A normal answer resets the backoff ladder.
rl.onResponse(h, 200, -1, 300000);
rl.onResponse(h, 429, -1, 300000);
XC_CHECK_EQ(rl.cooldownRemaining(h, 300000), int64_t(2000));
}
XC_TEST(ratelimit, plain_503_and_transport_errors_do_not_cool_down) {
RateLimiter rl;
rl.onResponse("a", 503, -1, 0);
rl.onResponse("a", 0, -1, 0);
XC_CHECK_EQ(rl.cooldownRemaining("a", 0), int64_t(0));
rl.onResponse("a", 503, 5, 0);
XC_CHECK_EQ(rl.cooldownRemaining("a", 0), int64_t(5000));
}
XC_TEST(ratelimit, session_start_cap) {
SessionStartLimiter sl;
int wait = 0;
int64_t t = 0;
for (int i = 0; i < SessionStartLimiter::kMaxCloud; ++i) {
XC_CHECK(sl.allow(false, t, wait));
t += 3000;
}
XC_CHECK(!sl.allow(false, t, wait));
XC_CHECK(wait > 0 && wait <= 300);
// Spacing applies across kinds.
XC_CHECK(!sl.allow(true, t - 3000 + 500, wait));
XC_CHECK(sl.allow(true, t, wait));
// After the window the oldest start expires.
XC_CHECK(sl.allow(false, SessionStartLimiter::kWindowMs + 1, wait));
}
+419
View File
@@ -0,0 +1,419 @@
// Nubix — RTP H.264 depacketizer / Opus receiver tests.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include <cstring>
#include "stream/rtp.hpp"
#include "test.hpp"
using namespace xc;
namespace {
std::vector<uint8_t> rtp(uint16_t seq, uint32_t ts, bool marker, const std::vector<uint8_t>& payload,
uint8_t pt = 102, uint32_t ssrc = 0x11223344) {
std::vector<uint8_t> p(12);
p[0] = 0x80;
p[1] = uint8_t((marker ? 0x80 : 0) | pt);
p[2] = uint8_t(seq >> 8); p[3] = uint8_t(seq);
p[4] = uint8_t(ts >> 24); p[5] = uint8_t(ts >> 16); p[6] = uint8_t(ts >> 8); p[7] = uint8_t(ts);
p[8] = uint8_t(ssrc >> 24); p[9] = uint8_t(ssrc >> 16); p[10] = uint8_t(ssrc >> 8); p[11] = uint8_t(ssrc);
p.insert(p.end(), payload.begin(), payload.end());
return p;
}
void push(RtpH264Depacketizer& d, const std::vector<uint8_t>& p) { d.push(p.data(), p.size()); }
const std::vector<uint8_t> kSps = {0x67, 0x42, 0xE0, 0x1F, 0xAA};
const std::vector<uint8_t> kPps = {0x68, 0xCE, 0x3C, 0x80};
bool contains(const std::vector<uint8_t>& hay, const std::vector<uint8_t>& needle) {
if (needle.size() > hay.size()) return false;
for (size_t i = 0; i + needle.size() <= hay.size(); ++i)
if (std::memcmp(&hay[i], needle.data(), needle.size()) == 0) return true;
return false;
}
std::vector<uint8_t> withStart(const std::vector<uint8_t>& nal) {
std::vector<uint8_t> v = {0, 0, 0, 1};
v.insert(v.end(), nal.begin(), nal.end());
return v;
}
} // namespace
XC_TEST(rtp, single_nal_frame) {
RtpH264Depacketizer d;
const std::vector<uint8_t> idr = {0x65, 0x88, 0x01, 0x02}; // IDR slice (first_mb = 0)
const std::vector<uint8_t> nal = {0x41, 0x9A, 0x01, 0x02, 0x03}; // non-IDR slice
push(d, rtp(9, 6000, true, idr));
push(d, rtp(10, 9000, true, nal));
std::vector<uint8_t> au;
uint32_t ts = 0;
bool key = false;
XC_REQUIRE(d.popFrame(au, ts, key));
XC_CHECK_EQ(ts, 6000u);
XC_CHECK(key);
XC_CHECK(au == withStart(idr));
XC_REQUIRE(d.popFrame(au, ts, key));
XC_CHECK_EQ(ts, 9000u);
XC_CHECK(!key);
XC_CHECK(au == withStart(nal));
XC_CHECK(!d.popFrame(au, ts, key));
}
XC_TEST(rtp, stap_a_and_fu_a_keyframe) {
RtpH264Depacketizer d;
// STAP-A (type 24) with SPS + PPS
std::vector<uint8_t> stap = {0x18};
stap.push_back(0); stap.push_back(uint8_t(kSps.size())); stap.insert(stap.end(), kSps.begin(), kSps.end());
stap.push_back(0); stap.push_back(uint8_t(kPps.size())); stap.insert(stap.end(), kPps.begin(), kPps.end());
push(d, rtp(100, 3000, false, stap));
// IDR (type 5, nri 3) split into 3 FU-A fragments
const std::vector<uint8_t> idr = {0x65, 1, 2, 3, 4, 5, 6, 7, 8, 9};
const uint8_t fuInd = (idr[0] & 0xE0) | 28;
push(d, rtp(101, 3000, false, {fuInd, uint8_t(0x80 | 5), 1, 2, 3}));
push(d, rtp(102, 3000, false, {fuInd, uint8_t(5), 4, 5, 6}));
push(d, rtp(103, 3000, true, {fuInd, uint8_t(0x40 | 5), 7, 8, 9}));
std::vector<uint8_t> au;
uint32_t ts = 0;
bool key = false;
XC_REQUIRE(d.popFrame(au, ts, key));
XC_CHECK(key);
XC_CHECK_EQ(ts, 3000u);
XC_CHECK(contains(au, withStart(kSps)));
XC_CHECK(contains(au, withStart(kPps)));
XC_CHECK(contains(au, withStart(idr)));
}
XC_TEST(rtp, reorders_within_window) {
RtpH264Depacketizer d;
const std::vector<uint8_t> idr = {0x65, 1, 2, 3, 4, 5, 6};
const uint8_t fuInd = (idr[0] & 0xE0) | 28;
push(d, rtp(201, 6000, false, {fuInd, uint8_t(0x80 | 5), 1, 2}));
push(d, rtp(203, 6000, true, {fuInd, uint8_t(0x40 | 5), 5, 6}));
push(d, rtp(202, 6000, false, {fuInd, uint8_t(5), 3, 4}));
std::vector<uint8_t> au;
uint32_t ts = 0;
bool key = false;
XC_REQUIRE(d.popFrame(au, ts, key));
XC_CHECK(contains(au, withStart(idr)));
XC_CHECK(d.takeNackList().empty());
}
XC_TEST(rtp, gap_produces_nack) {
RtpH264Depacketizer d;
push(d, rtp(65534, 1000, true, {0x41, 1}));
// 65535 and 0 missing (wraparound)
push(d, rtp(1, 2000, true, {0x41, 2}));
push(d, rtp(2, 3000, true, {0x41, 3}));
auto nacks = d.takeNackList();
bool s65535 = false, s0 = false;
for (auto s : nacks) {
if (s == 65535) s65535 = true;
if (s == 0) s0 = true;
}
XC_CHECK(s65535);
XC_CHECK(s0);
XC_CHECK(d.takeNackList().empty());
}
XC_TEST(rtp, opus_in_order_and_loss) {
RtpOpusReceiver r;
auto a = rtp(500, 960, false, {0xF8, 0x01}, 111);
auto b = rtp(501, 1920, false, {0xF8, 0x02}, 111);
auto c = rtp(503, 3840, false, {0xF8, 0x04}, 111);
r.push(a.data(), a.size());
r.push(b.data(), b.size());
r.push(c.data(), c.size());
std::vector<uint8_t> opus;
uint32_t ts = 0;
int lost = -1;
XC_REQUIRE(r.pop(opus, ts, lost));
XC_CHECK_EQ(ts, 960u);
XC_CHECK_EQ(lost, 0);
XC_CHECK_EQ(opus.size(), size_t(2));
XC_REQUIRE(r.pop(opus, ts, lost));
XC_CHECK_EQ(ts, 1920u);
// 502 missing: depending on the reorder window it may be reported now or after more packets
for (int i = 0; i < 10; ++i) {
auto p = rtp(uint16_t(504 + i), 4800u + 960u * i, false, {0xF8, 0x05}, 111);
r.push(p.data(), p.size());
}
XC_REQUIRE(r.pop(opus, ts, lost));
XC_CHECK_EQ(ts, 3840u);
XC_CHECK_EQ(lost, 1);
}
XC_TEST(rtp, stats_tracker_counts_loss) {
RtpStatsTracker t;
for (uint16_t s = 0; s < 100; ++s)
if (s % 10 != 5) t.onPacket(0xABCD, s, s * 3000u, 90000, 1000);
RtpReceiveStats st = t.snapshotForReport();
XC_CHECK_EQ(st.ssrc, 0xABCDu);
XC_CHECK_EQ(st.cumulativeLost, 10);
XC_CHECK_EQ(st.extHighestSeq & 0xFFFFu, 99u);
XC_CHECK(st.fractionLost > 0);
XC_CHECK_EQ(st.packets, uint64_t(90));
}
// ---- time-controlled scenarios (pushAt / takeNackListAt / needKeyframeAt) -----------------------
namespace {
void pushAt(RtpH264Depacketizer& d, const std::vector<uint8_t>& p, uint64_t t) { d.pushAt(p.data(), p.size(), t); }
const std::vector<uint8_t> kIdr = {0x65, 0x88, 0x10, 0x20};
const std::vector<uint8_t> kP = {0x41, 0x9A, 0x30, 0x40};
int drainFrames(RtpH264Depacketizer& d, std::vector<uint32_t>* tss = nullptr, int* keys = nullptr) {
std::vector<uint8_t> au;
uint32_t ts = 0;
bool key = false;
int n = 0;
while (d.popFrame(au, ts, key)) {
++n;
if (tss) tss->push_back(ts);
if (keys && key) ++*keys;
}
return n;
}
bool has(const std::vector<uint16_t>& v, uint16_t s) {
for (auto x : v)
if (x == s) return true;
return false;
}
} // namespace
XC_TEST(rtp, drops_p_frames_until_first_idr) {
RtpH264Depacketizer d;
pushAt(d, rtp(1, 1000, true, kP), 0);
pushAt(d, rtp(2, 2500, true, kP), 16);
XC_CHECK_EQ(drainFrames(d), 0);
pushAt(d, rtp(3, 4000, true, kIdr), 33);
pushAt(d, rtp(4, 5500, true, kP), 50);
int keys = 0;
XC_CHECK_EQ(drainFrames(d, nullptr, &keys), 2);
XC_CHECK_EQ(keys, 1);
XC_CHECK(!d.counters().waitingForKeyframe);
}
XC_TEST(rtp, fu_a_across_sequence_wrap) {
RtpH264Depacketizer d;
const uint8_t ind = 0x60 | 28;
pushAt(d, rtp(65534, 7000, false, {ind, uint8_t(0x80 | 5), 0x88, 1}), 0);
pushAt(d, rtp(65535, 7000, false, {ind, 5, 2, 3}), 0);
pushAt(d, rtp(0, 7000, false, {ind, 5, 4, 5}), 0);
pushAt(d, rtp(1, 7000, true, {ind, uint8_t(0x40 | 5), 6}), 0);
std::vector<uint8_t> au;
uint32_t ts = 0;
bool key = false;
XC_REQUIRE(d.popFrame(au, ts, key));
XC_CHECK(key);
XC_CHECK(au == withStart({0x65, 0x88, 1, 2, 3, 4, 5, 6}));
XC_CHECK(d.takeNackListAt(1).empty());
}
XC_TEST(rtp, retransmission_completes_frame) {
RtpH264Depacketizer d;
pushAt(d, rtp(10, 1000, true, kIdr), 0);
const uint8_t ind = 0x40 | 28;
pushAt(d, rtp(11, 2500, false, {ind, uint8_t(0x80 | 1), 0x9A, 1}), 16);
// 12 lost
pushAt(d, rtp(13, 2500, true, {ind, uint8_t(0x40 | 1), 3}), 17);
XC_CHECK_EQ(drainFrames(d), 1); // only the IDR so far
auto n = d.takeNackListAt(18);
XC_CHECK(has(n, 12));
pushAt(d, rtp(12, 2500, false, {ind, 1, 2}), 30); // retransmission, 12 ms RTT
std::vector<uint8_t> au;
uint32_t ts = 0;
bool key = true;
XC_REQUIRE(d.popFrame(au, ts, key));
XC_CHECK_EQ(ts, 2500u);
XC_CHECK(!key);
XC_CHECK(au == withStart({0x41, 0x9A, 1, 2, 3}));
XC_CHECK(!d.needKeyframeAt(31));
XC_CHECK_EQ(d.counters().recovered, uint64_t(1));
XC_CHECK(d.counters().rttMs > 0);
}
XC_TEST(rtp, unrecoverable_loss_requests_keyframe_and_drops_until_idr) {
RtpH264Depacketizer d;
pushAt(d, rtp(100, 1000, true, kIdr), 0);
XC_CHECK_EQ(drainFrames(d), 1);
const uint8_t ind = 0x40 | 28;
pushAt(d, rtp(101, 2500, false, {ind, uint8_t(0x80 | 1), 0x9A}), 16);
// 102 (middle fragment) never arrives
pushAt(d, rtp(103, 2500, true, {ind, uint8_t(0x40 | 1), 9}), 16);
pushAt(d, rtp(104, 4000, true, kP), 33);
XC_CHECK(has(d.takeNackListAt(17), 102));
// NACK retries are limited
int retries = 0;
for (uint64_t t = 20; t < 50; t += 5) retries += static_cast<int>(d.takeNackListAt(t).size());
XC_CHECK(retries <= 3);
XC_CHECK_EQ(drainFrames(d), 0); // held while a retransmission may still arrive
XC_CHECK(!d.needKeyframeAt(30));
// after the loss wait (40 ms) the frame is given up
XC_CHECK(d.needKeyframeAt(60));
XC_CHECK(!d.needKeyframeAt(61)); // once
XC_CHECK_EQ(drainFrames(d), 0); // the following P-frame is not decodable either
pushAt(d, rtp(105, 5500, true, kP), 70);
XC_CHECK_EQ(drainFrames(d), 0);
// still waiting: request is repeated after the retry interval
XC_CHECK(d.needKeyframeAt(1100));
pushAt(d, rtp(106, 7000, true, kIdr), 1110);
pushAt(d, rtp(107, 8500, true, kP), 1120);
std::vector<uint32_t> tss;
XC_CHECK_EQ(drainFrames(d, &tss), 2);
XC_REQUIRE(tss.size() == 2);
XC_CHECK_EQ(tss[0], 7000u);
XC_CHECK(!d.needKeyframeAt(5000));
// late retransmission of the given-up packet is ignored
pushAt(d, rtp(102, 2500, false, {ind, 1, 5}), 1200);
XC_CHECK_EQ(drainFrames(d), 0);
XC_CHECK(d.counters().framesDropped >= 2);
}
XC_TEST(rtp, padding_packets_are_not_loss) {
RtpH264Depacketizer d;
pushAt(d, rtp(1, 1000, true, kIdr), 0);
// padding-only packet (P bit, all payload is padding), then next frame
std::vector<uint8_t> pad = rtp(2, 1000, false, {0, 0, 0, 4});
pad[0] |= 0x20;
pushAt(d, pad, 5);
pushAt(d, rtp(3, 2500, true, kP), 16);
XC_CHECK_EQ(drainFrames(d), 2);
// a lost padding packet between frames: frame after it is a clean picture start -> no keyframe
pushAt(d, rtp(5, 4000, true, kP), 33); // 4 lost
XC_CHECK(d.takeNackListAt(34).size() == 1);
XC_CHECK_EQ(drainFrames(d), 0);
XC_CHECK(!d.needKeyframeAt(80)); // gap given up after 40 ms, picture after it is clean
XC_CHECK_EQ(drainFrames(d), 1);
XC_CHECK(!d.counters().waitingForKeyframe);
}
XC_TEST(rtp, header_extension_csrc_and_padding_stripped) {
RtpH264Depacketizer d;
std::vector<uint8_t> p = rtp(50, 3000, true, {});
p[0] = 0x80 | 0x20 | 0x10 | 0x01; // P, X, CC=1
std::vector<uint8_t> tail = {0xDE, 0xAD, 0xBE, 0xEF, // CSRC
0xBE, 0xDE, 0x00, 0x01, 1, 2, 3, 4}; // extension, 1 word
tail.insert(tail.end(), kIdr.begin(), kIdr.end());
tail.insert(tail.end(), {0, 0, 3}); // 3 bytes padding
p.insert(p.end(), tail.begin(), tail.end());
pushAt(d, p, 0);
std::vector<uint8_t> au;
uint32_t ts = 0;
bool key = false;
XC_REQUIRE(d.popFrame(au, ts, key));
XC_CHECK(au == withStart(kIdr));
}
XC_TEST(rtp, marker_less_frame_ends_on_timestamp_change) {
RtpH264Depacketizer d;
pushAt(d, rtp(1, 1000, false, kIdr), 0);
XC_CHECK_EQ(drainFrames(d), 0);
pushAt(d, rtp(2, 2500, true, kP), 16);
XC_CHECK_EQ(drainFrames(d), 2);
}
XC_TEST(rtp, ssrc_latch_ignores_stray_and_follows_switch) {
RtpH264Depacketizer d;
pushAt(d, rtp(1, 1000, true, kIdr, 102, 0xAAAA), 0);
XC_CHECK_EQ(d.ssrc(), 0xAAAAu);
pushAt(d, rtp(500, 9999, true, kIdr, 102, 0xBBBB), 1); // stray packet of another source
XC_CHECK_EQ(d.ssrc(), 0xAAAAu);
XC_CHECK_EQ(drainFrames(d), 1);
for (uint16_t s = 0; s < 3; ++s) pushAt(d, rtp(uint16_t(900 + s), 20000u + s * 1500u, true, kIdr, 102, 0xCCCC), 10 + s);
XC_CHECK_EQ(d.ssrc(), 0xCCCCu);
XC_CHECK(drainFrames(d) >= 1);
}
XC_TEST(rtp, stats_lsr_dlsr_and_jitter) {
RtpStatsTracker t;
// perfectly paced 90 kHz stream: zero jitter
for (uint32_t i = 0; i < 50; ++i) t.onPacketAt(7, uint16_t(i), i * 1500u, 90000, 100, i * 16667ull);
t.onSenderReportAt(0x0123456789ABCDEFull, 1000);
RtpReceiveStats st = t.snapshotForReportAt(1500); // 0.5 s after SR
XC_CHECK_EQ(st.lastSr, 0x456789ABu);
XC_CHECK_EQ(st.delaySinceLastSr, 32768u); // 0.5 * 65536
XC_CHECK(st.jitter <= 1u);
XC_CHECK_EQ(st.cumulativeLost, 0);
XC_CHECK_EQ(st.fractionLost, uint8_t(0));
// wraparound counts a cycle
RtpStatsTracker w;
for (uint32_t i = 0; i < 20; ++i) w.onPacketAt(9, uint16_t(65530 + i), i * 960u, 48000, 10, i * 20000ull);
RtpReceiveStats ws = w.snapshotForReport();
XC_CHECK_EQ(ws.extHighestSeq, 0x10000u + 13u);
XC_CHECK_EQ(ws.cumulativeLost, 0);
// copyable
RtpStatsTracker c = w;
XC_CHECK(c.hasData());
}
XC_TEST(rtp, opus_gap_skipped_after_timeout_and_dtx) {
RtpOpusReceiver r;
auto a = rtp(10, 0, false, {0xF8, 1}, 111);
auto c = rtp(12, 1920, false, {0xF8, 3}, 111);
r.pushAt(a.data(), a.size(), 0);
r.pushAt(c.data(), c.size(), 20);
std::vector<uint8_t> opus;
uint32_t ts = 0;
int lost = -1;
XC_REQUIRE(r.popAt(opus, ts, lost, 20));
XC_CHECK_EQ(lost, 0);
XC_CHECK(!r.popAt(opus, ts, lost, 30)); // 11 may still arrive
XC_REQUIRE(r.popAt(opus, ts, lost, 61)); // reorder window (20 ms) has passed
XC_CHECK_EQ(ts, 1920u);
XC_CHECK_EQ(lost, 1);
// late packet is dropped
auto b = rtp(11, 960, false, {0xF8, 2}, 111);
r.pushAt(b.data(), b.size(), 62);
XC_CHECK(!r.popAt(opus, ts, lost, 200));
// empty (DTX) packet carries loss over to the next real packet
auto e = rtp(14, 3840, false, {}, 111);
auto f = rtp(15, 4800, false, {0xF8, 5}, 111);
r.pushAt(e.data(), e.size(), 300);
r.pushAt(f.data(), f.size(), 300);
XC_REQUIRE(r.popAt(opus, ts, lost, 400));
XC_CHECK_EQ(ts, 4800u);
XC_CHECK_EQ(lost, 1); // 13 missing
}
XC_TEST(rtp, opus_swapped_pair_played_in_order) {
RtpOpusReceiver r;
auto a = rtp(100, 0, false, {0xF8, 1}, 111);
auto b = rtp(101, 960, false, {0xF8, 2}, 111);
auto c = rtp(102, 1920, false, {0xF8, 3}, 111);
std::vector<uint8_t> opus;
uint32_t ts = 0;
int lost = -1;
r.pushAt(a.data(), a.size(), 0);
XC_REQUIRE(r.popAt(opus, ts, lost, 0));
// 102 overtakes 101 by one slot: nothing is concealed, both play in order
r.pushAt(c.data(), c.size(), 10);
XC_CHECK(!r.popAt(opus, ts, lost, 10));
r.pushAt(b.data(), b.size(), 15);
XC_REQUIRE(r.popAt(opus, ts, lost, 15));
XC_CHECK_EQ(ts, 960u);
XC_CHECK_EQ(lost, 0);
XC_REQUIRE(r.popAt(opus, ts, lost, 15));
XC_CHECK_EQ(ts, 1920u);
XC_CHECK_EQ(lost, 0);
}
XC_TEST(rtp, stats_sr_before_first_packet_is_kept) {
RtpStatsTracker t;
t.onSenderReportAt(0x0123456789ABCDEFull, 1000); // SR right after DTLS, before any RTP
for (uint32_t i = 0; i < 10; ++i) t.onPacketAt(5, uint16_t(i), i * 1500u, 90000, 100, 1000000ull + i * 16667ull);
RtpReceiveStats st = t.snapshotForReportAt(1250);
XC_CHECK_EQ(st.lastSr, 0x456789ABu);
XC_CHECK_EQ(st.delaySinceLastSr, 16384u); // 0.25 s
// a different SSRC later drops it again
t.onPacketAt(6, 0, 0, 90000, 100, 2000000ull);
RtpReceiveStats st2 = t.snapshotForReportAt(1300);
XC_CHECK_EQ(st2.lastSr, 0u);
XC_CHECK_EQ(st2.delaySinceLastSr, 0u);
}
+46
View File
@@ -0,0 +1,46 @@
// Nubix — SDP offer template tests.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include <nlohmann/json.hpp>
#include "core/protocol.hpp"
#include "test.hpp"
using namespace xc;
namespace {
const char* kFp = "AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89";
}
XC_TEST(sdp, offer_contains_credentials_and_bundle) {
for (bool home : {false, true}) {
const std::string o = proto::buildOffer("uFrAg", "pAsSwOrDpAsSwOrDpAsSwOrD", kFp, home, "1080");
XC_REQUIRE(!o.empty());
XC_CHECK(o.rfind("v=0\r\n", 0) == 0);
XC_CHECK(o.find("a=group:BUNDLE video audio 0") != std::string::npos);
XC_CHECK(o.find("a=ice-ufrag:uFrAg\r\n") != std::string::npos);
XC_CHECK(o.find("a=ice-pwd:pAsSwOrDpAsSwOrDpAsSwOrD\r\n") != std::string::npos);
XC_CHECK(o.find(std::string("a=fingerprint:sha-256 ") + kFp) != std::string::npos);
const size_t v = o.find("m=video"), a = o.find("m=audio"), d = o.find("m=application");
XC_REQUIRE(v != std::string::npos && a != std::string::npos && d != std::string::npos);
XC_CHECK(v < a && a < d);
XC_CHECK(o.find("a=mid:video") != std::string::npos);
XC_CHECK(o.find("a=mid:audio") != std::string::npos);
XC_CHECK(o.find("a=mid:0") != std::string::npos);
XC_CHECK(o.find("H264") != std::string::npos);
XC_CHECK(o.find("opus/48000/2") != std::string::npos);
XC_CHECK(o.find("a=recvonly") != std::string::npos);
// every line CRLF terminated
XC_CHECK(o.size() >= 2 && o.substr(o.size() - 2) == "\r\n");
XC_CHECK(o.find("\n") == o.find("\r\n") + 1);
}
}
XC_TEST(sdp, post_body) {
const std::string o = proto::buildOffer("u", "p", kFp, false, "720");
auto j = nlohmann::json::parse(proto::sdpPostBody(o), nullptr, false);
XC_REQUIRE(!j.is_discarded());
XC_CHECK_EQ(j.value("messageType", std::string()), std::string("offer"));
XC_CHECK_EQ(j.value("sdp", std::string()), o);
XC_CHECK(j.contains("configuration"));
}
+49
View File
@@ -0,0 +1,49 @@
// Nubix — smoke tests for vendored / scaffold pieces (always expected to pass).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include <nlohmann/json.hpp>
#include "core/log.hpp"
#include "platform/platform.hpp"
#include "test.hpp"
#include "ui/qr.hpp"
XC_TEST(smoke, qr_encodes_microsoft_link) {
std::vector<bool> modules;
int size = 0;
XC_REQUIRE(xc::qr::encode("https://www.microsoft.com/link?otc=ABCD1234", modules, size));
XC_CHECK(size >= 21);
XC_CHECK_EQ(modules.size(), static_cast<size_t>(size) * static_cast<size_t>(size));
// finder pattern: top-left 7x7 has dark corners
XC_CHECK(modules[0]);
XC_CHECK(modules[6]);
XC_CHECK(modules[static_cast<size_t>(6) * size]);
}
XC_TEST(smoke, qr_rejects_oversized_input) {
std::vector<bool> modules;
int size = 0;
XC_CHECK(!xc::qr::encode(std::string(8000, 'x'), modules, size));
XC_CHECK_EQ(size, 0);
}
XC_TEST(smoke, json_roundtrip) {
auto j = nlohmann::json::parse(R"({"a":1,"b":"x"})");
XC_CHECK_EQ(j["a"].get<int>(), 1);
XC_CHECK_EQ(j["b"].get<std::string>(), std::string("x"));
}
XC_TEST(smoke, log_does_not_crash) {
xc::logInit("", xc::LogLevel::Debug);
XC_LOGD("debug %d", 1);
XC_LOGE("error %s", "x");
xc::logSetLevel(xc::LogLevel::Info);
XC_CHECK(xc::logLevel() == xc::LogLevel::Info);
}
XC_TEST(smoke, platform_clock) {
XC_CHECK(xc::platform::unixTime() > 1700000000);
const uint64_t a = xc::platform::monotonicMs();
const uint64_t b = xc::platform::monotonicMs();
XC_CHECK(b >= a);
}
+24765
View File
File diff suppressed because it is too large. Load diff
+11
View File
@@ -0,0 +1,11 @@
# Extra domains the PS5 may reach through tools/sony_dns.py (one per line, '#' starts a comment).
#
# Everything Nubix needs is built into the script; you do NOT need to add anything to play.
# Each entry also allows all of its subdomains ("example.com" allows "www.example.com").
# Sony / PlayStation domains stay blocked even if you list them here.
#
# You are responsible for what you allow. Only add domains you know and trust.
#
# Examples (remove the leading '#' to enable):
# youtube.com
# googlevideo.com
+252
View File
@@ -0,0 +1,252 @@
// Nubix — xc-cli subcommands for the catalog + gssv session API (live testing).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include "cli_gssv.hpp"
#include <algorithm>
#include <atomic>
#include <cctype>
#include <chrono>
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#include "core/auth.hpp"
#include "core/catalog.hpp"
#include "core/config.hpp"
#include "core/gssv.hpp"
#include "core/log.hpp"
#include "platform/platform.hpp"
using namespace xc;
namespace {
std::atomic<bool> g_cancel{false};
void onCancel(int) { g_cancel = true; }
void sleepMs(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); }
int gssvUsage() {
std::fprintf(stderr,
"usage: xc-cli titles [filter]\n"
" xc-cli consoles\n"
" xc-cli play <titleId> [--no-connect] [--timeout S]\n"
" xc-cli play --home <serverId> [--timeout S]\n");
return 2;
}
std::string lower(std::string s) {
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
bool ensureAuth(Config& cfg, Auth& auth) {
if (!auth.isLoggedIn()) {
std::fprintf(stderr, "not logged in (run: xc-cli login)\n");
return false;
}
std::string err;
if (!auth.ensureFresh(err)) {
std::fprintf(stderr, "auth: %s\n", err.c_str());
return false;
}
cfg.save();
return true;
}
int cmdTitles(Config& cfg, Auth& auth, const std::string& filter) {
if (!ensureAuth(cfg, auth)) return 1;
Catalog cat(cfg);
std::vector<Title> titles;
std::string err;
const auto t0 = std::chrono::steady_clock::now();
if (!cat.fetchCloudTitles(titles, err)) {
std::fprintf(stderr, "titles: %s\n", err.c_str());
return 1;
}
const double secs = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
const std::string f = lower(filter);
size_t shown = 0;
for (const auto& t : titles) {
if (!f.empty() && lower(t.name).find(f) == std::string::npos && lower(t.titleId).find(f) == std::string::npos)
continue;
std::printf("%-28s %-14s %-3s %-4s %-3s %-4s %s%s%s\n", t.titleId.c_str(), t.productId.c_str(),
t.gamePass ? "GP" : "", t.f2p ? "F2P" : "", t.hasEntitlement ? "OWN" : "",
t.f2pOnly ? "ADS" : "", t.name.c_str(), t.publisher.empty() ? "" : " / ",
t.publisher.c_str());
if (!filter.empty() && !t.imageUrl.empty()) std::printf(" art: %s\n", t.imageUrl.c_str());
++shown;
}
std::printf("%zu/%zu playable titles (%.1f s)\n", shown, titles.size(), secs);
return 0;
}
int cmdConsoles(Config& cfg, Auth& auth) {
if (!ensureAuth(cfg, auth)) return 1;
Catalog cat(cfg);
std::vector<Console> consoles;
std::string err;
if (!cat.fetchConsoles(consoles, err)) {
std::fprintf(stderr, "consoles: %s\n", err.c_str());
return 1;
}
for (const auto& c : consoles)
std::printf("%-20s %-16s %-18s %s\n", c.serverId.c_str(), c.consoleType.c_str(), c.powerState.c_str(),
c.name.c_str());
std::printf("%zu consoles\n", consoles.size());
return 0;
}
// Start a session, drive it to Provisioned (or ReadyToConnect with --no-connect), then DELETE it.
int cmdPlay(Config& cfg, Auth& auth, SessionKind kind, const std::string& id, bool doConnect, int timeoutSec) {
if (!ensureAuth(cfg, auth)) return 1;
const Tokens tk = cfg.tokens();
// xhome: the console agent only accepts the android/720 fingerprint (see gssv::deviceTierFor).
Settings gs = cfg.settings();
gs.resolution = gssv::deviceTierFor(kind == SessionKind::Home, gs.resolution);
struct Offering {
const char* name;
std::string base, token;
};
std::vector<Offering> offerings;
if (kind == SessionKind::Home) {
offerings.push_back({"xhome", tk.xhomeBase, tk.xhomeGs});
} else {
if (!tk.xcloudGs.empty()) offerings.push_back({"xgpuweb", tk.xcloudBase, tk.xcloudGs});
if (!tk.xcloudF2pGs.empty() && (offerings.empty() || gs.f2pFallback))
offerings.push_back({"xgpuwebf2p", tk.xcloudF2pBase, tk.xcloudF2pGs});
}
if (offerings.empty() || offerings.front().token.empty()) {
std::fprintf(stderr, "no %s offering token for this account\n", kind == SessionKind::Home ? "xhome" : "xCloud");
return 1;
}
// Our own handler so Ctrl-C still DELETEs the session.
g_cancel = false;
auto prevInt = std::signal(SIGINT, onCancel);
auto prevTerm = std::signal(SIGTERM, onCancel);
int rc = 1;
std::string err;
SessionInfo s;
const Offering* used = nullptr;
std::unique_ptr<GssvClient> client;
for (const Offering& off : offerings) {
client.reset(new GssvClient(off.base, off.token, gs));
std::printf("offering %s (%s), osName %s\n", off.name, off.base.c_str(),
gssv::osNameForResolution(gs.resolution));
if (!client->cleanupActive(kind)) std::printf(" (active-session cleanup reported a problem, continuing)\n");
if (client->play(kind, id, s, err)) {
used = &off;
break;
}
std::fprintf(stderr, "play: %s\n", err.c_str());
if (!s.errorCode.empty()) std::fprintf(stderr, " code %s %s\n", s.errorCode.c_str(), s.errorMessage.c_str());
if (s.errorCode != kErrOfferingDoesNotContainTitle) break;
}
if (!used) {
std::signal(SIGINT, prevInt);
std::signal(SIGTERM, prevTerm);
return 1;
}
std::printf("session %s (%s) state %s\n", s.sessionId.c_str(), s.sessionPath.c_str(), s.state.c_str());
bool connected = false;
bool waitShown = false;
std::string lastState;
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeoutSec);
while (!g_cancel) {
if (std::chrono::steady_clock::now() > deadline) {
std::fprintf(stderr, "timeout after %d s in state %s\n", timeoutSec, s.state.c_str());
break;
}
if (!client->pollState(s, err)) {
std::fprintf(stderr, "state: %s\n", err.c_str());
break;
}
if (s.state != lastState) {
std::printf("state: %s\n", s.state.c_str());
lastState = s.state;
}
if (s.state == "WaitingForResources" && kind == SessionKind::Cloud && !waitShown) {
waitShown = true;
int w = client->waitTimeSeconds(id);
if (w >= 0) std::printf(" estimated wait: %d s\n", w);
} else if (s.state == "ReadyToConnect" && !connected) {
if (!doConnect) {
std::printf("ReadyToConnect reached (--no-connect)\n");
rc = 0;
break;
}
std::string lpt;
if (!auth.fetchLpt(lpt, err)) {
std::fprintf(stderr, "lpt: %s\n", err.c_str());
break;
}
if (!client->connect(s, lpt, err)) {
std::fprintf(stderr, "connect: %s\n", err.c_str());
break;
}
std::printf("connect sent\n");
connected = true;
} else if (s.state == "Provisioned") {
const int ka = client->keepAliveSeconds(s);
std::printf("provisioned; keepalive interval %d s\n", ka);
std::printf("keepalive: %s\n", client->keepAlive(s) ? "ok" : "failed");
rc = 0;
break;
} else if (s.state == "Failed") {
std::fprintf(stderr, "failed: %s %s\n", s.errorCode.c_str(), s.errorMessage.c_str());
break;
}
sleepMs(1000);
}
if (g_cancel) std::fprintf(stderr, "cancelled\n");
std::printf("stopping session: %s\n", client->stop(s) ? "ok" : "failed");
std::signal(SIGINT, prevInt);
std::signal(SIGTERM, prevTerm);
return rc;
}
} // namespace
bool cli_gssv_handles(const char* cmd) {
return cmd && (!std::strcmp(cmd, "titles") || !std::strcmp(cmd, "consoles") || !std::strcmp(cmd, "play"));
}
int cli_gssv(int argc, char** argv) {
if (argc < 1 || !cli_gssv_handles(argv[0])) return gssvUsage();
const std::string cmd = argv[0];
Config cfg(platform::dataDir() + "/config.json");
if (!cfg.load()) XC_LOGW("config %s could not be parsed, using defaults", cfg.path().c_str());
Auth auth(cfg);
if (cmd == "titles") return cmdTitles(cfg, auth, argc > 1 ? argv[1] : "");
if (cmd == "consoles") return cmdConsoles(cfg, auth);
// play
SessionKind kind = SessionKind::Cloud;
bool doConnect = true;
int timeoutSec = 300;
std::string id;
for (int i = 1; i < argc; ++i) {
const std::string a = argv[i];
if (a == "--home") kind = SessionKind::Home;
else if (a == "--no-connect") doConnect = false;
else if (a == "--timeout" && i + 1 < argc) timeoutSec = std::max(10, std::atoi(argv[++i]));
else if (!a.empty() && a[0] != '-' && id.empty()) id = a;
else return gssvUsage();
}
if (id.empty()) return gssvUsage();
return cmdPlay(cfg, auth, kind, id, doConnect, timeoutSec);
}
+19
View File
@@ -0,0 +1,19 @@
// Nubix — xc-cli subcommands for the catalog + gssv session API (live testing).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
// Run a gssv/catalog subcommand. argv[0] is the subcommand name:
// titles [filter] playable cloud titles (both offerings merged, hydrated)
// consoles xHome consoles
// play <titleId> [--no-connect] [--timeout S]
// start a cloud session, poll /state (queue, /connect at
// ReadyToConnect) until Provisioned, then DELETE it
// play --home <serverId> [--timeout S] same against a home console (xhome offering)
// Precondition: the caller has run platform::init(), logInit() and Http::globalInit().
// Uses the same config.json (platform::dataDir()) as the app. Returns a process exit code
// (0 ok, 1 failure, 2 usage).
int cli_gssv(int argc, char** argv);
// True if `cmd` is handled by cli_gssv().
bool cli_gssv_handles(const char* cmd);
+60
View File
@@ -0,0 +1,60 @@
// Nubix — dependency link check (not shipped). Touches every third-party library the app
// uses so that a full static link of every dependency is exercised independently of the app.
// Build: -DXC_BUILD_LINKCHECK=ON -> target xc-linkcheck. Running it prints a local SDP offer.
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include <SDL.h>
#include <SDL_ttf.h>
#include <curl/curl.h>
#include <opus.h>
#include <openssl/opensslv.h>
#include <openssl/rand.h>
#include <chrono>
#include <cstdio>
#include <future>
#include <rtc/rtc.hpp>
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/frame.h>
}
int main(int, char**) {
rtc::InitLogger(rtc::LogLevel::Warning);
rtc::Configuration cfg;
cfg.disableAutoNegotiation = true;
auto pc = std::make_shared<rtc::PeerConnection>(cfg);
rtc::Description::Video v("video", rtc::Description::Direction::RecvOnly);
v.addH264Codec(102, std::string("level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f"));
auto vt = pc->addTrack(v);
vt->chainMediaHandler(std::make_shared<rtc::RtcpReceivingSession>());
rtc::Description::Audio a("audio", rtc::Description::Direction::RecvOnly);
a.addOpusCodec(111);
auto at = pc->addTrack(a);
rtc::DataChannelInit init;
init.protocol = "messageV1";
auto dc = pc->createDataChannel("message", init);
std::promise<void> done;
std::atomic<bool> fired{false};
pc->onGatheringStateChange([&](rtc::PeerConnection::GatheringState s) {
if (s == rtc::PeerConnection::GatheringState::Complete && !fired.exchange(true)) done.set_value();
});
pc->setLocalDescription(rtc::Description::Type::Offer);
done.get_future().wait_for(std::chrono::seconds(5));
if (auto ld = pc->localDescription()) std::printf("%s\n", std::string(*ld).c_str());
unsigned char rnd[8];
std::printf("openssl %s rand=%d\n", OPENSSL_VERSION_TEXT, RAND_bytes(rnd, sizeof(rnd)));
std::printf("curl %s\n", curl_version());
std::printf("opus %s\n", opus_get_version_string());
std::printf("avcodec h264 %s\n", avcodec_find_decoder(AV_CODEC_ID_H264) ? "yes" : "no");
AVFrame* f = av_frame_alloc();
av_frame_free(&f);
SDL_version sv;
SDL_GetVersion(&sv);
std::printf("SDL %d.%d.%d ttf init=%d\n", sv.major, sv.minor, sv.patch, TTF_Init());
TTF_Quit();
pc->close();
return 0;
}
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env python3
# Nubix — protective DNS server for a jailbroken PS5: allow only what Nubix needs, block the rest.
# Copyright (C) 2026 Nubix contributors
# SPDX-License-Identifier: GPL-3.0-only
"""
Run this on a PC in the same network as the PS5, then set the PS5's Primary AND Secondary DNS to
the address this script prints.
Allowlist mode: every DNS query is BLOCKED (answered with 0.0.0.0 / ::) unless the name belongs to
1. the domains Nubix needs for Xbox Cloud Gaming (NUBIX_DOMAINS below), or
2. a domain you add yourself in allowlist.txt (next to this script) or with --allow.
Sony / PlayStation domains are ALWAYS blocked, even if they appear in your allowlist, so the
console cannot reach PSN, download a system update or send telemetry.
Allowed names are forwarded unchanged to an upstream DNS server (default 1.1.1.1).
You are responsible for every domain you add to the allowlist.
Only the Python standard library is used (Python 3.8+).
Windows (terminal "Run as administrator"): python tools\\sony_dns.py
macOS / Linux: sudo python3 tools/sony_dns.py
Options:
--allow example.com allow one more domain (with its subdomains; can be repeated)
--allowlist FILE allowlist file (default: allowlist.txt next to this script)
--map host=IP answer host with a fixed IPv4 address, e.g. for a jailbreak page
(works even for Sony names; can be repeated)
--upstream 9.9.9.9 upstream DNS server (default 1.1.1.1)
--log-all print every query (ALLOW / BLOCK / MAP) to see what the PS5 asks for
--quiet print nothing per query
--port 53 listening port (the PS5 always uses 53)
The PC has to stay on while the PS5 is online; give it a fixed IP in your router.
"""
import argparse
import ipaddress
import os
import socket
import socketserver
import struct
import sys
import threading
import time
# What Nubix itself contacts (see src/core/auth.cpp, gssv.cpp, catalog.cpp, stream/webrtc.cpp).
# Each entry also covers its subdomains.
NUBIX_DOMAINS = [
"login.microsoftonline.com", # Microsoft sign-in (device code, tokens)
"login.live.com", # transfer token for starting cloud sessions
"xboxlive.com", # Xbox Live tokens, profile, cloud gaming API and regions
"displaycatalog.mp.microsoft.com", # game names
"store-images.s-microsoft.com", # box art
"stun.l.google.com", # WebRTC: discovers the public address for the stream
]
# Always blocked, whatever the allowlist says (only --map can answer one of these names).
SONY_DOMAINS = [
"playstation.com",
"playstation.net",
"playstation.org",
"sonyentertainmentnetwork.com",
"scea.com",
"sbdnpd.com",
"ribob01.net",
]
TYPE_A, TYPE_AAAA = 1, 28
ANSWER_TTL = 300
UPSTREAM_TIMEOUT = 4.0
_print_lock = threading.Lock()
def log(msg):
with _print_lock:
print(time.strftime("%H:%M:%S"), msg, flush=True)
def normalize(domain):
return domain.strip().lower().strip(".")
def matches(name, domains):
return any(name == d or name.endswith("." + d) for d in domains)
def load_allowlist(path):
"""One domain per line; '#' starts a comment. Missing file = empty list."""
domains = []
if not path or not os.path.isfile(path):
return domains
with open(path, encoding="utf-8") as f:
for line in f:
entry = normalize(line.split("#", 1)[0])
if entry:
domains.append(entry)
return domains
def parse_question(packet):
"""Return (qname, qtype, end_offset_of_question) of the first question, or None."""
if len(packet) < 12 or struct.unpack(">H", packet[4:6])[0] < 1:
return None
labels, pos = [], 12
while True:
if pos >= len(packet):
return None
length = packet[pos]
if length == 0:
pos += 1
break
if length & 0xC0: # compression pointers do not occur in a query's question
return None
pos += 1
labels.append(packet[pos:pos + length].decode("ascii", "replace"))
pos += length
if pos + 4 > len(packet):
return None
qtype = struct.unpack(">H", packet[pos:pos + 2])[0]
return ".".join(labels).lower().rstrip("."), qtype, pos + 4
def local_response(query, qtype, question_end, ipv4=b"\x00\x00\x00\x00", ipv6=b"\x00" * 16):
"""Answer the question locally: A -> ipv4, AAAA -> ipv6 (None = no AAAA record),
any other type -> empty NOERROR answer."""
qid = query[0:2]
flags = struct.unpack(">H", query[2:4])[0]
resp_flags = 0x8000 | (flags & 0x7800) | (flags & 0x0100) | 0x0080 # QR, opcode, RD, RA
if qtype == TYPE_A:
answer = struct.pack(">HHHIH", 0xC00C, TYPE_A, 1, ANSWER_TTL, 4) + ipv4
elif qtype == TYPE_AAAA and ipv6 is not None:
answer = struct.pack(">HHHIH", 0xC00C, TYPE_AAAA, 1, ANSWER_TTL, 16) + ipv6
else:
answer = b""
header = qid + struct.pack(">HHHHH", resp_flags, 1, 1 if answer else 0, 0, 0)
return header + query[12:question_end] + answer
def servfail(query):
if len(query) < 12:
return b""
flags = struct.unpack(">H", query[2:4])[0]
resp_flags = 0x8000 | (flags & 0x7900) | 0x0080 | 2 # SERVFAIL
return query[0:2] + struct.pack(">HHHHH", resp_flags, 0, 0, 0, 0)
class Resolver:
def __init__(self, upstream, allowed, mapped, log_all, quiet):
self.upstream = upstream
self.allowed = allowed
self.mapped = mapped
self.log_all = log_all
self.quiet = quiet
self.counts = {"ALLOW": 0, "BLOCK": 0, "MAP": 0}
def _note(self, verdict, name):
self.counts[verdict] += 1
if self.quiet:
return
if self.log_all or verdict == "BLOCK":
log("%-5s %s" % (verdict, name))
def handle(self, query, tcp=False):
q = parse_question(query)
if not q:
return servfail(query)
name, qtype, end = q
if name in self.mapped:
self._note("MAP", name)
return local_response(query, qtype, end, ipv4=self.mapped[name], ipv6=None)
if matches(name, SONY_DOMAINS) or not matches(name, self.allowed):
self._note("BLOCK", name)
return local_response(query, qtype, end)
self._note("ALLOW", name)
try:
return self.forward_tcp(query) if tcp else self.forward_udp(query)
except OSError as e:
log("upstream error for %s: %s" % (name, e))
return servfail(query)
def forward_udp(self, query):
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.settimeout(UPSTREAM_TIMEOUT)
s.sendto(query, (self.upstream, 53))
while True:
data, _ = s.recvfrom(65535)
if data[:2] == query[:2]:
return data
def forward_tcp(self, query):
with socket.create_connection((self.upstream, 53), timeout=UPSTREAM_TIMEOUT) as s:
s.sendall(struct.pack(">H", len(query)) + query)
size = struct.unpack(">H", recv_exact(s, 2))[0]
return recv_exact(s, size)
def recv_exact(s, n):
buf = b""
while len(buf) < n:
chunk = s.recv(n - len(buf))
if not chunk:
raise OSError("connection closed")
buf += chunk
return buf
class UDPHandler(socketserver.BaseRequestHandler):
def handle(self):
data, sock = self.request
resp = self.server.resolver.handle(data)
if resp:
sock.sendto(resp, self.client_address)
class TCPHandler(socketserver.BaseRequestHandler):
def handle(self):
try:
while True:
size = struct.unpack(">H", recv_exact(self.request, 2))[0]
resp = self.server.resolver.handle(recv_exact(self.request, size), tcp=True)
self.request.sendall(struct.pack(">H", len(resp)) + resp)
except OSError:
pass
class ThreadingUDP(socketserver.ThreadingMixIn, socketserver.UDPServer):
daemon_threads = True
allow_reuse_address = True
class ThreadingTCP(socketserver.ThreadingMixIn, socketserver.TCPServer):
daemon_threads = True
allow_reuse_address = True
def lan_ip():
"""Address of this PC on the local network (no packet is sent)."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("192.0.2.1", 53))
return s.getsockname()[0]
except OSError:
return "this PC's IP address"
def parse_maps(values):
mapped = {}
for v in values:
host, sep, ip = v.partition("=")
try:
if not sep:
raise ValueError
mapped[normalize(host)] = ipaddress.IPv4Address(ip.strip()).packed
except ValueError:
sys.exit("--map expects host=IPv4, e.g. --map manuals.playstation.net=192.168.1.20 (got %r)" % v)
return mapped
def main():
default_list = os.path.join(os.path.dirname(os.path.abspath(__file__)), "allowlist.txt")
ap = argparse.ArgumentParser(description="Protective DNS for a jailbroken PS5: allows what Nubix needs, "
"blocks everything else and always blocks Sony.")
ap.add_argument("--allow", action="append", default=[], metavar="DOMAIN", help="allow one more domain")
ap.add_argument("--allowlist", default=default_list, metavar="FILE", help="allowlist file (default: %(default)s)")
ap.add_argument("--map", action="append", default=[], metavar="HOST=IP", help="answer HOST with a fixed IPv4")
ap.add_argument("--upstream", default="1.1.1.1", help="upstream DNS server (default 1.1.1.1)")
ap.add_argument("--port", type=int, default=53, help="listening port (default 53)")
ap.add_argument("--bind", default="0.0.0.0", help="listening address (default all interfaces)")
ap.add_argument("--log-all", action="store_true", help="print every query")
ap.add_argument("--quiet", action="store_true", help="print nothing per query")
args = ap.parse_args()
user = load_allowlist(args.allowlist) + [normalize(d) for d in args.allow if normalize(d)]
ignored = [d for d in user if matches(d, SONY_DOMAINS)]
allowed = NUBIX_DOMAINS + [d for d in user if d not in ignored]
mapped = parse_maps(args.map)
resolver = Resolver(args.upstream, allowed, mapped, args.log_all, args.quiet)
try:
udp = ThreadingUDP((args.bind, args.port), UDPHandler)
tcp = ThreadingTCP((args.bind, args.port), TCPHandler)
except PermissionError:
sys.exit("Permission denied for port %d: run as administrator (Windows) or with sudo (macOS/Linux)." % args.port)
except OSError as e:
sys.exit("Cannot listen on port %d: %s\nIs another DNS server already running on this PC?" % (args.port, e))
udp.resolver = tcp.resolver = resolver
for srv in (udp, tcp):
threading.Thread(target=srv.serve_forever, daemon=True).start()
print("Nubix protective DNS is running (allowlist mode: everything not listed is blocked).")
print(" On the PS5 set Primary DNS AND Secondary DNS to: %s" % lan_ip())
print(" Allowed for Nubix: %s" % ", ".join(NUBIX_DOMAINS))
extra = [d for d in user if d not in ignored]
print(" Allowed by you: %s" % (", ".join(extra) if extra else "(none; add domains to %s)" % args.allowlist))
if ignored:
print(" Ignored (Sony domains are always blocked): %s" % ", ".join(ignored))
if mapped:
print(" Fixed answers: %s" % ", ".join("%s=%s" % (h, socket.inet_ntoa(ip)) for h, ip in mapped.items()))
print(" Allowed queries go to %s. Press Ctrl+C to stop." % args.upstream)
try:
while True:
time.sleep(3600)
except KeyboardInterrupt:
c = resolver.counts
print("\nStopped. %d allowed, %d blocked, %d mapped." % (c["ALLOW"], c["BLOCK"], c["MAP"]))
print("Without this PC the PS5 has no DNS. Do not switch it back to automatic DNS while jailbroken.")
if __name__ == "__main__":
main()
+347
View File
@@ -0,0 +1,347 @@
// Nubix — host CLI for live API testing without the UI.
//
// xc-cli login device-code login (prints code + URL), then authorize streaming
// xc-cli devicecode only request + print a device code (live endpoint smoke test)
// xc-cli token refresh + authorize streaming, print offerings and regions
// xc-cli refresh refresh MSA token + gs tokens
// xc-cli status show stored account/token state
// xc-cli titles [filter] list playable cloud titles (tools/cli_gssv.cpp)
// xc-cli consoles list xHome consoles (tools/cli_gssv.cpp)
// xc-cli play <titleId>|--home <serverId> [--no-connect] [--timeout S]
// gssv session start/poll/connect/keepalive/stop (tools/cli_gssv.cpp)
// xc-cli session cloud|home ID gssv only: start, poll until Provisioned (or fail), stop
// xc-cli stream cloud|home ID [seconds] full headless stream via Streamer (no video output)
// xc-cli offer print a generated SDP offer (local WebRTC gathering only)
// xc-cli logout
//
// Options: --verbose. Data dir: $XC_DATA_DIR or the platform default (shared with the app).
// Copyright (C) 2026 Nubix contributors
// SPDX-License-Identifier: GPL-3.0-only
#include <SDL.h>
#include <atomic>
#include <chrono>
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <thread>
#include <vector>
extern "C" {
#include <libavutil/frame.h>
}
#include "core/auth.hpp"
#include "core/config.hpp"
#include "core/gssv.hpp"
#include "core/http.hpp"
#include "core/log.hpp"
#include "cli_gssv.hpp"
#include "core/protocol.hpp"
#include "platform/platform.hpp"
#include "stream/streamer.hpp"
#include "stream/webrtc.hpp"
using namespace xc;
namespace {
std::atomic<bool> g_stop{false};
void onSignal(int) { g_stop = true; }
void sleepMs(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); }
int usage() {
std::fprintf(stderr,
"usage: xc-cli [--verbose] <command>\n"
" login | devicecode | token | refresh | status | logout\n"
" titles [filter] | consoles\n"
" play <titleId> | play --home <serverId> [--no-connect] [--timeout S]\n"
" session <cloud|home> <titleId|serverId>\n"
" stream <cloud|home> <titleId|serverId> [seconds]\n"
" offer\n");
return 2;
}
bool parseKind(const std::string& s, SessionKind& k) {
if (s == "cloud") { k = SessionKind::Cloud; return true; }
if (s == "home") { k = SessionKind::Home; return true; }
return false;
}
int cmdLogin(Config& cfg, Auth& auth) {
DeviceCode dc;
std::string err;
if (!auth.requestDeviceCode(dc, err)) {
std::fprintf(stderr, "device code request failed: %s\n", err.c_str());
return 1;
}
std::printf("Open %s and enter code: %s\n(QR url: %s)\n", dc.verificationUri.c_str(), dc.userCode.c_str(),
dc.qrUrl.c_str());
std::fflush(stdout);
int interval = dc.interval;
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(dc.expiresIn);
while (!g_stop && std::chrono::steady_clock::now() < deadline) {
for (int i = 0; i < interval * 10 && !g_stop; ++i) sleepMs(100);
if (g_stop) break;
PollResult r = auth.pollToken(dc, err);
if (r == PollResult::Pending) {
if (err == "slow_down") interval += 5; // RFC 8628
continue;
}
if (r != PollResult::Success) {
std::fprintf(stderr, "login failed: %s\n", err.c_str());
return 1;
}
if (!auth.authorizeStreaming(err)) {
std::fprintf(stderr, "signed in, but authorizeStreaming failed: %s\n", err.c_str());
return 1;
}
cfg.save();
std::printf("Logged in as %s\n", cfg.gamertag().c_str());
return 0;
}
std::fprintf(stderr, "login expired/cancelled\n");
return 1;
}
// Only request a device code and print it (live smoke test of the MSAL endpoint; no login).
int cmdDeviceCode(Auth& auth) {
DeviceCode dc;
std::string err;
if (!auth.requestDeviceCode(dc, err)) {
std::fprintf(stderr, "device code request failed: %s\n", err.c_str());
return 1;
}
std::printf("user code : %s\n", dc.userCode.c_str());
std::printf("verify url : %s\n", dc.verificationUri.c_str());
std::printf("qr url : %s\n", dc.qrUrl.c_str());
std::printf("interval : %d s\n", dc.interval);
std::printf("expires in : %d s\n", dc.expiresIn);
if (!dc.message.empty()) std::printf("message : %s\n", dc.message.c_str());
return 0;
}
void printRegions(const char* name, const std::string& base, const std::vector<Region>& regions) {
if (regions.empty()) {
std::printf("%-11s : not available\n", name);
return;
}
std::printf("%-11s : %s\n", name, base.c_str());
for (const auto& r : regions)
std::printf(" %c %-22s %s%s\n", r.baseUri == base ? '*' : ' ', r.name.c_str(), r.baseUri.c_str(),
r.isDefault ? " (default)" : "");
}
// Refresh the MSA token + authorize streaming, then print offerings and regions.
int cmdToken(Config& cfg, Auth& auth) {
std::string err;
if (!auth.isLoggedIn()) {
std::fprintf(stderr, "not signed in - run: xc-cli login\n");
return 1;
}
if (!auth.refresh(err) || !auth.authorizeStreaming(err)) {
std::fprintf(stderr, "token: %s\n", err.c_str());
return 1;
}
const Tokens t = cfg.tokens();
std::printf("gamertag : %s\n", t.gamertag.c_str());
std::printf("market : %s\n", t.market.c_str());
std::printf("msa valid : %lld s\n", (long long)(t.msaExpiry - platform::unixTime()));
std::printf("gs valid : %lld s\n", (long long)(t.gsExpiry - platform::unixTime()));
std::printf("xbl web : %s\n", t.xblToken.empty() ? "-" : "token");
printRegions("xhome", t.xhomeBase, t.xhomeRegions);
printRegions("xgpuweb", t.xcloudBase, t.xcloudRegions);
printRegions("xgpuwebf2p", t.xcloudF2pBase, t.xcloudF2pRegions);
return 0;
}
int cmdStatus(const Config& cfg, const Auth& auth) {
const Tokens t = cfg.tokens();
std::printf("config : %s\n", cfg.path().c_str());
std::printf("installId : %s\n", cfg.installId().c_str());
std::printf("logged in : %s\n", auth.isLoggedIn() ? "yes" : "no");
std::printf("gamertag : %s\n", t.gamertag.c_str());
std::printf("msa expiry : %lld (now %lld)\n", (long long)t.msaExpiry, (long long)platform::unixTime());
std::printf("gs expiry : %lld\n", (long long)t.gsExpiry);
std::printf("xhome : %s %s\n", t.xhomeGs.empty() ? "-" : "token", t.xhomeBase.c_str());
std::printf("xcloud : %s %s\n", t.xcloudGs.empty() ? "-" : "token", t.xcloudBase.c_str());
std::printf("xcloud f2p : %s %s\n", t.xcloudF2pGs.empty() ? "-" : "token", t.xcloudF2pBase.c_str());
return 0;
}
int cmdSession(Config& cfg, Auth& auth, SessionKind kind, const std::string& id) {
std::string err;
if (!auth.ensureFresh(err)) {
std::fprintf(stderr, "auth: %s\n", err.c_str());
return 1;
}
const Tokens t = cfg.tokens();
Settings gs = cfg.settings();
gs.resolution = gssv::deviceTierFor(kind == SessionKind::Home, gs.resolution); // xhome: android only
GssvClient gssv(kind == SessionKind::Home ? t.xhomeBase : t.xcloudBase,
kind == SessionKind::Home ? t.xhomeGs : t.xcloudGs, gs);
gssv.cleanupActive(kind);
SessionInfo s;
if (!gssv.play(kind, id, s, err)) {
std::fprintf(stderr, "play: %s\n", err.c_str());
return 1;
}
std::printf("session %s (%s)\n", s.sessionId.c_str(), s.sessionPath.c_str());
bool connected = false;
int rc = 1;
while (!g_stop) {
if (!gssv.pollState(s, err)) {
std::fprintf(stderr, "state: %s\n", err.c_str());
break;
}
std::printf("state: %s\n", s.state.c_str());
if (s.state == "ReadyToConnect" && !connected) {
std::string lpt;
if (!auth.fetchLpt(lpt, err) || !gssv.connect(s, lpt, err)) {
std::fprintf(stderr, "connect: %s\n", err.c_str());
break;
}
connected = true;
} else if (s.state == "Provisioned") {
std::printf("provisioned; keepalive interval %d s\n", gssv.keepAliveSeconds(s));
rc = 0;
break;
} else if (s.state == "Failed") {
std::fprintf(stderr, "failed: %s %s\n", s.errorCode.c_str(), s.errorMessage.c_str());
break;
}
sleepMs(1000);
}
gssv.stop(s);
return rc;
}
int cmdStream(Config& cfg, Auth& auth, SessionKind kind, const std::string& id, int seconds) {
if (SDL_Init(SDL_INIT_AUDIO) != 0) std::fprintf(stderr, "SDL audio unavailable: %s\n", SDL_GetError());
int rc = 1;
{
Streamer st(cfg, auth);
if (!st.start(kind, id)) {
std::fprintf(stderr, "stream start failed\n");
SDL_Quit();
return 1;
}
const auto t0 = std::chrono::steady_clock::now();
StreamState last = StreamState::Idle;
uint64_t frames = 0;
while (!g_stop) {
StreamState s = st.state();
if (s != last) {
std::printf("state: %s %s\n", streamStateName(s), st.statusText().c_str());
last = s;
}
if (AVFrame* f = st.currentFrame()) {
++frames;
av_frame_free(&f);
}
if (s == StreamState::Ended) { rc = 0; break; }
if (s == StreamState::Failed) break;
if (s == StreamState::Streaming) {
rc = 0;
const double el = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
static int lastPrint = -1;
if (static_cast<int>(el) != lastPrint) {
lastPrint = static_cast<int>(el);
StreamStats ss = st.stats();
std::printf("%dx%d %.1f fps %.0f kbps decode %.1f ms loss %.1f%% frames %llu\n", ss.width,
ss.height, ss.fps, ss.bitrateKbps, ss.decodeMs, ss.lossPercent,
(unsigned long long)frames);
}
if (seconds > 0 && el >= seconds) break;
}
sleepMs(5);
}
st.stop();
}
SDL_Quit();
return rc;
}
int cmdOffer() {
WebRtc rtc;
WebRtc::Callbacks cb;
if (!rtc.init(cb)) {
std::fprintf(stderr, "webrtc init failed\n");
return 1;
}
std::string ufrag, pwd, fp;
std::vector<std::string> cands;
if (!rtc.gatherLocal(ufrag, pwd, fp, cands, 5000)) {
std::fprintf(stderr, "gathering failed\n");
return 1;
}
std::printf("%s\n", proto::buildOffer(ufrag, pwd, fp, false, "1080").c_str());
for (const auto& c : cands) std::printf("%s\n", c.c_str());
std::printf("ice body: %s\n", proto::icePostBody(cands, ufrag).c_str());
rtc.close();
return 0;
}
} // namespace
int main(int argc, char** argv) {
std::vector<std::string> args;
bool verbose = false;
for (int i = 1; i < argc; ++i) {
if (!std::strcmp(argv[i], "--verbose") || !std::strcmp(argv[i], "-v")) verbose = true;
else args.emplace_back(argv[i]);
}
if (args.empty()) return usage();
std::signal(SIGINT, onSignal);
std::signal(SIGTERM, onSignal);
if (!platform::init(argc, argv)) {
std::fprintf(stderr, "cannot create data dir %s\n", platform::dataDir().c_str());
return 1;
}
logInit("", verbose ? LogLevel::Debug : LogLevel::Info);
Http::globalInit(platform::assetPath("cacert.pem"));
// catalog + gssv commands (titles, consoles, play) live in cli_gssv.cpp; they build their own
// Config/Auth from the same data dir.
if (cli_gssv_handles(args[0].c_str())) {
std::vector<char*> av;
for (auto& a : args) av.push_back(const_cast<char*>(a.c_str()));
const int grc = cli_gssv(static_cast<int>(av.size()), av.data());
Http::globalCleanup();
logShutdown();
return grc;
}
Config cfg(platform::dataDir() + "/config.json");
if (!cfg.load()) XC_LOGW("config %s could not be parsed, using defaults", cfg.path().c_str());
Auth auth(cfg);
const std::string& cmd = args[0];
int rc = 2;
SessionKind kind = SessionKind::Cloud;
if (cmd == "login") rc = cmdLogin(cfg, auth);
else if (cmd == "devicecode") rc = cmdDeviceCode(auth);
else if (cmd == "token") rc = cmdToken(cfg, auth);
else if (cmd == "refresh") {
std::string err;
rc = (auth.refresh(err) && auth.authorizeStreaming(err)) ? 0 : 1;
if (rc) std::fprintf(stderr, "refresh failed: %s\n", err.c_str());
else cfg.save();
} else if (cmd == "status") rc = cmdStatus(cfg, auth);
else if (cmd == "logout") { auth.logout(); rc = 0; }
else if (cmd == "session" && args.size() >= 3 && parseKind(args[1], kind)) rc = cmdSession(cfg, auth, kind, args[2]);
else if (cmd == "stream" && args.size() >= 3 && parseKind(args[1], kind))
rc = cmdStream(cfg, auth, kind, args[2], args.size() > 3 ? std::atoi(args[3].c_str()) : 0);
else if (cmd == "offer") rc = cmdOffer();
else rc = usage();
Http::globalCleanup();
logShutdown();
return rc;
}