Music: Spotify, YouTube Music and External Audio through the in-game player

Three outside sources can now be played through the game's own music
player. Each has its own row in the music screen, shown only when it is
set up, with tiles that behave like the game's playlists: track name,
cover, pause, next, and pressing a row to start that track.

How the sound gets in
- The game's song decoder is hooked. While one of these tiles is on, the
  audio of the game song behind it (its "carrier") is replaced, block by
  block, with the outside source's audio. The game's volume, fades and
  pausing still apply.
- Rows are songs of our own registered with the game's music registry
  (copies of a real song's asset with their own artist and title), so no
  game song is borrowed or renamed to list a track.

Spotify
- The game is a Spotify Connect device ("ReSkate"), built on librespot
  and linked into ReSkate.dll (SpotifyHelper/, Rust). Audio, track info
  and controls come straight from Spotify. Needs Premium.
- Optional sign-in through the Web API lists the player's playlists and
  Liked Songs as tiles with their tracks.
- librespot can no longer end the process (it does so on a free account
  and on playback errors); only its thread is stopped.

YouTube Music
- Videos and playlists added by link. yt-dlp is fetched on request into
  <Local AppData>\ReSkate\bin and checked against its published sums;
  decoding is done in the DLL, so no ffmpeg.
- Private and age-restricted songs: cookies.txt or a browser's sign-in,
  with QuickJS fetched as the JavaScript runtime yt-dlp then needs
  (Node.js is used instead when installed).

External Audio
- Any app's sound, captured per process from Windows, with the app's own
  output turned down while it plays through the game.

Setup page
- "spotify setup" opens http://127.0.0.1:8421/: device status, sign-ins,
  YouTube links and tools, the external app, loudness. Loopback only;
  changes are accepted from its own page only.

Also
- Speakers in the world play the tile's audio; grabbing one keeps the
  player on their tile.
- Playlists added while the game runs are sent to the music screen live.
- MUSIC-SETUP.md: setup guide for players.

Building the Spotify device and the YouTube decoder needs cargo; without
it ReSkate.dll builds without them.
This commit is contained in:
AyeZeeBB committed 2026-10-12 03:16:36 -05:00
1 parent 1c5da074cc
commit 48a9cb8f5b
48 files changed
+9031 -70

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
/build/
/SpotifyHelper/target/
/out/
/artifacts/
/logs/
+8 -1
View File
@@ -134,6 +134,13 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS)
target_link_libraries(dingosdk_server_map_update_tests PRIVATE dingosdk_https)
endif()
add_test(NAME server_map_update COMMAND dingosdk_server_map_update_tests)
if(WIN32)
# By hand: what the music player outside the game is playing (Extension/Music/external_player.h).
add_executable(dingosdk_external_player_probe Extension/Music/Test/external_player_probe.cpp Extension/Music/external_player.cpp)
target_include_directories(dingosdk_external_player_probe PRIVATE "${PROJECT_SOURCE_DIR}")
add_executable(dingosdk_app_audio_probe Extension/Music/Test/app_audio_probe.cpp Extension/Music/app_audio_capture.cpp)
target_include_directories(dingosdk_app_audio_probe PRIVATE "${PROJECT_SOURCE_DIR}")
endif()
add_executable(dingosdk_word_filter_tests Engine/Core/Text/Test/word_filter_tests.cpp)
target_link_libraries(dingosdk_word_filter_tests PRIVATE dingosdk_word_filter)
add_test(NAME word_filter COMMAND dingosdk_word_filter_tests)
@@ -346,7 +353,7 @@ if(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS)
endif()
get_property(project_targets DIRECTORY PROPERTY BUILDSYSTEM_TARGETS)
foreach(target IN LISTS project_targets)
if(NOT target MATCHES "^(dingosdk_imgui|dingosdk_detours|dingosdk_sqlite|dingosdk_lz4|dingosdk_zstd|dingosdk_miniz)$")
if(NOT target MATCHES "^(dingosdk_imgui|dingosdk_detours|dingosdk_sqlite|dingosdk_lz4|dingosdk_zstd|dingosdk_miniz|dingosdk_spotify_device)$")
dingosdk_configure_target(${target})
endif()
endforeach()
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include "Engine/Game/Build/fingerprint.h"
#include <cstddef>
#include <cstdint>
// The game's sound engine (EA::Audio::Core): the plugin that plays a streamed sound file
// ("SndPlayer1"), which is what the music player's songs come out of.
namespace dingosdk::game::build::v20260929::audio_core {
// SndPlayer1's processing routine: (player, mixer) -> nonzero when it produced a block.
inline constexpr Fingerprint stream_player_process{0x13de2b0, {0x40,0x53,0x41,0x55,0x48,0x83,0xec,0x58,0x80,0xb9,0x24,0x03,0x00,0x00,0x00,0x4c,0x8d,0x91,0x23,0x03,0x00,0x00,0x4c,0x89,0x54,0x24,0x20,0x4c,0x8b,0xea,0x48,0x8b}};
// SndPlayer1's command routine: (player, command, arguments). Command 4 asks it to play a
// file, whose name is the string at arguments + 0x18.
inline constexpr Fingerprint stream_player_command{0x13e2190, {0x40,0x53,0x55,0x56,0x57,0x41,0x56,0x48,0x83,0xec,0x20,0x4c,0x8b,0x71,0x18,0x49,0x8b,0xf0,0x48,0x8b,0xf9,0x83,0xfa,0x04,0x0f,0x85,0x13,0x01,0x00,0x00,0x48,0x8b}};
inline constexpr int command_play = 4;
inline constexpr std::size_t command_play_name = 0x18;
// The player.
inline constexpr std::size_t player_block_frames = 0x318; // u16: the most frames of one block
inline constexpr std::size_t player_voices = 0x31c; // u16: where its voices begin
inline constexpr std::size_t player_voice = 0x321; // u8: the voice being played
inline constexpr std::size_t player_voice_count = 0x322; // u8
inline constexpr std::size_t voice_size = 0x168;
inline constexpr std::size_t voice_rate = 0x14; // float, Hz
inline constexpr std::size_t voice_frames = 0x18; // u32: the length of the file
inline constexpr std::size_t voice_state = 0x2e; // u8: 0 and 5 are not playing
inline constexpr std::size_t voice_channels = 0x2f; // u8
// The mixer. A block is written to the buffer at mixer_write, and the two are then exchanged:
// after the routine, what it wrote is the buffer at mixer_written.
inline constexpr std::size_t mixer_written = 0x10090;
inline constexpr std::size_t mixer_write = 0x10098;
inline constexpr std::size_t mixer_frames = 0x100bc; // u32: frames of the block
inline constexpr std::size_t mixer_rate = 0x100c0; // float
inline constexpr std::size_t mixer_channels = 0x100c8; // u8
// A buffer: 32-bit float samples, a channel after another.
inline constexpr std::size_t buffer_data = 0x8; // channel pointers, or the samples
inline constexpr std::size_t buffer_stride = 0x12; // u16: samples a channel, when together
inline constexpr std::size_t buffer_together = 0x15; // u8: nonzero when buffer_data is the samples
}
+20
View File
@@ -35,6 +35,26 @@ inline constexpr std::uintptr_t construct_song = 0x1d47a40;
// Publish the playlist and song DTO lists into the UI manager.
inline constexpr std::uintptr_t publish_playlists = 0x6e88d0;
inline constexpr std::uintptr_t publish_songs = 0x6e8e30;
// The music asset manager's own routines. Registers a song's asset under its "artist - title"
// (manager, asset); the asset of a song by that name (manager, char**), or 0; adds a song, by
// the 16 bytes before its asset, to the playlist of a number (manager, u32*, bytes).
inline constexpr Fingerprint song_register_contract{0x43e260, {0x48,0x85,0xd2,0x0f,0x84,0x76,0x02,0x00,0x00,0x48,0x8b,0xc4,0x55,0x41,0x55,0x41,0x57,0x48,0x8d,0x68,0xa1,0x48,0x81,0xec,0xa0,0x00,0x00,0x00,0x48,0x89,0x58,0x08}};
inline constexpr Fingerprint song_lookup_contract{0x435910, {0x48,0x8b,0x02,0x4c,0x8b,0xd1,0x41,0xb8,0x05,0x15,0x00,0x00,0x0f,0xb6,0x10,0x4c,0x8d,0x48,0x01,0x85,0xd2,0x74,0x1d,0x66,0x0f,0x1f,0x84,0x00,0x00,0x00,0x00,0x00}};
inline constexpr Fingerprint playlist_add_contract{0x433830, {0x4c,0x89,0x44,0x24,0x18,0x53,0x48,0x83,0xec,0x50,0x48,0x89,0x74,0x24,0x68,0x48,0x8d,0x59,0x20,0x48,0x89,0x7c,0x24,0x78,0x48,0x8b,0xf1,0x4c,0x89,0x74,0x24,0x38}};
// More of the manager's routines for the playlists the music screen makes: takes a song out of
// one (manager, u32*, bytes), empties one (manager, u32*), makes one (manager, u32* out).
inline constexpr Fingerprint playlist_remove_contract{0x43e780, {0x40,0x53,0x56,0x57,0x41,0x56,0x48,0x83,0xec,0x28,0x48,0x8d,0x59,0x20,0x48,0x8b,0xf1,0x48,0x8b,0xcb,0x4d,0x8b,0xf0,0x48,0x8b,0xfa,0xe8,0xe1,0x15,0x15,0x01,0x8b}};
inline constexpr Fingerprint playlist_clear_contract{0x434000, {0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x74,0x24,0x10,0x57,0x48,0x83,0xec,0x20,0x48,0x8d,0x59,0x20,0x48,0x8b,0xf1,0x48,0x8b,0xcb,0x48,0x8b,0xfa,0xe8,0x5f,0xbd,0x15}};
inline constexpr Fingerprint playlist_create_contract{0x434800, {0x40,0x53,0x55,0x56,0x57,0x41,0x56,0x41,0x57,0x48,0x81,0xec,0xa8,0x00,0x00,0x00,0x48,0x8d,0x69,0x20,0x48,0x8b,0xd9,0x48,0x8b,0xcd,0x4c,0x8b,0xf2,0xe8,0x5e,0xb5}};
// Asks a music queue for a song the player picked: (queue, asset). It looks the song up among
// those the queue can play and, when it is one, puts it on the queue's list of requests (which
// the selector takes from first).
inline constexpr Fingerprint song_request_contract{0x14f8060, {0x48,0x89,0x5c,0x24,0x10,0x57,0x48,0x83,0xec,0x20,0x48,0x8b,0xda,0x48,0x8b,0xf9,0xe8,0xeb,0xd5,0xff,0xff,0x84,0xc0,0x0f,0x84,0xce,0x00,0x00,0x00,0x4c,0x8b,0x47}};
// Begins to play a song's asset on a channel: (player, asset, channel, ...) -> the playing song.
inline constexpr Fingerprint song_play_contract{0x15568c0, {0x40,0x57,0x41,0x55,0x48,0x83,0xec,0x38,0x48,0x89,0x5c,0x24,0x50,0x48,0x8b,0xfa,0x48,0x89,0x6c,0x24,0x58,0x4c,0x8b,0xe9,0x48,0x89,0x74,0x24,0x60,0x41,0x8b,0xe8}};
// The music screen's song of a given id ("artist - title"), made when there is none:
// (manager, engine string*) -> its context in the model. Its field 1 is its cover's address.
inline constexpr Fingerprint song_context_contract{0x6db870, {0x48,0x89,0x5c,0x24,0x18,0x48,0x89,0x54,0x24,0x10,0x55,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x83,0xec,0x70,0x4c,0x8b,0x32,0xbf,0x05,0x15,0x00}};
// Inserts a context into the UI manager's context map.
inline constexpr std::uintptr_t context_insert = 0x6c6120;
// Invokes the initialization completion delegate.
+67
View File
@@ -2,6 +2,11 @@
#include "Engine/Core/Profiling/profiler.h"
#include "Extension/Settings/engine_tweaks.h"
#include "Extension/Settings/job_spin.h"
#include "Extension/Music/spotify_station.h"
#include "Extension/Music/music_inject.h"
#include "Extension/Music/spotify_web.h"
#include "Extension/Music/spotify_device.h"
#include "Extension/Music/spotify_dashboard.h"
#include "Extension/UI/ui_pointer_skip.h"
#include "Extension/World/texture_handle_limit.h"
#include "Engine/Core/Hooks/hooks.h"
@@ -271,6 +276,68 @@ void start_memory_trace_from_environment() noexcept {
}
void register_perf_commands(Commands& registry) {
// Spotify in the music screen (spotify_station.h): what it sees, and the player's account.
auto spotify = action("spotify", "Spotify in the music screen: what is selected, what Spotify is playing, and the account", Group::console);
spotify.execution = Execution::local;
spotify.run = [](const Model&, const Values&, const Output& out) {
out(spotify_station::status());
out(music_inject::status());
out(spotify_device::status());
const auto account = spotify_web::state();
out(std::format("Spotify account: {}{}; {} playlist(s){}{}.", !account.has_client ? "no client id set (spotify client <id>)" : account.signed_in ? "signed in" : "not signed in (spotify login)",
account.user.empty() ? std::string() : " as " + account.user, account.playlists, account.busy ? "; busy" : "",
account.problem.empty() ? std::string() : "; " + account.problem));
};
registry.add(std::move(spotify));
auto setup = action("spotify setup", "Open the page in your browser that sets Spotify up (the device, your playlists, how loud it is)", Group::console);
setup.execution = Execution::local;
setup.run = [](const Model&, const Values&, const Output& out) {
if (spotify_dashboard::open()) out("Opening " + spotify_dashboard::address() + " in your browser.");
else out("The Spotify setup page is not being served.");
};
registry.add(std::move(setup));
auto client = action("spotify client", "The client id of your Spotify application (its redirect address: http://127.0.0.1:47824/callback)", Group::console,
{argument("id")});
client.execution = Execution::local;
client.run = [](const Model&, const Values& args, const Output& out) {
spotify_web::set_client(std::get<std::string>(args[0]));
out("Spotify client id set. Sign in with: spotify login");
};
registry.add(std::move(client));
auto login = action("spotify login", "Sign in to Spotify in your browser, for your playlists in the music screen", Group::console);
login.execution = Execution::local;
login.run = [](const Model&, const Values&, const Output& out) {
if (!spotify_web::state().has_client) return out("Set your Spotify application's client id first: spotify client <id>");
spotify_web::login();
out("Your browser is opening Spotify's sign-in. Your playlists are in the music screen the next time the game starts.");
};
registry.add(std::move(login));
auto logout = action("spotify logout", "Forget the Spotify sign-in", Group::console);
logout.execution = Execution::local;
logout.run = [](const Model&, const Values&, const Output& out) {
spotify_web::logout();
out("The Spotify sign-in is forgotten.");
};
registry.add(std::move(logout));
auto through = variable("spotify through", "Spotify's sound through the game on the Spotify station: 0 off, 1 in place of songs, 2 in place of every stereo sound file",
Group::console, argument("0-2", Type::integer));
through.execution = Execution::local;
through.inspect = [](const Model&) { return State{true, std::to_string(music_inject::mode()), {}, "0 off, 1 songs, 2 every stereo sound file", false}; };
through.run = [](const Model&, const Values& args, const Output& out) {
music_inject::set_mode(static_cast<int>(std::clamp<std::int64_t>(std::get<std::int64_t>(args[0]), 0, 3)));
out(std::format("Spotify through the game: mode {}.", music_inject::mode()));
};
registry.add(std::move(through));
auto gain = variable("spotify gain", "How loud Spotify is through the game, in percent of how Spotify plays it (100 as it is, up to 1600)",
Group::console, argument("percent", Type::integer));
gain.execution = Execution::local;
gain.inspect = [](const Model&) { return State{true, std::to_string(static_cast<int>(music_inject::gain() * 100.0f + 0.5f)), {}, "percent", false}; };
gain.run = [](const Model&, const Values& args, const Output& out) {
music_inject::set_gain(static_cast<float>(std::clamp<std::int64_t>(std::get<std::int64_t>(args[0]), 0, 1600)) / 100.0f);
out(std::format("Spotify through the game: {}% loud.", static_cast<int>(music_inject::gain() * 100.0f + 0.5f)));
};
registry.add(std::move(gain));
// How close the game is to its limit of streamed textures (texture_handle_limit).
auto textures = action("textures", "Streamed textures in use, and the most the game can hold", Group::console);
textures.execution = Execution::local;
+44
View File
@@ -0,0 +1,44 @@
// By hand: captures what an application is playing for a few seconds and says how loud it
// was, first as it is and then with the application's own output muted (does the capture
// still hear it?). app_audio_probe [Spotify.exe]
#include "Extension/Music/app_audio_capture.h"
#include <chrono>
#include <cmath>
#include <iostream>
#include <string>
#include <thread>
#include <vector>
using namespace dingosdk;
int main(int argc, char** argv) {
std::wstring name = L"Spotify.exe";
if (argc > 1) name.assign(argv[1], argv[1] + std::strlen(argv[1]));
app_audio::start(name.c_str());
std::vector<float> left(480), right(480);
const auto listen = [&](const char* what, int seconds) {
float loudest = 0;
unsigned real = 0, asked = 0;
// Read as the game would: 480 frames a hundred times a second, at 48 kHz.
for (int i = 0; i < seconds * 100; ++i) {
real += app_audio::read(1, left.data(), right.data(), 480, 48000.0f);
asked += 480;
for (const float sample : left) loudest = std::max(loudest, std::fabs(sample));
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
const auto state = app_audio::state();
std::cout << what << ": capturing " << state.capturing << ", process " << state.process << ", captured " << state.frames << " frames, read "
<< real << " of " << asked << ", loudest read " << loudest << ", level " << state.level << ", muted " << state.muted
<< (state.problem.empty() ? "" : " (" + state.problem + ")") << std::endl;
};
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
listen("as it is", 3);
app_audio::mute_source(true);
std::this_thread::sleep_for(std::chrono::milliseconds(700));
listen("with its own output muted", 3);
app_audio::mute_source(false);
std::this_thread::sleep_for(std::chrono::milliseconds(700));
listen("unmuted again", 1);
app_audio::stop();
return 0;
}
@@ -0,0 +1,37 @@
// By hand: what the music player outside the game says it is playing, for a few seconds, and
// optionally one command to it. external_player_probe [toggle|next|previous|play|pause]
#include "Extension/Music/external_player.h"
#include <chrono>
#include <cstring>
#include <iostream>
#include <thread>
using namespace dingosdk;
int main(int argc, char** argv) {
external_player::start();
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
if (argc > 1) {
const std::string word = argv[1];
const auto command = word == "next" ? external_player::Command::next : word == "previous" ? external_player::Command::previous
: word == "play" ? external_player::Command::play : word == "pause" ? external_player::Command::pause
: external_player::Command::toggle;
external_player::send(command);
std::cout << "sent " << word << '\n';
}
std::uint64_t seen = ~0ull;
for (int i = 0; i < 12; ++i) {
if (const auto revision = external_player::revision(); revision != seen) {
seen = revision;
const auto track = external_player::now();
if (!track.available) std::cout << "no player with a media session\n";
else
std::cout << (track.spotify ? "Spotify" : track.app) << (track.playing ? " playing: " : " paused: ") << track.artist << " - " << track.title
<< " [" << track.album << "] cover " << track.artwork.size() << " bytes; next " << track.can_next << " previous "
<< track.can_previous << '\n';
}
std::this_thread::sleep_for(std::chrono::milliseconds(500));
}
external_player::stop();
return 0;
}
+437
View File
@@ -0,0 +1,437 @@
#include "app_audio_capture.h"
#include <Windows.h>
#include <audioclient.h>
#include <audioclientactivationparams.h>
#include <audiopolicy.h>
#include <mmdeviceapi.h>
#include <TlHelp32.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <cmath>
#include <cstring>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#pragma comment(lib, "mmdevapi")
#pragma comment(lib, "ole32")
namespace dingosdk::app_audio {
namespace {
// One writer (the capture thread), one reader (the game's audio thread): interleaved stereo
// floats at capture_rate. A little over a second.
constexpr std::uint32_t ring_frames = 65536; // a power of two
std::array<float, ring_frames * 2> ring{};
std::atomic<std::uint64_t> written{}, taken{}; // frames, ever
struct Reader {
std::atomic<std::uint64_t> key{}, used{}; // whose it is (0: nobody's), and when it last read
std::atomic<double> position{}; // its place, in frames, with its fraction
};
std::array<Reader, 8> readers{};
std::atomic<std::uint64_t> starved_frames{}, read_frames{};
std::atomic<float> peak{};
std::atomic<bool> running{}, stopping{}, capturing{}, want_mute{}, is_muted{};
std::atomic<unsigned long> source_process{};
std::thread worker;
std::mutex text_mutex;
std::string problem_text;
std::wstring executable_name;
// The reader keeps this far behind the writer: room for the capture's bursts.
constexpr double target_delay = capture_rate * 0.08, most_delay = capture_rate * 0.30;
void say(const std::string& text) {
std::lock_guard lock(text_mutex);
problem_text = text;
}
template <class T> struct Com {
T* p{};
~Com() { if (p) p->Release(); }
T** operator&() { return &p; }
T* operator->() const { return p; }
explicit operator bool() const { return p != nullptr; }
};
// Every process of the application, the one the others descend from first.
std::vector<DWORD> processes_of(const std::wstring& name) {
std::vector<DWORD> all, parents;
const auto snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snapshot == INVALID_HANDLE_VALUE) return all;
PROCESSENTRY32W entry{};
entry.dwSize = sizeof(entry);
for (BOOL more = Process32FirstW(snapshot, &entry); more; more = Process32NextW(snapshot, &entry))
if (_wcsicmp(entry.szExeFile, name.c_str()) == 0) {
all.push_back(entry.th32ProcessID);
parents.push_back(entry.th32ParentProcessID);
}
CloseHandle(snapshot);
for (std::size_t i = 0; i < all.size(); ++i)
if (std::find(all.begin(), all.end(), parents[i]) == all.end()) {
std::swap(all[0], all[i]);
break;
}
return all;
}
// The answer to ActivateAudioInterfaceAsync, which comes on a thread of the system's.
struct Activation : IActivateAudioInterfaceCompletionHandler, IAgileObject {
std::atomic<ULONG> references{1};
HANDLE done{CreateEventW(nullptr, TRUE, FALSE, nullptr)};
HRESULT result{E_FAIL};
IAudioClient* client{};
virtual ~Activation() { if (done) CloseHandle(done); }
STDMETHODIMP QueryInterface(REFIID id, void** out) override {
if (id == __uuidof(IUnknown) || id == __uuidof(IActivateAudioInterfaceCompletionHandler)) *out = static_cast<IActivateAudioInterfaceCompletionHandler*>(this);
else if (id == __uuidof(IAgileObject)) *out = static_cast<IAgileObject*>(this);
else { *out = nullptr; return E_NOINTERFACE; }
AddRef();
return S_OK;
}
STDMETHODIMP_(ULONG) AddRef() override { return ++references; }
STDMETHODIMP_(ULONG) Release() override {
const auto left = --references;
if (!left) delete this;
return left;
}
STDMETHODIMP ActivateCompleted(IActivateAudioInterfaceAsyncOperation* operation) override {
HRESULT activated = E_FAIL;
IUnknown* unknown{};
result = operation->GetActivateResult(&activated, &unknown);
if (SUCCEEDED(result)) result = activated;
if (SUCCEEDED(result) && unknown) result = unknown->QueryInterface(__uuidof(IAudioClient), reinterpret_cast<void**>(&client));
if (unknown) unknown->Release();
SetEvent(done);
return S_OK;
}
};
IAudioClient* loopback_of(DWORD process, HRESULT& result) {
AUDIOCLIENT_ACTIVATION_PARAMS parameters{};
parameters.ActivationType = AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK;
parameters.ProcessLoopbackParams.TargetProcessId = process;
parameters.ProcessLoopbackParams.ProcessLoopbackMode = PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE;
PROPVARIANT variant{};
variant.vt = VT_BLOB;
variant.blob.cbSize = sizeof(parameters);
variant.blob.pBlobData = reinterpret_cast<BYTE*>(&parameters);
auto* handler = new Activation;
Com<IActivateAudioInterfaceAsyncOperation> operation;
result = ActivateAudioInterfaceAsync(VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK, __uuidof(IAudioClient), &variant, handler, &operation);
IAudioClient* client{};
if (SUCCEEDED(result)) {
result = WaitForSingleObject(handler->done, 5000) == WAIT_OBJECT_0 ? handler->result : HRESULT_FROM_WIN32(ERROR_TIMEOUT);
if (SUCCEEDED(result)) client = handler->client;
}
handler->Release();
return client;
}
// Windows takes the capture after the application's volume, so muting it would silence the
// capture as well. Its volume is turned down this far instead (it cannot be heard), and what
// is captured, which is floating point and loses nothing by it, is turned up by as much.
constexpr float quiet_volume = 0.001f;
// The application's own sound to the speakers, turned down or put back: its sessions on the
// default output. A session found turned down that this did not turn down (the game ended
// without putting it back) is put back to full.
bool set_mute(const std::vector<DWORD>& processes, bool mute) {
static float before = 1.0f;
Com<IMMDeviceEnumerator> devices;
Com<IMMDevice> device;
Com<IAudioSessionManager2> manager;
Com<IAudioSessionEnumerator> sessions;
if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), reinterpret_cast<void**>(&devices))) ||
FAILED(devices->GetDefaultAudioEndpoint(eRender, eMultimedia, &device)) ||
FAILED(device->Activate(__uuidof(IAudioSessionManager2), CLSCTX_ALL, nullptr, reinterpret_cast<void**>(&manager))) ||
FAILED(manager->GetSessionEnumerator(&sessions)))
return false;
int count{};
sessions->GetCount(&count);
bool any{};
for (int i = 0; i < count; ++i) {
Com<IAudioSessionControl> control;
Com<IAudioSessionControl2> detail;
Com<ISimpleAudioVolume> volume;
DWORD owner{};
if (FAILED(sessions->GetSession(i, &control)) || FAILED(control->QueryInterface(__uuidof(IAudioSessionControl2), reinterpret_cast<void**>(&detail))) ||
FAILED(detail->GetProcessId(&owner)) || std::find(processes.begin(), processes.end(), owner) == processes.end() ||
FAILED(control->QueryInterface(__uuidof(ISimpleAudioVolume), reinterpret_cast<void**>(&volume))))
continue;
float current = 1.0f;
volume->GetMasterVolume(&current);
if (mute) {
if (current > quiet_volume * 2) before = current;
any = (current <= quiet_volume * 2 || SUCCEEDED(volume->SetMasterVolume(quiet_volume, nullptr))) || any;
} else if (current <= quiet_volume * 2) {
any = SUCCEEDED(volume->SetMasterVolume(before, nullptr)) || any;
} else {
any = true;
}
}
return any;
}
std::atomic<bool> external{};
std::atomic<unsigned> source_rate{capture_rate};
void write_frames(const BYTE* data, UINT32 frames, bool silent) {
if (external.load(std::memory_order_relaxed)) return; // (the sound is another's for now)
auto at = written.load(std::memory_order_relaxed);
float loudest = 0, raw = 0;
const auto* samples = reinterpret_cast<const float*>(data);
if (!silent)
for (UINT32 i = 0; i < frames * 2; ++i) raw = std::max(raw, std::fabs(samples[i]));
// Turned up by what the application was turned down by, unless this piece plainly was not
// turned down (the change had not reached it yet): then it is left as it is.
float gain = is_muted.load(std::memory_order_relaxed) ? 1.0f / quiet_volume : 1.0f;
if (raw * gain > 1.5f) gain = 1.0f;
for (UINT32 i = 0; i < frames; ++i, ++at) {
const float left = silent ? 0.0f : std::clamp(samples[i * 2] * gain, -1.0f, 1.0f), right = silent ? 0.0f : std::clamp(samples[i * 2 + 1] * gain, -1.0f, 1.0f);
ring[(at & (ring_frames - 1)) * 2] = left;
ring[(at & (ring_frames - 1)) * 2 + 1] = right;
loudest = std::max({loudest, std::fabs(left), std::fabs(right)});
}
written.store(at, std::memory_order_release);
// (A slow fall, so the level reads as the last second's.)
const auto before = peak.load(std::memory_order_relaxed);
peak.store(std::max(loudest, before * 0.97f), std::memory_order_relaxed);
}
// The application to capture (it can be changed while this runs: target()).
std::wstring wanted_name() {
std::lock_guard lock(text_mutex);
return executable_name;
}
// One capture, from finding the application to its end, a failure or another being wanted.
void capture_once() {
const auto name = wanted_name();
const auto processes = processes_of(name);
if (processes.empty()) {
source_process.store(0);
say("the application is not running");
return;
}
source_process.store(processes.front());
HRESULT result{};
Com<IAudioClient> client;
client.p = loopback_of(processes.front(), result);
if (!client) {
char text[96];
sprintf_s(text, "its sound could not be opened (0x%08lx; this needs Windows 10 version 2004 or later)", static_cast<unsigned long>(result));
say(text);
return;
}
WAVEFORMATEX format{};
format.wFormatTag = WAVE_FORMAT_IEEE_FLOAT;
format.nChannels = 2;
format.nSamplesPerSec = capture_rate;
format.wBitsPerSample = 32;
format.nBlockAlign = 8;
format.nAvgBytesPerSec = capture_rate * 8;
const auto event = CreateEventW(nullptr, FALSE, FALSE, nullptr);
Com<IAudioCaptureClient> capture;
result = client->Initialize(AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_LOOPBACK | AUDCLNT_STREAMFLAGS_EVENTCALLBACK | AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM, 0, 0, &format, nullptr);
if (SUCCEEDED(result)) result = client->SetEventHandle(event);
if (SUCCEEDED(result)) result = client->GetService(__uuidof(IAudioCaptureClient), reinterpret_cast<void**>(&capture));
if (SUCCEEDED(result)) result = client->Start();
if (FAILED(result)) {
char text[64];
sprintf_s(text, "its sound could not be started (0x%08lx)", static_cast<unsigned long>(result));
say(text);
CloseHandle(event);
return;
}
say({});
capturing.store(true);
ULONGLONG checked = GetTickCount64();
bool muted_now = false;
while (!stopping.load()) {
WaitForSingleObject(event, 200);
for (;;) {
BYTE* data{};
UINT32 frames{};
DWORD flags{};
if (FAILED(capture->GetBuffer(&data, &frames, &flags, nullptr, nullptr)) || !frames) break;
write_frames(data, frames, (flags & AUDCLNT_BUFFERFLAGS_SILENT) != 0);
capture->ReleaseBuffer(frames);
}
// Its mute follows what is asked, and is set again for a session that came since.
const bool mute = want_mute.load();
const auto now = GetTickCount64();
if (mute != muted_now || (mute && now - checked > 2000)) {
checked = now;
const auto current = processes_of(name);
if (current.empty()) break; // it closed
if (set_mute(current, mute)) muted_now = mute;
is_muted.store(muted_now);
} else if (now - checked > 2000) {
checked = now;
if (processes_of(name).empty()) break;
}
if (wanted_name() != name) break; // another application is wanted
}
client->Stop();
if (muted_now) {
set_mute(processes_of(name), false);
is_muted.store(false);
}
capturing.store(false);
CloseHandle(event);
}
void run() noexcept {
const bool initialized = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
while (!stopping.load()) {
try {
capture_once();
} catch (...) {
capturing.store(false);
}
const auto before = wanted_name();
for (int i = 0; i < 20 && !stopping.load() && wanted_name() == before; ++i) Sleep(100); // then it is looked for again
}
if (initialized) CoUninitialize();
running.store(false);
}
} // namespace
void start(const wchar_t* executable) noexcept {
if (running.exchange(true)) return;
stopping.store(false);
try {
{
std::lock_guard lock(text_mutex);
executable_name = executable;
}
worker = std::thread(run);
} catch (...) {
running.store(false);
}
}
void target(const wchar_t* executable) noexcept {
try {
{
std::lock_guard lock(text_mutex);
if (running.load() && executable_name == executable) return;
executable_name = executable;
}
start(executable);
} catch (...) {}
}
void stop() noexcept {
if (!running.load()) return;
stopping.store(true);
if (worker.joinable()) worker.join();
}
void mute_source(bool mute) noexcept { want_mute.store(mute); }
void restore_source(const wchar_t* executable) noexcept {
try {
std::thread([name = std::wstring(executable)] {
const bool initialized = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
try {
if (const auto processes = processes_of(name); !processes.empty()) set_mute(processes, false);
} catch (...) {}
if (initialized) CoUninitialize();
}).detach();
} catch (...) {}
}
unsigned read(std::uint64_t key, float* left, float* right, unsigned frames, float rate) noexcept {
const auto end = written.load(std::memory_order_acquire);
const auto now = GetTickCount64();
if (!key) key = 1;
Reader* reader{};
for (auto& candidate : readers)
if (candidate.key.load(std::memory_order_relaxed) == key) {
reader = &candidate;
break;
}
if (!reader)
for (auto& candidate : readers) {
auto held = candidate.key.load(std::memory_order_relaxed);
if (held && now - candidate.used.load(std::memory_order_relaxed) < 1000) continue;
if (candidate.key.compare_exchange_strong(held, key, std::memory_order_relaxed)) {
candidate.position.store(-1.0, std::memory_order_relaxed); // (its place is taken up below)
reader = &candidate;
break;
}
}
if (!reader) {
for (unsigned i = 0; i < frames; ++i) left[i] = right[i] = 0.0f;
starved_frames.fetch_add(frames, std::memory_order_relaxed);
return 0;
}
reader->used.store(now, std::memory_order_relaxed);
double at = reader->position.load(std::memory_order_relaxed);
const double step = rate > 1000.0f ? static_cast<double>(source_rate.load(std::memory_order_relaxed)) / static_cast<double>(rate) : 1.0;
// Too far behind (it was not read for a while) or ahead of the writer (the capture began
// again): take up the place a little behind the writer.
const double behind = static_cast<double>(end) - at;
if (behind > most_delay || behind < 0 || at < 0) at = std::max(0.0, static_cast<double>(end) - target_delay);
unsigned real{};
for (unsigned i = 0; i < frames; ++i) {
const auto whole = static_cast<std::uint64_t>(at);
if (whole + 1 >= end) { // nothing more yet: silence, and the place is kept
left[i] = right[i] = 0.0f;
continue;
}
const float fraction = static_cast<float>(at - static_cast<double>(whole));
const auto a = (whole & (ring_frames - 1)) * 2, b = ((whole + 1) & (ring_frames - 1)) * 2;
left[i] = ring[a] + (ring[b] - ring[a]) * fraction;
right[i] = ring[a + 1] + (ring[b + 1] - ring[a + 1]) * fraction;
at += step;
++real;
}
reader->position.store(at, std::memory_order_relaxed);
read_frames.fetch_add(real, std::memory_order_relaxed);
if (real < frames) starved_frames.fetch_add(frames - real, std::memory_order_relaxed);
return real;
}
void set_external(bool on, unsigned rate) noexcept {
external.store(on, std::memory_order_relaxed);
source_rate.store(on && rate ? rate : capture_rate, std::memory_order_relaxed);
}
void push(const float* interleaved, unsigned frames) noexcept {
auto at = written.load(std::memory_order_relaxed);
float loudest = 0;
for (unsigned i = 0; i < frames; ++i, ++at) {
const float left = interleaved[i * 2], right = interleaved[i * 2 + 1];
ring[(at & (ring_frames - 1)) * 2] = left;
ring[(at & (ring_frames - 1)) * 2 + 1] = right;
loudest = std::max({loudest, std::fabs(left), std::fabs(right)});
}
written.store(at, std::memory_order_release);
peak.store(std::max(loudest, peak.load(std::memory_order_relaxed) * 0.97f), std::memory_order_relaxed);
}
std::uint64_t ahead_frames() noexcept {
const auto end = written.load(std::memory_order_acquire);
const auto now = GetTickCount64();
double furthest = -1.0;
for (const auto& reader : readers)
if (reader.key.load(std::memory_order_relaxed) && now - reader.used.load(std::memory_order_relaxed) < 1000)
furthest = std::max(furthest, reader.position.load(std::memory_order_relaxed));
if (furthest < 0) return ~0ull;
return static_cast<double>(end) > furthest ? end - static_cast<std::uint64_t>(furthest) : 0;
}
State state() {
State out;
out.capturing = capturing.load();
out.process = source_process.load();
out.frames = written.load();
out.read = read_frames.load();
out.starved = starved_frames.load();
for (const auto now = GetTickCount64(); const auto& reader : readers)
if (reader.key.load() && now - reader.used.load() < 1000) ++out.readers;
out.level = peak.load();
out.muted = is_muted.load();
std::lock_guard lock(text_mutex);
out.problem = problem_text;
return out;
}
} // namespace dingosdk::app_audio
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <cstdint>
#include <string>
// The sound one application is playing, taken from Windows as it is played (an application
// loopback capture, Windows 10 version 2004 and later), for the game's radio to play in the
// place of a song (music_inject.h). The application's own sound to the speakers can be turned
// down to nothing meanwhile, so it is heard once.
namespace dingosdk::app_audio {
struct State {
bool capturing{};
unsigned long process{}; // the application's process id, 0 when it is not running
std::uint64_t frames{}; // stereo frames captured so far
std::uint64_t read{}; // frames handed to the reader so far
std::uint64_t starved{}; // frames the reader wanted and was not given
unsigned readers{}; // readers that read in the last second
float level{}; // the loudest sample of the last second, 0 to 1
bool muted{}; // its own output is turned down by this
std::string problem; // why it is not capturing, or empty
};
inline constexpr unsigned capture_rate = 48000;
// Starts capturing `executable` ("Spotify.exe"), on a thread of its own; it waits for the
// application when it is not running and follows it when it restarts. Once.
void start(const wchar_t* executable) noexcept;
void stop() noexcept;
// Starts it when it is not, and has it capture this application from now on.
void target(const wchar_t* executable) noexcept;
// Turns the application's own output down to nothing, or puts it back. Any thread; done by
// the capture thread.
void mute_source(bool mute) noexcept;
// A reader (the game's audio threads; no locks, no allocation): fills `frames` frames of
// `left` and `right` at `rate` Hz with what was captured. Whatever it has not got is silence.
// Returns the frames that were real. Each `reader` (any number that tells one from another)
// has its own place in what was captured, so two of them reading at once both get all of it;
// up to eight at a time, one that has not read for a second giving up its place.
// Puts back an application's output that was left turned down (the game ended before it could).
// On a thread of its own.
void restore_source(const wchar_t* executable) noexcept;
unsigned read(std::uint64_t reader, float* left, float* right, unsigned frames, float rate) noexcept;
State state();
// Sound from somewhere else in the place of what is captured (spotify_device.h): while it is
// `on`, what is captured is not kept, and `push` fills the buffer, with frames of two floats
// at `rate` Hz. One thread pushes.
void set_external(bool on, unsigned rate) noexcept;
void push(const float* interleaved, unsigned frames) noexcept;
// How far what was last pushed or captured is ahead of the reader that has read the most (in
// frames), or a great many when nothing has read for a second: how much there is yet to hear.
std::uint64_t ahead_frames() noexcept;
}
+104
View File
@@ -0,0 +1,104 @@
#include "external_audio.h"
#include "Engine/Core/Json/json.h"
#include "Extension/Boot/user_data_redirect.h"
#include <Windows.h>
#include <audiopolicy.h>
#include <mmdeviceapi.h>
#include <Psapi.h>
#include <algorithm>
#include <atomic>
#include <filesystem>
#include <fstream>
#include <mutex>
namespace dingosdk::external_audio {
namespace {
std::mutex mutex;
std::string picked;
bool read{};
std::atomic<bool> is_on{};
template <class T> struct Com {
T* p{};
~Com() { if (p) p->Release(); }
T** operator&() { return &p; }
T* operator->() const { return p; }
};
std::filesystem::path kept_file() { return redirected_local_app_data().parent_path() / L"external-audio.json"; }
// (With the mutex held.)
void read_kept() {
if (read) return;
read = true;
try {
std::ifstream in(kept_file(), std::ios::binary);
if (!in) return;
picked = Json::parse(std::string(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>())).value("app", "");
} catch (...) {}
}
// An application's name is its file's: letters, digits and a few marks, ending in ".exe".
bool plain(const std::string& name) {
return !name.empty() && name.size() <= 80 && std::all_of(name.begin(), name.end(), [](unsigned char c) { return std::isalnum(c) || c == '.' || c == '-' || c == '_' || c == ' ' || c == '+'; });
}
} // namespace
std::string app() {
std::lock_guard lock(mutex);
read_kept();
return picked;
}
void set_app(const std::string& executable) {
if (!executable.empty() && !plain(executable)) return;
std::lock_guard lock(mutex);
read_kept();
picked = executable;
try {
Json json = Json::object();
json["app"] = picked;
std::error_code ignored;
std::filesystem::create_directories(kept_file().parent_path(), ignored);
std::ofstream(kept_file(), std::ios::binary | std::ios::trunc) << json.dump(2);
} catch (...) {}
}
std::vector<std::string> apps() {
std::vector<std::string> found;
const bool initialized = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
try {
Com<IMMDeviceEnumerator> devices;
Com<IMMDevice> device;
Com<IAudioSessionManager2> manager;
Com<IAudioSessionEnumerator> sessions;
if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), reinterpret_cast<void**>(&devices))) &&
SUCCEEDED(devices->GetDefaultAudioEndpoint(eRender, eMultimedia, &device)) &&
SUCCEEDED(device->Activate(__uuidof(IAudioSessionManager2), CLSCTX_ALL, nullptr, reinterpret_cast<void**>(&manager))) && SUCCEEDED(manager->GetSessionEnumerator(&sessions))) {
int count{};
sessions->GetCount(&count);
for (int i = 0; i < count; ++i) {
Com<IAudioSessionControl> control;
Com<IAudioSessionControl2> detail;
DWORD owner{};
if (FAILED(sessions->GetSession(i, &control)) || FAILED(control->QueryInterface(__uuidof(IAudioSessionControl2), reinterpret_cast<void**>(&detail))) ||
FAILED(detail->GetProcessId(&owner)) || !owner || owner == GetCurrentProcessId())
continue;
const auto process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, owner);
if (!process) continue;
wchar_t path[MAX_PATH]{};
DWORD size = MAX_PATH;
if (QueryFullProcessImageNameW(process, 0, path, &size)) {
const auto name = std::filesystem::path(path).filename().wstring();
std::string narrow;
for (const auto c : name) narrow.push_back(c < 128 ? static_cast<char>(c) : '_');
if (plain(narrow) && std::find(found.begin(), found.end(), narrow) == found.end()) found.push_back(narrow);
}
CloseHandle(process);
}
}
} catch (...) {}
if (initialized) CoUninitialize();
std::sort(found.begin(), found.end());
return found;
}
bool on() noexcept { return is_on.load(std::memory_order_relaxed); }
void set_on(bool on) noexcept { is_on.store(on, std::memory_order_relaxed); }
} // namespace dingosdk::external_audio
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <string>
#include <vector>
// "External Audio" in the game's music screen: the sound of an application the player picks
// (a browser, a media player), heard through the game as Spotify's is (app_audio_capture.h
// takes it from Windows as it is played, and turns the application's own output down while it
// goes through the game). What it is playing, and play, pause and next, are by Windows' media
// session for it when it has one (external_player.h).
//
// Which application is picked in the setup page (spotify_dashboard.h) and kept in
// <Local AppData>\ReSkate\external-audio.json.
namespace dingosdk::external_audio {
// What its station's playlist is called among the Spotify stations (spotify_station.h).
inline constexpr char station[] = "~external";
inline constexpr char station_name[] = "External Audio";
// Its playlist's id in the music screen, and what the ids of its row there begin with.
inline constexpr char playlist_id[] = "external:audio";
inline constexpr char playlist_prefix[] = "external:";
// The application picked ("chrome.exe"), or empty.
std::string app();
void set_app(const std::string& executable);
// The applications that have sound going to the speakers now, to pick from. Any thread.
std::vector<std::string> apps();
// Whether its station is the one that is on.
bool on() noexcept;
void set_on(bool on) noexcept;
}
+212
View File
@@ -0,0 +1,212 @@
#include "external_player.h"
#include "spotify_device.h"
#include "youtube_music.h"
#include <Windows.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Foundation.Collections.h>
#include <winrt/Windows.Media.Control.h>
#include <winrt/Windows.Storage.Streams.h>
#include <algorithm>
#include <atomic>
#include <condition_variable>
#include <deque>
#include <mutex>
#include <thread>
#pragma comment(lib, "windowsapp")
namespace dingosdk::external_player {
namespace {
namespace control = winrt::Windows::Media::Control;
namespace streams = winrt::Windows::Storage::Streams;
using Status = control::GlobalSystemMediaTransportControlsSessionPlaybackStatus;
constexpr std::uint32_t largest_artwork = 4 * 1024 * 1024;
std::mutex mutex;
std::condition_variable wake;
Track current;
// (Each with whose session it was for when it was sent: the station may have gone on to
// another's by the time it is done, and a pause for the one left is still for it.)
std::deque<std::pair<Command, std::string>> commands;
std::atomic<std::uint64_t> revision_value{};
std::atomic<bool> running{}, stopping{};
std::thread worker;
bool spotify_app(const std::string& id) {
std::string lower(id);
std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return lower.find("spotify") != std::string::npos;
}
// Spotify's session, or none: another player's (a video in a browser) is not what the game's
// Spotify station shows or drives.
std::mutex app_mutex;
std::string wanted_app; // (lower case, without ".exe"; empty: Spotify)
control::GlobalSystemMediaTransportControlsSession pick(const control::GlobalSystemMediaTransportControlsSessionManager& manager, const std::string* of = nullptr) {
std::string wanted;
if (of) wanted = *of;
else {
std::lock_guard lock(app_mutex);
wanted = wanted_app;
}
for (const auto& session : manager.GetSessions()) {
auto id = winrt::to_string(session.SourceAppUserModelId());
if (wanted.empty() ? spotify_app(id) : (std::transform(id.begin(), id.end(), id.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); }), id.find(wanted) != std::string::npos))
return session;
}
// (A browser's session does not always go by the browser's name.)
return wanted.empty() ? nullptr : manager.GetCurrentSession();
}
std::vector<std::uint8_t> read_artwork(const streams::IRandomAccessStreamReference& reference) {
if (!reference) return {};
const auto stream = reference.OpenReadAsync().get();
const auto size = stream.Size();
if (!size || size > largest_artwork) return {};
streams::DataReader reader(stream);
reader.LoadAsync(static_cast<std::uint32_t>(size)).get();
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(size));
reader.ReadBytes(bytes);
return bytes;
}
// One look at the player: the track, with its cover read again only when the song changed.
Track look(const control::GlobalSystemMediaTransportControlsSessionManager& manager, const Track& before) {
Track track;
const auto session = pick(manager);
if (!session) return track;
track.available = true;
track.app = winrt::to_string(session.SourceAppUserModelId());
track.spotify = spotify_app(track.app);
if (const auto info = session.GetPlaybackInfo()) {
track.playing = info.PlaybackStatus() == Status::Playing;
const auto controls = info.Controls();
track.can_next = controls.IsNextEnabled();
track.can_previous = controls.IsPreviousEnabled();
track.can_pause = controls.IsPauseEnabled();
track.can_play = controls.IsPlayEnabled();
}
const auto properties = session.TryGetMediaPropertiesAsync().get();
if (!properties) return track;
track.title = winrt::to_string(properties.Title());
track.artist = winrt::to_string(properties.Artist());
track.album = winrt::to_string(properties.AlbumTitle());
// (A player often publishes the new title a moment before the new cover: the cover is
// read again for a few looks after a change, which `artwork_pending` counts down.)
const bool same_song = before.available && before.app == track.app && before.title == track.title && before.artist == track.artist;
if (same_song && !before.artwork.empty()) {
track.artwork = before.artwork;
track.artwork_revision = before.artwork_revision;
} else {
try {
track.artwork = read_artwork(properties.Thumbnail());
} catch (const winrt::hresult_error&) {}
track.artwork_revision = track.artwork == before.artwork ? before.artwork_revision : before.artwork_revision + 1;
}
return track;
}
bool same(const Track& a, const Track& b) {
return a.available == b.available && a.spotify == b.spotify && a.playing == b.playing && a.can_next == b.can_next &&
a.can_previous == b.can_previous && a.can_pause == b.can_pause && a.can_play == b.can_play && a.app == b.app &&
a.title == b.title && a.artist == b.artist && a.album == b.album && a.artwork_revision == b.artwork_revision;
}
void run_command(const control::GlobalSystemMediaTransportControlsSessionManager& manager, Command command, const std::string& app) {
const auto session = pick(manager, &app);
if (!session) return;
switch (command) {
case Command::play: session.TryPlayAsync().get(); break;
case Command::pause: session.TryPauseAsync().get(); break;
case Command::toggle: session.TryTogglePlayPauseAsync().get(); break;
case Command::next: session.TrySkipNextAsync().get(); break;
case Command::previous: session.TrySkipPreviousAsync().get(); break;
}
}
void watch() noexcept {
try {
winrt::init_apartment(winrt::apartment_type::multi_threaded);
} catch (...) {
running.store(false);
return;
}
try {
control::GlobalSystemMediaTransportControlsSessionManager manager{nullptr};
while (!stopping.load()) {
try {
if (!manager) manager = control::GlobalSystemMediaTransportControlsSessionManager::RequestAsync().get();
std::deque<std::pair<Command, std::string>> todo;
Track before;
{
std::lock_guard lock(mutex);
todo.swap(commands);
before = current;
}
for (const auto& [command, app] : todo) run_command(manager, command, app);
auto track = look(manager, before);
if (!same(track, before)) {
track.revision = before.revision + 1;
std::lock_guard lock(mutex);
current = std::move(track);
revision_value.store(current.revision);
}
} catch (const winrt::hresult_error&) {
manager = nullptr; // asked for again on the next look
}
// A look a second, and at once after a command (so its effect shows) or a stop.
std::unique_lock lock(mutex);
wake.wait_for(lock, std::chrono::milliseconds(commands.empty() ? 1000 : 150), [] { return stopping.load() || !commands.empty(); });
}
} catch (...) {}
winrt::uninit_apartment();
running.store(false);
}
} // namespace
void start() noexcept {
if (running.exchange(true)) return;
stopping.store(false);
try {
worker = std::thread(watch);
} catch (...) {
running.store(false);
}
}
void stop() noexcept {
stopping.store(true);
wake.notify_all();
if (worker.joinable()) worker.join();
}
// (When a Spotify account is on the game's own device, spotify_device.h, that is the player:
// what it plays is what there is, and the game's play, pause and next go to it.)
bool others() {
std::lock_guard lock(app_mutex);
return !wanted_app.empty();
}
void use_app(const std::string& executable) {
auto name = executable;
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (name.ends_with(".exe")) name.resize(name.size() - 4);
std::lock_guard lock(app_mutex);
wanted_app = name;
}
Track now() {
if (youtube_music::on()) return youtube_music::now();
if (!others() && spotify_device::connected()) return spotify_device::now();
std::lock_guard lock(mutex);
return current;
}
std::uint64_t revision() noexcept { return revision_value.load() + (spotify_device::revision() << 24) + (youtube_music::revision() << 40) + (youtube_music::on() ? 1ull << 62 : 0); }
void send(Command command) noexcept {
if (youtube_music::on()) return youtube_music::send(command);
if (!others() && spotify_device::connected()) return spotify_device::send(command);
try {
{
std::lock_guard lock(mutex);
std::string app;
{
std::lock_guard app_lock(app_mutex);
app = wanted_app;
}
if (commands.size() < 16) commands.emplace_back(command, std::move(app));
}
wake.notify_all();
} catch (...) {}
}
} // namespace dingosdk::external_player
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
// What a music player outside the game is playing, and its transport buttons: Spotify's
// desktop app first, else whichever player Windows counts as the current one. This goes through
// Windows' own media session interface (the one the volume flyout and media keys use), so it
// needs no login and no account with anyone; the sound stays that player's own.
namespace dingosdk::external_player {
struct Track {
bool available{}; // a player with a session is running
bool spotify{}; // that player is Spotify
bool playing{};
bool can_next{}, can_previous{}, can_pause{}, can_play{};
std::string app; // the player's application id, as Windows names it
std::string title, artist, album; // UTF-8
std::vector<std::uint8_t> artwork; // the cover as the player gives it (PNG or JPEG bytes), or empty
std::uint64_t revision{}; // changes whenever anything above does
std::uint64_t artwork_revision{}; // changes whenever the cover does
};
enum class Command { play, pause, toggle, next, previous };
// Starts the watcher (a thread of its own); once. Nothing is read before this.
void start() noexcept;
// Stops it and waits for its thread. Safe when it was never started.
void stop() noexcept;
// The last track seen. Any thread; a copy.
Track now();
// Only what changes often, without copying the cover. Any thread.
std::uint64_t revision() noexcept;
// Asks the player to do it. Any thread; done by the watcher, a moment later.
void send(Command command) noexcept;
// Whose session it is: Spotify's (empty, as it begins), or that of the application of this
// name ("chrome.exe"), for which Windows' current one is taken when it has none of its own.
void use_app(const std::string& executable);
}
+115 -2
View File
@@ -1,4 +1,12 @@
#include "Extension/Customization/local_customization_runtime.h"
#include "Extension/Music/spotify_station.h"
#include "Extension/Music/spotify_cover.h"
#include "Extension/Music/spotify_web.h"
#include "Extension/Music/spotify_rows.h"
#include "Extension/Music/external_audio.h"
#include "Extension/Music/youtube_music.h"
#include <cstring>
#include "Extension/Music/local_music_ui.h"
#include "local_music_assets.h"
#include "music_artwork.h"
#include "Engine/Core/Log/logging.h"
@@ -62,8 +70,42 @@ bool read_music_catalog(MusicCatalog& result) {
auto& native = music_asset_functions();
std::uintptr_t manager{};
if (!native.lock || !native.unlock || !read(base + addr::local_music::asset_manager, manager) || !manager) return false;
// Native registration/unregistration uses this same lock.
// Copy data while held; never retain a borrowed asset pointer in the UI.
// (The Spotify station's song reads as itself here, not as what Spotify is playing.)
spotify_station::restore_presentation();
// The rows of the Spotify playlists (spotify_rows.h): one each, that says what picking the
// playlist does. Registered before the songs are read, so they are among them.
// Spotify's own (the plain station, then the player's playlists) only when the player uses
// Spotify here: there is no tile of it, and no row, for one who does not.
std::vector<spotify_web::Playlist> spotify_playlists;
if (spotify_station::spotify_used()) {
spotify_playlists.push_back({spotify_station::plain_station, spotify_station::playlist_name, {}, {}});
for (auto& theirs : spotify_web::playlists()) spotify_playlists.push_back(std::move(theirs));
}
// (And the External Audio station, external_audio.h, when an application is picked for it:
// it is in a row of its own in the music screen.)
if (!external_audio::app().empty()) spotify_playlists.push_back({external_audio::station, external_audio::station_name, {}, {}});
// (And YouTube Music's playlists, youtube_music.h, each with its songs as its rows.)
{
std::size_t number{};
for (const auto& theirs : youtube_music::playlists()) {
spotify_web::Playlist playlist{youtube_music::station_prefix + std::to_string(number++), theirs.name, theirs.artwork, {}};
for (const auto& song : theirs.songs) playlist.tracks.push_back({song.artist, song.title, youtube_music::track_prefix + song.id});
spotify_playlists.push_back(std::move(playlist));
}
}
// For each station, its rows' ids and the Spotify track each is (none for a row that only
// says what picking the playlist does).
std::vector<std::vector<std::pair<std::string, std::string>>> spotify_row_ids;
for (const auto& theirs : spotify_playlists) {
auto& rows = spotify_row_ids.emplace_back();
for (const auto& track : theirs.tracks)
if (music_ui_text(track.artist, 200) && music_ui_text(track.title, 200))
if (auto id = spotify_rows::ensure(base, manager, track.artist, track.title); !id.empty() &&
std::none_of(rows.begin(), rows.end(), [&](const auto& row) { return row.first == id; }))
rows.emplace_back(std::move(id), track.uri);
if (theirs.id == spotify_station::plain_station) rows.emplace_back(spotify_rows::ensure(base, manager, "Spotify", "Start Radio"), std::string());
else if (rows.empty() && music_ui_text(theirs.name, 200)) rows.emplace_back(theirs.id == external_audio::station ? spotify_rows::ensure(base, manager, external_audio::station_name, "Start") : spotify_rows::ensure(base, manager, "Spotify", "Play " + theirs.name), std::string());
}
native.lock(manager + 0x20);
struct Unlock {
game::NativeModelFunctions::Lock function;
@@ -80,6 +122,12 @@ bool read_music_catalog(MusicCatalog& result) {
MusicCatalog snapshot;
snapshot.songs.reserve(count);
// Each song's identity as playlists hold it (the 16 bytes before its asset, when it has one).
std::map<std::string, std::array<unsigned char, 16>, std::less<>> identities;
std::map<std::string, std::uintptr_t, std::less<>> metadata_of;
// Songs the game ships the sound of (those its own offline playlists hold), which a
// Spotify station's song has to be: one whose sound is not there is never started.
std::set<std::string, std::less<>> shipped;
std::map<std::string, std::vector<std::string>, std::less<>> playlists;
std::set<std::uintptr_t> visited;
std::set<std::string, std::less<>> song_ids;
@@ -127,6 +175,12 @@ bool read_music_catalog(MusicCatalog& result) {
playlists[playlist].push_back(song.id);
}
}
if (std::uint32_t flags{}; graph >= 16 && read(graph + 0x14, flags) && (flags & 0x100)) {
std::array<unsigned char, 16> identity{};
if (dingosdk::memory::peek_bytes(graph - 16, identity.data(), identity.size()) && identities.emplace(song.id, identity).second)
metadata_of[song.id] = metadata;
}
if (!song.playlists.empty()) shipped.insert(song.id);
snapshot.songs.push_back(std::move(song));
if (!read(node + 0x10, node)) return false;
}
@@ -184,6 +238,65 @@ bool read_music_catalog(MusicCatalog& result) {
++song_artwork;
}
}
// The Spotify stations (spotify_station.h): a playlist has to hold a registered song to be
// one, so each is given one of them, a different one each: the first is whatever Spotify
// is playing, the rest the player's own Spotify playlists. What plays while one is selected
// is Spotify, not that song. Each is known again, when the game binds it, by that song
// being its only member.
{
std::vector<spotify_station::Station> stations;
const auto& own = spotify_playlists;
std::size_t next{};
std::string said;
for (const auto& [song, identity] : identities) {
if (next >= own.size()) break;
if (!shipped.empty() && !shipped.contains(song)) continue;
const auto metadata = metadata_of.find(song);
if (metadata == metadata_of.end()) continue;
std::string id = spotify_station::playlist_id, name = spotify_station::playlist_name, artwork = spotify_cover::station_url(), playlist;
const auto entry = next++;
if (own[entry].id != spotify_station::plain_station) {
const auto& theirs = own[entry];
if (!music_ui_text(theirs.name)) continue; // (a name the music screen cannot show)
// (The music screen shows playlists in the order of their ids: Liked Songs' is
// made to come first of these.)
const bool liked = theirs.id == spotify_web::liked_songs;
const bool tube = theirs.id.starts_with(youtube_music::station_prefix);
id = theirs.id == external_audio::station ? std::string(external_audio::playlist_id)
: tube ? youtube_music::playlist_prefix + theirs.id.substr(std::strlen(youtube_music::station_prefix))
: spotify_station::playlist_prefix + (liked ? "!" + theirs.id : theirs.id);
name = theirs.name;
// (Spotify's own picture is for Spotify's playlists that have none.)
artwork = liked && !spotify_cover::liked_url().empty() ? spotify_cover::liked_url()
: theirs.artwork.empty() && !tube && theirs.id != external_audio::station ? spotify_cover::station_url() : theirs.artwork;
playlist = theirs.id;
}
// Its rows are the songs registered for it, and the game's song is added as it is
// bound (local_music_playback.cpp); without any the game's song is its row.
spotify_station::Station station{{}, {}, identity, metadata->second, playlist, id};
std::vector<std::string> members;
for (const auto& [row, track] : spotify_row_ids[entry])
if (const auto found = row.empty() ? identities.end() : identities.find(row); found != identities.end() && index.contains(row)) {
station.rows.push_back(found->second);
station.tracks.push_back(track);
members.push_back(row);
}
if (members.empty()) {
station.rows.push_back(identity);
station.tracks.emplace_back();
members.push_back(song);
}
merge(id, name, artwork, members);
said += (said.empty() ? "" : "; ") + name + ": " + std::to_string(members.size()) + " row(s) over " + song;
stations.push_back(std::move(station));
}
spotify_station::set_stations(std::move(stations), base + addr::local_music::metadata_vtable);
static std::string last;
if (said != last) {
last = said;
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::music, "Spotify stations and the songs they were given: {}.", said.empty() ? "none" : said);
}
}
for (auto it = playlists.begin(); it != playlists.end();)
it = it->second.empty() ? playlists.erase(it) : std::next(it);
{
+159 -3
View File
@@ -1,4 +1,6 @@
#include "local_music_playback.h"
#include "Extension/Music/spotify_station.h"
#include "Extension/Music/spotify_rows.h"
#include "local_music_playback_policy.h"
#include "Engine/Core/Hooks/hooks.h"
@@ -136,6 +138,21 @@ bool song_count(std::uintptr_t playlist_asset, std::uint32_t& count) noexcept {
return count > 0 && count <= 8192 && valid_range(entries, static_cast<std::size_t>(count) * 8);
}
// The last of a playlist's members is made its first (the others move down one).
bool last_member_first(std::uintptr_t begin, std::uint32_t count) noexcept {
if (count < 2) return true;
__try {
unsigned char last[16];
auto* const members = reinterpret_cast<unsigned char*>(begin);
std::memcpy(last, members + std::size_t{count - 1} * 16, 16);
std::memmove(members + 16, members, std::size_t{count - 1} * 16);
std::memcpy(members, last, 16);
return true;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return false;
}
}
bool member_count(std::uintptr_t node, std::uintptr_t& begin, std::uint32_t& count) noexcept {
std::uintptr_t end{};
if (!read_at(node + 0x10, begin) || !read_at(node + 0x18, end) || !begin || end < begin ||
@@ -416,10 +433,63 @@ bool music_playback_available() noexcept { return playback_available.load(std::m
MusicSelectNext& music_select_next_original() noexcept { return select_next; }
// When each channel was last bound to a playlist.
std::array<std::atomic<std::uint64_t>, 256> channel_bound_at{};
std::int32_t __fastcall music_select_next_hook(std::uintptr_t queue, std::uintptr_t playlist_asset) {
// A next the player asked for (the queue's flag for it, at +0xf1, is still up) is Spotify's
// next while a Spotify station is on.
// Only of the queue the station is on: the others (a speaker's in the world, the one a song
// grabbed from a speaker plays on) go on to their next songs for reasons of their own.
if (std::uint8_t asked{}; spotify_station::active() && dingosdk::memory::peek(queue + 0xf1, asked)) {
std::uint32_t channel{};
if (const auto base = image_base.load(std::memory_order_acquire); base && native_queue_channel(base, queue, channel)) {
if (channel == spotify_station::channel()) spotify_station::song_chosen(queue, asked != 0);
// A next asked of another channel is the player's too when it is not that channel
// being set up (a speaker's music, once grabbed, is what the player's next button
// works: traced 2026-10-12): a channel asks for its first song the same way as it
// is bound, and that is not a press of the player's.
else if (asked && channel < channel_bound_at.size() && GetTickCount64() - channel_bound_at[channel].load(std::memory_order_relaxed) > 2500)
spotify_station::next_pressed();
}
}
if (select_next) {
apply_policy(queue, playlist_asset);
return select_next(queue, playlist_asset);
auto chosen = select_next(queue, playlist_asset);
// While a Spotify station is on, the game's one song of it is played again when the game
// finds none to go on to (it is at the end of a playlist of one song it can play): the
// music would stop otherwise.
static std::atomic<std::uintptr_t> last_queue{};
static std::atomic<std::int32_t> last_chosen{-1};
static std::atomic<int> last_station{-1};
// (Of the station's own queue alone: another channel's choices are not its.)
std::uint32_t queue_channel{~0u};
const bool stations_queue = native_queue_channel(image_base.load(std::memory_order_acquire), queue, queue_channel) && queue_channel == spotify_station::channel();
if (const int station = spotify_station::current(); station >= 0 && !stations_queue) {
} else if (station >= 0) {
if (last_station.exchange(station, std::memory_order_relaxed) != station) last_chosen.store(-1, std::memory_order_relaxed);
if (chosen >= 0) {
last_queue.store(queue, std::memory_order_relaxed);
last_chosen.store(chosen, std::memory_order_relaxed);
} else {
// (The game makes a queue anew when its channel is bound again: the place of the
// station's one song in it is the same.)
chosen = last_chosen.load(std::memory_order_relaxed);
}
} else {
last_station.store(-1, std::memory_order_relaxed);
}
if (static std::atomic<int> said{}; spotify_station::recent() && said.fetch_add(1, std::memory_order_relaxed) < 600) {
try {
std::uint32_t chosen_channel{~0u};
std::uint8_t asked{};
native_queue_channel(image_base.load(std::memory_order_acquire), queue, chosen_channel);
dingosdk::memory::peek(queue + 0xf1, asked);
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::music, "Trace: channel {} (queue 0x{:x}) chose song {} of its queue{}.", chosen_channel, queue, chosen,
asked ? " (asked for its next)" : "");
} catch (...) {}
}
return chosen;
}
return -1;
}
@@ -456,9 +526,95 @@ std::uintptr_t __fastcall music_channel_playlist_bind_hook(std::uintptr_t channe
favorites_remapped = playlist_id != requested_playlist_id;
}
const auto result = favorites_remapped
? bind_channel_playlist(channel_pointer, reinterpret_cast<std::uintptr_t>(&playlist_id))
// A Spotify station is a playlist whose only member is the song it was given
// (spotify_station.h). Looked up under the manager's lock, as Favorites is.
std::uint32_t station_members{};
int station{-1};
if (have_binding) {
const auto base = image_base.load(std::memory_order_acquire);
std::uintptr_t manager{};
if (base && read_at(base + local_music::asset_manager, manager) && manager && valid_range(manager, 0xd8)) {
CriticalSectionGuard manager_lock(manager + 0x20);
std::uintptr_t begin{};
if (const auto node = find_playlist_node(manager, playlist_id); node && member_count(node, begin, station_members) &&
station_members <= 128 && spotify_station::has_stations()) {
std::array<unsigned char, 128 * 16> members{};
std::array<unsigned char, 16> carrier{};
bool add{};
if (dingosdk::memory::peek_bytes(begin, members.data(), std::size_t{station_members} * 16))
station = spotify_station::station_of(members.data(), station_members, carrier.data(), add);
// Its members are rows of our own, which the game cannot play: the game's song
// whose sound is replaced is added to it now, once.
// (It is put first: the game starts a playlist at a member it can play.)
std::uint32_t cursor{};
read_at(node + 0x30, cursor);
const int first = station >= 0 ? spotify_station::row_of(station, members.data()) : -1;
if (station >= 0 && add && spotify_rows::add_member(base, manager, playlist_id, carrier.data())) {
std::uintptr_t now_begin{};
std::uint32_t now_count{};
if (member_count(node, now_begin, now_count) && now_count == station_members + 1) last_member_first(now_begin, now_count);
else {
try {
dingosdk::logging::log(dingosdk::logging::Level::warning, dingosdk::logging::Channel::music,
"Spotify station: the game's song did not go into playlist {} ({} member(s) after).", playlist_id, now_count);
} catch (...) {}
}
}
if (station >= 0) {
try {
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::music,
"Spotify station: playlist {} has {} member(s), its first is row {} of station {}, its place is {}{}.", playlist_id, station_members, first, station,
cursor, add ? "; the game's song was added" : "");
} catch (...) {}
}
}
}
}
// The playlist each channel was last bound to (0: none seen).
static std::array<std::atomic<std::uint32_t>, 256> bound_playlist{};
// Grabbing the music of a speaker in the world binds the player's channel to the speaker's
// playlist and has it go on to a song (traced 2026-10-12: no song is asked for by name).
// While a Spotify station is on the speakers' sound is Spotify's, so what the player grabs
// is what they have: their channel is bound to its station again and stays on Spotify.
// A playlist the player picks in the music screen comes by way of the screen's own code
// (the return address below is in every such binding traced, and in none the world made);
// anything else that takes the player's channel off its station is the world's doing.
if (have_binding && station < 0 && playlist_id != 0 && channel < bound_playlist.size() && spotify_station::active() && channel == spotify_station::channel()) {
constexpr std::uintptr_t music_screen_return = 0x3df0b6e;
void* frames[16]{};
const auto count = CaptureStackBackTrace(0, 16, frames, nullptr);
const auto image = image_base.load(std::memory_order_acquire);
bool from_screen{};
for (unsigned i = 0; i < count; ++i) from_screen = from_screen || reinterpret_cast<std::uintptr_t>(frames[i]) == image + music_screen_return;
if (auto own = bound_playlist[channel].load(std::memory_order_relaxed); !from_screen && own && image) {
spotify_station::grabbed();
try {
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::music,
"Spotify station: channel {} was asked for playlist {} by the world, not the music screen (a speaker's music was grabbed): it stays on Spotify.", channel, playlist_id);
} catch (...) {}
return bind_channel_playlist(channel_pointer, reinterpret_cast<std::uintptr_t>(&own));
}
}
const auto result = favorites_remapped ? bind_channel_playlist(channel_pointer, reinterpret_cast<std::uintptr_t>(&playlist_id))
: bind_channel_playlist(channel_pointer, playlist_id_pointer);
if (have_binding && channel < bound_playlist.size()) bound_playlist[channel].store(playlist_id, std::memory_order_relaxed);
if (have_binding && channel < channel_bound_at.size()) channel_bound_at[channel].store(GetTickCount64(), std::memory_order_relaxed);
if (static std::atomic<int> said{}; have_binding && said.fetch_add(1, std::memory_order_relaxed) < 600) {
try {
void* frames[10]{};
const auto count = CaptureStackBackTrace(1, 10, frames, nullptr);
const auto image = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
std::string callers;
for (unsigned i = 0; i < count; ++i) {
char text[24];
std::snprintf(text, sizeof(text), " +0x%llx", static_cast<unsigned long long>(reinterpret_cast<std::uintptr_t>(frames[i]) - image));
callers += text;
}
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::music, "Trace: channel {} bound to playlist {} ({} member(s), station {}); the Spotify station is on channel {}, a menu is {}; from{}.",
channel, playlist_id, station_members, station, static_cast<int>(spotify_station::channel()), spotify_station::music_screen() ? "up" : "not up", callers);
} catch (...) {}
}
if (have_binding) spotify_station::bound(channel, music_channel, playlist_id, station_members, station);
// Said once for each change of a channel's binding. A channel outside the table cannot be
// told from its last binding, so it is not said at all rather than every time.
+92 -64
View File
@@ -1,6 +1,9 @@
#include "Extension/Music/local_music_shelf.h"
#include "Extension/Music/local_music_shelf_lifetime.h"
#include "Extension/Music/local_music_assets.h"
#include "Extension/Music/spotify_station.h"
#include "Extension/Music/external_audio.h"
#include "Extension/Music/youtube_music.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Game/Abi/native_data.h"
#include "Engine/Game/Build/addresses.h"
@@ -9,6 +12,7 @@
#include "Extension/Profile/runtime_internal.h"
#include "Extension/UI/NativeMenu/native_menu_data.h"
#include <algorithm>
#include <array>
#include <cstring>
#include <exception>
#include <initializer_list>
@@ -26,11 +30,24 @@ std::string music_diag_hex(std::uint64_t value, int width) {
return out;
}
namespace md = dingosdk::multiplayer::menu_data;
// A row of our own above the game's: the tiles of Featured whose playlists are its.
struct ShelfRow {
const char* title;
bool (*mine)(std::string_view id);
std::uint64_t tag; // the low bits of its models' ids
md::Value anchor{}, tiles{};
std::vector<std::byte> published_rows;
};
bool mods_row(std::string_view id) { return id.starts_with("mod:"); }
bool spotify_row(std::string_view id) { return spotify_station::is_station(id); }
bool youtube_row(std::string_view id) { return id.starts_with(youtube_music::playlist_prefix); }
bool external_row(std::string_view id) { return id.starts_with(external_audio::playlist_prefix); }
struct ModsShelf {
std::uintptr_t manager{};
md::Value anchor{}, tiles{}, featured_anchor{}, list{};
md::Value featured_anchor{}, list{};
ULONGLONG next_poll{};
std::vector<std::byte> published_rows;
// In the order they are shown.
std::array<ShelfRow, 4> rows{{{"Spotify", &spotify_row, 0x5d}, {"YouTube Music", &youtube_row, 0x7d}, {"External Audio", &external_row, 0x6d}, {"Mods", &mods_row, 0x4d}}};
};
ModsShelf mods_shelf;
md::MenuLifetime music_shelf_lifetime;
@@ -62,13 +79,16 @@ void music_release_shelf(std::uintptr_t base) {
md::require(stride == sizeof(md::Ref), "Music shelf cleanup list differs.");
std::vector<md::Ref> refs(count);
if (!bytes.empty()) std::memcpy(refs.data(), bytes.data(), bytes.size());
const auto retained = music_shelf_detached(std::span<const md::Ref>{refs}, mods_shelf.anchor.handle);
auto retained = refs;
for (const auto& row : mods_shelf.rows)
if (row.anchor.handle) retained = music_shelf_detached(std::span<const md::Ref>{retained}, row.anchor.handle);
if (retained.size() != refs.size()) context.array(items, retained);
}
// Unbind the child before reverse-creation-order destruction. Both roots
// must release their widget references while the outgoing assets live.
if (mods_shelf.anchor.handle && context.type_of(mods_shelf.anchor.handle) == mods_shelf.anchor.type)
context.set(context.path(mods_shelf.anchor, {0x214d4984U, 0x25e4d6c8U}), md::Ref{});
for (const auto& row : mods_shelf.rows)
if (row.anchor.handle && context.type_of(row.anchor.handle) == row.anchor.type)
context.set(context.path(row.anchor, {0x214d4984U, 0x25e4d6c8U}), md::Ref{});
}, [&](md::Value value) { context.destroy(value); });
mods_shelf = {};
}
@@ -87,20 +107,20 @@ md::Ref music_tile_ref(const md::Context& context, md::Value anchor) {
{0x214d4984U, 0x25e4d6c8U}));
}
void music_sync_mods(const md::Context& context) {
if (mods_shelf.manager != context.manager || !mods_shelf.tiles.handle) return;
if (mods_shelf.manager != context.manager) return;
if (std::none_of(mods_shelf.rows.begin(), mods_shelf.rows.end(), [](const ShelfRow& row) { return row.tiles.handle != 0; })) return;
const auto ref = music_tile_ref(context, mods_shelf.featured_anchor);
const auto handle = ref.handle & ~std::uint64_t{1};
if (!handle) return;
const md::Value featured{handle, context.type_of(handle)};
md::require(featured.type == mods_shelf.tiles.type, "Featured tile schema differs.");
const auto source_items = context.field(featured, 0x67223da7U);
unsigned count{}, stride{};
const auto source = context.array(source_items, 1024, count, stride);
const auto row_type = md::read<std::uintptr_t>(md::read<std::uintptr_t>(source_items.type) + 0x30);
md::require(stride == 1200 && md::read<std::uint32_t>(md::read<std::uintptr_t>(row_type)) == 0xdeb20e0fU,
"Music tile row schema differs.");
std::vector<std::byte> filtered;
unsigned mods{};
// The playlist each of Featured's tiles is of.
std::vector<std::string> ids(count);
for (unsigned i = 0; i < count; ++i) {
const auto at = reinterpret_cast<std::uintptr_t>(source.data() + std::size_t{i} * stride);
const auto tile = md::read<md::Ref>(music_inline_path(context, at, row_type,
@@ -112,21 +132,29 @@ void music_sync_mods(const md::Context& context) {
context.address(tile_model), tile_model.type, {0xeb4f9b4cU})) & ~std::uint64_t{1};
if (!playlist_handle) continue;
const md::Value playlist{playlist_handle, context.type_of(playlist_handle)};
const auto id = context.text(context.field(playlist, 0xa5b84b8aU), 2048);
if (!id.starts_with("mod:")) continue;
filtered.insert(filtered.end(), source.begin() + std::size_t{i} * stride,
source.begin() + std::size_t{i + 1} * stride);
++mods;
ids[i] = context.text(context.field(playlist, 0xa5b84b8aU), 2048);
}
for (auto& row : mods_shelf.rows) {
if (!row.tiles.handle) continue;
md::require(featured.type == row.tiles.type, "Featured tile schema differs.");
std::vector<std::byte> filtered;
unsigned mine{};
for (unsigned i = 0; i < count; ++i) {
if (ids[i].empty() || !row.mine(ids[i])) continue;
filtered.insert(filtered.end(), source.begin() + std::size_t{i} * stride,
source.begin() + std::size_t{i + 1} * stride);
++mine;
}
const auto destination = context.field(row.tiles, 0x67223da7U);
unsigned previous{}, previous_stride{};
context.array(destination, 1024, previous, previous_stride);
md::require(previous_stride == stride, "Mods tile row schema differs.");
if (previous == mine && row.published_rows == filtered) continue;
context.array(destination, filtered, mine);
row.published_rows = std::move(filtered);
dingosdk::logging::event(dingosdk::logging::Channel::music,
dingosdk::Json{{"event", "music_mods_shelf_fill"}, {"row", row.title}, {"count", mine}, {"featured", count}}.dump().c_str());
}
const auto destination = context.field(mods_shelf.tiles, 0x67223da7U);
unsigned previous{}, previous_stride{};
context.array(destination, 1024, previous, previous_stride);
md::require(previous_stride == stride, "Mods tile row schema differs.");
if (previous == mods && mods_shelf.published_rows == filtered) return;
context.array(destination, filtered, mods);
mods_shelf.published_rows = std::move(filtered);
dingosdk::logging::event(dingosdk::logging::Channel::music,
dingosdk::Json{{"event", "music_mods_shelf_fill"}, {"count", mods}, {"featured", count}}.dump().c_str());
}
void music_poll_mods(std::uintptr_t base) {
if (music_shelf_lifetime.blocked() || music_shelf_cleaning ||
@@ -148,7 +176,7 @@ void music_poll_mods(std::uintptr_t base) {
}
}
// Publish the independent Mods shelf before native widgets bind.
std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, std::uint64_t exact_list = 0) {
std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, std::uint64_t exact_list, const std::array<bool, 4>& wanted) {
namespace md = dingosdk::multiplayer::menu_data;
game::ModelWriteLock lock(model);
md::Context shelf{base, model};
@@ -173,7 +201,7 @@ std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, st
++candidates;
if (target_schema != 0x0e3be640U) continue;
report += " list=0x" + music_diag_hex(root.model.handle, 16) + " count=" + std::to_string(count);
if (count >= 4) { report += " already"; break; }
if (count >= 4) { report += " already"; break; } // (the game's own are three)
if (count < 2) { report += " too_few"; break; }
std::uint64_t record_ref = 0, source_vm = 0;
std::memcpy(&record_ref, bytes.data() + stride, 8);
@@ -184,46 +212,51 @@ std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, st
music_release_shelf(base);
mods_shelf.manager = model;
mods_shelf.list = root.model;
auto new_vm = shelf.create(md::Schema{0x0e3be640U, 192}, (GetTickCount64() << 12) | 0x4dU);
mods_shelf.anchor = new_vm;
music_shelf_models.track(new_vm);
md::Value new_tiles{};
auto merged = bytes;
unsigned added{};
try {
const md::Value source_anchor{source_vm, source_type};
shelf.copy(new_vm, shelf.address(source_anchor));
const auto tile_ref = music_tile_ref(shelf, source_anchor);
auto tile_handle = tile_ref.handle & ~std::uint64_t{1};
const auto tile_type = shelf.type(md::Schema{0x74b1ea1dU, 392});
std::uintptr_t tile_data{};
if (tile_handle && shelf.type_of(tile_handle) == tile_type)
tile_data = shelf.address(md::Value{tile_handle, tile_type});
else if (tile_ref.record && md::read<std::uintptr_t>(tile_ref.record + 0x18) == tile_type)
tile_data = md::read<std::uintptr_t>(tile_ref.record + 0x20);
md::require(tile_data != 0, "Music tile template is not available.");
new_tiles = shelf.create(md::Schema{0x74b1ea1dU, 392}, (GetTickCount64() << 12) | 0x4eU);
mods_shelf.tiles = new_tiles;
music_shelf_models.track(new_tiles);
shelf.copy(new_tiles, tile_data);
shelf.text(shelf.path(new_tiles, {0xfaec2d05U, 0xcb79482dU, 0x4d8e01b9U}), "Mods");
// An independent list retains each native row's actions and artwork on publication.
shelf.array(shelf.field(new_tiles, 0x67223da7U), std::span<const std::byte>{}, 0);
shelf.set(shelf.path(new_vm, {0x214d4984U, 0x25e4d6c8U}), md::Ref{0, new_tiles.handle});
// The last of them first: each is put ahead of what is there.
for (std::size_t index = mods_shelf.rows.size(); index-- > 0;) {
if (!wanted[index]) continue;
auto& row = mods_shelf.rows[index];
auto new_vm = shelf.create(md::Schema{0x0e3be640U, 192}, (GetTickCount64() << 12) | row.tag);
row.anchor = new_vm;
music_shelf_models.track(new_vm);
const md::Value source_anchor{source_vm, source_type};
shelf.copy(new_vm, shelf.address(source_anchor));
const auto tile_ref = music_tile_ref(shelf, source_anchor);
auto tile_handle = tile_ref.handle & ~std::uint64_t{1};
const auto tile_type = shelf.type(md::Schema{0x74b1ea1dU, 392});
std::uintptr_t tile_data{};
if (tile_handle && shelf.type_of(tile_handle) == tile_type)
tile_data = shelf.address(md::Value{tile_handle, tile_type});
else if (tile_ref.record && md::read<std::uintptr_t>(tile_ref.record + 0x18) == tile_type)
tile_data = md::read<std::uintptr_t>(tile_ref.record + 0x20);
md::require(tile_data != 0, "Music tile template is not available.");
auto new_tiles = shelf.create(md::Schema{0x74b1ea1dU, 392}, (GetTickCount64() << 12) | (row.tag + 1));
row.tiles = new_tiles;
music_shelf_models.track(new_tiles);
shelf.copy(new_tiles, tile_data);
shelf.text(shelf.path(new_tiles, {0xfaec2d05U, 0xcb79482dU, 0x4d8e01b9U}), row.title);
// An independent list retains each native row's actions and artwork on publication.
shelf.array(shelf.field(new_tiles, 0x67223da7U), std::span<const std::byte>{}, 0);
shelf.set(shelf.path(new_vm, {0x214d4984U, 0x25e4d6c8U}), md::Ref{0, new_tiles.handle});
std::vector<std::byte> element(stride);
std::memcpy(element.data(), &record_ref, 8);
std::memcpy(element.data() + 8, &new_vm.handle, 8);
merged.insert(merged.begin(), element.begin(), element.end());
++added;
}
} catch (...) {
music_release_shelf(base);
throw;
}
std::vector<std::byte> element(stride);
std::memcpy(element.data(), &record_ref, 8);
std::memcpy(element.data() + 8, &new_vm.handle, 8);
auto merged = bytes;
merged.insert(merged.begin(), element.begin(), element.end());
shelf.array(items, merged, count + 1);
shelf.array(items, merged, count + added);
mods_shelf.featured_anchor = md::Value{target, shelf.type_of(target)};
try { music_sync_mods(shelf); } catch (const std::exception&) {}
unsigned after = 0, after_stride = 0;
shelf.array(items, 64, after, after_stride);
report += " new=0x" + music_diag_hex(new_vm.handle, 16) + " appended=" + std::to_string(count + 1) +
" after=" + std::to_string(after);
report += " appended=" + std::to_string(count + added) + " after=" + std::to_string(after);
break;
} catch (const std::exception& error) { report += " error=" + std::string(error.what()); continue; }
}
@@ -268,17 +301,12 @@ std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mo
}
}
if (!is_music_shelf) return handle;
// Only add our shelf when an enabled mod actually declares a playlist with songs. Otherwise
// leave the native three shelves untouched instead of publishing an empty Mods shelf.
// The Mods row only when an enabled mod actually declares a playlist with songs, rather
// than an empty one; the Spotify row always (it has the Spotify playlist at the least).
bool has_mod_playlists = false;
for (const auto& playlist : mod_music_playlists())
if (!playlist.songs.empty()) { has_mod_playlists = true; break; }
if (!has_mod_playlists) {
music_release_shelf(local_runtime().base);
dingosdk::logging::event(dingosdk::logging::Channel::music, "{\"event\":\"music_mods_shelf_skipped\"}");
return handle;
}
const auto report = music_shelf_append_one(local_runtime().base, manager, handle);
const auto report = music_shelf_append_one(local_runtime().base, manager, handle, {spotify_station::spotify_used(), !youtube_music::playlists().empty(), !external_audio::app().empty(), has_mod_playlists});
dingosdk::logging::event(dingosdk::logging::Channel::music,
dingosdk::Json{{"event", "music_shelf_append"}, {"report", report}}.dump().c_str());
} catch (...) {} // A shelf failure must not interrupt the native constructor.
+113
View File
@@ -6,6 +6,11 @@
#include "local_music_safety.h"
#include "local_music_ui.h"
#include "local_music_favorites.h"
#include "spotify_cover.h"
#include <atomic>
#include "Extension/UI/NativeMenu/native_menu_data.h"
#include "external_player.h"
#include "spotify_station.h"
#include "Engine/Game/Abi/native_data.h"
#include "Engine/Game/Build/addresses.h"
#include "Engine/Game/Build/20260929/engine.h"
@@ -413,4 +418,112 @@ void update_music_catalog() {
dingosdk::logging::event(dingosdk::logging::Channel::music, dingosdk::Json{{"event", "native_music_ui_complete"}, {"playlists", playlist_count}, {"songs", song_count}, {"source", "runtime_music_assets"}}.dump().c_str());
} catch (...) { retry = false; dingosdk::logging::event(dingosdk::logging::Channel::music, "{\"event\":\"native_music_ui_publish_failed\"}"); }
}
namespace {
std::atomic<bool> catalog_refresh_wanted{};
std::atomic<std::uint64_t> catalog_refresh_at{};
}
void request_music_catalog_refresh() noexcept {
catalog_refresh_at.store(GetTickCount64(), std::memory_order_relaxed);
catalog_refresh_wanted.store(true, std::memory_order_relaxed);
}
void update_music_catalog_refresh() {
auto& s = local_runtime(); auto& ui = music_ui_runtime();
if (!catalog_refresh_wanted.load(std::memory_order_relaxed) || !s.active.load(std::memory_order_acquire) || ui.updating ||
cosmetic_runtime().update_thread != GetCurrentThreadId())
return;
// (A moment after it was asked: what was added is still being finished, its picture for one.)
if (GetTickCount64() - catalog_refresh_at.load(std::memory_order_relaxed) < 1500) return;
PreserveError preserve; std::lock_guard lock(s.native_mutex);
catalog_refresh_wanted.store(false, std::memory_order_relaxed);
// A screen that is still to be filled for the first time is filled as things are now
// (update_music_catalog): there is nothing to give it again.
if (ui.pending) return;
if (!ui.initialized || ui.initialized->thread != GetCurrentThreadId() || !music_ui_current(*ui.initialized)) return; // (the next screen is built with it)
const char* outcome = "done";
std::size_t playlist_total{}, song_total{};
ui.updating = true;
struct Scope { bool& flag; ~Scope() { flag = false; } } scope{ui.updating};
try {
const auto manager = ui.initialized->manager;
MusicCatalog catalog;
std::string favorite_id;
std::uintptr_t arena{}, vtable{};
if (!read_music_catalog(catalog) || catalog.playlists.empty()) outcome = "the game's songs could not be read";
else if (!identifier(reinterpret_cast<const void*>(manager + 8), favorite_id) || !music_ui_catalog_valid(catalog, favorite_id)) outcome = "the list was not one the music screen takes";
else if (!read(s.base + addr::engine::default_arena, arena) || !arena || !read(arena, vtable) || vtable < s.base || vtable >= s.base + supported_build::game_image_size) outcome = "the game's memory was not ready";
else {
ui.functions.allocator[1] = arena;
MusicUiMessages playlists, songs;
playlists.items.reserve(catalog.playlists.size()); songs.items.reserve(catalog.songs.size());
for (const auto& playlist : catalog.playlists) playlists.items.push_back(music_ui_message(music_ui_wire(playlist.id, {}, {}, &playlist.songs, playlist.name, playlist.artwork), true));
for (const auto& song : catalog.songs) songs.items.push_back(music_ui_message(music_ui_wire(song.id, song.artist, song.title, nullptr, {}, song.artwork), false));
if (music_ui_current(*ui.initialized)) {
// (Songs first: a playlist's songs are looked up among them.)
songs.publish(manager, ui.functions.songs);
if (music_ui_current(*ui.initialized)) playlists.publish(manager, ui.functions.playlists);
playlist_total = catalog.playlists.size();
song_total = catalog.songs.size();
}
}
} catch (...) {
outcome = "it failed";
}
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::music, "Music screen: its playlists and songs were given again ({}): {} playlist(s), {} song(s).", outcome,
playlist_total, song_total);
}
void update_spotify_cover() {
auto& s = local_runtime(); auto& ui = music_ui_runtime();
// The song and cover last written, so each is written once.
static std::string shown;
static std::uint64_t generation{}, looked{};
// (Why it did nothing, into the log once for each reason, while this is being worked out.)
static int said_why{};
const auto why = [](int reason, const char* text) {
if (said_why == reason) return;
said_why = reason;
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::music, "Spotify station cover: {}.", text);
};
if (!s.active.load(std::memory_order_acquire) || ui.updating || cosmetic_runtime().update_thread != GetCurrentThreadId() || !spotify_station::active()) return;
const auto now = GetTickCount64();
if (now - looked < 250) return;
looked = now;
PreserveError preserve; std::lock_guard lock(s.native_mutex);
if (!ui.initialized || ui.initialized->thread != GetCurrentThreadId() || !music_ui_current(*ui.initialized)) {
return why(1, "the music screen's data is not there to write to");
}
if (generation != ui.initialized->generation) { // a music screen built anew
generation = ui.initialized->generation;
shown.clear();
}
const auto cover = spotify_cover::url();
const auto track = external_player::now();
if (cover.empty() || !track.available || track.artist.empty() || track.title.empty()) return why(2, "no cover of Spotify's is ready yet");
// The game shows as now playing the song whose artist and title its player reads, which
// the station has made Spotify's (spotify_station.h): the music screen is given a song of
// that name, as the game itself would make one, and the cover is written to it.
const auto id = track.artist + " - " + track.title;
if (id + "|" + cover == shown) return;
std::array<unsigned char, 32> bytes{};
if (!read(s.base + addr::local_music::song_context_contract.rva, bytes) || bytes != addr::local_music::song_context_contract.bytes)
return why(3, "the game's routine for a song of the music screen is not the one this was written for");
auto& models = game::native_data().models;
const auto manager = ui.initialized->manager, model = ui.initialized->model;
struct Text {
std::uintptr_t value{};
explicit Text(const std::string& text) { game::native_data().values.assign(&value, text.c_str(), static_cast<std::uint32_t>(text.size())); }
~Text() { local_runtime().destroy_string(&value); }
};
Text name(id), address(cover);
const auto context = reinterpret_cast<std::uint64_t (*)(std::uintptr_t, std::uintptr_t*)>(s.base + addr::local_music::song_context_contract.rva)(manager, &name.value);
const auto field = context ? models.field(model, context, 1, 0xffffffffU, false) : 0;
if (!field) return why(4, "the music screen gave no song to write the cover to");
models.publish(model, field, s.base + addr::engine::string_type, &address.value);
shown = id + "|" + cover;
said_why = 0;
// The game looks a song's cover up when the song starts, and this one has started.
spotify_station::refresh_now_playing();
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::music, "Spotify station cover: \"{}\" has {}.", id, cover);
}
}
+8
View File
@@ -89,5 +89,13 @@ void music_ui_initialize_hook(std::uint64_t all, std::uint64_t hidden, std::uint
const std::uint32_t* handle, const void* callback);
void update_music_catalog();
// The Spotify station (spotify_station.h): the cover of its song in the music screen is what
// Spotify is playing while the station is on, and the song's own again when it is left.
void update_spotify_cover();
// The music screen's playlists and songs are given to it again as they are now (a playlist
// the player has just added, in the setup page, is then there without the game being started
// again). Any thread asks; it is done by the thread that fills the screen. (Being tried.)
void request_music_catalog_refresh() noexcept;
void update_music_catalog_refresh();
}
+328
View File
@@ -0,0 +1,328 @@
#include "music_inject.h"
#include "app_audio_capture.h"
#include "spotify_device.h"
#include "external_audio.h"
#include "youtube_music.h"
#include "external_player.h"
#include "spotify_station.h"
#include "Engine/Core/Hooks/hooks.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Core/Platform/memory.h"
#include "Engine/Game/Build/20260929/audio_core.h"
#include "Engine/Game/Build/addresses.h"
#include <Windows.h>
#include <intrin.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <cstring>
#include <format>
namespace dingosdk::music_inject {
namespace {
namespace ac = addr::audio_core;
using Process = std::uint64_t (*)(std::uintptr_t player, std::uintptr_t mixer);
using Command = void (*)(std::uintptr_t player, int command, std::uintptr_t arguments);
using Exit = void (WINAPI*)(UINT code);
Process process_original{};
Command command_original{};
Exit exit_original{};
std::atomic<bool> installed{}, hooked{};
std::atomic<int> which{1};
std::atomic<float> loudness{1.0f};
// The sound file of the station's own song: the one a player is asked for just after the game
// begins that song (the asking is a moment after, on the same thread or the next frame).
std::atomic<std::uint64_t> expect_until{};
char station_file[120]{};
std::atomic<std::uint64_t> last_replaced{}; // GetTickCount64 of the last block replaced
std::atomic<std::uint64_t> replaced_blocks{}, replaced_frames{};
// The game's players of sound files seen so far, for the log and the console: what each was
// last asked to play and what it produces. Written by the game's threads, read by the client
// thread; a name read while it is being written is a muddle for one line and no more.
struct Player {
std::atomic<std::uintptr_t> address{};
char name[120]{};
std::atomic<std::uint32_t> requests{}, said{};
std::atomic<std::uint64_t> blocks{}, replaced{};
std::atomic<std::uint32_t> channels{}, rate{}, seconds{};
};
std::array<Player, 96> players{};
Player* player_of(std::uintptr_t address) noexcept {
for (auto& player : players) {
auto held = player.address.load(std::memory_order_relaxed);
if (held == address) return &player;
if (!held && player.address.compare_exchange_strong(held, address, std::memory_order_relaxed)) return &player;
if (held == address) return &player;
}
return nullptr;
}
template <class T> T at(std::uintptr_t address) noexcept {
T value;
std::memcpy(&value, reinterpret_cast<const void*>(address), sizeof(value));
return value;
}
// A block the player has just produced into `buffer` is written over with what Spotify is
// playing. The audio thread: no locks, no allocation.
bool replace(std::uint64_t reader, std::uintptr_t buffer, std::uint32_t frames, std::uint32_t channels, float rate) noexcept {
__try {
const auto data = at<std::uintptr_t>(buffer + ac::buffer_data);
const bool together = at<std::uint8_t>(buffer + ac::buffer_together) != 0;
const auto stride = at<std::uint16_t>(buffer + ac::buffer_stride);
if (!data || (together && stride < frames)) return false;
const auto channel = [&](std::uint32_t index) {
return together ? reinterpret_cast<float*>(data + std::size_t{stride} * index * 4) : at<float*>(data + std::size_t{index} * 8);
};
float* const left = channel(0);
float* const right = channel(1);
if (!left || !right) return false;
app_audio::read(reader, left, right, frames, rate);
if (const float gain = loudness.load(std::memory_order_relaxed); gain != 1.0f)
for (std::uint32_t i = 0; i < frames; ++i) {
left[i] *= gain;
right[i] *= gain;
}
for (std::uint32_t i = 2; i < channels; ++i)
if (float* const other = channel(i)) std::memset(other, 0, std::size_t{frames} * 4);
return true;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return false;
}
}
std::uint64_t process_hook(std::uintptr_t player, std::uintptr_t mixer) {
// What it is playing, read before it moves on to its next file.
std::uint32_t length{};
float voice_rate{};
std::uintptr_t buffer{};
std::uint64_t reader{};
__try {
const auto voice = player + at<std::uint16_t>(player + ac::player_voices) + std::size_t{at<std::uint8_t>(player + ac::player_voice)} * ac::voice_size;
if (const auto state = at<std::uint8_t>(voice + ac::voice_state); state != 0 && state != 5) {
length = at<std::uint32_t>(voice + ac::voice_frames);
voice_rate = at<float>(voice + ac::voice_rate);
}
buffer = at<std::uintptr_t>(mixer + ac::mixer_write);
// The game may run one player for more than one mix, and a player has more than one
// file going while it fades from one to the next: each of those reads for itself.
reader = (player * 0x9e3779b97f4a7c15ull) ^ (mixer * 0xc2b2ae3d27d4eb4full) ^ at<std::uint8_t>(player + ac::player_voice);
} __except (EXCEPTION_EXECUTE_HANDLER) {
buffer = 0;
}
const auto result = process_original(player, mixer);
if (!result || !buffer) return result;
__try {
const auto frames = at<std::uint32_t>(mixer + ac::mixer_frames);
const auto channels = std::uint32_t{at<std::uint8_t>(mixer + ac::mixer_channels)};
const auto rate = at<float>(mixer + ac::mixer_rate);
if (!frames || frames > 0x4000 || at<std::uintptr_t>(mixer + ac::mixer_written) != buffer) return result;
const std::uint32_t seconds = voice_rate > 1000.0f ? static_cast<std::uint32_t>(static_cast<float>(length) / voice_rate) : 0;
auto* const seen = player_of(player);
if (seen) {
seen->blocks.fetch_add(1, std::memory_order_relaxed);
seen->channels.store(channels, std::memory_order_relaxed);
seen->rate.store(static_cast<std::uint32_t>(rate), std::memory_order_relaxed);
if (seconds) seen->seconds.store(seconds, std::memory_order_relaxed);
}
const int mode = which.load(std::memory_order_relaxed);
if (!mode || channels < 2 || rate < 8000.0f || !spotify_station::active()) return result;
// (A song's length is kept from its last block that had one: the blocks of its fade
// out are of a file it has already left.)
// (A song's length is kept from its last block that had one: the blocks of its fade
// out are of a file it has already left.)
// The station's own song is replaced whatever its length (one a station was given was
// half a minute long, and was heard in the place of what the station plays, 2026-10-12).
const bool stations_own = seen && station_file[0] && std::strncmp(seen->name, station_file, sizeof(station_file)) == 0;
if (mode == 1 && !stations_own && (seen ? seen->seconds.load(std::memory_order_relaxed) : seconds) < 60) return result;
if (mode == 3 && !stations_own) return result;
if (replace(reader, buffer, frames, channels, rate)) {
last_replaced.store(GetTickCount64(), std::memory_order_relaxed);
replaced_blocks.fetch_add(1, std::memory_order_relaxed);
replaced_frames.fetch_add(frames, std::memory_order_relaxed);
if (seen) seen->replaced.fetch_add(1, std::memory_order_relaxed);
}
} __except (EXCEPTION_EXECUTE_HANDLER) {
}
return result;
}
void command_hook(std::uintptr_t player, int command, std::uintptr_t arguments) {
if (command == ac::command_play && arguments) {
__try {
if (auto* const seen = player_of(player)) {
const auto name = at<const char*>(arguments + ac::command_play_name);
std::size_t length{};
if (name)
while (length + 1 < sizeof(seen->name) && name[length]) {
seen->name[length] = name[length];
++length;
}
seen->name[length] = 0;
if (const auto until = expect_until.load(std::memory_order_relaxed); until && GetTickCount64() < until && length) {
expect_until.store(0, std::memory_order_relaxed);
std::memcpy(station_file, seen->name, sizeof(station_file));
}
seen->seconds.store(0, std::memory_order_relaxed);
seen->requests.fetch_add(1, std::memory_order_relaxed);
}
} __except (EXCEPTION_EXECUTE_HANDLER) {
}
}
command_original(player, command, arguments);
}
// The game is ending: Spotify's own output is put back first.
//
// Not when it is the Spotify library built into this DLL that asks for the end (librespot ends
// the whole program in a dozen places: on an account that is not Premium, on a sound it cannot
// play): that took the game down with it at every start for a player whose account was not
// Premium, 2026-10-12. Only the thread that asked is ended, and the device is left stopped.
// The library is known by the call coming from this DLL, which itself never ends the program.
void WINAPI exit_hook(UINT code) {
HMODULE caller{}, own{};
if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, static_cast<LPCWSTR>(_ReturnAddress()), &caller) &&
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, reinterpret_cast<LPCWSTR>(&exit_hook), &own) && caller == own) {
spotify_device::library_gave_up();
ExitThread(code);
}
app_audio::stop();
exit_original(code);
}
bool matches(std::uintptr_t base, const game::build::Fingerprint& contract) noexcept {
std::array<unsigned char, 32> bytes{};
return memory::peek(base + contract.rva, bytes) && bytes == contract.bytes;
}
} // namespace
bool install(std::uintptr_t base) noexcept {
if (installed.exchange(true)) return hooked.load();
if (!base || !matches(base, ac::stream_player_process) || !matches(base, ac::stream_player_command)) {
try {
logging::log(logging::Level::warning, logging::Channel::music, "Spotify through the game: the game's sound file player is not the one this was written for.");
} catch (...) {}
return false;
}
const auto process = reinterpret_cast<void*>(base + ac::stream_player_process.rva), command = reinterpret_cast<void*>(base + ac::stream_player_command.rva);
if (hook_prepare(command, reinterpret_cast<void*>(&command_hook), reinterpret_cast<void**>(&command_original)) != HookOk || hook_enable(command) != HookOk) {
command_original = nullptr;
return false;
}
if (hook_prepare(process, reinterpret_cast<void*>(&process_hook), reinterpret_cast<void**>(&process_original)) != HookOk || hook_enable(process) != HookOk) {
process_original = nullptr;
return false;
}
if (const auto exit = reinterpret_cast<void*>(&ExitProcess);
hook_prepare(exit, reinterpret_cast<void*>(&exit_hook), reinterpret_cast<void**>(&exit_original)) != HookOk || hook_enable(exit) != HookOk)
exit_original = nullptr;
hooked.store(true);
// (Spotify left turned down by a game that ended without putting it back.)
app_audio::restore_source(L"Spotify.exe");
return true;
}
void station_song_begins() noexcept { expect_until.store(GetTickCount64() + 1500, std::memory_order_relaxed); }
int mode() noexcept { return which.load(std::memory_order_relaxed); }
void set_mode(int mode) noexcept { which.store(std::clamp(mode, 0, 3), std::memory_order_relaxed); }
float gain() noexcept { return loudness.load(std::memory_order_relaxed); }
void set_gain(float gain) noexcept { loudness.store(std::clamp(gain, 0.0f, 16.0f), std::memory_order_relaxed); }
void tick() noexcept {
if (!hooked.load(std::memory_order_relaxed)) return;
static bool started{};
static std::uint64_t looked{};
static unsigned said{};
const auto now = GetTickCount64();
const bool on = spotify_station::active() && which.load(std::memory_order_relaxed) != 0;
// The sound is the game's own Spotify device's when an account is on it (spotify_device.h),
// and what the Spotify app on this computer plays otherwise.
// (And the picked application's, external_audio.h, when its station is the one that is on.)
// (And YouTube Music's, youtube_music.h, which fills the buffer itself as the device does.)
const bool others = external_audio::on(), tube = youtube_music::on();
const bool device = !others && !tube && spotify_device::connected();
app_audio::set_external(device || tube, tube ? youtube_music::rate() : 44100);
if (on && !device && !tube) {
const auto name = others ? external_audio::app() : std::string("Spotify.exe");
if (!name.empty()) {
started = true;
app_audio::target(std::wstring(name.begin(), name.end()).c_str());
}
}
// Spotify's own output is down only while its sound is in fact going through the game. When
// the game stops asking for the song's sound (the player paused the game's music), Spotify
// is paused, and kept down for the moment that takes; it plays again when the game does.
if (started || device || tube) {
static bool flowing{}, paused{};
const auto since = now - last_replaced.load(std::memory_order_relaxed);
const bool flows = on && since < 700;
if (flows != flowing) {
flowing = flows;
if (!flows && on) {
external_player::send(external_player::Command::pause);
paused = true;
} else if (flows && paused) {
external_player::send(external_player::Command::play);
paused = false;
}
if (!on) paused = false;
}
if (started) app_audio::mute_source(on && !device && !tube && since < 2500);
}
if (now - looked < 1000) return;
looked = now;
// How the capture of an application's sound is going, into the log as it changes (a player
// who hears nothing has this to send): whether it runs, why not, and whether any sound has
// come of it.
if (on && started && !device && !tube) {
static std::string said_capture;
const auto capture = app_audio::state();
const auto name = others ? external_audio::app() : std::string("Spotify.exe");
auto text = std::format("{}: {}{}; {}; its own output {}", name, capture.capturing ? "being captured" : "not being captured",
capture.problem.empty() ? std::string() : " (" + capture.problem + ")", capture.level > 0.0005f ? "sound is coming of it" : "no sound has come of it yet",
capture.muted ? "turned down" : "as it is");
if (text != said_capture) {
said_capture = text;
try {
logging::log(capture.capturing ? logging::Level::info : logging::Level::warning, logging::Channel::music, "Sound through the game: {}.", text);
} catch (...) {}
}
}
// (What the game's sound files are, into the log while this is being worked out.)
for (std::size_t i = 0; i < players.size() && said < 400; ++i) {
auto& player = players[i];
const auto requests = player.requests.load(std::memory_order_relaxed);
if (!player.address.load(std::memory_order_relaxed) || requests == player.said.load(std::memory_order_relaxed) || !player.seconds.load(std::memory_order_relaxed)) continue;
player.said.store(requests, std::memory_order_relaxed);
++said;
try {
char name[sizeof(player.name)];
std::memcpy(name, player.name, sizeof(name));
name[sizeof(name) - 1] = 0;
logging::log(logging::Level::info, logging::Channel::music, "Sound file player {}: \"{}\", {} channel(s) at {} Hz, {} s long{}.", i, name,
player.channels.load(std::memory_order_relaxed), player.rate.load(std::memory_order_relaxed), player.seconds.load(std::memory_order_relaxed),
spotify_station::active() ? " (the Spotify station is on)" : "");
} catch (...) {}
}
}
std::string status() {
if (!hooked.load()) return "Spotify through the game: not available (the game's sound file player was not recognised).";
const auto capture = app_audio::state();
auto text = std::format("Spotify through the game: mode {}, gain {:.2f}; {} block(s) replaced ({} s); capture {}{}, {} reader(s), level {:.3f}, {} frame(s) short; Spotify's own output {}.",
which.load(), loudness.load(), replaced_blocks.load(), replaced_frames.load() / 48000,
capture.capturing ? "running" : "not running", capture.problem.empty() ? std::string() : " (" + capture.problem + ")", capture.readers, capture.level, capture.starved,
capture.muted ? "turned down" : "as it is");
for (std::size_t i = 0; i < players.size(); ++i) {
const auto& player = players[i];
if (!player.address.load() || !player.replaced.load()) continue;
char name[sizeof(player.name)];
std::memcpy(name, player.name, sizeof(name));
name[sizeof(name) - 1] = 0;
text += std::format("\n replaced: player {} \"{}\", {} channel(s), {} s, {} block(s)", i, name, player.channels.load(), player.seconds.load(), player.replaced.load());
}
return text;
}
} // namespace dingosdk::music_inject
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <cstdint>
#include <string>
// Spotify heard through the game: while the Spotify station is selected (spotify_station.h),
// the sound of the song the game is playing for it is replaced, as the game's sound engine
// produces it, by what Spotify is playing (app_audio_capture.h). The game's music volume, its
// fades and its pausing all still apply, as it is still the game's song to the game.
namespace dingosdk::music_inject {
bool install(std::uintptr_t base) noexcept;
// Which of the game's playing sound files is replaced while the station is on:
// 0 none
// 1 the stereo ones a minute long or more (songs), the default: the station's own and the
// speakers' in the world
// 2 every stereo one
// 3 the station's own song only: the speakers keep their music
int mode() noexcept;
// The game is beginning the station's own song (spotify_rows.cpp): the sound file it asks for
// next is the one that is replaced. Any thread.
void station_song_begins() noexcept;
void set_mode(int mode) noexcept;
// How much louder or quieter than Spotify plays it, 1 as it is.
float gain() noexcept;
void set_gain(float gain) noexcept;
// The client thread, each frame: starts the capture when the station is first on, turns
// Spotify's own output down while its sound is going through the game, and writes what the
// game's sound files are into the log.
void tick() noexcept;
std::string status();
}
+159
View File
@@ -0,0 +1,159 @@
#include "spotify_cover.h"
#include "music_artwork.h"
#include "Engine/Core/Log/logging.h"
#include <Windows.h>
#include <shlwapi.h>
#include <wincodec.h>
#include <atomic>
#include <filesystem>
#include <fstream>
#include <format>
#include <mutex>
#include <thread>
#pragma comment(lib, "windowscodecs")
#pragma comment(lib, "shlwapi")
#pragma comment(lib, "ole32")
namespace dingosdk::spotify_cover {
namespace {
std::mutex mutex;
std::string ready;
std::atomic<std::uint64_t> asked{~0ull};
std::atomic<bool> busy{};
template <class T> struct Com {
T* p{};
~Com() { if (p) p->Release(); }
T** operator&() { return &p; }
T* operator->() const { return p; }
};
// Any picture Windows can read, written as a PNG no larger than 512 across.
bool write_png(const std::vector<std::uint8_t>& image, const std::filesystem::path& file) {
Com<IWICImagingFactory> factory;
Com<IStream> input;
Com<IWICBitmapDecoder> decoder;
Com<IWICBitmapFrameDecode> frame;
Com<IWICFormatConverter> converted;
Com<IWICBitmapScaler> scaled;
Com<IWICStream> output;
Com<IWICBitmapEncoder> encoder;
Com<IWICBitmapFrameEncode> target;
if (FAILED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&factory)))) return false;
input.p = SHCreateMemStream(image.data(), static_cast<UINT>(image.size()));
UINT width{}, height{};
if (!input.p || FAILED(factory->CreateDecoderFromStream(input.p, nullptr, WICDecodeMetadataCacheOnLoad, &decoder)) ||
FAILED(decoder->GetFrame(0, &frame)) || FAILED(factory->CreateFormatConverter(&converted)) ||
FAILED(converted->Initialize(frame.p, GUID_WICPixelFormat32bppBGRA, WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom)) ||
FAILED(converted->GetSize(&width, &height)) || !width || !height)
return false;
IWICBitmapSource* source = converted.p;
if (const UINT longest = width > height ? width : height; longest > 512) {
width = width * 512 / longest;
height = height * 512 / longest;
if (!width || !height || FAILED(factory->CreateBitmapScaler(&scaled)) ||
FAILED(scaled->Initialize(converted.p, width, height, WICBitmapInterpolationModeFant)))
return false;
source = scaled.p;
}
WICPixelFormatGUID format = GUID_WICPixelFormat32bppBGRA;
return SUCCEEDED(factory->CreateStream(&output)) && SUCCEEDED(output->InitializeFromFilename(file.c_str(), GENERIC_WRITE)) &&
SUCCEEDED(factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, &encoder)) && SUCCEEDED(encoder->Initialize(output.p, WICBitmapEncoderNoCache)) &&
SUCCEEDED(encoder->CreateNewFrame(&target, nullptr)) && SUCCEEDED(target->Initialize(nullptr)) && SUCCEEDED(target->SetSize(width, height)) &&
SUCCEEDED(target->SetPixelFormat(&format)) && SUCCEEDED(target->WriteSource(source, nullptr)) && SUCCEEDED(target->Commit()) &&
SUCCEEDED(encoder->Commit());
}
void make(std::vector<std::uint8_t> image) noexcept {
const bool initialized = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
try {
// A folder of this run's covers; those of an earlier run are cleared out of it once.
static const auto folder = [] {
auto path = std::filesystem::temp_directory_path() / L"ReSkateSpotify";
std::error_code ignored;
std::filesystem::remove_all(path, ignored);
std::filesystem::create_directories(path, ignored);
return path;
}();
static unsigned number{};
const auto name = std::format("cover{}-{:x}.png", ++number, GetTickCount64());
if (write_png(image, folder / name)) {
if (auto address = profile_runtime::mod_music_artwork_url(folder, name); !address.empty()) {
std::lock_guard lock(mutex);
ready = std::move(address);
} else {
logging::log(logging::Level::warning, logging::Channel::music, "Spotify station: the cover could not be served to the game.");
}
} else {
logging::log(logging::Level::warning, logging::Channel::music, "Spotify station: the cover Windows gave ({} bytes) could not be read as a picture.", image.size());
}
} catch (...) {}
if (initialized) CoUninitialize();
busy.store(false);
}
} // namespace
void want(const std::vector<std::uint8_t>& image, std::uint64_t revision) noexcept {
if (image.empty() || asked.load() == revision || busy.exchange(true)) return;
asked.store(revision);
try {
std::thread(make, image).detach();
} catch (...) {
busy.store(false);
}
}
std::string serve(const std::vector<std::uint8_t>& image, const std::string& name) {
try {
const auto folder = std::filesystem::temp_directory_path() / L"ReSkateSpotifyStation";
std::error_code ignored;
std::filesystem::create_directories(folder, ignored);
if (image.empty() || !write_png(image, folder / name)) return {};
return profile_runtime::mod_music_artwork_url(folder, name);
} catch (...) {
return {};
}
}
namespace {
// A picture built into the DLL (cmake/Runtime.cmake), served under `name`.
std::string built_in(const wchar_t* resource_name, const char* name) {
try {
HMODULE module{};
if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, reinterpret_cast<LPCWSTR>(&station_url), &module))
return {};
const auto resource = FindResourceW(module, resource_name, MAKEINTRESOURCEW(10) /* RT_RCDATA */);
const auto handle = resource ? LoadResource(module, resource) : nullptr;
const auto* data = handle ? static_cast<const char*>(LockResource(handle)) : nullptr;
if (!data) return {};
// (A folder of its own: the covers' folder is cleared as the first of them is made.)
const auto folder = std::filesystem::temp_directory_path() / L"ReSkateSpotifyStation";
std::error_code ignored;
std::filesystem::create_directories(folder, ignored);
{
std::ofstream file(folder / name, std::ios::binary | std::ios::trunc);
file.write(data, SizeofResource(module, resource));
if (!file) return {};
}
return profile_runtime::mod_music_artwork_url(folder, name);
} catch (...) {
return {};
}
}
} // namespace
std::string station_url() {
static const std::string address = built_in(L"SPOTIFY_PNG", "spotify.png");
return address;
}
std::string liked_url() {
static const std::string address = built_in(L"LIKED_PNG", "liked.png");
return address;
}
std::string url() {
std::lock_guard lock(mutex);
return ready;
}
} // namespace dingosdk::spotify_cover
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
// The cover of what Spotify is playing, as something the game's music screen can show: the
// picture Windows gives for it (a JPEG, usually) written out as a PNG and served by the
// artwork server the mods' covers come from (music_artwork.h).
namespace dingosdk::spotify_cover {
// Asks for `image` (the bytes Windows gave) to be made ready, on a thread of its own.
// `revision` tells one picture from the next; asking again for the same one does nothing.
void want(const std::vector<std::uint8_t>& image, std::uint64_t revision) noexcept;
// Where the last picture made ready is served from, or empty when there is none yet. A new
// address for each picture: the game keeps what it has fetched from an address.
std::string url();
// Where the station's own picture (assets/spotify.png, built into the DLL) is served from, for
// its tile among the playlists; empty when the DLL was built without one.
std::string station_url();
// The same for Liked Songs' picture (assets/liked.png).
std::string liked_url();
// Any picture Windows can read, served as a PNG under `name` ("x.png"); the address, or empty.
// On the caller's thread, which must have COM started.
std::string serve(const std::vector<std::uint8_t>& image, const std::string& name);
}
+387
View File
@@ -0,0 +1,387 @@
#include "spotify_dashboard.h"
#include "external_player.h"
#include "external_audio.h"
#include "youtube_music.h"
#include "app_audio_capture.h"
#include "local_music_ui.h"
#include "music_inject.h"
#include "spotify_cover.h"
#include "spotify_device.h"
#include "spotify_station.h"
#include "spotify_web.h"
#include "Engine/Core/Json/json.h"
#include "Engine/Core/Log/logging.h"
#include <WinSock2.h>
#include <WS2tcpip.h>
#include <Windows.h>
#include <shellapi.h>
#include <algorithm>
#include <atomic>
#include <format>
#include <string>
#include <thread>
namespace dingosdk::spotify_dashboard {
namespace {
constexpr unsigned short first_port = 8421;
std::atomic<unsigned short> port{};
std::atomic<bool> started{};
constexpr char page[] = R"html(<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ReSkate · Spotify</title>
<style>
:root{--bg:#101214;--card:#1a1d21;--line:#2a2f36;--text:#e9edf1;--dim:#98a2ad;--green:#1ed760;--warn:#ffb454}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.5 system-ui,Segoe UI,sans-serif}
main{max-width:760px;margin:0 auto;padding:28px 16px 60px}h1{font-size:22px;margin:0 0 4px}p.sub{color:var(--dim);margin:0 0 22px}
section{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:18px;margin:0 0 16px}
h2{font-size:15px;margin:0 0 10px;letter-spacing:.02em}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.grow{flex:1;min-width:0}
.dot{width:10px;height:10px;border-radius:50%;background:#555;flex:none}.dot.on{background:var(--green)}.dot.warn{background:var(--warn)}
.dim{color:var(--dim)}small{color:var(--dim)}
button{background:#2a2f36;color:var(--text);border:1px solid #3a414a;border-radius:8px;padding:8px 14px;font:inherit;cursor:pointer}
button:hover{background:#343a43}button.primary{background:var(--green);border-color:var(--green);color:#04130a;font-weight:600}
input[type=text]{background:#0e1012;color:var(--text);border:1px solid #3a414a;border-radius:8px;padding:8px 10px;font:inherit;width:100%}
input[type=range]{width:100%}select{background:#0e1012;color:var(--text);border:1px solid #3a414a;border-radius:8px;padding:8px 10px;font:inherit;width:100%}
.now{display:flex;gap:14px;align-items:center}.now img{width:72px;height:72px;border-radius:8px;object-fit:cover;background:#0e1012}
.title{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
ol{margin:8px 0 0;padding-left:20px;color:var(--dim)}ul{margin:8px 0 0;padding:0;list-style:none}li.pl{display:flex;justify-content:space-between;padding:6px 0;border-top:1px solid var(--line)}
code{background:#0e1012;border:1px solid var(--line);border-radius:6px;padding:1px 6px}
.note{color:var(--warn)}
</style></head><body><main>
<h1>ReSkate · Spotify</h1><p class="sub">Set up Spotify for the in-game music player. This page is only reachable from this computer.</p>
<section><h2>Now playing</h2>
<div class="now"><img id="cover" alt=""><div class="grow"><div class="title" id="title">Nothing yet</div><div class="dim" id="artist"></div><small id="tile"></small></div></div>
<div class="row" style="margin-top:12px"><button data-cmd="previous">Previous</button><button data-cmd="toggle" id="toggle">Play</button><button data-cmd="next">Next</button></div>
</section>
<section><h2>Spotify device</h2>
<div class="row"><span class="dot" id="deviceDot"></span><div class="grow" id="deviceText">…</div></div>
<ol id="deviceSteps"><li>Open Spotify on your phone or this computer (same network as this PC).</li><li>Open the device list and pick <b>ReSkate</b>.</li><li>That is remembered: next time it connects by itself.</li></ol>
<small>Needs Spotify Premium. Audio comes straight from Spotify into the game.</small>
</section>
<section><h2>Your playlists</h2>
<div class="row"><span class="dot" id="accountDot"></span><div class="grow" id="accountText">…</div><button id="login" class="primary">Sign in</button><button id="logout">Sign out</button><button id="refresh">Refresh</button></div>
<div id="clientBox" style="margin-top:12px"><small>Listing your playlists uses a Spotify developer app. Create one at developer.spotify.com, add the redirect URI <code>http://127.0.0.1:47824/callback</code>, and paste its client ID here.</small>
<div class="row" style="margin-top:8px"><div class="grow"><input type="text" id="client" placeholder="Client ID" autocomplete="off" spellcheck="false"></div><button id="saveClient">Save</button></div></div>
<div class="note" id="problem"></div>
<ul id="playlists"></ul>
<small>New playlists appear as tiles in the game's music screen the next time it is opened. A whole new row may need the game restarted.</small>
</section>
<section><h2>YouTube Music</h2>
<div id="ytSetup"><small>Plays YouTube videos and playlists through the game. It needs <b>yt-dlp</b>, a free tool that is not part of ReSkate: this downloads it (about 18 MB) from its own releases on GitHub into your ReSkate folder. Using it is against YouTube's terms of service; that choice is yours.</small>
<div class="row" style="margin-top:10px"><button id="ytTools" class="primary">Set up YouTube Music</button><small id="ytProgress"></small></div></div>
<div id="ytReady"><small>Paste a link to a YouTube or YouTube Music video or playlist (up to 100 songs each).</small>
<div class="row" style="margin-top:8px"><div class="grow"><input type="text" id="ytLink" placeholder="https://music.youtube.com/playlist?list=…" autocomplete="off" spellcheck="false"></div><button id="ytAdd">Add</button></div>
<ul id="ytLists"></ul>
<div style="margin-top:14px"><b>Private and age-restricted songs</b><br><small>These need your YouTube sign-in as a Netscape <code>cookies.txt</code> exported from your browser. Use an extension like <i>Get cookies.txt LOCALLY</i> on youtube.com to export it, then choose the file here. It is kept on this computer only, in your ReSkate folder; treat it like a password. With a sign-in set, each song takes a few seconds longer to start.</small>
<div class="row" style="margin-top:8px"><input type="file" id="ytCookies" accept=".txt,text/plain"><button id="ytCookiesRemove">Remove cookies.txt</button><small id="ytCookiesText"></small></div><div class="note" id="ytRuntime"></div></div>
<div class="row" style="margin-top:10px"><div style="width:190px">YouTube sign-in from</div><div class="grow"><select id="ytBrowser"><option value="">None</option><option value="firefox">Firefox</option><option value="edge">Edge</option><option value="chrome">Chrome</option><option value="brave">Brave</option><option value="opera">Opera</option><option value="vivaldi">Vivaldi</option></select></div></div>
<small>Or let yt-dlp read the sign-in from a browser directly. Firefox works best; Chrome and Edge often refuse while they are open. A cookies.txt, when you have added one, is used instead.</small><br><small>New playlists appear as tiles in the game's music screen the next time it is opened. A whole new row may need the game restarted.</small></div>
<div class="note" id="ytProblem"></div><small id="ytNone"></small>
</section>
<section><h2>External audio</h2>
<small>Play any app on this computer through the game: pick it here, then choose the <b>External Audio</b> tile in the game's music screen. The app's own sound is turned down while it plays through the game.</small>
<div class="row" style="margin-top:10px"><div class="grow"><select id="app"></select></div><button id="saveApp">Use this app</button></div>
<small id="appText"></small>
</section>
<section><h2>Sound</h2>
<div class="row"><div style="width:130px">Loudness <b id="gainText"></b></div><div class="grow"><input type="range" id="gain" min="0" max="400" step="5"></div></div>
<div class="row" style="margin-top:10px"><label><input type="checkbox" id="speakers"> Speakers in the world play Spotify too</label></div>
</section>
</main>
<script>
const $=id=>document.getElementById(id);let busy=false,gainHeld=0,cookieNote=0;
async function post(path,body){await fetch(path,{method:'POST',headers:{'Content-Type':'application/json','X-ReSkate':'1'},body:JSON.stringify(body||{})});load()}
document.querySelectorAll('[data-cmd]').forEach(b=>b.onclick=()=>post('/api/command',{command:b.dataset.cmd}));
$('login').onclick=()=>post('/api/login');$('logout').onclick=()=>post('/api/logout');$('refresh').onclick=()=>post('/api/refresh');
$('saveClient').onclick=()=>post('/api/client',{id:$('client').value.trim()});
$('gain').oninput=()=>{gainHeld=Date.now();$('gainText').textContent=$('gain').value+'%'};$('gain').onchange=()=>post('/api/settings',{gain:+$('gain').value});
$('ytTools').onclick=()=>post('/api/youtube/tools');
$('ytAdd').onclick=()=>{const link=$('ytLink').value.trim();if(link){$('ytLink').value='';post('/api/youtube/add',{link})}};
$('ytCookies').onchange=async()=>{const f=$('ytCookies').files[0];if(!f)return;const text=await f.text();$('ytCookies').value='';
const r=await fetch('/api/youtube/cookies',{method:'POST',headers:{'Content-Type':'application/json','X-ReSkate':'1'},body:JSON.stringify({text})});
$('ytCookiesText').textContent=r.ok?'Saved.':'That is not a Netscape cookies.txt with YouTube cookies in it.';cookieNote=Date.now();load()};
$('ytCookiesRemove').onclick=()=>post('/api/youtube/cookies',{text:''});
$('ytBrowser').onchange=()=>post('/api/youtube/browser',{browser:$('ytBrowser').value});
$('saveApp').onclick=()=>post('/api/external',{app:$('app').value});
$('speakers').onchange=()=>post('/api/settings',{speakers:$('speakers').checked});
async function load(){if(busy)return;busy=true;try{const s=await (await fetch('/api/state')).json();
$('title').textContent=s.now.title||'Nothing yet';$('artist').textContent=s.now.artist||'';$('toggle').textContent=s.now.playing?'Pause':'Play';
$('tile').textContent=s.station?'A Spotify tile is on in the game':'No Spotify tile is on in the game: pick one in the music screen to hear it';
if(s.now.cover){if($('cover').dataset.src!==s.now.cover){$('cover').dataset.src=s.now.cover;$('cover').src=s.now.cover}}else{$('cover').removeAttribute('src');$('cover').dataset.src=''}
$('deviceDot').className='dot'+(s.device.connected?' on':' warn');$('deviceText').textContent=s.device.text;$('deviceSteps').style.display=s.device.connected?'none':'';
const a=s.account;$('accountDot').className='dot'+(a.signed_in?' on':'');
$('accountText').textContent=a.signed_in?('Signed in'+(a.user?' as '+a.user:'')+(a.busy?' · working…':'')):(a.has_client?'Not signed in':'No client ID yet');
$('login').style.display=a.signed_in||!a.has_client?'none':'';$('logout').style.display=a.signed_in?'':'none';$('refresh').style.display=a.signed_in?'':'none';
$('clientBox').style.display=a.signed_in?'none':'';if(document.activeElement!==$('client')&&!$('client').value)$('client').value=a.client||'';
$('problem').textContent=a.problem||'';
$('playlists').innerHTML='';for(const p of a.playlists){const li=document.createElement('li');li.className='pl';const n=document.createElement('span');n.textContent=p.name;const c=document.createElement('small');c.textContent=p.tracks+' tracks';li.append(n,c);$('playlists').append(li)}
const y=s.youtube;$('ytNone').textContent=y.available?'':'This ReSkate.dll was built without the part that plays it.';
$('ytSetup').style.display=y.available&&!y.tools?'':'none';$('ytReady').style.display=y.available&&y.tools?'':'none';
$('ytProgress').textContent=y.busy&&!y.tools?('Downloading… '+(y.percent?y.percent+'%':'')):'';$('ytTools').disabled=y.busy;$('ytAdd').disabled=y.busy;$('ytAdd').textContent=y.busy?'Working…':'Add';
$('ytProblem').textContent=y.problem||'';$('ytCookiesRemove').style.display=y.cookies?'':'none';$('ytRuntime').textContent=(y.cookies||y.browser)&&!y.runtime?'Getting what the sign-in needs (a small JavaScript runtime)… songs will not play until it is here. If this stays, check your connection or install Node.js (nodejs.org).':'';if(Date.now()-cookieNote>4000)$('ytCookiesText').textContent=y.cookies?'A cookies.txt is in use.':'';if(document.activeElement!==$('ytBrowser'))$('ytBrowser').value=y.browser||'';$('ytLists').innerHTML='';
y.playlists.forEach((p,i)=>{const li=document.createElement('li');li.className='pl';const n=document.createElement('span');n.textContent=p.name+' · '+p.songs+' songs';const b=document.createElement('button');b.textContent='Remove';b.onclick=()=>post('/api/youtube/remove',{index:i});li.append(n,b);$('ytLists').append(li)});
if(document.activeElement!==$('app')){const e=s.external,names=e.apps.includes(e.app)||!e.app?e.apps:[e.app,...e.apps];$('app').innerHTML='';
for(const n of names){const o=document.createElement('option');o.value=n;o.textContent=n+(e.apps.includes(n)?'':' (not playing sound now)');$('app').append(o)}if(e.app)$('app').value=e.app}
$('appText').textContent=s.external.app?('Using '+s.external.app+(s.external.on?' · on now · '+s.external.capture:'')):'No app picked yet. Only apps that are making sound right now are listed: start it playing first.';
if(Date.now()-gainHeld>1500){$('gain').value=s.settings.gain;$('gainText').textContent=s.settings.gain+'%'}$('speakers').checked=s.settings.speakers;
}catch(e){$('deviceText').textContent='The game is not answering (is it still running?)'}busy=false}
load();setInterval(load,2000);
</script></body></html>)html";
std::string state_json() {
Json out = Json::object();
const auto track = external_player::now();
Json now = Json::object();
now["artist"] = track.available ? track.artist : std::string();
now["title"] = track.available ? track.title : std::string();
now["playing"] = track.available && track.playing;
now["cover"] = track.available ? spotify_cover::url() : std::string();
out["now"] = std::move(now);
out["station"] = spotify_station::active();
Json device = Json::object();
device["connected"] = spotify_device::connected();
device["text"] = spotify_device::status();
out["device"] = std::move(device);
const auto account_state = spotify_web::state();
Json account = Json::object();
account["has_client"] = account_state.has_client;
account["signed_in"] = account_state.signed_in;
account["busy"] = account_state.busy;
account["user"] = account_state.user;
account["problem"] = account_state.problem;
account["client"] = spotify_web::client();
Json playlists = Json::array();
for (const auto& playlist : spotify_web::playlists()) {
Json entry = Json::object();
entry["name"] = playlist.name;
entry["tracks"] = static_cast<std::uint64_t>(playlist.tracks.size());
playlists.push_back(std::move(entry));
}
account["playlists"] = std::move(playlists);
out["account"] = std::move(account);
{
const auto tube = youtube_music::state();
Json youtube = Json::object();
youtube["available"] = tube.available;
youtube["tools"] = tube.tools;
youtube["busy"] = tube.busy;
youtube["percent"] = static_cast<std::uint64_t>(tube.percent);
youtube["problem"] = tube.problem;
youtube["browser"] = tube.browser;
youtube["cookies"] = tube.cookies;
youtube["runtime"] = tube.runtime;
Json lists = Json::array();
for (const auto& playlist : tube.playlists) {
Json entry = Json::object();
entry["name"] = playlist.name;
entry["songs"] = static_cast<std::uint64_t>(playlist.songs.size());
lists.push_back(std::move(entry));
}
youtube["playlists"] = std::move(lists);
out["youtube"] = std::move(youtube);
}
Json external = Json::object();
external["app"] = external_audio::app();
external["on"] = external_audio::on();
{
// (How its capture is going, for the player who hears nothing.)
const auto capture = app_audio::state();
external["capture"] = !external_audio::on() ? std::string()
: !capture.capturing ? "Not capturing it" + (capture.problem.empty() ? std::string() : ": " + capture.problem)
: capture.level > 0.0005f ? std::string("Capturing it: sound is coming through") : std::string("Capturing it, but it is silent: is it playing, and not muted in Windows' volume mixer?");
}
Json apps = Json::array();
for (const auto& name : external_audio::apps()) apps.push_back(name);
external["apps"] = std::move(apps);
out["external"] = std::move(external);
Json settings = Json::object();
settings["gain"] = static_cast<std::uint64_t>(music_inject::gain() * 100.0f + 0.5f);
settings["speakers"] = music_inject::mode() != 3;
out["settings"] = std::move(settings);
return out.dump();
}
// What a change asks for, done. False when it is not one of them.
bool change(const std::string& path, const std::string& body) {
Json json = Json::object();
try {
if (!body.empty()) json = Json::parse(body);
} catch (...) {
return false;
}
if (!json.is_object()) return false;
if (path == "/api/command") {
const auto command = json.value("command", "");
using external_player::Command;
if (command == "play") external_player::send(Command::play);
else if (command == "pause") external_player::send(Command::pause);
else if (command == "toggle") external_player::send(Command::toggle);
else if (command == "next") external_player::send(Command::next);
else if (command == "previous") external_player::send(Command::previous);
else return false;
} else if (path == "/api/client") {
const auto id = json.value("id", "");
if (id.empty() || id.size() > 64 || !std::all_of(id.begin(), id.end(), [](unsigned char c) { return std::isalnum(c) != 0; })) return false;
spotify_web::set_client(id);
} else if (path == "/api/login") {
spotify_web::login();
} else if (path == "/api/logout") {
spotify_web::logout();
} else if (path == "/api/refresh") {
spotify_web::refresh();
} else if (path == "/api/youtube/tools") {
youtube_music::fetch_tools();
} else if (path == "/api/youtube/add") {
youtube_music::add(json.value("link", ""));
} else if (path == "/api/youtube/cookies") {
if (!youtube_music::set_cookies(json.value("text", ""))) return false;
} else if (path == "/api/youtube/browser") {
youtube_music::set_browser(json.value("browser", ""));
} else if (path == "/api/youtube/remove") {
if (!json.contains("index") || !json.at("index").is_number()) return false;
youtube_music::remove(static_cast<std::size_t>(std::clamp<std::int64_t>(json.at("index").get<std::int64_t>(), 0, 1000)));
} else if (path == "/api/external") {
external_audio::set_app(json.value("app", ""));
profile_runtime::request_music_catalog_refresh();
if (external_audio::on()) external_player::use_app(external_audio::app());
} else if (path == "/api/settings") {
if (json.contains("gain") && json.at("gain").is_number()) music_inject::set_gain(static_cast<float>(std::clamp<std::int64_t>(json.at("gain").get<std::int64_t>(), 0, 400)) / 100.0f);
if (json.contains("speakers") && json.at("speakers").is_boolean()) music_inject::set_mode(json.at("speakers").get<bool>() ? 1 : 3);
} else {
return false;
}
return true;
}
void answer(SOCKET client, const char* status, const char* type, const std::string& body) {
const auto head = std::format("HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n", status, type,
body.size());
send(client, head.data(), static_cast<int>(head.size()), 0);
for (std::size_t sent = 0; sent < body.size();) {
const int did = send(client, body.data() + sent, static_cast<int>(std::min<std::size_t>(body.size() - sent, 65536)), 0);
if (did <= 0) break;
sent += static_cast<std::size_t>(did);
}
}
std::string header(const std::string& request, const std::string& name) {
// (Names are matched as browsers write them here.)
const auto at = request.find("\r\n" + name + ": ");
if (at == std::string::npos) return {};
const auto from = at + name.size() + 4;
return request.substr(from, request.find("\r\n", from) - from);
}
void serve(SOCKET client) {
DWORD timeout = 3000;
setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast<const char*>(&timeout), sizeof(timeout));
std::string request;
char buffer[4096];
std::size_t head_end = std::string::npos;
while (head_end == std::string::npos && request.size() < 16384) {
const int got = recv(client, buffer, sizeof(buffer), 0);
if (got <= 0) return;
request.append(buffer, static_cast<std::size_t>(got));
head_end = request.find("\r\n\r\n");
}
if (head_end == std::string::npos) return;
std::size_t length{};
try {
if (const auto text = header(request, "Content-Length"); !text.empty()) length = std::stoul(text);
} catch (...) {
return;
}
if (length > 2 * 1024 * 1024) return answer(client, "413 Payload Too Large", "text/plain", "too large");
while (request.size() < head_end + 4 + length) {
const int got = recv(client, buffer, sizeof(buffer), 0);
if (got <= 0) return;
request.append(buffer, static_cast<std::size_t>(got));
}
const auto line = request.substr(0, request.find("\r\n"));
const auto method = line.substr(0, line.find(' '));
const auto path_from = method.size() + 1;
const auto path = line.substr(path_from, line.find(' ', path_from) - path_from);
// Only as itself: a request that names another host (a site made to resolve to this
// computer) is not answered.
const auto host = header(request, "Host");
const auto own = std::to_string(port.load());
if (host != "127.0.0.1:" + own && host != "localhost:" + own) return answer(client, "403 Forbidden", "text/plain", "not for this address");
if (method == "GET" && (path == "/" || path == "/index.html")) return answer(client, "200 OK", "text/html; charset=utf-8", page);
if (method == "GET" && path == "/api/state") return answer(client, "200 OK", "application/json", state_json());
if (method == "POST") {
// A change comes from this page alone: its script sends a header that a page of another
// site cannot send without the browser asking first, which is refused by not answering.
if (header(request, "X-ReSkate") != "1") return answer(client, "403 Forbidden", "text/plain", "not from the page");
return change(path, request.substr(head_end + 4, length)) ? answer(client, "200 OK", "application/json", "{}") : answer(client, "400 Bad Request", "text/plain", "not understood");
}
answer(client, "404 Not Found", "text/plain", "nothing here");
}
void run() noexcept {
WSADATA data{};
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) return;
SOCKET listener = INVALID_SOCKET;
for (unsigned short candidate = first_port; candidate < first_port + 10 && listener == INVALID_SOCKET; ++candidate) {
const SOCKET made = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
sockaddr_in at{};
at.sin_family = AF_INET;
at.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
at.sin_port = htons(candidate);
if (made != INVALID_SOCKET && bind(made, reinterpret_cast<const sockaddr*>(&at), sizeof(at)) == 0 && listen(made, 8) == 0) {
listener = made;
port.store(candidate);
} else if (made != INVALID_SOCKET) {
closesocket(made);
}
}
if (listener == INVALID_SOCKET) {
try {
logging::log(logging::Level::warning, logging::Channel::music, "Spotify setup page: no port on this computer was free for it.");
} catch (...) {}
return;
}
try {
logging::log(logging::Level::info, logging::Channel::music, "Spotify setup page: {} (\"spotify setup\" in the console opens it).", address());
} catch (...) {}
for (;;) {
const SOCKET client = accept(listener, nullptr, nullptr);
if (client == INVALID_SOCKET) {
Sleep(50);
continue;
}
try {
serve(client);
} catch (...) {}
shutdown(client, SD_SEND);
closesocket(client);
}
}
} // namespace
void start() noexcept {
if (started.exchange(true)) return;
try {
std::thread(run).detach();
} catch (...) {}
}
std::string address() {
const auto at = port.load();
return at ? "http://127.0.0.1:" + std::to_string(at) + "/" : std::string();
}
bool open() noexcept {
try {
const auto where = address();
if (where.empty()) return false;
const std::wstring wide(where.begin(), where.end());
return reinterpret_cast<INT_PTR>(ShellExecuteW(nullptr, L"open", wide.c_str(), nullptr, nullptr, SW_SHOWNORMAL)) > 32;
} catch (...) {
return false;
}
}
} // namespace dingosdk::spotify_dashboard
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <string>
// A page in the player's browser for setting Spotify up, served by the game on this computer
// alone (http://127.0.0.1:8421): the game's Spotify device and whether an account is on it,
// the sign-in that lists the player's playlists (its client id, signing in and out), what is
// playing with play, pause and next, and how loud it is through the game. "spotify setup" in
// the console opens it.
//
// It listens on the loopback address only, answers only requests addressed to it by that
// address, and takes changes only from its own page (a header a page of another site cannot
// send without being refused first).
namespace dingosdk::spotify_dashboard {
// Starts serving, once, on a thread of its own.
void start() noexcept;
// The page's address, or empty when it is not being served.
std::string address();
// Opens it in the player's browser.
bool open() noexcept;
}
+245
View File
@@ -0,0 +1,245 @@
#include "spotify_device.h"
#include "app_audio_capture.h"
#include "local_music_ui.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Vfs/https_download.h"
#include "Extension/Boot/user_data_redirect.h"
#include <Windows.h>
#include <atomic>
#include <filesystem>
#include <format>
#include <mutex>
#include <sstream>
#include <thread>
#include <vector>
#if DINGOSDK_SPOTIFY_DEVICE
// SpotifyHelper/src/lib.rs, linked into the DLL (cmake/Runtime.cmake).
extern "C" {
int reskate_spotify_start(const char* name, const char* cache, void (*sound)(const float* samples, std::uint32_t frames), void (*told)(const char* line));
void reskate_spotify_ask(const char* line);
}
#endif
namespace dingosdk::spotify_device {
namespace {
constexpr unsigned device_rate = 44100;
std::atomic<bool> started{}, running{}, ready{}, account{};
std::atomic<std::uint64_t> changes{1};
std::mutex mutex; // the track
external_player::Track track;
std::string user_name, gave_up_why;
std::atomic<bool> not_premium{};
void say(logging::Level level, const std::string& text) noexcept {
try {
logging::log(level, logging::Channel::music, "Spotify device: {}", text);
} catch (...) {}
}
std::wstring wide(const std::string& text) {
if (text.empty()) return {};
std::wstring out(static_cast<std::size_t>(MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), nullptr, 0)), L'\0');
MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), out.data(), static_cast<int>(out.size()));
return out;
}
std::string narrow(const std::wstring& text) {
if (text.empty()) return {};
std::string out(static_cast<std::size_t>(WideCharToMultiByte(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), nullptr, 0, nullptr, nullptr)), '\0');
WideCharToMultiByte(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), out.data(), static_cast<int>(out.size()), nullptr, nullptr);
return out;
}
void ask(const std::string& line) noexcept {
#if DINGOSDK_SPOTIFY_DEVICE
if (running.load(std::memory_order_relaxed)) reskate_spotify_ask(line.c_str());
#else
(void)line;
#endif
}
// A song's cover, fetched from where Spotify keeps it, for the music screen.
void fetch_cover(std::string address, std::uint64_t of) {
try {
if (!address.starts_with("https://")) return;
const auto image = https::get_text(wide(address), 4 * 1024 * 1024, 10, L"ReSkate");
if (!image) return;
std::lock_guard lock(mutex);
if (track.revision != of) return; // (the song has changed since)
track.artwork.assign(image->begin(), image->end());
++track.artwork_revision;
changes.fetch_add(1, std::memory_order_relaxed);
} catch (...) {}
}
// A line the device told the game (SpotifyHelper/src/lib.rs), on a thread of the device's.
void told(const char* text) {
try {
std::vector<std::string> parts;
std::stringstream stream{std::string(text)};
for (std::string part; std::getline(stream, part, '\t');) parts.push_back(part);
if (parts.empty()) return;
if (parts[0] == "LOG") {
static std::atomic<int> logged{};
if (parts.size() > 1 && logged.fetch_add(1, std::memory_order_relaxed) < 300) say(logging::Level::info, "(librespot) " + parts[1]);
// (What it says just before it gives up on an account that is not Premium.)
if (parts.size() > 1 && parts[1].find("does not support") != std::string::npos && parts[1].find("accounts") != std::string::npos) not_premium.store(true);
} else if (parts[0] == "READY") {
ready.store(true);
say(logging::Level::info, "\"ReSkate\" is among Spotify's devices on this network.");
} else if (parts[0] == "CONNECTED") {
{
std::lock_guard lock(mutex);
user_name = parts.size() > 1 ? parts[1] : std::string();
}
if (!account.exchange(true)) {
say(logging::Level::info, "a Spotify account is on it.");
profile_runtime::request_music_catalog_refresh(); // (its tiles are in the music screen from now)
}
changes.fetch_add(1, std::memory_order_relaxed);
} else if (parts[0] == "DISCONNECTED") {
if (account.exchange(false)) say(logging::Level::info, "no Spotify account is on it.");
changes.fetch_add(1, std::memory_order_relaxed);
} else if (parts[0] == "ENDED") {
running.store(false);
account.store(false);
say(logging::Level::warning, "it has stopped.");
} else if (parts[0] == "TRACK" && parts.size() >= 4) {
std::uint64_t of{};
{
std::lock_guard lock(mutex);
track.available = true;
track.spotify = true;
track.app = "ReSkate device";
track.can_next = track.can_previous = track.can_pause = track.can_play = true;
track.artist = parts[2];
track.title = parts[3];
track.album.clear();
track.artwork.clear();
of = ++track.revision;
}
changes.fetch_add(1, std::memory_order_relaxed);
if (parts.size() > 4 && !parts[4].empty()) std::thread(fetch_cover, parts[4], of).detach();
} else if (parts[0] == "STATE" && parts.size() >= 2) {
std::lock_guard lock(mutex);
track.playing = parts[1] == "playing";
changes.fetch_add(1, std::memory_order_relaxed);
}
} catch (...) {}
}
// The sound, on the device's own thread. It is taken as fast as the game hears it and no faster
// (a fraction of a second is kept ahead): this returning when there is room for more is what
// keeps Spotify at the speed of the game.
void sound(const float* samples, std::uint32_t frames) {
// (For half a second at the most: when the game is not listening at all, the sound is let go
// by, so the device can still be told to pause.)
for (int waited = 0; app_audio::ahead_frames() > device_rate * 15 / 100; ++waited) {
if (waited >= 125) return;
Sleep(4);
}
app_audio::push(samples, frames);
}
} // namespace
void start() noexcept {
if (started.exchange(true)) return;
#if DINGOSDK_SPOTIFY_DEVICE
try {
const auto cache = redirected_local_app_data().parent_path() / L"spotify-device";
std::error_code ignored;
std::filesystem::create_directories(cache, ignored);
running.store(true);
if (!reskate_spotify_start("ReSkate", narrow(cache.wstring()).c_str(), &sound, &told)) {
running.store(false);
say(logging::Level::warning, "it could not be started.");
}
} catch (...) {
running.store(false);
}
#else
say(logging::Level::info, "not built into this ReSkate.dll (it needs Rust's cargo when ReSkate is built): Spotify is played through its own app.");
#endif
}
void library_gave_up() noexcept {
running.store(false);
account.store(false);
try {
const bool premium = !not_premium.load();
{
std::lock_guard lock(mutex);
gave_up_why = premium ? "it stopped on a fault of its own; it is back the next time the game is started"
: "that Spotify account is not Premium, which Spotify asks for to play to a device; the account was forgotten";
}
if (!premium) {
// (Kept, it would be tried again, and refused again, at every start.)
std::error_code ignored;
std::filesystem::remove(redirected_local_app_data().parent_path() / L"spotify-device" / L"credentials.json", ignored);
}
say(logging::Level::warning, premium ? "the library asked for the game to be ended; it was stopped instead, and the device is off for this run."
: "that Spotify account is not Premium: the device is off, and the account was forgotten. The game goes on.");
} catch (...) {}
}
bool in_use() noexcept {
if (!running.load(std::memory_order_relaxed) || not_premium.load(std::memory_order_relaxed)) return false;
if (account.load(std::memory_order_relaxed)) return true;
try {
std::error_code ignored;
return std::filesystem::exists(redirected_local_app_data().parent_path() / L"spotify-device" / L"credentials.json", ignored);
} catch (...) {
return false;
}
}
bool connected() noexcept { return running.load(std::memory_order_relaxed) && account.load(std::memory_order_relaxed); }
external_player::Track now() {
std::lock_guard lock(mutex);
return track;
}
std::uint64_t revision() noexcept { return changes.load(std::memory_order_relaxed); }
void send(external_player::Command command) noexcept {
switch (command) {
case external_player::Command::play: ask("play"); break;
case external_player::Command::pause: ask("pause"); break;
case external_player::Command::next: ask("next"); break;
case external_player::Command::previous: ask("prev"); break;
case external_player::Command::toggle: {
bool playing{};
{
std::lock_guard lock(mutex);
playing = track.playing;
}
ask(playing ? "pause" : "play");
break;
}
}
}
void activate() noexcept { ask("activate"); }
void start_radio() noexcept {
try {
std::thread([] {
ask("activate");
ask("play");
// (A moment for what Spotify was playing to come over; with nothing to come over,
// there is nothing playing after it.)
Sleep(2000);
std::string user;
bool playing{};
{
std::lock_guard lock(mutex);
user = user_name;
playing = track.playing;
}
if (!playing && !user.empty()) {
say(logging::Level::info, "nothing was playing to go on with: Liked Songs, in no order.");
ask("shuffle spotify:user:" + user + ":collection");
}
}).detach();
} catch (...) {}
}
void load(const std::string& context, const std::string& track_uri) noexcept { ask("load " + context + (track_uri.empty() ? std::string() : " " + track_uri)); }
void load_tracks(const std::string& tracks_spaced, unsigned index) noexcept { ask("tracks " + std::to_string(index) + " " + tracks_spaced); }
std::string status() {
std::lock_guard lock(mutex);
if (!gave_up_why.empty()) return "Spotify device: off (" + gave_up_why + ").";
return std::format("Spotify device: {}{}.", !running.load() ? "not running (Spotify is played through its own app)" : !ready.load() ? "starting" : account.load() ? "a Spotify account is on it" :
"in Spotify's list of devices; pick \"ReSkate\" there once to use it", account.load() && !user_name.empty() ? " (" + user_name + ")" : std::string());
}
} // namespace dingosdk::spotify_device
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include "external_player.h"
#include <string>
// The game as a device in the player's Spotify app ("ReSkate"), by way of ReSkateSpotify.exe
// (SpotifyHelper/, built on librespot), which is started beside ReSkate.dll. Spotify plays to
// it as it would to a speaker: its sound comes to the game as it is (app_audio_capture.h's
// buffer is filled with it, in the place of what is captured from the Spotify app), with the
// song's name and cover, and the game's play, pause and next go to it.
//
// Who the account is comes from the Spotify app: the player picks "ReSkate" among its devices
// once, on the same network, and it is kept. Until an account is on it, the Spotify station
// goes on working through the Spotify app on this computer (external_player.h).
namespace dingosdk::spotify_device {
// Starts the helper, once; nothing when it is not beside the DLL.
void start() noexcept;
// Whether a Spotify account is on the device: it is then what the station plays and shows.
bool connected() noexcept;
// Whether it has an account or one kept to sign in with: the player uses Spotify here.
bool in_use() noexcept;
// What it is playing, as external_player gives it.
external_player::Track now();
std::uint64_t revision() noexcept;
void send(external_player::Command command) noexcept;
// Has it play here, taking over from wherever Spotify is playing.
void activate() noexcept;
// The plain Spotify station: whatever Spotify was playing goes on here; when it was playing
// nothing, the player's Liked Songs are played, in no order.
void start_radio() noexcept;
// A playlist ("spotify:playlist:..."), at one of its tracks when given; or these tracks, at one.
void load(const std::string& context, const std::string& track = {}) noexcept;
void load_tracks(const std::string& tracks_spaced, unsigned index) noexcept;
// The library asked for the whole program to be ended (music_inject.cpp stops that): the device
// is stopped for this run, and an account it will not take is forgotten.
void library_gave_up() noexcept;
// For the console.
std::string status();
}
+311
View File
@@ -0,0 +1,311 @@
#include "spotify_rows.h"
#include "spotify_station.h"
#include "music_inject.h"
#include "Engine/Core/Hooks/hooks.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Core/Platform/memory.h"
#include "Engine/Game/Build/20260929/local_music.h"
#include "Engine/Game/Build/addresses.h"
#include <Windows.h>
#include <array>
#include <atomic>
#include <cstring>
#include <format>
#include <map>
#include <memory>
#include <mutex>
#include <random>
namespace dingosdk::spotify_rows {
namespace {
namespace lm = addr::local_music;
// An asset, as much of it as is copied: the 16 bytes it is known by, then the asset.
constexpr std::size_t header = 0x10, copied = 0x200;
constexpr std::size_t asset_flags = 0x14, graph_metadata = 0x20, graph_tags = 0x28, metadata_artist = 0x18, metadata_title = 0x20;
constexpr std::uint16_t has_identity = 0x100, never_freed = 0x2000;
struct Row {
std::unique_ptr<unsigned char[]> graph, metadata;
std::string artist, title;
};
std::mutex mutex;
std::map<std::string, Row, std::less<>> rows; // by id; never freed: the game holds them
std::string said_problem;
bool matches(std::uintptr_t base, const game::build::Fingerprint& contract) noexcept {
std::array<unsigned char, 32> bytes{};
return memory::peek(base + contract.rva, bytes) && bytes == contract.bytes;
}
void problem(const std::string& text) {
if (said_problem == text) return;
said_problem = text;
try {
logging::log(logging::Level::warning, logging::Channel::music, "Spotify rows: {}.", text);
} catch (...) {}
}
bool is_type(std::uintptr_t base, std::uintptr_t asset, std::uintptr_t table) noexcept {
std::uintptr_t held{};
return asset && memory::peek(asset, held) && held == base + table;
}
// Any registered song's asset, to copy.
std::uintptr_t a_song(std::uintptr_t base, std::uintptr_t manager) noexcept {
std::uintptr_t buckets{}, sentinel{};
std::uint32_t bucket_count{};
if (!memory::peek(manager + 0x78, buckets) || !buckets || !memory::peek(manager + 0x80, bucket_count) || !bucket_count || bucket_count > 16384 ||
!memory::peek(buckets + bucket_count * 8ull, sentinel))
return 0;
for (std::uint32_t bucket = 0; bucket < bucket_count; ++bucket) {
std::uintptr_t node{}, graph{}, metadata{};
std::uint16_t flags{};
if (!memory::peek(buckets + bucket * 8ull, node) || !node || node == sentinel || !memory::peek(node + 8, graph)) continue;
graph &= ~std::uintptr_t{4};
if (!is_type(base, graph, lm::graph_asset_vtable) || !memory::peek(graph + graph_metadata, metadata) || !memory::peek(graph + asset_flags, flags) || !(flags & has_identity)) continue;
metadata &= ~std::uintptr_t{4};
if (is_type(base, metadata, lm::metadata_vtable)) return graph;
}
return 0;
}
// A copy of an asset with its 16 bytes before it; `at` is where the asset begins in it.
std::unique_ptr<unsigned char[]> copy_of(std::uintptr_t asset, bool with_identity) {
auto copy = std::make_unique<unsigned char[]>(header + copied);
std::memset(copy.get(), 0, header + copied);
// (As much as can be read: an asset near the end of its memory is shorter than this.)
for (std::size_t size = copied; size >= 0x40; size /= 2)
if (memory::peek_bytes(asset, copy.get() + header, size)) {
if (with_identity) memory::peek_bytes(asset - header, copy.get(), header);
return copy;
}
return nullptr;
}
bool registered(std::uintptr_t base, std::uintptr_t manager, const std::string& id) noexcept {
const char* text = id.c_str();
__try {
return reinterpret_cast<std::uintptr_t (*)(std::uintptr_t, const char**)>(base + lm::song_lookup_contract.rva)(manager, &text) != 0;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return false;
}
}
bool register_song(std::uintptr_t base, std::uintptr_t manager, std::uintptr_t graph) noexcept {
__try {
reinterpret_cast<void (*)(std::uintptr_t, std::uintptr_t)>(base + lm::song_register_contract.rva)(manager, graph);
return true;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return false;
}
}
// The game's routine that begins to play a song's asset.
using PlaySong = std::uintptr_t (*)(std::uintptr_t, std::uintptr_t, std::uint32_t, std::uintptr_t, std::uintptr_t, std::uintptr_t, std::uintptr_t);
PlaySong play_song_original{};
std::uintptr_t play_song_hook(std::uintptr_t player, std::uintptr_t asset, std::uint32_t channel, std::uintptr_t a, std::uintptr_t b, std::uintptr_t c, std::uintptr_t d) {
try {
const auto graph = asset & ~std::uintptr_t{4};
bool ours{};
{
std::lock_guard lock(mutex);
for (const auto& [id, row] : rows)
if (reinterpret_cast<std::uintptr_t>(row.graph.get() + header) == graph) {
spotify_station::row_started(row.graph.get());
ours = true;
break;
}
}
// (Each song the game begins while a station is on, into the log while this is worked out.)
if (static std::atomic<int> said{}; said.fetch_add(1, std::memory_order_relaxed) < 600)
{
// (Which song, and from where in the game it was begun.)
std::uintptr_t metadata{}, artist{}, title{};
char artist_text[96]{}, title_text[96]{};
if (graph && memory::peek(graph + graph_metadata, metadata) && (metadata &= ~std::uintptr_t{4}) != 0 && memory::peek(metadata + metadata_artist, artist) &&
memory::peek(metadata + metadata_title, title)) {
memory::peek_cstring(artist, artist_text, sizeof(artist_text));
memory::peek_cstring(title, title_text, sizeof(title_text));
}
void* frames[6]{};
const auto count = CaptureStackBackTrace(1, 6, frames, nullptr);
const auto image = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
std::string callers;
for (unsigned i = 0; i < count; ++i) callers += std::format(" +0x{:x}", reinterpret_cast<std::uintptr_t>(frames[i]) - image);
logging::log(logging::Level::info, logging::Channel::music, "Spotify rows: the game began {} (\"{} - {}\") on channel {}, from{}.", ours ? "a row of ours" : "a song of its own",
artist_text, title_text, channel, callers);
}
// (A row of ours keeps its own artist and title: they are what its row says.)
// The station's own song beginning: its sound file is known for it ("spotify through 3").
if (!ours && spotify_station::active() && channel == spotify_station::channel()) music_inject::station_song_begins();
// Any song of the game's begun while a station is on has Spotify's sound (a speaker's in
// the world), so it reads as Spotify's. (A row of ours keeps what its row says.)
if (std::uintptr_t metadata{}; !ours && graph && memory::peek(graph + graph_metadata, metadata) && (metadata &= ~std::uintptr_t{4}) != 0) {
spotify_station::song_begun(channel, metadata);
spotify_station::song_started(metadata);
}
} catch (...) {}
return play_song_original(player, asset, channel, a, b, c, d);
}
// (What the music screen does with the game's playlists, into the log while it is being
// worked out how a picked row is told to the game: each song put in or taken out of one, and
// each one emptied or made. A song that is a row of ours is named.)
std::atomic<int> traced{};
std::string song_text(const unsigned char* song) {
if (!song) return "nothing";
std::lock_guard lock(mutex);
for (const auto& [id, row] : rows)
if (std::memcmp(row.graph.get(), song, header) == 0) return "\"" + id + "\"";
return "a song of the game's";
}
void trace(const char* what, std::uint32_t playlist, const unsigned char* song) noexcept {
// (No more than thirty a second, so that the many of the music screen being built do not
// use up what is kept for a row being picked.)
static std::atomic<std::uint64_t> second{};
static std::atomic<int> in_second{};
if (const auto now = GetTickCount64() / 1000; second.exchange(now, std::memory_order_relaxed) != now) in_second.store(0, std::memory_order_relaxed);
if (in_second.fetch_add(1, std::memory_order_relaxed) >= 30 || traced.fetch_add(1, std::memory_order_relaxed) >= 1500) return;
try {
logging::log(logging::Level::info, logging::Channel::music, "Playlist {}: {}{}.", playlist, what, song ? " " + song_text(song) : std::string());
} catch (...) {}
}
using PlaylistSong = void* (*)(std::uintptr_t, std::uint32_t*, const unsigned char*);
using PlaylistOnly = void* (*)(std::uintptr_t, std::uint32_t*);
PlaylistSong add_original{}, remove_original{};
PlaylistOnly clear_original{}, create_original{};
void* add_hook(std::uintptr_t manager, std::uint32_t* playlist, const unsigned char* song) {
if (playlist) trace("put in", *playlist, song);
return add_original(manager, playlist, song);
}
void* remove_hook(std::uintptr_t manager, std::uint32_t* playlist, const unsigned char* song) {
if (playlist) trace("taken out", *playlist, song);
return remove_original(manager, playlist, song);
}
void* clear_hook(std::uintptr_t manager, std::uint32_t* playlist) {
if (playlist) trace("emptied", *playlist, nullptr);
return clear_original(manager, playlist);
}
void* create_hook(std::uintptr_t manager, std::uint32_t* playlist) {
const auto result = create_original(manager, playlist);
if (playlist) trace("made", *playlist, nullptr);
return result;
}
// The game being asked for a song the player picked. A row of ours is no song the queue can
// play, so the game does nothing with it: the station is told, and starts Spotify at it.
using RequestSong = void (*)(std::uintptr_t, std::uintptr_t);
RequestSong request_original{};
void request_hook(std::uintptr_t queue, std::uintptr_t asset) {
try {
const auto graph = asset & ~std::uintptr_t{4};
bool ours{};
{
std::lock_guard lock(mutex);
for (const auto& [id, row] : rows)
if (reinterpret_cast<std::uintptr_t>(row.graph.get() + header) == graph) {
spotify_station::row_started(row.graph.get());
ours = true;
break;
}
}
// (Each song asked for, into the log while grabbing a speaker's music is worked out.)
if (static std::atomic<int> said{}; spotify_station::recent() && said.fetch_add(1, std::memory_order_relaxed) < 600) {
std::uintptr_t metadata{}, artist{}, title{};
char artist_text[96]{}, title_text[96]{};
if (graph && memory::peek(graph + graph_metadata, metadata) && (metadata &= ~std::uintptr_t{4}) != 0 && memory::peek(metadata + metadata_artist, artist) &&
memory::peek(metadata + metadata_title, title)) {
memory::peek_cstring(artist, artist_text, sizeof(artist_text));
memory::peek_cstring(title, title_text, sizeof(title_text));
}
void* frames[6]{};
const auto count = CaptureStackBackTrace(1, 6, frames, nullptr);
const auto image = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
std::string callers;
for (unsigned i = 0; i < count; ++i) callers += std::format(" +0x{:x}", reinterpret_cast<std::uintptr_t>(frames[i]) - image);
logging::log(logging::Level::info, logging::Channel::music, "Trace: queue 0x{:x} was asked for {} (\"{} - {}\"), from{}.", queue, ours ? "a row of ours" : "a song of the game's", artist_text, title_text, callers);
}
} catch (...) {}
request_original(queue, asset);
}
template <class T> void watch(std::uintptr_t base, const game::build::Fingerprint& contract, T hook, T& original) noexcept {
if (!matches(base, contract)) return;
const auto target = reinterpret_cast<void*>(base + contract.rva);
if (hook_prepare(target, reinterpret_cast<void*>(hook), reinterpret_cast<void**>(&original)) != HookOk || hook_enable(target) != HookOk) original = nullptr;
}
} // namespace
std::string id_of(const unsigned char* row) {
std::lock_guard lock(mutex);
for (const auto& [id, made] : rows)
if (std::memcmp(made.graph.get(), row, header) == 0) return id;
return {};
}
bool install(std::uintptr_t base) noexcept {
static bool done{};
if (done || !base || !matches(base, lm::song_play_contract)) return false;
done = true;
// (The game's playlist routines are not watched: with them watched the song added to a
// station's playlist did not stay in it, 2026-10-11. Only removals are, which change nothing.)
watch(base, lm::song_request_contract, &request_hook, request_original);
const auto target = reinterpret_cast<void*>(base + lm::song_play_contract.rva);
if (hook_prepare(target, reinterpret_cast<void*>(&play_song_hook), reinterpret_cast<void**>(&play_song_original)) != HookOk || hook_enable(target) != HookOk) {
play_song_original = nullptr;
return false;
}
return true;
}
std::string ensure(std::uintptr_t base, std::uintptr_t manager, const std::string& artist, const std::string& title) {
if (!base || !manager || artist.empty() || title.empty()) return {};
if (!matches(base, lm::song_register_contract) || !matches(base, lm::song_lookup_contract)) {
problem("the game's routines for registering a song are not the ones this was written for");
return {};
}
const auto id = artist + " - " + title;
std::lock_guard lock(mutex);
auto found = rows.find(id);
if (found == rows.end()) {
const auto original = a_song(base, manager);
std::uintptr_t original_metadata{};
if (!original || !memory::peek(original + graph_metadata, original_metadata)) {
problem("no registered song was found to copy");
return {};
}
Row row{copy_of(original, true), copy_of(original_metadata & ~std::uintptr_t{4}, true), artist, title};
if (!row.graph || !row.metadata) {
problem("a registered song could not be copied");
return {};
}
// The copy: known by 16 bytes of its own, never freed by the game, in no playlist, and
// with an artist and title of its own.
std::random_device random;
for (std::size_t i = 0; i < header; ++i) row.graph[i] = static_cast<unsigned char>(random());
for (std::size_t i = 0; i < header; ++i) row.metadata[i] = static_cast<unsigned char>(random());
const auto set = [](unsigned char* asset, std::size_t at, std::uintptr_t value) { std::memcpy(asset + at, &value, sizeof(value)); };
for (auto* asset : {row.graph.get() + header, row.metadata.get() + header}) {
std::uint16_t flags{};
std::memcpy(&flags, asset + asset_flags, sizeof(flags));
flags |= never_freed;
std::memcpy(asset + asset_flags, &flags, sizeof(flags));
}
found = rows.emplace(id, std::move(row)).first;
auto& made = found->second;
set(made.metadata.get() + header, metadata_artist, reinterpret_cast<std::uintptr_t>(made.artist.c_str()));
set(made.metadata.get() + header, metadata_title, reinterpret_cast<std::uintptr_t>(made.title.c_str()));
set(made.graph.get() + header, graph_metadata, reinterpret_cast<std::uintptr_t>(made.metadata.get() + header));
set(made.graph.get() + header, graph_tags, 0);
}
if (registered(base, manager, id)) return id;
if (!register_song(base, manager, reinterpret_cast<std::uintptr_t>(found->second.graph.get() + header)) || !registered(base, manager, id)) {
problem("the game did not take the song \"" + id + "\"");
return {};
}
try {
logging::log(logging::Level::info, logging::Channel::music, "Spotify rows: registered \"{}\".", id);
} catch (...) {}
return id;
}
bool add_member(std::uintptr_t base, std::uintptr_t manager, std::uint32_t playlist, const unsigned char* song) noexcept {
if (!base || !manager || !matches(base, lm::playlist_add_contract)) return false;
__try {
(add_original ? add_original : reinterpret_cast<PlaylistSong>(base + lm::playlist_add_contract.rva))(manager, &playlist, song);
return true;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return false;
}
}
} // namespace dingosdk::spotify_rows
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <cstdint>
#include <string>
// Songs of our own in the game's registry of music, for the rows of the Spotify playlists in
// the music screen (spotify_station.h). A row there is a registered song, shown by the artist
// and title of its asset: these are copies of a registered song's asset with an artist and
// title of their own, registered by the game's own routine. They are in no playlist of the
// game's, so the game never chooses one to play. (Being tried.)
namespace dingosdk::spotify_rows {
// A song of this artist and title, registered with the game's music manager when it is not
// yet: its id in the music screen ("artist - title"), or empty when it could not be made.
// The thread that reads the catalog; not with the manager's lock held.
std::string ensure(std::uintptr_t base, std::uintptr_t manager, const std::string& artist, const std::string& title);
// The id ("artist - title") of the row known to the game by these 16 bytes, or empty.
std::string id_of(const unsigned char* row);
// Watches for the game beginning to play one of these (the player picked its row), which
// spotify_station is told of. Once.
bool install(std::uintptr_t base) noexcept;
// Adds a song (the 16 bytes the game knows it by) to a playlist of the game's, by the
// playlist's number, with the game's own routine. Any thread.
bool add_member(std::uintptr_t base, std::uintptr_t manager, std::uint32_t playlist, const unsigned char* song) noexcept;
}
+461
View File
@@ -0,0 +1,461 @@
#include "spotify_station.h"
#include "external_player.h"
#include "spotify_cover.h"
#include "spotify_web.h"
#include "spotify_device.h"
#include "external_audio.h"
#include "youtube_music.h"
#include "spotify_rows.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Core/Platform/memory.h"
#include <Windows.h>
#include <algorithm>
#include <atomic>
#include <cstring>
#include <format>
#include <memory>
#include <mutex>
namespace dingosdk::spotify_station {
namespace {
constexpr std::uint32_t no_channel = ~0u;
std::atomic<std::uint32_t> station_channel{no_channel}; // the music channel bound to one of them
std::atomic<int> station_index{-1}; // and which
std::atomic<std::uint64_t> binds_seen{};
std::atomic<std::size_t> station_count{};
std::atomic<std::uint64_t> last_said{~0ull};
std::atomic<bool> skip{};
// The channel last bound to a playlist that is no station, and when.
std::atomic<std::uint32_t> plain_channel{no_channel};
std::atomic<std::uint64_t> plain_at{};
void left_at_store() noexcept;
// The music player's queue, as last seen choosing a song while a station is on, how it was
// flagged when the player asked for the next song, and when this last asked it itself.
std::atomic<std::uintptr_t> player_queue{}, player_queue_table{}; // (the table a queue begins with: by it the queue is known to be there still)
std::atomic<std::uint8_t> asked_flag{1};
std::atomic<std::uint64_t> self_asked{};
constexpr std::size_t queue_asked = 0xf1, queue_start = 0xf2;
// The stations, and the artist and title of the one that is on (see the header). The game may
// still be reading a text it was given earlier, so none of them is ever freed: a song's worth
// of bytes for each song heard.
constexpr std::size_t artist_at = 0x18, title_at = 0x20;
std::mutex mutex;
std::vector<Station> stations;
std::array<unsigned char, 16> picked_row{};
std::atomic<bool> row_picked{};
std::uintptr_t metadata_table{};
// The songs whose artist and title read as Spotify's now, with what they read before: the
// station's own, and any other song of the game's begun while it is on (a speaker's in the
// world, one grabbed from it), whose sound is Spotify's as well. `current_*`: what they read.
struct Shown {
std::uintptr_t metadata{}, artist{}, title{};
};
std::vector<Shown> shown;
// Those that are to read so again after they were put back for the catalog to be read (the
// station's own is not among them: it is shown again as a matter of course).
std::vector<std::uintptr_t> again;
// What each of the game's music channels last began (its metadata asset).
std::array<std::uintptr_t, 16> channel_song{};
std::uintptr_t current_artist{}, current_title{};
std::vector<std::unique_ptr<char[]>> kept;
bool poke(std::uintptr_t address, std::uintptr_t value) noexcept {
__try {
std::memcpy(reinterpret_cast<void*>(address), &value, sizeof(value));
return true;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return false;
}
}
bool there(std::uintptr_t metadata) noexcept {
std::uintptr_t table{};
return metadata && memory::peek(metadata, table) && table == metadata_table;
}
std::uintptr_t keep(const std::string& text) {
auto copy = std::make_unique<char[]>(text.size() + 1);
std::memcpy(copy.get(), text.c_str(), text.size() + 1);
kept.push_back(std::move(copy));
return reinterpret_cast<std::uintptr_t>(kept.back().get());
}
// (With the mutex held.)
void restore_locked() noexcept {
for (const auto& entry : shown)
if (there(entry.metadata)) {
poke(entry.metadata + artist_at, entry.artist);
poke(entry.metadata + title_at, entry.title);
}
shown.clear();
}
// (With the mutex held.) A song reads as Spotify's from now on.
void show_locked(std::uintptr_t metadata) noexcept {
if (!current_artist || !current_title || !there(metadata)) return;
if (std::none_of(shown.begin(), shown.end(), [&](const Shown& entry) { return entry.metadata == metadata; })) {
Shown entry{metadata};
if (shown.size() >= 64 || !memory::peek(metadata + artist_at, entry.artist) || !memory::peek(metadata + title_at, entry.title)) return;
shown.push_back(entry);
}
const bool done = poke(metadata + artist_at, current_artist) && poke(metadata + title_at, current_title);
// (Each song made to read as Spotify's, into the log while the speakers are worked out.)
if (static int said{}; said++ < 60) {
try {
logging::log(logging::Level::info, logging::Channel::music, "Spotify station: a song of the game's {} as Spotify's ({} in all).", done ? "now reads" : "could not be made to read", shown.size());
} catch (...) {}
}
}
bool is_shown_locked(std::uintptr_t metadata) noexcept {
return std::any_of(shown.begin(), shown.end(), [&](const Shown& entry) { return entry.metadata == metadata; });
}
void show(int index, const std::string& artist, const std::string& title) {
std::lock_guard lock(mutex);
if (index < 0 || static_cast<std::size_t>(index) >= stations.size() || artist.empty() || title.empty()) return;
current_artist = keep(artist);
current_title = keep(title);
show_locked(stations[static_cast<std::size_t>(index)].metadata);
for (const auto metadata : again) show_locked(metadata);
again.clear();
for (const auto metadata : channel_song)
if (metadata) show_locked(metadata);
for (const auto& entry : std::vector<Shown>(shown)) show_locked(entry.metadata);
}
// Which of YouTube Music's playlists a station's is ("~yt:<number>").
std::size_t youtube_list(const std::string& playlist) {
try {
return std::stoul(playlist.substr(std::strlen(youtube_music::station_prefix)));
} catch (...) {
return ~std::size_t{};
}
}
} // namespace
bool spotify_used() noexcept {
try {
return spotify_device::in_use() || spotify_web::state().signed_in;
} catch (...) {
return false;
}
}
bool is_station(std::string_view id) noexcept { return id == playlist_id || id.starts_with(playlist_prefix); }
void set_stations(std::vector<Station> next, std::uintptr_t table) {
std::lock_guard lock(mutex);
restore_locked();
stations = std::move(next);
metadata_table = table;
station_count.store(stations.size(), std::memory_order_release);
}
std::vector<std::string> playlist_ids() {
std::lock_guard lock(mutex);
std::vector<std::string> ids;
for (const auto& station : stations) ids.push_back(station.id);
return ids;
}
bool has_stations() noexcept { return station_count.load(std::memory_order_acquire) != 0; }
int station_of(const unsigned char* members, std::uint32_t count, unsigned char* carrier, bool& add) noexcept {
std::lock_guard lock(mutex);
add = false;
int best{-1};
std::uint32_t best_rows{};
for (std::size_t i = 0; i < stations.size(); ++i) {
const auto& station = stations[i];
// Every member is one of its rows or its carrier, and the more of its rows the better.
std::uint32_t rows{};
bool mine{true}, carried{};
for (std::uint32_t member = 0; member < count && mine; ++member) {
const auto* bytes = members + std::size_t{member} * 16;
const bool row = std::any_of(station.rows.begin(), station.rows.end(), [&](const auto& candidate) { return std::memcmp(candidate.data(), bytes, 16) == 0; });
const bool is_carrier = std::memcmp(station.carrier.data(), bytes, 16) == 0;
rows += row;
carried = carried || is_carrier;
mine = row || is_carrier;
}
if (!mine || !rows || rows <= best_rows) continue;
best = static_cast<int>(i);
best_rows = rows;
add = !carried;
std::memcpy(carrier, station.carrier.data(), 16);
}
return best;
}
void song_begun(std::uint32_t channel, std::uintptr_t metadata) noexcept {
std::lock_guard lock(mutex);
if (channel < channel_song.size()) channel_song[channel] = metadata;
}
void song_started(std::uintptr_t metadata) noexcept {
if (!active()) return;
std::lock_guard lock(mutex);
show_locked(metadata);
}
int row_of(int station, const unsigned char* song) noexcept {
std::lock_guard lock(mutex);
if (station < 0 || static_cast<std::size_t>(station) >= stations.size()) return -1;
const auto& rows = stations[static_cast<std::size_t>(station)].rows;
for (std::size_t i = 0; i < rows.size(); ++i)
if (std::memcmp(rows[i].data(), song, 16) == 0) return static_cast<int>(i);
return -1;
}
void row_started(const unsigned char* row) noexcept {
std::lock_guard lock(mutex);
std::memcpy(picked_row.data(), row, 16);
row_picked.store(true, std::memory_order_release);
}
void bound(std::uint32_t channel, bool music_channel, std::uint32_t playlist, std::uint32_t members, int station) noexcept {
binds_seen.fetch_add(1, std::memory_order_relaxed);
// (Each change of a channel's playlist to a station, into the log.)
if (const auto signature = (std::uint64_t{channel} << 40) ^ (std::uint64_t{playlist} << 8) ^ members ^ (static_cast<std::uint64_t>(station + 1) << 52);
last_said.exchange(signature, std::memory_order_relaxed) != signature && station >= 0) {
try {
logging::log(logging::Level::info, logging::Channel::music, "Spotify station: the game bound channel {} to playlist {}, station {}{}.",
channel, playlist, station, music_channel ? "" : " (not yet a music channel)");
} catch (...) {}
}
const auto before = station_channel.load(std::memory_order_relaxed);
if (station < 0) {
plain_channel.store(channel, std::memory_order_relaxed);
plain_at.store(GetTickCount64(), std::memory_order_relaxed);
}
if (station >= 0 || before == channel) player_queue.store(0, std::memory_order_relaxed);
if (station >= 0) {
station_index.store(station, std::memory_order_relaxed);
station_channel.store(channel, std::memory_order_relaxed);
} else if (before == channel) {
station_channel.store(no_channel, std::memory_order_relaxed);
station_index.store(-1, std::memory_order_relaxed);
left_at_store();
}
}
void restore_presentation() noexcept {
std::lock_guard lock(mutex);
// (While a station is on they are only put back for a moment, for the catalog to be read.)
if (active())
for (const auto& entry : shown)
if (again.size() < 64 && std::find(again.begin(), again.end(), entry.metadata) == again.end()) again.push_back(entry.metadata);
restore_locked();
}
void song_chosen(std::uintptr_t queue, bool asked) noexcept {
// (A queue is the game's for as long as its channel stays on a playlist: a new one is made
// when the channel is bound again, and the old one's memory goes to something else. Written
// into after that, it took the game down, 2026-10-12.)
if (std::uintptr_t table{}; memory::peek(queue, table)) {
player_queue_table.store(table, std::memory_order_relaxed);
player_queue.store(queue, std::memory_order_relaxed);
}
if (!asked) return;
// (Its own asking is answered within a frame or two; a press of the player's is not it.)
if (const auto mine = self_asked.exchange(0, std::memory_order_relaxed); mine && GetTickCount64() - mine < 3000) return;
if (std::uint8_t start{}; memory::peek(queue + queue_start, start)) asked_flag.store(start, std::memory_order_relaxed);
skip.store(true, std::memory_order_relaxed);
}
void refresh_now_playing() noexcept {
const auto queue = player_queue.load(std::memory_order_relaxed);
if (!queue || !active()) return;
if (std::uintptr_t table{}; !memory::peek(queue, table) || table != player_queue_table.load(std::memory_order_relaxed)) {
player_queue.store(0, std::memory_order_relaxed);
return;
}
std::uint8_t asked{};
if (!memory::peek(queue + queue_asked, asked) || asked) return; // (it is choosing one as it is)
self_asked.store(GetTickCount64(), std::memory_order_relaxed);
__try {
*reinterpret_cast<std::uint8_t*>(queue + queue_start) = asked_flag.load(std::memory_order_relaxed);
*reinterpret_cast<std::uint8_t*>(queue + queue_asked) = 1;
} __except (EXCEPTION_EXECUTE_HANDLER) {
self_asked.store(0, std::memory_order_relaxed);
}
}
bool active() noexcept { return station_channel.load(std::memory_order_relaxed) != no_channel; }
std::atomic<bool> screen_open{};
void set_music_screen(bool open) noexcept { screen_open.store(open, std::memory_order_relaxed); }
bool music_screen() noexcept { return screen_open.load(std::memory_order_relaxed); }
std::atomic<std::uint64_t> left_at{}, grabbed_at{};
void next_pressed() noexcept {
if (active()) skip.store(true, std::memory_order_relaxed);
}
void grabbed() noexcept { grabbed_at.store(GetTickCount64(), std::memory_order_relaxed); }
namespace {
void left_at_store() noexcept { left_at.store(GetTickCount64(), std::memory_order_relaxed); }
}
bool recent() noexcept { return active() || GetTickCount64() - left_at.load(std::memory_order_relaxed) < 15000; }
std::uint32_t channel() noexcept { return station_channel.load(std::memory_order_relaxed); }
int current() noexcept { return active() ? station_index.load(std::memory_order_relaxed) : -1; }
void tick() noexcept {
static int was{-1};
static std::uint64_t said{}, entered{}, picked_at{}, tube_start_at{};
static std::size_t tube_start{};
const int now = active() ? station_index.load(std::memory_order_relaxed) : -1;
if (now != was) {
const bool first = was < 0;
was = now;
// Entered, or gone from one to another: the outside player is watched and started, on
// the playlist when the station has one. Left: it is paused, and the watcher stays (it
// costs a look a second) so the next entry is at once.
if (now >= 0) {
external_player::start();
std::string playlist;
{
std::lock_guard lock(mutex);
if (static_cast<std::size_t>(now) < stations.size()) playlist = stations[static_cast<std::size_t>(now)].playlist;
}
// The External Audio station (external_audio.h) is another application's sound and
// session; the others are Spotify's. What was playing is paused as one gives way to
// the other.
// (And a YouTube Music station, youtube_music.h, is a playlist of the player's own.)
const bool others = playlist == external_audio::station, tube = playlist.starts_with(youtube_music::station_prefix);
if (others != external_audio::on() || tube != youtube_music::on()) {
external_player::send(external_player::Command::pause);
external_audio::set_on(others);
youtube_music::set_on(tube);
}
external_player::use_app(others ? external_audio::app() : std::string());
if (tube) {
tube_start = youtube_list(playlist);
tube_start_at = GetTickCount64();
} else if (others) external_player::send(external_player::Command::play);
// (On the game's own device, Spotify is had to play there, wherever it was playing.)
else if (!playlist.empty() && GetTickCount64() - picked_at > 1500) {
if (spotify_device::connected()) spotify_device::activate();
spotify_web::play(playlist);
} else if (spotify_device::connected()) spotify_device::start_radio();
else if (first) external_player::send(external_player::Command::play);
entered = GetTickCount64();
try {
logging::log(logging::Level::info, logging::Channel::music, "Spotify station: station {} is on{}.", now,
playlist.empty() ? std::string() : " (Spotify playlist " + playlist + ")");
} catch (...) {}
} else {
external_player::send(external_player::Command::pause);
}
}
// A row the player picked: Spotify is started at its track, and the game is had to go on
// to the station's own song (the row's is a copy of some song of the game's, which would
// otherwise play out under its own name).
if (row_picked.exchange(false, std::memory_order_acquire)) {
// (The row is looked for in the station that is on first, then in the others: the game
// may ask for it a moment before it binds the playlist.)
std::string playlist, track;
int found{-1};
{
std::lock_guard lock(mutex);
const auto look = [&](const Station& station) {
for (std::size_t i = 0; i < station.rows.size() && i < station.tracks.size(); ++i)
if (station.rows[i] == picked_row && !station.tracks[i].empty()) {
playlist = station.playlist;
track = station.tracks[i];
return true;
}
return false;
};
if (now >= 0 && static_cast<std::size_t>(now) < stations.size() && look(stations[static_cast<std::size_t>(now)])) found = now;
else
for (std::size_t i = 0; i < stations.size() && found < 0; ++i)
if (look(stations[i])) found = static_cast<int>(i);
}
// A playlist added since the game began has no playlist of the game's behind its tile
// (the game makes those as it starts), so the game binds the channel to none: the row
// picked says which station is meant, and it is taken to be on, on the channel the game
// has just bound. Whatever song of the game's plays there is the one whose sound is
// replaced. (Seen 2026-10-12: a playlist added from the setup page played the game's
// own songs.)
if (found >= 0 && found != now && music_screen() && GetTickCount64() - plain_at.load(std::memory_order_relaxed) < 4000) {
if (const auto channel = plain_channel.load(std::memory_order_relaxed); channel != no_channel) {
station_index.store(found, std::memory_order_relaxed);
station_channel.store(channel, std::memory_order_relaxed);
try {
logging::log(logging::Level::info, logging::Channel::music, "Spotify station: station {} is taken to be on, on channel {} (its tile has no playlist of the game's behind it yet).", found, channel);
} catch (...) {}
}
}
// The song Spotify is playing now is asked for when the player grabs the music of a
// speaker in the world: its song reads as Spotify's, and the game finds the row of that
// name. Spotify is playing it already, and is left to.
bool current{};
try {
std::array<unsigned char, 16> row{};
{
std::lock_guard lock(mutex);
row = picked_row;
}
if (const auto playing = external_player::now(); playing.available && now >= 0) current = spotify_rows::id_of(row.data()) == playing.artist + " - " + playing.title;
} catch (...) {}
// A row is picked in the music screen, which is a menu of the game's. A song asked for
// with no menu up is the world's doing (the player grabbed a speaker's music, whose song
// reads as one Spotify played): Spotify is left where it is.
if (!music_screen()) track = "asked for with no menu up: Spotify is left as it is";
else if (current) track = "the song Spotify is playing: left to play";
else if (playlist.starts_with(youtube_music::station_prefix) && track.starts_with(youtube_music::track_prefix))
youtube_music::play(youtube_list(playlist), track.substr(std::strlen(youtube_music::track_prefix)));
else if (!playlist.empty() && !track.empty()) spotify_web::play(playlist, track);
picked_at = GetTickCount64(); // (with a track or not: the game's next that follows is not Spotify's)
try {
logging::log(logging::Level::info, logging::Channel::music, "Spotify station: a row was picked{}.", track.empty() ? std::string(" (not one with a track)") : " (" + track + ")");
} catch (...) {}
}
// A YouTube Music station that has come on starts its playlist a moment after, unless a row
// of it was picked meanwhile (which is how it usually comes on, and is what is played).
if (tube_start_at && GetTickCount64() - tube_start_at > 400) {
if (now >= 0 && youtube_music::on() && GetTickCount64() - picked_at > 1500) youtube_music::play(tube_start);
tube_start_at = 0;
}
if (now < 0) {
skip.store(false, std::memory_order_relaxed);
player_queue.store(0, std::memory_order_relaxed);
restore_presentation();
{
std::lock_guard lock(mutex);
again.clear();
}
return;
}
// The game's "next" is Spotify's (once for a press: the game may ask more than once).
static std::uint64_t skipped{};
if (const auto tick_now = GetTickCount64(); skip.exchange(false, std::memory_order_relaxed) && tick_now - skipped > 1200 &&
tick_now - entered > 2000 && tick_now - picked_at > 2500 && tick_now - grabbed_at.load(std::memory_order_relaxed) > 2500) { // (the game asks the same way for a station's first song)
skipped = tick_now;
// (The game asks the same way when its music is taken off pause, which is not a next:
// Spotify is paused then, by music_inject, and is only played again.)
bool playing{true};
try {
if (const auto track = external_player::now(); track.available) playing = track.playing;
} catch (...) {}
if (playing) external_player::send(external_player::Command::next);
try {
logging::log(logging::Level::info, logging::Channel::music, "Spotify station: the game asked for its next song; {}.", playing ? "Spotify's next" : "Spotify is paused, so it is left where it is");
} catch (...) {}
}
try {
const auto track = external_player::now();
if (!track.available) return;
// Its cover is made ready for the music screen as Windows comes to have it.
static std::uint64_t cover{~0ull};
if (!track.artwork.empty() && track.artwork_revision != cover) {
cover = track.artwork_revision;
spotify_cover::want(track.artwork, track.artwork_revision);
}
bool unshown{};
{
std::lock_guard lock(mutex);
unshown = static_cast<std::size_t>(now) >= stations.size() || !is_shown_locked(stations[static_cast<std::size_t>(now)].metadata);
}
const auto revision = external_player::revision();
if (unshown || revision != said) show(now, track.artist, track.title);
if (revision != said) {
said = revision;
logging::log(logging::Level::info, logging::Channel::music, "Spotify station: {} {}: {} - {}{}.", track.spotify ? "Spotify" : track.app,
track.playing ? "is playing" : "is paused on", track.artist, track.title, track.artwork.empty() ? "" : " (with a cover)");
}
} catch (...) {}
}
std::string status() {
const auto channel = station_channel.load(std::memory_order_relaxed);
const auto track = external_player::now();
return std::format("Spotify station: {} station(s); {}; {} playlist binding(s) seen. Outside player: {}.", station_count.load(std::memory_order_relaxed),
channel == no_channel ? std::string("none selected") : std::format("station {} selected on channel {}", station_index.load(std::memory_order_relaxed), channel),
binds_seen.load(std::memory_order_relaxed),
!track.available ? std::string("none seen (it is watched once a station has been selected)")
: (track.spotify ? std::string("Spotify") : track.app) + (track.playing ? " playing " : " paused on ") + track.artist + " - " + track.title);
}
} // namespace dingosdk::spotify_station
+98
View File
@@ -0,0 +1,98 @@
#pragma once
#include <array>
#include <cstdint>
#include <string>
#include <vector>
// Spotify in the game's own music screen. A "Spotify" playlist there plays whatever the
// player's Spotify app is playing, and each of the player's own Spotify playlists, when they
// have signed in (spotify_web.h), is a playlist there that starts it in the app. The sound is
// Spotify's, heard through the game (music_inject.h); the song, its cover and the next button
// are Spotify's too, through Windows' media session (external_player.h).
//
// The game only takes a playlist that has one of its own registered songs in it, so each of
// these is given one, a different one each: that song being a playlist's only member is how
// the game's binding of it is known (the game's number for a playlist is not the hash of its
// id: tried on 2026-10-11). The song's own sound is what is replaced by Spotify's.
namespace dingosdk::spotify_station {
inline constexpr char playlist_id[] = "reskate:spotify";
inline constexpr char playlist_name[] = "Spotify";
// The ids of the player's own playlists in the music screen begin with this, their Spotify
// id after it.
inline constexpr char playlist_prefix[] = "spotify:";
// Whether the player uses Spotify here at all (an account on the game's device, or one kept
// for it, or a sign-in for their playlists): its tiles and its row are only there when so.
bool spotify_used() noexcept;
// What the plain Spotify station's playlist is called among the stations (it starts none).
inline constexpr char plain_station[] = "~plain";
// Whether a playlist of the music screen is one of these (the row they are shown in).
bool is_station(std::string_view id) noexcept;
struct Station {
std::vector<std::array<unsigned char, 16>> rows; // its members, as the game identifies a song
std::vector<std::string> tracks; // the Spotify track each is, or empty
std::array<unsigned char, 16> carrier{}; // the song of the game's whose sound is replaced: a row, or one added when it is bound
std::uintptr_t metadata{}; // the carrier's artist and title, as the game holds them
std::string playlist; // the Spotify playlist it starts, or empty: whatever is playing
std::string id; // its playlist's id in the music screen
};
// Set each time the catalog is read (local_music_assets.cpp). `table` is what a metadata
// asset begins with, by which one is known to be still there.
void set_stations(std::vector<Station> stations, std::uintptr_t table);
bool has_stations() noexcept;
// Their playlists' ids in the music screen.
std::vector<std::string> playlist_ids();
// Which of them a playlist of these `count` members (16 bytes each) is, or -1. `add` is set
// when its carrier is not among them and is to be added (`carrier`, 16 bytes). Any thread.
int station_of(const unsigned char* members, std::uint32_t count, unsigned char* carrier, bool& add) noexcept;
// The game began to play a song of its own (spotify_rows.cpp), by its metadata asset: while a
// station is on its sound is Spotify's (a speaker's in the world, a song grabbed from one), so
// its artist and title are made to read as Spotify's as well. Any thread.
void song_started(std::uintptr_t metadata) noexcept;
// Every song of the game's as it begins, station or none, by its channel: what each channel
// is playing is made to read as Spotify's when a station comes on (the speakers' songs then
// playing among them, which began before it). Any thread.
void song_begun(std::uint32_t channel, std::uintptr_t metadata) noexcept;
// Which of a station's rows a song is, or -1. Any thread.
int row_of(int station, const unsigned char* song) noexcept;
// The game began to play a row of ours (spotify_rows.cpp), which the player picked: Spotify
// is started at its track. Any thread.
void row_started(const unsigned char* row) noexcept;
// From the playlist bind hook, on whichever thread the game binds on.
// `station`: which of them the playlist bound is, or -1.
void bound(std::uint32_t channel, bool music_channel, std::uint32_t playlist, std::uint32_t members, int station) noexcept;
// While one is on, its song's artist and title are made to read as what Spotify is playing,
// which is what the game shows as now playing. They are put back when it is left, and before
// the catalog is read from them.
void restore_presentation() noexcept;
// The game's music player chose a song while one is on (local_music_playback.cpp:
// music_select_next_hook). `queue` is the player's queue, `asked` whether it was asked for its
// next song (its flags for that, at +0xf1 and +0xf2, are still up), which is Spotify's next
// unless it was this that asked (refresh_now_playing).
void song_chosen(std::uintptr_t queue, bool asked) noexcept;
// Has the game start its song again, which is when it looks up what it shows as now playing:
// the cover of a song Spotify has gone on to is only shown then. The client thread.
void refresh_now_playing() noexcept;
// Whether the game's music player is on one of them now, and which (-1: none). Any thread.
bool active() noexcept;
int current() noexcept;
// Whether a menu of the game's is up (the music screen is one), as last seen while a station
// is on (client_tick.cpp).
void set_music_screen(bool open) noexcept;
bool music_screen() noexcept;
// The player pressed next on a channel other than the station's (the one a grabbed speaker's
// music plays on): Spotify's next, as on the station's own. Any thread.
void next_pressed() noexcept;
// A speaker's music was grabbed and the player kept on Spotify (local_music_playback.cpp):
// the next song the game goes on to for it is not Spotify's next. Any thread.
void grabbed() noexcept;
// Whether one is on or was until a quarter of a minute ago (what is traced into the log
// while grabbing a speaker's music is being worked out).
bool recent() noexcept;
// The music channel it is on, or ~0u.
std::uint32_t channel() noexcept;
// The client thread, each frame: starts or pauses the outside player as one is entered or left.
void tick() noexcept;
// For the console.
std::string status();
}
+534
View File
@@ -0,0 +1,534 @@
#include "spotify_web.h"
#include "spotify_cover.h"
#include "spotify_device.h"
#include "local_music_ui.h"
#include "Engine/Core/Json/json.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Vfs/https_download.h"
#include "Extension/Boot/user_data_redirect.h"
#include <WinSock2.h>
#include <WS2tcpip.h>
#include <Windows.h>
#include <bcrypt.h>
#include <dpapi.h>
#include <objbase.h>
#include <shellapi.h>
#include <winhttp.h>
#include <atomic>
#include <condition_variable>
#include <deque>
#include <filesystem>
#include <format>
#include <fstream>
#include <mutex>
#include <thread>
#pragma comment(lib, "winhttp")
#pragma comment(lib, "bcrypt")
#pragma comment(lib, "crypt32")
#pragma comment(lib, "ws2_32")
#pragma comment(lib, "shell32")
namespace dingosdk::spotify_web {
namespace {
constexpr wchar_t agent[] = L"ReSkate";
constexpr char scopes[] = "playlist-read-private playlist-read-collaborative user-modify-playback-state user-read-playback-state user-library-read";
std::mutex mutex; // everything below it
std::condition_variable wake;
std::deque<std::string> commands; // "login", "logout", "refresh", "play <id>"
std::string client_id, refresh_token, user_name, problem_text;
std::vector<Playlist> known;
bool is_busy{};
std::atomic<bool> started{};
// (The worker's alone.)
std::string access_token;
std::uint64_t access_until{};
void say(logging::Level level, const std::string& text) noexcept {
try {
logging::log(level, logging::Channel::music, "Spotify account: {}", text);
} catch (...) {}
}
void problem(const std::string& text) {
{
std::lock_guard lock(mutex);
problem_text = text;
}
if (!text.empty()) say(logging::Level::warning, text);
}
std::wstring wide(const std::string& text) {
if (text.empty()) return {};
std::wstring out(static_cast<std::size_t>(MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), nullptr, 0)), L'\0');
MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), out.data(), static_cast<int>(out.size()));
return out;
}
std::string escaped(const std::string& text) {
std::string out;
for (const unsigned char c : text) {
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') out.push_back(static_cast<char>(c));
else out += std::format("%{:02X}", c);
}
return out;
}
std::string base64url(const unsigned char* data, std::size_t size) {
static constexpr char digits[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
std::string out;
for (std::size_t i = 0; i < size; i += 3) {
const std::uint32_t value = (std::uint32_t{data[i]} << 16) | (i + 1 < size ? std::uint32_t{data[i + 1]} << 8 : 0) | (i + 2 < size ? data[i + 2] : 0);
out.push_back(digits[value >> 18]);
out.push_back(digits[(value >> 12) & 63]);
if (i + 1 < size) out.push_back(digits[(value >> 6) & 63]);
if (i + 2 < size) out.push_back(digits[value & 63]);
}
return out;
}
std::string random_text(std::size_t bytes) {
std::vector<unsigned char> data(bytes);
if (BCryptGenRandom(nullptr, data.data(), static_cast<ULONG>(data.size()), BCRYPT_USE_SYSTEM_PREFERRED_RNG) != 0) return {};
return base64url(data.data(), data.size());
}
std::string sha256_base64url(const std::string& text) {
unsigned char digest[32]{};
if (BCryptHash(BCRYPT_SHA256_ALG_HANDLE, nullptr, 0, reinterpret_cast<PUCHAR>(const_cast<char*>(text.data())), static_cast<ULONG>(text.size()), digest, sizeof(digest)) != 0) return {};
return base64url(digest, sizeof(digest));
}
// The token that renews the sign-in, kept so that only this Windows account can read it.
std::string sealed(const std::string& text) {
DATA_BLOB in{static_cast<DWORD>(text.size()), reinterpret_cast<BYTE*>(const_cast<char*>(text.data()))}, out{};
if (text.empty() || !CryptProtectData(&in, L"ReSkate Spotify", nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &out)) return {};
std::string hex;
for (DWORD i = 0; i < out.cbData; ++i) hex += std::format("{:02x}", out.pbData[i]);
LocalFree(out.pbData);
return hex;
}
std::string unsealed(const std::string& hex) {
std::vector<BYTE> bytes;
for (std::size_t i = 0; i + 1 < hex.size(); i += 2) {
const auto digit = [](char c) { return c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : 0; };
bytes.push_back(static_cast<BYTE>(digit(hex[i]) * 16 + digit(hex[i + 1])));
}
DATA_BLOB in{static_cast<DWORD>(bytes.size()), bytes.data()}, out{};
if (bytes.empty() || !CryptUnprotectData(&in, nullptr, nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &out)) return {};
std::string text(reinterpret_cast<const char*>(out.pbData), out.cbData);
LocalFree(out.pbData);
return text;
}
std::filesystem::path kept_file() { return redirected_local_app_data().parent_path() / L"spotify.json"; }
void keep() {
try {
Json json = Json::object();
{
std::lock_guard lock(mutex);
json["client_id"] = client_id;
json["refresh_token"] = sealed(refresh_token);
}
std::error_code ignored;
std::filesystem::create_directories(kept_file().parent_path(), ignored);
std::ofstream(kept_file(), std::ios::binary | std::ios::trunc) << json.dump(2);
} catch (...) {}
}
void read_kept() {
try {
std::ifstream in(kept_file(), std::ios::binary);
if (!in) return;
const auto json = Json::parse(std::string(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>()));
std::lock_guard lock(mutex);
client_id = json.value("client_id", "");
refresh_token = unsealed(json.value("refresh_token", ""));
} catch (...) {}
}
struct Reply {
unsigned long status{};
std::string body;
};
struct Handle {
HINTERNET h{};
~Handle() { if (h) WinHttpCloseHandle(h); }
};
Reply request(const wchar_t* method, const wchar_t* host, const std::wstring& path, const std::wstring& headers, const std::string& body) {
Reply reply;
Handle session{WinHttpOpen(agent, WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0)};
if (!session.h) return reply;
WinHttpSetTimeouts(session.h, 10000, 10000, 15000, 15000);
Handle connection{WinHttpConnect(session.h, host, INTERNET_DEFAULT_HTTPS_PORT, 0)};
Handle call{connection.h ? WinHttpOpenRequest(connection.h, method, path.c_str(), nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE) : nullptr};
if (!call.h || !WinHttpSendRequest(call.h, headers.empty() ? WINHTTP_NO_ADDITIONAL_HEADERS : headers.c_str(), static_cast<DWORD>(-1),
body.empty() ? WINHTTP_NO_REQUEST_DATA : const_cast<char*>(body.data()), static_cast<DWORD>(body.size()), static_cast<DWORD>(body.size()), 0) ||
!WinHttpReceiveResponse(call.h, nullptr))
return reply;
DWORD status{}, size = sizeof(status);
WinHttpQueryHeaders(call.h, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, &status, &size, WINHTTP_NO_HEADER_INDEX);
reply.status = status;
char buffer[8192];
for (DWORD got{}; WinHttpReadData(call.h, buffer, sizeof(buffer), &got) && got && reply.body.size() < 4 * 1024 * 1024;) reply.body.append(buffer, got);
return reply;
}
std::string redirect() { return std::format("http://127.0.0.1:{}/callback", callback_port); }
// The tokens out of Spotify's answer to a sign-in or a renewal.
bool take_tokens(const Reply& reply) {
try {
const auto json = Json::parse(reply.body);
if (reply.status != 200 || !json.contains("access_token")) return false;
access_token = json.value("access_token", "");
access_until = GetTickCount64() + (json.contains("expires_in") ? json.at("expires_in").get<std::uint64_t>() : 3600) * 1000;
if (const auto renewed = json.value("refresh_token", ""); !renewed.empty()) {
{
std::lock_guard lock(mutex);
refresh_token = renewed;
}
keep();
}
return !access_token.empty();
} catch (...) {
return false;
}
}
// A token to ask with, renewed when it is near its end. Empty when the player is not signed in.
std::string token() {
if (!access_token.empty() && GetTickCount64() + 60000 < access_until) return access_token;
std::string client, renewal;
{
std::lock_guard lock(mutex);
client = client_id;
renewal = refresh_token;
}
access_token.clear();
if (client.empty() || renewal.empty()) return {};
const auto reply = request(L"POST", L"accounts.spotify.com", L"/api/token", L"Content-Type: application/x-www-form-urlencoded",
"grant_type=refresh_token&refresh_token=" + escaped(renewal) + "&client_id=" + escaped(client));
if (!take_tokens(reply)) {
if (reply.status == 400 || reply.status == 401) { // the sign-in was withdrawn
{
std::lock_guard lock(mutex);
refresh_token.clear();
}
keep();
problem("the sign-in is no longer good; sign in again (spotify login)");
} else {
problem(std::format("Spotify could not be reached to renew the sign-in ({})", reply.status));
}
return {};
}
return access_token;
}
Reply api(const wchar_t* method, const std::string& path, const std::string& body = {}) {
const auto bearer = token();
if (bearer.empty()) return {};
return request(method, L"api.spotify.com", wide(path), wide("Authorization: Bearer " + bearer) + (body.empty() ? L"" : L"\r\nContent-Type: application/json"), body);
}
// Waits for the browser to come back to the redirect address, and answers it. The code, or
// empty.
std::string await_callback(const std::string& expected_state) {
WSADATA data{};
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) return {};
std::string code;
const SOCKET listener = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
address.sin_port = htons(callback_port);
if (listener != INVALID_SOCKET && bind(listener, reinterpret_cast<const sockaddr*>(&address), sizeof(address)) == 0 && listen(listener, 4) == 0) {
const auto until = GetTickCount64() + 180000;
while (code.empty() && GetTickCount64() < until) {
fd_set ready;
FD_ZERO(&ready);
FD_SET(listener, &ready);
timeval wait{1, 0};
if (select(0, &ready, nullptr, nullptr, &wait) <= 0) continue;
const SOCKET client = accept(listener, nullptr, nullptr);
if (client == INVALID_SOCKET) continue;
DWORD timeout = 3000;
setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast<const char*>(&timeout), sizeof(timeout));
std::string text;
char buffer[2048];
while (text.find("\r\n") == std::string::npos && text.size() < 8192) {
const int got = recv(client, buffer, sizeof(buffer), 0);
if (got <= 0) break;
text.append(buffer, static_cast<std::size_t>(got));
}
const auto line = text.substr(0, text.find("\r\n"));
const auto value = [&](const std::string& name) -> std::string {
const auto at = line.find(name + "=");
if (at == std::string::npos) return {};
const auto from = at + name.size() + 1;
return line.substr(from, line.find_first_of("& ", from) - from);
};
bool good{};
if (line.starts_with("GET /callback") && value("state") == expected_state) {
code = value("code");
good = !code.empty();
if (!good) code = "-"; // refused in the browser: stop waiting
}
const std::string page = std::string("<html><body style=\"font-family:sans-serif;background:#121212;color:#fff;text-align:center;padding-top:20vh\"><h2>") +
(good ? "ReSkate is signed in to Spotify" : "ReSkate was not signed in") + "</h2><p>You can close this and go back to the game.</p></body></html>";
const auto answer = std::format("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", page.size(), page);
send(client, answer.data(), static_cast<int>(answer.size()), 0);
shutdown(client, SD_SEND);
closesocket(client);
}
} else {
problem(std::format("port {} on this computer, which the sign-in comes back to, is in use", callback_port));
}
if (listener != INVALID_SOCKET) closesocket(listener);
WSACleanup();
return code == "-" ? std::string() : code;
}
// The tracks out of Spotify's answer for a playlist's (or the liked songs'): those the music
// screen can show.
std::vector<Track> tracks_of(const Reply& reply) {
std::vector<Track> tracks;
if (reply.status != 200) return tracks;
try {
const auto json = Json::parse(reply.body);
if (!json.contains("items") || !json.at("items").is_array()) return tracks;
const auto& items = json.at("items");
const auto plain = [](const std::string& text) {
return !text.empty() && text.size() <= 200 && std::all_of(text.begin(), text.end(), [](unsigned char c) { return c >= 0x20; });
};
for (std::size_t i = 0; i < items.size(); ++i) {
const auto& item = items.at(i);
if (!item.is_object() || !item.contains("track") || !item.at("track").is_object()) continue;
const auto& song = item.at("track");
Track track{{}, song.value("name", ""), song.value("uri", "")};
if (song.contains("artists") && song.at("artists").is_array() && song.at("artists").size() && song.at("artists").at(std::size_t{0}).is_object())
track.artist = song.at("artists").at(std::size_t{0}).value("name", "");
if (plain(track.artist) && plain(track.title) && track.uri.starts_with("spotify:track:")) tracks.push_back(std::move(track));
}
} catch (...) {}
return tracks;
}
void fetch_playlists() {
std::vector<Playlist> found;
if (const auto me = api(L"GET", "/v1/me"); me.status == 200) {
try {
const auto json = Json::parse(me.body);
std::lock_guard lock(mutex);
user_name = json.value("display_name", "");
} catch (...) {}
}
// Liked Songs first, when the sign-in lets them be read (one made before this asked for
// that does not: signing in again does).
if (const auto liked = api(L"GET", "/v1/me/tracks?limit=50"); liked.status == 200) found.push_back({liked_songs, "Liked Songs", {}, tracks_of(liked)});
else if (liked.status == 401 || liked.status == 403) say(logging::Level::info, "Liked Songs need the sign-in made again (spotify login).");
const auto reply = api(L"GET", "/v1/me/playlists?limit=50");
if (reply.status != 200) {
if (reply.status) problem(std::format("the playlists could not be fetched ({})", reply.status));
return;
}
try {
const auto json = Json::parse(reply.body);
if (!json.contains("items") || !json.at("items").is_array()) return;
const auto& items = json.at("items");
for (std::size_t i = 0; i < items.size(); ++i) {
const auto& item = items.at(i);
if (!item.is_object()) continue;
Playlist playlist{item.value("id", ""), item.value("name", ""), {}, {}};
if (playlist.id.empty() || playlist.name.empty() || playlist.id.size() > 64 ||
!std::all_of(playlist.id.begin(), playlist.id.end(), [](unsigned char c) { return std::isalnum(c) != 0; }))
continue;
// Its picture, fetched and made into what the music screen shows.
if (item.contains("images") && item.at("images").is_array() && item.at("images").size() && item.at("images").at(std::size_t{0}).is_object()) {
const auto address = item.at("images").at(std::size_t{0}).value("url", "");
if (address.starts_with("https://"))
if (const auto image = https::get_text(wide(address), 4 * 1024 * 1024, 15, agent))
playlist.artwork = spotify_cover::serve(std::vector<std::uint8_t>(image->begin(), image->end()), "playlist-" + playlist.id + ".png");
}
playlist.tracks = tracks_of(api(L"GET", "/v1/playlists/" + playlist.id + "/tracks?limit=50"));
found.push_back(std::move(playlist));
}
} catch (...) {
problem("Spotify's list of playlists could not be read");
return;
}
say(logging::Level::info, std::format("{} playlist(s) fetched.", found.size()));
{
std::lock_guard lock(mutex);
known = std::move(found);
problem_text.clear();
}
profile_runtime::request_music_catalog_refresh();
}
void sign_in() {
std::string client;
{
std::lock_guard lock(mutex);
client = client_id;
}
if (client.empty()) return problem("no client id is set (spotify client <id>)");
const auto verifier = random_text(64), state = random_text(16);
const auto challenge = sha256_base64url(verifier);
if (verifier.empty() || challenge.empty()) return problem("the sign-in could not be prepared");
const auto address = "https://accounts.spotify.com/authorize?response_type=code&client_id=" + escaped(client) + "&scope=" + escaped(scopes) +
"&redirect_uri=" + escaped(redirect()) + "&state=" + state + "&code_challenge_method=S256&code_challenge=" + challenge;
ShellExecuteW(nullptr, L"open", wide(address).c_str(), nullptr, nullptr, SW_SHOWNORMAL);
say(logging::Level::info, "waiting for the sign-in in the browser.");
const auto code = await_callback(state);
if (code.empty()) return problem("the sign-in was not finished in the browser");
const auto reply = request(L"POST", L"accounts.spotify.com", L"/api/token", L"Content-Type: application/x-www-form-urlencoded",
"grant_type=authorization_code&code=" + escaped(code) + "&redirect_uri=" + escaped(redirect()) + "&client_id=" + escaped(client) + "&code_verifier=" + verifier);
if (!take_tokens(reply)) return problem(std::format("Spotify refused the sign-in ({})", reply.status));
say(logging::Level::info, "signed in.");
fetch_playlists();
}
void start_playlist(const std::string& playlist, const std::string& track) {
Json wanted = Json::object();
if (playlist == liked_songs) {
// Spotify has nothing to start Liked Songs by: those fetched are handed to it.
Json songs = Json::array();
std::uint64_t position{}, at{};
{
std::lock_guard lock(mutex);
for (const auto& known_playlist : known)
if (known_playlist.id == liked_songs)
for (const auto& song : known_playlist.tracks) {
if (song.uri == track) position = at;
songs.push_back(song.uri);
++at;
}
}
if (songs.empty()) return problem("there are no Liked Songs to play");
wanted["uris"] = std::move(songs);
if (position) {
Json offset = Json::object();
offset["position"] = position;
wanted["offset"] = std::move(offset);
}
} else {
wanted["context_uri"] = "spotify:playlist:" + playlist;
if (!track.empty()) {
Json offset = Json::object();
offset["uri"] = track;
wanted["offset"] = std::move(offset);
}
}
const auto body = wanted.dump();
auto reply = api(L"PUT", "/v1/me/player/play", body);
if (reply.status == 404) { // no player of theirs is the active one: this computer's is made it
std::string device;
if (const auto devices = api(L"GET", "/v1/me/player/devices"); devices.status == 200) {
try {
const auto json = Json::parse(devices.body);
const auto& list = json.at("devices");
for (std::size_t i = 0; i < list.size(); ++i)
if (device.empty() || list.at(i).value("type", "") == "Computer") device = list.at(i).value("id", "");
} catch (...) {}
}
if (device.empty()) return problem("Spotify is not open on any device to play the playlist on");
reply = api(L"PUT", "/v1/me/player/play?device_id=" + escaped(device), body);
}
if (reply.status == 403) return problem("Spotify only lets Premium accounts start a playlist from another application");
if (reply.status != 200 && reply.status != 202 && reply.status != 204) return problem(std::format("the playlist could not be started ({})", reply.status));
say(logging::Level::info, "started playlist " + playlist + (track.empty() ? std::string() : " at " + track) + ".");
}
void run() noexcept {
const bool initialized = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)); // (the pictures are converted on this thread)
read_kept();
try {
if (!token().empty()) fetch_playlists();
} catch (...) {}
for (;;) {
std::string command;
{
std::unique_lock lock(mutex);
is_busy = false;
wake.wait(lock, [] { return !commands.empty(); });
command = std::move(commands.front());
commands.pop_front();
is_busy = true;
}
try {
if (command == "login") sign_in();
else if (command == "refresh") fetch_playlists();
else if (command.starts_with("client ")) {
{
std::lock_guard lock(mutex);
client_id = command.substr(7);
}
keep();
}
else if (command == "logout") {
access_token.clear();
{
std::lock_guard lock(mutex);
refresh_token.clear();
known.clear();
user_name.clear();
problem_text.clear();
}
keep();
} else if (command.starts_with("play ")) {
const auto space = command.find(' ', 5);
start_playlist(command.substr(5, space == std::string::npos ? space : space - 5), space == std::string::npos ? std::string() : command.substr(space + 1));
}
} catch (...) {}
}
if (initialized) CoUninitialize();
}
void ask(std::string command) {
start();
{
std::lock_guard lock(mutex);
// (Only the last playlist asked for matters.)
if (command.starts_with("play ")) std::erase_if(commands, [](const std::string& waiting) { return waiting.starts_with("play "); });
commands.push_back(std::move(command));
}
wake.notify_one();
}
} // namespace
void start() noexcept {
if (started.exchange(true)) return;
try {
std::thread(run).detach();
} catch (...) {}
}
void set_client(const std::string& id) { ask("client " + id); }
std::string client() {
std::lock_guard lock(mutex);
return client_id;
}
void login() { ask("login"); }
void logout() { ask("logout"); }
void refresh() { ask("refresh"); }
void play(const std::string& playlist, const std::string& track) {
// On the game's own device (spotify_device.h) when an account is on it: it is told itself.
if (spotify_device::connected()) {
if (playlist != liked_songs) return spotify_device::load("spotify:playlist:" + playlist, track);
std::string tracks;
unsigned index{}, at{};
{
std::lock_guard lock(mutex);
for (const auto& known_playlist : known)
if (known_playlist.id == liked_songs)
for (const auto& song : known_playlist.tracks) {
if (song.uri == track) index = at;
tracks += (tracks.empty() ? "" : " ") + song.uri;
++at;
}
}
if (!tracks.empty()) spotify_device::load_tracks(tracks, index);
return;
}
ask("play " + playlist + (track.empty() ? std::string() : " " + track));
}
std::vector<Playlist> playlists() {
std::lock_guard lock(mutex);
return known;
}
State state() {
std::lock_guard lock(mutex);
return {!client_id.empty(), !refresh_token.empty(), is_busy, user_name, problem_text, known.size()};
}
} // namespace dingosdk::spotify_web
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
// The player's Spotify account, through Spotify's Web API: their playlists, for the music
// screen's Spotify row (spotify_station.h), and starting one of them in their Spotify app.
//
// Spotify gives this to an application registered with it, by its client id. ReSkate has
// none of its own built in: the id is a setting ("spotify client <id>" in the console), of an
// application whose redirect address is http://127.0.0.1:47824/callback. The player signs in
// once in their browser ("spotify login"); what is kept is the token that renews the sign-in,
// encrypted for their Windows account, in <Local AppData>\ReSkate\spotify.json. Starting a
// playlist needs Spotify Premium (Spotify's rule) and the Spotify app open.
namespace dingosdk::spotify_web {
inline constexpr unsigned short callback_port = 47824;
// The id Liked Songs goes by here (Spotify does not count it among the playlists).
inline constexpr char liked_songs[] = "liked";
struct Track {
std::string artist, title, uri;
};
struct Playlist {
std::string id, name;
std::string artwork; // served by the artwork server (music_artwork.h), or empty
std::vector<Track> tracks; // its first fifty
};
struct State {
bool has_client{}, signed_in{}, busy{};
std::string user, problem;
std::size_t playlists{};
};
// Reads what was kept and, when the player had signed in, fetches their playlists. Once, on a
// thread of its own, which does everything below.
void start() noexcept;
void set_client(const std::string& id);
std::string client();
void login(); // opens the browser
void logout(); // forgets the sign-in
void refresh(); // fetches the playlists again
// The playlists as last fetched. Any thread.
std::vector<Playlist> playlists();
// Starts a playlist in the player's Spotify app, at one of its tracks when `track` (its
// Spotify address) is given.
void play(const std::string& playlist, const std::string& track = {});
State state();
}
+673
View File
@@ -0,0 +1,673 @@
#include "youtube_music.h"
#include "app_audio_capture.h"
#include "spotify_cover.h"
#include "local_music_ui.h"
#include "Engine/Core/Json/json.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Vfs/https_download.h"
#include "Extension/Boot/user_data_redirect.h"
#include <Windows.h>
#include <bcrypt.h>
#include <objbase.h>
#include <algorithm>
#include <atomic>
#include <condition_variable>
#include <deque>
#include <filesystem>
#include <format>
#include <fstream>
#include <mutex>
#include <thread>
#if DINGOSDK_SPOTIFY_DEVICE
// SpotifyHelper/src/stream.rs, linked into the DLL (cmake/Runtime.cmake).
extern "C" {
std::uint64_t reskate_stream_play(const char* program, const char* arguments, void (*sound)(const float* samples, std::uint32_t frames, std::uint32_t rate),
void (*ended)(std::uint64_t stream, const char* how));
void reskate_stream_stop();
}
#endif
namespace dingosdk::youtube_music {
namespace {
constexpr wchar_t agent[] = L"ReSkate";
constexpr wchar_t tool_address[] = L"https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe";
constexpr wchar_t runtime_address[] = L"https://github.com/quickjs-ng/quickjs/releases/latest/download/qjs-windows-x86_64.exe";
constexpr wchar_t sums_address[] = L"https://github.com/yt-dlp/yt-dlp/releases/latest/download/SHA2-256SUMS";
constexpr std::size_t most_songs = 100, most_playlists = 12;
std::mutex mutex; // everything below it
std::condition_variable wake;
std::deque<std::string> commands; // "tools", "add <link>", "covers"
std::vector<Playlist> library;
std::string problem_text, browser_name;
bool is_busy{}, have_tools{}, have_cookies{};
std::atomic<bool> started{}, is_on{}, paused{};
std::atomic<unsigned> sound_rate{44100}, fetched_percent{};
https::Watch tool_watch;
// What is playing.
std::size_t playing_list{}, playing_song{};
std::uint64_t playing_stream{};
int failures{};
external_player::Track track;
std::atomic<std::uint64_t> changes{1}, wanted_stream{}, begun_at{}, heard_stream{};
void say(logging::Level level, const std::string& text) noexcept {
try {
logging::log(level, logging::Channel::music, "YouTube Music: {}", text);
} catch (...) {}
}
void problem(const std::string& text) {
{
std::lock_guard lock(mutex);
problem_text = text;
}
if (!text.empty()) say(logging::Level::warning, text);
}
std::filesystem::path home() { return redirected_local_app_data().parent_path(); }
std::filesystem::path tool() { return home() / L"bin" / L"yt-dlp.exe"; }
std::filesystem::path cookies_file() { return home() / L"youtube-cookies.txt"; }
std::filesystem::path kept_file() { return home() / L"youtube-music.json"; }
std::string narrow(const std::wstring& text) {
if (text.empty()) return {};
std::string out(static_cast<std::size_t>(WideCharToMultiByte(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), nullptr, 0, nullptr, nullptr)), '\0');
WideCharToMultiByte(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), out.data(), static_cast<int>(out.size()), nullptr, nullptr);
return out;
}
std::wstring wide(const std::string& text) {
if (text.empty()) return {};
std::wstring out(static_cast<std::size_t>(MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), nullptr, 0)), L'\0');
MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), out.data(), static_cast<int>(out.size()));
return out;
}
// A video's id is eleven letters, digits, '-' and '_'.
bool video_id(const std::string& id) {
return id.size() == 11 && std::all_of(id.begin(), id.end(), [](unsigned char c) { return std::isalnum(c) || c == '-' || c == '_'; });
}
// Text the music screen can show as it is: no control characters, and not over long.
std::string shown(std::string text) {
std::erase_if(text, [](unsigned char c) { return c < 0x20; });
while (!text.empty() && text.back() == ' ') text.pop_back();
while (!text.empty() && text.front() == ' ') text.erase(text.begin());
if (text.size() > 120) {
text.resize(120);
while (!text.empty() && (static_cast<unsigned char>(text.back()) & 0xc0) == 0x80) text.pop_back(); // (not in the middle of a character)
if (!text.empty() && static_cast<unsigned char>(text.back()) >= 0xc0) text.pop_back();
}
return text;
}
// A JavaScript runtime for yt-dlp, which YouTube's pages need worked out when they are asked
// for as a signed-in player (without one, every song fails once there is a sign-in: seen
// 2026-10-12). Node.js when it is installed, or Deno in ReSkate's bin folder, as they are the
// quicker (a song begins in about four seconds); else QuickJS, which this fetches into the bin
// folder itself (about seven seconds: both timed 2026-10-12). The name yt-dlp knows it by and
// where it is; empty when there is none.
std::filesystem::path quickjs() { return home() / L"bin" / L"qjs.exe"; }
std::pair<std::string, std::string> javascript() {
std::error_code ignored;
wchar_t path[MAX_PATH]{};
if (SearchPathW(nullptr, L"node.exe", nullptr, MAX_PATH, path, nullptr)) return {"node", narrow(path)};
if (const auto deno = home() / L"bin" / L"deno.exe"; std::filesystem::exists(deno, ignored)) return {"deno", narrow(deno.wstring())};
if (std::filesystem::exists(quickjs(), ignored)) return {"quickjs", narrow(quickjs().wstring())};
return {};
}
// (With the mutex held.) What yt-dlp is run with for the player's YouTube sign-in: their
// cookies.txt when one is kept, else the browser they picked, else nothing.
std::vector<std::string> sign_in() {
std::vector<std::string> arguments;
if (!have_cookies && browser_name.empty()) return arguments; // (no sign-in: yt-dlp asks YouTube in a way that needs no runtime, and is quicker)
if (const auto [name, path] = javascript(); !name.empty()) arguments = {"--no-js-runtimes", "--js-runtimes", name + ":" + path};
if (have_cookies) arguments.insert(arguments.end(), {"--cookies", narrow(cookies_file().wstring())});
else arguments.insert(arguments.end(), {"--cookies-from-browser", browser_name});
return arguments;
}
bool good_browser(const std::string& browser) {
for (const char* known : {"", "firefox", "edge", "chrome", "brave", "opera", "vivaldi"})
if (browser == known) return true;
return false;
}
void keep() {
try {
Json lists = Json::array();
{
std::lock_guard lock(mutex);
for (const auto& playlist : library) {
Json songs = Json::array();
for (const auto& song : playlist.songs) {
Json entry = Json::object();
entry["id"] = song.id;
entry["artist"] = song.artist;
entry["title"] = song.title;
songs.push_back(std::move(entry));
}
Json entry = Json::object();
entry["name"] = playlist.name;
entry["url"] = playlist.url;
entry["songs"] = std::move(songs);
lists.push_back(std::move(entry));
}
}
Json json = Json::object();
json["playlists"] = std::move(lists);
{
std::lock_guard lock(mutex);
json["browser"] = browser_name;
}
std::error_code ignored;
std::filesystem::create_directories(home(), ignored);
std::ofstream(kept_file(), std::ios::binary | std::ios::trunc) << json.dump(2);
} catch (...) {}
}
void read_kept() {
try {
std::ifstream in(kept_file(), std::ios::binary);
if (!in) return;
const auto json = Json::parse(std::string(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>()));
if (const auto browser = json.value("browser", ""); good_browser(browser)) {
std::lock_guard lock(mutex);
browser_name = browser;
}
if (!json.contains("playlists") || !json.at("playlists").is_array()) return;
std::vector<Playlist> read;
const auto& lists = json.at("playlists");
for (std::size_t i = 0; i < lists.size() && read.size() < most_playlists; ++i) {
const auto& list = lists.at(i);
if (!list.is_object() || !list.contains("songs") || !list.at("songs").is_array()) continue;
Playlist playlist{shown(list.value("name", "")), list.value("url", ""), {}, {}};
const auto& songs = list.at("songs");
for (std::size_t s = 0; s < songs.size() && playlist.songs.size() < most_songs; ++s) {
if (!songs.at(s).is_object()) continue;
Song song{songs.at(s).value("id", ""), shown(songs.at(s).value("artist", "")), shown(songs.at(s).value("title", ""))};
if (video_id(song.id) && !song.artist.empty() && !song.title.empty()) playlist.songs.push_back(std::move(song));
}
if (!playlist.name.empty() && !playlist.songs.empty()) read.push_back(std::move(playlist));
}
std::lock_guard lock(mutex);
library = std::move(read);
} catch (...) {}
}
// Runs yt-dlp and gives what it wrote, or nothing when it could not be run or took too long.
std::optional<std::string> run_tool(const std::vector<std::string>& arguments, DWORD milliseconds) {
SECURITY_ATTRIBUTES inherit{sizeof(inherit), nullptr, TRUE};
HANDLE read{}, write{};
if (!CreatePipe(&read, &write, &inherit, 0)) return std::nullopt;
SetHandleInformation(read, HANDLE_FLAG_INHERIT, 0);
std::wstring command = L"\"" + tool().wstring() + L"\"";
for (const auto& argument : arguments) {
// (None of what is passed has a quote in it: a link is checked before it is.)
command += L" \"" + wide(argument) + L"\"";
}
STARTUPINFOW startup{};
startup.cb = sizeof(startup);
startup.dwFlags = STARTF_USESTDHANDLES;
startup.hStdOutput = write;
const auto nothing = CreateFileW(L"NUL", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, &inherit, OPEN_EXISTING, 0, nullptr);
startup.hStdInput = startup.hStdError = nothing;
PROCESS_INFORMATION process{};
const bool made = CreateProcessW(tool().c_str(), command.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &startup, &process) != 0;
CloseHandle(write);
if (nothing != INVALID_HANDLE_VALUE) CloseHandle(nothing);
if (!made) {
CloseHandle(read);
return std::nullopt;
}
// (Taken out of the way if it takes too long: its pipe closing ends the reading below.)
std::thread([process = process.hProcess, milliseconds] {
if (WaitForSingleObject(process, milliseconds) == WAIT_TIMEOUT) TerminateProcess(process, 1);
CloseHandle(process);
}).detach();
CloseHandle(process.hThread);
std::string output;
char buffer[8192];
for (DWORD got{}; ReadFile(read, buffer, sizeof(buffer), &got, nullptr) && got && output.size() < 16 * 1024 * 1024;) output.append(buffer, got);
CloseHandle(read);
return output;
}
// A song's artist and title out of what yt-dlp says of a video: a title of the form
// "artist - title" is taken apart, and otherwise the channel is the artist.
Song song_of(const Json& entry) {
Song song{entry.value("id", ""), {}, {}};
auto title = entry.value("title", "");
auto channel = entry.contains("channel") && entry.at("channel").is_string() ? entry.at("channel").string() : entry.contains("uploader") && entry.at("uploader").is_string() ? entry.at("uploader").string() : std::string();
if (channel.ends_with(" - Topic")) channel.resize(channel.size() - 8);
if (const auto dash = title.find(" - "); dash != std::string::npos && dash > 0 && dash + 3 < title.size()) {
song.artist = shown(title.substr(0, dash));
song.title = shown(title.substr(dash + 3));
} else {
song.artist = shown(channel.empty() ? std::string("YouTube") : channel);
song.title = shown(title);
}
return song;
}
// A link is one of YouTube's, and has nothing in it that a command line would take amiss.
bool good_link(const std::string& link) {
if (link.size() > 300 || std::any_of(link.begin(), link.end(), [](unsigned char c) { return c <= 0x20 || c == '"' || c == '\\' || c == '^' || c == '%' || c >= 0x7f; })) return false;
for (const char* start : {"https://www.youtube.com/", "https://youtube.com/", "https://music.youtube.com/", "https://m.youtube.com/", "https://youtu.be/"})
if (link.starts_with(start)) return true;
return false;
}
void add_link(const std::string& link) {
if (!good_link(link)) return problem("that is not a link to a YouTube video or playlist");
std::vector<std::string> arguments{"--flat-playlist", "-J", "--no-warnings"};
{
std::lock_guard lock(mutex);
for (auto& argument : sign_in()) arguments.push_back(std::move(argument));
}
arguments.push_back(link);
const auto output = run_tool(arguments, 90000);
if (!output || output->empty()) return problem("yt-dlp could not read that link");
Playlist playlist{{}, link, {}, {}};
try {
const auto json = Json::parse(*output);
playlist.name = shown(json.value("title", ""));
if (json.contains("entries") && json.at("entries").is_array()) {
const auto& entries = json.at("entries");
for (std::size_t i = 0; i < entries.size() && playlist.songs.size() < most_songs; ++i)
if (entries.at(i).is_object())
if (auto song = song_of(entries.at(i)); video_id(song.id) && !song.artist.empty() && !song.title.empty() &&
std::none_of(playlist.songs.begin(), playlist.songs.end(), [&](const Song& other) { return other.artist == song.artist && other.title == song.title; }))
playlist.songs.push_back(std::move(song));
} else if (auto song = song_of(json); video_id(song.id) && !song.artist.empty() && !song.title.empty()) {
playlist.songs.push_back(std::move(song));
}
} catch (...) {
return problem("what yt-dlp said of that link could not be read");
}
if (playlist.name.empty() || playlist.songs.empty()) return problem("there is nothing to play at that link");
{
std::lock_guard lock(mutex);
if (library.size() >= most_playlists) {
problem_text = "there is room for no more than twelve";
return;
}
library.push_back(playlist);
problem_text.clear();
}
keep();
say(logging::Level::info, std::format("added \"{}\" ({} song(s)).", playlist.name, playlist.songs.size()));
}
// The picture of a song (its video's), for a playlist's tile or for what is playing.
std::vector<std::uint8_t> picture(const std::string& id) {
const auto image = https::get_text(L"https://i.ytimg.com/vi/" + wide(id) + L"/hqdefault.jpg", 2 * 1024 * 1024, 10, agent);
return image ? std::vector<std::uint8_t>(image->begin(), image->end()) : std::vector<std::uint8_t>{};
}
// Each playlist's tile is its first song's picture.
void make_covers() {
std::vector<std::pair<std::size_t, std::string>> wanted;
{
std::lock_guard lock(mutex);
for (std::size_t i = 0; i < library.size(); ++i)
if (library[i].artwork.empty() && !library[i].songs.empty()) wanted.emplace_back(i, library[i].songs.front().id);
}
for (const auto& [index, id] : wanted) {
const auto address = spotify_cover::serve(picture(id), "youtube-" + id + ".png");
std::lock_guard lock(mutex);
if (index < library.size() && !library[index].songs.empty() && library[index].songs.front().id == id) library[index].artwork = address;
}
}
std::string hex_sha256(const std::filesystem::path& file) {
std::ifstream in(file, std::ios::binary);
const std::vector<char> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
unsigned char digest[32]{};
if (bytes.empty() || BCryptHash(BCRYPT_SHA256_ALG_HANDLE, nullptr, 0, reinterpret_cast<PUCHAR>(const_cast<char*>(bytes.data())), static_cast<ULONG>(bytes.size()), digest, sizeof(digest)) != 0) return {};
std::string hex;
for (const auto byte : digest) hex += std::format("{:02x}", byte);
return hex;
}
// yt-dlp, from its own releases, checked against the sums published beside it.
void fetch_tool() {
std::error_code ignored;
std::filesystem::create_directories(tool().parent_path(), ignored);
const auto fetched = tool().parent_path() / L"yt-dlp.fetching";
std::filesystem::remove(fetched, ignored);
tool_watch.received.store(0);
tool_watch.total.store(0);
std::atomic<bool> fetching{true};
std::thread progress([&] {
while (fetching.load()) {
const auto total = tool_watch.total.load();
fetched_percent.store(total ? static_cast<unsigned>(tool_watch.received.load() * 100 / total) : 0);
Sleep(200);
}
});
const auto result = https::get(tool_address, fetched, 96ull * 1024 * 1024, 600, agent, &tool_watch);
fetching.store(false);
progress.join();
fetched_percent.store(0);
if (!result.ok) {
std::filesystem::remove(fetched, ignored);
return problem(std::format("yt-dlp could not be fetched ({}, {})", result.http_status, result.error));
}
const auto sums = https::get_text(sums_address, 64 * 1024, 30, agent);
const auto sum = hex_sha256(fetched);
if (!sums || sum.empty() || sums->find(sum + " yt-dlp.exe") == std::string::npos) {
std::filesystem::remove(fetched, ignored);
return problem("what was fetched is not the yt-dlp its releases say it is: it was not kept");
}
std::filesystem::remove(tool(), ignored);
std::filesystem::rename(fetched, tool(), ignored);
const bool there = std::filesystem::exists(tool(), ignored);
{
std::lock_guard lock(mutex);
have_tools = there;
if (there) problem_text.clear();
}
if (there) say(logging::Level::info, "yt-dlp was fetched.");
else problem("yt-dlp could not be put in its place");
}
// QuickJS, from its own releases, when there is no runtime for yt-dlp on this computer.
void fetch_runtime() {
if (!javascript().first.empty()) return;
std::error_code ignored;
std::filesystem::create_directories(quickjs().parent_path(), ignored);
const auto fetched = quickjs().parent_path() / L"qjs.fetching";
std::filesystem::remove(fetched, ignored);
const auto result = https::get(runtime_address, fetched, 32ull * 1024 * 1024, 300, agent);
char start[2]{};
if (result.ok) std::ifstream(fetched, std::ios::binary).read(start, 2);
// (A Windows program, and of a size a runtime is.)
if (!result.ok || start[0] != 'M' || start[1] != 'Z' || std::filesystem::file_size(fetched, ignored) < 256 * 1024) {
std::filesystem::remove(fetched, ignored);
return problem("the JavaScript runtime a YouTube sign-in needs (QuickJS) could not be fetched; Node.js, when it is installed, does as well");
}
std::filesystem::rename(fetched, quickjs(), ignored);
say(logging::Level::info, "QuickJS was fetched, for songs that need the YouTube sign-in.");
}
void run() noexcept {
const bool initialized = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)); // (pictures are converted on this thread)
read_kept();
{
std::error_code ignored;
std::lock_guard lock(mutex);
have_tools = std::filesystem::exists(tool(), ignored);
have_cookies = std::filesystem::exists(cookies_file(), ignored);
}
try {
bool signed_in{};
{
std::lock_guard lock(mutex);
signed_in = have_tools && (have_cookies || !browser_name.empty());
}
if (signed_in) fetch_runtime(); // (a sign-in set by a ReSkate that did not fetch one)
make_covers();
} catch (...) {}
for (;;) {
std::string command;
{
std::unique_lock lock(mutex);
is_busy = false;
wake.wait(lock, [] { return !commands.empty(); });
command = std::move(commands.front());
commands.pop_front();
is_busy = true;
}
try {
if (command == "tools") {
fetch_tool();
fetch_runtime();
} else if (command == "runtime") fetch_runtime();
if (command.starts_with("add ")) {
add_link(command.substr(4));
make_covers();
}
profile_runtime::request_music_catalog_refresh();
} catch (...) {}
}
if (initialized) CoUninitialize();
}
void ask(std::string command) {
start();
{
std::lock_guard lock(mutex);
if (commands.size() < 8) commands.push_back(std::move(command));
}
wake.notify_one();
}
// --- playing ---
// The sound of the song that is playing, on the decoder's thread. It is taken as fast as the
// game hears it; while the player has it paused, none is taken.
void sound(const float* samples, std::uint32_t frames, std::uint32_t rate) {
const auto mine = wanted_stream.load(std::memory_order_relaxed);
sound_rate.store(rate, std::memory_order_relaxed);
// (The first of a song's sound: how long it was in coming, into the log.)
if (mine && heard_stream.exchange(mine, std::memory_order_relaxed) != mine)
say(logging::Level::info, std::format("the song's sound began {:.1f} s after it was asked for.", static_cast<double>(GetTickCount64() - begun_at.load(std::memory_order_relaxed)) / 1000.0));
for (int waited = 0; paused.load(std::memory_order_relaxed) || app_audio::ahead_frames() > rate * 15 / 100; ++waited) {
if (wanted_stream.load(std::memory_order_relaxed) != mine) return; // (another song is wanted)
if (!paused.load(std::memory_order_relaxed) && waited >= 125) return; // (the game is not listening)
Sleep(4);
}
app_audio::push(samples, frames);
}
void begin(std::size_t list, std::size_t song);
// A song is over: the next of its playlist, and round again after its last.
void ended(std::uint64_t stream, const char* how) {
try {
std::size_t list{}, next{};
{
std::lock_guard lock(mutex);
if (stream != playing_stream || list >= library.size()) return; // (it was stopped for another)
list = playing_list;
if (list >= library.size() || library[list].songs.empty()) return;
const bool failed = std::string_view(how).starts_with("failed");
failures = failed ? failures + 1 : 0;
if (failed) {
const auto& title = library[list].songs[playing_song % library[list].songs.size()].title;
say(logging::Level::warning, std::format("\"{}\" {}.", title, how));
// (The usual reason: YouTube wants a sign-in before it plays it.)
problem_text = "\"" + title + "\" could not be played and was passed over. YouTube asks for a sign-in for private and age-restricted songs: add your cookies.txt below.";
}
if (failures >= 4) {
problem_text = "songs are not playing (yt-dlp may need to be fetched again, or YouTube is not letting them be)";
return;
}
next = (playing_song + 1) % library[list].songs.size();
}
begin(list, next);
} catch (...) {}
}
void fetch_picture(std::string id, std::uint64_t of) {
try {
auto image = picture(id);
if (image.empty()) return;
std::lock_guard lock(mutex);
if (track.revision != of) return;
track.artwork = std::move(image);
++track.artwork_revision;
changes.fetch_add(1, std::memory_order_relaxed);
} catch (...) {}
}
void begin(std::size_t list, std::size_t song) {
#if DINGOSDK_SPOTIFY_DEVICE
std::string id;
std::uint64_t of{};
{
std::lock_guard lock(mutex);
if (!have_tools || list >= library.size() || library[list].songs.empty()) return;
song %= library[list].songs.size();
if (playing_stream && list == playing_list && song == playing_song && GetTickCount64() - begun_at.load(std::memory_order_relaxed) < 3000) return;
begun_at.store(GetTickCount64(), std::memory_order_relaxed);
const auto& chosen = library[list].songs[song];
id = chosen.id;
playing_list = list;
playing_song = song;
track.available = true;
track.spotify = false;
track.app = "YouTube Music";
track.can_next = track.can_previous = track.can_pause = track.can_play = true;
track.artist = chosen.artist;
track.title = chosen.title;
track.album.clear();
track.artwork.clear();
track.playing = true;
of = ++track.revision;
paused.store(false);
const auto program = narrow(tool().wstring());
std::string arguments = "-f\n140/bestaudio[ext=m4a]\n--no-playlist\n--quiet\n--no-warnings\n";
for (const auto& argument : sign_in()) arguments += argument + "\n";
arguments += "-o\n-\nhttps://www.youtube.com/watch?v=" + id;
// (The number the stream will have is known before it is begun: its sound is asked for it.)
playing_stream = reskate_stream_play(program.c_str(), arguments.c_str(), &sound, &ended);
wanted_stream.store(playing_stream, std::memory_order_relaxed);
}
changes.fetch_add(1, std::memory_order_relaxed);
try {
std::thread(fetch_picture, id, of).detach();
} catch (...) {}
#else
(void)list;
(void)song;
#endif
}
} // namespace
void start() noexcept {
if (started.exchange(true)) return;
try {
std::thread(run).detach();
} catch (...) {}
}
State state() {
std::lock_guard lock(mutex);
State out;
#if DINGOSDK_SPOTIFY_DEVICE
out.available = true;
#endif
out.tools = have_tools;
out.busy = is_busy;
out.percent = fetched_percent.load();
out.problem = problem_text;
out.browser = browser_name;
out.cookies = have_cookies;
out.runtime = javascript().first;
out.playlists = library;
return out;
}
std::vector<Playlist> playlists() {
std::lock_guard lock(mutex);
return have_tools ? library : std::vector<Playlist>{};
}
void fetch_tools() { ask("tools"); }
void add(const std::string& link) { ask("add " + link); }
void remove(std::size_t playlist) {
{
std::lock_guard lock(mutex);
if (playlist >= library.size()) return;
library.erase(library.begin() + static_cast<std::ptrdiff_t>(playlist));
}
keep();
profile_runtime::request_music_catalog_refresh();
}
void set_browser(const std::string& browser) {
if (!good_browser(browser)) return;
{
std::lock_guard lock(mutex);
browser_name = browser;
problem_text.clear();
failures = 0;
}
keep();
if (!browser.empty()) ask("runtime");
}
bool set_cookies(const std::string& text) {
std::error_code ignored;
if (text.empty()) {
std::filesystem::remove(cookies_file(), ignored);
std::lock_guard lock(mutex);
have_cookies = false;
return true;
}
// A Netscape cookie file: its first line says so, and it has YouTube's cookies in it.
if (text.size() > 1024 * 1024 || !(text.starts_with("# Netscape HTTP Cookie File") || text.starts_with("# HTTP Cookie File")) || text.find("youtube.com") == std::string::npos ||
text.find('\0') != std::string::npos)
return false;
std::filesystem::create_directories(home(), ignored);
{
std::ofstream out(cookies_file(), std::ios::binary | std::ios::trunc);
out << text;
if (!out) return false;
}
{
std::lock_guard lock(mutex);
have_cookies = true;
problem_text.clear();
failures = 0;
}
ask("runtime");
return true;
}
bool on() noexcept { return is_on.load(std::memory_order_relaxed); }
void set_on(bool on) noexcept {
if (is_on.exchange(on, std::memory_order_relaxed) && !on) {
#if DINGOSDK_SPOTIFY_DEVICE
// (Its song is stopped as another kind of station takes over.)
wanted_stream.store(0, std::memory_order_relaxed);
reskate_stream_stop();
#endif
std::lock_guard lock(mutex);
playing_stream = 0;
track.playing = false;
}
}
unsigned rate() noexcept { return sound_rate.load(std::memory_order_relaxed); }
void play(std::size_t playlist, const std::string& song) {
std::size_t at{};
{
std::lock_guard lock(mutex);
if (playlist >= library.size()) return;
// (The same playlist again, with no song asked for, goes on from where it was.)
if (song.empty() && playlist == playing_list && playing_stream) {
paused.store(false);
track.playing = true;
changes.fetch_add(1, std::memory_order_relaxed);
return;
}
for (std::size_t i = 0; i < library[playlist].songs.size(); ++i)
if (library[playlist].songs[i].id == song) at = i;
failures = 0;
}
begin(playlist, at);
}
external_player::Track now() {
std::lock_guard lock(mutex);
return track;
}
std::uint64_t revision() noexcept { return changes.load(std::memory_order_relaxed); }
void send(external_player::Command command) noexcept {
try {
using external_player::Command;
std::size_t list{}, song{}, count{};
{
std::lock_guard lock(mutex);
list = playing_list;
song = playing_song;
count = list < library.size() ? library[list].songs.size() : 0;
if (command == Command::pause || command == Command::play || command == Command::toggle) {
const bool pause = command == Command::pause || (command == Command::toggle && !paused.load());
paused.store(pause);
track.playing = !pause && playing_stream != 0;
changes.fetch_add(1, std::memory_order_relaxed);
return;
}
failures = 0;
}
if (!count) return;
begin(list, command == Command::next ? (song + 1) % count : (song + count - 1) % count);
} catch (...) {}
}
} // namespace dingosdk::youtube_music
+71
View File
@@ -0,0 +1,71 @@
#pragma once
#include "external_player.h"
#include <string>
#include <vector>
// "YouTube Music" in the game's music screen: videos and playlists the player has added by
// their links (in the setup page, spotify_dashboard.h), each a tile there with its songs as
// rows. A song's sound is fetched by yt-dlp, which this runs, and decoded by the library that
// is built into the DLL (SpotifyHelper/src/stream.rs); it is heard through the game as
// Spotify's is.
//
// yt-dlp is not part of ReSkate: the player has it fetched from the setup page, from its own
// releases on GitHub, into <Local AppData>\ReSkate\bin. What is added is kept in
// <Local AppData>\ReSkate\youtube-music.json.
namespace dingosdk::youtube_music {
// What a station's playlist is called among the stations (spotify_station.h): this and its
// number; what a row's track is called: this and the video's id.
inline constexpr char station_prefix[] = "~yt:";
inline constexpr char track_prefix[] = "yt:";
// What the ids of its row's playlists in the music screen begin with.
inline constexpr char playlist_prefix[] = "youtube:";
struct Song {
std::string id, artist, title;
};
struct Playlist {
std::string name, url;
std::string artwork; // served by the artwork server, or empty
std::vector<Song> songs;
};
struct State {
bool available{}; // the DLL was built with the library that decodes
bool tools{}; // yt-dlp is there
bool busy{};
unsigned percent{}; // of yt-dlp fetched, while it is being
std::string problem;
std::string browser; // whose YouTube sign-in yt-dlp uses, or empty
bool cookies{}; // a cookies.txt of the player's is kept, and is what it uses
std::string runtime; // the JavaScript runtime yt-dlp is given ("node", "deno"), or empty: a sign-in does not work without one
std::vector<Playlist> playlists;
};
// Reads what was kept, once, on a thread of its own, which does what takes time below.
void start() noexcept;
State state();
std::vector<Playlist> playlists();
// Fetches yt-dlp. Adds what a link is of (a video, a playlist). Takes one out.
void fetch_tools();
void add(const std::string& link);
void remove(std::size_t playlist);
// The browser whose YouTube sign-in yt-dlp is to use ("firefox", "edge", "chrome", "brave",
// "opera", "vivaldi"; empty for none): YouTube asks for one before it plays songs it has
// marked as not for everyone.
void set_browser(const std::string& browser);
// The same sign-in as a file: a Netscape cookies.txt the player exported from their browser
// (what private and age-restricted songs need), kept as
// <Local AppData>\ReSkate\youtube-cookies.txt and used before any browser. Empty takes it away.
// False when what was given is no such file.
bool set_cookies(const std::string& text);
// Whether one of its stations is the one that is on.
bool on() noexcept;
void set_on(bool on) noexcept;
// The rate of the sound it is giving, in Hz.
unsigned rate() noexcept;
// Plays a playlist, from a song of it when given (its video's id), else from where it was.
void play(std::size_t playlist, const std::string& song = {});
// What is playing, as external_player gives it; and play, pause, next and previous.
external_player::Track now();
std::uint64_t revision() noexcept;
void send(external_player::Command command) noexcept;
}
@@ -328,6 +328,8 @@ void update_local_customization() noexcept {
try { update_object_categories(); }
catch (...) { dingosdk::logging::event(dingosdk::logging::Channel::profile, "{\"event\":\"local_object_categories_failed\",\"operation\":\"update\"}"); }
update_music_catalog();
try { update_music_catalog_refresh(); } catch (...) {}
try { update_spotify_cover(); } catch (...) {}
update_music_shelf();
item_browser::update(s.base);
try {
+148
View File
@@ -0,0 +1,148 @@
# ReSkate music sources: setup
ReSkate can play **Spotify**, **YouTube Music** and **any app on your PC** through the in-game
music player. Each one shows up as its own row in the game's music screen, with tiles you pick
like any other playlist. Pause, next, track name and cover art all work from the game.
Everything is set up from one page in your browser.
## Open the setup page
1. Start the game.
2. Press **~** to open the console.
3. Type `spotify setup` and press Enter.
Your browser opens `http://127.0.0.1:8421/`. The page only works on the PC the game is running
on, and only while the game is running.
> A new row or tile can take a game restart to show up. If you set something up and don't see
> it in the music screen, restart the game once.
---
## Spotify
**You need:** Spotify Premium. A free account cannot play to the game: if you pick **ReSkate**
with one, the game tells you so on the setup page and forgets it.
### Play Spotify through the game
1. Start the game. Windows may ask whether to allow the game through the firewall: allow it on
private networks. Spotify uses this to find the game.
2. Open Spotify on your phone or PC, on the same network as the game.
3. Open Spotify's device list and pick **ReSkate**.
That's it. You only do this once; after that the game signs in by itself.
In the game's music screen, open the **Spotify** row and pick the **Spotify** tile. Whatever
Spotify was playing carries on through the game. If nothing was playing, it starts your Liked
Songs on shuffle.
### Show your own playlists (optional)
To get your playlists and Liked Songs as tiles, with their tracks listed, ReSkate needs a
Spotify developer app. This is free and takes a couple of minutes.
1. Go to <https://developer.spotify.com/dashboard> and sign in.
2. Create an app. Any name and description will do.
3. Add this redirect URI exactly: `http://127.0.0.1:47824/callback`
4. Copy the app's **Client ID**.
5. On the setup page, under **Your playlists**, paste the Client ID and press **Save**.
6. Press **Sign in** and approve it in the browser tab that opens.
Your first 50 playlists appear, each with its first 50 tracks. Liked Songs shows your 50 most
recent. Pressing a track in the game starts Spotify at that track.
---
## YouTube Music
**You need:** nothing installed beforehand. ReSkate downloads one small tool for you.
> Playing YouTube this way is against YouTube's terms of service. Whether to use it is up to you.
1. On the setup page, under **YouTube Music**, press **Set up YouTube Music**. This downloads
`yt-dlp` (about 18 MB) from its official GitHub releases.
2. Paste a link to a YouTube or YouTube Music **video or playlist** and press **Add**.
Up to 100 songs per playlist, 12 playlists.
3. In the game's music screen, open the **YouTube Music** row and pick your playlist, or press
a song in it.
Each song takes about two seconds to start.
### Songs that get skipped
Private and age-restricted songs only play if YouTube can see you're signed in. If songs are
being skipped:
1. In your browser, signed in to YouTube, install an extension such as
**Get cookies.txt LOCALLY**.
2. On youtube.com, use it to export a `cookies.txt`.
3. On the setup page, under **Private and age-restricted songs**, choose that file.
ReSkate downloads one more small tool (QuickJS, about 2 MB) the first time you add a sign-in;
there is nothing else to install. With a sign-in set, each song takes about seven seconds to
start instead of two. If you have **Node.js** installed, ReSkate uses that instead and it is
about twice as fast.
Treat `cookies.txt` like a password: it is your YouTube sign-in. It stays on your PC, and the
**Remove cookies.txt** button deletes it. Cookies expire, so if songs start skipping again
weeks later, export a fresh file.
---
## External audio
Plays the sound of any app on your PC through the game: a browser, a media player, another
music service.
1. Start the app and get it **playing something**. Only apps making sound are listed.
2. On the setup page, under **External audio**, pick the app and press **Use this app**.
3. In the game's music screen, open the **External Audio** row and pick its tile.
While it plays through the game, the app's own volume is turned right down so you don't hear
it twice. It's put back when you leave the tile or close the game.
Track name, cover, pause and next work for apps that support Windows media controls (the same
ones your keyboard's play/pause key uses). Browsers and most media players do. An app that
doesn't will still be heard through the game, but can't be paused or skipped from it.
---
## Sound settings
At the bottom of the setup page:
- **Loudness:** how loud the music is through the game.
- **Speakers in the world play Spotify too:** when on, speakers in the world play what you're
playing, and grabbing music from one keeps you on your own music.
---
## Troubleshooting
| Problem | Try this |
| --- | --- |
| "ReSkate" isn't in Spotify's device list | Make sure the game is running, both are on the same network, and the game was allowed through the firewall. |
| Spotify tile is silent | Type `spotify` in the console. It shows whether a Spotify account is on the device. If not, pick **ReSkate** in Spotify's device list again. |
| New playlist or row isn't in the music screen | Restart the game. |
| A newly added playlist plays the wrong music | Press a song in it instead of the tile, or restart the game. |
| YouTube songs keep getting skipped | See "Songs that get skipped" above. |
| YouTube stopped working altogether | YouTube changes things often. Close the game, delete `yt-dlp.exe` from `%LOCALAPPDATA%\ReSkate\bin`, start the game and press **Set up YouTube Music** again to get the latest version. |
| The setup page says the game isn't answering | The game has been closed. The page only works while it's running. |
## Where things are kept
Everything is on your PC, in `%LOCALAPPDATA%\ReSkate`:
| File or folder | What it is |
| --- | --- |
| `spotify-device\` | the remembered Spotify sign-in for the in-game device |
| `spotify.json` | your Client ID and playlist sign-in (encrypted for your Windows account) |
| `youtube-music.json` | your saved YouTube playlists |
| `youtube-cookies.txt` | your YouTube sign-in, if you added one |
| `external-audio.json` | which app External Audio uses |
| `bin\yt-dlp.exe` | the YouTube tool |
| `bin\qjs.exe` | QuickJS, which the YouTube tool needs once a sign-in is set |
Deleting any of these resets that part.
+18
View File
@@ -4,6 +4,9 @@
#include "Extension/Assets/live_mods.h"
#include "Extension/Assets/map_download.h"
#include "Extension/Assets/map_icons.h"
#include "Extension/Music/spotify_station.h"
#include "Extension/Multiplayer/Hud/game_ui_state.h"
#include "Extension/Music/music_inject.h"
#include "Extension/World/texture_handle_limit.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Core/Profiling/profiler.h"
@@ -1216,6 +1219,21 @@ void tick(std::uintptr_t client, std::uintptr_t update) {
dingosdk::map_download::tick();
dingosdk::map_icons::tick();
dingosdk::texture_handle_limit::tick();
// (Whether a menu of the game's is up, the music screen among them: a playlist changed then
// is the player's own choice, and one changed in play is a speaker's music being grabbed.)
{
const bool menu = dingosdk::multiplayer::sample_game_ui_state(r.base).in_menu;
static bool was{};
if (menu != was) {
was = menu;
try {
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::music, "Trace: a menu of the game's is {}.", menu ? "up" : "no longer up");
} catch (...) {}
}
dingosdk::spotify_station::set_music_screen(menu);
}
dingosdk::spotify_station::tick();
dingosdk::music_inject::tick();
}
dingosdk::multiplayer::refresh_identity_lists();
// How much memory the game holds, beside what a session adds to it: said when it has
+12
View File
@@ -9,6 +9,12 @@
#include "Extension/World/load_error_guard.h"
#include "Extension/World/mesh_unload_guard.h"
#include "Extension/World/stack_check_report.h"
#include "Extension/Music/music_inject.h"
#include "Extension/Music/spotify_web.h"
#include "Extension/Music/spotify_device.h"
#include "Extension/Music/spotify_dashboard.h"
#include "Extension/Music/youtube_music.h"
#include "Extension/Music/spotify_rows.h"
#include "Extension/World/texture_handle_limit.h"
#include "Extension/Console/commands.h"
#include "Extension/UI/ui_pointer_skip.h"
@@ -323,6 +329,12 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() {
(void)dingosdk::load_error_guard::install(r.base);
(void)dingosdk::texture_handle_limit::install(r.base);
(void)dingosdk::stack_check_report::install(r.base);
(void)dingosdk::music_inject::install(r.base);
(void)dingosdk::spotify_rows::install(r.base);
dingosdk::spotify_device::start(); // (the game as a device in the player's Spotify app)
dingosdk::spotify_web::start();
dingosdk::youtube_music::start(); // (the player's YouTube playlists, read before the music screen is built)
dingosdk::spotify_dashboard::start(); // (the page in the browser that sets Spotify up) // (the player's playlists, fetched before the music screen is built)
dingosdk::console::start_memory_trace_from_environment();
const bool fixed_stop_provider = (global_offline_hook && progression_guard_armed) &&
dingosdk::prepare_fixed_stop_entitlement_provider(r.base);
+3
View File
@@ -0,0 +1,3 @@
# ReSkate.dll is built with the static C runtime (/MT): so is this.
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
+3296
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "reskate-spotify"
version = "0.1.0"
edition = "2021"
description = "ReSkate's Spotify Connect device, linked into ReSkate.dll: receives what Spotify plays and hands it to the game"
license = "MIT"
[lib]
name = "reskate_spotify"
path = "src/lib.rs"
crate-type = ["staticlib", "rlib"]
[dependencies]
librespot-core = "0.8.0"
librespot-playback = { version = "0.8.0", default-features = false }
librespot-connect = "0.8.0"
librespot-discovery = "0.8.0"
librespot-metadata = "0.8.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
futures-util = "0.3"
log = "0.4"
sha1 = "0.10"
hex = "0.4"
# (For sound that is not Spotify's: a program's output, decoded. The version librespot uses.)
symphonia = { version = "0.5.5", default-features = false, features = ["aac", "isomp4", "mp3", "ogg", "vorbis"] }
[profile.release]
opt-level = 2
strip = true
+30
View File
@@ -0,0 +1,30 @@
//! By hand: runs a program and says how much sound came of its output.
//! cargo run --release --example decode -- <program> <argument>...
use std::ffi::{c_char, CStr, CString};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
static FRAMES: AtomicU64 = AtomicU64::new(0);
static RATE: AtomicU64 = AtomicU64::new(0);
static DONE: AtomicBool = AtomicBool::new(false);
extern "C" fn sound(_samples: *const f32, frames: u32, rate: u32) {
FRAMES.fetch_add(u64::from(frames), Ordering::Relaxed);
RATE.store(u64::from(rate), Ordering::Relaxed);
}
extern "C" fn ended(stream: u64, how: *const c_char) {
println!("stream {stream}: {}", unsafe { CStr::from_ptr(how) }.to_string_lossy());
DONE.store(true, Ordering::Relaxed);
}
fn main() {
let mut arguments = std::env::args().skip(1);
let program = CString::new(arguments.next().expect("a program")).unwrap();
let rest = CString::new(arguments.collect::<Vec<_>>().join("\n")).unwrap();
let stream = reskate_spotify::stream::reskate_stream_play(program.as_ptr(), rest.as_ptr(), sound, ended);
println!("began stream {stream}");
while !DONE.load(Ordering::Relaxed) {
std::thread::sleep(std::time::Duration::from_millis(50));
}
let (frames, rate) = (FRAMES.load(Ordering::Relaxed), RATE.load(Ordering::Relaxed));
println!("{frames} frames at {rate} Hz: {:.1} s", frames as f64 / rate.max(1) as f64);
}
+289
View File
@@ -0,0 +1,289 @@
//! ReSkate's Spotify Connect device (librespot), built into ReSkate.dll.
//!
//! The game is a device in the player's Spotify app ("ReSkate"), and what Spotify plays on it
//! goes to the game (Extension/Music/spotify_device.cpp), which starts this once:
//! `sound` is given the sound as it is to be heard: 32-bit float samples, stereo, 44100 Hz.
//! It returns when the game has room for more, which is what keeps Spotify at the
//! speed of the game.
//! `told` is given a line for each thing the game is told, with tabs between its parts:
//! READY the device is in Spotify's list (on this network)
//! CONNECTED <user> a Spotify account is using it
//! DISCONNECTED
//! TRACK <uri> <artist> <title> <cover address> <milliseconds>
//! STATE playing|paused|stopped
//! LOG <text> this library's own log
//! ENDED it has stopped for good
//! `reskate_spotify_ask` takes a line for each thing the game asks:
//! activate play here, taking over from wherever Spotify plays
//! play | pause | next | prev
//! load <context uri> [<track uri>] a playlist or album, at a track of it
//! shuffle <context uri> the same, in no order and over again
//! tracks <index> <track uri>... these tracks, at one of them
//! quit
//!
//! Who the account is comes from the Spotify app: picking the device there hands it over, and
//! it is kept (the cache folder) so the device is there the next time without being picked.
pub mod stream;
use std::ffi::{c_char, CStr, CString};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use futures_util::StreamExt;
use librespot_connect::{ConnectConfig, LoadContextOptions, LoadRequest, LoadRequestOptions, Options, PlayingTrack, Spirc};
use librespot_core::authentication::Credentials;
use librespot_core::cache::Cache;
use librespot_core::config::SessionConfig;
use librespot_core::session::Session;
use librespot_discovery::{DeviceType, Discovery};
use librespot_metadata::audio::UniqueFields;
use librespot_playback::audio_backend::{Sink, SinkError, SinkResult};
use librespot_playback::config::{Bitrate, PlayerConfig};
use librespot_playback::convert::Converter;
use librespot_playback::decoder::AudioPacket;
use librespot_playback::mixer::softmixer::SoftMixer;
use librespot_playback::mixer::{Mixer, MixerConfig};
use librespot_playback::player::{Player, PlayerEvent};
use sha1::{Digest, Sha1};
use tokio::sync::mpsc;
type Sound = extern "C" fn(samples: *const f32, frames: u32);
type Told = extern "C" fn(line: *const c_char);
static SOUND: OnceLock<Sound> = OnceLock::new();
static TOLD: OnceLock<Told> = OnceLock::new();
static ASK: OnceLock<mpsc::UnboundedSender<String>> = OnceLock::new();
/// What the game is told: a line.
fn tell(parts: &[&str]) {
let clean: Vec<String> = parts.iter().map(|part| part.replace(['\t', '\r', '\n', '\0'], " ")).collect();
if let (Some(told), Ok(line)) = (TOLD.get(), CString::new(clean.join("\t"))) {
told(line.as_ptr());
}
}
/// This library's own log, told to the game as well.
struct Log;
impl log::Log for Log {
fn enabled(&self, metadata: &log::Metadata) -> bool {
metadata.level() <= log::Level::Info
}
fn log(&self, record: &log::Record) {
if self.enabled(record.metadata()) {
tell(&["LOG", &format!("{}: {}", record.level(), record.args())]);
}
}
fn flush(&self) {}
}
/// The sound goes to the game as it comes.
struct GameSink;
impl Sink for GameSink {
fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()> {
let samples = packet.samples().map_err(|error| SinkError::OnWrite(error.to_string()))?;
let converted = converter.f64_to_f32(samples);
if let Some(sound) = SOUND.get() {
sound(converted.as_ptr(), (converted.len() / 2) as u32);
}
Ok(())
}
}
fn told(event: PlayerEvent) {
match event {
PlayerEvent::TrackChanged { audio_item } => {
let artist = match &audio_item.unique_fields {
UniqueFields::Track { artists, .. } => artists.first().map(|artist| artist.name.clone()).unwrap_or_default(),
UniqueFields::Episode { show_name, .. } => show_name.clone(),
_ => String::new(),
};
let cover = audio_item.covers.iter().max_by_key(|cover| cover.width).map(|cover| cover.url.clone()).unwrap_or_default();
tell(&["TRACK", &audio_item.uri, &artist, &audio_item.name, &cover, &audio_item.duration_ms.to_string()]);
}
PlayerEvent::Playing { .. } => tell(&["STATE", "playing"]),
PlayerEvent::Paused { .. } => tell(&["STATE", "paused"]),
PlayerEvent::Stopped { .. } => tell(&["STATE", "stopped"]),
PlayerEvent::SessionConnected { user_name, .. } => tell(&["CONNECTED", &user_name]),
PlayerEvent::SessionDisconnected { .. } => tell(&["DISCONNECTED"]),
_ => {}
}
}
async fn run(name: String, cache_folder: Option<PathBuf>, mut asked: mpsc::UnboundedReceiver<String>) {
let device_id = hex::encode(Sha1::digest(name.as_bytes()));
let mut session_config = SessionConfig::default();
session_config.device_id = device_id.clone();
let cache = cache_folder.and_then(|folder| Cache::new(Some(folder.clone()), Some(folder), None::<PathBuf>, None).ok());
// In Spotify's list of devices on this network: picking it there hands over the account.
let mut discovery = match Discovery::builder(device_id, session_config.client_id.clone()).name(name.clone()).device_type(DeviceType::Computer).launch() {
Ok(discovery) => {
tell(&["READY"]);
Some(discovery)
}
Err(error) => {
log::error!("the device could not be put in Spotify's list: {error}");
None
}
};
let mut credentials: Option<Credentials> = cache.as_ref().and_then(|cache| cache.credentials());
loop {
// Until there is an account: one kept from before, or one handed over now.
let account = match credentials.take() {
Some(account) => account,
None => loop {
tokio::select! {
found = async { match discovery.as_mut() { Some(discovery) => discovery.next().await, None => std::future::pending().await } } => {
if let Some(account) = found { break account; }
}
line = asked.recv() => match line.as_deref() {
Some("quit") | None => return,
_ => {}
},
}
},
};
let session = Session::new(session_config.clone(), cache.clone());
let mixer: Arc<dyn Mixer> = match SoftMixer::open(MixerConfig::default()) {
Ok(mixer) => Arc::new(mixer),
Err(error) => {
log::error!("no mixer: {error}");
return;
}
};
let player_config = PlayerConfig { bitrate: Bitrate::Bitrate320, ..PlayerConfig::default() };
let player = Player::new(player_config, session.clone(), mixer.get_soft_volume(), || Box::new(GameSink));
let mut events = player.get_player_event_channel();
let connect_config = ConnectConfig { name: name.clone(), device_type: DeviceType::Computer, initial_volume: u16::MAX, ..ConnectConfig::default() };
let (spirc, task) = match Spirc::new(connect_config, session.clone(), account, player.clone(), mixer).await {
Ok(made) => made,
Err(error) => {
// (An account kept from before that Spotify no longer takes: it is asked for anew.)
log::error!("Spotify did not take the account: {error}");
tell(&["DISCONNECTED"]);
tokio::time::sleep(Duration::from_secs(3)).await;
continue;
}
};
// (With the account taken, the device is the player's to use.)
tell(&["CONNECTED", &session.username()]);
tokio::pin!(task);
loop {
tokio::select! {
_ = &mut task => {
tell(&["DISCONNECTED"]);
break;
}
event = events.recv() => match event {
Some(event) => told(event),
None => break,
},
found = async { match discovery.as_mut() { Some(discovery) => discovery.next().await, None => std::future::pending().await } } => {
// Another account was handed over: this one is let go of.
if let Some(account) = found {
credentials = Some(account);
let _ = spirc.shutdown();
}
}
line = asked.recv() => {
let Some(line) = line else { return };
let mut words = line.split_whitespace();
let result = match words.next() {
Some("activate") => spirc.activate(),
Some("play") => spirc.play(),
Some("pause") => spirc.pause(),
Some("next") => spirc.next(),
Some("prev") => spirc.prev(),
Some("load") => match words.next() {
Some(context) => {
let playing_track = words.next().map(|track| PlayingTrack::Uri(track.to_string()));
// (Loading is for the device that is playing: this is made it first.)
let _ = spirc.activate();
tokio::time::sleep(Duration::from_millis(400)).await;
spirc.load(LoadRequest::from_context_uri(context.to_string(), LoadRequestOptions { start_playing: true, seek_to: 0, context_options: None, playing_track }))
}
None => Ok(()),
},
Some("shuffle") => match words.next() {
Some(context) => {
let _ = spirc.activate();
tokio::time::sleep(Duration::from_millis(400)).await;
let context_options = Some(LoadContextOptions::Options(Options { shuffle: true, repeat: true, repeat_track: false }));
spirc.load(LoadRequest::from_context_uri(context.to_string(), LoadRequestOptions { start_playing: true, seek_to: 0, context_options, playing_track: None }))
}
None => Ok(()),
},
Some("tracks") => {
let index = words.next().and_then(|index| index.parse::<u32>().ok()).unwrap_or(0);
let tracks: Vec<String> = words.map(str::to_string).collect();
if tracks.is_empty() {
Ok(())
} else {
let _ = spirc.activate();
tokio::time::sleep(Duration::from_millis(400)).await;
spirc.load(LoadRequest::from_tracks(tracks, LoadRequestOptions { start_playing: true, seek_to: 0, context_options: None, playing_track: Some(PlayingTrack::Index(index)) }))
}
}
Some("quit") => {
let _ = spirc.shutdown();
return;
}
_ => Ok(()),
};
if let Err(error) = result {
log::warn!("'{line}' was not done: {error}");
}
}
}
}
}
}
fn text(pointer: *const c_char) -> Option<String> {
if pointer.is_null() {
return None;
}
// SAFETY: the game passes a string that ends in a zero and outlives the call.
unsafe { CStr::from_ptr(pointer) }.to_str().ok().map(str::to_string)
}
/// Starts the device, once, on threads of its own. `name` is what it is called in Spotify,
/// `cache` the folder the account is kept in (may be null). Returns 1 when it was started.
#[no_mangle]
pub extern "C" fn reskate_spotify_start(name: *const c_char, cache: *const c_char, sound: Sound, told: Told) -> i32 {
let (ask, asked) = mpsc::unbounded_channel::<String>();
if ASK.set(ask).is_err() {
return 0;
}
let _ = SOUND.set(sound);
let _ = TOLD.set(told);
let _ = log::set_logger(&Log).map(|()| log::set_max_level(log::LevelFilter::Info));
let name = text(name).unwrap_or_else(|| "ReSkate".to_string());
let cache = text(cache).map(PathBuf::from);
let started = std::thread::Builder::new().name("ReSkate Spotify".to_string()).spawn(move || {
// (Nothing that goes wrong here is to take the game with it.)
let outcome = catch_unwind(AssertUnwindSafe(|| match tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build() {
Ok(runtime) => runtime.block_on(run(name, cache, asked)),
Err(error) => log::error!("no runtime: {error}"),
}));
if outcome.is_err() {
tell(&["LOG", "ERROR: the Spotify device stopped on a fault of its own"]);
}
tell(&["ENDED"]);
});
i32::from(started.is_ok())
}
/// Asks the device for something (see the top of this file).
#[no_mangle]
pub extern "C" fn reskate_spotify_ask(line: *const c_char) {
if let (Some(ask), Some(line)) = (ASK.get(), text(line)) {
let _ = ask.send(line.trim().to_string());
}
}
+126
View File
@@ -0,0 +1,126 @@
//! Sound that is not Spotify's: a program is run and what it writes to its output (an M4A,
//! MP3 or Ogg file, as it comes) is decoded and handed to the game. ReSkate's YouTube Music
//! runs yt-dlp this way (Extension/Music/youtube_music.cpp).
use std::ffi::{c_char, CStr, CString};
use std::io::Read;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL};
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::{MediaSourceStream, ReadOnlySource};
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
/// The sound: frames of two floats, at `rate` Hz. It returns when the game has room for more.
pub type Sound = extern "C" fn(samples: *const f32, frames: u32, rate: u32);
/// How a stream came to its end: "ended", or "failed <why>".
pub type Ended = extern "C" fn(stream: u64, how: *const c_char);
/// The stream that is wanted: one that is not it stops at its next piece of sound.
static WANTED: AtomicU64 = AtomicU64::new(0);
fn decode(source: impl Read + Send + Sync + 'static, stream: u64, sound: Sound) -> Result<(), String> {
let media = MediaSourceStream::new(Box::new(ReadOnlySource::new(source)), Default::default());
let probed = symphonia::default::get_probe()
.format(&Hint::new(), media, &FormatOptions::default(), &MetadataOptions::default())
.map_err(|error| format!("it is no sound this can read ({error})"))?;
let mut format = probed.format;
let track = format.tracks().iter().find(|track| track.codec_params.codec != CODEC_TYPE_NULL).ok_or("it has no sound in it")?;
let track_id = track.id;
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.map_err(|error| format!("its sound is of a kind this cannot decode ({error})"))?;
let mut stereo: Vec<f32> = Vec::new();
let mut samples: Option<SampleBuffer<f32>> = None;
loop {
if WANTED.load(Ordering::Relaxed) != stream {
return Ok(());
}
let packet = match format.next_packet() {
Ok(packet) => packet,
// (The end of what the program wrote.)
Err(symphonia::core::errors::Error::IoError(_)) => return Ok(()),
Err(error) => return Err(error.to_string()),
};
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(decoded) => decoded,
// (A piece that cannot be decoded is passed over.)
Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
Err(error) => return Err(error.to_string()),
};
let spec = *decoded.spec();
let channels = spec.channels.count().max(1);
let buffer = samples.get_or_insert_with(|| SampleBuffer::<f32>::new(decoded.capacity() as u64, spec));
buffer.copy_interleaved_ref(decoded);
let interleaved = buffer.samples();
let frames = interleaved.len() / channels;
stereo.clear();
for frame in 0..frames {
let left = interleaved[frame * channels];
stereo.push(left);
stereo.push(if channels > 1 { interleaved[frame * channels + 1] } else { left });
}
if frames > 0 {
sound(stereo.as_ptr(), frames as u32, spec.rate);
}
}
}
fn text(pointer: *const c_char) -> Option<String> {
if pointer.is_null() {
return None;
}
// SAFETY: the game passes a string that ends in a zero and outlives the call.
unsafe { CStr::from_ptr(pointer) }.to_str().ok().map(str::to_string)
}
/// Runs `program` with `arguments` (one to a line) and plays what it writes, in the place of
/// any stream before it. Returns the stream's number (0 when it could not be begun), which
/// `ended` is told with when it is over.
#[no_mangle]
pub extern "C" fn reskate_stream_play(program: *const c_char, arguments: *const c_char, sound: Sound, ended: Ended) -> u64 {
let (Some(program), Some(arguments)) = (text(program), text(arguments)) else { return 0 };
let stream = WANTED.fetch_add(1, Ordering::Relaxed) + 1;
let begun = std::thread::Builder::new().name("ReSkate stream".to_string()).spawn(move || {
let outcome = std::panic::catch_unwind(move || {
let mut command = Command::new(&program);
command.args(arguments.lines().filter(|line| !line.is_empty())).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
let mut child = command.spawn().map_err(|error| format!("{program} could not be run ({error})"))?;
let output = child.stdout.take().ok_or("it has no output")?;
let result = decode(output, stream, sound);
let _ = child.kill();
let _ = child.wait();
result
});
let how = match outcome {
Ok(Ok(())) => "ended".to_string(),
Ok(Err(why)) => format!("failed {why}"),
Err(_) => "failed on a fault of its own".to_string(),
};
if let Ok(how) = CString::new(how.replace('\0', " ")) {
ended(stream, how.as_ptr());
}
});
if begun.is_ok() {
stream
} else {
0
}
}
/// Stops the stream that is playing, if any.
#[no_mangle]
pub extern "C" fn reskate_stream_stop() {
WANTED.fetch_add(1, Ordering::Relaxed);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+59
View File
@@ -71,6 +71,17 @@ add_library(dingosdk_runtime SHARED
Extension/Settings/engine_tweaks.cpp
Extension/Settings/replay_guard.cpp
Extension/World/load_error_guard.cpp
Extension/Music/external_player.cpp
Extension/Music/spotify_station.cpp
Extension/Music/spotify_cover.cpp
Extension/Music/spotify_web.cpp
Extension/Music/spotify_device.cpp
Extension/Music/spotify_dashboard.cpp
Extension/Music/external_audio.cpp
Extension/Music/youtube_music.cpp
Extension/Music/spotify_rows.cpp
Extension/Music/app_audio_capture.cpp
Extension/Music/music_inject.cpp
Extension/World/mesh_unload_guard.cpp
Extension/World/stack_check_report.cpp
Extension/World/texture_handle_limit.cpp
@@ -219,6 +230,40 @@ target_link_libraries(dingosdk_custom_level_manifest PUBLIC dingosdk_json)
target_link_libraries(dingosdk_runtime PRIVATE dingosdk_custom_level_manifest)
set_target_properties(dingosdk_runtime PROPERTIES OUTPUT_NAME "ReSkate" PREFIX "")
# The game as a Spotify Connect device (Extension/Music/spotify_device.h): SpotifyHelper/, a
# Rust library on librespot, built by cargo and linked into ReSkate.dll. Without cargo the DLL
# is built without it, and Spotify is played through its own app. (Not in Debug builds: the
# library is built with the release C runtime.)
find_program(DINGOSDK_CARGO cargo HINTS "$ENV{USERPROFILE}/.cargo/bin")
set(dingosdk_spotify_dir "${PROJECT_SOURCE_DIR}/SpotifyHelper")
if(WIN32 AND DINGOSDK_CARGO AND EXISTS "${dingosdk_spotify_dir}/Cargo.toml")
set(dingosdk_spotify_lib "${dingosdk_spotify_dir}/target/release/reskate_spotify.lib")
add_custom_command(OUTPUT "${dingosdk_spotify_lib}"
COMMAND "${DINGOSDK_CARGO}" build --release --lib
WORKING_DIRECTORY "${dingosdk_spotify_dir}"
DEPENDS "${dingosdk_spotify_dir}/src/lib.rs" "${dingosdk_spotify_dir}/Cargo.toml" "${dingosdk_spotify_dir}/Cargo.lock"
COMMENT "Building the Spotify device (cargo)"
VERBATIM)
add_custom_target(dingosdk_spotify_device DEPENDS "${dingosdk_spotify_lib}")
add_dependencies(dingosdk_runtime dingosdk_spotify_device)
# (An import library of the Windows API that the Rust code is linked against, which cargo
# has fetched beside the code that uses it.)
if(DEFINED ENV{CARGO_HOME})
set(dingosdk_cargo_home "$ENV{CARGO_HOME}")
else()
set(dingosdk_cargo_home "$ENV{USERPROFILE}/.cargo")
endif()
file(GLOB dingosdk_windows_import "${dingosdk_cargo_home}/registry/src/*/windows_x86_64_msvc-0.52.*/lib/windows.0.52.0.lib")
list(GET dingosdk_windows_import 0 dingosdk_windows_import)
set(dingosdk_spotify_release "$<NOT:$<CONFIG:Debug>>")
target_compile_definitions(dingosdk_runtime PRIVATE "DINGOSDK_SPOTIFY_DEVICE=${dingosdk_spotify_release}")
target_link_libraries(dingosdk_runtime PRIVATE
"$<${dingosdk_spotify_release}:${dingosdk_spotify_lib}>" "$<${dingosdk_spotify_release}:${dingosdk_windows_import}>"
ntdll advapi32 credui secur32 userenv dbghelp legacy_stdio_definitions)
else()
target_compile_definitions(dingosdk_runtime PRIVATE DINGOSDK_SPOTIFY_DEVICE=0)
endif()
dingosdk_version_info(dingosdk_runtime "ReSkate mod runtime" "ReSkate.dll" VFT_DLL)
target_include_directories(dingosdk_runtime SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking")
target_link_libraries(dingosdk_runtime PRIVATE dingosdk_lz4 dingosdk_zstd)
@@ -256,6 +301,20 @@ if(EXISTS "${dingosdk_emotes}/emotes.json" AND EXISTS "${dingosdk_emotes}/emotes
"EMOTES_PNG RCDATA \"${dingosdk_emotes_native}\\\\emotes.png\"\n")
target_sources(dingosdk_runtime PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated/emotes.rc")
endif()
# The Spotify station's picture in the music screen (Extension/Music/spotify_cover.h).
if(EXISTS "${PROJECT_SOURCE_DIR}/assets/spotify.png")
file(TO_NATIVE_PATH "${PROJECT_SOURCE_DIR}/assets/spotify.png" dingosdk_spotify_art_native)
string(REPLACE "\\" "\\\\" dingosdk_spotify_art_native "${dingosdk_spotify_art_native}")
file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/generated/spotify_art.rc"
"SPOTIFY_PNG RCDATA \"${dingosdk_spotify_art_native}\"\n")
if(EXISTS "${PROJECT_SOURCE_DIR}/assets/liked.png")
file(TO_NATIVE_PATH "${PROJECT_SOURCE_DIR}/assets/liked.png" dingosdk_liked_art_native)
string(REPLACE "\\" "\\\\" dingosdk_liked_art_native "${dingosdk_liked_art_native}")
file(APPEND "${CMAKE_CURRENT_BINARY_DIR}/generated/spotify_art.rc"
"LIKED_PNG RCDATA \"${dingosdk_liked_art_native}\"\n")
endif()
target_sources(dingosdk_runtime PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated/spotify_art.rc")
endif()
if(EXISTS "${dingosdk_startup_art}")
file(TO_NATIVE_PATH "${dingosdk_startup_art}" dingosdk_startup_art_native)
string(REPLACE "\\" "\\\\" dingosdk_startup_art_native "${dingosdk_startup_art_native}")