Online First-person camera anchor fix

This commit is contained in:
iChris4 committed 2026-09-10 03:56:42 +02:00
1 parent 0353b76baa
commit ee34fbe419
5 files changed
+320 -46

No files matched your search

+5 -1
View File
@@ -89,10 +89,14 @@ four are live and are also exposed in the F10 settings bar.
By default the headset sits where Mario Kart's own chase camera sits, and `world_units_per_meter`
of 500 presents the race as a small diorama on a table. Turning on `first_person` moves the camera
to the Player 1 driver's head instead, and switches the world scale to
to the local driver's head instead, and switches the world scale to
`first_person_units_per_meter`, whose default of 30 is what makes the race read life-size from the
seat. It is a matter of taste rather than a property of the game, so the F10 bar exposes it.
The kart is selected through the game's local-screen-to-racer mapping, including online races
where your racer is not slot zero. First person requires a locally controlled racer; spectating
another racer keeps the game's own camera.
The game's own transforms are never modified. Each guest frame the runtime reads the race camera's
view matrix and the player kart's physics pose and derives one affine transform from the recorded
view space into the space to render from. That transform is published with the sealed frame, and
+6
View File
@@ -345,6 +345,12 @@ target_include_directories(mkw_vr_first_person_tests PRIVATE "${CMAKE_CURRENT_LI
target_compile_features(mkw_vr_first_person_tests PRIVATE cxx_std_17)
add_test(NAME mkw_vr_first_person_tests COMMAND mkw_vr_first_person_tests)
# Resolve the real local-kart pointer walk against synthetic offline/online rosters.
add_executable(mkw_vr_player_tests "${CMAKE_CURRENT_LIST_DIR}/tests/vr_player_tests.cpp")
target_include_directories(mkw_vr_player_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_features(mkw_vr_player_tests PRIVATE cxx_std_17)
add_test(NAME mkw_vr_player_tests COMMAND mkw_vr_player_tests)
if(MKW_ENABLE_OPENXR AND MKW_PLATFORM_WINDOWS)
add_executable(mkw_openxr_replay_tests
tests/openxr_d3d12_replay_tests.cpp src/vr/openxr_d3d12.cpp)
+88
View File
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <cstdint>
namespace mkw::vr::detail {
struct LocalPlayerKartRead {
const char* failed_step = nullptr;
uint32_t race_data = 0;
uint32_t player_index = 0xFFu;
uint32_t manager = 0;
uint32_t players = 0;
uint32_t proxy = 0;
uint32_t accessor = 0;
};
// PAL RMCP01. Use the same screen-to-racer mapping as RaceCameraMgr::__ct
// (0x805A8468), not a kart-array slot or a search for a non-CPU racer.
// The memory provider is injectable so the actual pointer walk can be tested
// without a running guest. Production uses Memory's checked, big-endian reads.
template <typename GuestMemory>
LocalPlayerKartRead ReadLocalPlayerKart() noexcept {
// Racedata::CreateInstance (0x8052FE58): 0x809C0000 - 10456.
constexpr uint32_t kRaceDataInstanceAddress = 0x809BD728u;
// Racedata::GetPlayerIdOfLocalPlayer (0x80531F70): byte +2948 + screen.
constexpr uint32_t kHudPlayerIdsOffset = 0xB84u;
// Active race scenario, not the menu scenario. GetRacePlayerCount
// (0x8052DD30) reads +0x24; the local count is the byte at +0x26.
constexpr uint32_t kPlayerCountOffset = 0x24u;
constexpr uint32_t kLocalPlayerCountOffset = 0x26u;
// Scenario::GetPlayer (0x8052DD20): scenario +8 + index*0xF0.
// Race scenario starts at +0x20; Player::GetPlayerType (0x8052ED20)
// reads +0x10. TYPE_REAL_LOCAL is 0; TYPE_REAL_ONLINE is 4.
constexpr uint32_t kPlayerTypeOffset = 0x38u;
constexpr uint32_t kPlayerStride = 0xF0u;
constexpr uint32_t kMaxPlayers = 12;
// Kart::Manager::CreateInstance (0x8058FAA8), GetKartPlayer (0x80590100),
// and Kart::Link's shared proxy -> accessor link.
constexpr uint32_t kKartManagerInstanceAddress = 0x809C18F8u;
constexpr uint32_t kKartManagerPlayersOffset = 0x20u;
LocalPlayerKartRead read{};
const auto pointer = [](uint32_t address, uint32_t& out) {
return GuestMemory::TryRead32(address, out) && out != 0;
};
if (!pointer(kRaceDataInstanceAddress, read.race_data)) {
read.failed_step = "Racedata instance";
return read;
}
read.failed_step = "local racer mapping";
if (!GuestMemory::Contains(read.race_data, kHudPlayerIdsOffset + 1u)) {
return read;
}
try {
const uint32_t player_count = GuestMemory::Read8(read.race_data + kPlayerCountOffset);
if (player_count == 0 || player_count > kMaxPlayers ||
GuestMemory::Read8(read.race_data + kLocalPlayerCountOffset) != 1) {
return read;
}
// Immersive first person supports one local screen. Its racer may be
// anywhere in the online roster. 0xFF means no racer is assigned.
read.player_index = GuestMemory::Read8(read.race_data + kHudPlayerIdsOffset);
if (read.player_index >= player_count ||
GuestMemory::Read32(read.race_data + kPlayerTypeOffset +
read.player_index * kPlayerStride) != 0) {
return read;
}
} catch (const typename GuestMemory::AccessViolation&) {
return read;
}
if (!pointer(kKartManagerInstanceAddress, read.manager)) {
read.failed_step = "Kart::Manager instance";
} else if (!pointer(read.manager + kKartManagerPlayersOffset, read.players)) {
read.failed_step = "Kart::Manager players array";
} else if (!pointer(read.players + read.player_index * 4u, read.proxy)) {
read.failed_step = "local player kart object";
} else if (!pointer(read.proxy, read.accessor)) {
read.failed_step = "local kart accessor";
} else {
read.failed_step = nullptr;
}
return read;
}
} // namespace mkw::vr::detail
+30 -45
View File
@@ -6,6 +6,7 @@
#include "runtime_config.h"
#include "runtime_log.h"
#include "vr/mkw_vr_policy.h"
#include "vr/mkw_vr_player.h"
#include <mutex>
#include <string>
@@ -50,19 +51,9 @@ constexpr uint32_t kG3DCameraMtxStateAddress = 0x802BBAB4u;
constexpr uint32_t kG3DCameraMtxBankOffset = 0x2u;
constexpr uint32_t kG3DCameraMtxOffset = 52u;
// Kart::Manager's instance pointer. Its CreateInstance (0x8058FAA8) resolves
// the slot as 0x809C0000 + 6392 in the generated translation. Read directly
// rather than observed from Kart::Manager::Update's r3, so enabling the camera
// needs no change to the translated output: an entry observer only exists in a
// build whose translation was regenerated for it, and its absence is silent.
// This mirrors how the race scene's instance slot is reached in
// mkw_vr_instrumentation.cpp.
constexpr uint32_t kKartManagerInstanceAddress = 0x809C18F8u;
// Kart::Manager::GetKartPlayer (0x80590100): `lwz r3,0x20(r3)` then indexes.
constexpr uint32_t kKartManagerPlayersOffset = 0x20u;
// Kart::Link::GetKartPosition (0x8059020C) walks proxy -> accessor -> body ->
// physics -> dynamics; the first three links are shared by every kart accessor.
constexpr uint32_t kKartProxyAccessorOffset = 0x00u;
// physics -> dynamics. The local racer and its accessor are resolved by
// ReadLocalPlayerKart, shared with driver visibility.
constexpr uint32_t kKartAccessorBodyOffset = 0x08u;
constexpr uint32_t kKartBodyPhysicsOffset = 0x90u;
// KartPhysics::pose (Kart::Link::GetMtx 0x80590264). This is the physics-driven
@@ -96,10 +87,6 @@ constexpr uint32_t kModelHolderArrayOffset = 0xD8u;
constexpr uint32_t kModelHolderCountOffset = 0xF0u;
constexpr uint32_t kMaxPlayerModels = 32;
// Offline Mario Kart Wii puts the local racer first, and immersive
// presentation already requires exactly one on-screen player.
constexpr uint32_t kLocalPlayerIndex = 0;
// Frames the last good anchor survives a failed read before the camera returns
// to the game's own. Rides out a transient null during a respawn or transition
// without letting a genuinely broken anchor persist.
@@ -184,27 +171,17 @@ bool ReadRaceCameraViewMatrix(const CpuContext* context, uint32_t camera_address
// The pointer walk, kept inspectable: on failure `failed_step` names the link
// that broke and the resolved pointers before it are still filled in. One log
// line then says exactly which offset needs revisiting.
struct KartPoseRead {
const char* failed_step = nullptr;
uint32_t manager = 0;
uint32_t players = 0;
uint32_t proxy = 0;
uint32_t accessor = 0;
struct KartPoseRead : detail::LocalPlayerKartRead {
uint32_t body = 0;
uint32_t physics = 0;
};
KartPoseRead ReadPlayerKartPose(Mtx34& out) noexcept {
KartPoseRead read{};
if (!ReadGuestPointer(kKartManagerInstanceAddress, read.manager)) {
read.failed_step = "Kart::Manager instance";
} else if (!ReadGuestPointer(read.manager + kKartManagerPlayersOffset, read.players)) {
read.failed_step = "Kart::Manager players array";
} else if (!ReadGuestPointer(read.players + kLocalPlayerIndex * 4u, read.proxy)) {
read.failed_step = "player kart object";
} else if (!ReadGuestPointer(read.proxy + kKartProxyAccessorOffset, read.accessor)) {
read.failed_step = "kart accessor";
} else if (!ReadGuestPointer(read.accessor + kKartAccessorBodyOffset, read.body)) {
KartPoseRead ReadPlayerKartPose(const detail::LocalPlayerKartRead& player, Mtx34& out) noexcept {
KartPoseRead read{player};
if (read.failed_step != nullptr) {
return read;
}
if (!ReadGuestPointer(read.accessor + kKartAccessorBodyOffset, read.body)) {
read.failed_step = "kart body";
} else if (!ReadGuestPointer(read.body + kKartBodyPhysicsOffset, read.physics)) {
read.failed_step = "kart physics";
@@ -240,6 +217,7 @@ struct FirstPersonState {
FirstPersonRotation rotation = FirstPersonRotation::YawOnly;
uint32_t camera_address = 0;
detail::LocalPlayerKartRead player_kart{};
// Armed by the draw boundary, consumed by the frame seal.
bool armed = false;
uint64_t armed_frame = 0;
@@ -260,16 +238,9 @@ FirstPersonState g_state;
ModelVisibilityState g_visibility;
// Walks to the player's ModelsVisibility, or zero when the race is not up.
uint32_t ResolveModelsVisibility() noexcept {
uint32_t manager = 0;
uint32_t players = 0;
uint32_t proxy = 0;
uint32_t accessor = 0;
uint32_t ResolveModelsVisibility(uint32_t accessor) noexcept {
uint32_t visibility = 0;
if (!ReadGuestPointer(kKartManagerInstanceAddress, manager) ||
!ReadGuestPointer(manager + kKartManagerPlayersOffset, players) ||
!ReadGuestPointer(players + kLocalPlayerIndex * 4u, proxy) ||
!ReadGuestPointer(proxy + kKartProxyAccessorOffset, accessor) ||
if (accessor == 0 ||
!ReadGuestPointer(accessor + kKartAccessorModelsVisibilityOffset, visibility)) {
return 0;
}
@@ -362,7 +333,7 @@ void ApplyModelVisibilityLocked() noexcept {
RestoreModelVisibilityLocked();
return;
}
const uint32_t visibility = ResolveModelsVisibility();
const uint32_t visibility = ResolveModelsVisibility(g_state.player_kart.accessor);
if (visibility == 0 ||
!Memory::Contains(visibility + kModelsVisibilityDrawOffset, 1)) {
return;
@@ -442,6 +413,7 @@ void LogAnchorLocked(uint64_t frame, const Mtx34& anchor, const Mtx34& view_from
// little below the chase camera, and well in front of it.
RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] first-person anchor: frame=" << frame << ", camera=0x"
<< std::hex << g_state.camera_address << std::dec
<< ", local racer=" << kart.player_index
<< ", head from camera (right, up, forward)=(" << -anchor[3] << ", "
<< -anchor[7] << ", " << anchor[11] << ") units" << std::endl;
RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] first-person view: rows=(" << view_from_world[0] << ", "
@@ -541,6 +513,7 @@ void MkwVRFirstPersonReset() noexcept {
g_state.armed = false;
g_state.armed_view_valid = false;
g_state.camera_address = 0;
g_state.player_kart = {};
g_state.anchor = {};
g_state.hold_frames = 0;
g_state.ever_valid_this_race = false;
@@ -558,6 +531,15 @@ void MkwVRFirstPersonUpdate(uint64_t guest_frame_index, uint32_t race_camera_add
RestoreModelVisibilityLocked();
return;
}
const auto player = detail::ReadLocalPlayerKart<Memory>();
if (player.failed_step != nullptr || player.accessor != g_state.player_kart.accessor) {
// Do not carry a held anchor or hidden models across an ownership
// change, including entering spectator mode or an online roster reset.
RestoreModelVisibilityLocked();
g_state.anchor = {};
g_state.hold_frames = 0;
}
g_state.player_kart = player;
g_state.armed = true;
g_state.armed_frame = guest_frame_index;
g_state.armed_view_valid = ReadSceneViewMatrix(g_state.armed_view);
@@ -591,7 +573,8 @@ void MkwVRFirstPersonCommit() noexcept {
!(g_state.camera_address != 0 &&
ReadRaceCameraViewMatrix(TryGetCpuContext(), g_state.camera_address, view_from_world))) {
failed_step = "scene view matrix";
} else if (kart = ReadPlayerKartPose(kart_from_local); kart.failed_step != nullptr) {
} else if (kart = ReadPlayerKartPose(g_state.player_kart, kart_from_local);
kart.failed_step != nullptr) {
failed_step = kart.failed_step;
} else if (!ComputeFirstPersonAnchor(view_from_world, kart_from_local,
g_state.offsets.right * g_state.units_per_meter,
@@ -621,7 +604,9 @@ void MkwVRFirstPersonCommit() noexcept {
RT_LOG(RT_TAG_RUNTIME)
<< "[mkw-vr] first-person camera is enabled but could not resolve the "
<< failed_step << "; staying on the game's own camera (camera=0x" << std::hex
<< g_state.camera_address << ", manager=0x" << kart.manager << ", players=0x"
<< g_state.camera_address << ", race data=0x" << kart.race_data
<< ", local racer=" << std::dec << kart.player_index << std::hex
<< ", manager=0x" << kart.manager << ", players=0x"
<< kart.players << ", kart=0x" << kart.proxy << ", accessor=0x" << kart.accessor
<< ", body=0x" << kart.body << ", physics=0x" << kart.physics << std::dec << ")"
<< std::endl;
+191
View File
@@ -0,0 +1,191 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Exercise the production local-kart resolver against a synthetic PAL roster.
#include "vr/mkw_vr_player.h"
#include <iostream>
#include <stdexcept>
#include <unordered_map>
namespace {
int g_failures = 0;
void Check(bool condition, const char* what) {
if (!condition) {
++g_failures;
std::cerr << "FAILED: " << what << '\n';
}
}
struct GuestMemory {
using AccessViolation = std::out_of_range;
inline static std::unordered_map<uint32_t, uint8_t> bytes;
inline static uint32_t fault_address = 0;
static bool Contains(uint32_t address, uint32_t size) {
for (uint32_t i = 0; i < size; ++i) {
if (bytes.count(address + i) == 0) {
return false;
}
}
return true;
}
static uint8_t Read8(uint32_t address) {
if (address == fault_address) {
throw AccessViolation("injected read fault");
}
return bytes.at(address);
}
static uint32_t Read32(uint32_t address) {
uint32_t value = 0;
for (uint32_t i = 0; i < 4; ++i) {
value = (value << 8) | Read8(address + i);
}
return value;
}
static bool TryRead32(uint32_t address, uint32_t& out) {
try {
out = Read32(address);
return true;
} catch (const AccessViolation&) {
return false;
}
}
static void Write32(uint32_t address, uint32_t value) {
for (uint32_t i = 0; i < 4; ++i) {
bytes[address + i] = static_cast<uint8_t>(value >> (24 - 8 * i));
}
}
};
constexpr uint32_t kRaceData = 0x81000000u;
constexpr uint32_t kManager = 0x81100000u;
constexpr uint32_t kKarts = 0x81100100u;
constexpr uint32_t kProxies = 0x81200000u;
constexpr uint32_t kAccessors = 0x81300000u;
void SetLocalRacer(uint8_t player) {
GuestMemory::bytes[kRaceData + 0xB84] = player;
for (uint32_t i = 0; i < 12; ++i) {
GuestMemory::Write32(kRaceData + 0x38 + i * 0xF0, i == player ? 0 : 4);
}
}
void MakeRace(uint8_t local_player) {
GuestMemory::bytes.clear();
GuestMemory::fault_address = 0;
for (uint32_t i = 0; i < 0xB90; ++i) {
GuestMemory::bytes[kRaceData + i] = 0;
}
GuestMemory::Write32(0x809BD728u, kRaceData);
GuestMemory::Write32(0x809C18F8u, kManager);
GuestMemory::Write32(kManager + 0x20, kKarts);
GuestMemory::bytes[kRaceData + 0x24] = 12;
GuestMemory::bytes[kRaceData + 0x26] = 1;
for (uint32_t i = 0; i < 12; ++i) {
GuestMemory::Write32(kKarts + i * 4, kProxies + i * 0x100);
GuestMemory::Write32(kProxies + i * 0x100, kAccessors + i * 0x100);
}
SetLocalRacer(local_player);
}
auto Resolve() {
return mkw::vr::detail::ReadLocalPlayerKart<GuestMemory>();
}
void TestSinglePlayer() {
MakeRace(0);
for (uint32_t i = 1; i < 12; ++i) {
GuestMemory::Write32(kRaceData + 0x38 + i * 0xF0, 1); // CPU
}
const auto read = Resolve();
Check(read.failed_step == nullptr && read.player_index == 0 &&
read.accessor == kAccessors,
"offline racing still selects the local kart in slot zero");
}
void TestEveryOnlineSlot() {
for (uint8_t player = 0; player < 12; ++player) {
MakeRace(player);
const auto read = Resolve();
Check(read.failed_step == nullptr && read.player_index == player &&
read.proxy == kProxies + player * 0x100u &&
read.accessor == kAccessors + player * 0x100u,
"an all-human online roster selects only the screen's local racer");
}
}
void TestRosterRemapping() {
MakeRace(5);
Check(Resolve().accessor == kAccessors + 0x500, "initial online assignment");
SetLocalRacer(11);
Check(Resolve().accessor == kAccessors + 0xB00, "a new roster assignment is read afresh");
}
void TestNoOpponentFallback() {
for (uint32_t type : {1u, 2u, 3u, 4u, 5u}) {
MakeRace(5);
GuestMemory::Write32(kRaceData + 0x38 + 5 * 0xF0, type);
GuestMemory::Write32(kRaceData + 0x38, 0); // Another valid kart is not a fallback.
const auto read = Resolve();
Check(read.failed_step != nullptr && read.accessor == 0,
"a mapped CPU, unused, ghost, remote or absent racer cannot be an anchor");
}
MakeRace(5);
GuestMemory::Write32(kKarts + 5 * 4, 0);
Check(Resolve().failed_step != nullptr && Resolve().accessor == 0,
"a missing local kart never falls back to another valid kart");
}
void TestInvalidMapping() {
for (uint8_t player : {12, 127, 255}) {
MakeRace(5);
GuestMemory::bytes[kRaceData + 0xB84] = player;
Check(Resolve().failed_step != nullptr, "out-of-range and unassigned HUD racers fail");
}
MakeRace(5);
GuestMemory::bytes[kRaceData + 0x24] = 5;
Check(Resolve().failed_step != nullptr, "racer must be inside the active roster");
for (uint8_t count : {0, 13}) {
MakeRace(5);
GuestMemory::bytes[kRaceData + 0x24] = count;
Check(Resolve().failed_step != nullptr, "empty and corrupt rosters fail");
}
for (uint8_t count : {0, 2}) {
MakeRace(5);
GuestMemory::bytes[kRaceData + 0x26] = count;
Check(Resolve().failed_step != nullptr, "spectating and split-screen have no anchor");
}
}
void TestUnavailableGuestData() {
MakeRace(5);
GuestMemory::Write32(0x809BD728u, 0);
Check(Resolve().failed_step != nullptr, "missing race data fails");
MakeRace(5);
GuestMemory::bytes.erase(kRaceData + 0xB84);
Check(Resolve().failed_step != nullptr, "unmapped race data fails");
MakeRace(5);
GuestMemory::fault_address = kRaceData + 0x38 + 5 * 0xF0;
Check(Resolve().failed_step != nullptr, "a fault after the bounds check is contained");
MakeRace(5);
GuestMemory::bytes.erase(kProxies + 5 * 0x100);
Check(Resolve().failed_step != nullptr, "an unreadable local accessor fails");
}
} // namespace
int main() {
TestSinglePlayer();
TestEveryOnlineSlot();
TestRosterRemapping();
TestNoOpponentFallback();
TestInvalidMapping();
TestUnavailableGuestData();
return g_failures == 0 ? 0 : 1;
}