mirror of
https://github.com/JBuildr/nubix.git
synced 2026-10-17 12:00:51 +02:00
Nubix 0.3.0
This commit is contained in:
commit
dee9e24842
95 files changed
+56257
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
build*/
|
||||
deps/
|
||||
dist/
|
||||
.cache/
|
||||
.DS_Store
|
||||
compile_commands.json
|
||||
+131
@@ -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()
|
||||
@@ -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>.
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
Binary file not shown.
Binary file not shown.
@@ -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$
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
|
||||
Executable
+149
@@ -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
|
||||
Executable
+42
@@ -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
|
||||
Executable
+96
@@ -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}"
|
||||
Executable
+68
@@ -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
|
||||
Executable
+305
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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__)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
@@ -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, <)) std::strftime(clock, sizeof(clock), "%H:%M", <);
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+163
@@ -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
|
||||
@@ -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)
|
||||
@@ -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, ""));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+24765
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+309
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user