From db30945f4c627732933f6a62bb50339f5bbb8842 Mon Sep 17 00:00:00 2001 From: iChris4 Date: Tue, 29 Sep 2026 23:55:52 +0200 Subject: [PATCH] Added First Person VR item management - Implemented DVDReadVrAsset function to read mapped disc paths for VR assets. - Created HeldItem structure and ReadHeldItem function for managing held items in the game. - Developed unit tests for ReadHeldItem to ensure correct functionality and edge case handling. - Added cockpit item data tests to validate model indexing and parsing of archives. --- OPENXR.md | 14 + .../quest/launcher/SettingsPage.kt | 14 +- android/app/src/main/res/values/strings.xml | 5 + aurora-main/include/aurora/aurora.h | 15 + aurora-main/lib/aurora.cpp | 10 + aurora-main/lib/gfx/cockpit.hpp | 42 +- aurora-main/lib/gfx/cockpit_item.hpp | 528 ++++++++++++++++ aurora-main/lib/gfx/cockpit_item_data.hpp | 577 ++++++++++++++++++ aurora-main/lib/gfx/common.cpp | 4 + aurora-main/lib/gfx/common.hpp | 1 + aurora-main/tests/CMakeLists.txt | 8 +- aurora-main/tests/cockpit_gpu_smoke.cpp | 64 +- aurora-main/tests/cockpit_item_data_test.cpp | 75 +++ runtime/CMakeLists.txt | 5 + runtime/include/hle/dvd_vr_asset.h | 9 + runtime/include/runtime_config.h | 16 + runtime/include/vr/mkw_vr_first_person.h | 5 + runtime/include/vr/mkw_vr_item.h | 47 ++ runtime/src/hle/storage/dvd.cpp | 15 + runtime/src/hle/vi.cpp | 10 + runtime/src/settings_overlay.cpp | 19 +- runtime/src/vr/mkw_vr_first_person.cpp | 24 + runtime/src/vr/openxr_input.cpp | 13 +- runtime/src/vr/openxr_integration.cpp | 6 +- runtime/tests/vr_config_tests.cpp | 7 + runtime/tests/vr_item_tests.cpp | 84 +++ 26 files changed, 1598 insertions(+), 19 deletions(-) create mode 100644 aurora-main/lib/gfx/cockpit_item.hpp create mode 100644 aurora-main/lib/gfx/cockpit_item_data.hpp create mode 100644 aurora-main/tests/cockpit_item_data_test.cpp create mode 100644 runtime/include/hle/dvd_vr_asset.h create mode 100644 runtime/include/vr/mkw_vr_item.h create mode 100644 runtime/tests/vr_item_tests.cpp diff --git a/OPENXR.md b/OPENXR.md index 1706fce..f563cd3 100644 --- a/OPENXR.md +++ b/OPENXR.md @@ -62,6 +62,7 @@ steering_wheel = true native_steering_wheel = true object_culling = false hand_steering = true +cockpit_item_hand = "left" performance_level = "boost" ``` @@ -300,6 +301,19 @@ see [Steering wheel and hand steering](#steering-wheel-and-hand-steering). Turning the wheel moves the controllers, and the game's own motion detection still reads them, so a sharp enough turn can read as a shake. +**Held item.** `cockpit_item_hand` accepts `"left"` (default), `"right"`, or `"off"` and is also +available in F10 > VR. In cockpit view, the selected tracked hand holds one item model from the +game's `Race/Common.szs` after the roulette settles. The item stands upright just above the palm +with its front toward the player. It turns only with the hand's heading, so rolling or tilting the +hand never tips it over. Triple items show their remaining inventory count beside the model, facing +the player. The display follows player 1's inventory: using, losing, or deploying the +item removes it from the hand even if a deployed object remains near the kart. Stick steering and +the existing item buttons still work. The imported models use their static bind pose; item effects +and animations are not reproduced in the hand. Each material is drawn from its own data: texture +layers with their wrap modes, SRT and environment mapping, vertex colours, culling, blending and up +to four TEV stages. Only the lighting is approximated, by a fixed cockpit light in place of the +course's light set. + **Bare hands.** On the Quest, with `hand_tracking` on and the controllers put down, the hands drive `khr/simple_controller`: a right pinch is A with the pointer on the hand's aim ray, the left palm-up pinch is + (pause), and in the cockpit, while a hand holds the wheel, that hand holds A and diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt index 220bf9e..31cecd8 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt @@ -189,13 +189,20 @@ class SettingsPage( write = { c, value -> c.setBool("vr", "hand_steering", value) }, enabledIf = cockpit, ) - // The hands follow the headset's hand tracking; kVrHandTrackingDefault is off. The - // hands are only drawn while they can steer, so it goes with hand steering. + choice( + R.string.vr_cockpit_item_hand, R.string.vr_cockpit_item_hand_helper, + listOf(R.string.vr_item_left, R.string.vr_item_right, R.string.vr_item_off), + read = { stringIndex(it, "vr", "cockpit_item_hand", ITEM_HANDS) }, + write = { c, index -> c.setString("vr", "cockpit_item_hand", ITEM_HANDS[index]) }, + enabledIf = cockpit, + ) + // Tracked hands can show the item with stick steering as well. toggle( R.string.vr_hand_tracking, R.string.vr_hand_tracking_helper, read = { it.bool("vr", "hand_tracking") ?: false }, write = { c, value -> c.setBool("vr", "hand_tracking", value) }, - enabledIf = { c -> cockpit(c) && (c.bool("vr", "hand_steering") ?: true) }, + enabledIf = { c -> cockpit(c) && ((c.bool("vr", "hand_steering") ?: true) || + stringIndex(c, "vr", "cockpit_item_hand", ITEM_HANDS) != 2) }, ) slider( R.string.vr_lean_back, R.string.vr_lean_back_helper, -45.0, 45.0, 1.0, @@ -755,6 +762,7 @@ class SettingsPage( val ROTATION_DEFAULT = ROTATIONS.indexOf("yaw_pitch") // The runtime's default ("cockpit") first. val SEATS = listOf("cockpit", "custom") + val ITEM_HANDS = listOf("left", "right", "off") // The runtime's default ("boost") first: an absent key reads as index 0. val PERFORMANCE_LEVELS = listOf("boost", "sustained_high", "sustained_low", "power_savings", "default") // runtime_config.h's kVrFoveationLevels, and its Quest default. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index eb1aa05..68f2d21 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -535,6 +535,11 @@ Custom Hand steering In the cockpit, squeeze a grip near the steering wheel or handlebar to grab it, and turn it to steer. Releasing both grips gives steering back to the stick. Hand steering by heurazy. + Item in cockpit hand + Show Player 1\'s settled inventory item above the selected palm. Triple items show the remaining count. Using or losing the item hides it. + Left + Right + Off Tracked hands The cockpit hands follow your own. Holding the controllers, the fingers follow their touch sensors; put one down and the cameras track that hand at once. Put the controllers down to drive with bare hands: close a hand on the wheel to hold it, which also holds the gas; pinch with a free hand to use an item; flick your hands up for a trick; pinch with your left palm facing you to pause. In menus, a right pinch is A. Choose Automatic drift. Needs hand tracking on in the headset\'s settings. Lean back angle diff --git a/aurora-main/include/aurora/aurora.h b/aurora-main/include/aurora/aurora.h index 7510ece..1c75b71 100644 --- a/aurora-main/include/aurora/aurora.h +++ b/aurora-main/include/aurora/aurora.h @@ -153,6 +153,16 @@ typedef struct { AuroraCockpitHand hands[2]; } AuroraCockpit; +// Inventory from the same guest frame as the scene. hand: 0 left, 1 right, +// 2 off. A zero-initialised value has no item. +typedef struct { + uint64_t raceGeneration; + uint8_t id; + uint8_t count; + uint8_t hand; + bool valid; +} AuroraCockpitItem; + typedef struct { float position[3]; int16_t joints[4]; @@ -324,6 +334,11 @@ void aurora_set_stereo_scene_anchor(const float anchorFromScene[12]); // The sealed frame then owns that scale: each eye's head/IPD translation is // rescaled from the packet's AuroraCockpit::unitsPerMeter to it. void aurora_set_stereo_scene_anchor_scaled(const float anchorFromScene[12], float unitsPerMeter); +// GX producer thread, after the scene anchor and before sealing that frame. +void aurora_set_stereo_cockpit_item(const AuroraCockpitItem* item); +// Copies a user-supplied Race/Common.szs archive. May be called on the guest +// thread; the renderer owns decoded assets and never refers back to guest RAM. +void aurora_set_cockpit_item_archive(const void* bytes, uint32_t size); // 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. diff --git a/aurora-main/lib/aurora.cpp b/aurora-main/lib/aurora.cpp index d87e856..7f8b00f 100644 --- a/aurora-main/lib/aurora.cpp +++ b/aurora-main/lib/aurora.cpp @@ -118,6 +118,7 @@ struct StereoSceneAnchor { // World units per metre the anchor was built with, or zero when the packet's // own scale applies (aurora_set_stereo_scene_anchor_scaled). float unitsPerMeter = 0.f; + AuroraCockpitItem cockpitItem{}; }; // 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 @@ -829,6 +830,7 @@ gfx::StereoReplayFrame make_stereo_replay_frame(const AuroraStereoFrame& input, std::memcpy(&anchorFromScene, sceneAnchor.anchorFromScene.data(), sizeof(anchorFromScene)); gfx::StereoReplayFrame replay{}; replay.cockpit = input.cockpit; + replay.cockpitItem = sceneAnchor.cockpitItem; replay.window = input.mode == AURORA_STEREO_FRAME_IMMERSIVE_REPLAY && input.window; // The sealed guest frame owns its scale. The packet may have been sampled // just before a change of scale (a character swap, a lightning strike), so @@ -2703,6 +2705,10 @@ void set_stereo_scene_anchor(const float anchorFromScene[12]) noexcept { g_pendingSceneAnchor = anchor; } +void set_stereo_cockpit_item(const AuroraCockpitItem* item) noexcept { + g_pendingSceneAnchor.cockpitItem = item != nullptr ? *item : AuroraCockpitItem{}; +} + #ifdef AURORA_ENABLE_GX namespace stereo { void set_sink(SinkCallback callback, SubmitCallback submitted, void* userdata) noexcept { @@ -2763,6 +2769,10 @@ void aurora_set_stereo_scene_anchor_scaled(const float anchorFromScene[12], floa aurora::g_pendingSceneAnchor.unitsPerMeter = unitsPerMeter; } } + +void aurora_set_stereo_cockpit_item(const AuroraCockpitItem* item) { + aurora::set_stereo_cockpit_item(item); +} void aurora_set_stereo_local_player_count(uint32_t count) { aurora::g_pendingStereoLocalPlayerCount = count >= 1 && count <= 4 ? count : 1; } diff --git a/aurora-main/lib/gfx/cockpit.hpp b/aurora-main/lib/gfx/cockpit.hpp index 5dc2ae7..41a7838 100644 --- a/aurora-main/lib/gfx/cockpit.hpp +++ b/aurora-main/lib/gfx/cockpit.hpp @@ -7,6 +7,7 @@ // and hand steering". #pragma once #include "common.hpp" +#include "cockpit_item.hpp" #include "../webgpu/gpu.hpp" #include #include @@ -251,12 +252,44 @@ inline void build_geometry(const AuroraCockpit& cockpit, std::vector& ve else glove(vertices,hand,side); } } +inline void append_item_badge(const AuroraCockpit& cockpit,const AuroraCockpitItem& item, + std::vector& vertices) { + if(!item.valid || item.hand>1 || item.count<1 || item.count>3 || + (item.id!=5 && item.id!=16 && item.id!=17 && item.id!=18) || + !cockpit.hands[item.hand].tracked || !cockpit_item::has_model(item.id)) return; + const auto& hand=cockpit.hands[item.hand]; + if(hand.jointsValid && !joints_finite(hand)) return; + M frame; + if(!cockpit_item::seat_from_item(hand,frame)) return; + // In the item's upright frame, beside the widest model on the hand's outer + // side, facing the player like the item. + const float side=item.hand==0?-1.0f:1.0f; + const size_t first=vertices.size(); + const V base{side*0.095f,0.035f,0.0f}; + ellipsoid(vertices,base,{0.016f,0.020f,0.004f},{0.05f,0.08f,0.13f}); + // A tiny raised seven-segment digit stays legible without creating another + // textured game asset. One model is held for every triple inventory ID. + const uint8_t digit=item.count==1?0x06:item.count==2?0x5b:0x4f; + const V white{0.95f,0.98f,0.85f}; + const auto segment=[&](int bit,float x0,float y0,float x1,float y1) { + if(digit&(1u< geometry(const AuroraCockpit& cockpit) { std::vector result;build_geometry(cockpit,result);return result; } inline std::atomic meshRevision{1}; inline std::vector frameVertices; inline AuroraCockpit cachedCockpit{}; +inline AuroraCockpitItem cachedCockpitItem{}; inline uint64_t cachedMeshRevision=0; inline wgpu::RenderPipeline pipeline; struct SceneDepth { @@ -268,7 +301,7 @@ inline bool pipelineReversedDepth=false; inline wgpu::TextureFormat pipelineFormat{}, pipelineDepthFormat{}; inline std::array vertexBuffers; inline std::array vertexCapacity{}; -inline void shutdown() { pipeline=nullptr;pipelineSamples=0;vertexBuffers={};vertexCapacity={};cachedMeshRevision=0;frameVertices.clear(); } +inline void shutdown() { pipeline=nullptr;pipelineSamples=0;vertexBuffers={};vertexCapacity={};cachedMeshRevision=0;cachedCockpitItem={};frameVertices.clear();cockpit_item::shutdown(); } inline void render(wgpu::CommandEncoder& cmd,const StereoReplayFrame& frame,uint32_t eye,SceneDepth sceneDepth={}, const wgpu::RenderPassEncoder* existingPass=nullptr) { if(!frame.cockpit.active || !sceneDepth.valid) return; @@ -308,9 +341,11 @@ inline void render(wgpu::CommandEncoder& cmd,const StereoReplayFrame& frame,uint pipelineReversedDepth=reversedDepth;pipelineDepthFormat=target.depthFormat; } const auto revision=meshRevision.load(); - if(cachedMeshRevision!=revision || std::memcmp(&cachedCockpit,&frame.cockpit,sizeof(AuroraCockpit))!=0) { + if(cachedMeshRevision!=revision || std::memcmp(&cachedCockpit,&frame.cockpit,sizeof(AuroraCockpit))!=0 || + std::memcmp(&cachedCockpitItem,&frame.cockpitItem,sizeof(AuroraCockpitItem))!=0) { build_geometry(frame.cockpit,frameVertices); - cachedCockpit=frame.cockpit;cachedMeshRevision=revision; + append_item_badge(frame.cockpit,frame.cockpitItem,frameVertices); + cachedCockpit=frame.cockpit;cachedCockpitItem=frame.cockpitItem;cachedMeshRevision=revision; } const auto& vertices=frameVertices; if(vertices.empty()) return; @@ -348,6 +383,7 @@ inline void render(wgpu::CommandEncoder& cmd,const StereoReplayFrame& frame,uint // Mark only depth-visible samples; later virtual-screen draws test for zero. pass.SetStencilReference(1); pass.SetPipeline(pipeline);pass.SetVertexBuffer(0,buffer);pass.Draw(clip.size()); + cockpit_item::render(pass,frame,eye,sceneDepth.z,sceneDepth.constant); pass.SetStencilReference(0); if(!existingPass) pass.End(); } diff --git a/aurora-main/lib/gfx/cockpit_item.hpp b/aurora-main/lib/gfx/cockpit_item.hpp new file mode 100644 index 0000000..6f7602d --- /dev/null +++ b/aurora-main/lib/gfx/cockpit_item.hpp @@ -0,0 +1,528 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "cockpit_item_data.hpp" +#include "texture_convert.hpp" +#include "../webgpu/gpu.hpp" + +#include +#include +#include +#include +#include + +namespace aurora::gfx::cockpit_item { + +inline std::mutex archiveMutex; +inline std::shared_ptr archive; +inline std::atomic archiveRevision{0}; + +inline size_t mip_bytes(const data::Texture& texture,uint32_t mips) { + size_t total=0; + for(uint32_t level=0;level>level,1))*std::max(texture.height>>level,1)*4; + return total; +} +inline void set_archive(const void* bytes,uint32_t size) { + { std::lock_guard lock(archiveMutex); if(archive) return; } + auto parsed=std::make_shared(data::parse_archive(bytes,size)); + for(auto& model:parsed->models) for(auto& texture:model.textures) { + for(uint32_t mips:{texture.mips,1u}) { + auto converted=convert_texture(texture.format,texture.width,texture.height,mips, + ArrayRef(texture.bytes)); + const size_t length=mip_bytes(texture,mips); + if(converted.format!=wgpu::TextureFormat::RGBA8Unorm || converted.data.size()loaded?std::move(parsed):nullptr; + ++archiveRevision; +} +inline bool has_model(uint8_t id) { + const int index=data::model_index(id); + if(index<0) return false; + std::lock_guard lock(archiveMutex); + return archive && archive->models[index].valid(); +} + +// Uniform images. WGSL: struct Material and struct Frame below. +struct GpuStage { uint32_t color[4],colorOp[4],alpha[4],alphaOp[4],misc[4];float konst[4],texGen[2][4]; }; +struct GpuMaterial { GpuStage stages[4];float registers[4][4],materialColor[4];uint32_t info[4]; }; +static_assert(sizeof(GpuMaterial)==608); +struct GpuFrame { float eyeFromModel[12],seatFromModel[12],projection[4],depth[4]; }; +static_assert(sizeof(GpuFrame)==128); +// Static per-model vertices: billboards keep their origin in position (w=1) +// and their bone-local offset, which the vertex shader turns to face the eye. +struct GpuVertex { float position[4],offset[4],normal[4],color[4],uv[4]; }; +static_assert(sizeof(GpuVertex)==80); + +inline GpuMaterial gpu_material(const data::Material& material) { + GpuMaterial out{}; + for(uint32_t i=0;i>12)&15u;g.color[1]=(c>>8)&15u;g.color[2]=(c>>4)&15u;g.color[3]=c&15u; + g.colorOp[0]=(c>>16)&3u;g.colorOp[1]=(c>>18)&1u;g.colorOp[2]=(c>>19)&1u;g.colorOp[3]=(c>>20)&3u; + g.alpha[0]=(a>>13)&7u;g.alpha[1]=(a>>10)&7u;g.alpha[2]=(a>>7)&7u;g.alpha[3]=(a>>4)&7u; + g.alphaOp[0]=(a>>16)&3u;g.alphaOp[1]=(a>>18)&1u;g.alphaOp[2]=(a>>19)&1u;g.alphaOp[3]=(a>>20)&3u; + g.misc[0]=(c>>22)&3u;g.misc[1]=(a>>22)&3u;g.misc[2]=s.textured;g.misc[3]=s.rasterized; + std::memcpy(g.konst,s.konst.data(),sizeof(g.konst)); + const auto& gen=material.texGens[s.texCoord]; + const auto& m=gen.matrix; + const float row0[4]{m[0],m[1],m[2],gen.normal?1.f:0.f},row1[4]{m[3],m[4],m[5],float(gen.uvSet)}; + std::memcpy(g.texGen[0],row0,sizeof(row0));std::memcpy(g.texGen[1],row1,sizeof(row1)); + } + for(int i=0;i<4;++i) std::memcpy(out.registers[i],material.registers[i].data(),sizeof(out.registers[i])); + std::memcpy(out.materialColor,material.materialColor.data(),sizeof(out.materialColor)); + out.info[0]=material.stageCount;out.info[1]=material.alphaCompare; + out.info[2]=material.colorControl;out.info[3]=material.alphaControl; + return out; +} + +struct GpuModel { + wgpu::Buffer vertices; + std::vector textures; + std::vector uniforms; + std::vector materials; +}; +inline std::array gpuModels; +inline std::array gpuReady{}; +inline std::shared_ptr gpuArchive; +inline uint64_t gpuRevision=0; +inline wgpu::Texture whiteTexture; +inline std::array samplers; +inline wgpu::BindGroupLayout materialLayout,frameLayout; +inline wgpu::PipelineLayout pipelineLayout; +inline wgpu::ShaderModule shader; +// One frame uniform per eye: both eyes may be encoded before one submit. +inline std::array frameBuffers; +inline std::array frameGroups; +struct PipelineKey { + uint8_t cull=2,blendSrc=0,blendDst=0; + bool blend=false,subtract=false,depthWrite=true; + bool operator==(const PipelineKey&) const = default; +}; +inline std::vector> pipelines; +inline uint32_t pipelineSamples=0; +inline bool pipelineReversed=false; +inline wgpu::TextureFormat pipelineColor{},pipelineDepth{}; + +inline void shutdown() { + gpuModels={};gpuReady={};gpuArchive.reset();gpuRevision=0; + pipelines.clear();samplers={};whiteTexture=nullptr;shader=nullptr; + frameBuffers={};frameGroups={};materialLayout=nullptr;frameLayout=nullptr;pipelineLayout=nullptr; + pipelineSamples=0; +} + +inline void refresh_archive() { + const uint64_t revision=archiveRevision.load(std::memory_order_acquire); + if(revision==gpuRevision) return; + std::lock_guard lock(archiveMutex); + gpuArchive=archive; + gpuModels={};gpuReady={}; + gpuRevision=revision; +} + +// GX TEV, four stages at most (item materials use three). Konst selections are +// resolved on the CPU; each stage samples its own binding with its own texgen. +inline constexpr const char* tevShader=R"( + struct Stage { color: vec4u, colorOp: vec4u, alpha: vec4u, alphaOp: vec4u, misc: vec4u, konst: vec4f, + texGen0: vec4f, texGen1: vec4f }; + struct Material { stages: array, registers: array, materialColor: vec4f, info: vec4u }; + struct Frame { eye0: vec4f, eye1: vec4f, eye2: vec4f, seat0: vec4f, seat1: vec4f, seat2: vec4f, + projection: vec4f, depth: vec4f }; + @group(0) @binding(0) var material: Material; + @group(0) @binding(1) var sampler0: sampler; + @group(0) @binding(2) var texture0: texture_2d; + @group(0) @binding(3) var sampler1: sampler; + @group(0) @binding(4) var texture1: texture_2d; + @group(0) @binding(5) var sampler2: sampler; + @group(0) @binding(6) var texture2: texture_2d; + @group(0) @binding(7) var sampler3: sampler; + @group(0) @binding(8) var texture3: texture_2d; + @group(1) @binding(0) var frame: Frame; + struct Out { @builtin(position) position: vec4f, @location(0) color: vec4f, + @location(1) uv01: vec4f, @location(2) uv23: vec4f }; + fn unit(v: vec3f) -> vec3f { return v / max(length(v), 1e-4); } + // G3D texgen: a UV set or, for env maps, the view-space normal, then the SRT. + fn texCoord(s: u32, normal: vec3f, uv: vec4f) -> vec2f { + let g0 = material.stages[s].texGen0; + let g1 = material.stages[s].texGen1; + var base = select(uv.xy, uv.zw, g1.w > 0.5); + if (g0.w > 0.5) { base = vec2f(0.5 * normal.x + 0.5, -0.5 * normal.y + 0.5); } + return vec2f(dot(g0.xyz, vec3f(base, 1.0)), dot(g1.xyz, vec3f(base, 1.0))); + } + @vertex fn vs(@location(0) position: vec4f, @location(1) offset: vec4f, @location(2) normal: vec4f, + @location(3) color: vec4f, @location(4) uv: vec4f) -> Out { + let p4 = vec4f(position.xyz, 1.0); + let p = vec3f(dot(frame.eye0, p4), dot(frame.eye1, p4), dot(frame.eye2, p4)) + offset.xyz * frame.depth.z; + let billboard = position.w > 0.5; + let n = normal.xyz; + let eyeNormal = select(unit(vec3f(dot(frame.eye0.xyz, n), dot(frame.eye1.xyz, n), dot(frame.eye2.xyz, n))), + vec3f(0.0, 0.0, 1.0), billboard); + var lit = 1.0; + if (!billboard && (material.info.z & 2u) != 0u) { + let seatNormal = unit(vec3f(dot(frame.seat0.xyz, n), dot(frame.seat1.xyz, n), dot(frame.seat2.xyz, n))); + lit = 0.55 + 0.45 * abs(dot(seatNormal, vec3f(0.3, 0.8, 0.5))); + } + var o: Out; + let z = frame.depth.x * p.z + frame.depth.y; + o.position = vec4f(frame.projection.x * p.x + frame.projection.y * p.z, + frame.projection.z * p.y + frame.projection.w * p.z, + clamp(z, 0.0, max(-p.z, 0.0)), -p.z); + o.color = vec4f(select(material.materialColor.rgb, color.rgb, (material.info.z & 1u) != 0u) * lit, + select(material.materialColor.a, color.a, (material.info.w & 1u) != 0u)); + o.uv01 = vec4f(texCoord(0u, eyeNormal, uv), texCoord(1u, eyeNormal, uv)); + o.uv23 = vec4f(texCoord(2u, eyeNormal, uv), texCoord(3u, eyeNormal, uv)); + return o; + } + fn colorIn(sel: u32, prev: vec4f, c0: vec4f, c1: vec4f, c2: vec4f, tex: vec4f, ras: vec4f, k: vec4f) -> vec3f { + switch sel { + case 0u: { return prev.rgb; } case 1u: { return vec3f(prev.a); } + case 2u: { return c0.rgb; } case 3u: { return vec3f(c0.a); } + case 4u: { return c1.rgb; } case 5u: { return vec3f(c1.a); } + case 6u: { return c2.rgb; } case 7u: { return vec3f(c2.a); } + case 8u: { return tex.rgb; } case 9u: { return vec3f(tex.a); } + case 10u: { return ras.rgb; } case 11u: { return vec3f(ras.a); } + case 12u: { return vec3f(1.0); } case 13u: { return vec3f(0.5); } + case 14u: { return k.rgb; } + default: { return vec3f(0.0); } + } + } + fn alphaIn(sel: u32, prev: vec4f, c0: vec4f, c1: vec4f, c2: vec4f, tex: vec4f, ras: vec4f, k: vec4f) -> f32 { + switch sel { + case 0u: { return prev.a; } case 1u: { return c0.a; } case 2u: { return c1.a; } case 3u: { return c2.a; } + case 4u: { return tex.a; } case 5u: { return ras.a; } case 6u: { return k.a; } + default: { return 0.0; } + } + } + fn tevBias(b: u32) -> f32 { if (b == 1u) { return 0.5; } if (b == 2u) { return -0.5; } return 0.0; } + fn tevScale(s: u32) -> f32 { if (s == 1u) { return 2.0; } if (s == 2u) { return 4.0; } if (s == 3u) { return 0.5; } return 1.0; } + fn alphaTest(f: u32, value: f32, reference: f32) -> bool { + switch f { + case 0u: { return false; } case 1u: { return value < reference; } case 2u: { return value == reference; } + case 3u: { return value <= reference; } case 4u: { return value > reference; } + case 5u: { return value != reference; } case 6u: { return value >= reference; } + default: { return true; } + } + } + @fragment fn fs(i: Out) -> @location(0) vec4f { + var samples = array(textureSample(texture0, sampler0, i.uv01.xy), textureSample(texture1, sampler1, i.uv01.zw), + textureSample(texture2, sampler2, i.uv23.xy), textureSample(texture3, sampler3, i.uv23.zw)); + var prev = material.registers[0]; + var c0 = material.registers[1]; + var c1 = material.registers[2]; + var c2 = material.registers[3]; + var result = prev; + for (var s = 0u; s < min(material.info.x, 4u); s++) { + let st = material.stages[s]; + let tex = select(vec4f(1.0), samples[s], st.misc.z != 0u); + let ras = select(vec4f(0.0), i.color, st.misc.w != 0u); + let ca = colorIn(st.color.x, prev, c0, c1, c2, tex, ras, st.konst); + let cb = colorIn(st.color.y, prev, c0, c1, c2, tex, ras, st.konst); + let cc = colorIn(st.color.z, prev, c0, c1, c2, tex, ras, st.konst); + let cd = colorIn(st.color.w, prev, c0, c1, c2, tex, ras, st.konst); + var color = (cd + select(1.0, -1.0, st.colorOp.y != 0u) * mix(ca, cb, cc) + tevBias(st.colorOp.x)) * tevScale(st.colorOp.w); + color = select(clamp(color, vec3f(-4.0), vec3f(4.0)), clamp(color, vec3f(0.0), vec3f(1.0)), st.colorOp.z != 0u); + let aa = alphaIn(st.alpha.x, prev, c0, c1, c2, tex, ras, st.konst); + let ab = alphaIn(st.alpha.y, prev, c0, c1, c2, tex, ras, st.konst); + let ac = alphaIn(st.alpha.z, prev, c0, c1, c2, tex, ras, st.konst); + let ad = alphaIn(st.alpha.w, prev, c0, c1, c2, tex, ras, st.konst); + var alpha = (ad + select(1.0, -1.0, st.alphaOp.y != 0u) * mix(aa, ab, ac) + tevBias(st.alphaOp.x)) * tevScale(st.alphaOp.w); + alpha = select(clamp(alpha, -4.0, 4.0), clamp(alpha, 0.0, 1.0), st.alphaOp.z != 0u); + switch st.misc.x { + case 1u: { c0 = vec4f(color, c0.a); } case 2u: { c1 = vec4f(color, c1.a); } + case 3u: { c2 = vec4f(color, c2.a); } default: { prev = vec4f(color, prev.a); } + } + switch st.misc.y { + case 1u: { c0.a = alpha; } case 2u: { c1.a = alpha; } + case 3u: { c2.a = alpha; } default: { prev.a = alpha; } + } + result = vec4f(color, alpha); + } + result = clamp(result, vec4f(0.0), vec4f(1.0)); + let word = material.info.y; + let a8 = round(result.a * 255.0); + let pass0 = alphaTest((word >> 16u) & 7u, a8, f32(word & 255u)); + let pass1 = alphaTest((word >> 19u) & 7u, a8, f32((word >> 8u) & 255u)); + let logic = (word >> 22u) & 3u; + var passed = pass0 && pass1; + if (logic == 1u) { passed = pass0 || pass1; } else if (logic == 2u) { passed = pass0 != pass1; } + else if (logic == 3u) { passed = pass0 == pass1; } + if (!passed) { discard; } + return result; + } +)"; + +inline const wgpu::Sampler& sampler(uint8_t wrapS,uint8_t wrapT,bool mipmapped) { + auto& slot=samplers[(wrapS*3+wrapT)*2+mipmapped]; + if(!slot) { + constexpr wgpu::AddressMode modes[3]{wgpu::AddressMode::ClampToEdge,wgpu::AddressMode::Repeat, + wgpu::AddressMode::MirrorRepeat}; + const wgpu::SamplerDescriptor desc{.label="Cockpit item sampler", + .addressModeU=modes[wrapS],.addressModeV=modes[wrapT], + .magFilter=wgpu::FilterMode::Linear,.minFilter=wgpu::FilterMode::Linear, + .mipmapFilter=mipmapped?wgpu::MipmapFilterMode::Linear:wgpu::MipmapFilterMode::Nearest}; + slot=webgpu::g_device.CreateSampler(&desc); + } + return slot; +} + +inline void prepare_layout() { + using namespace webgpu; + if(materialLayout) return; + std::array entries{}; + entries[0]={.binding=0,.visibility=wgpu::ShaderStage::Vertex|wgpu::ShaderStage::Fragment, + .buffer=wgpu::BufferBindingLayout{.type=wgpu::BufferBindingType::Uniform,.minBindingSize=sizeof(GpuMaterial)}}; + for(uint32_t i=0;i<4;++i) { + entries[1+i*2]={.binding=1+i*2,.visibility=wgpu::ShaderStage::Fragment, + .sampler=wgpu::SamplerBindingLayout{.type=wgpu::SamplerBindingType::Filtering}}; + entries[2+i*2]={.binding=2+i*2,.visibility=wgpu::ShaderStage::Fragment, + .texture=wgpu::TextureBindingLayout{.sampleType=wgpu::TextureSampleType::Float, + .viewDimension=wgpu::TextureViewDimension::e2D}}; + } + const wgpu::BindGroupLayoutDescriptor materialDesc{.entryCount=entries.size(),.entries=entries.data()}; + materialLayout=g_device.CreateBindGroupLayout(&materialDesc); + const wgpu::BindGroupLayoutEntry frameEntry{.binding=0,.visibility=wgpu::ShaderStage::Vertex, + .buffer=wgpu::BufferBindingLayout{.type=wgpu::BufferBindingType::Uniform,.minBindingSize=sizeof(GpuFrame)}}; + const wgpu::BindGroupLayoutDescriptor frameDesc{.entryCount=1,.entries=&frameEntry}; + frameLayout=g_device.CreateBindGroupLayout(&frameDesc); + const std::array layouts{materialLayout,frameLayout}; + const wgpu::PipelineLayoutDescriptor layoutDesc{.bindGroupLayoutCount=layouts.size(),.bindGroupLayouts=layouts.data()}; + pipelineLayout=g_device.CreatePipelineLayout(&layoutDesc); + wgpu::ShaderSourceWGSL source{}; + source.code=tevShader; + wgpu::ShaderModuleDescriptor md{};md.nextInChain=&source;md.label="Cockpit item TEV"; + shader=g_device.CreateShaderModule(&md); + for(uint32_t eye=0;eye<2;++eye) { + const wgpu::BufferDescriptor bufferDesc{.label="Cockpit item frame", + .usage=wgpu::BufferUsage::Uniform|wgpu::BufferUsage::CopyDst,.size=sizeof(GpuFrame)}; + frameBuffers[eye]=g_device.CreateBuffer(&bufferDesc); + const wgpu::BindGroupEntry entry{.binding=0,.buffer=frameBuffers[eye],.size=sizeof(GpuFrame)}; + const wgpu::BindGroupDescriptor group{.layout=frameLayout,.entryCount=1,.entries=&entry}; + frameGroups[eye]=g_device.CreateBindGroup(&group); + } + const wgpu::TextureDescriptor desc{.label="Cockpit item white", + .usage=wgpu::TextureUsage::TextureBinding|wgpu::TextureUsage::CopyDst, + .dimension=wgpu::TextureDimension::e2D,.size={1,1,1},.format=wgpu::TextureFormat::RGBA8Unorm, + .mipLevelCount=1,.sampleCount=1}; + whiteTexture=g_device.CreateTexture(&desc); + const uint8_t white[4]{255,255,255,255}; + const wgpu::TexelCopyTextureInfo destination{.texture=whiteTexture}; + const wgpu::TexelCopyBufferLayout layout{.bytesPerRow=4,.rowsPerImage=1}; + const wgpu::Extent3D extent{1,1,1}; + g_queue.WriteTexture(&destination,white,4,&layout,&extent); +} + +inline void prepare_model(size_t index) { + using namespace webgpu; + const auto& model=gpuArchive->models[index]; + auto& gpu=gpuModels[index]; + std::vector vertices; + for(const auto& part:model.parts) for(const auto& v:part.vertices) { + const data::V3 at=part.billboard?part.origin:v.position,offset=part.billboard?v.position:data::V3{}; + vertices.push_back({{at.x,at.y,at.z,part.billboard?1.f:0.f},{offset.x,offset.y,offset.z,0}, + {v.normal.x,v.normal.y,v.normal.z,0},{v.color[0],v.color[1],v.color[2],v.color[3]}, + {v.uv[0].x,v.uv[0].y,v.uv[1].x,v.uv[1].y}}); + } + const wgpu::BufferDescriptor vertexDesc{.label="Cockpit item vertices", + .usage=wgpu::BufferUsage::Vertex|wgpu::BufferUsage::CopyDst,.size=vertices.size()*sizeof(GpuVertex)}; + gpu.vertices=g_device.CreateBuffer(&vertexDesc); + g_queue.WriteBuffer(gpu.vertices,0,vertices.data(),vertices.size()*sizeof(GpuVertex)); + for(const auto& texture:model.textures) { + const bool usable=!texture.rgba.empty() && texture.rgba.size()>=mip_bytes(texture,texture.mips); + if(!usable) { gpu.textures.push_back(nullptr);continue; } + const wgpu::TextureDescriptor desc{.label="Cockpit item texture", + .usage=wgpu::TextureUsage::TextureBinding|wgpu::TextureUsage::CopyDst, + .dimension=wgpu::TextureDimension::e2D,.size={texture.width,texture.height,1}, + .format=wgpu::TextureFormat::RGBA8Unorm,.mipLevelCount=texture.mips,.sampleCount=1}; + auto gpuTexture=g_device.CreateTexture(&desc); + size_t offset=0; + for(uint32_t level=0;level>level,1),h=std::max(texture.height>>level,1); + const wgpu::TexelCopyTextureInfo destination{.texture=gpuTexture,.mipLevel=level}; + const wgpu::TexelCopyBufferLayout layout{.bytesPerRow=w*4,.rowsPerImage=h}; + const wgpu::Extent3D extent{w,h,1}; + g_queue.WriteTexture(&destination,texture.rgba.data()+offset,size_t(w)*h*4,&layout,&extent); + offset+=size_t(w)*h*4; + } + gpu.textures.push_back(std::move(gpuTexture)); + } + for(const auto& material:model.materials) { + const auto uniform=gpu_material(material); + const wgpu::BufferDescriptor bufferDesc{.label="Cockpit item material", + .usage=wgpu::BufferUsage::Uniform|wgpu::BufferUsage::CopyDst,.size=sizeof(GpuMaterial)}; + auto buffer=g_device.CreateBuffer(&bufferDesc); + g_queue.WriteBuffer(buffer,0,&uniform,sizeof(uniform)); + std::array entries{}; + entries[0]={.binding=0,.buffer=buffer,.size=sizeof(GpuMaterial)}; + for(uint32_t i=0;i<4;++i) { + const auto& stage=material.stages[i]; + const data::Map* map=itexture]?&gpu.textures[map->texture]:nullptr; + const bool mipmapped=texture && model.textures[map->texture].mips>1; + entries[1+i*2]={.binding=1+i*2,.sampler=texture?sampler(map->wrapS,map->wrapT,mipmapped):sampler(1,1,false)}; + entries[2+i*2]={.binding=2+i*2,.textureView=(texture?*texture:whiteTexture).CreateView()}; + } + const wgpu::BindGroupDescriptor group{.layout=materialLayout,.entryCount=entries.size(),.entries=entries.data()}; + gpu.materials.push_back(g_device.CreateBindGroup(&group)); + gpu.uniforms.push_back(std::move(buffer)); + } + gpuReady[index]=true; +} + +inline wgpu::BlendFactor blend_factor(uint8_t factor,bool source) { + switch(factor) { + case 0: return wgpu::BlendFactor::Zero; + case 1: return wgpu::BlendFactor::One; + case 2: return source?wgpu::BlendFactor::Dst:wgpu::BlendFactor::Src; + case 3: return source?wgpu::BlendFactor::OneMinusDst:wgpu::BlendFactor::OneMinusSrc; + case 4: return wgpu::BlendFactor::SrcAlpha; + case 5: return wgpu::BlendFactor::OneMinusSrcAlpha; + case 6: return wgpu::BlendFactor::One; // The eye target's alpha is not the EFB's. + default: return wgpu::BlendFactor::Zero; + } +} +inline const wgpu::RenderPipeline& pipeline(const PipelineKey& key,const StereoReplayFrame& frame,uint32_t eye,bool reversed) { + using namespace webgpu; + const auto& target=frame.eyes[eye].target; + const auto format=g_graphicsConfig.surfaceConfiguration.format; + if(pipelineSamples!=target.msaaSamples || pipelineColor!=format || + pipelineDepth!=target.depthFormat || pipelineReversed!=reversed) { + pipelines.clear(); + pipelineSamples=target.msaaSamples;pipelineColor=format;pipelineDepth=target.depthFormat;pipelineReversed=reversed; + } + for(const auto& [cached,value]:pipelines) if(cached==key) return value; + const wgpu::VertexAttribute attrs[]{ + {.format=wgpu::VertexFormat::Float32x4,.offset=0,.shaderLocation=0}, + {.format=wgpu::VertexFormat::Float32x4,.offset=16,.shaderLocation=1}, + {.format=wgpu::VertexFormat::Float32x4,.offset=32,.shaderLocation=2}, + {.format=wgpu::VertexFormat::Float32x4,.offset=48,.shaderLocation=3}, + {.format=wgpu::VertexFormat::Float32x4,.offset=64,.shaderLocation=4}, + }; + const wgpu::VertexBufferLayout vertices{.arrayStride=sizeof(GpuVertex),.attributeCount=5,.attributes=attrs}; + const wgpu::BlendState blend{ + .color=key.subtract?wgpu::BlendComponent{.operation=wgpu::BlendOperation::ReverseSubtract, + .srcFactor=wgpu::BlendFactor::One,.dstFactor=wgpu::BlendFactor::One} + :wgpu::BlendComponent{.operation=wgpu::BlendOperation::Add, + .srcFactor=blend_factor(key.blendSrc,true), + .dstFactor=blend_factor(key.blendDst,false)}, + .alpha={.operation=wgpu::BlendOperation::Add,.srcFactor=wgpu::BlendFactor::One, + .dstFactor=wgpu::BlendFactor::OneMinusSrcAlpha}, + }; + const wgpu::ColorTargetState color{.format=format,.blend=key.blend?&blend:nullptr}; + const wgpu::FragmentState fragment{.module=shader,.entryPoint="fs",.targetCount=1,.targets=&color}; + const bool stencil=target.depthFormat==wgpu::TextureFormat::Depth24PlusStencil8; + const wgpu::StencilFaceState mark{.compare=wgpu::CompareFunction::Always, + .passOp=stencil?wgpu::StencilOperation::Replace:wgpu::StencilOperation::Keep}; + const wgpu::DepthStencilState depth{.format=target.depthFormat,.depthWriteEnabled=key.depthWrite, + .depthCompare=reversed?wgpu::CompareFunction::GreaterEqual:wgpu::CompareFunction::LessEqual, + .stencilFront=mark,.stencilBack=mark,.stencilReadMask=1,.stencilWriteMask=stencil?1u:0u}; + wgpu::RenderPipelineDescriptor desc{};desc.label="Cockpit item";desc.layout=pipelineLayout; + desc.vertex={.module=shader,.entryPoint="vs",.bufferCount=1,.buffers=&vertices}; + desc.fragment=&fragment;desc.depthStencil=&depth;desc.multisample.count=target.msaaSamples; + desc.primitive.topology=wgpu::PrimitiveTopology::TriangleList; + // Same winding as Aurora's GX pipelines: GX front faces are clockwise. + desc.primitive.frontFace=wgpu::FrontFace::CW; + desc.primitive.cullMode=key.cull==1?wgpu::CullMode::Front:key.cull==2?wgpu::CullMode::Back:wgpu::CullMode::None; + pipelines.emplace_back(key,g_device.CreateRenderPipeline(&desc)); + return pipelines.back().second; +} + +inline bool finite_matrix(const float* m) { + for(int i=0;i<12;++i) {uint32_t bits;std::memcpy(&bits,m+i,4);if((bits&0x7f800000u)==0x7f800000u) return false;} + return true; +} +inline void compose(const float* a,const float* b,float* out) { + for(int r=0;r<3;++r) for(int c=0;c<4;++c) { + out[r*4+c]=c==3?a[r*4+3]:0; + for(int k=0;k<3;++k) out[r*4+c]+=a[r*4+k]*b[k*4+c]; + } +} + +// The held item's frame in the seated frame (+X right, +Y up, -Z forward). It +// stays upright whatever the hand's roll and pitch, sits just above the palm and +// turns only with the hand's heading, facing back along it: the item's front +// (+Z) faces the player while the fingers point ahead. The fingers run along +// grip -Y and along the palm joint's -Z. +inline bool seat_from_item(const AuroraCockpitHand& hand,std::array& out) { + const float* pose=hand.jointsValid?hand.seatFromJoint[0]:hand.seatFromGrip; + if(!finite_matrix(pose)) return false; + const int fingers=hand.jointsValid?2:1; + // The heading only. Within ~9 degrees of pointing straight up or down it + // fades to straight ahead instead of spinning the item. + const float x=-pose[fingers],z=-pose[8+fingers]; + const float length=std::sqrt(x*x+z*z),weight=std::clamp(length/0.15f,0.0f,1.0f); + float headingX=weight*x/std::max(length,1e-6f),headingZ=weight*z/std::max(length,1e-6f)-(1-weight); + float heading=std::sqrt(headingX*headingX+headingZ*headingZ); + if(!(heading>1e-4f)) { headingX=0;headingZ=-1;heading=1; } + const float frontX=-headingX/heading,frontZ=-headingZ/heading; + constexpr float lift=0.05f; + out={frontZ,0,frontX,pose[3], 0,1,0,pose[7]+lift, -frontX,0,frontZ,pose[11]}; + return true; +} +// Scales a model to 14 cm across its largest side and stands it on the item +// frame's origin, centred. +inline std::array item_from_model(const data::Model& model) { + const float span=std::max({model.maximum.x-model.minimum.x,model.maximum.y-model.minimum.y, + model.maximum.z-model.minimum.z}); + if(!(span>0.001f) || !std::isfinite(span)) return {}; + const float k=0.14f/span; + const float cx=(model.minimum.x+model.maximum.x)*0.5f,cz=(model.minimum.z+model.maximum.z)*0.5f; + return {k,0,0,-k*cx, 0,k,0,-k*model.minimum.y, 0,0,k,-k*cz}; +} + +inline void render(const wgpu::RenderPassEncoder& pass,const StereoReplayFrame& frame,uint32_t eye, + float sceneZ,float sceneConstant) { + if(!frame.cockpit.active || !(frame.cockpit.unitsPerMeter>0) || eye>1) return; + refresh_archive(); + if(!gpuArchive) return; + prepare_layout(); + // Upload on registration, before the first roulette settles, so receiving an + // item does not pause the frame on texture uploads. + for(size_t i=0;imodels.size();++i) + if(!gpuReady[i] && gpuArchive->models[i].valid()) prepare_model(i); + const auto& item=frame.cockpitItem; + if(!item.valid || item.hand>1 || item.count==0 || item.count>3) return; + const int index=data::model_index(item.id); + if(index<0 || !gpuArchive->models[index].valid()) return; + const auto& hand=frame.cockpit.hands[item.hand]; + std::array seatFromItem; + if(!hand.tracked || !seat_from_item(hand,seatFromItem)) return; + const auto& model=gpuArchive->models[index]; + const auto itemFromModel=item_from_model(model); + if(!(itemFromModel[0]>0)) return; + GpuFrame uniform{}; + compose(seatFromItem.data(),itemFromModel.data(),uniform.seatFromModel); + compose(frame.cockpit.eyeFromSeat[eye],uniform.seatFromModel,uniform.eyeFromModel); + const auto& projection=frame.eyes[eye].projection; + const float projectionRow[4]{projection.m0[0],projection.m0[2],projection.m1[1],projection.m1[2]}; + const float depth[4]{sceneZ,sceneConstant/std::max(frame.cockpit.unitsPerMeter,0.001f),itemFromModel[0],0}; + std::memcpy(uniform.projection,projectionRow,sizeof(projectionRow)); + std::memcpy(uniform.depth,depth,sizeof(depth)); + webgpu::g_queue.WriteBuffer(frameBuffers[eye],0,&uniform,sizeof(uniform)); + const auto& gpu=gpuModels[index]; + pass.SetVertexBuffer(0,gpu.vertices); + pass.SetBindGroup(1,frameGroups[eye],0,nullptr); + const bool reversed=sceneConstant>0; + uint32_t start=0; + for(const auto& part:model.parts) { + const auto count=static_cast(part.vertices.size()); + const auto& material=model.materials[part.material]; + if(material.cull!=3) { + const PipelineKey key{material.cull,material.blendSrc,material.blendDst,material.blend,material.subtract, + material.depthWrite}; + pass.SetPipeline(pipeline(key,frame,eye,reversed)); + pass.SetBindGroup(0,gpu.materials[part.material],0,nullptr); + pass.Draw(count,1,start,0); + } + start+=count; + } +} + +} // namespace aurora::gfx::cockpit_item diff --git a/aurora-main/lib/gfx/cockpit_item_data.hpp b/aurora-main/lib/gfx/cockpit_item_data.hpp new file mode 100644 index 0000000..3058521 --- /dev/null +++ b/aurora-main/lib/gfx/cockpit_item_data.hpp @@ -0,0 +1,577 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +// Small, bounded reader for MKW's item BRRES models: bind-pose geometry plus +// each material's texture layers, texgens and TEV stages. All input is copied +// from the user's mapped Common.szs. No game data is shipped. +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace aurora::gfx::cockpit_item::data { + +struct Reader { + const uint8_t* bytes = nullptr; + size_t size = 0; + bool has(size_t at, size_t count) const { return at <= size && count <= size - at; } + uint8_t u8(size_t at) const { return has(at,1) ? bytes[at] : 0; } + uint16_t u16(size_t at) const { return has(at,2) ? (uint16_t(bytes[at])<<8)|bytes[at+1] : 0; } + uint32_t u32(size_t at) const { + return has(at,4) ? (uint32_t(bytes[at])<<24)|(uint32_t(bytes[at+1])<<16)| + (uint32_t(bytes[at+2])<<8)|bytes[at+3] : 0; + } + float f32(size_t at) const { uint32_t bits=u32(at); float out; std::memcpy(&out,&bits,4); return out; } + // A section-relative offset: returns 0 (never valid here) when it leaves the file. + size_t rel(size_t base,size_t at) const { + const int64_t target=int64_t(base)+int32_t(u32(at)); + return target>0 && size_t(target)=size) return {}; + size_t end=at; + while(end(bytes+at),end-at) : std::string{}; + } +}; + +struct Entry { std::string name; size_t at; }; +inline std::vector dict(Reader r,size_t at) { + if(!r.has(at,8)) return {}; + const uint32_t count=r.u32(at+4); + if(count>4096 || !r.has(at+8,size_t(count+1)*16)) return {}; + std::vector out; + out.reserve(count); + for(uint32_t i=1;i<=count;++i) { + const size_t e=at+8+size_t(i)*16; + const size_t target=at+r.u32(e+12); + const auto name=r.str(at+r.u32(e+8)); + if(name.empty() || target>=r.size) return {}; + out.push_back({name,target}); + } + return out; +} +inline size_t find(const std::vector& entries,const std::string& name) { + for(const auto& e:entries) if(e.name==name) return e.at; + return 0; +} + +inline std::vector yaz0(Reader input) { + if(input.size>32u*1024u*1024u || !input.has(0,16)) return {}; + if(input.u32(0)!=0x59617a30u) return std::vector(input.bytes,input.bytes+input.size); + const size_t length=input.u32(4); + if(length==0 || length>32u*1024u*1024u) return {}; + std::vector out; + out.reserve(length); + size_t at=16; + while(out.size()=0 && out.size()>4; + if(count) count+=2; + else { if(!input.has(at,1)) return {}; count=size_t(input.u8(at++))+18; } + const size_t distance=((size_t(a&15)<<8)|b)+1; + if(distance>out.size() || count>length-out.size()) return {}; + for(size_t j=0;j; +struct Matrix { + std::array v{1,0,0,0,0,1,0,0,0,0,1,0}; +}; +inline V3 point(const Matrix& m,V3 p) { + const auto& a=m.v; + return {a[0]*p.x+a[1]*p.y+a[2]*p.z+a[3],a[4]*p.x+a[5]*p.y+a[6]*p.z+a[7], + a[8]*p.x+a[9]*p.y+a[10]*p.z+a[11]}; +} +inline V3 direction(const Matrix& m,V3 p) { + const auto& a=m.v; + return {a[0]*p.x+a[1]*p.y+a[2]*p.z,a[4]*p.x+a[5]*p.y+a[6]*p.z, + a[8]*p.x+a[9]*p.y+a[10]*p.z}; +} + +inline float component(Reader r,size_t at,uint32_t type,uint8_t shift) { + if(type==4) return r.f32(at); + const float scale=std::ldexp(1.0f,-int(shift)); + if(type==0) return r.u8(at)*scale; + if(type==1) return int8_t(r.u8(at))*scale; + if(type==2) return r.u16(at)*scale; + if(type==3) return int16_t(r.u16(at))*scale; + return 0; +} +struct Array { + uint32_t id=0; + std::vector values; +}; +enum class ArrayKind { Position, Normal, UV }; +inline std::vector arrays(Reader r,size_t model,uint32_t dictionary_offset,ArrayKind kind) { + std::vector result; + if(!dictionary_offset) return result; + for(const auto& e:dict(r,model+dictionary_offset)) { + const size_t h=e.at; + if(!r.has(h,0x20)) return {}; + const uint32_t type=r.u32(h+0x18), comps=r.u32(h+0x14); + const uint8_t shift=r.u8(h+0x1c), stride=r.u8(h+0x1d); + const uint16_t count=r.u16(h+0x1e); + const size_t data=h+r.u32(h+8); + const size_t elem=type==4?4:(type==2||type==3?2:1); + const size_t n=kind==ArrayKind::UV?(comps?2:1): + kind==ArrayKind::Normal?(comps?9:3):(comps?3:2); + if(type>4 || stride1?component(r,p+elem,type,shift):0, + n>2?component(r,p+2*elem,type,shift):0}); + } + result.push_back(std::move(a)); + } + return result; +} +template inline const T* array_id(const std::vector& entries,uint16_t id) { + for(const auto& a:entries) if(a.id==id) return &a; + return nullptr; +} +struct ColorArray { + uint32_t id=0; + std::vector values; +}; +// GX colour array formats: RGB565, RGB8, RGBX8, RGBA4, RGBA6, RGBA8. +inline std::vector color_arrays(Reader r,size_t model,uint32_t dictionary_offset) { + std::vector result; + if(!dictionary_offset) return result; + constexpr uint8_t sizes[6]{2,3,4,2,3,4}; + for(const auto& e:dict(r,model+dictionary_offset)) { + const size_t h=e.at; + if(!r.has(h,0x20)) return {}; + const uint32_t format=r.u32(h+0x18); + const uint8_t stride=r.u8(h+0x1c); + const uint16_t count=r.u16(h+0x1e); + const size_t data=h+r.u32(h+8); + if(format>5 || stride>11)&31)/31.f,((v16>>5)&63)/63.f,(v16&31)/31.f,1}; break; + case 1: case 2: c={r.u8(p)/255.f,r.u8(p+1)/255.f,r.u8(p+2)/255.f,1}; break; + case 3: c={(v16>>12)/15.f,((v16>>8)&15)/15.f,((v16>>4)&15)/15.f,(v16&15)/15.f}; break; + case 4: c={(v24>>18)/63.f,((v24>>12)&63)/63.f,((v24>>6)&63)/63.f,(v24&63)/63.f}; break; + default: c={r.u8(p)/255.f,r.u8(p+1)/255.f,r.u8(p+2)/255.f,r.u8(p+3)/255.f}; break; + } + a.values.push_back(c); + } + result.push_back(std::move(a)); + } + return result; +} + +// G3D texture SRT in Maya mode (every item material uses it), as a 2x3 matrix +// applied to (s,t,1). Other modes fall back to a plain scale-rotate-translate. +inline std::array texture_srt(float sx,float sy,float degrees,float tx,float ty,uint32_t mode) { + const float r=degrees*0.017453292519943295f,c=std::cos(r),s=std::sin(r); + if(mode!=0) return {sx*c,-sy*s,tx,sx*s,sy*c,ty}; + return {sx*c,sy*-s,sx*(-0.5f*c-(0.5f*s-0.5f)-tx), + sx*s,sy*c,sy*(-0.5f*c+(0.5f*s-0.5f)+ty)+1.0f}; +} + +struct Vertex { V3 position;V3 normal;Color color{1,1,1,1};std::array uv{}; }; +struct Texture { + std::string name; + uint16_t width=0,height=0; + uint32_t format=0,mips=1; + std::vector bytes; + std::vector rgba; // every mip level, level 0 first +}; +struct TexGen { + bool normal=false; // env map from the view-space normal; else a UV set + uint8_t uvSet=0; + std::array matrix{1,0,0,0,1,0}; +}; +struct Stage { + uint8_t texMap=0,texCoord=0; + bool textured=false,rasterized=true; + uint32_t color=0x8fff0,alpha=0; // BP 0xC0/0xC1 combiner words + Color konst{1,1,1,1}; // the stage's resolved KSEL constant +}; +struct Map { int texture=-1;uint8_t wrapS=1,wrapT=1; }; +struct Material { + uint8_t cull=2; // GX: 0 none, 1 front, 2 back, 3 all + bool blend=false,subtract=false,depthWrite=true; + uint8_t blendSrc=4,blendDst=5; + uint32_t alphaCompare=0x3f0000; // BP 0xF3 + uint32_t colorControl=0x700,alphaControl=0x700; + Color materialColor{1,1,1,1}; + uint8_t stageCount=0; + std::array stages{}; + std::array registers{}; // PREV, C0, C1, C2 + std::array texGens{}; + std::array maps{}; +}; +struct Part { + std::vector vertices; + uint16_t material=0; + bool translucent=false; + // Billboards keep bone-local positions around origin and face the eye. + bool billboard=false; + V3 origin; +}; +struct Model { + std::vector parts; + std::vector materials; + std::vector textures; + V3 minimum{std::numeric_limits::max(),std::numeric_limits::max(),std::numeric_limits::max()}; + V3 maximum{-std::numeric_limits::max(),-std::numeric_limits::max(),-std::numeric_limits::max()}; + bool valid() const { return !parts.empty() && maximum.x>=minimum.x; } +}; +inline void bounds(Model& model,V3 p) { + model.minimum={std::min(model.minimum.x,p.x),std::min(model.minimum.y,p.y),std::min(model.minimum.z,p.z)}; + model.maximum={std::max(model.maximum.x,p.x),std::max(model.maximum.y,p.y),std::max(model.maximum.z,p.z)}; +} + +inline float signed11(uint32_t v) { int32_t x=int32_t(v&0x7ffu);if(x&0x400) x-=0x800;return float(x)/255.f; } +inline Color konst_value(const std::array& konst,uint32_t sel,bool alpha) { + if(sel<8) { const float v=float(8-sel)/8.f;return {v,v,v,v}; } + if(!alpha && sel>=0x0c && sel<=0x0f) { const auto& k=konst[sel-0x0c];return {k[0],k[1],k[2],k[3]}; } + if(sel>=0x10 && sel<=0x1f) { const float v=konst[sel&3][(sel-0x10)>>2];return {v,v,v,v}; } + return {0,0,0,0}; +} +// Walks a G3D display list of BP (0x61), XF (0x10) and CP (0x08) loads. +template +inline void walk_dl(Reader r,size_t at,size_t end,Bp&& bp,Xf&& xf) { + end=std::min(end,r.size); + while(at& fileTextures,const std::string& name,Model& model) { + for(size_t i=0;ir.size || start>=end || !width || !height || width>1024 || height>1024 || + format>14 || format==7 || (format>=8 && format<=13)) return -1; + model.textures.push_back({name,width,height,format,mips,std::vector(r.bytes+start,r.bytes+end),{}}); + return int(model.textures.size()-1); +} +inline bool parse_material(Reader r,size_t mat,const std::vector& fileTextures,Model& model,Material& out) { + if(!r.has(mat,0x418)) return false; + const uint8_t genCount=std::min(r.u8(mat+0x14),8); + out.cull=uint8_t(r.u32(mat+0x18)&3u); + const uint32_t layers=r.u32(mat+0x2c); + const size_t layerAt=r.rel(mat,mat+0x30); + if(layers>8 || (layers && (!layerAt || !r.has(layerAt,size_t(layers)*0x34)))) return false; + for(uint32_t i=0;i=8) return false; + out.maps[map]={texture_index(r,fileTextures,r.str(r.rel(layer,layer)),model), + uint8_t(std::min(r.u32(layer+0x18),2u)),uint8_t(std::min(r.u32(layer+0x1c),2u))}; + } + const uint32_t srtMode=r.u32(mat+0x1ac); + for(uint8_t i=0;i konst{}; + std::array kc{},ka{}; + kc.fill(0x0c);ka.fill(0x1c); + const auto bp=[&](uint8_t reg,uint32_t v) { + if(reg==0xf3) out.alphaCompare=v; + else if(reg==0x40) out.depthWrite=(v>>4)&1u; + else if(reg==0x41) { + out.blend=v&1u;out.blendDst=(v>>5)&7u;out.blendSrc=(v>>8)&7u;out.subtract=(v>>11)&1u; + } else if(reg>=0xe0 && reg<=0xe7) { + const bool hi=reg&1u; + auto& c=(v>>23)?konst[(reg-0xe0)>>1]:out.registers[(reg-0xe0)>>1]; + const float low=(v>>23)?float(v&0xffu)/255.f:signed11(v),high=(v>>23)?float((v>>12)&0xffu)/255.f:signed11(v>>12); + if(hi) { c[2]=low;c[1]=high; } else { c[0]=low;c[3]=high; } + } else if(reg>=0x28 && reg<=0x29) { + for(uint32_t half=0;half<2;++half) { + auto& s=out.stages[(reg-0x28)*2+half]; + const uint32_t x=v>>(12*half); + s.texMap=x&7u;s.texCoord=(x>>3)&7u;s.textured=(x>>6)&1u;s.rasterized=((x>>7)&7u)==0; + } + } else if(reg>=0xc0 && reg<=0xc7) { + auto& s=out.stages[(reg-0xc0)>>1]; + (reg&1u?s.alpha:s.color)=v; + } else if(reg>=0xf6 && reg<=0xf7) { + for(uint32_t half=0;half<2;++half) { + const size_t stage=(reg-0xf6)*2+half; + kc[stage]=(v>>(4+10*half))&31u;ka[stage]=(v>>(9+10*half))&31u; + } + } + }; + const auto xf=[&](uint32_t address,uint32_t v) { + if(address<0x1040 || address>=0x1040u+genCount) return; + // TEXMTXINFO source row: 1 is the normal, 5..12 are UV sets. + const uint32_t row=(v>>7)&31u; + auto& gen=out.texGens[address-0x1040]; + if(row>=5 && row<=12 && !gen.normal) gen.uvSet=uint8_t(std::min(row-5,1u)); + else gen.normal=true; + }; + const size_t tev=r.rel(mat,mat+0x28),dl=r.rel(mat,mat+0x3c); + if(!tev || !dl || !r.has(tev,0x20)) return false; + walk_dl(r,dl,dl+0x180,bp,xf); + walk_dl(r,tev+0x20,tev+std::min(r.u32(tev),0x400),bp,xf); + out.stageCount=std::min(r.u8(tev+0xc),4); + for(uint8_t i=0;i=0; + if(s.texCoord>=genCount) s.texCoord=0; + } + return out.stageCount>0; +} + +struct Bone { Matrix matrix;uint32_t billboard=0;bool valid=false; }; +inline bool decode_shape(Reader r,size_t shape,const std::vector& positions, + const std::vector& normals,const std::vector& colors, + const std::vector& uvs,const std::vector& bones, + Part& part,Model& model) { + if(!r.has(shape,0x68)) return false; + const uint32_t lo=r.u32(shape+0xc),hi=r.u32(shape+0x10); + const int desc[12]{int((lo>>9)&3),int((lo>>11)&3),int((lo>>13)&3),int((lo>>15)&3), + int(hi&3),int((hi>>2)&3),int((hi>>4)&3),int((hi>>6)&3),int((hi>>8)&3), + int((hi>>10)&3),int((hi>>12)&3),int((hi>>14)&3)}; + if(desc[0]<2) return false; + const auto* pos=array_id(positions,r.u16(shape+0x48)); + const auto* nrm=desc[1]?array_id(normals,r.u16(shape+0x4a)):nullptr; + const auto* clr=desc[2]?array_id(colors,r.u16(shape+0x4c)):nullptr; + const Array* uv[2]{desc[4]?array_id(uvs,r.u16(shape+0x50)):nullptr, + desc[5]?array_id(uvs,r.u16(shape+0x52)):nullptr}; + if(!pos || (desc[1] && !nrm) || (desc[2] && !clr) || (desc[4] && !uv[0]) || (desc[5] && !uv[1])) return false; + size_t matrix_bytes=0; + for(uint32_t mask=lo&511;mask;mask>>=1) matrix_bytes+=mask&1u; + size_t stride=matrix_bytes; + for(int d:desc) { + if(d==1) return false; + stride+=d==2?1:d==3?2:0; + } + const size_t begin=shape+0x24+r.u32(shape+0x2c),length=r.u32(shape+0x28); + if(stride<2 || length>65536 || !r.has(begin,length)) return false; + const size_t end=begin+length; + // Matrix IDs, not bone indices: a single-bound shape names its own matrix; + // one with PNMTXIDX loads a palette of them. Envelope IDs have no bone and are + // identity in the bind pose (their vertices are already in model space). + const int32_t single=int32_t(r.u32(shape+8)); + const auto resolve=[&](uint32_t id) -> const Bone* { return id=0?resolve(uint32_t(single)):nullptr; + if(!(lo&1u) && singleBone && singleBone->billboard) { + part.billboard=true; + const auto& m=singleBone->matrix.v; + part.origin={m[3],m[7],m[11]}; + } + std::array palette{}; + palette.fill(uint16_t(std::max(single,0))); + size_t at=begin; + while(atend) return false; + const uint32_t slot=(r.u16(at+2)&0xfffu)/12u; + if(op==0x20 && slotend) return false; + const uint16_t count=r.u16(at);at+=2; + if(count>8192 || size_t(count)*stride>end-at) return false; + std::vector source; + source.reserve(count); + for(uint16_t i=0;i=pos->values.size() || (nrm && indices[1]>=nrm->values.size()) || + (clr && indices[2]>=clr->values.size()) || (uv[0] && indices[4]>=uv[0]->values.size()) || + (uv[1] && indices[5]>=uv[1]->values.size())) return false; + const Matrix& transform=(bone?bone:&identity)->matrix; + Vertex v; + v.normal=nrm?nrm->values[indices[1]]:V3{0,1,0}; + if(part.billboard) { + // Keep the bone's scale; the renderer replaces its rotation. + const auto& m=transform.v; + const V3 p=pos->values[indices[0]]; + v.position={p.x*std::hypot(m[0],m[4],m[8]),p.y*std::hypot(m[1],m[5],m[9]),p.z*std::hypot(m[2],m[6],m[10])}; + } else { + v.position=point(transform,pos->values[indices[0]]); + v.normal=direction(transform,v.normal); + } + if(clr) v.color=clr->values[indices[2]]; + for(int set=0;set<2;++set) if(uv[set]) { + const V3 t=uv[set]->values[indices[4+set]]; + v.uv[set]={t.x,t.y}; + } + source.push_back(v); + } + const auto tri=[&](uint16_t a,uint16_t b,uint16_t c) { + if(a==b || b==c || a==c) return; + for(uint16_t i:{a,b,c}) part.vertices.push_back(source[i]); + }; + if(primitive==0x90) { for(uint16_t i=0;i+2r.size || model_end<=m) return result; + const auto joint_entries=dict(r,m+r.u32(m+0x14)); + const auto material_entries=dict(r,m+r.u32(m+0x30)); + const auto shape_entries=dict(r,m+r.u32(m+0x38)); + const auto file_textures=dict(r,texture_dict); + const auto draw_entries=dict(r,m+r.u32(m+0x10)); + if(joint_entries.empty() || material_entries.empty() || shape_entries.empty() || + joint_entries.size()>256 || shape_entries.size()>256 || material_entries.size()>64) return result; + // Each bone stores its bind-pose model matrix; index it by matrix ID, the + // number shapes and palettes use (NodeTree parents are matrix IDs too). + std::vector bones; + for(const auto& e:joint_entries) { + if(!r.has(e.at,0xa0)) return {}; + const uint32_t id=r.u32(e.at+0x10); + if(id>=1024) return {}; + if(id>=bones.size()) bones.resize(id+1); + auto& bone=bones[id]; + for(int i=0;i<12;++i) bone.matrix.v[i]=r.f32(e.at+0x70+size_t(i)*4); + for(float f:bone.matrix.v) if(!std::isfinite(f)) return {}; + bone.billboard=r.u32(e.at+0x18);bone.valid=true; + } + const auto positions=arrays(r,m,r.u32(m+0x18),ArrayKind::Position); + const auto normals=arrays(r,m,r.u32(m+0x1c),ArrayKind::Normal); + const auto colors=color_arrays(r,m,r.u32(m+0x20)); + const auto uvs=arrays(r,m,r.u32(m+0x24),ArrayKind::UV); + if(positions.empty()) return {}; + struct Draw { uint16_t mat,shape;bool xlu; }; + std::vector draws; + for(const char* list:{"DrawOpa","DrawXlu"}) { + const size_t start=find(draw_entries,list); + if(!start) continue; + size_t at=start; + for(size_t guard=0;guard<4096 && at512) return {}; + std::vector materialSlot(material_entries.size(),-1); + for(const auto& draw:draws) { + if(draw.mat>=material_entries.size() || draw.shape>=shape_entries.size()) return {}; + if(materialSlot[draw.mat]<0) { + Material material; + if(!parse_material(r,material_entries[draw.mat].at,file_textures,result,material)) return {}; + materialSlot[draw.mat]=int(result.materials.size()); + result.materials.push_back(material); + } + Part part;part.translucent=draw.xlu;part.material=uint16_t(materialSlot[draw.mat]); + if(!decode_shape(r,shape_entries[draw.shape].at,positions,normals,colors,uvs,bones,part,result)) return {}; + if(!part.vertices.empty()) result.parts.push_back(std::move(part)); + } + return result.valid()?result:Model{}; +} + +inline constexpr std::array names{"koura_green","koura_red","banana","itemBoxNiseRtpa", + "kinoko","bomb","togezo_koura","thunder","star","kinoko_p","big_kinoko","gesso", + "pow_bloc","kumo","item_killer"}; +inline int model_index(uint8_t id) { + constexpr int map[19]{0,1,2,3,4,4,5,6,7,8,9,10,11,12,13,14,0,1,2}; + return id<19?map[id]:-1; +} +struct Archive { std::array models;uint32_t loaded=0; }; +inline Archive parse_archive(const void* bytes,size_t size) { + Archive archive; + if(!bytes || !size || size>32u*1024u*1024u) return archive; + const auto unpacked=yaz0({static_cast(bytes),size}); + Reader r{unpacked.data(),unpacked.size()}; + if(!r.has(0,0x20) || r.u32(0)!=0x55aa382du) return archive; + const size_t root=r.u32(4); + if(!r.has(root,12)) return archive; + const uint32_t count=r.u32(root+8); + if(count>8192 || count<2 || !r.has(root,size_t(count)*12)) return archive; + const size_t names_base=root+size_t(count)*12; + for(uint32_t i=1;i>24) continue; + const std::string filename=r.str(names_base+(tag&0xffffff)); + for(size_t item=0;item4u*1024u*1024u) break; + const Reader file{r.bytes+at,length}; + const std::string model_name=item==3?"itemBoxNise":names[item]; + archive.models[item]=parse_model(file,model_name); + if(archive.models[item].valid()) ++archive.loaded; + break; + } + } + return archive; +} + +} // namespace aurora::gfx::cockpit_item::data diff --git a/aurora-main/lib/gfx/common.cpp b/aurora-main/lib/gfx/common.cpp index 78b7a9e..17da6a7 100644 --- a/aurora-main/lib/gfx/common.cpp +++ b/aurora-main/lib/gfx/common.cpp @@ -3122,3 +3122,7 @@ void aurora_set_vr_hand_mesh(uint32_t hand, const AuroraVRHandVertex* vertices, meshes[hand] = std::move(mesh); ++meshRevision; } + +void aurora_set_cockpit_item_archive(const void* bytes, uint32_t size) { + aurora::gfx::cockpit_item::set_archive(bytes, size); +} diff --git a/aurora-main/lib/gfx/common.hpp b/aurora-main/lib/gfx/common.hpp index 9e9d808..218f933 100644 --- a/aurora-main/lib/gfx/common.hpp +++ b/aurora-main/lib/gfx/common.hpp @@ -321,6 +321,7 @@ struct StereoReplayFrame { std::array eyes; // VR hands and synthetic wheel, drawn per eye after the world (gfx/cockpit.hpp). AuroraCockpit cockpit{}; + AuroraCockpitItem cockpitItem{}; // The immersive window (AuroraStereoFrame::window): each eye is masked to the // 2D layer's screen after its last draw (gfx/window_mask.hpp). bool window = false; diff --git a/aurora-main/tests/CMakeLists.txt b/aurora-main/tests/CMakeLists.txt index e2958e7..ab34673 100644 --- a/aurora-main/tests/CMakeLists.txt +++ b/aurora-main/tests/CMakeLists.txt @@ -1,6 +1,12 @@ include(FetchContent) include(GoogleTest) +# The parser test uses synthetic malformed inputs by default. Passing the +# user's Race/Common.szs additionally audits every required model and texture. +add_executable(cockpit_item_data_test cockpit_item_data_test.cpp) +target_compile_features(cockpit_item_data_test PRIVATE cxx_std_20) +add_test(NAME cockpit_item_data_test COMMAND cockpit_item_data_test) + option(AURORA_GPU_SMOKE_TESTS "Build opt-in tests requiring a desktop GPU" OFF) if (AURORA_GPU_SMOKE_TESTS AND AURORA_ENABLE_GX AND WIN32) # Exercises the custom Dawn DLL's Aurora Vulkan ABI (patches/dawn). Run it with that @@ -29,7 +35,7 @@ if (AURORA_GPU_SMOKE_TESTS AND AURORA_ENABLE_GX AND WIN32) dawn::dawncpp_headers) # VR cockpit overlay (hands, synthetic wheel) against real scene depth. Standalone: it # defines the GPU globals itself and needs only the header. - add_executable(cockpit_gpu_smoke cockpit_gpu_smoke.cpp) + add_executable(cockpit_gpu_smoke cockpit_gpu_smoke.cpp ../lib/gfx/texture_convert.cpp) target_include_directories(cockpit_gpu_smoke PRIVATE ../include ../lib) target_compile_definitions(cockpit_gpu_smoke PRIVATE AURORA TARGET_PC WEBGPU_DAWN) target_link_libraries(cockpit_gpu_smoke PRIVATE fmt::fmt xxhash absl::flat_hash_map absl::btree diff --git a/aurora-main/tests/cockpit_gpu_smoke.cpp b/aurora-main/tests/cockpit_gpu_smoke.cpp index 00fc1c1..84bfb4c 100644 --- a/aurora-main/tests/cockpit_gpu_smoke.cpp +++ b/aurora-main/tests/cockpit_gpu_smoke.cpp @@ -5,10 +5,16 @@ #include "../lib/gfx/cockpit.hpp" #include #include +#include #include namespace aurora::webgpu { wgpu::Device g_device; wgpu::Queue g_queue; GraphicsConfig g_graphicsConfig{}; } +namespace aurora { +AuroraConfig g_config{}; +void log_internal(AuroraLogLevel,const char*,const char*,unsigned int) noexcept {} +void Module::show_fatal_dialog(const char*,std::string_view) noexcept {} +} std::atomic errors=0; -int main() { +int main(int argc,char** argv) { using namespace aurora; using namespace webgpu; wgpu::InstanceDescriptor id{}; @@ -40,6 +46,50 @@ int main() { if(!gfx::cockpit::geometry(native).empty()) return 1; native.nativeWheel=false; if(gfx::cockpit::geometry(native).empty()) return 1; + // The held item stays upright and faces the player whatever the hand's roll + // and pitch; only the hand's heading turns it. + const auto itemFrame=[](const AuroraCockpitHand& hand,float frontX,float frontZ) { + std::array item{}; + if(!gfx::cockpit_item::seat_from_item(hand,item)) return false; + const bool upright=std::abs(item[1])<1e-5f && std::abs(item[5]-1)<1e-5f && std::abs(item[9])<1e-5f; + return upright && std::abs(item[2]-frontX)<1e-4f && std::abs(item[10]-frontZ)<1e-4f; + }; + for(float roll : {0.0f,1.0f,-2.0f}) for(float pitch : {0.0f,0.5f,-0.7f}) { + // Fingers ahead (grip -Y is seat -Z), little finger to thumb up (grip -Z is + // seat +Y), then rolled about the fingers and pitched about seat +X. + const float cr=std::cos(roll),sr=std::sin(roll),cp=std::cos(pitch),sp=std::sin(pitch); + const gfx::cockpit::M neutral{1,0,0,-0.18f, 0,0,-1,-0.30f, 0,1,0,-0.42f}; + const gfx::cockpit::M rollZ{cr,-sr,0,0, sr,cr,0,0, 0,0,1,0}, pitchX{1,0,0,0, 0,cp,-sp,0, 0,sp,cp,0}; + AuroraCockpitHand hand{}; + const auto pose=gfx::cockpit::compose(pitchX,gfx::cockpit::compose(rollZ,neutral)); + std::memcpy(hand.seatFromGrip,pose.data(),sizeof(hand.seatFromGrip)); + if(!itemFrame(hand,0,1)) { std::cerr<<"Held item not upright or not facing the player\n"; return 1; } + } + { + AuroraCockpitHand hand{}; + // A tracked palm joint with the fingers (-Z) ahead, then a grip turned to the right. + const auto palm=gfx::cockpit::identity(); + std::memcpy(hand.seatFromJoint[0],palm.data(),sizeof(hand.seatFromJoint[0])); + hand.jointsValid=true; + if(!itemFrame(hand,0,1)) { std::cerr<<"Held item ignores the palm joint\n"; return 1; } + hand.jointsValid=false; + const gfx::cockpit::M right{0,-1,0,0, 0,0,-1,0, 1,0,0,0}; + std::memcpy(hand.seatFromGrip,right.data(),sizeof(hand.seatFromGrip)); + if(!itemFrame(hand,-1,0)) { std::cerr<<"Held item does not turn with the hand\n"; return 1; } + } + const bool itemEnabled=argc>1; + if(itemEnabled) { + std::ifstream file(argv[1],std::ios::binary); + if(!file) return 1; + const std::vector bytes{std::istreambuf_iterator(file),std::istreambuf_iterator()}; + gfx::cockpit_item::set_archive(bytes.data(),static_cast(bytes.size())); + for(uint8_t id=0;id<19;++id) if(!gfx::cockpit_item::has_model(id)) return 1; + for(const auto& model:gfx::cockpit_item::archive->models) + for(const auto& texture:model.textures) + if(texture.rgba.size()!=gfx::cockpit_item::mip_bytes(texture,texture.mips)) { + std::cerr << "Cockpit item texture conversion failed: " << texture.name << '\n';return 1; + } + } for(bool bike : {false,true}) for(bool original : {false,true}) for(uint32_t samples : {1u,4u}) for(bool hud : {false,true}) for(int coverage : {0,1,2}) for(bool reversed : {false,true}) for(uint32_t eyeIndex : {0u,1u}) { const bool occluded=coverage==1; @@ -48,6 +98,16 @@ int main() { frame.cockpit.active=true;frame.cockpit.wheelAngle=0.35f; frame.cockpit.nativeWheel=original; frame.cockpit.bike=bike;frame.cockpit.handlebarRadius=0.25f; + if(itemEnabled) { + const uint32_t itemCase=((((uint32_t(bike)*2+uint32_t(original))*2+ + uint32_t(samples==4))*3+uint32_t(coverage))*2+ + uint32_t(reversed))*2+eyeIndex; + const bool preview=samples==4 && !bike && !original && coverage==0 && + !reversed && eyeIndex==0; + const uint8_t id=preview?0:static_cast(itemCase%19); + frame.cockpitItem={1,id,static_cast(itemCase%3+1), + static_cast(itemCase%2),true}; + } const float handlePose[12]{1,0,0,0, 0,0,1,-0.3f, 0,-1,0,-0.42f}; std::memcpy(frame.cockpit.seatFromHandlebar,handlePose,sizeof(handlePose)); for(int hand=0;hand<2;++hand) { @@ -156,7 +216,7 @@ int main() { } if(left<500||right>8) { std::cerr<<"Partial wall occlusion failed for eye "<(bytes+i*4),3); } diff --git a/aurora-main/tests/cockpit_item_data_test.cpp b/aurora-main/tests/cockpit_item_data_test.cpp new file mode 100644 index 0000000..9c1387e --- /dev/null +++ b/aurora-main/tests/cockpit_item_data_test.cpp @@ -0,0 +1,75 @@ +#include "../lib/gfx/cockpit_item_data.hpp" + +#include +#include +#include +#include +#include + +static void Check(bool value,const char* what) { + if (!value) { std::cerr << "FAILED: " << what << '\n'; std::abort(); } +} +static bool Near(float a,float b) { return std::fabs(a-b)<1e-4f; } + +int main(int argc, char** argv) { + using namespace aurora::gfx::cockpit_item::data; + for (uint8_t id=0; id<19; ++id) Check(model_index(id)>=0,"every inventory ID has a model"); + Check(model_index(19)==-1 && model_index(20)==-1,"no model past the triple banana"); + const uint8_t brokenYaz[]{'Y','a','z','0',0,0,0,4,0,0,0,0,0,0,0,0,0}; + const uint8_t brokenU8[]{0x55,0xaa,0x38,0x2d,0,0,0,0}; + Check(parse_archive(nullptr,0).loaded==0,"null archive"); + Check(parse_archive(brokenYaz,sizeof(brokenYaz)).loaded==0,"truncated Yaz0"); + Check(parse_archive(brokenU8,sizeof(brokenU8)).loaded==0,"truncated U8"); + // Maya texture SRT: a 2x scale pivots t around 1, as G3D does. + const auto srt=texture_srt(2,2,0,0,0,0); + Check(Near(srt[0],2) && Near(srt[1],0) && Near(srt[2],0) && + Near(srt[3],0) && Near(srt[4],2) && Near(srt[5],-1),"Maya scale"); + const auto identity=texture_srt(1,1,0,0,0,0); + Check(Near(identity[0],1) && Near(identity[2],0) && Near(identity[4],1) && Near(identity[5],0),"identity SRT"); + // KSEL: fixed fractions, whole konst colours, and single konst components. + const std::array konst{{{0.1f,0.2f,0.3f,0.4f},{0.5f,0.6f,0.7f,0.8f},{},{}}}; + Check(Near(konst_value(konst,0,false)[0],1) && Near(konst_value(konst,4,true)[3],0.5f),"KSEL fractions"); + Check(Near(konst_value(konst,0x0d,false)[1],0.6f),"KSEL konst colour"); + Check(Near(konst_value(konst,0x1c,true)[3],0.4f) && Near(konst_value(konst,0x15,true)[3],0.6f),"KSEL components"); + Check(Near(signed11(0x7ff),-1.f/255.f) && Near(signed11(0xff),1),"TEV register sign"); + if (argc>1) { + std::ifstream file(argv[1],std::ios::binary); + Check(bool(file),"archive readable"); + const std::vector bytes{std::istreambuf_iterator(file),std::istreambuf_iterator()}; + const Archive archive=parse_archive(bytes.data(),bytes.size()); + Check(archive.loaded==15,"all 15 item models"); + for (size_t i=0;i=1 && material.stageCount<=4,"TEV stage count"); + for (uint32_t s=0;s=0,"stage texture resolves"); + } + } + Check(vertices>0,"geometry"); + std::cout << names[i] << ": " << vertices << " vertices, " << model.textures.size() << " textures, " + << model.materials.size() << " materials, " << billboards << " billboard parts\n"; + } + // The properties whose absence corrupted the held items. + const auto& shell=archive.models[0].materials[0]; + Check(shell.maps[0].wrapS==2 && Near(shell.texGens[0].matrix[0],2),"shell mirror wrap and 2x SRT"); + Check(shell.texGens[1].normal,"shell specular is an env map"); + Check(archive.models[2].materials[0].cull==0,"banana is double-sided"); + Check(archive.models[3].parts.size()==3,"fake item box parts"); + size_t thunderBillboards=0; + for (const auto& part:archive.models[7].parts) thunderBillboards+=part.billboard; + Check(thunderBillboards==1,"lightning glow is a billboard"); + const auto& bolt=archive.models[7].materials[archive.models[7].parts[0].material]; + Check(Near(bolt.registers[1][0],1) && Near(bolt.registers[1][1],1) && Near(bolt.registers[1][2],0), + "lightning C0 is yellow"); + } +} diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index dc1ac89..97e6941 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -456,6 +456,11 @@ 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_item_tests "${CMAKE_CURRENT_LIST_DIR}/tests/vr_item_tests.cpp") +target_include_directories(mkw_vr_item_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") +target_compile_features(mkw_vr_item_tests PRIVATE cxx_std_17) +add_test(NAME mkw_vr_item_tests COMMAND mkw_vr_item_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) diff --git a/runtime/include/hle/dvd_vr_asset.h b/runtime/include/hle/dvd_vr_asset.h new file mode 100644 index 0000000..b449968 --- /dev/null +++ b/runtime/include/hle/dvd_vr_asset.h @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +// Reads the same mapped disc path that the guest sees, including active file +// replacements. Called on the guest thread after DVD initialization. +std::vector DVDReadVrAsset(const char* dvdPath); diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index 6d25b94..ab17869 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -78,6 +78,7 @@ struct RuntimeUserConfig { std::optional vrNativeSteeringWheel; std::optional vrObjectCulling; std::optional vrHandSteering; + std::optional vrCockpitItemHand; std::optional vrHandTracking; std::optional vrWheelKartDegrees; std::optional vrWheelBikeDegrees; @@ -237,6 +238,7 @@ inline constexpr float kVrCockpitUnitsPerMeterMax = 400.0f; inline constexpr bool kVrSteeringWheelDefault = true; inline constexpr bool kVrNativeSteeringWheelDefault = true; inline constexpr bool kVrHandSteeringDefault = true; +inline constexpr const char* kVrCockpitItemHandDefault = "left"; // The game hides karts and objects its own camera cannot see, which a head // turn in VR reveals. object_culling false draws them anyway (see // vr/mkw_vr_culling.h); it only takes effect while VR is enabled. The PC @@ -605,6 +607,8 @@ inline void EnsureConfigFile() { "# (seconds) a hand that loses tracking keeps hold, and a short\n" "# pulse on grab and release. All changeable live from the F10 menu.\n" "hand_steering = true\n" + "# Show the settled inventory item in one cockpit hand: left, right, or off.\n" + "cockpit_item_hand = \"left\"\n" "wheel_kart_degrees = 90.0\n" "wheel_bike_degrees = 45.0\n" "wheel_grab_distance = 0.35\n" @@ -880,6 +884,7 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { config.vrNativeSteeringWheel = FindConfigValue(document, "vr", "native_steering_wheel"); config.vrObjectCulling = FindConfigValue(document, "vr", "object_culling"); config.vrHandSteering = FindConfigValue(document, "vr", "hand_steering"); + config.vrCockpitItemHand = FindConfigValue(document, "vr", "cockpit_item_hand"); config.vrHandTracking = FindConfigValue(document, "vr", "hand_tracking"); config.vrWheelKartDegrees = readRangedFloat("wheel_kart_degrees", kVrWheelDegreesMin, kVrWheelDegreesMax); config.vrWheelBikeDegrees = readRangedFloat("wheel_bike_degrees", kVrWheelDegreesMin, kVrWheelDegreesMax); @@ -1282,6 +1287,12 @@ inline bool SetVrHandSteering(bool value) { return WriteSetting("vr", "hand_steering", value ? "true" : "false"); } +inline bool SetVrCockpitItemHand(const std::string& value) { + if (value != "left" && value != "right" && value != "off") return false; + Mutable().vrCockpitItemHand = value; + return WriteSetting("vr", "cockpit_item_hand", "\"" + value + "\""); +} + inline bool SetVrHandTracking(bool value) { Mutable().vrHandTracking = value; return WriteSetting("vr", "hand_tracking", value ? "true" : "false"); @@ -1773,6 +1784,11 @@ inline bool VrHandSteering(bool fallback = kVrHandSteeringDefault) { return Get().vrHandSteering.value_or(fallback); } +inline std::string VrCockpitItemHand() { + const std::string value = Get().vrCockpitItemHand.value_or(kVrCockpitItemHandDefault); + return value == "left" || value == "right" || value == "off" ? value : kVrCockpitItemHandDefault; +} + inline bool VrHandTracking(bool fallback = kVrHandTrackingDefault) { return Get().vrHandTracking.value_or(fallback); } diff --git a/runtime/include/vr/mkw_vr_first_person.h b/runtime/include/vr/mkw_vr_first_person.h index 1a4125b..a3976a2 100644 --- a/runtime/include/vr/mkw_vr_first_person.h +++ b/runtime/include/vr/mkw_vr_first_person.h @@ -3,6 +3,7 @@ #pragma once #include "vr/steering_wheel.h" +#include "vr/mkw_vr_item.h" #include #include @@ -572,4 +573,8 @@ void MkwVRFirstPersonRecenter() noexcept; // anchor has been missing long enough to give up holding the last one. FirstPersonAnchor MkwVRFirstPersonGetAnchor() noexcept; +// Guest-frame inventory snapshot, sampled at the race draw boundary. A +// generation change invalidates any item retained by a prior race. +HeldItem MkwVRFirstPersonGetHeldItem() noexcept; + } // namespace mkw::vr diff --git a/runtime/include/vr/mkw_vr_item.h b/runtime/include/vr/mkw_vr_item.h new file mode 100644 index 0000000..faab9bb --- /dev/null +++ b/runtime/include/vr/mkw_vr_item.h @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +namespace mkw::vr { + +struct HeldItem { + uint8_t id = 0x14; + uint8_t count = 0; + bool valid = false; + uint64_t race_generation = 0; +}; + +namespace detail { + +// PAL RMCP01 Item::Manager and Item::Player. Item::PlayerInventory is at +// Player+0x88. Read the inventory, never PlayerRoulette::nextItemId: roulette +// teardown clears the latter, and an interrupted roulette may predict an item +// the player never receives. +template +HeldItem ReadHeldItem(uint32_t local_racer, uint64_t race_generation) noexcept { + HeldItem result{}; + result.race_generation = race_generation; + if (local_racer >= 12) return result; + uint32_t manager = 0, players = 0; + if (!GuestMemory::TryRead32(0x809C3618u, manager) || !manager || + !GuestMemory::TryRead32(manager + 0x14u, players) || !players) return result; + const uint32_t player = players + local_racer * 0x248u; + if (player < players || !GuestMemory::Contains(player, 0x94u)) return result; + try { + if (GuestMemory::Read8(player + 0x18u) != local_racer || + GuestMemory::Read32(player + 0x58u) != 0) return result; + const uint32_t id = GuestMemory::Read32(player + 0x8Cu); + const uint32_t count = GuestMemory::Read32(player + 0x90u); + if (id > 0x12u || count == 0 || count > 3) return result; + result.id = static_cast(id); + result.count = static_cast(count); + result.valid = true; + } catch (const typename GuestMemory::AccessViolation&) { + return result; + } + return result; +} + +} // namespace detail +} // namespace mkw::vr diff --git a/runtime/src/hle/storage/dvd.cpp b/runtime/src/hle/storage/dvd.cpp index 1acf239..bb63144 100644 --- a/runtime/src/hle/storage/dvd.cpp +++ b/runtime/src/hle/storage/dvd.cpp @@ -1,6 +1,7 @@ #include "hle_stubs.h" #include "isa/big_endian.h" #include "hle/dvd_contract.h" +#include "hle/dvd_vr_asset.h" #include "hle/runtime_parse_helpers.h" #include "memory.h" @@ -743,6 +744,20 @@ extern "C" const char* DVDResolveHostPathForTest(const char* dvdPath) return resolved.c_str(); } +std::vector DVDReadVrAsset(const char* dvdPath) { + if (dvdPath == nullptr || dvdPath[0] == '\0') return {}; + DVDInit_8015EA1C(); + const auto it = g_pathToEntry.find(NormalizePath(dvdPath)); + if (it == g_pathToEntry.end() || it->second < 0 || + it->second >= static_cast(g_fileEntries.size())) return {}; + const DVDFileEntry& entry = g_fileEntries[it->second]; + if (entry.isDirectory || entry.size == 0 || entry.size > 32u * 1024u * 1024u) return {}; + std::vector bytes(entry.size); + std::ifstream file(entry.hostPath, std::ios::binary); + if (!file.read(reinterpret_cast(bytes.data()), bytes.size())) return {}; + return bytes; +} + // ============================================================================ // High-Level DVD API // ============================================================================ diff --git a/runtime/src/hle/vi.cpp b/runtime/src/hle/vi.cpp index b2916c0..38d975d 100644 --- a/runtime/src/hle/vi.cpp +++ b/runtime/src/hle/vi.cpp @@ -12,6 +12,7 @@ #include "fiber_manager.h" #include "platform/host_platform.h" #include "runtime_log.h" +#include "runtime_config.h" #include "vr/mkw_vr_first_person.h" #include "vr/mkw_vr_policy.h" #include "vr/openxr_integration.h" @@ -592,6 +593,7 @@ struct GxPresentRecord { std::array anchor{}; bool anchorValid = false; float anchorUnitsPerMeter = 0.0f; + AuroraCockpitItem cockpitItem{}; bool reportPaced = false; bool paced = false; uint32_t localPlayerCount = 1; @@ -609,6 +611,7 @@ void GxPresent_gx(GxPresentRecord record) { } else { aurora_set_stereo_scene_anchor(record.anchorValid ? record.anchor.data() : nullptr); } + aurora_set_stereo_cockpit_item(&record.cockpitItem); aurora_set_stereo_local_player_count(record.localPlayerCount); aurora_end_frame_ex(record.contentTag, record.imguiFrame); g_auroraFrameActive.store(false, std::memory_order_release); @@ -693,6 +696,13 @@ void VI_HLE_PresentFrame(bool presentedXfb, bool paceToRetrace) { record.anchor = anchor.anchor; record.anchorValid = anchor.valid; record.anchorUnitsPerMeter = anchor.unitsPerMeter; + if (anchor.valid) { + const auto item = mkw::vr::MkwVRFirstPersonGetHeldItem(); + const auto hand = RuntimeConfigFile::VrCockpitItemHand(); + record.cockpitItem = {item.race_generation, item.id, item.count, + static_cast(hand == "right" ? 1 : hand == "off" ? 2 : 0), + item.valid && hand != "off"}; + } // Latch the current policy safety state into this exact Aurora job. The // 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 diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 9dac3fc..1b37a63 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -170,6 +170,9 @@ bool g_vrSteeringWheel = RuntimeConfigFile::VrSteeringWheel(); bool g_vrNativeSteeringWheel = RuntimeConfigFile::VrNativeSteeringWheel(); bool g_vrObjectCulling = RuntimeConfigFile::VrObjectCulling(); bool g_vrHandSteering = RuntimeConfigFile::VrHandSteering(); +constexpr std::array kVrCockpitItemHands{"Left", "Right", "Off"}; +int g_vrCockpitItemHand = RuntimeConfigFile::VrCockpitItemHand() == "right" ? 1 : + RuntimeConfigFile::VrCockpitItemHand() == "off" ? 2 : 0; mkw::vr::WheelTuning g_vrWheelTuning = RuntimeConfigFile::VrWheelTuning(); float g_vrFirstPersonHeadUp = RuntimeConfigFile::VrFirstPersonHeadUpMeters(); float g_vrFirstPersonHeadForward = RuntimeConfigFile::VrFirstPersonHeadForwardMeters(); @@ -1221,11 +1224,19 @@ void DrawVrSteeringWheelSettings() { if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Squeeze a grip near the wheel or handlebar to take hold of it, and turn " "it to steer, with one hand or both. Releasing both grips gives steering " - "back to the stick, which still aims items. The runtime's hand mesh is " - "used when hand steering was on at launch."); + "back to the stick, which still aims items."); + } + if (ImGui::Combo("Item in cockpit hand", &g_vrCockpitItemHand, + kVrCockpitItemHands.data(), static_cast(kVrCockpitItemHands.size()))) { + RuntimeConfigFile::SetVrCockpitItemHand( + g_vrCockpitItemHand == 1 ? "right" : g_vrCockpitItemHand == 2 ? "off" : "left"); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Show Player 1's settled inventory item above this palm. " + "Triple items show their remaining count. Using or losing the item hides it."); } #if defined(__ANDROID__) - ImGui::BeginDisabled(!g_vrHandSteering); + ImGui::BeginDisabled(!g_vrHandSteering && g_vrCockpitItemHand == 2); if (ImGui::Checkbox("Tracked hands", &g_vrHandTracking)) { RuntimeConfigFile::SetVrHandTracking(g_vrHandTracking); } @@ -1747,11 +1758,13 @@ void DrawVrCameraSettings() { g_vrSteeringWheel = RuntimeConfigFile::kVrSteeringWheelDefault; g_vrNativeSteeringWheel = RuntimeConfigFile::kVrNativeSteeringWheelDefault; g_vrHandSteering = RuntimeConfigFile::kVrHandSteeringDefault; + g_vrCockpitItemHand = 0; RuntimeConfigFile::SetVrFirstPersonSeat(RuntimeConfigFile::kVrFirstPersonSeatDefault); RuntimeConfigFile::SetVrCockpitUnitsPerMeter(g_vrCockpitUnitsPerMeter); RuntimeConfigFile::SetVrSteeringWheel(g_vrSteeringWheel); RuntimeConfigFile::SetVrNativeSteeringWheel(g_vrNativeSteeringWheel); RuntimeConfigFile::SetVrHandSteering(g_vrHandSteering); + RuntimeConfigFile::SetVrCockpitItemHand(RuntimeConfigFile::kVrCockpitItemHandDefault); #if defined(__ANDROID__) g_vrHandTracking = RuntimeConfigFile::kVrHandTrackingDefault; RuntimeConfigFile::SetVrHandTracking(g_vrHandTracking); diff --git a/runtime/src/vr/mkw_vr_first_person.cpp b/runtime/src/vr/mkw_vr_first_person.cpp index 97d84a1..73d2f91 100644 --- a/runtime/src/vr/mkw_vr_first_person.cpp +++ b/runtime/src/vr/mkw_vr_first_person.cpp @@ -2,7 +2,10 @@ #include "vr/mkw_vr_first_person.h" +#include "aurora/aurora.h" + #include "gx_native_wheel.h" +#include "hle/dvd_vr_asset.h" #include "gx_model_visibility.h" #include "memory.h" #include "runtime_config.h" @@ -317,6 +320,8 @@ struct FirstPersonState { uint32_t camera_address = 0; detail::LocalPlayerKartRead player_kart{}; + HeldItem held_item{}; + uint64_t race_generation = 0; // Armed by the draw boundary, consumed by the frame seal. bool armed = false; uint64_t armed_frame = 0; @@ -1386,6 +1391,9 @@ void MkwVRFirstPersonReset() noexcept { g_state.armed_view_valid = false; g_state.camera_address = 0; g_state.player_kart = {}; + g_state.held_item = {}; + ++g_state.race_generation; + g_state.held_item.race_generation = g_state.race_generation; g_state.anchor = {}; g_state.hold_frames = 0; g_state.ever_valid_this_race = false; @@ -1412,6 +1420,8 @@ void MkwVRFirstPersonRecenter() noexcept { void MkwVRFirstPersonUpdate(uint64_t guest_frame_index, uint32_t race_camera_address) noexcept { std::lock_guard lock(g_mutex); + g_state.held_item = {}; + g_state.held_item.race_generation = g_state.race_generation; DropHiddenModelsLocked(); if (g_recenter_requested.exchange(false, std::memory_order_acq_rel)) { g_state.seated_eye.Recalibrate(); @@ -1435,6 +1445,15 @@ void MkwVRFirstPersonUpdate(uint64_t guest_frame_index, uint32_t race_camera_add g_state.hold_frames = 0; } g_state.player_kart = player; + if (player.failed_step == nullptr && g_state.seat == FirstPersonSeat::Cockpit) { + static bool item_archive_attempted = false; + if (!item_archive_attempted && RuntimeConfigFile::VrCockpitItemHand() != "off") { + item_archive_attempted = true; + const auto archive = DVDReadVrAsset("/Race/Common.szs"); + if (!archive.empty()) aurora_set_cockpit_item_archive(archive.data(), static_cast(archive.size())); + } + g_state.held_item = detail::ReadHeldItem(player.player_index, g_state.race_generation); + } g_state.armed = true; g_state.armed_frame = guest_frame_index; g_state.armed_view_valid = ReadSceneViewMatrix(g_state.armed_view); @@ -1538,4 +1557,9 @@ FirstPersonAnchor MkwVRFirstPersonGetAnchor() noexcept { return g_state.anchor; } +HeldItem MkwVRFirstPersonGetHeldItem() noexcept { + std::lock_guard lock(g_mutex); + return g_state.held_item; +} + } // namespace mkw::vr diff --git a/runtime/src/vr/openxr_input.cpp b/runtime/src/vr/openxr_input.cpp index 8dce000..dfe0464 100644 --- a/runtime/src/vr/openxr_input.cpp +++ b/runtime/src/vr/openxr_input.cpp @@ -656,12 +656,13 @@ void OpenXRInput::UpdateSimultaneousHandsAndControllers(bool wanted) { "side to the cameras"); } -// Trackers only exist while tracked hands and hand steering are both on: the -// hands are only drawn while they can steer. They live as long as the session +// Tracked hands can carry a cockpit item even while stick steering is used. +// They live as long as the session // otherwise (Idle and the cockpit's reset keep them); a runtime that refuses // them is not asked again until the option is turned off and on. void OpenXRInput::UpdateHandTrackers() { - const bool wanted = RuntimeConfigFile::VrHandTracking() && RuntimeConfigFile::VrHandSteering(); + const bool wanted = RuntimeConfigFile::VrHandTracking() && + (RuntimeConfigFile::VrHandSteering() || RuntimeConfigFile::VrCockpitItemHand() != "off"); UpdateSimultaneousHandsAndControllers(wanted); if (!wanted) { DestroyHandTrackers(); @@ -1417,8 +1418,10 @@ void OpenXRInput::UpdateDriving(XrTime display_time, const driving::SeatFrame& s tracked = false; } DrivingHand& out = snapshot.hands[hand]; - // Hands are shown only while they can steer. - out.tracked = hand_steering && (tracked || joints); + // The selected item hand is visible with stick steering too. + const auto item_hand = RuntimeConfigFile::VrCockpitItemHand(); + const bool displays_item = item_hand == (hand == 0 ? "left" : "right"); + out.tracked = (hand_steering || displays_item) && (tracked || joints); out.held = false; out.squeeze = squeeze; out.seat_from_grip = seat_from_grip; diff --git a/runtime/src/vr/openxr_integration.cpp b/runtime/src/vr/openxr_integration.cpp index cc39225..e4e5730 100644 --- a/runtime/src/vr/openxr_integration.cpp +++ b/runtime/src/vr/openxr_integration.cpp @@ -1386,7 +1386,8 @@ private: float lean_back_radians, AuroraCockpit& cockpit) noexcept { cockpit.unitsPerMeter = units_per_meter; const DrivingSnapshot driving = input_ != nullptr ? input_->Driving() : DrivingSnapshot{}; - if (driving.hand_steering && !hand_meshes_loaded_ && runtime_ != nullptr) { + if ((driving.hand_steering || RuntimeConfigFile::VrCockpitItemHand() != "off") && + !hand_meshes_loaded_ && runtime_ != nullptr) { hand_meshes_loaded_ = true; // Tracked hands' trackers, when they exist, serve the mesh too. const XrHandTrackerEXT trackers[2]{input_ != nullptr ? input_->HandTracker(0) : XR_NULL_HANDLE, @@ -1397,7 +1398,8 @@ private: << std::endl; } cockpit.active = driving.cockpit_active && position_valid && base_position_valid_ && - (driving.synthetic_control || driving.hand_steering); + (driving.synthetic_control || driving.hand_steering || + RuntimeConfigFile::VrCockpitItemHand() != "off"); if (!cockpit.active) { return; } diff --git a/runtime/tests/vr_config_tests.cpp b/runtime/tests/vr_config_tests.cpp index a017c92..3b7bbb6 100644 --- a/runtime/tests/vr_config_tests.cpp +++ b/runtime/tests/vr_config_tests.cpp @@ -15,6 +15,13 @@ static RuntimeUserConfig Parse(const std::string& text) { } int main() { + for (std::string_view hand : {"left", "right", "off"}) { + Require(Parse("[vr]\ncockpit_item_hand = \"" + std::string(hand) + "\"\n") + .vrCockpitItemHand == std::string(hand)); + } + Require(std::string_view(RuntimeConfigFile::kVrCockpitItemHandDefault) == "left"); + Require(!Parse("[vr]\n").vrCockpitItemHand.has_value()); + Require(!Parse("[vr]\ncockpit_item_hand = 1\n").vrCockpitItemHand.has_value()); // [vr] foveation: the Quest's foveated rendering level, index-matched to // aurora_set_stereo_foveation. for (std::string_view level : RuntimeConfigFile::kVrFoveationLevels) { diff --git a/runtime/tests/vr_item_tests.cpp b/runtime/tests/vr_item_tests.cpp new file mode 100644 index 0000000..364eac0 --- /dev/null +++ b/runtime/tests/vr_item_tests.cpp @@ -0,0 +1,84 @@ +#include "vr/mkw_vr_item.h" + +#include +#include +#include +#include + +namespace { +struct GuestMemory { + using AccessViolation = std::out_of_range; + inline static std::unordered_map bytes; + inline static uint32_t fault = 0; + static bool Contains(uint32_t address, uint32_t length) { + for (uint32_t i = 0; i < length; ++i) if (!bytes.count(address + i)) return false; + return true; + } + static uint8_t Read8(uint32_t address) { + if (address == fault) throw AccessViolation("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] = uint8_t(value >> (24 - i * 8)); + } +}; + +constexpr uint32_t manager = 0x81000000u, players = 0x82000000u; +constexpr uint32_t player = players + 7 * 0x248u; +int failures = 0; +void Check(bool value, const char* name) { + if (!value) { ++failures; std::cerr << "FAILED: " << name << '\n'; } +} +void Init() { + GuestMemory::bytes.clear(); GuestMemory::fault = 0; + GuestMemory::Write32(0x809C3618u, manager); + GuestMemory::Write32(manager + 0x14u, players); + for (uint32_t i = 0; i < 0x94u; ++i) GuestMemory::bytes[player + i] = 0; + GuestMemory::bytes[player + 0x18u] = 7; + GuestMemory::Write32(player + 0x90u, 1); +} +} + +int main() { + using mkw::vr::detail::ReadHeldItem; + for (uint32_t id = 0; id <= 0x12u; ++id) { + Init(); GuestMemory::Write32(player + 0x8cu, id); + const auto item = ReadHeldItem(7, 19); + Check(item.valid && item.id == id && item.count == 1 && item.race_generation == 19, + "all 19 IDs, nonzero racer, generation"); + } + Init(); GuestMemory::Write32(player + 0x8cu, 0x10); GuestMemory::Write32(player + 0x90u, 3); + Check(ReadHeldItem(7, 1).count == 3, "triple full"); + GuestMemory::Write32(player + 0x90u, 2); + Check(ReadHeldItem(7, 1).count == 2, "triple remaining"); + GuestMemory::Write32(player + 0x90u, 0); + Check(!ReadHeldItem(7, 1).valid, "inventory removed on use or damage"); + GuestMemory::Write32(player + 0x90u, 1); + GuestMemory::Write32(player + 0x58u, 1); + Check(!ReadHeldItem(7, 1).valid, "roulette spinning"); + GuestMemory::Write32(player + 0x58u, 0); + for (uint32_t id : {0x13u, 0x14u, 0xffu}) { + GuestMemory::Write32(player + 0x8cu, id); + Check(!ReadHeldItem(7, 1).valid, "empty or unsupported"); + } + Init(); GuestMemory::Write32(player + 0x8cu, 0x0au); + Check(ReadHeldItem(7, 1).valid, "golden mushroom held"); + GuestMemory::Write32(player + 0x90u, 0); + Check(!ReadHeldItem(7, 1).valid, "golden mushroom expiry"); + Init(); GuestMemory::fault = player + 0x8cu; + const auto faulted = ReadHeldItem(7, 2); + Check(!faulted.valid && faulted.race_generation == 2, "read fault preserves generation"); + Init(); GuestMemory::Write32(0x809C3618u, 0); + Check(!ReadHeldItem(7, 1).valid, "invalid manager"); + Init(); GuestMemory::bytes[player + 0x18u] = 0; + Check(!ReadHeldItem(7, 1).valid, "racer identity mismatch"); + return failures ? 1 : 0; +}