mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-23 04:35:54 +02:00
Sources - Local Files: add a folder of music from the setup page (MP3, M4A, AAC, Ogg, FLAC, WAV; up to 200 songs a folder, 12 folders). Names and covers come from the files' tags. Each folder is a tile in its own row. - Online Radio: find stations by name (radio-browser.info) or add a stream's address (MP3, AAC, .pls, .m3u; no HLS). Each station is a tile in its own row; the track name follows what the station sends. Up to 24. - Both are decoded by the library built into the DLL (SpotifyHelper), which now plays files and HTTP streams as well as a program's output, reads tags and embedded pictures, and accepts MPEG-2 ADTS from AAC stations. Sound - Five-band equaliser (60 Hz, 250 Hz, 1 kHz, 4 kHz, 12 kHz) and loudness levelling, applied to every source on its way into the game. - Loudness, the speakers option, the equaliser and levelling are now kept in music-settings.json between sessions. Setup page - Remade in the mod's own colours and type, with numbered steps per source. - Queue pinned to the right: now playing, the playlist's songs with covers, find, and press to jump. Spotify's own queue shows once signed in. - Cards for Local files and Online radio; equaliser and levelling under Sound. In game - MUSIC page in the menu: player and queue, sound settings, source status and a button for the setup page. - "Loading song..." notice in the corner for songs that take a moment, with a switch to hide it. Presses made while a song is loading no longer restart it. - The Spotify row is hidden when Spotify is not in use. - Console: "spotify ..." is now "music ..." (music, music setup, music gain, music through, music spotify client|login|logout). Logs - The music sources' working lines (traces, per-song and per-bind lines, librespot's own) are debug only; stack captures are skipped unless debug logging is on. Grind exits: the per-pop line is debug only too. - A "Pace:" line when the game takes the sound faster or slower than real time, to find why music sounds fast after tabbing back in. Chat filter - A run of digits is a number, not a word spelled with look-alikes: a race time such as 2:55.0 is no longer masked.
312 lines
16 KiB
C++
312 lines
16 KiB
C++
#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{}; logging::enabled(logging::Level::debug) && 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::debug, 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 ("music 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::debug, 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{}; logging::enabled(logging::Level::debug) && 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::debug, 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::debug, 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
|