Added Support for Multiplayer Player 1 Immersive View

This commit is contained in:
iChris4 committed 2026-09-16 17:06:03 +02:00
1 parent 9e37bb6447
commit 3e8bdcfd7b
22 files changed
+1012 -37

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
<Project>
<PropertyGroup>
<Version>0.2.32</Version>
<Version>0.2.38</Version>
</PropertyGroup>
</Project>
+31
View File
@@ -66,6 +66,30 @@ image is chosen, so the setting can always be changed back.
Eye mirror modes retain the last eye image when a desktop frame has no new XR packet, so they
do not alternate with the normal camera. `"none"` also stays black between XR packets.
## Local multiplayer
During 2-, 3-, and 4-player races, the headset replays Player 1's world in immersive stereo
with head tracking. Keep **F10 > VR > Desktop view** set to **Normal** (`mirror_view = "normal"`)
for the original desktop split-screen layout. No extra multiplayer switch is required.
Menus continue to use the virtual screen.
Only headset replay filters the other players' viewports and expands Player 1 to each eye.
The desktop split-screen partition (the game's `partition_line` layout, one-pixel textured
picture panes on the split boundaries) and full masks of the other panes are omitted from
the eyes; the desktop image keeps them.
Player-local HUD viewports follow Player 1; shared orthographic overlays keep their full-screen
layout on the virtual screen. Framebuffer effects that sample the desktop split-screen image
are omitted from multiplayer eyes, since those textures contain the other cameras too.
The local-screen count is sealed with each frame, including retained VR interpolation frames,
and a layout change invalidates older XR packets.
Multiplayer uses Player 1's game camera. The optional first-person relocation and model hiding
remain single-player-only: guest model visibility changes would also affect the desktop players.
The opt-in `stereo_multiplayer_smoke` D3D12 test reads back both eye images and the desktop EFB
for 1/2/3/4/1-screen transitions with VR interpolation on and off. Actual headset racing still
needs visual validation for course effects, HUD layout, pause/resume, and scene transitions.
**F10 > VR > VR frame interpolation (experimental)** offers **Off, Auto, 72, 90, 120** and
applies immediately. `frame_interpolation_fps` stores `0` for Off (the default), `1` for Auto,
or the selected rate. The earlier `frame_interpolation = true` checkbox migrates to Auto.
@@ -240,6 +264,13 @@ so it can be enabled once Aurora exposes those handles safely.
### Interpolation validation
Probe-sized EFB readbacks retain completed pixels in host memory and publish them only inside
the next compatible `GXCopyTex` call. GPU completion callbacks must not write to guest RAM:
a race restart can reuse a freed probe buffer for `RaceCamera`, and a late 4x4 Z24X8 tile then
turns its rotation fields into NaNs and triggers `triangular.h` / `PPCHalt`.
The optional Windows GPU test `efb_ram_lifetime_smoke` exercises that allocation reuse and
format/size changes. It fails with the former callback write and passes with deferred publication.
The GX tests cover retained transform endpoints with desktop interpolation off and continuous
sampling at 72/90/120 Hz. `mkw_frame_interpolation_pacing_tests` covers fixed-rate scheduling,
live changes, stalls and configuration migration; `mkw_openxr_replay_tests` exercises swapchain
+4
View File
@@ -236,6 +236,10 @@ void aurora_end_frame_tagged(uint64_t contentTag);
* provider, which cannot know which frame will consume its packet.
*/
void aurora_set_stereo_scene_anchor(const float anchorFromScene[12]);
// Select Player 1's subview for immersive replay of 2-4 local screens.
// Producer-thread, per-frame metadata, consumed by the next end_frame call.
// One (the default) keeps full-frame replay. Desktop rendering is unaffected.
void aurora_set_stereo_local_player_count(uint32_t count);
typedef void (*AuroraFrameWorkerWaitCallback)();
// Called from the producer thread at bounded intervals while Aurora waits for
// the asynchronous frame worker. The callback must not enter Aurora.
+9 -1
View File
@@ -100,11 +100,13 @@ struct StereoSceneAnchor {
0.f, 0.f, 1.f, 0.f,
};
bool active = false;
uint32_t localPlayerCount = 1;
};
// Producer thread only, between aurora_set_stereo_scene_anchor() and the seal
// that consumes it. Cleared at every seal so a producer that stops publishing
// falls back to the recorded camera instead of freezing on a stale anchor.
StereoSceneAnchor g_pendingSceneAnchor;
uint32_t g_pendingStereoLocalPlayerCount = 1;
using PresentClock = std::chrono::steady_clock;
@@ -1762,6 +1764,7 @@ void seal_frame_locked(gfx::SealedFrame& sealedFrame, SealedFrameContext& ctx, u
// current_frame() advances inside gfx::end_frame; unsigned wrap maps the
// pre-first-frame UINT32_MAX value to logical frame zero.
ctx.logicalFrame = gfx::current_frame() + 1;
gfx::set_stereo_local_player_count(sceneAnchor.localPlayerCount);
if (const auto stereoInput = request_stereo_frame(ctx.logicalFrame, contentTag)) {
ctx.stereoInput = stereoInput;
ctx.stereoFrameToken = stereoInput->frameToken;
@@ -2246,7 +2249,9 @@ void end_frame(uint64_t contentTag) noexcept {
#endif
// Claim the anchor published for this frame. Clearing it here is what makes a
// producer that stops publishing fall back to the recorded camera.
const StereoSceneAnchor sceneAnchor = g_pendingSceneAnchor;
StereoSceneAnchor sceneAnchor = g_pendingSceneAnchor;
sceneAnchor.localPlayerCount = g_pendingStereoLocalPlayerCount;
g_pendingStereoLocalPlayerCount = 1;
g_pendingSceneAnchor = {};
if (!frame_worker_requested()) {
end_frame_impl(true, true, contentTag, sceneAnchor);
@@ -2378,6 +2383,9 @@ void aurora_end_frame_tagged(uint64_t contentTag) { aurora::end_frame(contentTag
void aurora_set_stereo_scene_anchor(const float anchorFromScene[12]) {
aurora::set_stereo_scene_anchor(anchorFromScene);
}
void aurora_set_stereo_local_player_count(uint32_t count) {
aurora::g_pendingStereoLocalPlayerCount = count >= 1 && count <= 4 ? count : 1;
}
void aurora_set_frame_worker_wait_callback(AuroraFrameWorkerWaitCallback callback) {
aurora::g_frameWorkerWaitCallback.store(callback, std::memory_order_release);
}
+79 -9
View File
@@ -305,6 +305,7 @@ static void recycle_render_passes(std::vector<RenderPass>& passes) noexcept {
struct LateStereoUniform {
gx::UniformReplayLayout layout;
Viewport viewport;
ClipRect displayRegion;
Range current;
Range previous;
std::array<Range, AURORA_STEREO_EYE_COUNT> eyes;
@@ -322,9 +323,16 @@ struct LateStereoData {
// Advanced for every upload, including synchronous mid-frame EFB readbacks.
static std::atomic_uint64_t g_replayBufferGeneration{0};
static LateStereoData g_pendingLateStereo;
static uint32_t g_stereoLocalPlayerCount = 1;
void set_stereo_local_player_count(uint32_t count) noexcept {
g_stereoLocalPlayerCount = count >= 1 && count <= 4 ? count : 1;
}
struct SealedFrameData {
std::vector<RenderPass> passes;
LateStereoData stereo;
uint32_t localPlayerCount = 1;
};
SealedFrame::SealedFrame() : m_data(std::make_unique<SealedFrameData>()) {}
@@ -1396,6 +1404,10 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame,
LateStereoData* history = nullptr) noexcept {
const StereoDisplaySource displaySource = stereo_display_source(g_renderPasses);
const ClipRect displayRegion = displaySource.region;
const bool multiplayer = g_stereoLocalPlayerCount > 1;
const auto playerRegion = stereo_replay::player_one_region(
{float(displayRegion.x), float(displayRegion.y), float(displayRegion.width), float(displayRegion.height)},
g_stereoLocalPlayerCount);
// This is the producer-side preparation path; eye replay can query the pure
// helper concurrently without touching this diagnostic state.
log_stereo_display_source_region(displayRegion, displaySource.foundDisplayCopy);
@@ -1403,18 +1415,25 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame,
// A draw is replayed per eye when it carries the game camera (perspective) or
// when it is 2D content the virtual screen is claiming.
const auto replayed = [&](const gx::UniformReplayLayout& layout) noexcept {
return layout.perspective || (hudScreen.valid() && !layout.nativeEfbEffect);
return (!multiplayer || !layout.nativeEfbEffect) &&
(layout.perspective || (hudScreen.valid() && !layout.nativeEfbEffect));
};
size_t requiredBytes = 0;
size_t efbPassCount = 0;
size_t perspectiveDrawCount = 0;
size_t replayPerspectiveDrawCount = 0;
size_t replayHudScreenDrawCount = 0;
stereo_replay::SubviewRect allocationViewport{float(displayRegion.x), float(displayRegion.y),
float(displayRegion.width), float(displayRegion.height)};
for (const auto& pass : g_renderPasses) {
if (pass.efbTarget) {
++efbPassCount;
}
for (const auto& command : pass.commands) {
if (pass.efbTarget && command.type == CommandType::SetViewport) {
const auto& vp = command.data.setViewport;
allocationViewport = {vp.left, vp.top, vp.width, vp.height};
}
if (command.type != CommandType::Draw || command.data.draw.type != ShaderType::GX ||
!replayed(command.data.draw.gx.uniformReplayLayout)) {
continue;
@@ -1427,6 +1446,10 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame,
if (!pass.efbTarget) {
continue;
}
if (multiplayer && !stereo_replay::replay_player_one_draw(allocationViewport, playerRegion, layout.perspective,
layout.nativeEfbEffect)) {
continue;
}
if (layout.perspective) {
++replayPerspectiveDrawCount;
} else {
@@ -1499,6 +1522,18 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame,
}
auto& draw = command.data.draw.gx;
const auto& layout = draw.uniformReplayLayout;
const stereo_replay::SubviewRect viewportRect{drawViewport.left, drawViewport.top, drawViewport.width,
drawViewport.height};
if (multiplayer && !stereo_replay::replay_player_one_draw(viewportRect, playerRegion, layout.perspective,
layout.nativeEfbEffect)) {
continue;
}
// Pane-local HUD coordinates expand with P1. Shared race/pause overlays
// retain their full-screen layout on the virtual screen.
const bool playerLocal = multiplayer && stereo_replay::subview_contains(playerRegion, viewportRect);
const ClipRect uniformRegion = playerLocal ? ClipRect{int32_t(playerRegion.left), int32_t(playerRegion.top),
int32_t(playerRegion.width), int32_t(playerRegion.height)}
: displayRegion;
std::memcpy(sourceUniform.data(), g_uniforms.data() + draw.uniformRange.offset, draw.uniformRange.size);
Mat4x4<float> gameProjection;
std::memcpy(&gameProjection, sourceUniform.data() + layout.projectionOffset, sizeof(gameProjection));
@@ -1522,6 +1557,7 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame,
saved = &history->uniforms.emplace_back();
saved->layout = layout;
saved->viewport = drawViewport;
saved->displayRegion = uniformRegion;
const auto save = [&](const uint8_t* source, uint32_t size) -> Range {
if (size == 0)
return {};
@@ -1544,7 +1580,7 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame,
}
const auto& eye = stereoFrame.eyes[eyeIndex];
std::memcpy(eyeUniform.data(), sourceUniform.data(), range.size);
write_stereo_uniform({eyeUniform.data(), range.size}, layout, eye, gameProjection, drawViewport, displayRegion,
write_stereo_uniform({eyeUniform.data(), range.size}, layout, eye, gameProjection, drawViewport, uniformRegion,
hudScreen);
std::memcpy(uniform.data(), eyeUniform.data(), range.size);
}
@@ -1681,6 +1717,7 @@ struct RenderInvocation {
uint32_t stereoEye = UINT32_MAX;
const ReplayTarget* target = nullptr;
ClipRect replaySourceRegion{};
uint32_t localPlayerCount = 1;
// Inclusive index of the last pass to replay; -1 replays every pass.
int32_t replayLastPass = -1;
bool finalize = true;
@@ -1857,6 +1894,8 @@ void seal_frame(SealedFrame& out) noexcept {
// producer joins the worker's DONE phase before it seals another frame.
g_retiredBindGroups.clear();
out.data().stereo = std::move(g_pendingLateStereo);
out.data().localPlayerCount = g_stereoLocalPlayerCount;
g_stereoLocalPlayerCount = 1;
auto& passes = out.data().passes;
// The previous cycle already recycled these, so this normally just hands the empty vector, its
// capacity included, back to the producer.
@@ -1928,7 +1967,7 @@ bool prepare_late_stereo_replay(SealedFrame& frame, wgpu::CommandEncoder& cmd, c
}
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
write_stereo_uniform({bytes + saved.eyes[eye].offset - data.uploadOffset, saved.current.size}, layout,
stereoFrame.eyes[eye], projection, saved.viewport, data.displayRegion, data.hudScreen);
stereoFrame.eyes[eye], projection, saved.viewport, saved.displayRegion, data.hudScreen);
}
}
// Never interpolate in mapped upload memory: write-combined pages make CPU
@@ -1961,6 +2000,7 @@ void render_stereo_eye(SealedFrame& frame, wgpu::CommandEncoder& cmd, const Ster
.stereoEye = eye,
.target = &stereoFrame.eyes[eye].target,
.replaySourceRegion = displaySource.region,
.localPlayerCount = frame.data().localPlayerCount,
.replayLastPass = lastPass,
.finalize = finalize,
.replayOnlyEfb = true,
@@ -2010,6 +2050,12 @@ static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vec
const auto& sourceSize = renderPasses[idx].targetSize;
const bool overrideTarget = invocation.target != nullptr && renderPasses[idx].efbTarget;
const auto targetSize = overrideTarget ? invocation.target->size : sourceSize;
const bool multiplayer = overrideTarget && invocation.localPlayerCount > 1;
const auto& display = invocation.replaySourceRegion;
const auto playerRegion = stereo_replay::player_one_region(
{float(display.x), float(display.y), float(display.width), float(display.height)}, invocation.localPlayerCount);
stereo_replay::SubviewRect sourceViewport{0.f, 0.f, float(sourceSize.width), float(sourceSize.height)};
auto sourceScissor = sourceViewport;
const int32_t sourceWidth = static_cast<int32_t>(sourceSize.width);
const int32_t sourceHeight = static_cast<int32_t>(sourceSize.height);
int32_t sourceRegionLeft = 0;
@@ -2086,6 +2132,7 @@ static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vec
switch (cmd.type) {
case CommandType::SetViewport: {
const auto& vp = cmd.data.setViewport;
sourceViewport = {vp.left, vp.top, vp.width, vp.height};
// WebGPU requires 0 <= minDepth <= maxDepth <= 1. vp.znear/vp.zfar are in GX's own distance
// terms (0 = near); under UseReversedZ the host depth-buffer storage direction is flipped
// (near = 1, far = 0), so this range has to be remapped through 1-x the same way the
@@ -2121,6 +2168,7 @@ static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vec
} break;
case CommandType::SetScissor: {
const auto& sc = cmd.data.setScissor;
sourceScissor = {float(sc.x), float(sc.y), float(sc.width), float(sc.height)};
const auto sourceLeft = std::clamp(sc.x, sourceRegionLeft, sourceRegionRight);
const auto sourceTop = std::clamp(sc.y, sourceRegionTop, sourceRegionBottom);
const auto sourceRight = std::clamp(sc.x + sc.width, sourceLeft, sourceRegionRight);
@@ -2146,6 +2194,18 @@ static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vec
const auto& draw = cmd.data.draw;
switch (draw.type) {
case ShaderType::GX: {
if (multiplayer && draw.gx.screenRect &&
stereo_replay::is_split_screen_furniture(
*draw.gx.screenRect, {float(display.x), float(display.y), float(display.width), float(display.height)},
invocation.localPlayerCount)) {
break;
}
if (multiplayer && (!stereo_replay::replay_player_one_draw(sourceViewport, playerRegion,
draw.gx.uniformReplayLayout.perspective,
draw.gx.uniformReplayLayout.nativeEfbEffect) ||
!stereo_replay::subviews_overlap(sourceScissor, playerRegion))) {
break;
}
const gfx::Range* uniformOverride = nullptr;
// Only a 2D draw the virtual screen actually claimed carries a stereo
// uniform range without being perspective.
@@ -2164,21 +2224,31 @@ static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vec
// frame (Mario Kart clips the item roulette that way). Honouring that
// rectangle would cut the reprojected element away, so it gets the whole
// eye and every other draw gets the game's own rectangle back.
if (!scissorStateKnown || virtualScreenDraw != hudScreenScissor) {
hudScreenScissor = virtualScreenDraw;
const bool fullEyeDraw = virtualScreenDraw || (multiplayer && draw.gx.uniformReplayLayout.perspective);
if (!scissorStateKnown || fullEyeDraw != hudScreenScissor) {
hudScreenScissor = fullEyeDraw;
scissorStateKnown = true;
apply_scissor(virtualScreenDraw ? fullTargetScissor : recordedScissor);
apply_scissor(fullEyeDraw ? fullTargetScissor : recordedScissor);
}
if (!viewportStateKnown || virtualScreenDraw != hudScreenViewport) {
hudScreenViewport = virtualScreenDraw;
if (!viewportStateKnown || fullEyeDraw != hudScreenViewport) {
hudScreenViewport = fullEyeDraw;
viewportStateKnown = true;
apply_viewport(virtualScreenDraw);
apply_viewport(fullEyeDraw);
}
gx::render(draw.gx, pass, encodeState, renderPasses[idx].requireReadyPipelines, uniformOverride,
virtualScreenDraw ? draw.gx.exactScreenDepthPipeline : 0);
} break;
case ShaderType::Clear: {
auto clearDraw = draw.clear;
if (multiplayer) {
const auto& sc = clearDraw.scissor;
if (clearDraw.copyClear ||
(clearDraw.useScissor &&
!stereo_replay::subviews_overlap({float(sc.x), float(sc.y), float(sc.width), float(sc.height)},
playerRegion))) {
break;
}
}
if (invocation.skipCopyClears && overrideTarget && clearDraw.copyClear && renderPasses[idx].postCopyClear) {
// The scissored twin of the attachment-load-op case above: the copy's
// EFB reset, rescaled into eye space, covers the whole eye.
+2
View File
@@ -316,6 +316,8 @@ struct StereoReplayFrame {
};
void end_frame(const wgpu::CommandEncoder& cmd);
// Set under the renderer mutex immediately before preparing/sealing the frame.
void set_stereo_local_player_count(uint32_t count) noexcept;
// Prepares eye-specific uniform copies before unmapping the staging buffer.
// Returns false without modifying the mono path when the uniform buffer has
// insufficient room for the additional copies.
+30 -13
View File
@@ -60,7 +60,6 @@ struct AsyncSlot {
uint64_t bufferSize = 0;
uint32_t bytesPerRow = 0;
// Latched at encode time and read by the map callback.
void* dest = nullptr;
GXTexFmt format = GX_TF_RGBA8;
uint32_t width = 0;
uint32_t height = 0;
@@ -68,6 +67,14 @@ struct AsyncSlot {
uint32_t hostHeight = 0;
HostPixelOrder order = HostPixelOrder::RGBA;
AsyncState state = AsyncState::Idle;
// A guest address is not an allocation lifetime: a scene restart can reuse a
// probe buffer for a camera before this slot's GPU work completes. Callbacks
// therefore retain results here; only a new schedule() may publish to RAM.
std::array<uint8_t, kAsyncReadbackMaxBytes> completedData{};
GXTexFmt completedFormat = GX_TF_RGBA8;
uint32_t completedWidth = 0;
uint32_t completedHeight = 0;
size_t completedSize = 0;
};
std::vector<PendingCopy> g_pending;
@@ -138,17 +145,18 @@ void complete_async_slot(void* dest, wgpu::MapAsyncStatus status, wgpu::StringVi
if (status == wgpu::MapAsyncStatus::Success) {
const auto* pixels = static_cast<const uint8_t*>(slot.buffer.GetConstMappedRange(0, slot.bufferSize));
if (pixels != nullptr) {
// Writes guest RAM from the event-queue thread while the guest may be reading it. The only
// consumer min/maxes depth for a fade factor, so a torn tile just mixes two frames' depths.
const size_t outputSize = encoded_size(slot.format, slot.width, slot.height);
if (!encode(slot.dest, outputSize, slot.format, slot.width, slot.height, pixels, slot.hostWidth, slot.hostHeight,
slot.bytesPerRow, slot.order)) {
if (!encode(slot.completedData.data(), slot.completedData.size(), slot.format, slot.width, slot.height, pixels,
slot.hostWidth, slot.hostHeight, slot.bytesPerRow, slot.order)) {
slot.completedSize = 0;
Log.error("Failed to encode async EFB RAM copy format=0x{:x} size={}x{}", static_cast<unsigned>(slot.format),
slot.width, slot.height);
} else {
slot.completedFormat = slot.format;
slot.completedWidth = slot.width;
slot.completedHeight = slot.height;
slot.completedSize = outputSize;
}
// Guest RAM written from outside the embedder, so nothing bumps its write generation and a
// texture cached over this range would keep its digest.
notify_guest_write(slot.dest, outputSize);
}
slot.buffer.Unmap();
} else if (status != wgpu::MapAsyncStatus::CallbackCancelled && status != wgpu::MapAsyncStatus::Aborted) {
@@ -201,12 +209,22 @@ void schedule(void* dest, uint32_t width, uint32_t height, GXTexFmt format, Text
async = false;
} else {
g_asyncSlots.try_emplace(dest);
// No readback has landed here yet. 0xff decodes to far Z, which the probe reads as unobstructed
// so a new flare fades in; zero-filled RAM would decode as fully occluded.
std::memset(dest, 0xff, encodedSize);
notify_guest_write(dest, encodedSize);
}
}
if (async) {
const auto& slot = g_asyncSlots.at(dest);
// schedule runs on the producer inside GXCopyTex, while this destination
// is owned by the copy. Never let a later GPU callback write to it.
if (slot.completedSize == encodedSize && slot.completedFormat == format && slot.completedWidth == width &&
slot.completedHeight == height) {
std::memcpy(dest, slot.completedData.data(), encodedSize);
} else {
// No compatible result yet. Far Z makes a new flare fade in instead
// of treating uninitialized RAM as a fully occluded probe.
std::memset(dest, 0xff, encodedSize);
}
notify_guest_write(dest, encodedSize);
}
}
auto& list = async ? g_asyncPending : g_pending;
@@ -395,7 +413,6 @@ void encode_async_downloads(const wgpu::CommandEncoder& encoder) noexcept {
};
encoder.CopyTextureToBuffer(&source, &destination, &texture->size);
slot.bytesPerRow = bytesPerRow;
slot.dest = pending.dest;
slot.format = pending.format;
slot.width = pending.width;
slot.height = pending.height;
+4 -1
View File
@@ -15,7 +15,10 @@ bool complete_downloads() noexcept;
void cancel() noexcept;
// Frame-latent readbacks for probe-sized CPU-consumed copies: they ride the frame's own encode and
// land in guest RAM a frame later. Per frame, from the worker: seal, encode, then after_submit.
// retain completed pixels in host memory. The producer publishes a compatible
// result only when schedule() is called again for that destination, while the
// game still owns it as a copy buffer. Per frame, from the worker: seal, encode,
// then after_submit. GPU callbacks must never write to guest RAM.
void seal_async_downloads() noexcept;
void encode_async_downloads(const wgpu::CommandEncoder& encoder) noexcept;
void after_submit() noexcept;
+68
View File
@@ -1,9 +1,77 @@
#pragma once
#include <aurora/math.hpp>
#include <cmath>
namespace aurora::gfx::stereo_replay {
struct SubviewRect {
float left = 0.f;
float top = 0.f;
float width = 0.f;
float height = 0.f;
};
// MKW uses a top/bottom split for two screens and quadrants for three/four.
// Coordinates belong to the displayed EFB region, including its crop origin.
inline SubviewRect player_one_region(SubviewRect display, uint32_t players) noexcept {
if (players >= 2 && players <= 4) {
display.height *= 0.5f;
if (players >= 3) {
display.width *= 0.5f;
}
}
return display;
}
inline bool subviews_overlap(SubviewRect a, SubviewRect b) noexcept {
return a.width > 0.f && a.height > 0.f && b.width > 0.f && b.height > 0.f && a.left < b.left + b.width &&
a.left + a.width > b.left && a.top < b.top + b.height && a.top + a.height > b.top;
}
inline bool subview_contains(SubviewRect outer, SubviewRect inner) noexcept {
// GX viewport jitter and rounding can extend a pane by a fraction of a pixel.
constexpr float tolerance = 1.f;
return inner.width > 0.f && inner.height > 0.f && inner.left >= outer.left - tolerance &&
inner.top >= outer.top - tolerance && inner.left + inner.width <= outer.left + outer.width + tolerance &&
inner.top + inner.height <= outer.top + outer.height + tolerance;
}
// Shared orthographic overlays may span the display. World geometry must belong
// wholly to P1; otherwise another camera could be expanded into the same eye.
// EFB effects sample the desktop's multi-camera image, so they cannot be reused.
inline bool replay_player_one_draw(SubviewRect viewport, SubviewRect playerRegion, bool perspective,
bool nativeEfbEffect) noexcept {
return !nativeEfbEffect &&
(perspective ? subview_contains(playerRegion, viewport) : subviews_overlap(playerRegion, viewport));
}
// Split-screen furniture is often geometry in a full-display orthographic
// viewport, not a separate viewport. MKW draws its partition as the
// partition_line layout: yoko_line (800x1) and tate_line (1x800) picture panes
// centred on the display and sampling a pattern texture. Recognize only
// rectangles/lines at the split boundaries and complete masks of other
// players' panes, whether textured or not. Full-frame fades and small HUD
// backgrounds must remain visible.
inline bool is_split_screen_furniture(SubviewRect bounds, SubviewRect display, uint32_t players) noexcept {
if (players < 2 || players > 4 || display.width <= 0.f || display.height <= 0.f)
return false;
const float x = (bounds.left - display.left) / display.width;
const float y = (bounds.top - display.top) / display.height;
const float w = bounds.width / display.width;
const float h = bounds.height / display.height;
constexpr float tolerance = 0.008f; // Up to a few native EFB pixels of inset/jitter.
const auto near = [](float a, float b) { return std::abs(a - b) <= tolerance; };
if (h <= tolerance && w >= 0.45f && near(y + h * 0.5f, 0.5f))
return true;
if (players >= 3 && w <= tolerance && h >= 0.45f && near(x + w * 0.5f, 0.5f))
return true;
if (players == 2)
return near(x, 0.f) && near(y, 0.5f) && near(w, 1.f) && near(h, 0.5f);
return near(w, 0.5f) && near(h, 0.5f) &&
((near(x, 0.5f) && (near(y, 0.f) || near(y, 0.5f))) || (near(x, 0.f) && near(y, 0.5f)));
}
// An OpenXR eye supplies the shape of its asymmetric frustum, but the sealed
// GX draw already contains the depth mapping adjusted for that draw's GX
// viewport and Aurora's reversed-Z convention. Replacing the complete matrix
+104 -5
View File
@@ -1861,7 +1861,7 @@ static u32 calculate_last_vtx_size(GXVtxFmt fmt) {
static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, gfx::Range vertRange,
uint16_t usedPnMtxMask, HashType matrixTopologySignature, HashType geometrySignature,
bool interpolationIdentityActive);
bool interpolationIdentityActive, const uint8_t* vertices, uint32_t vtxStride);
// The per-draw geometry signature, matrix-usage mask and draw-identity hashes exist purely to feed frame interpolation
// (build_uniform consumes them only after its `frame_interpolation_fps() == 0` early-out).
@@ -1891,6 +1891,104 @@ static uint32_t matrix_index_prefix_size(GXVtxFmt fmt) noexcept {
return size;
}
// Screen-space bounds of a simple orthographic rectangle or line, textured or
// not: MKW's split-screen partition is a layout picture pane (a one-pixel quad
// sampling a pattern texture), so texture use cannot disqualify a candidate.
// The geometry rules in is_split_screen_furniture keep HUD art visible.
static std::optional<gfx::stereo_replay::SubviewRect> screen_rect(GXPrimitive prim, GXVtxFmt fmt,
const uint8_t* vertices, uint16_t count,
uint32_t stride) noexcept {
if (!aurora::stereo_frame_provider_active() || g_gxState.projType != GX_ORTHOGRAPHIC ||
!((count == 4 && (prim == GX_QUADS || prim == GX_TRIANGLESTRIP || prim == GX_TRIANGLEFAN)) ||
(count == 2 && prim == GX_LINES)))
return {};
const auto& projection = g_gxState.proj;
if (!gfx::stereo_replay::is_orthographic_projection(projection))
return {};
const auto& attr = g_gxState.vtxFmts[fmt].attrs[GX_VA_POS];
const uint32_t components = attr.cnt == GX_POS_XY ? 2 : 3;
const uint32_t componentBytes = comp_type_size(GX_VA_POS, attr.type);
if (componentBytes == 0)
return {};
const uint32_t offset = matrix_index_prefix_size(fmt);
std::array<std::array<float, 2>, 4> points{};
float left = INFINITY, top = INFINITY, right = -INFINITY, bottom = -INFINITY;
for (uint32_t i = 0; i < count; ++i) {
const auto* vertex = vertices + i * stride;
const auto* position = vertex + offset;
bool bigEndian = true;
const auto type = g_gxState.vtxDesc[GX_VA_POS];
if (type == GX_INDEX8 || type == GX_INDEX16) {
const uint32_t index = type == GX_INDEX8 ? *position : read_u16(position, true);
const auto& array = g_gxState.arrays[GX_VA_POS];
const size_t start = size_t(index) * array.stride;
if (array.data == nullptr || start + components * componentBytes > array.size)
return {};
position = static_cast<const uint8_t*>(array.data) + start;
bigEndian = !array.le;
} else if (type != GX_DIRECT || offset + components * componentBytes > stride) {
return {};
}
std::array<float, 3> point{};
for (uint32_t c = 0; c < components; ++c) {
const auto* value = position + c * componentBytes;
switch (attr.type) {
case GX_U8:
point[c] = *value;
break;
case GX_S8:
point[c] = static_cast<int8_t>(*value);
break;
case GX_U16:
point[c] = read_u16(value, bigEndian);
break;
case GX_S16:
point[c] = static_cast<int16_t>(read_u16(value, bigEndian));
break;
case GX_F32:
point[c] = read_f32(value, bigEndian);
break;
default:
return {};
}
if (attr.type != GX_F32)
point[c] = std::ldexp(point[c], -int(attr.frac));
}
const uint32_t matrixIndex =
g_gxState.vtxDesc[GX_VA_PNMTXIDX] == GX_DIRECT ? vertex[0] / 3u : g_gxState.currentPnMtx;
if (matrixIndex >= g_gxState.pnMtx.size())
return {};
const auto& matrix = g_gxState.pnMtx[matrixIndex].pos;
const auto transform = [](const Vec4<float>& row, const std::array<float, 3>& p) {
return row[0] * p[0] + row[1] * p[1] + row[2] * p[2] + row[3];
};
const std::array<float, 3> view{transform(matrix.m0, point), transform(matrix.m1, point),
transform(matrix.m2, point)};
const auto& vp = g_gxState.renderViewport;
const float x = vp.left + (transform(projection.m0, view) + 1.f) * vp.width * 0.5f;
const float y = vp.top + (1.f - transform(projection.m1, view)) * vp.height * 0.5f;
if (!std::isfinite(x) || !std::isfinite(y))
return {};
points[i] = {x, y};
left = std::min(left, x);
right = std::max(right, x);
top = std::min(top, y);
bottom = std::max(bottom, y);
}
// A diagonal/rotated HUD polygon's bounding box is not a screen mask.
uint32_t corners = 0;
for (uint32_t i = 0; i < count; ++i) {
const auto& p = points[i];
if ((std::abs(p[0] - left) > 0.01f && std::abs(p[0] - right) > 0.01f) ||
(std::abs(p[1] - top) > 0.01f && std::abs(p[1] - bottom) > 0.01f))
return {};
corners |= 1u << ((std::abs(p[0] - right) < 0.01f ? 1 : 0) + (std::abs(p[1] - bottom) < 0.01f ? 2 : 0));
}
if ((count == 4 && corners != 15) || (count == 2 && right - left > 0.01f && bottom - top > 0.01f))
return {};
return gfx::stereo_replay::SubviewRect{left, top, right - left, bottom - top};
}
static HashType draw_geometry_signature(GXVtxFmt fmt, const uint8_t* vertices, uint16_t vtxCount,
uint32_t vtxStride) noexcept {
Hasher hasher;
@@ -2155,7 +2253,7 @@ bool submit_raw_draw(GXPrimitive prim, GXVtxFmt fmt, const uint8_t* vertices, ui
const PnMtxUsage matrixUsage = interpolationIdentityActive ? pn_mtx_usage(vertices, vtxCount, vtxSize) : PnMtxUsage{};
handle_draw_unmerged(prim, fmt, vtxCount, vertRange, matrixUsage.mask, matrixUsage.topologySignature,
interpolationIdentityActive ? draw_geometry_signature(fmt, vertices, vtxCount, vtxSize) : 0,
interpolationIdentityActive);
interpolationIdentityActive, vertices, vtxSize);
return true;
}
@@ -2189,7 +2287,7 @@ static bool handle_draw(u8 cmd, const u8* data, u32& pos, u32 size, bool bigEndi
pos += totalVtxBytes;
// Try to merge with previous draw call
if (!g_gxState.stateDirty)
if (!g_gxState.stateDirty && !(aurora::stereo_frame_provider_active() && g_gxState.projType == GX_ORTHOGRAPHIC))
LIKELY {
auto* lastDraw = gfx::get_last_draw_command<DrawData>();
// Only if the previous draw call was a single instance draw (no lines/points handling)
@@ -2223,13 +2321,13 @@ static bool handle_draw(u8 cmd, const u8* data, u32& pos, u32 size, bool bigEndi
const PnMtxUsage matrixUsage = interpolationIdentityActive ? pn_mtx_usage(vertices, vtxCount, vtxSize) : PnMtxUsage{};
handle_draw_unmerged(prim, fmt, vtxCount, vertRange, matrixUsage.mask, matrixUsage.topologySignature,
interpolationIdentityActive ? draw_geometry_signature(fmt, vertices, vtxCount, vtxSize) : 0,
interpolationIdentityActive);
interpolationIdentityActive, vertices, vtxSize);
return true;
}
static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, gfx::Range vertRange,
uint16_t usedPnMtxMask, HashType matrixTopologySignature, HashType geometrySignature,
bool interpolationIdentityActive) {
bool interpolationIdentityActive, const uint8_t* vertices, uint32_t vtxStride) {
ZoneScoped;
// GX_CULL_ALL rasterizes nothing on hardware - no color, no depth.
if (g_gxState.cullMode == GX_CULL_ALL && prim != GX_LINES && prim != GX_LINESTRIP && prim != GX_POINTS)
@@ -2320,6 +2418,7 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, g
.instanceCount = instanceCount,
.bindGroups = bindGroups,
.dstAlpha = pipelineState.dstAlpha,
.screenRect = screen_rect(prim, fmt, vertices, vtxCount, vtxStride),
});
g_gxState.stateDirty = false;
}
+6
View File
@@ -2,6 +2,8 @@
#include "../gfx/common.hpp"
#include "shader_info.hpp"
#include "../gfx/stereo_replay.hpp"
#include <optional>
namespace aurora::gx {
struct DrawData {
@@ -21,6 +23,10 @@ struct DrawData {
uint32_t instanceCount;
GXBindGroups bindGroups;
uint32_t dstAlpha;
// Valid only for simple orthographic rectangles/lines (textured or not).
// Recorded before merging so VR can omit desktop split masks without
// modifying GX.
std::optional<gfx::stereo_replay::SubviewRect> screenRect;
};
constexpr uint32_t GXPipelineConfigVersion = 20;
+8
View File
@@ -7,6 +7,14 @@ if (AURORA_GPU_SMOKE_TESTS AND AURORA_ENABLE_GX AND WIN32)
target_include_directories(stereo_frame_worker_smoke PRIVATE ../lib)
target_link_libraries(stereo_frame_worker_smoke PRIVATE aurora::core aurora::gx aurora::main aurora::vi
dawn::dawncpp_headers)
add_executable(stereo_multiplayer_smoke stereo_multiplayer_smoke.cpp)
target_include_directories(stereo_multiplayer_smoke PRIVATE ../lib)
target_link_libraries(stereo_multiplayer_smoke PRIVATE aurora::core aurora::gx aurora::main aurora::vi
dawn::dawncpp_headers)
add_executable(efb_ram_lifetime_smoke efb_ram_lifetime_smoke.cpp)
target_include_directories(efb_ram_lifetime_smoke PRIVATE ../lib)
target_link_libraries(efb_ram_lifetime_smoke PRIVATE aurora::core aurora::gx aurora::main aurora::vi
dawn::dawncpp_headers)
endif ()
if (NOT TARGET gtest)
@@ -0,0 +1,146 @@
// Opt-in GPU regression for a depth-probe allocation reused during a scene
// restart. A late map callback must not overwrite the new camera allocation.
#include <aurora/aurora.h>
#include <dolphin/gx.h>
#include "gfx/efb_ram_copy.hpp"
#include "gfx/efb_ram_encoder.hpp"
#include "webgpu/gpu.hpp"
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdio>
#include <filesystem>
#include <thread>
using namespace aurora;
namespace {
constexpr uint8_t kCameraByte = 0x35;
std::array<uint8_t, 256> guest;
gfx::TextureHandle MakeTexture(uint32_t width, uint32_t height, std::array<uint8_t, 4> pixel) {
auto texture = gfx::new_render_texture(width, height, GX_TF_RGBA8, "Probe lifetime test");
if (texture->format == wgpu::TextureFormat::BGRA8Unorm)
std::swap(pixel[0], pixel[2]);
std::array<uint8_t, 256> pixels;
for (size_t i = 0; i < width * height; ++i)
std::copy(pixel.begin(), pixel.end(), pixels.begin() + i * 4);
const wgpu::TexelCopyTextureInfo destination{.texture = texture->texture};
const wgpu::TexelCopyBufferLayout layout{.bytesPerRow = width * 4, .rowsPerImage = height};
webgpu::g_queue.WriteTexture(&destination, pixels.data(), width * height * 4, &layout, &texture->size);
return texture;
}
void SubmitProbe(const gfx::TextureHandle& texture, GXTexFmt format, uint32_t width, uint32_t height) {
gfx::efb_ram::schedule(guest.data(), width, height, format, texture);
gfx::efb_ram::seal_async_downloads();
auto encoder = webgpu::g_device.CreateCommandEncoder();
gfx::efb_ram::encode_async_downloads(encoder);
auto commands = encoder.Finish();
webgpu::g_queue.Submit(1, &commands);
// The guest frees the probe and constructs a camera at the same address,
// before the readback callback is registered.
guest.fill(kCameraByte);
gfx::efb_ram::after_submit();
}
bool CameraIntact() {
return std::all_of(guest.begin(), guest.end(), [](uint8_t byte) { return byte == kCameraByte; });
}
bool AwaitProbe(const gfx::TextureHandle& texture, GXTexFmt format, uint32_t width, uint32_t height,
const std::array<uint8_t, 4>& pixel) {
const size_t size = gfx::efb_ram::encoded_size(format, width, height);
std::array<uint8_t, 256> pixels{}, expected{};
for (size_t i = 0; i < width * height; ++i)
std::copy(pixel.begin(), pixel.end(), pixels.begin() + i * 4);
if (!gfx::efb_ram::encode(expected.data(), size, format, width, height, pixels.data(), width, height, width * 4,
gfx::efb_ram::HostPixelOrder::RGBA))
return false;
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
do {
// Give the real GPU completion callback a chance to run while the old
// destination belongs to the camera, then verify it remains untouched.
webgpu::g_instance.ProcessEvents();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
if (!CameraIntact()) {
std::fprintf(stderr, "Late GPU readback overwrote the reused camera allocation\n");
return false;
}
// This explicit copy owns the destination again and may receive the result.
gfx::efb_ram::schedule(guest.data(), width, height, format, texture);
const bool arrived = std::equal(expected.begin(), expected.begin() + size, guest.begin());
const bool canary =
std::all_of(guest.begin() + size, guest.end(), [](uint8_t byte) { return byte == kCameraByte; });
gfx::efb_ram::cancel();
guest.fill(kCameraByte);
if (!canary)
return false;
if (arrived)
return true;
} while (std::chrono::steady_clock::now() < deadline);
std::fprintf(stderr, "Completed probe was not delivered by the next compatible copy\n");
return false;
}
bool Run() {
const std::array<uint8_t, 4> pixel{0xff, 0xfa, 0xb6, 0xff};
auto large = MakeTexture(8, 8, pixel);
auto small = MakeTexture(4, 4, pixel);
SubmitProbe(large, GX_TF_Z24X8, 8, 8);
if (!AwaitProbe(large, GX_TF_Z24X8, 8, 8, pixel))
return false;
std::puts("Reused camera allocation survives late 256-byte probe: PASS");
// Same address, smaller allocation and different encoding: the retained
// 256-byte result must not escape the new 32-byte destination.
gfx::efb_ram::schedule(guest.data(), 4, 4, GX_TF_Z16, small);
const bool fallback = std::all_of(guest.begin(), guest.begin() + 32, [](uint8_t b) { return b == 0xff; });
const bool canary = std::all_of(guest.begin() + 32, guest.end(), [](uint8_t b) { return b == kCameraByte; });
gfx::efb_ram::cancel();
if (!fallback || !canary)
return false;
std::puts("Reused address rejects incompatible retained format and size: PASS");
SubmitProbe(small, GX_TF_Z24X8, 4, 4);
if (!AwaitProbe(small, GX_TF_Z24X8, 4, 4, pixel))
return false;
std::puts("Crash-dump 64-byte depth pattern delivered only during a new copy: PASS");
SubmitProbe(small, GX_TF_Z16, 4, 4);
if (!AwaitProbe(small, GX_TF_Z16, 4, 4, pixel))
return false;
std::puts("Changed probe format delivers compatible pixels without touching canaries: PASS");
return true;
}
} // namespace
int main(int argc, char** argv) {
std::setvbuf(stdout, nullptr, _IONBF, 0);
std::filesystem::create_directories("efb-lifetime-cache");
AuroraConfig config{};
config.appName = "Aurora EFB lifetime validation";
config.userPath = ".";
config.cachePath = "efb-lifetime-cache";
config.desiredBackend = BACKEND_D3D12;
config.windowWidth = 160;
config.windowHeight = 120;
config.hasWindowPosition = true;
config.windowPosX = config.windowPosY = -30000;
config.xrInterop = true;
config.logLevel = LOG_WARNING;
config.logCallback = [](AuroraLogLevel, const char* module, const char* message, unsigned length) {
std::fprintf(stderr, "%s: %.*s\n", module, int(length), message);
};
aurora_initialize(argc, argv, &config);
std::puts("GPU initialized");
aurora_begin_frame();
GXInit(nullptr, 0);
std::puts("Starting readback lifetime checks");
const bool passed = Run();
std::puts("Readback lifetime checks finished");
aurora_end_frame();
aurora_quiesce_frame_worker();
aurora_shutdown();
return passed ? 0 : 1;
}
+65
View File
@@ -2269,6 +2269,71 @@ TEST_F(GXFifoTest, MergedDrawOffsetsCachedTopologyWithoutJoiningPrimitives) {
EXPECT_EQ(aurora::gfx::testing::last_pushed_indices(), (std::vector<u16>{3, 4, 5}));
}
TEST_F(GXFifoTest, OrthographicQuadRecordsScreenRectForVrFurniture) {
// MKW draws its split-screen partition with the partition_line layout: a
// one-pixel picture pane sampling a pattern texture in a full-display
// orthographic viewport. VR replay drops it by its recorded screen
// rectangle. This covers the FIFO decode of that rectangle. The harness
// stubs populate_pipeline_config, so whether texture use disqualifies a
// rectangle is exercised by stereo_multiplayer_smoke instead.
aurora::gfx::testing::use_real_vertex_format_helpers(true);
aurora::gfx::testing::use_draw_command_tracking(true);
aurora::Mat4x4<float> proj{};
proj.m0[0] = 2.0f / 640.0f;
proj.m0[3] = -1.0f;
proj.m1[1] = 2.0f / 480.0f;
proj.m1[3] = -1.0f;
proj.m2[2] = -1.0f;
proj.m3[3] = 1.0f;
GXSetProjection(&proj, GX_ORTHOGRAPHIC);
GXSetViewport(0.0f, 0.0f, 640.0f, 480.0f, 0.0f, 1.0f);
GXSetScissor(0, 0, 640, 480);
aurora::Mat3x4<float> identity{};
identity.m0[0] = identity.m1[1] = identity.m2[2] = 1.0f;
GXLoadPosMtxImm(&identity, GX_PNMTX0);
GXSetCurrentMtx(GX_PNMTX0);
GXClearVtxDesc();
GXSetVtxDesc(GX_VA_POS, GX_DIRECT);
GXSetVtxDesc(GX_VA_CLR0, GX_DIRECT);
GXSetVtxDesc(GX_VA_TEX0, GX_DIRECT);
GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_POS, GX_POS_XYZ, GX_F32, 0);
GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_CLR0, GX_CLR_RGBA, GX_RGBA8, 0);
GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_TEX0, GX_TEX_ST, GX_F32, 0);
GXSetNumChans(1);
GXSetNumTexGens(1);
GXSetTexCoordGen(GX_TEXCOORD0, GX_TG_MTX2x4, GX_TG_TEX0, GX_IDENTITY);
GXSetNumTevStages(1);
GXSetTevOrder(GX_TEVSTAGE0, GX_TEXCOORD0, GX_TEXMAP0, GX_COLOR0A0);
GXSetTevOp(GX_TEVSTAGE0, GX_MODULATE);
alignas(32) static const u8 pattern[8 * 8 * 2]{};
GXTexObj obj{};
GXInitTexObj(&obj, pattern, 8, 8, GX_TF_RGB565, GX_CLAMP, GX_CLAMP, GX_FALSE);
GXLoadTexObj(&obj, GX_TEXMAP0);
// yoko_line: full width, one pixel tall, centred vertically.
const float corners[4][2]{{0.0f, 239.5f}, {640.0f, 239.5f}, {640.0f, 240.5f}, {0.0f, 240.5f}};
GXBegin(GX_QUADS, GX_VTXFMT0, 4);
for (const auto& corner : corners) {
GXPosition3f32(corner[0], corner[1], 0.0f);
GXColor4u8(0, 0, 0, 255);
GXTexCoord2f32(corner[0] / 640.0f, corner[1] > 240.0f ? 1.0f : 0.0f);
}
GXEnd();
decode_fifo(flush_and_capture());
const auto* draw = aurora::gfx::get_last_draw_command<aurora::gx::DrawData>();
ASSERT_NE(draw, nullptr);
ASSERT_TRUE(draw->screenRect.has_value());
EXPECT_NEAR(draw->screenRect->left, 0.0f, 0.01f);
EXPECT_NEAR(draw->screenRect->top, 239.5f, 0.01f);
EXPECT_NEAR(draw->screenRect->width, 640.0f, 0.01f);
EXPECT_NEAR(draw->screenRect->height, 1.0f, 0.01f);
EXPECT_TRUE(
aurora::gfx::stereo_replay::is_split_screen_furniture(*draw->screenRect, {0.0f, 0.0f, 640.0f, 480.0f}, 2));
}
TEST_F(GXFifoTest, TexBufferSize_UsesExactLinearPcFormatSizes) {
EXPECT_EQ(GXGetTexBufferSize(8, 4, GX_TF_R8_PC, GX_FALSE, 0), 32u);
EXPECT_EQ(GXGetTexBufferSize(8, 4, GX_TF_RGBA8_PC, GX_FALSE, 0), 128u);
@@ -0,0 +1,265 @@
// Opt-in D3D12 readback test: P1 is red, P2 green, P3 blue, P4 yellow.
// Both eyes must be entirely P1 while the original EFB keeps every pane.
#include <aurora/aurora.h>
#include <aurora/gfx.h>
#include <dolphin/gx.h>
#include <dolphin/mtx.h>
#include "stereo.hpp"
#include "gfx/common.hpp"
#include <array>
#include <cstdio>
#include <filesystem>
using namespace aurora::webgpu;
namespace {
// Eyes match the EFB width so a one-pixel-class divider on the virtual screen
// still covers eye pixels; a 160x120 eye never rasterized it.
constexpr uint32_t kWidth = 640, kHeight = 480, kEfbWidth = 640, kEfbHeight = 528, kPitch = 2560;
constexpr uint64_t kBytes = kPitch * kEfbHeight;
std::array<wgpu::Buffer, 3> readbacks;
std::array<wgpu::TextureFormat, 3> formats;
uint64_t token = 0;
uint32_t copies = 0;
bool Provide(uint32_t, AuroraStereoFrame* frame, void*) {
*frame = {};
frame->frameToken = ++token;
frame->contentTag = 42;
for (auto& eye : frame->eyes) {
eye.width = kWidth;
eye.height = kHeight;
eye.projection[0] = eye.projection[5] = 1;
eye.projection[10] = eye.projection[11] = eye.projection[14] = -1;
eye.viewFromCenter[0] = eye.viewFromCenter[5] = eye.viewFromCenter[10] = 1;
}
return true;
}
bool Encode(wgpu::CommandEncoder& encoder, const aurora::stereo::SinkFrame& frame, void*) noexcept {
for (uint32_t i = 0; i < 3; ++i) {
const auto texture = i < 2 ? *frame.eyes[i].texture : present_source().texture;
formats[i] = texture.GetFormat();
const wgpu::TexelCopyTextureInfo source{.texture = texture};
const wgpu::TexelCopyBufferInfo destination{.layout = {.bytesPerRow = kPitch, .rowsPerImage = kEfbHeight},
.buffer = readbacks[i]};
const wgpu::Extent3D size{i < 2 ? kWidth : kEfbWidth, i < 2 ? kHeight : kEfbHeight, 1};
encoder.CopyTextureToBuffer(&source, &destination, &size);
}
++copies;
return true;
}
void Draw(uint32_t players) {
// GX depth occupies [-w, 0], unlike OpenGL's [-w, +w].
Mtx44 projection{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 0, -1}, {0, 0, -1, 0}};
Mtx transform{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, -2}};
GXSetProjection(projection, GX_PERSPECTIVE);
GXSetCurrentMtx(GX_PNMTX0);
GXLoadPosMtxImm(transform, GX_PNMTX0);
GXClearVtxDesc();
GXSetVtxDesc(GX_VA_POS, GX_DIRECT);
GXSetVtxDesc(GX_VA_CLR0, GX_DIRECT);
GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_POS, GX_POS_XYZ, GX_F32, 0);
GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_CLR0, GX_CLR_RGBA, GX_RGBA8, 0);
GXSetNumTexGens(0);
GXSetNumChans(1);
GXSetChanCtrl(GX_COLOR0A0, GX_FALSE, GX_SRC_REG, GX_SRC_VTX, GX_LIGHT_NULL, GX_DF_NONE, GX_AF_NONE);
GXSetNumTevStages(1);
GXSetTevOrder(GX_TEVSTAGE0, GX_TEXCOORD_NULL, GX_TEXMAP_NULL, GX_COLOR0A0);
GXSetTevColorIn(GX_TEVSTAGE0, GX_CC_ZERO, GX_CC_ZERO, GX_CC_ZERO, GX_CC_RASC);
GXSetTevAlphaIn(GX_TEVSTAGE0, GX_CA_ZERO, GX_CA_ZERO, GX_CA_ZERO, GX_CA_RASA);
GXSetTevColorOp(GX_TEVSTAGE0, GX_TEV_ADD, GX_TB_ZERO, GX_CS_SCALE_1, GX_TRUE, GX_TEVPREV);
GXSetTevAlphaOp(GX_TEVSTAGE0, GX_TEV_ADD, GX_TB_ZERO, GX_CS_SCALE_1, GX_TRUE, GX_TEVPREV);
GXSetZMode(GX_FALSE, GX_ALWAYS, GX_FALSE);
GXSetAlphaCompare(GX_ALWAYS, 0, GX_AOP_AND, GX_ALWAYS, 0);
GXSetCullMode(GX_CULL_NONE);
GXSetBlendMode(GX_BM_NONE, GX_BL_ONE, GX_BL_ZERO, GX_LO_COPY);
GXSetColorUpdate(GX_TRUE);
GXSetAlphaUpdate(GX_TRUE);
const GXColor colors[]{{255, 0, 0, 255}, {0, 255, 0, 255}, {0, 0, 255, 255}, {255, 255, 0, 255}};
for (uint32_t p = 0; p < players; ++p) {
const uint32_t width = players >= 3 ? kEfbWidth / 2 : kEfbWidth;
const uint32_t height = players >= 2 ? kEfbHeight / 2 : kEfbHeight;
const uint32_t x = players >= 3 ? (p % 2) * width : 0;
const uint32_t y = players >= 3 ? (p / 2) * height : p * height;
GXSetViewport(float(x), float(y), float(width), float(height), 0, 1);
GXSetScissor(x, y, width, height);
GXBegin(GX_QUADS, GX_VTXFMT0, 4);
GXPosition3f32(-2, -2, 0);
GXColor4u8(colors[p].r, colors[p].g, colors[p].b, 255);
GXPosition3f32(2, -2, 0);
GXColor4u8(colors[p].r, colors[p].g, colors[p].b, 255);
GXPosition3f32(2, 2, 0);
GXColor4u8(colors[p].r, colors[p].g, colors[p].b, 255);
GXPosition3f32(-2, 2, 0);
GXColor4u8(colors[p].r, colors[p].g, colors[p].b, 255);
GXEnd();
}
if (players == 1)
return;
// MKW's separators and per-pane backing quads can share one full-screen
// orthographic viewport. Viewport filtering alone must not admit them in VR.
Mtx44 ortho{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 0, -0.5f}, {0, 0, 0, 1}};
Mtx identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
GXSetProjection(ortho, GX_ORTHOGRAPHIC);
GXLoadPosMtxImm(identity, GX_PNMTX0);
GXSetViewport(0, 0, kEfbWidth, kEfbHeight, 0, 1);
GXSetScissor(0, 0, kEfbWidth, kEfbHeight);
const auto rect = [](float left, float bottom, float right, float top) {
GXBegin(GX_QUADS, GX_VTXFMT0, 4);
for (const auto& p :
std::array<std::array<float, 2>, 4>{{{left, bottom}, {right, bottom}, {right, top}, {left, top}}}) {
GXPosition3f32(p[0], p[1], 0);
GXColor4u8(0, 0, 0, 255);
}
GXEnd();
};
if (players >= 3)
rect(0, 0, 1, 1); // The reported black quadrant.
else
rect(-1, -1, 1, 0);
if (players >= 3) {
GXSetLineWidth(6, GX_TO_ZERO);
GXBegin(GX_LINES, GX_VTXFMT0, 2);
GXPosition3f32(0, -1, 0);
GXColor4u8(0, 0, 0, 255);
GXPosition3f32(0, 1, 0);
GXColor4u8(0, 0, 0, 255);
GXEnd();
}
// MKW's partition_line layout draws the divider as one-pixel picture panes
// that sample a pattern texture, so the divider here is a textured quad too:
// texture use alone must not keep it out of the furniture filter.
alignas(32) static const uint8_t blackTexels[8 * 8 * 2]{}; // RGB565 zero: opaque black.
GXTexObj divider{};
GXInitTexObj(&divider, blackTexels, 8, 8, GX_TF_RGB565, GX_CLAMP, GX_CLAMP, GX_FALSE);
GXLoadTexObj(&divider, GX_TEXMAP0);
GXSetVtxDesc(GX_VA_TEX0, GX_DIRECT);
GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_TEX0, GX_TEX_ST, GX_F32, 0);
GXSetNumTexGens(1);
GXSetTexCoordGen(GX_TEXCOORD0, GX_TG_MTX2x4, GX_TG_TEX0, GX_IDENTITY);
GXSetTevOrder(GX_TEVSTAGE0, GX_TEXCOORD0, GX_TEXMAP0, GX_COLOR0A0);
GXSetTevColorIn(GX_TEVSTAGE0, GX_CC_ZERO, GX_CC_ZERO, GX_CC_ZERO, GX_CC_TEXC);
GXSetTevAlphaIn(GX_TEVSTAGE0, GX_CA_ZERO, GX_CA_ZERO, GX_CA_ZERO, GX_CA_TEXA);
GXBegin(GX_QUADS, GX_VTXFMT0, 4);
for (const auto& p :
std::array<std::array<float, 2>, 4>{{{-1, -0.006f}, {1, -0.006f}, {1, 0.006f}, {-1, 0.006f}}}) {
GXPosition3f32(p[0], p[1], 0);
GXColor4u8(255, 255, 255, 255);
GXTexCoord2f32(p[0] * 0.5f + 0.5f, p[1] > 0 ? 1.f : 0.f);
}
GXEnd();
}
bool Check(uint32_t players) {
bool okay = copies != 0;
for (uint32_t i = 0; i < 3; ++i) {
wgpu::MapAsyncStatus status{};
const auto future = readbacks[i].MapAsync(wgpu::MapMode::Read, 0, kBytes, wgpu::CallbackMode::WaitAnyOnly,
[&](wgpu::MapAsyncStatus result, wgpu::StringView) { status = result; });
if (g_instance.WaitAny(future, 5'000'000'000) != wgpu::WaitStatus::Success ||
status != wgpu::MapAsyncStatus::Success) {
return false;
}
const auto* bytes = static_cast<const uint8_t*>(readbacks[i].GetConstMappedRange());
for (uint32_t quadrant = 0; quadrant < 4; ++quadrant) {
const uint32_t width = i < 2 ? kWidth : kEfbWidth;
const uint32_t height = i < 2 ? kHeight : kEfbHeight;
const uint32_t x = (quadrant % 2) * width / 2 + width / 4;
const uint32_t y = (quadrant / 2) * height / 2 + height / 4;
uint32_t player = i < 2 || players == 1 ? 0 : players == 2 ? quadrant / 2 : quadrant;
if (player >= players)
continue;
const auto* pixel = bytes + y * kPitch + x * 4;
const bool bgra = formats[i] == wgpu::TextureFormat::BGRA8Unorm;
const uint8_t r = pixel[bgra ? 2 : 0], g = pixel[1], b = pixel[bgra ? 0 : 2];
const bool mask = i == 2 && players > 1 && (players == 2 ? quadrant >= 2 : quadrant == 1);
const bool match = r == (!mask && (player == 0 || player == 3) ? 255 : 0) &&
g == (!mask && (player == 1 || player == 3) ? 255 : 0) &&
b == (!mask && player == 2 ? 255 : 0);
if (!match)
std::fprintf(stderr, "%uP target %u quadrant %u expected player %u, got %u,%u,%u\n", players, i, quadrant,
player + 1, r, g, b);
okay &= match;
}
if (i < 2) {
// Include the divider locations; quadrant-center samples alone miss them.
for (uint32_t y = 4; y < kHeight - 4; ++y) {
for (uint32_t x = 4; x < kWidth - 4; ++x) {
const auto* pixel = bytes + y * kPitch + x * 4;
const bool bgra = formats[i] == wgpu::TextureFormat::BGRA8Unorm;
if (pixel[bgra ? 2 : 0] != 255 || pixel[1] != 0 || pixel[bgra ? 0 : 2] != 0) {
std::fprintf(stderr, "%uP eye %u has split-screen overlay at %u,%u\n", players, i, x, y);
okay = false;
y = kHeight;
break;
}
}
}
} else if (players > 1) {
const auto* divider = bytes + (kEfbHeight / 2) * kPitch + (kEfbWidth / 4) * 4;
if (divider[0] != 0 || divider[1] != 0 || divider[2] != 0) {
std::fprintf(stderr, "Desktop divider was removed\n");
okay = false;
}
}
readbacks[i].Unmap();
}
return okay;
}
} // namespace
int main(int argc, char** argv) {
std::filesystem::create_directories("stereo-multiplayer-cache");
AuroraConfig config{};
config.appName = "Aurora multiplayer VR validation";
config.userPath = ".";
config.cachePath = "stereo-multiplayer-cache";
config.desiredBackend = BACKEND_D3D12;
config.windowWidth = kWidth;
config.windowHeight = kHeight;
config.hasWindowPosition = true;
config.windowPosX = config.windowPosY = -30000;
config.xrInterop = true;
config.logLevel = LOG_WARNING;
config.logCallback = [](AuroraLogLevel, const char* module, const char* message, unsigned length) {
std::fprintf(stderr, "%s: %.*s\n", module, int(length), message);
};
aurora_initialize(argc, argv, &config);
aurora_set_skip_unready_pipelines(false);
aurora_begin_frame();
GXInit(nullptr, 0);
const wgpu::BufferDescriptor descriptor{.usage = wgpu::BufferUsage::MapRead | wgpu::BufferUsage::CopyDst,
.size = kBytes};
for (auto& buffer : readbacks)
buffer = g_device.CreateBuffer(&descriptor);
aurora_set_stereo_frame_provider(Provide, nullptr);
aurora::gfx::set_stereo_hud_screen(true, 2.f, 1.f);
aurora::stereo::set_sink(Encode, nullptr);
bool okay = true;
for (bool interpolate : {false, true}) {
aurora_set_stereo_frame_interpolation(interpolate);
for (uint32_t players : {1u, 2u, 3u, 4u, 1u}) {
for (uint32_t frame = 0; frame < 3; ++frame) {
aurora_update();
if (!aurora_begin_frame())
return 2;
Draw(players);
// Deliberately omit the setter for 1P to exercise per-frame reset.
if (players > 1)
aurora_set_stereo_local_player_count(players);
aurora_end_frame_tagged(42);
aurora_begin_frame();
aurora_wait_for_frame_worker();
}
const bool passed = Check(players);
okay &= passed;
std::printf("%u players, interpolation %s: %s\n", players, interpolate ? "on" : "off", passed ? "PASS" : "FAIL");
}
}
aurora_set_stereo_frame_interpolation(false);
aurora_quiesce_frame_worker();
aurora_set_stereo_frame_provider(nullptr, nullptr);
aurora::stereo::set_sink(nullptr, nullptr);
for (auto& buffer : readbacks)
buffer = nullptr;
aurora_shutdown();
return okay ? 0 : 1;
}
+83
View File
@@ -8,6 +8,89 @@
namespace aurora::gfx::stereo_replay {
namespace {
TEST(StereoReplayTest, SplitFurnitureIsRecognizedInFullDisplayCoordinates) {
const SubviewRect display{16.f, 8.f, 1280.f, 912.f};
for (uint32_t players : {2u, 3u, 4u}) {
EXPECT_TRUE(is_split_screen_furniture({16.f, 462.f, 1280.f, 4.f}, display, players));
EXPECT_FALSE(is_split_screen_furniture(display, display, players)); // Race fade.
EXPECT_FALSE(is_split_screen_furniture({80.f, 60.f, 100.f, 40.f}, display, players)); // HUD backing.
EXPECT_FALSE(is_split_screen_furniture(player_one_region(display, players), display, players));
}
EXPECT_TRUE(is_split_screen_furniture({16.f, 464.f, 1280.f, 456.f}, display, 2));
for (uint32_t players : {3u, 4u}) {
EXPECT_TRUE(is_split_screen_furniture({656.f, 8.f, 0.f, 912.f}, display, players));
EXPECT_TRUE(is_split_screen_furniture({658.f, 10.f, 636.f, 452.f}, display, players));
EXPECT_TRUE(is_split_screen_furniture({16.f, 464.f, 640.f, 456.f}, display, players));
EXPECT_TRUE(is_split_screen_furniture({656.f, 464.f, 640.f, 456.f}, display, players));
}
EXPECT_FALSE(is_split_screen_furniture({656.f, 8.f, 640.f, 456.f}, display, 1));
EXPECT_FALSE(is_split_screen_furniture({656.f, 8.f, 0.f, 912.f}, display, 2));
}
TEST(StereoReplayTest, PartitionLineLayoutPanesAreFurniture) {
// MKW's partition_line.brlyt draws yoko_line (800x1) and tate_line (1x800)
// picture panes centred on the display; both extend past a 4:3 root and are
// clipped by the display copy, so only the centre line and thickness matter.
const SubviewRect display{0.f, 0.f, 893.f, 456.f};
const SubviewRect yoko{46.5f, 227.5f, 800.f, 1.f};
const SubviewRect tate{446.f, -172.f, 1.f, 800.f};
EXPECT_TRUE(is_split_screen_furniture(yoko, display, 2));
EXPECT_FALSE(is_split_screen_furniture(tate, display, 2));
for (uint32_t players : {3u, 4u}) {
EXPECT_TRUE(is_split_screen_furniture(yoko, display, players));
EXPECT_TRUE(is_split_screen_furniture(tate, display, players));
}
// A textured pane of the same shape elsewhere is HUD art, not furniture.
EXPECT_FALSE(is_split_screen_furniture({46.5f, 100.f, 800.f, 1.f}, display, 2));
EXPECT_FALSE(is_split_screen_furniture({46.5f, 227.5f, 300.f, 1.f}, display, 2));
}
TEST(StereoReplayTest, MultiplayerSelectsOnlyPlayerOneWorld) {
const SubviewRect display{12.f, 8.f, 640.f, 456.f};
for (uint32_t count : {2u, 3u, 4u}) {
const auto player = player_one_region(display, count);
EXPECT_FLOAT_EQ(player.left, display.left);
EXPECT_FLOAT_EQ(player.top, display.top);
EXPECT_FLOAT_EQ(player.width, count == 2 ? 640.f : 320.f);
EXPECT_FLOAT_EQ(player.height, 228.f);
EXPECT_TRUE(replay_player_one_draw(player, player, true, false));
auto opponent = player;
opponent.top += player.height;
EXPECT_FALSE(replay_player_one_draw(opponent, player, true, false));
EXPECT_FALSE(replay_player_one_draw(opponent, player, false, false));
if (count >= 3) {
opponent = player;
opponent.left += player.width;
EXPECT_FALSE(replay_player_one_draw(opponent, player, true, false));
EXPECT_FALSE(replay_player_one_draw(opponent, player, false, false));
opponent.top += player.height; // P4 / unused fourth quadrant in 3P.
EXPECT_FALSE(replay_player_one_draw(opponent, player, true, false));
}
EXPECT_FALSE(replay_player_one_draw(display, player, true, false));
EXPECT_TRUE(replay_player_one_draw(display, player, false, false));
EXPECT_FALSE(replay_player_one_draw(player, player, false, true));
EXPECT_FALSE(replay_player_one_draw(display, player, false, true));
const auto remap = make_hud_ndc_remap(player.left, player.top, player.width, player.height, player.left, player.top,
player.width, player.height);
EXPECT_FLOAT_EQ(remap.scaleX, 1.f);
EXPECT_FLOAT_EQ(remap.scaleY, 1.f);
EXPECT_FLOAT_EQ(remap.offsetX, 0.f);
EXPECT_FLOAT_EQ(remap.offsetY, 0.f);
}
const auto single = player_one_region(display, 1);
EXPECT_FLOAT_EQ(single.width, display.width);
EXPECT_FLOAT_EQ(single.height, display.height);
}
TEST(StereoReplayTest, MultiplayerRejectsEmptyAndNonOverlappingScissors) {
const SubviewRect player{0.f, 0.f, 320.f, 228.f};
EXPECT_FALSE(subviews_overlap(player, {320.f, 0.f, 320.f, 228.f}));
EXPECT_FALSE(subviews_overlap(player, {0.f, 228.f, 640.f, 228.f}));
EXPECT_FALSE(subviews_overlap(player, {10.f, 10.f, 0.f, 10.f}));
EXPECT_TRUE(subviews_overlap(player, {10.f, 10.f, 20.f, 20.f}));
EXPECT_TRUE(subview_contains(player, {0.f, -0.5f, 320.f, 228.f}));
}
TEST(StereoReplayTest, EyeFrustumPreservesGameDepthMapping) {
const Mat4x4<float> game{
{10.0f, 11.0f, 12.0f, 13.0f},
+8
View File
@@ -356,6 +356,14 @@ target_include_directories(mkw_vr_player_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR
target_compile_features(mkw_vr_player_tests PRIVATE cxx_std_17)
add_test(NAME mkw_vr_player_tests COMMAND mkw_vr_player_tests)
add_executable(mkw_vr_policy_tests tests/vr_policy_tests.cpp src/vr/mkw_vr_policy.cpp)
target_include_directories(mkw_vr_policy_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_features(mkw_vr_policy_tests PRIVATE cxx_std_17)
if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU")
target_compile_options(mkw_vr_policy_tests PRIVATE -ffast-math)
endif()
add_test(NAME mkw_vr_policy_tests COMMAND mkw_vr_policy_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)
+3 -2
View File
@@ -147,8 +147,9 @@ extern "C" void GX__CopyTex_8016fd74(uint32_t da, uint32_t c) {
// EFB exactly once, matching Dolphin's ConvertEFBRectangle path.
GXSetTexCopySrc(rawSrcLeft, rawSrcTop, rawSrcWidth, rawSrcHeight);
// EFB copies stay GPU-only except probe-sized ones (e.g. the 4x4 lens-flare depth probe),
// which Aurora reads back asynchronously via efb_ram::schedule and land in guest RAM a frame
// later. RISK: copies above the probe threshold, or on the offscreen list, are not
// which Aurora reads back asynchronously and publishes during the next copy to that buffer.
// GPU callbacks retain pixels in host memory so a scene restart cannot receive a late write
// into a freed/reused allocation. RISK: copies above the probe threshold, or on the offscreen list, are not
// auto-downloaded, so guest reads see stale RAM; call aurora_flush_efb_copies_to_ram if a
// copy needs reading back.
GXCopyTex(GuestToHostPtr(da), (GXBool)c);
+5 -2
View File
@@ -615,8 +615,11 @@ void VI_HLE_PresentFrame(bool presentedXfb, bool paceToRetrace) {
// asynchronous worker may ask for an XR packet after the guest has already
// begun the next frame, so immersive replay is accepted only when both
// tags match.
const uint64_t vrContentTag = mkw::vr::MkwVRPolicyGetSnapshot().content_tag;
aurora_end_frame_tagged(vrContentTag);
const auto vrPolicy = mkw::vr::MkwVRPolicyGetSnapshot();
aurora_set_stereo_local_player_count(
vrPolicy.presentation == mkw::vr::VRPresentationMode::ImmersiveRace
? vrPolicy.scene.local_player_count : 1);
aurora_end_frame_tagged(vrPolicy.content_tag);
if (paceThisFrame) {
PaceToRetraceBoundary(paceDeadline);
std::lock_guard<std::mutex> lock(g_viMutex);
@@ -168,7 +168,9 @@ extern "C" void MkwVRObserveTranslatedFunctionEntry(uint32_t address,
case kRaceCameraUpdate: {
const uint32_t camera_address = context != nullptr ? context->gpr[3] : 0;
ObserveCamera(frame, camera_address);
if (camera_address == FirstCamera(frame)) {
PublishObservedCamera(frame, camera_address);
}
break;
}
case kScnMgrRaceDraw: {
+10 -3
View File
@@ -86,12 +86,13 @@ VRPresentationMode SelectPresentation(const PolicyState& state) noexcept {
return VRPresentationMode::Desktop;
}
// A virtual screen is the fail-safe for menus, split-screen, and any
// A virtual screen is the fail-safe for menus and any
// incomplete instrumentation. It preserves the unmodified render path.
if ((state.available_bindings & kMkwVRRequiredImmersiveBindings) !=
kMkwVRRequiredImmersiveBindings ||
!state.config.immersive_races || state.scene.mode != VRSceneMode::Race ||
state.scene.local_player_count != 1 || !IsFiniteCamera(state.camera) ||
(state.scene.local_player_count < 1 || state.scene.local_player_count > 4) ||
!IsFiniteCamera(state.camera) ||
!ObservationsAreCoherent(state.scene, state.camera)) {
return VRPresentationMode::VirtualScreen;
}
@@ -111,7 +112,8 @@ VRPresentationMode SelectStablePresentation(const PolicyState& state) noexcept {
if ((state.available_bindings & kMkwVRRequiredImmersiveBindings) !=
kMkwVRRequiredImmersiveBindings ||
!state.config.immersive_races || state.scene.mode != VRSceneMode::Race ||
state.scene.local_player_count != 1 || !IsFiniteCamera(state.camera)) {
(state.scene.local_player_count < 1 || state.scene.local_player_count > 4) ||
!IsFiniteCamera(state.camera)) {
return VRPresentationMode::VirtualScreen;
}
@@ -211,6 +213,11 @@ void MkwVRPolicySetAvailableBindings(uint32_t bindings) noexcept {
void MkwVRPolicyPublishScene(const MkwVRSceneObservation& scene) noexcept {
std::lock_guard<std::mutex> lock(g_policy_mutex);
// A packet for a different split layout must not consume retained content,
// even when both layouts use immersive presentation.
if (scene.local_player_count != g_policy.scene.local_player_count) {
AdvanceSafetyGeneration(g_policy);
}
ApplyPolicyMutation([&] {
if (scene.mode != VRSceneMode::Race || g_policy.scene.mode != VRSceneMode::Race) {
// Never carry a camera sample across a menu/replay-to-race transition.
+79
View File
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "vr/mkw_vr_policy.h"
#include <cstring>
#include <iostream>
using namespace mkw::vr;
int main() {
int failures = 0;
const auto check = [&](bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
++failures;
}
};
MkwVRPolicyReset();
MkwVRPolicyConfig config{};
config.enabled = true;
MkwVRPolicyConfigure(config);
MkwVRPolicySetSessionActive(true);
MkwVRPolicySetAvailableBindings(kMkwVRRequiredImmersiveBindings);
MkwVRSceneObservation scene{VRSceneMode::Race, 1, 10};
MkwVRCameraObservation camera{};
camera.view_from_world = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0};
camera.guest_camera_address = 0x81000000;
camera.guest_frame_index = 10;
camera.valid = true;
MkwVRPolicyPublishScene(scene);
MkwVRPolicyPublishRaceCamera(camera);
uint64_t previous_tag = 0;
for (uint32_t players : {1u, 2u, 3u, 4u, 2u, 1u}) {
scene.local_player_count = players;
MkwVRPolicyPublishScene(scene);
const auto snapshot = MkwVRPolicyGetSnapshot();
check(snapshot.presentation == VRPresentationMode::ImmersiveRace, "1-4 players should be immersive");
check(snapshot.content_tag != previous_tag, "layout changes must invalidate retained packets");
previous_tag = snapshot.content_tag;
MkwVRPolicyPublishScene(scene);
MkwVRPolicyPublishRaceCamera(camera);
check(MkwVRPolicyGetSnapshot().content_tag == previous_tag, "steady frames must keep the same tag");
}
camera.guest_frame_index = 11;
MkwVRPolicyPublishRaceCamera(camera);
check(MkwVRPolicyGetSnapshot().presentation == VRPresentationMode::ImmersiveRace,
"adjacent camera publication is coherent");
camera.guest_frame_index = 12;
MkwVRPolicyPublishRaceCamera(camera);
check(MkwVRPolicyGetSnapshot().presentation == VRPresentationMode::VirtualScreen, "stale camera fails safe");
camera.guest_frame_index = 10;
MkwVRPolicyPublishRaceCamera(camera);
for (uint32_t players : {0u, 5u, UINT32_MAX}) {
scene.local_player_count = players;
MkwVRPolicyPublishScene(scene);
check(MkwVRPolicyGetSnapshot().presentation == VRPresentationMode::VirtualScreen, "invalid counts fail safe");
}
scene.local_player_count = 4;
MkwVRPolicyPublishScene(scene);
const uint32_t nan_bits = 0x7fc00000;
std::memcpy(&camera.view_from_world[0], &nan_bits, sizeof(nan_bits));
MkwVRPolicyPublishRaceCamera(camera);
check(MkwVRPolicyGetSnapshot().presentation == VRPresentationMode::VirtualScreen, "NaN camera fails under fast math");
camera.view_from_world[0] = 1;
MkwVRPolicyPublishRaceCamera(camera);
MkwVRPolicySetAvailableBindings(MkwVRBindingRaceCamera);
check(MkwVRPolicyGetSnapshot().presentation == VRPresentationMode::VirtualScreen, "partial hooks fail safe");
MkwVRPolicySetAvailableBindings(kMkwVRRequiredImmersiveBindings);
scene.mode = VRSceneMode::FrontEnd;
MkwVRPolicyPublishScene(scene);
check(MkwVRPolicyGetSnapshot().presentation == VRPresentationMode::VirtualScreen, "menus use virtual screen");
scene.mode = VRSceneMode::Race;
MkwVRPolicyPublishScene(scene);
check(MkwVRPolicyGetSnapshot().presentation == VRPresentationMode::VirtualScreen, "race entry needs fresh camera");
MkwVRPolicyPublishRaceCamera(camera);
check(MkwVRPolicyGetSnapshot().presentation == VRPresentationMode::ImmersiveRace, "race resumes with fresh camera");
MkwVRPolicySetSessionActive(false);
check(MkwVRPolicyGetSnapshot().presentation == VRPresentationMode::Desktop, "inactive session uses desktop");
return failures == 0 ? 0 : 1;
}