Mod Support (#499)

Integrates the modding functionality in N64ModernRuntime and adds several exported functions for mods to use. Also adds a ROM decompressor so that the runtime has access to the uncompressed code in the ROM for hooking purposes.
This commit is contained in:
Wiseguy
2025-02-14 18:38:10 -05:00
committed by GitHub
parent 0d0f64e32f
commit 91db87632c
42 changed files with 1172 additions and 187 deletions
-1
View File
@@ -19,7 +19,6 @@ constexpr std::u8string_view general_filename = u8"general.json";
constexpr std::u8string_view graphics_filename = u8"graphics.json";
constexpr std::u8string_view controls_filename = u8"controls.json";
constexpr std::u8string_view sound_filename = u8"sound.json";
constexpr std::u8string_view program_id = u8"Zelda64Recompiled";
constexpr auto res_default = ultramodern::renderer::Resolution::Auto;
constexpr auto hr_default = ultramodern::renderer::HUDRatioMode::Clamp16x9;
+1 -1
View File
@@ -2,7 +2,7 @@
#include <mutex>
#include "ultramodern/ultramodern.hpp"
#include "librecomp/recomp.h"
#include "recomp.h"
#include "recomp_input.h"
#include "zelda_config.h"
#include "recomp_ui.h"
+15 -4
View File
@@ -1,6 +1,6 @@
#include <cmath>
#include "librecomp/recomp.h"
#include "recomp.h"
#include "librecomp/overlays.hpp"
#include "zelda_config.h"
#include "recomp_input.h"
@@ -58,7 +58,18 @@ extern "C" void recomp_get_target_framerate(uint8_t* rdram, recomp_context* ctx)
_return(ctx, ultramodern::get_target_framerate(60 / frame_divisor));
}
extern "C" void recomp_get_aspect_ratio(uint8_t* rdram, recomp_context* ctx) {
extern "C" void recomp_get_window_resolution(uint8_t* rdram, recomp_context* ctx) {
int width, height;
recompui::get_window_size(width, height);
gpr width_out = _arg<0, PTR(u32)>(rdram, ctx);
gpr height_out = _arg<1, PTR(u32)>(rdram, ctx);
MEM_W(0, width_out) = (u32)width;
MEM_W(0, height_out) = (u32)height;
}
extern "C" void recomp_get_target_aspect_ratio(uint8_t* rdram, recomp_context* ctx) {
ultramodern::renderer::GraphicsConfig graphics_config = ultramodern::renderer::get_graphics_config();
float original = _arg<0, float>(rdram, ctx);
int width, height;
@@ -91,7 +102,7 @@ extern "C" void recomp_time_us(uint8_t* rdram, recomp_context* ctx) {
_return(ctx, static_cast<u32>(std::chrono::duration_cast<std::chrono::microseconds>(ultramodern::time_since_start()).count()));
}
extern "C" void recomp_autosave_enabled(uint8_t* rdram, recomp_context* ctx) {
extern "C" void recomp_get_autosave_enabled(uint8_t* rdram, recomp_context* ctx) {
_return(ctx, static_cast<s32>(zelda64::get_autosave_mode() == zelda64::AutosaveMode::On));
}
@@ -131,7 +142,7 @@ extern "C" void recomp_get_analog_inverted_axes(uint8_t* rdram, recomp_context*
*y_out = (mode == zelda64::CameraInvertMode::InvertY || mode == zelda64::CameraInvertMode::InvertBoth);
}
extern "C" void recomp_analog_cam_enabled(uint8_t* rdram, recomp_context* ctx) {
extern "C" void recomp_get_analog_cam_enabled(uint8_t* rdram, recomp_context* ctx) {
_return<s32>(ctx, zelda64::get_analog_cam_mode() == zelda64::AnalogCamMode::On);
}
+168
View File
@@ -0,0 +1,168 @@
#include <cassert>
#include <cstring>
#include <fstream>
#include "zelda_game.h"
void naive_copy(std::span<uint8_t> dst, std::span<const uint8_t> src) {
for (size_t i = 0; i < src.size(); i++) {
dst[i] = src[i];
}
}
void yaz0_decompress(std::span<const uint8_t> input, std::span<uint8_t> output) {
int32_t layoutBitIndex;
uint8_t layoutBits;
size_t input_pos = 0;
size_t output_pos = 0;
size_t input_size = input.size();
size_t output_size = output.size();
while (input_pos < input_size) {
int32_t layoutBitIndex = 0;
uint8_t layoutBits = input[input_pos++];
while (layoutBitIndex < 8 && input_pos < input_size && output_pos < output_size) {
if (layoutBits & 0x80) {
output[output_pos++] = input[input_pos++];
} else {
int32_t firstByte = input[input_pos++];
int32_t secondByte = input[input_pos++];
uint32_t bytes = firstByte << 8 | secondByte;
uint32_t offset = (bytes & 0x0FFF) + 1;
uint32_t length;
// Check how the group length is encoded
if ((firstByte & 0xF0) == 0) {
// 3 byte encoding, 0RRRNN
int32_t thirdByte = input[input_pos++];
length = thirdByte + 0x12;
} else {
// 2 byte encoding, NRRR
length = ((bytes & 0xF000) >> 12) + 2;
}
naive_copy(output.subspan(output_pos, length), output.subspan(output_pos - offset, length));
output_pos += length;
}
layoutBitIndex++;
layoutBits <<= 1;
}
}
}
#ifdef _MSC_VER
inline uint32_t byteswap(uint32_t val) {
return _byteswap_ulong(val);
}
#else
constexpr uint32_t byteswap(uint32_t val) {
return __builtin_bswap32(val);
}
#endif
// Produces a decompressed MM rom. This is only needed because the game has compressed code.
// For other recomps using this repo as an example, you can omit the decompression routine and
// set the corresponding fields in the GameEntry if the game doesn't have compressed code,
// even if it does have compressed data.
std::vector<uint8_t> zelda64::decompress_mm(std::span<const uint8_t> compressed_rom) {
// Sanity check the rom size and header. These should already be correct from the runtime's check,
// but it should prevent this file from accidentally being copied to another recomp.
if (compressed_rom.size() != 0x2000000) {
assert(false);
return {};
}
if (compressed_rom[0x3B] != 'N' || compressed_rom[0x3C] != 'Z' || compressed_rom[0x3D] != 'S' || compressed_rom[0x3E] != 'E') {
assert(false);
return {};
}
struct DmaDataEntry {
uint32_t vrom_start;
uint32_t vrom_end;
uint32_t rom_start;
uint32_t rom_end;
void bswap() {
vrom_start = byteswap(vrom_start);
vrom_end = byteswap(vrom_end);
rom_start = byteswap(rom_start);
rom_end = byteswap(rom_end);
}
};
DmaDataEntry cur_entry{};
size_t cur_entry_index = 0;
constexpr size_t dma_data_rom_addr = 0x1A500;
std::vector<uint8_t> ret{};
ret.resize(0x2F00000);
size_t content_end = 0;
do {
// Read the entry from the compressed rom.
size_t cur_entry_rom_address = dma_data_rom_addr + (cur_entry_index++) * sizeof(DmaDataEntry);
memcpy(&cur_entry, compressed_rom.data() + cur_entry_rom_address, sizeof(DmaDataEntry));
// Swap the entry to native endianness after reading from the big endian data.
cur_entry.bswap();
// Rom end being 0 means the data is already uncompressed, so copy it as-is to vrom start.
size_t entry_decompressed_size = cur_entry.vrom_end - cur_entry.vrom_start;
if (cur_entry.rom_end == 0) {
memcpy(ret.data() + cur_entry.vrom_start, compressed_rom.data() + cur_entry.rom_start, entry_decompressed_size);
// Edit the entry to account for it being in a new location now.
cur_entry.rom_start = cur_entry.vrom_start;
}
// Otherwise, decompress the input data into the output data.
else {
if (cur_entry.rom_end != cur_entry.rom_start) {
// Validate the presence of the yaz0 header.
if (compressed_rom[cur_entry.rom_start + 0] != 'Y' ||
compressed_rom[cur_entry.rom_start + 1] != 'a' ||
compressed_rom[cur_entry.rom_start + 2] != 'z' ||
compressed_rom[cur_entry.rom_start + 3] != '0')
{
assert(false);
return {};
}
// Skip the yaz0 header.
size_t compressed_data_rom_start = cur_entry.rom_start + 0x10;
size_t entry_compressed_size = cur_entry.rom_end - compressed_data_rom_start;
std::span input_span = std::span{ compressed_rom }.subspan(compressed_data_rom_start, entry_compressed_size);
std::span output_span = std::span{ ret }.subspan(cur_entry.vrom_start, entry_decompressed_size);
yaz0_decompress(input_span, output_span);
// Edit the entry to account for it being decompressed now.
cur_entry.rom_start = cur_entry.vrom_start;
cur_entry.rom_end = 0;
}
}
if (entry_decompressed_size != 0) {
if (cur_entry.vrom_end > content_end) {
content_end = cur_entry.vrom_end;
}
}
// Swap the entry back to big endian for writing.
cur_entry.bswap();
// Write the modified entry to the decompressed rom.
memcpy(ret.data() + cur_entry_rom_address, &cur_entry, sizeof(DmaDataEntry));
} while (cur_entry.vrom_end != 0);
// Align the start of padding to the closest 0x1000 (matches decomp rom decompression behavior).
content_end = (content_end + 0x1000 - 1) & -0x1000;
// Write 0xFF as the padding.
std::fill(ret.begin() + content_end, ret.end(), 0xFF);
return ret;
}
+102 -16
View File
@@ -25,8 +25,16 @@
#include "zelda_config.h"
#include "zelda_sound.h"
#include "zelda_render.h"
#include "zelda_game.h"
#include "ovl_patches.hpp"
#include "librecomp/game.hpp"
#include "librecomp/mods.hpp"
#include "librecomp/helpers.hpp"
#include "../../patches/graphics.h"
#include "../../patches/input.h"
#include "../../patches/sound.h"
#include "../../patches/misc_funcs.h"
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
@@ -36,6 +44,8 @@
#include "../../lib/rt64/src/contrib/stb/stb_image.h"
const std::string version_string = "1.2.0-dev";
template<typename... Ts>
void exit_error(const char* str, Ts ...args) {
// TODO pop up an error
@@ -52,14 +62,12 @@ ultramodern::gfx_callbacks_t::gfx_data_t create_gfx() {
SDL_SetHint(SDL_HINT_MOUSE_FOCUS_CLICKTHROUGH, "1");
SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1");
#if defined(__linux__)
SDL_SetHint(SDL_HINT_VIDEODRIVER, "x11");
#endif
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) > 0) {
exit_error("Failed to initialize SDL2: %s\n", SDL_GetError());
}
fprintf(stdout, "SDL Video Driver: %s\n", SDL_GetCurrentVideoDriver());
return {};
}
@@ -115,7 +123,13 @@ bool SetImageAsIcon(const char* filename, SDL_Window* window)
SDL_Window* window;
ultramodern::renderer::WindowHandle create_window(ultramodern::gfx_callbacks_t::gfx_data_t) {
window = SDL_CreateWindow("Zelda 64: Recompiled", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 1600, 960, SDL_WINDOW_RESIZABLE );
uint32_t flags = SDL_WINDOW_RESIZABLE;
#if defined(RT64_SDL_WINDOW_VULKAN)
flags |= SDL_WINDOW_VULKAN;
#endif
window = SDL_CreateWindow("Zelda 64: Recompiled", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 1600, 960, flags);
#if defined(__linux__)
SetImageAsIcon("icons/512.png",window);
if (ultramodern::renderer::get_graphics_config().wm_option == ultramodern::renderer::WindowMode::Fullscreen) { // TODO: Remove once RT64 gets native fullscreen support on Linux
@@ -135,14 +149,8 @@ ultramodern::renderer::WindowHandle create_window(ultramodern::gfx_callbacks_t::
#if defined(_WIN32)
return ultramodern::renderer::WindowHandle{ wmInfo.info.win.window, GetCurrentThreadId() };
#elif defined(__ANDROID__)
static_assert(false && "Unimplemented");
#elif defined(__linux__)
if (wmInfo.subsystem != SDL_SYSWM_X11) {
exit_error("Unsupported SDL2 video driver \"%s\". Only X11 is supported on Linux.\n", SDL_GetCurrentVideoDriver());
}
return ultramodern::renderer::WindowHandle{ wmInfo.info.x11.display, wmInfo.info.x11.window };
#elif defined(__linux__) || defined(__ANDROID__)
return ultramodern::renderer::WindowHandle{ window };
#else
static_assert(false && "Unimplemented");
#endif
@@ -327,7 +335,11 @@ std::vector<recomp::GameEntry> supported_games = {
.rom_hash = 0xEF18B4A9E2386169ULL,
.internal_name = "ZELDA MAJORA'S MASK",
.game_id = u8"mm.n64.us.1.0",
.is_enabled = true,
.mod_game_id = "mm",
.save_type = recomp::SaveType::Flashram,
.is_enabled = false,
.decompression_routine = zelda64::decompress_mm,
.has_compressed_code = true,
.entrypoint_address = get_entrypoint_address(),
.entrypoint = recomp_entrypoint,
},
@@ -524,7 +536,27 @@ void release_preload(PreloadContext& context) {
#endif
void enable_texture_pack(recomp::mods::ModContext& context, const recomp::mods::ModHandle& mod) {
(void)context;
zelda64::renderer::enable_texture_pack(mod);
}
void disable_texture_pack(recomp::mods::ModContext& context, const recomp::mods::ModHandle& mod) {
(void)context;
zelda64::renderer::disable_texture_pack(mod);
}
#define REGISTER_FUNC(name) recomp::overlays::register_base_export(#name, name)
int main(int argc, char** argv) {
(void)argc;
(void)argv;
recomp::Version project_version{};
if (!recomp::Version::from_string(version_string, project_version)) {
ultramodern::error_handling::message_box(("Invalid version string: " + version_string).c_str());
return EXIT_FAILURE;
}
// Map this executable into memory and lock it, which should keep it in physical memory. This ensures
// that there are no stutters from the OS having to load new pages of the executable whenever a new code page is run.
PreloadContext preload_context;
@@ -568,15 +600,29 @@ int main(int argc, char** argv) {
fprintf(stderr, "Failed to load controller mappings: %s\n", SDL_GetError());
}
recomp::register_config_path(zelda64::get_app_folder_path());
// Register supported games and patches
for (const auto& game : supported_games) {
recomp::register_game(game);
}
REGISTER_FUNC(recomp_get_window_resolution);
REGISTER_FUNC(recomp_get_target_aspect_ratio);
REGISTER_FUNC(recomp_get_target_framerate);
REGISTER_FUNC(recomp_get_autosave_enabled);
REGISTER_FUNC(recomp_get_analog_cam_enabled);
REGISTER_FUNC(recomp_get_camera_inputs);
REGISTER_FUNC(recomp_get_targeting_mode);
REGISTER_FUNC(recomp_get_bgm_volume);
REGISTER_FUNC(recomp_get_low_health_beeps_enabled);
REGISTER_FUNC(recomp_get_gyro_deltas);
REGISTER_FUNC(recomp_get_mouse_deltas);
REGISTER_FUNC(recomp_get_inverted_axes);
REGISTER_FUNC(recomp_get_analog_inverted_axes);
zelda64::register_overlays();
zelda64::register_patches();
recomp::register_config_path(zelda64::get_app_folder_path());
zelda64::load_config();
recomp::rsp::callbacks_t rsp_callbacks{
@@ -619,7 +665,47 @@ int main(int argc, char** argv) {
.get_game_thread_name = zelda64::get_game_thread_name,
};
// Register the texture pack content type with rt64.json as its content file.
recomp::mods::ModContentType texture_pack_content_type{
.content_filename = "rt64.json",
.allow_runtime_toggle = true,
.on_enabled = enable_texture_pack,
.on_disabled = disable_texture_pack,
};
auto texture_pack_content_type_id = recomp::mods::register_mod_content_type(texture_pack_content_type);
// Register the .rtz texture pack file format with the previous content type as its only allowed content type.
recomp::mods::register_mod_container_type("rtz", std::vector{ texture_pack_content_type_id }, false);
recomp::mods::scan_mods();
printf("Found mods:\n");
for (const auto& mod : recomp::mods::get_mod_details("mm")) {
printf(" %s(%s)\n", mod.mod_id.c_str(), mod.version.to_string().c_str());
if (!mod.authors.empty()) {
printf(" Authors: %s", mod.authors[0].c_str());
for (size_t author_index = 1; author_index < mod.authors.size(); author_index++) {
const std::string& author = mod.authors[author_index];
printf(", %s", author.c_str());
}
printf("\n");
printf(" Runtime toggleable: %d\n", mod.runtime_toggleable);
}
if (!mod.dependencies.empty()) {
printf(" Dependencies: %s:%s", mod.dependencies[0].mod_id.c_str(), mod.dependencies[0].version.to_string().c_str());
for (size_t dep_index = 1; dep_index < mod.dependencies.size(); dep_index++) {
const recomp::mods::Dependency& dep = mod.dependencies[dep_index];
printf(", %s:%s", dep.mod_id.c_str(), dep.version.to_string().c_str());
}
printf("\n");
}
// TODO load all mods as a temporary solution to not having a UI yet.
recomp::mods::enable_mod(mod.mod_id, true);
}
printf("\n");
recomp::start(
project_version,
{},
rsp_callbacks,
renderer_callbacks,
+3
View File
@@ -7,4 +7,7 @@
void zelda64::register_patches() {
recomp::overlays::register_patches(mm_patches_bin, sizeof(mm_patches_bin), section_table, ARRLEN(section_table));
recomp::overlays::register_base_exports(export_table);
recomp::overlays::register_base_events(event_names);
recomp::overlays::register_manual_patch_symbols(manual_patch_symbols);
}
+56 -15
View File
@@ -1,5 +1,6 @@
#include <memory>
#include <cstring>
#include <variant>
#define HLSL_CPU
#include "hle/rt64_application.h"
@@ -10,6 +11,13 @@
#include "zelda_render.h"
#include "recomp_ui.h"
#include "concurrentqueue.h"
// Helper class for variant visiting.
template<class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;
static RT64::UserConfiguration::Antialiasing device_max_msaa = RT64::UserConfiguration::Antialiasing::None;
static bool sample_positions_supported = false;
@@ -18,6 +26,18 @@ static bool high_precision_fb_enabled = false;
static uint8_t DMEM[0x1000];
static uint8_t IMEM[0x1000];
struct TexturePackEnableAction {
std::filesystem::path path;
};
struct TexturePackDisableAction {
std::filesystem::path path;
};
using TexturePackAction = std::variant<TexturePackEnableAction, TexturePackDisableAction>;
static moodycamel::ConcurrentQueue<TexturePackAction> texture_pack_action_queue;
unsigned int MI_INTR_REG = 0;
unsigned int DPC_START_REG = 0;
@@ -180,11 +200,8 @@ zelda64::renderer::RT64Context::RT64Context(uint8_t* rdram, ultramodern::rendere
RT64::Application::Core appCore{};
#if defined(_WIN32)
appCore.window = window_handle.window;
#elif defined(__ANDROID__)
assert(false && "Unimplemented");
#elif defined(__linux__)
appCore.window.display = window_handle.display;
appCore.window.window = window_handle.window;
#elif defined(__linux__) || defined(__ANDROID__)
appCore.window = window_handle;
#elif defined(__APPLE__)
appCore.window.window = window_handle.window;
appCore.window.view = window_handle.view;
@@ -286,6 +303,32 @@ zelda64::renderer::RT64Context::RT64Context(uint8_t* rdram, ultramodern::rendere
zelda64::renderer::RT64Context::~RT64Context() = default;
void zelda64::renderer::RT64Context::send_dl(const OSTask* task) {
bool packs_disabled = false;
TexturePackAction cur_action;
while (texture_pack_action_queue.try_dequeue(cur_action)) {
std::visit(overloaded{
[&](TexturePackDisableAction& to_disable) {
enabled_texture_packs.erase(to_disable.path);
packs_disabled = true;
},
[&](TexturePackEnableAction& to_enable) {
enabled_texture_packs.insert(to_enable.path);
// Load the pack now if no packs have been disabled.
if (!packs_disabled) {
app->textureCache->loadReplacementDirectory(to_enable.path);
}
}
}, cur_action);
}
// If any packs were disabled, unload all packs and load all the active ones.
if (packs_disabled) {
app->textureCache->clearReplacementDirectories();
for (const std::filesystem::path& cur_pack_path : enabled_texture_packs) {
app->textureCache->loadReplacementDirectory(cur_pack_path);
}
}
app->state->rsp->reset();
app->interpreter->loadUCodeGBI(task->t.ucode & 0x3FFFFFF, task->t.ucode_data & 0x3FFFFFF, true);
app->processDisplayLists(app->core.RDRAM, task->t.data_ptr & 0x3FFFFFF, 0, true);
@@ -351,16 +394,6 @@ float zelda64::renderer::RT64Context::get_resolution_scale() const {
}
}
void zelda64::renderer::RT64Context::load_shader_cache(std::span<const char> cache_binary) {
// TODO figure out how to avoid a copy here.
std::istringstream cache_stream{std::string{cache_binary.data(), cache_binary.size()}};
if (!app->rasterShaderCache->loadOfflineList(cache_stream)) {
printf("Failed to preload shader cache!\n");
assert(false);
}
}
RT64::UserConfiguration::Antialiasing zelda64::renderer::RT64MaxMSAA() {
return device_max_msaa;
}
@@ -376,3 +409,11 @@ bool zelda64::renderer::RT64SamplePositionsSupported() {
bool zelda64::renderer::RT64HighPrecisionFBEnabled() {
return high_precision_fb_enabled;
}
void zelda64::renderer::enable_texture_pack(const recomp::mods::ModHandle& mod) {
texture_pack_action_queue.enqueue(TexturePackEnableAction{mod.manifest.mod_root_path});
}
void zelda64::renderer::disable_texture_pack(const recomp::mods::ModHandle& mod) {
texture_pack_action_queue.enqueue(TexturePackDisableAction{mod.manifest.mod_root_path});
}
+4 -2
View File
@@ -6,7 +6,7 @@
#include "nfd.h"
#include <filesystem>
std::string version_number = "v1.1.1";
static std::string version_string;
Rml::DataModelHandle model_handle;
bool mm_rom_valid = false;
@@ -103,7 +103,9 @@ public:
Rml::DataModelConstructor constructor = context->CreateDataModel("launcher_model");
constructor.Bind("mm_rom_valid", &mm_rom_valid);
constructor.Bind("version_number", &version_number);
version_string = recomp::get_project_version().to_string();
constructor.Bind("version_number", &version_string);
model_handle = constructor.GetModelHandle();
}
+6
View File
@@ -1287,6 +1287,12 @@ void draw_hook(RT64::RenderCommandList* command_list, RT64::RenderFramebuffer* s
static recompui::Menu prev_menu = recompui::Menu::None;
recompui::Menu cur_menu = open_menu.load();
// Return to the launcher if no menu is open and the game isn't started.
if (cur_menu == recompui::Menu::None && !ultramodern::is_game_started()) {
cur_menu = recompui::Menu::Launcher;
recompui::set_current_menu(cur_menu);
}
if (reload_sheets) {
ui_context->rml.load_documents();
prev_menu = recompui::Menu::None;