[Kernel/XMP] Add title-specific file I/O hooks for XMP volume sync

Some games persist BGM volume through their own save files but never
call XMPSetVolume. Add a TitlePatch system that intercepts NtReadFile/
NtWriteFile to apply game-specific logic as well as logic to identify
the memory location that the volume data is read into and monitor that
memory location for changes, updating XMP player volume if it changes
during gameplay.

Includes patches for known games with this issue:
Dead or Alive Xtreme 2 (544307D2), PGR4 (4D5307F9) and PGR3 (4D5307D1)
This commit is contained in:
Herman S.
2026-03-08 14:42:11 +09:00
parent 54400cbc2c
commit 02fccd9113
6 changed files with 246 additions and 4 deletions

View File

@@ -407,6 +407,9 @@ void KernelState::SetExecutableModule(object_ref<UserModule> module) {
xboxkrnl::XboxkrnlModule::kExLoadedCommandLineSize);
}
// Initialize file I/O hooks for XMP volume title-specific patches.
InitXmpVolumePatch();
// Spin up deferred dispatch worker.
// TODO(benvanik): move someplace more appropriate (out of ctor, but around
// here).
@@ -738,8 +741,13 @@ void KernelState::UnloadUserModule(const object_ref<UserModule>& module,
object_table()->ReleaseHandleInLock(module->handle());
}
void KernelState::InitXmpVolumePatch() {
xmp_volume_patch_ = XmpVolumePatch::CreateForTitle(title_id(), this);
}
void KernelState::TerminateTitle() {
XELOGD("KernelState::TerminateTitle");
xmp_volume_patch_.reset();
auto global_lock = global_critical_region_.Acquire();
// Call terminate routines.

View File

@@ -24,6 +24,7 @@
#include "xenia/kernel/util/kernel_fwd.h"
#include "xenia/kernel/util/native_list.h"
#include "xenia/kernel/util/object_table.h"
#include "xenia/kernel/util/xmp_volume_patch.h"
#include "xenia/kernel/xam/achievement_manager.h"
#include "xenia/kernel/xam/app_manager.h"
#include "xenia/kernel/xam/content_manager.h"
@@ -196,6 +197,9 @@ class KernelState {
return xam_state()->content_manager();
}
XmpVolumePatch* xmp_volume_patch() const { return xmp_volume_patch_.get(); }
void InitXmpVolumePatch();
std::bitset<4> GetConnectedUsers() const;
// Access must be guarded by the global critical region.
@@ -351,6 +355,7 @@ class KernelState {
vfs::VirtualFileSystem* file_system_;
std::unique_ptr<xam::XamState> xam_state_;
std::unique_ptr<SystemManagementController> smc_;
std::unique_ptr<XmpVolumePatch> xmp_volume_patch_;
KernelVersion kernel_version_;

View File

@@ -0,0 +1,130 @@
/**
******************************************************************************
* Xenia : Xbox 360 Emulator Research Project *
******************************************************************************
* Copyright 2026 Ben Vanik. All rights reserved. *
* Released under the BSD license - see LICENSE in the root for more details. *
******************************************************************************
*/
#include "xenia/kernel/util/xmp_volume_patch.h"
#include "xenia/apu/audio_media_player.h"
#include "xenia/base/logging.h"
#include "xenia/emulator.h"
#include "xenia/kernel/kernel_state.h"
#include "xenia/memory.h"
namespace xe {
namespace kernel {
// Convert a volume byte to a float in [0.0001, 1.0]. Uses a near-zero floor
// instead of 0.0f so the audio player doesn't interpret it as "never set"
// and override with the default volume.
static float ByteToVolume(uint8_t value, uint8_t max_value) {
if (value == 0) {
return 0.0001f;
}
return static_cast<float>(value) / static_cast<float>(max_value);
}
bool XmpVolumePatch::Matches(const std::string& path, uint32_t length) const {
if (path.find(config_.file_name) == std::string::npos) {
return false;
}
if (config_.write_length != 0) {
return length == config_.write_length;
}
return length >= config_.offset + 1;
}
void XmpVolumePatch::ApplyVolume(const uint8_t* buffer, uint32_t length) {
uint8_t volume_byte = buffer[config_.offset];
if (volume_byte > config_.max_value) {
return;
}
float volume = ByteToVolume(volume_byte, config_.max_value);
XELOGD("XmpVolumePatch: volume={}/{} -> {:.2f}", volume_byte,
config_.max_value, volume);
last_volume_ = volume_byte;
auto* player = kernel_state_->emulator()->audio_media_player();
if (player) {
player->SetVolume(volume);
}
}
void XmpVolumePatch::PollGuestVolume() {
if (!settings_guest_addr_) {
return;
}
auto* mem = kernel_state_->memory();
uint32_t vol_addr = settings_guest_addr_ + config_.offset;
uint8_t current = mem->TranslateVirtual<uint8_t*>(vol_addr)[0];
if (current == last_volume_ || current > config_.max_value) {
return;
}
last_volume_ = current;
float volume = ByteToVolume(current, config_.max_value);
XELOGD("XmpVolumePatch: polled volume change={}/{} -> {:.2f}", current,
config_.max_value, volume);
auto* player = kernel_state_->emulator()->audio_media_player();
if (player) {
player->SetVolume(volume);
}
}
void XmpVolumePatch::OnFileWrite(const std::string& path, const uint8_t* buffer,
uint32_t length, uint32_t guest_address) {
if (Matches(path, length)) {
ApplyVolume(buffer, length);
}
}
void XmpVolumePatch::OnFileRead(const std::string& path, const uint8_t* buffer,
uint32_t length, uint32_t guest_address) {
if (Matches(path, length)) {
settings_guest_addr_ = guest_address;
ApplyVolume(buffer, length);
}
}
void XmpVolumePatch::OnInputPoll(uint32_t packet_number) {
if (packet_number == last_packet_number_) {
return;
}
last_packet_number_ = packet_number;
PollGuestVolume();
}
std::unique_ptr<XmpVolumePatch> XmpVolumePatch::CreateForTitle(
uint32_t title_id, KernelState* ks) {
XmpVolumeConfig config;
switch (title_id) {
case 0x544307D2: // DOAX2
config = {"ups.dat", 0, 0x50, 100};
break;
case 0x4D5307D1: // PGR3
config = {"profile.save", 528, 19, 10};
break;
case 0x4D5307F9: // PGR4
config = {"savegame", 344, 2, 10};
break;
default:
return nullptr;
}
XELOGI("XmpVolumePatch: Loading for title {:08X}", title_id);
return std::make_unique<XmpVolumePatch>(ks, config);
}
} // namespace kernel
} // namespace xe

View File

@@ -0,0 +1,70 @@
/**
******************************************************************************
* Xenia : Xbox 360 Emulator Research Project *
******************************************************************************
* Copyright 2026 Ben Vanik. All rights reserved. *
* Released under the BSD license - see LICENSE in the root for more details. *
******************************************************************************
*/
#ifndef XENIA_KERNEL_UTIL_XMP_VOLUME_PATCH_H_
#define XENIA_KERNEL_UTIL_XMP_VOLUME_PATCH_H_
#include <cstdint>
#include <memory>
#include <string>
namespace xe {
namespace kernel {
class KernelState;
// Patch for games that store their BGM volume in a save file but never call
// XMPSetVolume. Each game just needs to specify:
// - file_name: substring to match in the file path
// - write_length: exact length of the write containing the volume byte
// (0 to match any write >= offset + 1)
// - offset: byte offset of the volume value within the write buffer
// - max_value: maximum volume value (maps to 1.0f)
//
// On savegame read/write, the patch reads the volume byte and syncs it to
// the XMP audio player. It also captures the guest memory address of the
// buffer and re-reads it on controller input changes to detect in-game
// volume adjustments before they are saved.
struct XmpVolumeConfig {
const char* file_name;
uint32_t write_length;
uint32_t offset;
uint8_t max_value;
};
class XmpVolumePatch {
public:
XmpVolumePatch(KernelState* kernel_state, const XmpVolumeConfig& config)
: kernel_state_(kernel_state), config_(config) {}
void OnFileWrite(const std::string& path, const uint8_t* buffer,
uint32_t length, uint32_t guest_address);
void OnFileRead(const std::string& path, const uint8_t* buffer,
uint32_t length, uint32_t guest_address);
void OnInputPoll(uint32_t packet_number);
static std::unique_ptr<XmpVolumePatch> CreateForTitle(uint32_t title_id,
KernelState* ks);
private:
bool Matches(const std::string& path, uint32_t length) const;
void ApplyVolume(const uint8_t* buffer, uint32_t length);
void PollGuestVolume();
KernelState* kernel_state_;
XmpVolumeConfig config_;
uint32_t settings_guest_addr_ = 0;
uint8_t last_volume_ = 0xFF;
uint32_t last_packet_number_ = 0;
};
} // namespace kernel
} // namespace xe
#endif // XENIA_KERNEL_UTIL_XMP_VOLUME_PATCH_H_

View File

@@ -121,11 +121,22 @@ dword_result_t XamInputGetState_entry(dword_t user_index, dword_t flags,
actual_user_index = 0;
}
X_RESULT result;
auto input_system = kernel_state()->emulator()->input_system();
auto lock = input_system->lock();
return input_system->GetState(
user_index, !flags ? X_INPUT_FLAG::X_INPUT_FLAG_GAMEPAD : flags,
input_state);
{
auto lock = input_system->lock();
result = input_system->GetState(
user_index, !flags ? X_INPUT_FLAG::X_INPUT_FLAG_GAMEPAD : flags,
input_state);
}
if (input_state && result == X_ERROR_SUCCESS) {
if (auto patch = kernel_state()->xmp_volume_patch()) {
patch->OnInputPoll(input_state->packet_number);
}
}
return result;
}
DECLARE_XAM_EXPORT2(XamInputGetState, kInput, kImplemented, kHighFrequency);

View File

@@ -171,6 +171,15 @@ dword_result_t NtReadFile_entry(dword_t file_handle, dword_t event_handle,
// Mark that we should signal the event now. We do this after
// we have written the info out.
signal_event = true;
if (XSUCCEEDED(result)) {
if (auto patch = kernel_state()->xmp_volume_patch()) {
auto host_buf =
kernel_memory()->TranslateVirtual(buffer.guest_address());
patch->OnFileRead(file->entry()->name(), host_buf, buffer_length,
buffer.guest_address());
}
}
} else {
// TODO(benvanik): async.
@@ -346,6 +355,15 @@ dword_result_t NtWriteFile_entry(dword_t file_handle, dword_t event_handle,
// Mark that we should signal the event now. We do this after
// we have written the info out.
signal_event = true;
if (XSUCCEEDED(result)) {
if (auto patch = kernel_state()->xmp_volume_patch()) {
auto host_buf =
kernel_memory()->TranslateVirtual(buffer.guest_address());
patch->OnFileWrite(file->entry()->name(), host_buf, buffer_length,
buffer.guest_address());
}
}
} else {
// X_STATUS_PENDING if not returning immediately.
result = X_STATUS_PENDING;