Shader tables: mods' are merged as in 2.0.7 again, and the game's song scorer stays within its list

Shader tables
- Reverts a61f82c ("Shader crash fix"). Since 2.0.8 a mod's own shader
  programs were copied into the merged table, and the tables that cosmetic
  bundles carry were merged across mods. Each such bundle then held other
  mods' programs, and the game built them as the bundle loaded, before the
  files with their compiled variants were there. That was 2.0.8's most
  reported crash (Skate.exe+0x3f86da6, some forty machines in ten minutes).
  2.0.9's guard passed such a program over, and the same players then
  crashed a little later in cosmetic loading (+0x3f7bb65, +0x16fe1a6,
  +0x3dc07ed).
- Whatever crash a61f82c fixed comes back with this for the mods that
  needed it.
- The guard stays.

Music
- The game's read of one song for its scorer (0x14f23a0) is replaced by the
  same read with the check it lacks: a place past the end of the playlist
  asset's songs has no song, which the game already answers with 0. 2.0.9's
  check in music_select_next_hook looked at what the queue held before the
  game was asked and did not see these; the game asked for places such as
  0xd570 and crashed reading them (7 reports on 6 machines).
- Said in the log the first eight times.

Not confirmed in game: neither change has been run by a player who was
crashing.
This commit is contained in:
AyeZeeBB committed 2026-10-13 18:44:15 -05:00
1 parent 83f148390a
commit 9f93c3cbb2
7 files changed
+66 -181

No files matched your search

+5
View File
@@ -72,6 +72,11 @@ inline constexpr Fingerprint favorite_apply_contract{0x6f51e0, {0x48,0x89,0x5c,0
// Queue selector shared by UI Next and natural end-of-track selection. The queue
// supplies its own channel and registered playlist asset; do not hard-code IDs.
inline constexpr Fingerprint playback_select_next_contract{0x14f8ed0, {0x48,0x89,0x54,0x24,0x10,0x55,0x53,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x8d,0x6c,0x24,0xc8,0x48,0x81,0xec,0x38,0x01,0x00,0x00,0x4c,0x8b,0xe1}};
// The callback the queue selector hands the game's song scorer for a playlist asset with a
// selector: (u32 place, u32 *out, context) -> *out, a number of the song at that place of the
// asset's songs (song + 0x7c), or 0 where the place holds none. The whole function (32 bytes):
// it does not look at how many songs the asset has.
inline constexpr Fingerprint playback_song_score_contract{0x14f23a0, {0x49,0x8b,0x00,0x44,0x8b,0xc9,0x48,0x8b,0x08,0x4a,0x8b,0x04,0xc9,0x48,0x83,0xe0,0xfb,0x74,0x06,0x8b,0x40,0x7c,0x89,0x02,0xc3,0xc7,0x02,0x00,0x00,0x00,0x00,0xc3}};
// Native playlist playback-mode setter (PlaylistPlaybackMode, not MusicPlaylistType).
inline constexpr Fingerprint playback_mode_setter_contract{0x443aa0, {0x44,0x8b,0x91,0xa8,0x00,0x00,0x00,0x4c,0x8b,0x99,0xa0,0x00,0x00,0x00,0x44,0x8b,0xca,0x8b,0xc2,0x33,0xd2,0x49,0xf7,0xf2,0x8b,0xc2,0x49,0x8b,0x04,0xc3,0x48,0x85}};
// Native channel-to-playlist binding entrypoint, used by the generated UI/script thunk.
+15 -162
View File
@@ -1,9 +1,6 @@
#include "shader_lookup.h"
#include <algorithm>
#include <array>
#include <cstring>
#include <limits>
#include <map>
#include <stdexcept>
#include <string>
@@ -80,176 +77,32 @@ TextureRows read_texture_rows(const Table& table) {
} // namespace
namespace {
constexpr std::size_t programStride = 168;
struct ProgramBlob {
Table source;
std::span<const std::byte> payload;
std::uint64_t records{};
std::uint32_t count{};
std::vector<std::uint32_t> relocations;
};
ProgramBlob program_blob(const Table& source) {
const auto end = lookup_offset(source);
const auto size = read<std::uint32_t>(source.resourceMeta, 0);
if ((end - size) % 4) throw std::runtime_error("Program relocations are not aligned");
ProgramBlob blob{source, source.resource.first(size)};
blob.count = read<std::uint32_t>(blob.payload, 0);
blob.records = read<std::uint64_t>(blob.payload, 4);
if (blob.records > size || blob.count > (size - blob.records) / programStride)
throw std::runtime_error("Program records exceed their payload");
for (auto at = std::size_t{size}; at < end; at += 4) {
const auto pointer = read<std::uint32_t>(source.resource, at);
if (read<std::uint64_t>(blob.payload, pointer) > size)
throw std::runtime_error("Program relocation points outside its payload");
blob.relocations.push_back(pointer);
}
return blob;
}
struct ProgramArray { std::size_t pointer, stride; };
constexpr std::array programArrays{
ProgramArray{0x10, 16}, ProgramArray{0x1c, 8}, ProgramArray{0x28, 8}, ProgramArray{0x34, 16},
ProgramArray{0x40, 6}, ProgramArray{0x4c, 8}, ProgramArray{0x58, 2},
ProgramArray{0x64, 8}, ProgramArray{0x70, 8}, ProgramArray{0x80, 16}, ProgramArray{0x8c, 32}
};
std::size_t record_at(const ProgramBlob& blob, std::uint32_t index) {
if (index >= blob.count) throw std::runtime_error("Material references an absent shader program");
return static_cast<std::size_t>(blob.records) + index * programStride;
}
std::span<const std::byte> program_array(const ProgramBlob& blob, std::size_t record, ProgramArray array) {
const auto count = read<std::uint32_t>(blob.payload, record + array.pointer - 4);
const auto pointer = read<std::uint64_t>(blob.payload, record + array.pointer);
if (pointer > blob.payload.size() || count > (blob.payload.size() - pointer) / array.stride)
throw std::runtime_error("Shader program array exceeds its payload");
return blob.payload.subspan(static_cast<std::size_t>(pointer), count * array.stride);
}
bool same_program(const ProgramBlob& a, std::uint32_t ai, const ProgramBlob& b, std::uint32_t bi) {
const auto aa = record_at(a, ai), ba = record_at(b, bi);
for (std::size_t at = 0; at < programStride; ++at) {
if (std::ranges::any_of(programArrays, [&](auto array) {
return at >= array.pointer && at < array.pointer + 8; })) continue;
if (a.payload[aa + at] != b.payload[ba + at]) return false;
}
for (const auto array : programArrays)
if (!std::ranges::equal(program_array(a, aa, array), program_array(b, ba, array))) return false;
return true;
}
std::uint32_t native_offset(std::size_t offset) {
if (offset > std::numeric_limits<std::uint32_t>::max())
throw std::runtime_error("Merged program database exceeds the native address range");
return static_cast<std::uint32_t>(offset);
}
} // namespace
MergedTable merge_program_lookup(const Table& base, const std::span<const Table> edits) {
std::vector<ProgramBlob> blobs{program_blob(base)};
for (const auto& edit : edits) blobs.push_back(program_blob(edit));
struct Reference { std::size_t blob; std::uint32_t program; };
std::map<std::uint64_t, std::vector<Reference>> references;
const auto start = lookup_offset(base);
auto rows = read_program_rows(base);
for (const auto& [key, values] : rows) for (const auto index : values) {
record_at(blobs.front(), index);
references[key].push_back({0, index});
}
const auto baseRows = rows;
MergedTable merged;
for (std::size_t b = 1; b < blobs.size(); ++b) {
for (const auto& [key, values] : read_program_rows(blobs[b].source)) {
for (const auto index : values) record_at(blobs[b], index);
for (const auto& edit : edits) {
for (const auto& [key, values] : read_program_rows(edit)) {
if (baseRows.contains(key)) continue;
if (const auto previous = references.find(key); previous != references.end()) {
bool same = previous->second.size() == values.size();
for (std::size_t i = 0; same && i < values.size(); ++i) {
const auto ref = previous->second[i];
same = same_program(blobs[ref.blob], ref.program, blobs[b], values[i]);
}
if (!same) ++merged.conflicts;
continue;
}
for (const auto index : values) references[key].push_back({b, index});
++merged.added;
const auto [row, inserted] = rows.try_emplace(key, values);
if (inserted) ++merged.added;
else if (row->second != values) ++merged.conflicts;
}
}
// Preserve the exact native prefix when edits only add aliases to stock programs.
// Private programs need their own records and relocated backing data as well.
using Source = std::pair<std::size_t, std::uint32_t>;
std::map<Source, std::uint32_t> remapped;
std::vector<Source> additions;
for (const auto& [key, refs] : references) {
auto& row = rows[key];
row.clear();
for (const auto ref : refs) {
auto index = ref.program;
if (ref.blob && (index >= blobs.front().count ||
!same_program(blobs.front(), index, blobs[ref.blob], index))) {
const Source source{ref.blob, index};
auto [entry, inserted] = remapped.try_emplace(source,
native_offset(std::size_t{blobs.front().count} + additions.size()));
if (inserted) additions.push_back(source);
index = entry->second;
}
row.push_back(index);
}
}
merged.resourceMeta.assign(base.resourceMeta.begin(), base.resourceMeta.end());
if (additions.empty()) {
merged.resource.assign(base.resource.begin(), base.resource.begin() + lookup_offset(base));
} else {
merged.resource.assign(blobs.front().payload.begin(), blobs.front().payload.end());
auto relocations = blobs.front().relocations;
std::map<std::size_t, std::size_t> offsets;
for (const auto& [b, index] : additions) {
if (offsets.contains(b)) continue;
merged.resource.resize((merged.resource.size() + 15) & ~std::size_t{15});
const auto offset = merged.resource.size();
offsets[b] = offset;
const auto& blob = blobs[b];
// Copy the complete backing graph, including opaque resource bindings.
// This keeps runtime-mutated GUID arrays separate across mods.
merged.resource.insert(merged.resource.end(), blob.payload.begin(), blob.payload.end());
for (const auto pointer : blob.relocations) {
const auto destination = native_offset(offset + pointer);
const auto target = read<std::uint64_t>(blob.payload, pointer) + offset;
std::memcpy(merged.resource.data() + destination, &target, 8);
relocations.push_back(destination);
}
}
merged.resource.resize((merged.resource.size() + 7) & ~std::size_t{7});
const auto table = static_cast<std::uint64_t>(merged.resource.size());
const auto count = native_offset(std::size_t{blobs.front().count} + additions.size());
merged.resource.resize(native_offset(static_cast<std::size_t>(table) + std::size_t{count} * programStride));
const auto copy_record = [&](const ProgramBlob& blob, std::uint32_t index,
std::size_t offset, std::uint32_t destination) {
const auto source = record_at(blob, index);
const auto target = static_cast<std::size_t>(table) + destination * programStride;
std::memcpy(merged.resource.data() + target, merged.resource.data() + offset + source, programStride);
for (const auto pointer : blob.relocations)
if (pointer >= source && pointer < source + programStride)
relocations.push_back(native_offset(target + pointer - source));
};
for (std::uint32_t i = 0; i < blobs.front().count; ++i) copy_record(blobs.front(), i, 0, i);
for (const auto& source : additions)
copy_record(blobs[source.first], source.second, offsets.at(source.first), remapped.at(source));
std::memcpy(merged.resource.data(), &count, 4);
std::memcpy(merged.resource.data() + 4, &table, 8);
relocations.push_back(4);
std::ranges::sort(relocations);
relocations.erase(std::ranges::unique(relocations).begin(), relocations.end());
const auto size = native_offset(merged.resource.size());
for (const auto pointer : relocations) append(merged.resource, pointer);
const auto relocationSize = native_offset(relocations.size() * 4);
std::memcpy(merged.resourceMeta.data(), &size, 4);
std::memcpy(merged.resourceMeta.data() + 4, &relocationSize, 4);
}
const auto lookup = merged.resource.size();
append(merged.resource, native_offset(rows.size()));
merged.resource.assign(base.resource.begin(),
base.resource.begin() + static_cast<std::ptrdiff_t>(start));
append(merged.resource, static_cast<std::uint32_t>(rows.size()));
for (const auto& [key, values] : rows) {
append(merged.resource, key);
append(merged.resource, native_offset(values.size()));
append(merged.resource, static_cast<std::uint32_t>(values.size()));
for (const auto value : values) append(merged.resource, value);
}
const auto size = native_offset(merged.resource.size() - lookup);
std::memcpy(merged.resourceMeta.data() + 8, &size, 4);
merged.resourceMeta.assign(base.resourceMeta.begin(), base.resourceMeta.end());
const auto size = static_cast<std::uint32_t>(merged.resource.size() - start);
std::memcpy(merged.resourceMeta.data() + 8, &size, sizeof(size));
return merged;
}
+7 -6
View File
@@ -1,8 +1,9 @@
#pragma once
// Shader lookup tables carry material rows and compiled program records.
// Mods can alias stock programs or add private programs with their own
// permutations and backing arrays; both must survive a merge.
// The two shader lookup tables a level superbundle carries. A map mod adds a
// row per material it introduces, aliasing a shader program and texture set the
// base already ships, so two mods' tables differ from the base only by the rows
// each added and can be combined exactly.
#include <cstddef>
#include <cstdint>
@@ -26,9 +27,9 @@ struct MergedTable {
std::size_t conflicts{}; // rows two edits disagreed on; the first won
};
// `resourceMeta` is 16 bytes: program size, relocation size, lookup size.
// Private program records and their relocated backing data are copied from
// each edit, and material indices are remapped into the combined table.
// `resourceMeta` is 16 bytes: program size, relocation size, lookup size. The
// program blob and its relocations are identical across copies because a mod
// only ever points new keys at programs that are already there.
[[nodiscard]] MergedTable merge_program_lookup(const Table& base, std::span<const Table> edits);
[[nodiscard]] MergedTable merge_texture_lookup(const Table& base, std::span<const Table> edits);
+5 -13
View File
@@ -788,10 +788,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
// holds one asset per name, so the lower-priority mod's is gone,
// and whatever of that mod's still names it by guid finds nothing
// (an item list entry is dropped for that further down).
const bool shaderTable = entry.asset.kind == fb::AssetKind::resource &&
(entry.asset.resourceType == fb::shader::programLookupType ||
entry.asset.resourceType == fb::shader::textureLookupType);
if (entry.asset.kind != fb::AssetKind::chunk && !shaderTable && !rewritten && previous.mod &&
if (entry.asset.kind != fb::AssetKind::chunk && !rewritten && previous.mod &&
previous.mod != owner && previous.asset.sha1 != entry.asset.sha1 &&
!state.baseSha.contains(at->first)) {
auto& [count, example] = replaced[*previous.mod];
@@ -849,11 +846,6 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
const Contribution* baseCopy{};
for (const auto& contribution : found->second)
if (contribution.base) { baseCopy = &contribution; break; }
// Custom cosmetic bundles also carry the global shader tables,
// although the game's copy of that bundle does not. Use the
// highest-priority contributor as the starting table there.
const bool addedShaderTable = !baseCopy && shaderTable;
if (addedShaderTable) baseCopy = &found->second.back();
if (!baseCopy) continue;
// Both mods rebuild the whole bundle, so only the assets whose
// content actually moved away from the base are contested.
@@ -864,8 +856,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
// An item list is looked at even when one mod alone changed it:
// what its entries name has to be in the bundle either way.
const auto named = entry.asset.kind == fb::AssetKind::ebx && item_list_name(entry.asset.name);
if (edits.size() < 2 && !(named && edits.size() == 1) &&
!(addedShaderTable && !edits.empty())) continue;
if (edits.size() < 2 && !(named && edits.size() == 1)) continue;
try {
const auto baseBytes = decode(*baseCopy);
std::vector<std::vector<std::byte>> editBytes;
@@ -898,8 +889,9 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
std::to_string(clash.first) + " preset(s) share a short name with another mod's, e.g. " +
clash.second + "; they are registered by their full path only");
} else if (shaderTable) {
// Keep each private program and its backing arrays, then
// remap material rows into the combined program table.
// Each mod aliases new material keys onto shader programs
// and texture sets the base already ships, so the union of
// the rows they added is exactly right.
std::vector<fb::shader::Table> tables;
for (std::size_t index = 0; index < editBytes.size(); ++index)
tables.push_back({editBytes[index], edits[index]->resourceMeta});
+28
View File
@@ -465,6 +465,34 @@ bool music_playback_available() noexcept { return playback_available.load(std::m
MusicSelectNext& music_select_next_original() noexcept { return select_next; }
// The scorer asks for places the asset has no song at (selector_overruns, which sees only what
// the queue holds before the game is asked: 2.0.9 still crashed here with places such as 0xd570,
// far past any list the queue had). The game's callback reads whatever lies there as a song.
// This is that callback with the one check it lacks: a place past the end has no song, which
// the game's own answer for is 0. It replaces the game's whole function, which is these few
// reads.
MusicSongScore song_score{};
MusicSongScore& music_song_score_original() noexcept { return song_score; }
void __fastcall music_song_score_hook(std::uint32_t place, std::uint32_t* out, std::uintptr_t** context) {
const auto* const songs = *context ? reinterpret_cast<const std::uintptr_t*>(**context) : nullptr;
// (The count is the u32 before the first song, its top bit apart: song_count.)
const auto count = songs ? *(reinterpret_cast<const std::uint32_t*>(songs) - 1) & 0x7fffffffu : 0u;
if (place >= count) {
*out = 0;
static std::atomic<unsigned> said{};
if (said.fetch_add(1, std::memory_order_relaxed) < 8) {
try {
dingosdk::logging::log(dingosdk::logging::Level::warning, dingosdk::logging::Channel::music,
"Next song: the game asked for song {} of a playlist asset that has {}, where it would have crashed; "
"it was told there is none there.", place, count);
} catch (...) {}
}
return;
}
const auto song = songs[place] & ~std::uintptr_t{4};
*out = song ? *reinterpret_cast<const std::uint32_t*>(song + 0x7c) : 0;
}
// When each channel was last bound to a playlist.
std::array<std::atomic<std::uint64_t>, 256> channel_bound_at{};
+4
View File
@@ -14,6 +14,10 @@ void activate_music_playback() noexcept;
void deactivate_music_playback() noexcept;
bool music_playback_available() noexcept;
MusicSelectNext& music_select_next_original() noexcept;
// The game's read of one song's number for its scorer, kept within the asset's songs.
using MusicSongScore = void (__fastcall *)(std::uint32_t place, std::uint32_t* out, std::uintptr_t** context);
MusicSongScore& music_song_score_original() noexcept;
void __fastcall music_song_score_hook(std::uint32_t place, std::uint32_t* out, std::uintptr_t** context);
std::int32_t __fastcall music_select_next_hook(std::uintptr_t queue, std::uintptr_t playlist_asset);
MusicChannelPlaylistBind& music_channel_playlist_bind_original() noexcept;
std::uintptr_t __fastcall music_channel_playlist_bind_hook(std::uintptr_t channel_pointer,
@@ -557,6 +557,8 @@ bool initialize_local_profile(std::uintptr_t base, bool authored_offline,
"{\"event\":\"music_channel_playlist_binding_contract_mismatch\"}");
hook(addr::local_music::playback_select_next_contract,
&music_select_next_hook, music_select_next_original());
hook(addr::local_music::playback_song_score_contract,
&music_song_score_hook, music_song_score_original());
} else dingosdk::logging::event(dingosdk::logging::Channel::music,
"{\"event\":\"music_playback_order_contract_mismatch\"}");
hook(buildkit_text_exists_contract, &buildkit_text_exists, buildkit_text_functions().exists);