From bbd9dc78ec6798e43d8c9ea8c138199599c6cb6b Mon Sep 17 00:00:00 2001 From: Elin <37795467+Ladypoly@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:14:56 +0100 Subject: [PATCH 1/3] Add Quest Home APK extraction and loading support Introduces a new QuestHome module for loading Quest Home environments from SideQuest APK files. Adds APKExtractor for extracting GLTF, textures, and audio from APKs, BanterQuestHome for orchestrating the loading pipeline, GLTFMaterialMapper for material and texture handling, and KTXParser for KTX texture parsing. Also adds visual scripting nodes for loading/unloading Quest Home environments and updates BanterComponentNames accordingly. --- Runtime/Scripts/QuestHome.meta | 8 + Runtime/Scripts/QuestHome/APKExtractor.cs | 232 +++++ .../Scripts/QuestHome/APKExtractor.cs.meta | 2 + Runtime/Scripts/QuestHome/BanterQuestHome.cs | 844 ++++++++++++++++++ .../Scripts/QuestHome/BanterQuestHome.cs.meta | 2 + .../Scripts/QuestHome/GLTFMaterialMapper.cs | 471 ++++++++++ .../QuestHome/GLTFMaterialMapper.cs.meta | 2 + Runtime/Scripts/QuestHome/KTXParser.cs | 190 ++++ Runtime/Scripts/QuestHome/KTXParser.cs.meta | 2 + Runtime/Scripts/QuestHome/QuestHomeLoader.cs | 188 ++++ .../Scripts/QuestHome/QuestHomeLoader.cs.meta | 2 + Runtime/Scripts/QuestHome/README.md | 98 ++ Runtime/Scripts/QuestHome/README.md.meta | 7 + .../Scripts/QuestHome/SideQuestUrlResolver.cs | 227 +++++ .../BanterComponent/BanterComponentNames.cs | 1 + VisualScripting/Space/LoadQuestHome.cs | 67 ++ VisualScripting/Space/LoadQuestHome.cs.meta | 2 + VisualScripting/Space/UnloadQuestHome.cs | 50 ++ VisualScripting/Space/UnloadQuestHome.cs.meta | 2 + 19 files changed, 2397 insertions(+) create mode 100644 Runtime/Scripts/QuestHome.meta create mode 100644 Runtime/Scripts/QuestHome/APKExtractor.cs create mode 100644 Runtime/Scripts/QuestHome/APKExtractor.cs.meta create mode 100644 Runtime/Scripts/QuestHome/BanterQuestHome.cs create mode 100644 Runtime/Scripts/QuestHome/BanterQuestHome.cs.meta create mode 100644 Runtime/Scripts/QuestHome/GLTFMaterialMapper.cs create mode 100644 Runtime/Scripts/QuestHome/GLTFMaterialMapper.cs.meta create mode 100644 Runtime/Scripts/QuestHome/KTXParser.cs create mode 100644 Runtime/Scripts/QuestHome/KTXParser.cs.meta create mode 100644 Runtime/Scripts/QuestHome/QuestHomeLoader.cs create mode 100644 Runtime/Scripts/QuestHome/QuestHomeLoader.cs.meta create mode 100644 Runtime/Scripts/QuestHome/README.md create mode 100644 Runtime/Scripts/QuestHome/README.md.meta create mode 100644 Runtime/Scripts/QuestHome/SideQuestUrlResolver.cs create mode 100644 VisualScripting/Space/LoadQuestHome.cs create mode 100644 VisualScripting/Space/LoadQuestHome.cs.meta create mode 100644 VisualScripting/Space/UnloadQuestHome.cs create mode 100644 VisualScripting/Space/UnloadQuestHome.cs.meta diff --git a/Runtime/Scripts/QuestHome.meta b/Runtime/Scripts/QuestHome.meta new file mode 100644 index 00000000..5881f8e7 --- /dev/null +++ b/Runtime/Scripts/QuestHome.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 20cc12c79e05c374fbbd03554919b320 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Scripts/QuestHome/APKExtractor.cs b/Runtime/Scripts/QuestHome/APKExtractor.cs new file mode 100644 index 00000000..22a770ee --- /dev/null +++ b/Runtime/Scripts/QuestHome/APKExtractor.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using UnityEngine; + +namespace Banter.SDK +{ + /// + /// Represents extracted Quest Home asset data + /// + public class QuestHomeAssets + { + public byte[] gltfData; + public byte[] binData; + public Dictionary textures = new Dictionary(); + public byte[] audioData; + public string gltfJson; + } + + /// + /// Extractor for Quest Home APK files + /// Handles nested ZIP extraction: APK → assets/scene.zip → *.ovrscene → GLTF files + /// + public static class APKExtractor + { + /// + /// Extract all Quest Home assets from APK data + /// + /// Raw APK file data + /// Extracted Quest Home assets + public static QuestHomeAssets ExtractAll(byte[] apkData) + { + try + { + Debug.Log("Starting APK extraction..."); + + // Step 1: Extract scene.zip from APK + byte[] sceneZipData = ExtractSceneZip(apkData); + if (sceneZipData == null) + { + throw new Exception("Failed to extract scene.zip from APK"); + } + + Debug.Log($"Extracted scene.zip ({sceneZipData.Length} bytes)"); + + // Step 2: Extract .ovrscene and audio from scene.zip + var sceneContents = ExtractSceneContents(sceneZipData); + if (sceneContents.ovrsceneData == null) + { + throw new Exception("Failed to extract .ovrscene from scene.zip"); + } + + Debug.Log($"Extracted .ovrscene ({sceneContents.ovrsceneData.Length} bytes)"); + + // Step 3: Extract GLTF files from .ovrscene + var assets = ExtractGLTFFiles(sceneContents.ovrsceneData); + assets.audioData = sceneContents.audioData; + + Debug.Log($"Extraction complete: GLTF={assets.gltfData != null}, BIN={assets.binData != null}, Textures={assets.textures.Count}, Audio={assets.audioData != null}"); + + return assets; + } + catch (Exception ex) + { + Debug.LogError($"APK extraction failed: {ex.Message}\n{ex.StackTrace}"); + throw; + } + } + + /// + /// Extract scene.zip from APK (first level ZIP) + /// + private static byte[] ExtractSceneZip(byte[] apkData) + { + using (var stream = new MemoryStream(apkData)) + using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) + { + // Find assets/scene.zip + var sceneZipEntry = archive.Entries.FirstOrDefault(e => + e.FullName.Equals("assets/scene.zip", StringComparison.OrdinalIgnoreCase)); + + if (sceneZipEntry == null) + { + Debug.LogError("assets/scene.zip not found in APK. Available entries:"); + foreach (var entry in archive.Entries.Take(20)) + { + Debug.Log($" - {entry.FullName}"); + } + throw new FileNotFoundException("assets/scene.zip not found in APK"); + } + + using (var entryStream = sceneZipEntry.Open()) + using (var memStream = new MemoryStream()) + { + entryStream.CopyTo(memStream); + return memStream.ToArray(); + } + } + } + + /// + /// Extract .ovrscene and audio from scene.zip (second level ZIP) + /// + private static (byte[] ovrsceneData, byte[] audioData) ExtractSceneContents(byte[] sceneZipData) + { + using (var stream = new MemoryStream(sceneZipData)) + using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) + { + byte[] ovrsceneData = null; + byte[] audioData = null; + + foreach (var entry in archive.Entries) + { + // Find .ovrscene file (typically _WORLD_MODEL.gltf.ovrscene) + if (entry.FullName.EndsWith(".ovrscene", StringComparison.OrdinalIgnoreCase)) + { + using (var entryStream = entry.Open()) + using (var memStream = new MemoryStream()) + { + entryStream.CopyTo(memStream); + ovrsceneData = memStream.ToArray(); + } + Debug.Log($"Found .ovrscene: {entry.FullName}"); + } + // Find background audio + else if (entry.FullName.Equals("_BACKGROUND_LOOP.ogg", StringComparison.OrdinalIgnoreCase)) + { + using (var entryStream = entry.Open()) + using (var memStream = new MemoryStream()) + { + entryStream.CopyTo(memStream); + audioData = memStream.ToArray(); + } + Debug.Log($"Found background audio: {entry.FullName}"); + } + } + + return (ovrsceneData, audioData); + } + } + + /// + /// Extract GLTF files from .ovrscene (third level ZIP) + /// + private static QuestHomeAssets ExtractGLTFFiles(byte[] ovrsceneData) + { + var assets = new QuestHomeAssets(); + + using (var stream = new MemoryStream(ovrsceneData)) + using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) + { + Debug.Log($".ovrscene contains {archive.Entries.Count} files:"); + + foreach (var entry in archive.Entries) + { + Debug.Log($" - {entry.FullName} ({entry.Length} bytes)"); + + using (var entryStream = entry.Open()) + using (var memStream = new MemoryStream()) + { + entryStream.CopyTo(memStream); + byte[] data = memStream.ToArray(); + + if (entry.FullName.EndsWith(".gltf", StringComparison.OrdinalIgnoreCase)) + { + assets.gltfData = data; + assets.gltfJson = System.Text.Encoding.UTF8.GetString(data); + Debug.Log($"Extracted GLTF: {entry.FullName}"); + } + else if (entry.FullName.EndsWith(".bin", StringComparison.OrdinalIgnoreCase)) + { + assets.binData = data; + Debug.Log($"Extracted BIN: {entry.FullName}"); + } + else if (entry.FullName.EndsWith(".ktx", StringComparison.OrdinalIgnoreCase)) + { + // Extract texture name without path + string textureName = Path.GetFileName(entry.FullName); + assets.textures[textureName] = data; + Debug.Log($"Extracted texture: {textureName}"); + } + else if (entry.FullName.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || + entry.FullName.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) || + entry.FullName.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase)) + { + // Some Quest homes may have PNG/JPG textures + string textureName = Path.GetFileName(entry.FullName); + assets.textures[textureName] = data; + Debug.Log($"Extracted image texture: {textureName}"); + } + } + } + } + + return assets; + } + + /// + /// Validate that extracted assets are complete + /// + public static bool ValidateAssets(QuestHomeAssets assets) + { + if (assets == null) + { + Debug.LogError("Assets are null"); + return false; + } + + if (assets.gltfData == null || assets.gltfData.Length == 0) + { + Debug.LogError("No GLTF data found"); + return false; + } + + if (assets.binData == null || assets.binData.Length == 0) + { + Debug.LogWarning("No BIN data found (may be embedded in GLTF)"); + } + + if (assets.textures.Count == 0) + { + Debug.LogWarning("No textures found"); + } + + Debug.Log($"Assets validation: GLTF={assets.gltfData.Length} bytes, BIN={assets.binData?.Length ?? 0} bytes, Textures={assets.textures.Count}"); + + return true; + } + } +} diff --git a/Runtime/Scripts/QuestHome/APKExtractor.cs.meta b/Runtime/Scripts/QuestHome/APKExtractor.cs.meta new file mode 100644 index 00000000..9ee34b54 --- /dev/null +++ b/Runtime/Scripts/QuestHome/APKExtractor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e0e21fe73f0f66f4eb036214e22bcdab \ No newline at end of file diff --git a/Runtime/Scripts/QuestHome/BanterQuestHome.cs b/Runtime/Scripts/QuestHome/BanterQuestHome.cs new file mode 100644 index 00000000..c6967993 --- /dev/null +++ b/Runtime/Scripts/QuestHome/BanterQuestHome.cs @@ -0,0 +1,844 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using UnityEngine; +using UnityEngine.Networking; +using Siccity.GLTFUtility; +using Newtonsoft.Json.Linq; + +namespace Banter.SDK +{ + /* + #### Banter Quest Home + Load Quest Home environments from SideQuest APK files. Downloads the APK, extracts GLTF and textures, + and loads them with automatic material and collider setup. + + **Properties** + - `url` - The URL of the Quest Home APK file. + - `addColliders` - If colliders should be added to opaque meshes. + - `legacyShaderFix` - Convert materials to unlit shaders (Quest optimization). + + **Code Example** + ```js + const url = "https://cdn.sidequestvr.com/file/167567/canyon_environment.apk"; + const gameObject = new BS.GameObject("MyQuestHome"); + const questHome = await gameObject.AddComponent(new BS.BanterQuestHome(url, true, true)); + ``` + */ + [DefaultExecutionOrder(-1)] + [WatchComponent] + [RequireComponent(typeof(BanterObjectId))] + public class BanterQuestHome : BanterComponentBase + { + [Tooltip("The URL of the Quest Home APK file to be loaded.")] + [See(initial = "")][SerializeField] internal string url; + + [Tooltip("Enable to automatically add colliders to opaque meshes.")] + [See(initial = "true")][SerializeField] internal bool addColliders = true; + + [Tooltip("Enable to convert materials to unlit shaders (Quest optimization).")] + [See(initial = "true")][SerializeField] internal bool legacyShaderFix = true; + + private bool loadStarted; + private GameObject loadedModel; + private bool alreadyStarted = false; + + BanterScene _scene; + public BanterScene scene + { + get + { + if (_scene == null) + { + _scene = BanterScene.Instance(); + } + return _scene; + } + } + + internal override void StartStuff() + { + LoadQuestHome(); + } + + /// + /// Main loading pipeline for Quest Home APK + /// + async void LoadQuestHome() + { + if (loadStarted) + { + Debug.LogWarning("Quest Home load already in progress"); + return; + } + + _loaded = false; + loadStarted = true; + + try + { + LogLine.Do($"Starting Quest Home load from: {url}"); + + // Step 0: Resolve URL (handles both direct APK URLs and SideQuest listing URLs) + LogLine.Do("Resolving URL..."); + string resolvedApkUrl = await SideQuestUrlResolver.ResolveApkUrl(url); + if (resolvedApkUrl != url) + { + LogLine.Do($"Resolved listing URL to APK URL: {resolvedApkUrl}"); + } + + // Step 1: Download APK + LogLine.Do("Downloading APK..."); + byte[] apkData = await DownloadAPK(resolvedApkUrl); + LogLine.Do($"APK downloaded ({apkData.Length / 1024 / 1024} MB)"); + + // Step 2: Extract files + LogLine.Do("Extracting APK contents..."); + QuestHomeAssets assets = APKExtractor.ExtractAll(apkData); + + if (!APKExtractor.ValidateAssets(assets)) + { + throw new Exception("Extracted assets validation failed"); + } + + // Step 3: Load textures + LogLine.Do($"Loading {assets.textures.Count} textures..."); + Dictionary loadedTextures = await LoadTextures(assets.textures); + LogLine.Do($"Loaded {loadedTextures.Count} textures"); + + // Step 4: Parse GLTF for texture mappings + LogLine.Do("Parsing GLTF material mappings..."); + List textureMappings = GLTFMaterialMapper.ParseGLTF(assets.gltfJson); + + // Step 5: Load GLTF model with animations + LogLine.Do("Loading GLTF model with animations..."); + + // Configure import settings for animation looping + ImportSettings importSettings = new ImportSettings(); + importSettings.animationSettings.looping = true; // Enable looping + importSettings.animationSettings.useLegacyClips = true; // Use legacy Animation component + + var result = await LoadGLTFModel(assets.gltfData, assets.binData, importSettings); + GameObject model = result.model; + AnimationClip[] animations = result.animations; + + if (model == null) + { + throw new Exception("Failed to load GLTF model"); + } + + loadedModel = model; + LogLine.Do("GLTF model loaded successfully"); + + // Step 5b: Setup animations if present + if (animations != null && animations.Length > 0) + { + LogLine.Do($"Setting up {animations.Length} animations..."); + SetupAnimations(model, animations); + } + + // Step 6: Apply textures to materials + // This also applies correct unlit shaders based on alpha mode (OPAQUE/MASK/BLEND) + LogLine.Do("Applying textures to materials with alpha-aware shaders..."); + GLTFMaterialMapper.ApplyTextures(model, loadedTextures, textureMappings); + + // Step 7: Convert remaining materials to unlit shaders if requested + // (ApplyTextures already handles materials with textures) + if (legacyShaderFix) + { + LogLine.Do("Converting any remaining materials to unlit shaders..."); + GLTFMaterialMapper.ConvertAllToUnlit(model); + } + + // Step 8: Setup colliders + if (addColliders) + { + LogLine.Do("Setting up colliders..."); + SetupColliders(model); + } + + // Step 9: Load background audio (optional) + if (assets.audioData != null && assets.audioData.Length > 0) + { + LogLine.Do("Loading background audio..."); + await SetupAudio(assets.audioData); + } + + // Step 10: Set skybox to black for Quest Home environments + SetSkyboxToBlack(); + + LogLine.Do("Quest Home loaded successfully!"); + + // SetLoadedIfNot triggers callbacks that may require BanterLink + // Wrap in try-catch for standalone usage (e.g., via QuestHomeLoader) + try + { + SetLoadedIfNot(); + } + catch (Exception callbackEx) + { + Debug.LogWarning($"SetLoadedIfNot callback failed (this is normal for standalone usage): {callbackEx.Message}"); + } + } + catch (Exception ex) + { + Debug.LogError($"Failed to load Quest Home: {ex.Message}\n{ex.StackTrace}"); + loadStarted = false; + } + } + + /// + /// Download APK from URL with progress tracking + /// + private async Task DownloadAPK(string apkUrl) + { + using (UnityWebRequest request = UnityWebRequest.Get(apkUrl)) + { + var operation = request.SendWebRequest(); + + while (!operation.isDone) + { + float progress = request.downloadProgress; + // Log progress every 10% + if (progress > 0 && (int)(progress * 10) % 2 == 0) + { + LogLine.Do($"Download progress: {progress * 100:F0}%"); + } + await Task.Yield(); + } + + if (request.result != UnityWebRequest.Result.Success) + { + throw new Exception($"APK download failed: {request.error}"); + } + + return request.downloadHandler.data; + } + } + + /// + /// Load textures from KTX files + /// + private async Task> LoadTextures(Dictionary textureFiles) + { + var loadedTextures = new Dictionary(); + + foreach (var kvp in textureFiles) + { + string textureName = kvp.Key; + byte[] textureData = kvp.Value; + + try + { + Texture2D texture = null; + + if (textureName.EndsWith(".ktx", StringComparison.OrdinalIgnoreCase)) + { + // Load ASTC texture from KTX file + texture = KTXParser.LoadASTCTexture(textureData, textureName); + } + else if (textureName.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || + textureName.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) || + textureName.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase)) + { + // Load regular image texture + texture = new Texture2D(2, 2); + texture.LoadImage(textureData); + texture.name = textureName; + } + + if (texture != null) + { + loadedTextures[textureName] = texture; + LogLine.Do($"Loaded texture: {textureName} ({texture.width}x{texture.height})"); + } + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to load texture '{textureName}': {ex.Message}"); + } + + await Task.Yield(); + } + + return loadedTextures; + } + + /// + /// Strip texture references from GLTF JSON to prevent loading errors + /// + private byte[] StripTextureReferences(byte[] gltfData) + { + try + { + // Parse GLTF JSON + string gltfJson = System.Text.Encoding.UTF8.GetString(gltfData); + var gltf = JObject.Parse(gltfJson); + + // Remove texture references from materials + if (gltf["materials"] != null && gltf["materials"] is JArray materials) + { + foreach (JObject material in materials) + { + // Remove texture references from PBR metallic roughness + if (material["pbrMetallicRoughness"] is JObject pbr) + { + pbr.Remove("baseColorTexture"); + pbr.Remove("metallicRoughnessTexture"); + } + + // Remove other texture references + material.Remove("normalTexture"); + material.Remove("emissiveTexture"); + material.Remove("occlusionTexture"); + } + } + + // Clear images array (textures reference these) + if (gltf["images"] != null) + { + gltf["images"] = new JArray(); + } + + // Clear textures array + if (gltf["textures"] != null) + { + gltf["textures"] = new JArray(); + } + + // Fix buffer URI to point to our temp .bin file + if (gltf["buffers"] != null && gltf["buffers"] is JArray buffers && buffers.Count > 0) + { + if (buffers[0] is JObject buffer && buffer["uri"] != null) + { + // Update the URI to point to our temp bin file + buffer["uri"] = "temp_quest_home.bin"; + LogLine.Do($"Updated buffer URI to: temp_quest_home.bin"); + } + } + + // Serialize back to bytes + string modifiedJson = gltf.ToString(Newtonsoft.Json.Formatting.None); + return System.Text.Encoding.UTF8.GetBytes(modifiedJson); + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to strip texture references: {ex.Message}\n{ex.StackTrace}"); + return gltfData; // Return original if parsing fails + } + } + + /// + /// Load GLTF model from extracted data with animation support + /// + /// GLTF JSON data + /// Binary buffer data + /// Import settings (includes animation looping configuration) + /// Tuple containing loaded GameObject and AnimationClip array + private async Task<(GameObject model, AnimationClip[] animations)> LoadGLTFModel(byte[] gltfData, byte[] binData, ImportSettings importSettings) + { + GameObject model = null; + AnimationClip[] animations = null; + + try + { + // Create temporary file for GLTF (GLTFUtility requires file-based loading) + string tempGltfPath = System.IO.Path.Combine(Application.temporaryCachePath, "temp_quest_home.gltf"); + string tempBinPath = System.IO.Path.Combine(Application.temporaryCachePath, "temp_quest_home.bin"); + + // Strip texture references from GLTF JSON + // This prevents GLTFUtility from trying to load texture files that don't exist + // We load textures separately via KTXParser and apply them later + LogLine.Do("Stripping texture references from GLTF..."); + byte[] modifiedGltfData = StripTextureReferences(gltfData); + + // Debug: Save modified GLTF for inspection + string debugGltfPath = System.IO.Path.Combine(Application.temporaryCachePath, "debug_quest_home.gltf"); + System.IO.File.WriteAllBytes(debugGltfPath, modifiedGltfData); + LogLine.Do($"Debug GLTF saved to: {debugGltfPath}"); + + // Write temporary files + System.IO.File.WriteAllBytes(tempGltfPath, modifiedGltfData); + LogLine.Do($"GLTF file written: {tempGltfPath} ({modifiedGltfData.Length} bytes)"); + + if (binData != null && binData.Length > 0) + { + System.IO.File.WriteAllBytes(tempBinPath, binData); + LogLine.Do($"BIN file written: {tempBinPath} ({binData.Length} bytes)"); + } + else + { + LogLine.Do("No BIN data to write"); + } + + // Yield to let Unity process other tasks + await Task.Yield(); + + // Load using GLTFUtility (must run on main thread) + // GLTFUtility accesses Unity rendering APIs that require the main thread + LogLine.Do("Loading GLTF with GLTFUtility..."); + AnimationClip[] loadedAnimations; + model = Importer.LoadFromFile(tempGltfPath, importSettings, out loadedAnimations); + animations = loadedAnimations; + LogLine.Do($"GLTFUtility load completed ({animations?.Length ?? 0} animations)"); + + // Yield again after loading to keep things responsive + await Task.Yield(); + + if (model != null) + { + // Parent to this GameObject + model.transform.SetParent(transform, false); + model.transform.localPosition = Vector3.zero; + model.transform.localRotation = Quaternion.identity; + model.transform.localScale = Vector3.one; + model.name = "QuestHomeModel"; + } + + // Cleanup temporary files + try + { + if (System.IO.File.Exists(tempGltfPath)) + System.IO.File.Delete(tempGltfPath); + if (System.IO.File.Exists(tempBinPath)) + System.IO.File.Delete(tempBinPath); + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to cleanup temp files: {ex.Message}"); + } + } + catch (Exception ex) + { + Debug.LogError($"GLTF loading failed: {ex.Message}"); + throw; + } + + return (model, animations); + } + + /// + /// Setup colliders on opaque meshes + /// + private void SetupColliders(GameObject model) + { + if (model == null) return; + + int collidersAdded = 0; + var meshFilters = model.GetComponentsInChildren(); + + foreach (var meshFilter in meshFilters) + { + if (meshFilter.sharedMesh == null) continue; + + var renderer = meshFilter.GetComponent(); + if (renderer == null) continue; + + // Check if material is opaque + bool hasOpaqueMaterial = false; + foreach (var material in renderer.sharedMaterials) + { + if (GLTFMaterialMapper.IsOpaqueMaterial(material)) + { + hasOpaqueMaterial = true; + break; + } + } + + if (hasOpaqueMaterial) + { + // Add MeshCollider + var existingCollider = meshFilter.GetComponent(); + if (existingCollider == null) + { + var collider = meshFilter.gameObject.AddComponent(); + collider.sharedMesh = meshFilter.sharedMesh; + collider.convex = false; // Use exact mesh collision + collidersAdded++; + } + } + } + + LogLine.Do($"Added {collidersAdded} mesh colliders"); + } + + /// + /// Setup background audio (optional) + /// + private async Task SetupAudio(byte[] audioData) + { + try + { + // Create temporary audio file + string tempAudioPath = System.IO.Path.Combine(Application.temporaryCachePath, "quest_home_audio.ogg"); + System.IO.File.WriteAllBytes(tempAudioPath, audioData); + LogLine.Do($"Audio file written: {tempAudioPath} ({audioData.Length} bytes)"); + + AudioClip clip = null; + + // Load audio clip + using (UnityWebRequest request = UnityWebRequestMultimedia.GetAudioClip("file://" + tempAudioPath, AudioType.OGGVORBIS)) + { + // Configure download handler to stream audio + ((DownloadHandlerAudioClip)request.downloadHandler).streamAudio = false; + + var operation = request.SendWebRequest(); + + // Wait for completion + while (!operation.isDone) + { + await Task.Yield(); + } + + if (request.result == UnityWebRequest.Result.Success) + { + clip = DownloadHandlerAudioClip.GetContent(request); + + if (clip != null) + { + clip.name = "QuestHomeAudio"; + LogLine.Do($"Audio clip loaded: {clip.length:F2}s, {clip.channels} channels, {clip.frequency}Hz"); + } + else + { + Debug.LogWarning("DownloadHandlerAudioClip.GetContent returned null"); + } + } + else + { + Debug.LogError($"Audio load failed: {request.error}"); + } + } + + // Add AudioSource with loaded clip (outside using block to keep clip alive) + if (clip != null) + { + var audioSource = gameObject.AddComponent(); + audioSource.clip = clip; + audioSource.loop = true; + audioSource.playOnAwake = false; // We'll call Play() manually + audioSource.volume = 0.5f; + audioSource.spatialBlend = 0f; // 2D sound + audioSource.Play(); + + LogLine.Do($"Background audio playing: {clip.name}"); + } + + // Cleanup temp file + try + { + if (System.IO.File.Exists(tempAudioPath)) + System.IO.File.Delete(tempAudioPath); + } + catch { } + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to setup audio: {ex.Message}\n{ex.StackTrace}"); + } + } + + /// + /// Set the scene skybox to solid black for Quest Home environments + /// + private void SetSkyboxToBlack() + { + try + { + // Create or get black skybox material + Material skyboxMaterial = new Material(Shader.Find("Skybox/Procedural")); + skyboxMaterial.SetColor("_SkyTint", Color.black); + skyboxMaterial.SetColor("_GroundColor", Color.black); + skyboxMaterial.SetFloat("_SunSize", 0f); + skyboxMaterial.SetFloat("_SunSizeConvergence", 0f); + skyboxMaterial.SetFloat("_AtmosphereThickness", 0f); + skyboxMaterial.SetFloat("_Exposure", 0f); + + // Apply to RenderSettings + RenderSettings.skybox = skyboxMaterial; + RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat; + RenderSettings.ambientLight = Color.black; + + LogLine.Do("Skybox set to black"); + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to set skybox to black: {ex.Message}"); + } + } + + /// + /// Setup animations on the loaded model with looping + /// + /// Root GameObject with animations + /// Array of animation clips to add + private void SetupAnimations(GameObject model, AnimationClip[] animations) + { + if (model == null || animations == null || animations.Length == 0) + { + LogLine.Do("No animations to setup"); + return; + } + + try + { + // Add Animation component (legacy) - simpler for Quest Home environments + Animation animation = model.GetComponent(); + if (animation == null) + { + animation = model.AddComponent(); + } + + // Add all animation clips + for (int i = 0; i < animations.Length; i++) + { + AnimationClip clip = animations[i]; + animation.AddClip(clip, clip.name); + + if (i == 0) + { + // Set first animation as default + animation.clip = clip; + animation.playAutomatically = true; + } + + LogLine.Do($"Added animation: {clip.name} (length: {clip.length:F2}s, looping: {clip.wrapMode == WrapMode.Loop})"); + } + + // Start playing the first animation + if (animations.Length > 0 && animation.clip != null) + { + animation.Play(); + LogLine.Do($"Playing animation: {animation.clip.name}"); + } + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to setup animations: {ex.Message}"); + } + } + + /// + /// Handles property changes from JavaScript or C# API + /// + internal void UpdateCallback(List changedProperties) + { + if (changedProperties.Contains(PropertyName.url)) + { + // Reset and reload if URL changes + loadStarted = false; + if (loadedModel != null) + { + Destroy(loadedModel); + loadedModel = null; + } + } + LoadQuestHome(); + } + + /// + /// Public C# API property for URL + /// + public System.String Url + { + get { return url; } + set { url = value; UpdateCallback(new List { PropertyName.url }); } + } + + /// + /// Public C# API property for AddColliders + /// + public System.Boolean AddColliders + { + get { return addColliders; } + set { addColliders = value; UpdateCallback(new List { PropertyName.addColliders }); } + } + + /// + /// Public C# API property for LegacyShaderFix + /// + public System.Boolean LegacyShaderFix + { + get { return legacyShaderFix; } + set { legacyShaderFix = value; UpdateCallback(new List { PropertyName.legacyShaderFix }); } + } + + /// + /// Unity Awake - Register component with BanterScene + /// + void Awake() + { + BanterScene.Instance().RegisterComponentOnMainThread(gameObject, this); + } + + /// + /// Unity Start - Initialize and start component + /// + void Start() + { + Init(); + StartStuff(); + } + + /// + /// Cleanup when component is destroyed + /// + void OnDestroy() + { + scene.UnregisterComponentOnMainThread(gameObject, this); + DestroyStuff(); + } + + // ========== ABSTRACT METHOD IMPLEMENTATIONS ========== + + /// + /// Watch properties for changes (unused - handled automatically) + /// + internal override void WatchProperties(PropertyName[] properties) + { + // Empty implementation - property watching handled by UpdateCallback + } + + /// + /// Deserialize property updates from JavaScript + /// + internal override void Deserialise(List values) + { + List changedProperties = new List(); + + for (int i = 0; i < values.Count; i++) + { + // Handle string properties (url) + if (values[i] is BanterString) + { + var valurl = (BanterString)values[i]; + if (valurl.n == PropertyName.url) + { + url = valurl.x; + changedProperties.Add(PropertyName.url); + } + } + + // Handle boolean properties (addColliders, legacyShaderFix) + if (values[i] is BanterBool) + { + var valbool = (BanterBool)values[i]; + if (valbool.n == PropertyName.addColliders) + { + addColliders = valbool.x; + changedProperties.Add(PropertyName.addColliders); + } + else if (valbool.n == PropertyName.legacyShaderFix) + { + legacyShaderFix = valbool.x; + changedProperties.Add(PropertyName.legacyShaderFix); + } + } + } + + if (values.Count > 0) + { + UpdateCallback(changedProperties); + } + } + + /// + /// Call method from JavaScript (not used for BanterQuestHome) + /// + internal override object CallMethod(string methodName, List parameters) + { + return null; // No callable methods for BanterQuestHome + } + + /// + /// Sync properties from Unity to JavaScript + /// + internal override void SyncProperties(bool force = false, Action callback = null) + { + var updates = new List(); + + if (force) + { + updates.Add(new BanterComponentPropertyUpdate() + { + name = PropertyName.url, + type = PropertyType.String, + value = url, + componentType = ComponentType.BanterQuestHome, + oid = oid, + cid = cid + }); + + updates.Add(new BanterComponentPropertyUpdate() + { + name = PropertyName.addColliders, + type = PropertyType.Bool, + value = addColliders, + componentType = ComponentType.BanterQuestHome, + oid = oid, + cid = cid + }); + + updates.Add(new BanterComponentPropertyUpdate() + { + name = PropertyName.legacyShaderFix, + type = PropertyType.Bool, + value = legacyShaderFix, + componentType = ComponentType.BanterQuestHome, + oid = oid, + cid = cid + }); + } + + scene.SetFromUnityProperties(updates, callback); + } + + /// + /// Reset and reload the component with all properties + /// + internal override void ReSetup() + { + List changedProperties = new List() + { + PropertyName.url, + PropertyName.addColliders, + PropertyName.legacyShaderFix + }; + UpdateCallback(changedProperties); + } + + /// + /// Initialize component with BanterScene + /// + internal override void Init(List constructorProperties = null) + { + if (alreadyStarted) { return; } + alreadyStarted = true; + + scene.RegisterBanterMonoscript(gameObject.GetInstanceID(), GetInstanceID(), ComponentType.BanterQuestHome); + + oid = gameObject.GetInstanceID(); + cid = GetInstanceID(); + + if (constructorProperties != null) + { + Deserialise(constructorProperties); + } + + SyncProperties(true); + } + + /// + /// Cleanup loaded assets + /// + internal override void DestroyStuff() + { + if (loadedModel != null) + { + Destroy(loadedModel); + loadedModel = null; + } + } + } +} diff --git a/Runtime/Scripts/QuestHome/BanterQuestHome.cs.meta b/Runtime/Scripts/QuestHome/BanterQuestHome.cs.meta new file mode 100644 index 00000000..64f57446 --- /dev/null +++ b/Runtime/Scripts/QuestHome/BanterQuestHome.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 784f056ec79ac3f4eb3734d33adbed2b \ No newline at end of file diff --git a/Runtime/Scripts/QuestHome/GLTFMaterialMapper.cs b/Runtime/Scripts/QuestHome/GLTFMaterialMapper.cs new file mode 100644 index 00000000..4b4fc497 --- /dev/null +++ b/Runtime/Scripts/QuestHome/GLTFMaterialMapper.cs @@ -0,0 +1,471 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace Banter.SDK +{ + /// + /// Represents a texture-to-material mapping from GLTF + /// + public class TextureMapping + { + public int materialIndex; + public string materialName; + public string textureType; // "baseColor" or "emissive" + public string imageUri; // e.g., "texture0.ktx" + public int textureIndex; + public string alphaMode; // "OPAQUE", "MASK", or "BLEND" + public float alphaCutoff; // Used with MASK mode (default 0.5) + } + + /// + /// Helper class for parsing GLTF materials and applying textures + /// Handles Quest Home-specific material requirements (unlit materials) + /// + public static class GLTFMaterialMapper + { + /// + /// Parse GLTF JSON to extract texture-to-material mappings + /// + /// GLTF JSON string + /// List of texture mappings + public static List ParseGLTF(string gltfJson) + { + var mappings = new List(); + + try + { + JObject gltf = JObject.Parse(gltfJson); + + var materials = gltf["materials"] as JArray; + var textures = gltf["textures"] as JArray; + var images = gltf["images"] as JArray; + + if (materials == null || textures == null || images == null) + { + Debug.LogWarning("GLTF missing materials, textures, or images arrays"); + return mappings; + } + + for (int matIndex = 0; matIndex < materials.Count; matIndex++) + { + var material = materials[matIndex] as JObject; + string materialName = material["name"]?.ToString() ?? $"Material_{matIndex}"; + + // Extract alpha mode (OPAQUE, MASK, or BLEND) + string alphaMode = material["alphaMode"]?.ToString() ?? "OPAQUE"; + float alphaCutoff = material["alphaCutoff"]?.Value() ?? 0.5f; + + // Check for base color texture + var pbrMetallicRoughness = material["pbrMetallicRoughness"] as JObject; + if (pbrMetallicRoughness != null) + { + var baseColorTexture = pbrMetallicRoughness["baseColorTexture"] as JObject; + if (baseColorTexture != null) + { + int textureIndex = baseColorTexture["index"]?.Value() ?? -1; + if (textureIndex >= 0 && textureIndex < textures.Count) + { + var texture = textures[textureIndex] as JObject; + int imageIndex = texture["source"]?.Value() ?? -1; + if (imageIndex >= 0 && imageIndex < images.Count) + { + var image = images[imageIndex] as JObject; + string imageUri = image["uri"]?.ToString(); + + if (!string.IsNullOrEmpty(imageUri)) + { + mappings.Add(new TextureMapping + { + materialIndex = matIndex, + materialName = materialName, + textureType = "baseColor", + imageUri = imageUri, + textureIndex = textureIndex, + alphaMode = alphaMode, + alphaCutoff = alphaCutoff + }); + + Debug.Log($"Material '{materialName}' uses base color texture: {imageUri} (alphaMode: {alphaMode})"); + } + } + } + } + } + + // Check for emissive texture (fallback if no base color) + var emissiveTexture = material["emissiveTexture"] as JObject; + if (emissiveTexture != null) + { + int textureIndex = emissiveTexture["index"]?.Value() ?? -1; + if (textureIndex >= 0 && textureIndex < textures.Count) + { + var texture = textures[textureIndex] as JObject; + int imageIndex = texture["source"]?.Value() ?? -1; + if (imageIndex >= 0 && imageIndex < images.Count) + { + var image = images[imageIndex] as JObject; + string imageUri = image["uri"]?.ToString(); + + if (!string.IsNullOrEmpty(imageUri)) + { + // Only add if no base color texture exists + if (!mappings.Any(m => m.materialIndex == matIndex && m.textureType == "baseColor")) + { + mappings.Add(new TextureMapping + { + materialIndex = matIndex, + materialName = materialName, + textureType = "emissive", + imageUri = imageUri, + textureIndex = textureIndex, + alphaMode = alphaMode, + alphaCutoff = alphaCutoff + }); + + Debug.Log($"Material '{materialName}' uses emissive texture: {imageUri} (alphaMode: {alphaMode})"); + } + } + } + } + } + } + + Debug.Log($"Parsed {mappings.Count} texture mappings from GLTF"); + } + catch (Exception ex) + { + Debug.LogError($"Failed to parse GLTF JSON: {ex.Message}"); + } + + return mappings; + } + + /// + /// Apply textures to materials in loaded GLTF model + /// + /// Root GameObject of loaded GLTF + /// Dictionary of texture name → Texture2D + /// Texture mappings from ParseGLTF + public static void ApplyTextures(GameObject model, Dictionary textures, List mappings) + { + if (model == null || textures == null || mappings == null) + { + Debug.LogWarning("Cannot apply textures: null parameters"); + return; + } + + // Build material name → Material dictionary and alpha mode tracking + var materialsByName = new Dictionary>(); + var alphaModeLookup = new Dictionary(); + var renderers = model.GetComponentsInChildren(true); + + foreach (var renderer in renderers) + { + var materials = renderer.sharedMaterials; + foreach (var material in materials) + { + if (material == null) continue; + + if (!materialsByName.ContainsKey(material.name)) + { + materialsByName[material.name] = new List(); + } + materialsByName[material.name].Add(material); + } + } + + Debug.Log($"Found {materialsByName.Count} unique materials in model"); + + // Apply textures based on mappings and track alpha modes + int texturesApplied = 0; + foreach (var mapping in mappings) + { + // Get texture by imageUri (e.g., "texture0.ktx") + if (!textures.TryGetValue(mapping.imageUri, out Texture2D texture)) + { + Debug.LogWarning($"Texture '{mapping.imageUri}' not found in texture dictionary"); + continue; + } + + // Find materials by name + if (!materialsByName.TryGetValue(mapping.materialName, out List materials)) + { + Debug.LogWarning($"Material '{mapping.materialName}' not found in model"); + continue; + } + + // Track alpha mode for this material + if (!alphaModeLookup.ContainsKey(mapping.materialName)) + { + alphaModeLookup[mapping.materialName] = (mapping.alphaMode, mapping.alphaCutoff); + } + + // Check if this is a KTX texture (needs vertical flip) + bool isKTX = mapping.imageUri.EndsWith(".ktx", System.StringComparison.OrdinalIgnoreCase); + + // Apply texture to all instances of this material + foreach (var material in materials) + { + if (mapping.textureType == "baseColor") + { + material.mainTexture = texture; + if (material.HasProperty("_BaseMap")) + { + material.SetTexture("_BaseMap", texture); + } + + // Flip KTX textures vertically (coordinate system difference) + if (isKTX) + { + material.mainTextureScale = new Vector2(1, -1); + material.mainTextureOffset = new Vector2(0, 1); + + if (material.HasProperty("_BaseMap")) + { + material.SetTextureScale("_BaseMap", new Vector2(1, -1)); + material.SetTextureOffset("_BaseMap", new Vector2(0, 1)); + } + } + + texturesApplied++; + } + else if (mapping.textureType == "emissive") + { + if (material.HasProperty("_EmissionMap")) + { + material.SetTexture("_EmissionMap", texture); + material.EnableKeyword("_EMISSION"); + + // Flip KTX textures vertically + if (isKTX) + { + material.SetTextureScale("_EmissionMap", new Vector2(1, -1)); + material.SetTextureOffset("_EmissionMap", new Vector2(0, 1)); + } + } + // Also set as main texture if no base color + if (material.mainTexture == null) + { + material.mainTexture = texture; + + // Flip KTX textures vertically + if (isKTX) + { + material.mainTextureScale = new Vector2(1, -1); + material.mainTextureOffset = new Vector2(0, 1); + } + } + texturesApplied++; + } + } + + Debug.Log($"Applied texture '{mapping.imageUri}' to material '{mapping.materialName}' ({materials.Count} instances)"); + } + + Debug.Log($"Applied {texturesApplied} textures to materials"); + + // Apply correct shaders based on alpha mode + int shadersApplied = 0; + foreach (var kvp in alphaModeLookup) + { + string materialName = kvp.Key; + string alphaMode = kvp.Value.alphaMode; + float alphaCutoff = kvp.Value.alphaCutoff; + + if (materialsByName.TryGetValue(materialName, out List materials)) + { + foreach (var material in materials) + { + ApplyShaderForAlphaMode(material, alphaMode, alphaCutoff); + shadersApplied++; + } + } + } + + Debug.Log($"Applied alpha-aware shaders to {shadersApplied} material instances"); + } + + /// + /// Apply the appropriate unlit shader based on GLTF alpha mode + /// + /// Material to update + /// GLTF alpha mode (OPAQUE, MASK, or BLEND) + /// Alpha cutoff value for MASK mode + private static void ApplyShaderForAlphaMode(Material material, string alphaMode, float alphaCutoff) + { + if (material == null) return; + + // Store current texture and color + Texture mainTex = material.mainTexture; + Color color = material.HasProperty("_Color") ? material.color : Color.white; + Vector2 textureScale = material.mainTextureScale; + Vector2 textureOffset = material.mainTextureOffset; + + Shader targetShader = null; + + switch (alphaMode) + { + case "MASK": + // Alpha clipping - use Transparent Cutout + targetShader = Shader.Find("Unlit/Transparent Cutout"); + if (targetShader != null) + { + material.shader = targetShader; + if (material.HasProperty("_Cutoff")) + { + material.SetFloat("_Cutoff", alphaCutoff); + } + Debug.Log($"Applied Unlit/Transparent Cutout to material '{material.name}' (cutoff: {alphaCutoff})"); + } + break; + + case "BLEND": + // Alpha blending - use Transparent + targetShader = Shader.Find("Unlit/Transparent"); + if (targetShader != null) + { + material.shader = targetShader; + // Enable transparency + if (material.HasProperty("_Mode")) + { + material.SetFloat("_Mode", 3); // Transparent mode + } + Debug.Log($"Applied Unlit/Transparent to material '{material.name}'"); + } + break; + + case "OPAQUE": + default: + // Opaque - use regular Unlit/Texture + targetShader = Shader.Find("Unlit/Texture"); + if (targetShader != null) + { + material.shader = targetShader; + Debug.Log($"Applied Unlit/Texture to material '{material.name}'"); + } + break; + } + + // Restore texture and properties + if (mainTex != null) + { + material.mainTexture = mainTex; + material.mainTextureScale = textureScale; + material.mainTextureOffset = textureOffset; + } + if (material.HasProperty("_Color")) + { + material.color = color; + } + } + + /// + /// Convert material to unlit shader (Quest Home optimization) + /// + /// Material to convert + public static void ConvertToUnlit(Material material) + { + if (material == null) return; + + // Try to find Unlit/Texture shader + Shader unlitShader = Shader.Find("Unlit/Texture"); + if (unlitShader == null) + { + // Fallback to standard unlit + unlitShader = Shader.Find("Unlit/Color"); + } + + if (unlitShader != null) + { + // Store current texture + Texture mainTex = material.mainTexture; + Color color = material.HasProperty("_Color") ? material.color : Color.white; + + // Switch shader + material.shader = unlitShader; + + // Restore texture and color + if (mainTex != null) + { + material.mainTexture = mainTex; + } + if (material.HasProperty("_Color")) + { + material.color = color; + } + } + else + { + Debug.LogWarning("Unlit shader not found, keeping original shader"); + } + } + + /// + /// Convert all materials in model to unlit shaders + /// + /// Root GameObject + public static void ConvertAllToUnlit(GameObject model) + { + if (model == null) return; + + var renderers = model.GetComponentsInChildren(true); + int converted = 0; + int skipped = 0; + + foreach (var renderer in renderers) + { + foreach (var material in renderer.sharedMaterials) + { + if (material != null) + { + // Skip materials that already have alpha-aware unlit shaders + string shaderName = material.shader.name; + if (shaderName.StartsWith("Unlit/", System.StringComparison.OrdinalIgnoreCase)) + { + // Material already has an unlit shader (Texture, Transparent, Transparent Cutout, etc.) + skipped++; + continue; + } + + ConvertToUnlit(material); + converted++; + } + } + } + + Debug.Log($"Converted {converted} materials to unlit shaders ({skipped} already had unlit shaders)"); + } + + /// + /// Check if material is opaque (for collider generation) + /// + /// Material to check + /// True if material is opaque + public static bool IsOpaqueMaterial(Material material) + { + if (material == null) return false; + + // Check if material is transparent + if (material.HasProperty("_Mode")) + { + float mode = material.GetFloat("_Mode"); + if (mode == 3) return false; // Transparent mode + } + + // Check alpha + if (material.HasProperty("_Color")) + { + Color color = material.color; + if (color.a < 1.0f) return false; + } + + // Check render queue (transparent materials typically use queue > 2500) + if (material.renderQueue > 2500) return false; + + return true; + } + } +} diff --git a/Runtime/Scripts/QuestHome/GLTFMaterialMapper.cs.meta b/Runtime/Scripts/QuestHome/GLTFMaterialMapper.cs.meta new file mode 100644 index 00000000..c16ec7c0 --- /dev/null +++ b/Runtime/Scripts/QuestHome/GLTFMaterialMapper.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c1866da4d1416024f97ecf3545ddf80b \ No newline at end of file diff --git a/Runtime/Scripts/QuestHome/KTXParser.cs b/Runtime/Scripts/QuestHome/KTXParser.cs new file mode 100644 index 00000000..8abdcedb --- /dev/null +++ b/Runtime/Scripts/QuestHome/KTXParser.cs @@ -0,0 +1,190 @@ +using System; +using System.IO; +using System.Text; +using UnityEngine; + +namespace Banter.SDK +{ + /// + /// Struct containing parsed KTX texture header information + /// + public struct KTXHeader + { + public uint width; + public uint height; + public uint depth; + public uint glInternalFormat; + public uint dataOffset; + public uint dataSize; + } + + /// + /// Parser for KTX texture files, specifically for ASTC-compressed Quest Home textures + /// KTX format specification: https://www.khronos.org/opengles/sdk/tools/KTX/file_format_spec/ + /// + public static class KTXParser + { + // KTX file identifier + private static readonly byte[] KTX_IDENTIFIER = new byte[] + { + 0xAB, 0x4B, 0x54, 0x58, 0x20, 0x31, 0x31, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A + }; + + /// + /// Parse KTX file header to extract texture metadata + /// + /// Raw KTX file data + /// Parsed KTX header information + public static KTXHeader ParseHeader(byte[] ktxData) + { + if (ktxData == null || ktxData.Length < 64) + { + throw new ArgumentException("Invalid KTX data: file too small"); + } + + // Verify KTX identifier + for (int i = 0; i < KTX_IDENTIFIER.Length; i++) + { + if (ktxData[i] != KTX_IDENTIFIER[i]) + { + throw new FormatException("Invalid KTX file identifier"); + } + } + + using (var stream = new MemoryStream(ktxData)) + using (var reader = new BinaryReader(stream)) + { + // Skip identifier (12 bytes) + stream.Position = 12; + + // Read endianness (4 bytes) - 0x04030201 for little-endian + uint endianness = reader.ReadUInt32(); + bool isLittleEndian = (endianness == 0x04030201); + + if (!isLittleEndian) + { + throw new NotSupportedException("Big-endian KTX files are not supported"); + } + + // Read header fields + uint glType = reader.ReadUInt32(); // 16 + uint glTypeSize = reader.ReadUInt32(); // 20 + uint glFormat = reader.ReadUInt32(); // 24 + uint glInternalFormat = reader.ReadUInt32(); // 28 + uint glBaseInternalFormat = reader.ReadUInt32(); // 32 + uint pixelWidth = reader.ReadUInt32(); // 36 + uint pixelHeight = reader.ReadUInt32(); // 40 + uint pixelDepth = reader.ReadUInt32(); // 44 + uint numberOfArrayElements = reader.ReadUInt32(); // 48 + uint numberOfFaces = reader.ReadUInt32(); // 52 + uint numberOfMipmapLevels = reader.ReadUInt32(); // 56 + uint bytesOfKeyValueData = reader.ReadUInt32(); // 60 + + // Skip key-value data + stream.Position += bytesOfKeyValueData; + + // Read image size + uint imageSize = reader.ReadUInt32(); + + return new KTXHeader + { + width = pixelWidth, + height = pixelHeight, + depth = pixelDepth, + glInternalFormat = glInternalFormat, + dataOffset = (uint)stream.Position, + dataSize = imageSize + }; + } + } + + /// + /// Convert OpenGL internal format constant to Unity TextureFormat + /// ASTC format constants from: https://www.khronos.org/registry/OpenGL/extensions/KHR/KHR_texture_compression_astc_hdr.txt + /// Note: Unity only supports square and certain rectangular ASTC block sizes + /// + /// OpenGL internal format constant + /// Corresponding Unity TextureFormat + public static TextureFormat GetTextureFormat(uint glInternalFormat) + { + switch (glInternalFormat) + { + // ASTC formats supported by Unity + case 0x93B0: return TextureFormat.ASTC_4x4; + case 0x93B2: return TextureFormat.ASTC_5x5; + case 0x93B4: return TextureFormat.ASTC_6x6; + case 0x93B7: return TextureFormat.ASTC_8x8; + case 0x93BB: return TextureFormat.ASTC_10x10; + case 0x93BD: return TextureFormat.ASTC_12x12; + + // ASTC formats NOT supported by Unity (throw informative error) + case 0x93B1: throw new NotSupportedException($"ASTC 5x4 format (0x93B1) is not supported by Unity. Supported formats: 4x4, 5x5, 6x6, 8x8, 10x10, 12x12"); + case 0x93B3: throw new NotSupportedException($"ASTC 6x5 format (0x93B3) is not supported by Unity. Supported formats: 4x4, 5x5, 6x6, 8x8, 10x10, 12x12"); + case 0x93B5: throw new NotSupportedException($"ASTC 8x5 format (0x93B5) is not supported by Unity. Supported formats: 4x4, 5x5, 6x6, 8x8, 10x10, 12x12"); + case 0x93B6: throw new NotSupportedException($"ASTC 8x6 format (0x93B6) is not supported by Unity. Supported formats: 4x4, 5x5, 6x6, 8x8, 10x10, 12x12"); + case 0x93B8: throw new NotSupportedException($"ASTC 10x5 format (0x93B8) is not supported by Unity. Supported formats: 4x4, 5x5, 6x6, 8x8, 10x10, 12x12"); + case 0x93B9: throw new NotSupportedException($"ASTC 10x6 format (0x93B9) is not supported by Unity. Supported formats: 4x4, 5x5, 6x6, 8x8, 10x10, 12x12"); + case 0x93BA: throw new NotSupportedException($"ASTC 10x8 format (0x93BA) is not supported by Unity. Supported formats: 4x4, 5x5, 6x6, 8x8, 10x10, 12x12"); + case 0x93BC: throw new NotSupportedException($"ASTC 12x10 format (0x93BC) is not supported by Unity. Supported formats: 4x4, 5x5, 6x6, 8x8, 10x10, 12x12"); + + default: + throw new NotSupportedException($"Unsupported GL internal format: 0x{glInternalFormat:X}. Only ASTC formats are supported, and Unity only supports: 4x4, 5x5, 6x6, 8x8, 10x10, 12x12"); + } + } + + /// + /// Extract compressed texture data from KTX file + /// + /// Raw KTX file data + /// Parsed KTX header + /// Raw compressed texture data + public static byte[] ExtractTextureData(byte[] ktxData, KTXHeader header) + { + if (header.dataOffset + header.dataSize > ktxData.Length) + { + throw new ArgumentException("Invalid KTX data: texture data extends beyond file size"); + } + + byte[] textureData = new byte[header.dataSize]; + Array.Copy(ktxData, header.dataOffset, textureData, 0, header.dataSize); + return textureData; + } + + /// + /// Load ASTC texture from KTX file data + /// + /// Raw KTX file data + /// Name for the created texture + /// Loaded Texture2D with ASTC compression + public static Texture2D LoadASTCTexture(byte[] ktxData, string textureName = "ASTCTexture") + { + try + { + var header = ParseHeader(ktxData); + TextureFormat format = GetTextureFormat(header.glInternalFormat); + byte[] textureData = ExtractTextureData(ktxData, header); + + // Create texture with ASTC format + Texture2D texture = new Texture2D( + (int)header.width, + (int)header.height, + format, + false // No mipmaps + ); + + texture.name = textureName; + + // Load compressed data directly (Unity will decompress on GPU) + texture.LoadRawTextureData(textureData); + texture.Apply(false, true); // uploadToGPU=false, makeNoLongerReadable=true + + return texture; + } + catch (Exception ex) + { + Debug.LogError($"Failed to load ASTC texture from KTX data: {ex.Message}"); + throw; + } + } + } +} diff --git a/Runtime/Scripts/QuestHome/KTXParser.cs.meta b/Runtime/Scripts/QuestHome/KTXParser.cs.meta new file mode 100644 index 00000000..1b832f1b --- /dev/null +++ b/Runtime/Scripts/QuestHome/KTXParser.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c90dd1e1bb805a344bf2ba8c3ca89ffc \ No newline at end of file diff --git a/Runtime/Scripts/QuestHome/QuestHomeLoader.cs b/Runtime/Scripts/QuestHome/QuestHomeLoader.cs new file mode 100644 index 00000000..2ab19b15 --- /dev/null +++ b/Runtime/Scripts/QuestHome/QuestHomeLoader.cs @@ -0,0 +1,188 @@ +using UnityEngine; + +namespace Banter.SDK +{ + /// + /// Simple helper script to load Quest Home environments with minimal setup. + /// Just attach to a GameObject, set the APK URL in the Inspector, and it loads automatically on Start. + /// + [AddComponentMenu("Banter/Quest Home Loader")] + public class QuestHomeLoader : MonoBehaviour + { + [Header("Quest Home Settings")] + [Tooltip("SideQuest listing URL (e.g., https://sidequestvr.com/app/1234/...) or direct APK URL (e.g., https://cdn.sidequestvr.com/file/1234/app.apk)")] + public string apkUrl = "https://sidequestvr.com/app/167567/canyon-environment"; + + [Tooltip("Automatically generate colliders on opaque meshes")] + public bool addColliders = true; + + [Tooltip("Convert materials to unlit shaders for Quest optimization")] + public bool legacyShaderFix = true; + + [Space(10)] + [Tooltip("Load the Quest Home automatically when Start() is called")] + public bool loadOnStart = true; + + [Header("Status (Read Only)")] + [Tooltip("Current loading status")] + [SerializeField] private string status = "Not loaded"; + + private GameObject questHomeObject; + private BanterQuestHome questHomeComponent; + private bool isLoading = false; + + /// + /// Unity Start - Automatically load Quest Home if loadOnStart is enabled + /// + void Start() + { + if (loadOnStart) + { + LoadQuestHome(); + } + } + + /// + /// Load the Quest Home from the specified APK URL + /// Can be called manually or automatically on Start + /// + public void LoadQuestHome() + { + if (isLoading) + { + Debug.LogWarning("[QuestHomeLoader] Already loading a Quest Home, please wait..."); + return; + } + + if (string.IsNullOrEmpty(apkUrl)) + { + Debug.LogError("[QuestHomeLoader] APK URL is not set! Please set the URL in the Inspector."); + status = "Error: No URL"; + return; + } + + if (!apkUrl.StartsWith("http")) + { + Debug.LogError($"[QuestHomeLoader] Invalid APK URL: {apkUrl}. URL must start with http:// or https://"); + status = "Error: Invalid URL"; + return; + } + + Debug.Log($"[QuestHomeLoader] Loading Quest Home from: {apkUrl}"); + status = "Loading..."; + isLoading = true; + + // Create a child GameObject to hold the Quest Home + questHomeObject = new GameObject("QuestHome_" + System.DateTime.Now.Ticks); + questHomeObject.transform.SetParent(transform); + questHomeObject.transform.localPosition = Vector3.zero; + questHomeObject.transform.localRotation = Quaternion.identity; + questHomeObject.transform.localScale = Vector3.one; + + // Add the BanterQuestHome component + questHomeComponent = questHomeObject.AddComponent(); + + // Configure the component using the public C# API + questHomeComponent.Url = apkUrl; + questHomeComponent.AddColliders = addColliders; + questHomeComponent.LegacyShaderFix = legacyShaderFix; + + // Monitor for completion (check the _loaded field via reflection or wait) + StartCoroutine(MonitorLoadStatus()); + + Debug.Log("[QuestHomeLoader] Quest Home component added and loading started"); + } + + /// + /// Monitor the loading status and update the status field + /// + private System.Collections.IEnumerator MonitorLoadStatus() + { + float startTime = Time.time; + float timeout = 120f; // 2 minute timeout + + while (isLoading) + { + // Check if component was destroyed + if (questHomeComponent == null) + { + status = "Error: Component destroyed"; + isLoading = false; + yield break; + } + + // Check for timeout + if (Time.time - startTime > timeout) + { + status = "Error: Load timeout"; + isLoading = false; + Debug.LogError("[QuestHomeLoader] Quest Home load timed out after 2 minutes"); + yield break; + } + + // Update status + float elapsed = Time.time - startTime; + status = $"Loading... ({elapsed:F0}s)"; + + // Wait a bit before checking again + yield return new WaitForSeconds(0.5f); + + // Check if loaded (we can check if the component is still active and loading hasn't failed) + // For now, just check if it's been a reasonable time and no errors + if (elapsed > 5f && questHomeObject != null && questHomeObject.transform.childCount > 0) + { + // Looks like something loaded + status = $"Loaded ({elapsed:F0}s)"; + isLoading = false; + Debug.Log($"[QuestHomeLoader] Quest Home appears to have loaded successfully in {elapsed:F1} seconds"); + yield break; + } + } + } + + /// + /// Unload the current Quest Home + /// + public void UnloadQuestHome() + { + if (questHomeObject != null) + { + Debug.Log("[QuestHomeLoader] Unloading Quest Home"); + Destroy(questHomeObject); + questHomeObject = null; + questHomeComponent = null; + status = "Unloaded"; + isLoading = false; + } + } + + /// + /// Reload the Quest Home (unload then load again) + /// + public void ReloadQuestHome() + { + UnloadQuestHome(); + LoadQuestHome(); + } + + /// + /// Unity Editor - Draw gizmo to show the loader position + /// + void OnDrawGizmos() + { + Gizmos.color = Color.cyan; + Gizmos.DrawWireSphere(transform.position, 0.5f); + } + + /// + /// Cleanup when destroyed + /// + void OnDestroy() + { + if (questHomeObject != null) + { + Destroy(questHomeObject); + } + } + } +} diff --git a/Runtime/Scripts/QuestHome/QuestHomeLoader.cs.meta b/Runtime/Scripts/QuestHome/QuestHomeLoader.cs.meta new file mode 100644 index 00000000..85378398 --- /dev/null +++ b/Runtime/Scripts/QuestHome/QuestHomeLoader.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b07dd133baed6974b8aebecef32fec11 \ No newline at end of file diff --git a/Runtime/Scripts/QuestHome/README.md b/Runtime/Scripts/QuestHome/README.md new file mode 100644 index 00000000..0cb7bb43 --- /dev/null +++ b/Runtime/Scripts/QuestHome/README.md @@ -0,0 +1,98 @@ +# Quest Home Loader + +This folder contains all the scripts necessary for loading Quest Home environments from SideQuest APK files. + +## Components + +### Core Component +- **BanterQuestHome.cs** - Main component that orchestrates the Quest Home loading pipeline + +### Helper Script +- **QuestHomeLoader.cs** - Simple MonoBehaviour helper for easy Unity Editor usage (drag-and-drop) + +### Helper Classes +- **APKExtractor.cs** - Handles nested ZIP extraction (APK → scene.zip → .ovrscene → GLTF files) +- **KTXParser.cs** - Parses KTX texture files and extracts ASTC compressed texture data +- **GLTFMaterialMapper.cs** - Maps GLTF textures to Unity materials and handles material conversion + +## Usage + +### Unity Editor (Easiest!) +``` +1. Create an empty GameObject in your scene +2. Add Component → Banter → Quest Home Loader +3. Set the APK URL in the Inspector +4. Press Play - loads automatically! +``` + +**Inspector Properties:** +- **APK Url** - URL of the Quest Home APK from SideQuest +- **Add Colliders** - Auto-generate colliders on opaque meshes +- **Legacy Shader Fix** - Convert to unlit shaders for Quest +- **Load On Start** - Auto-load when scene starts + +**Public Methods:** +```csharp +loader.LoadQuestHome(); // Load manually +loader.UnloadQuestHome(); // Unload current home +loader.ReloadQuestHome(); // Reload (unload + load) +``` + +### A-Frame +```html + +``` + +### JavaScript SDK +```javascript +const go = new BS.GameObject("CustomHome"); +const questHome = await go.AddComponent( + new BS.BanterQuestHome( + "https://cdn.sidequestvr.com/file/167567/canyon_environment.apk", + true, // addColliders + true // legacyShaderFix + ) +); +``` + +## Loading Pipeline + +1. **Download APK** - Downloads APK from SideQuest CDN with progress tracking +2. **Extract Files** - Extracts nested ZIP archives to get GLTF, textures, and audio +3. **Load Textures** - Loads ASTC-compressed textures using Unity's native support +4. **Parse GLTF** - Parses GLTF JSON to extract material-texture mappings +5. **Load Model** - Loads GLTF model using GLTFUtility +6. **Apply Textures** - Applies loaded textures to Unity materials +7. **Convert Shaders** - Converts materials to unlit shaders (Quest optimization) +8. **Generate Colliders** - Auto-generates MeshColliders on opaque meshes +9. **Setup Audio** - Loads background audio if present in APK + +## Technical Details + +- Uses Unity's native ASTC texture decompression (no external decoder needed) +- Supports 6 ASTC formats that Unity supports: 4x4, 5x5, 6x6, 8x8, 10x10, 12x12 +- Non-square formats (5x4, 6x5, 8x5, 8x6, 10x5, 10x6, 10x8, 12x10) are not supported by Unity +- Uses System.IO.Compression for ZIP extraction +- Integrates with existing GLTFUtility package +- Full async/await support for non-blocking loading + +## APK Structure + +``` +CustomHome.apk/ +├── assets/ +│ └── scene.zip +│ ├── _WORLD_MODEL.gltf.ovrscene +│ │ ├── scene.gltf +│ │ ├── scene.bin +│ │ ├── texture0.ktx +│ │ ├── texture1.ktx +│ │ └── ... +│ └── _BACKGROUND_LOOP.ogg +``` + +## Dependencies + +- **GLTFUtility** (Siccity.GLTFUtility) - Already included in Banter SDK +- **System.IO.Compression** - Built-in .NET library +- **Newtonsoft.Json** - Already included in Banter SDK diff --git a/Runtime/Scripts/QuestHome/README.md.meta b/Runtime/Scripts/QuestHome/README.md.meta new file mode 100644 index 00000000..4b1a1a1e --- /dev/null +++ b/Runtime/Scripts/QuestHome/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3fdec53813c61bb4587b486f2a4e0b63 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Scripts/QuestHome/SideQuestUrlResolver.cs b/Runtime/Scripts/QuestHome/SideQuestUrlResolver.cs new file mode 100644 index 00000000..a79dd502 --- /dev/null +++ b/Runtime/Scripts/QuestHome/SideQuestUrlResolver.cs @@ -0,0 +1,227 @@ +using System; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using UnityEngine; +using UnityEngine.Networking; +using Newtonsoft.Json.Linq; + +namespace Banter.SDK +{ + /// + /// Utility class for resolving SideQuest listing URLs to direct APK URLs. + /// Works cross-platform on PC, Quest (Android), and other Unity platforms. + /// + public static class SideQuestUrlResolver + { + private const string SIDEQUEST_API_BASE = "https://api.sidequestvr.com/v2"; + private const string SIDEQUEST_CDN_BASE = "https://cdn.sidequestvr.com/file"; + + /// + /// Resolves a URL to a direct APK download URL. + /// Handles both direct APK URLs (returns as-is) and SideQuest listing URLs (extracts APK URL via API). + /// + /// SideQuest listing URL or direct APK URL + /// Direct APK download URL + public static async Task ResolveApkUrl(string inputUrl) + { + if (string.IsNullOrWhiteSpace(inputUrl)) + { + throw new ArgumentException("URL cannot be empty"); + } + + // Normalize URL (trim whitespace) + inputUrl = inputUrl.Trim(); + + // Check if already a direct APK URL + if (IsDirectApkUrl(inputUrl)) + { + LogLine.Do("Direct APK URL detected, using as-is"); + return inputUrl; + } + + // Check if SideQuest listing URL + if (IsListingUrl(inputUrl)) + { + LogLine.Do("SideQuest listing URL detected, extracting APK URL via API..."); + string appId = ExtractAppIdFromUrl(inputUrl); + LogLine.Do($"Extracted app ID: {appId}"); + + string apkUrl = await FetchApkUrlFromApi(appId); + LogLine.Do($"Resolved to APK URL: {apkUrl}"); + return apkUrl; + } + + throw new Exception($"Invalid URL format. Must be either:\n" + + $"- SideQuest listing URL (e.g., https://sidequestvr.com/app/1234/...)\n" + + $"- Direct APK URL (e.g., https://cdn.sidequestvr.com/file/1234/app.apk)\n" + + $"Got: {inputUrl}"); + } + + /// + /// Checks if the URL is a direct APK download URL + /// + public static bool IsDirectApkUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return false; + + url = url.ToLowerInvariant(); + return url.EndsWith(".apk") && url.Contains("cdn.sidequestvr.com"); + } + + /// + /// Checks if the URL is a SideQuest listing page URL + /// + public static bool IsListingUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return false; + + // Match: https://sidequestvr.com/app/1234/... + // or: http://sidequestvr.com/app/1234/... + // or: sidequestvr.com/app/1234/... + return Regex.IsMatch(url, @"sidequestvr\.com/app/\d+", RegexOptions.IgnoreCase); + } + + /// + /// Extracts the numeric app ID from a SideQuest listing URL + /// + /// SideQuest listing URL + /// App ID as string + public static string ExtractAppIdFromUrl(string url) + { + // Match: https://sidequestvr.com/app/1234/app-name + // Extract: 1234 + var match = Regex.Match(url, @"sidequestvr\.com/app/(\d+)", RegexOptions.IgnoreCase); + + if (!match.Success) + { + throw new Exception($"Could not extract app ID from URL: {url}"); + } + + return match.Groups[1].Value; + } + + /// + /// Fetches APK URL from SideQuest API using app ID + /// + /// SideQuest app ID + /// Direct APK download URL + public static async Task FetchApkUrlFromApi(string appId) + { + string apiUrl = $"{SIDEQUEST_API_BASE}/apps/{appId}"; + LogLine.Do($"Fetching app data from: {apiUrl}"); + + using (UnityWebRequest request = UnityWebRequest.Get(apiUrl)) + { + // Set a reasonable timeout (10 seconds) + request.timeout = 10; + + // Send request + var operation = request.SendWebRequest(); + + // Wait for completion (async) + while (!operation.isDone) + { + await Task.Yield(); + } + + // Check for errors + if (request.result != UnityWebRequest.Result.Success) + { + string error = request.error; + if (request.responseCode == 404) + { + throw new Exception($"App not found on SideQuest (ID: {appId}). Check the URL and try again."); + } + else if (request.responseCode >= 500) + { + throw new Exception($"SideQuest API server error ({request.responseCode}). Try again later."); + } + else + { + throw new Exception($"Failed to fetch app data from SideQuest API: {error} (Code: {request.responseCode})"); + } + } + + // Parse JSON response + string jsonResponse = request.downloadHandler.text; + LogLine.Do($"API response received ({jsonResponse.Length} bytes)"); + + return ParseApkUrlFromApiResponse(jsonResponse, appId); + } + } + + /// + /// Parses SideQuest API JSON response to extract APK download URL + /// + /// JSON response from SideQuest API + /// App ID (for error messages) + /// Direct APK download URL + public static string ParseApkUrlFromApiResponse(string jsonResponse, string appId) + { + try + { + JObject json = JObject.Parse(jsonResponse); + + // Get app name for constructing filename + string appName = json["name"]?.ToString() ?? "app"; + appName = SanitizeFilename(appName); + + // Look for app_release_files array + var releaseFiles = json["app_release_files"] as JArray; + + if (releaseFiles == null || releaseFiles.Count == 0) + { + throw new Exception($"No release files found for app ID {appId}. The app may not have any published versions."); + } + + // Find APK file in release files + foreach (var file in releaseFiles) + { + string fileType = file["type"]?.ToString(); + + if (fileType == "apk") + { + string filesId = file["files_id"]?.ToString(); + + if (string.IsNullOrEmpty(filesId)) + { + LogLine.Do("Found APK entry but files_id is empty, skipping..."); + continue; + } + + // Construct CDN URL + string apkUrl = $"{SIDEQUEST_CDN_BASE}/{filesId}/{appName}.apk"; + LogLine.Do($"Constructed APK URL from files_id: {filesId}"); + return apkUrl; + } + } + + // No APK file found + throw new Exception($"No APK file found for app ID {appId}. The app may not be a Quest Home or may not have an APK release."); + } + catch (Exception ex) when (ex is Newtonsoft.Json.JsonException) + { + throw new Exception($"Failed to parse SideQuest API response: {ex.Message}"); + } + } + + /// + /// Sanitizes a filename for use in URLs + /// + private static string SanitizeFilename(string filename) + { + // Replace spaces with underscores + filename = filename.Replace(" ", "_"); + + // Convert to lowercase + filename = filename.ToLowerInvariant(); + + // Remove special characters (keep only alphanumeric, underscore, dash) + filename = Regex.Replace(filename, @"[^a-z0-9_\-]", ""); + + return filename; + } + } +} diff --git a/Runtime/Scripts/Scene/BanterComponent/BanterComponentNames.cs b/Runtime/Scripts/Scene/BanterComponent/BanterComponentNames.cs index f7e1dada..37c24f5d 100644 --- a/Runtime/Scripts/Scene/BanterComponent/BanterComponentNames.cs +++ b/Runtime/Scripts/Scene/BanterComponent/BanterComponentNames.cs @@ -62,5 +62,6 @@ public enum ComponentType BanterUIPanel, BanterVideoPlayer, BanterWorldObject, + BanterQuestHome, } } diff --git a/VisualScripting/Space/LoadQuestHome.cs b/VisualScripting/Space/LoadQuestHome.cs new file mode 100644 index 00000000..283eaf15 --- /dev/null +++ b/VisualScripting/Space/LoadQuestHome.cs @@ -0,0 +1,67 @@ +#if BANTER_VISUAL_SCRIPTING +using UnityEngine; +using Unity.VisualScripting; +using Banter.SDK; +using Banter.Utilities.Async; + +namespace Banter.VisualScripting +{ + [UnitTitle("Load Quest Home")] + [UnitShortTitle("Load Quest Home")] + [UnitCategory("Banter\\Space")] + [TypeIcon(typeof(BanterObjectId))] + public class LoadQuestHome : Unit + { + [DoNotSerialize] + public ControlInput inputTrigger; + + [DoNotSerialize] + public ControlOutput outputTrigger; + + [DoNotSerialize] + public ValueInput url; + + [DoNotSerialize] + public ValueInput addColliders; + + [DoNotSerialize] + public ValueInput legacyShaderFix; + + [DoNotSerialize] + public ValueOutput questHomeObject; + + protected override void Definition() + { + inputTrigger = ControlInput("", (flow) => { + var _url = flow.GetValue(url); + var _addColliders = flow.GetValue(addColliders); + var _legacyShaderFix = flow.GetValue(legacyShaderFix); + + UnityMainThreadTaskScheduler.Default.Enqueue(TaskRunner.Track(() => { + // Create a new GameObject with BanterQuestHome component + GameObject questHomeGo = new GameObject("QuestHome"); + var questHomeComponent = questHomeGo.AddComponent(); + + // Set properties + questHomeComponent.Url = _url; + questHomeComponent.AddColliders = _addColliders; + questHomeComponent.LegacyShaderFix = _legacyShaderFix; + + // Store the GameObject for output + flow.SetValue(questHomeObject, questHomeGo); + + Debug.Log($"LoadQuestHome: Started loading Quest Home from {_url}"); + }, $"{nameof(LoadQuestHome)}.{nameof(Definition)}")); + + return outputTrigger; + }); + + outputTrigger = ControlOutput(""); + url = ValueInput("URL", "https://sidequestvr.com/app/167567/canyon-environment"); + addColliders = ValueInput("Add Colliders", true); + legacyShaderFix = ValueInput("Legacy Shader Fix", true); + questHomeObject = ValueOutput("Quest Home Object"); + } + } +} +#endif diff --git a/VisualScripting/Space/LoadQuestHome.cs.meta b/VisualScripting/Space/LoadQuestHome.cs.meta new file mode 100644 index 00000000..e826a1bc --- /dev/null +++ b/VisualScripting/Space/LoadQuestHome.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 72a55fde8c065e24db2fa91a71c65dfa \ No newline at end of file diff --git a/VisualScripting/Space/UnloadQuestHome.cs b/VisualScripting/Space/UnloadQuestHome.cs new file mode 100644 index 00000000..47b2ffa0 --- /dev/null +++ b/VisualScripting/Space/UnloadQuestHome.cs @@ -0,0 +1,50 @@ +#if BANTER_VISUAL_SCRIPTING +using UnityEngine; +using Unity.VisualScripting; +using Banter.SDK; +using Banter.Utilities.Async; + +namespace Banter.VisualScripting +{ + [UnitTitle("Unload Quest Home")] + [UnitShortTitle("Unload Quest Home")] + [UnitCategory("Banter\\Space")] + [TypeIcon(typeof(BanterObjectId))] + public class UnloadQuestHome : Unit + { + [DoNotSerialize] + public ControlInput inputTrigger; + + [DoNotSerialize] + public ControlOutput outputTrigger; + + [DoNotSerialize] + public ValueInput questHomeObject; + + protected override void Definition() + { + inputTrigger = ControlInput("", (flow) => { + var _questHomeObject = flow.GetValue(questHomeObject); + + UnityMainThreadTaskScheduler.Default.Enqueue(TaskRunner.Track(() => { + if (_questHomeObject != null) + { + // Destroy the Quest Home GameObject + Object.Destroy(_questHomeObject); + Debug.Log("UnloadQuestHome: Quest Home unloaded"); + } + else + { + Debug.LogWarning("UnloadQuestHome: Quest Home object is null"); + } + }, $"{nameof(UnloadQuestHome)}.{nameof(Definition)}")); + + return outputTrigger; + }); + + outputTrigger = ControlOutput(""); + questHomeObject = ValueInput("Quest Home Object", null); + } + } +} +#endif diff --git a/VisualScripting/Space/UnloadQuestHome.cs.meta b/VisualScripting/Space/UnloadQuestHome.cs.meta new file mode 100644 index 00000000..c54fa59b --- /dev/null +++ b/VisualScripting/Space/UnloadQuestHome.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 79ed670bc35b962419ab32055103f54e \ No newline at end of file From a2bc87e691d27f1c17ae3bcbb370d0770a7bbf29 Mon Sep 17 00:00:00 2001 From: Elin <37795467+Ladypoly@users.noreply.github.com> Date: Thu, 30 Oct 2025 18:26:39 +0100 Subject: [PATCH 2/3] Improve Quest Home loading, add editor and VS nodes Adds BanterQuestHomeEditor for custom inspector UI and a Gizmo icon. Improves APK download and extraction with better error handling, retry logic, and ZIP validation. Expands SideQuestUrlResolver to support more API response formats and robustly extract APK URLs. Updates Visual Scripting nodes for synchronous GameObject creation/destruction and adds GetMenuBrowserUrl node for menu browser URL access. --- Editor/Components/BanterQuestHomeEditor.cs | 51 ++++ .../Components/BanterQuestHomeEditor.cs.meta | 2 + Gizmos/BanterQuestHome Icon.png | Bin 0 -> 28904 bytes Gizmos/BanterQuestHome Icon.png.meta | 143 ++++++++++ Runtime/Scripts/QuestHome/APKExtractor.cs | 61 +++-- Runtime/Scripts/QuestHome/BanterQuestHome.cs | 110 ++++++-- .../Scripts/QuestHome/SideQuestUrlResolver.cs | 254 ++++++++++++++++-- .../QuestHome/SideQuestUrlResolver.cs.meta | 2 + VisualScripting/Space/LoadQuestHome.cs | 24 +- VisualScripting/Space/UnloadQuestHome.cs | 25 +- VisualScripting/Utils/GetMenuBrowserUrl.cs | 88 ++++++ .../Utils/GetMenuBrowserUrl.cs.meta | 2 + 12 files changed, 684 insertions(+), 78 deletions(-) create mode 100644 Editor/Components/BanterQuestHomeEditor.cs create mode 100644 Editor/Components/BanterQuestHomeEditor.cs.meta create mode 100644 Gizmos/BanterQuestHome Icon.png create mode 100644 Gizmos/BanterQuestHome Icon.png.meta create mode 100644 Runtime/Scripts/QuestHome/SideQuestUrlResolver.cs.meta create mode 100644 VisualScripting/Utils/GetMenuBrowserUrl.cs create mode 100644 VisualScripting/Utils/GetMenuBrowserUrl.cs.meta diff --git a/Editor/Components/BanterQuestHomeEditor.cs b/Editor/Components/BanterQuestHomeEditor.cs new file mode 100644 index 00000000..bb566c8f --- /dev/null +++ b/Editor/Components/BanterQuestHomeEditor.cs @@ -0,0 +1,51 @@ +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; +using Banter.SDK; + +namespace Banter.SDKEditor +{ + [CustomEditor(typeof(BanterQuestHome))] + public class BanterQuestHomeEditor : Editor + { + void OnEnable() + { + if (target is BanterQuestHome) + { + var script = (BanterQuestHome)target; + var path = AssetDatabase.GetAssetPath(script); + } + } + public override bool UseDefaultMargins() => false; + public override VisualElement CreateInspectorGUI() + { + var script = (BanterQuestHome)target; + Editor editor = Editor.CreateEditor(script); + VisualElement myInspector = new VisualElement(); + + var _mainWindowStyleSheet = Resources.Load("BanterCustomInspector"); + myInspector.styleSheets.Add(_mainWindowStyleSheet); + + var title = new Label("PROPERTIES SEEN BY JS"); + title.style.fontSize = 14; + myInspector.Add(title); + var seeFields = new Label("url, addColliders, legacyShaderFix, "); + seeFields.style.unityFontStyleAndWeight = FontStyle.Bold; + seeFields.style.flexWrap = Wrap.Wrap; + seeFields.style.whiteSpace = WhiteSpace.Normal; + seeFields.style.marginBottom = 10; + seeFields.style.marginTop = 10; + seeFields.style.color = Color.gray; + myInspector.Add(seeFields); + + var foldout = new Foldout(); + foldout.text = "Available Properties"; + IMGUIContainer inspectorIMGUI = new IMGUIContainer(() => { editor.OnInspectorGUI(); }); + foldout.value = false; + foldout.Add(inspectorIMGUI); + myInspector.Add(foldout); + + return myInspector; + } + } +} diff --git a/Editor/Components/BanterQuestHomeEditor.cs.meta b/Editor/Components/BanterQuestHomeEditor.cs.meta new file mode 100644 index 00000000..b25e832b --- /dev/null +++ b/Editor/Components/BanterQuestHomeEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 30399390f4638de43b96ecbd4e4a3149 \ No newline at end of file diff --git a/Gizmos/BanterQuestHome Icon.png b/Gizmos/BanterQuestHome Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a1f36320a9136c07f5e655cd88b6012269cc8f1b GIT binary patch literal 28904 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 Hu0mjfSrTZ) literal 0 HcmV?d00001 diff --git a/Gizmos/BanterQuestHome Icon.png.meta b/Gizmos/BanterQuestHome Icon.png.meta new file mode 100644 index 00000000..b6f7a289 --- /dev/null +++ b/Gizmos/BanterQuestHome Icon.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: bf0b8db33631351499b50a35412163c2 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 1 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 1 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Server + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: 5e97eb03825dee720800000000000000 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Scripts/QuestHome/APKExtractor.cs b/Runtime/Scripts/QuestHome/APKExtractor.cs index 22a770ee..733c839c 100644 --- a/Runtime/Scripts/QuestHome/APKExtractor.cs +++ b/Runtime/Scripts/QuestHome/APKExtractor.cs @@ -74,30 +74,59 @@ public static QuestHomeAssets ExtractAll(byte[] apkData) /// private static byte[] ExtractSceneZip(byte[] apkData) { - using (var stream = new MemoryStream(apkData)) - using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) + // Validate input data + if (apkData == null || apkData.Length == 0) + { + throw new ArgumentException("APK data is null or empty"); + } + + Debug.Log($"Extracting scene.zip from APK data ({apkData.Length} bytes, {apkData.Length / 1024.0 / 1024.0:F2} MB)"); + + // Validate ZIP signature + bool hasValidZipSignature = apkData.Length >= 4 && apkData[0] == 0x50 && apkData[1] == 0x4B; + if (!hasValidZipSignature) { - // Find assets/scene.zip - var sceneZipEntry = archive.Entries.FirstOrDefault(e => - e.FullName.Equals("assets/scene.zip", StringComparison.OrdinalIgnoreCase)); + throw new InvalidDataException( + $"APK data does not have valid ZIP signature. " + + $"First 4 bytes: {BitConverter.ToString(apkData, 0, Math.Min(4, apkData.Length))}"); + } - if (sceneZipEntry == null) + try + { + using (var stream = new MemoryStream(apkData)) + using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) { - Debug.LogError("assets/scene.zip not found in APK. Available entries:"); - foreach (var entry in archive.Entries.Take(20)) + // Find assets/scene.zip + var sceneZipEntry = archive.Entries.FirstOrDefault(e => + e.FullName.Equals("assets/scene.zip", StringComparison.OrdinalIgnoreCase)); + + if (sceneZipEntry == null) { - Debug.Log($" - {entry.FullName}"); + Debug.LogError("assets/scene.zip not found in APK. Available entries:"); + foreach (var entry in archive.Entries.Take(20)) + { + Debug.Log($" - {entry.FullName}"); + } + throw new FileNotFoundException("assets/scene.zip not found in APK"); } - throw new FileNotFoundException("assets/scene.zip not found in APK"); - } - using (var entryStream = sceneZipEntry.Open()) - using (var memStream = new MemoryStream()) - { - entryStream.CopyTo(memStream); - return memStream.ToArray(); + using (var entryStream = sceneZipEntry.Open()) + using (var memStream = new MemoryStream()) + { + entryStream.CopyTo(memStream); + return memStream.ToArray(); + } } } + catch (InvalidDataException ex) + { + // More specific error for ZIP corruption + throw new InvalidDataException( + $"APK data appears to be corrupted or incomplete. " + + $"Size: {apkData.Length} bytes. " + + $"ZIP signature valid: {hasValidZipSignature}. " + + $"Error: {ex.Message}", ex); + } } /// diff --git a/Runtime/Scripts/QuestHome/BanterQuestHome.cs b/Runtime/Scripts/QuestHome/BanterQuestHome.cs index c6967993..2650d6b2 100644 --- a/Runtime/Scripts/QuestHome/BanterQuestHome.cs +++ b/Runtime/Scripts/QuestHome/BanterQuestHome.cs @@ -73,6 +73,12 @@ async void LoadQuestHome() return; } + // Don't load if URL is empty + if (string.IsNullOrEmpty(url)) + { + return; + } + _loaded = false; loadStarted = true; @@ -189,32 +195,92 @@ async void LoadQuestHome() } /// - /// Download APK from URL with progress tracking + /// Download APK from URL with progress tracking and retry logic /// - private async Task DownloadAPK(string apkUrl) + private async Task DownloadAPK(string apkUrl, int maxRetries = 3) { - using (UnityWebRequest request = UnityWebRequest.Get(apkUrl)) - { - var operation = request.SendWebRequest(); + Exception lastException = null; - while (!operation.isDone) + for (int attempt = 1; attempt <= maxRetries; attempt++) + { + try { - float progress = request.downloadProgress; - // Log progress every 10% - if (progress > 0 && (int)(progress * 10) % 2 == 0) + if (attempt > 1) { - LogLine.Do($"Download progress: {progress * 100:F0}%"); + LogLine.Do($"Download attempt {attempt}/{maxRetries}..."); } - await Task.Yield(); - } - if (request.result != UnityWebRequest.Result.Success) - { - throw new Exception($"APK download failed: {request.error}"); + using (UnityWebRequest request = UnityWebRequest.Get(apkUrl)) + { + // Set timeout for large APKs (5 minutes) + request.timeout = 300; + + var operation = request.SendWebRequest(); + + while (!operation.isDone) + { + float progress = request.downloadProgress; + // Log progress every 10% + if (progress > 0 && (int)(progress * 10) % 2 == 0) + { + LogLine.Do($"Download progress: {progress * 100:F0}%"); + } + await Task.Yield(); + } + + if (request.result != UnityWebRequest.Result.Success) + { + throw new Exception($"APK download failed: {request.error}"); + } + + // Get data reference before disposal + byte[] data = request.downloadHandler.data; + if (data == null || data.Length == 0) + { + throw new Exception("Downloaded data is null or empty"); + } + + // CRITICAL FIX: Copy data before disposal to prevent corruption + byte[] dataCopy = new byte[data.Length]; + Buffer.BlockCopy(data, 0, dataCopy, 0, data.Length); + + LogLine.Do($"Download complete: {dataCopy.Length} bytes ({dataCopy.Length / 1024.0 / 1024.0:F2} MB)"); + + // Validate ZIP signature (PK header: 0x50 0x4B) + if (dataCopy.Length < 4 || dataCopy[0] != 0x50 || dataCopy[1] != 0x4B) + { + throw new Exception($"Downloaded data is not a valid ZIP/APK file. First 4 bytes: {BitConverter.ToString(dataCopy, 0, Math.Min(4, dataCopy.Length))}"); + } + + // Validate size matches Content-Length if available + string contentLength = request.GetResponseHeader("Content-Length"); + if (!string.IsNullOrEmpty(contentLength) && long.TryParse(contentLength, out long expectedSize)) + { + if (dataCopy.Length != expectedSize) + { + Debug.LogWarning($"Downloaded size mismatch: expected {expectedSize} bytes, got {dataCopy.Length} bytes"); + } + } + + return dataCopy; + } } + catch (Exception ex) + { + lastException = ex; + LogLine.Do($"Download attempt {attempt} failed: {ex.Message}"); - return request.downloadHandler.data; + if (attempt < maxRetries) + { + // Wait before retry (exponential backoff: 1s, 2s, 4s) + int delayMs = 1000 * (int)Math.Pow(2, attempt - 1); + LogLine.Do($"Retrying in {delayMs}ms..."); + await Task.Delay(delayMs); + } + } } + + throw new Exception($"Download failed after {maxRetries} attempts. Last error: {lastException?.Message}", lastException); } /// @@ -816,7 +882,11 @@ internal override void Init(List constructorProperties = null) if (alreadyStarted) { return; } alreadyStarted = true; - scene.RegisterBanterMonoscript(gameObject.GetInstanceID(), GetInstanceID(), ComponentType.BanterQuestHome); + // Only register with scene if link is available (not in standalone mode) + if (scene.link != null) + { + scene.RegisterBanterMonoscript(gameObject.GetInstanceID(), GetInstanceID(), ComponentType.BanterQuestHome); + } oid = gameObject.GetInstanceID(); cid = GetInstanceID(); @@ -826,7 +896,11 @@ internal override void Init(List constructorProperties = null) Deserialise(constructorProperties); } - SyncProperties(true); + // Only sync properties if link is available + if (scene.link != null) + { + SyncProperties(true); + } } /// diff --git a/Runtime/Scripts/QuestHome/SideQuestUrlResolver.cs b/Runtime/Scripts/QuestHome/SideQuestUrlResolver.cs index a79dd502..f4446fa0 100644 --- a/Runtime/Scripts/QuestHome/SideQuestUrlResolver.cs +++ b/Runtime/Scripts/QuestHome/SideQuestUrlResolver.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using UnityEngine; @@ -66,7 +67,8 @@ public static bool IsDirectApkUrl(string url) return false; url = url.ToLowerInvariant(); - return url.EndsWith(".apk") && url.Contains("cdn.sidequestvr.com"); + // Accept any .apk URL, not just CDN URLs + return url.EndsWith(".apk"); } /// @@ -164,42 +166,171 @@ public static string ParseApkUrlFromApiResponse(string jsonResponse, string appI { JObject json = JObject.Parse(jsonResponse); + // Debug: Log the JSON structure + LogLine.Do($"API Response Keys: {string.Join(", ", json.Properties().Select(p => p.Name))}"); + // Get app name for constructing filename string appName = json["name"]?.ToString() ?? "app"; appName = SanitizeFilename(appName); - // Look for app_release_files array + // Try multiple paths to find files_id + string filesId = null; + + // Method 1: Look for app_release_files array + LogLine.Do("Method 1: Checking app_release_files array..."); var releaseFiles = json["app_release_files"] as JArray; + if (releaseFiles != null && releaseFiles.Count > 0) + { + LogLine.Do($"Found app_release_files array with {releaseFiles.Count} entries"); + filesId = ExtractFilesIdFromReleaseFiles(releaseFiles); + } + else + { + LogLine.Do("Method 1: No app_release_files array found"); + } - if (releaseFiles == null || releaseFiles.Count == 0) + // Method 2: Look for urls array (alternative structure) + if (string.IsNullOrEmpty(filesId)) { - throw new Exception($"No release files found for app ID {appId}. The app may not have any published versions."); + LogLine.Do("Method 2: Checking urls array..."); + var urls = json["urls"] as JArray; + if (urls != null && urls.Count > 0) + { + LogLine.Do($"Found urls array with {urls.Count} entries"); + string urlResult = ExtractFilesIdFromUrls(urls); + + // Check if we got a full URL or just a files_id + if (!string.IsNullOrEmpty(urlResult)) + { + if (urlResult.StartsWith("http")) + { + // Full URL returned, use it directly + LogLine.Do($"Using full URL from urls array: {urlResult}"); + return urlResult; + } + else + { + // Just files_id returned + filesId = urlResult; + } + } + else + { + LogLine.Do("Method 2: No valid APK URL found in urls array"); + } + } + else + { + LogLine.Do("Method 2: No urls array found"); + } } - // Find APK file in release files - foreach (var file in releaseFiles) + // Method 3: Look for direct download_url field + if (string.IsNullOrEmpty(filesId)) { - string fileType = file["type"]?.ToString(); + LogLine.Do("Method 3: Checking direct download_url field..."); + string downloadUrl = json["download_url"]?.ToString(); + if (!string.IsNullOrEmpty(downloadUrl) && downloadUrl.EndsWith(".apk")) + { + LogLine.Do($"Found direct download_url: {downloadUrl}"); + return downloadUrl; + } + else + { + LogLine.Do("Method 3: No valid download_url field found"); + } + } + + // Method 4: Check top-level URL fields + if (string.IsNullOrEmpty(filesId)) + { + LogLine.Do("Method 4: Checking top-level URL fields (apk_url, file_url, url, cdn_url)..."); + string[] topLevelFields = { "apk_url", "file_url", "url", "cdn_url" }; + + foreach (var field in topLevelFields) + { + string url = json[field]?.ToString(); + if (!string.IsNullOrEmpty(url) && url.EndsWith(".apk")) + { + LogLine.Do($"Found APK URL in top-level field '{field}': {url}"); + return url; + } + } + LogLine.Do("Method 4: No valid APK URL found in top-level fields"); + } - if (fileType == "apk") + // Method 5: Check nested data object + if (string.IsNullOrEmpty(filesId)) + { + LogLine.Do("Method 5: Checking nested data object..."); + var dataObj = json["data"] as JObject; + if (dataObj != null) { - string filesId = file["files_id"]?.ToString(); + LogLine.Do("Found nested data object"); - if (string.IsNullOrEmpty(filesId)) + // Check data.download_url + string dataDownloadUrl = dataObj["download_url"]?.ToString(); + if (!string.IsNullOrEmpty(dataDownloadUrl) && dataDownloadUrl.EndsWith(".apk")) { - LogLine.Do("Found APK entry but files_id is empty, skipping..."); - continue; + LogLine.Do($"Found APK URL in data.download_url: {dataDownloadUrl}"); + return dataDownloadUrl; } - // Construct CDN URL - string apkUrl = $"{SIDEQUEST_CDN_BASE}/{filesId}/{appName}.apk"; - LogLine.Do($"Constructed APK URL from files_id: {filesId}"); - return apkUrl; + // Check data.apk_url + string dataApkUrl = dataObj["apk_url"]?.ToString(); + if (!string.IsNullOrEmpty(dataApkUrl) && dataApkUrl.EndsWith(".apk")) + { + LogLine.Do($"Found APK URL in data.apk_url: {dataApkUrl}"); + return dataApkUrl; + } + + // Check data.urls array + var dataUrls = dataObj["urls"] as JArray; + if (dataUrls != null && dataUrls.Count > 0) + { + LogLine.Do($"Found data.urls array with {dataUrls.Count} entries"); + string urlResult = ExtractFilesIdFromUrls(dataUrls); + + if (!string.IsNullOrEmpty(urlResult)) + { + if (urlResult.StartsWith("http")) + { + LogLine.Do($"Using full URL from data.urls array: {urlResult}"); + return urlResult; + } + else + { + filesId = urlResult; + } + } + } + + LogLine.Do("Method 5: No valid APK URL found in nested data object"); } + else + { + LogLine.Do("Method 5: No nested data object found"); + } + } + + if (string.IsNullOrEmpty(filesId)) + { + // Log the full JSON for debugging + Debug.LogWarning($"Could not find files_id in API response. JSON: {jsonResponse.Substring(0, Math.Min(500, jsonResponse.Length))}..."); + throw new Exception($"No APK download URL found for app ID {appId}. " + + $"Tried the following methods:\n" + + $"1. app_release_files array\n" + + $"2. urls array (link_url, url, download_url, cdn_url)\n" + + $"3. Top-level download_url field\n" + + $"4. Top-level URL fields (apk_url, file_url, url, cdn_url)\n" + + $"5. Nested data object (data.download_url, data.apk_url, data.urls)\n" + + $"This may not be a Quest Home app, or the app structure is not supported."); } - // No APK file found - throw new Exception($"No APK file found for app ID {appId}. The app may not be a Quest Home or may not have an APK release."); + // Construct CDN URL + string apkUrl = $"{SIDEQUEST_CDN_BASE}/{filesId}/{appName}.apk"; + LogLine.Do($"Constructed APK URL from files_id {filesId}: {apkUrl}"); + return apkUrl; } catch (Exception ex) when (ex is Newtonsoft.Json.JsonException) { @@ -207,6 +338,93 @@ public static string ParseApkUrlFromApiResponse(string jsonResponse, string appI } } + /// + /// Extract files_id from app_release_files array + /// + private static string ExtractFilesIdFromReleaseFiles(JArray releaseFiles) + { + foreach (var file in releaseFiles) + { + string fileType = file["type"]?.ToString(); + + if (fileType == "apk") + { + string filesId = file["files_id"]?.ToString(); + + if (!string.IsNullOrEmpty(filesId)) + { + LogLine.Do($"Found APK in release_files with files_id: {filesId}"); + return filesId; + } + } + } + + return null; + } + + /// + /// Extract files_id from urls array (alternative structure) + /// + private static string ExtractFilesIdFromUrls(JArray urls) + { + // Property names to check in URL objects (matching Tampermonkey script) + string[] candidateFields = { "link_url", "url", "download_url", "cdn_url" }; + + foreach (var urlEntry in urls) + { + // Check if the entry is a simple string + if (urlEntry.Type == JTokenType.String) + { + string url = urlEntry.ToString(); + + // Look for any URL ending with .apk (not just CDN URLs) + if (url.EndsWith(".apk")) + { + LogLine.Do($"Found APK URL in urls array (string): {url}"); + return url; // Return full URL directly + } + + // Try to extract files_id from CDN URL + var match = Regex.Match(url, @"cdn\.sidequestvr\.com/file/(\d+)/"); + if (match.Success) + { + string filesId = match.Groups[1].Value; + LogLine.Do($"Extracted files_id from URL string in urls array: {filesId}"); + return filesId; + } + } + + // If it's an object, check common URL property names + if (urlEntry.Type == JTokenType.Object) + { + foreach (var field in candidateFields) + { + string url = urlEntry[field]?.ToString(); + if (!string.IsNullOrEmpty(url)) + { + // Look for any URL ending with .apk (not just CDN URLs) + if (url.EndsWith(".apk")) + { + LogLine.Do($"Found APK URL in urls array ({field}): {url}"); + return url; // Return full URL directly + } + + // Try to extract files_id from CDN URL + var match = Regex.Match(url, @"cdn\.sidequestvr\.com/file/(\d+)/"); + if (match.Success) + { + string filesId = match.Groups[1].Value; + LogLine.Do($"Extracted files_id from {field} in urls array: {filesId}"); + return filesId; + } + } + } + } + } + + return null; + } + /// /// Sanitizes a filename for use in URLs /// diff --git a/Runtime/Scripts/QuestHome/SideQuestUrlResolver.cs.meta b/Runtime/Scripts/QuestHome/SideQuestUrlResolver.cs.meta new file mode 100644 index 00000000..9fcea1e3 --- /dev/null +++ b/Runtime/Scripts/QuestHome/SideQuestUrlResolver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: af0862368c69c8442b419b284cb99599 \ No newline at end of file diff --git a/VisualScripting/Space/LoadQuestHome.cs b/VisualScripting/Space/LoadQuestHome.cs index 283eaf15..a11a6a6b 100644 --- a/VisualScripting/Space/LoadQuestHome.cs +++ b/VisualScripting/Space/LoadQuestHome.cs @@ -2,7 +2,6 @@ using UnityEngine; using Unity.VisualScripting; using Banter.SDK; -using Banter.Utilities.Async; namespace Banter.VisualScripting { @@ -37,21 +36,20 @@ protected override void Definition() var _addColliders = flow.GetValue(addColliders); var _legacyShaderFix = flow.GetValue(legacyShaderFix); - UnityMainThreadTaskScheduler.Default.Enqueue(TaskRunner.Track(() => { - // Create a new GameObject with BanterQuestHome component - GameObject questHomeGo = new GameObject("QuestHome"); - var questHomeComponent = questHomeGo.AddComponent(); + // Create GameObject and component immediately (synchronously on main thread) + // Visual Scripting already runs on the main thread, no need for task scheduler + GameObject questHomeGo = new GameObject("QuestHome"); + var questHomeComponent = questHomeGo.AddComponent(); - // Set properties - questHomeComponent.Url = _url; - questHomeComponent.AddColliders = _addColliders; - questHomeComponent.LegacyShaderFix = _legacyShaderFix; + // Set properties (BanterQuestHome will handle async loading internally) + questHomeComponent.Url = _url; + questHomeComponent.AddColliders = _addColliders; + questHomeComponent.LegacyShaderFix = _legacyShaderFix; - // Store the GameObject for output - flow.SetValue(questHomeObject, questHomeGo); + // Store the GameObject for output (must be synchronous so next node can use it) + flow.SetValue(questHomeObject, questHomeGo); - Debug.Log($"LoadQuestHome: Started loading Quest Home from {_url}"); - }, $"{nameof(LoadQuestHome)}.{nameof(Definition)}")); + Debug.Log($"[LoadQuestHome] Started loading Quest Home from {_url}"); return outputTrigger; }); diff --git a/VisualScripting/Space/UnloadQuestHome.cs b/VisualScripting/Space/UnloadQuestHome.cs index 47b2ffa0..a1b41094 100644 --- a/VisualScripting/Space/UnloadQuestHome.cs +++ b/VisualScripting/Space/UnloadQuestHome.cs @@ -2,7 +2,6 @@ using UnityEngine; using Unity.VisualScripting; using Banter.SDK; -using Banter.Utilities.Async; namespace Banter.VisualScripting { @@ -26,18 +25,18 @@ protected override void Definition() inputTrigger = ControlInput("", (flow) => { var _questHomeObject = flow.GetValue(questHomeObject); - UnityMainThreadTaskScheduler.Default.Enqueue(TaskRunner.Track(() => { - if (_questHomeObject != null) - { - // Destroy the Quest Home GameObject - Object.Destroy(_questHomeObject); - Debug.Log("UnloadQuestHome: Quest Home unloaded"); - } - else - { - Debug.LogWarning("UnloadQuestHome: Quest Home object is null"); - } - }, $"{nameof(UnloadQuestHome)}.{nameof(Definition)}")); + // Destroy immediately (synchronously on main thread) + // Visual Scripting already runs on the main thread, no need for task scheduler + if (_questHomeObject != null) + { + // Destroy the Quest Home GameObject + Object.Destroy(_questHomeObject); + Debug.Log("[UnloadQuestHome] Quest Home unloaded"); + } + else + { + Debug.LogWarning("[UnloadQuestHome] Quest Home object is null"); + } return outputTrigger; }); diff --git a/VisualScripting/Utils/GetMenuBrowserUrl.cs b/VisualScripting/Utils/GetMenuBrowserUrl.cs new file mode 100644 index 00000000..51c2d876 --- /dev/null +++ b/VisualScripting/Utils/GetMenuBrowserUrl.cs @@ -0,0 +1,88 @@ +#if BANTER_VISUAL_SCRIPTING +using System; +using System.Reflection; +using UnityEngine; +using Unity.VisualScripting; +using Banter.SDK; + +namespace Banter.VisualScripting +{ + /// + /// Visual Scripting node that gets the current URL of the menu browser. + /// The menu browser is the main UI browser used for navigation, settings, and space browsing. + /// Use this to detect when users navigate to specific pages like SideQuest listings. + /// Uses reflection to avoid assembly dependencies. + /// + [UnitTitle("Get Menu Browser URL")] + [UnitShortTitle("Menu Browser URL")] + [UnitCategory("Banter\\Browser")] + [TypeIcon(typeof(BanterObjectId))] + public class GetMenuBrowserUrl : Unit + { + [DoNotSerialize] + public ValueOutput url; + + protected override void Definition() + { + url = ValueOutput("URL", flow => { + try + { + // Use reflection to find MenuBrowserMessager without compile-time reference + Type menuBrowserMessagerType = Type.GetType("MenuBrowserMessager, Assembly-CSharp"); + if (menuBrowserMessagerType == null) + { + return string.Empty; + } + + // Find instance of MenuBrowserMessager + var menuBrowserMessager = UnityEngine.Object.FindObjectOfType(menuBrowserMessagerType); + if (menuBrowserMessager == null) + { + return string.Empty; + } + + // Get webViewPrefab field + FieldInfo webViewPrefabField = menuBrowserMessagerType.GetField("webViewPrefab"); + if (webViewPrefabField == null) + { + return string.Empty; + } + + var webViewPrefab = webViewPrefabField.GetValue(menuBrowserMessager); + if (webViewPrefab == null) + { + return string.Empty; + } + + // Get WebView property + PropertyInfo webViewProperty = webViewPrefab.GetType().GetProperty("WebView"); + if (webViewProperty == null) + { + return string.Empty; + } + + var webView = webViewProperty.GetValue(webViewPrefab); + if (webView == null) + { + return string.Empty; + } + + // Get Url property from WebView + PropertyInfo urlProperty = webView.GetType().GetProperty("Url"); + if (urlProperty == null) + { + return string.Empty; + } + + return urlProperty.GetValue(webView) as string ?? string.Empty; + } + catch (Exception e) + { + Debug.LogWarning($"[GetMenuBrowserUrl] Failed to get menu browser URL: {e.Message}"); + } + return string.Empty; + }); + } + } +} +#endif diff --git a/VisualScripting/Utils/GetMenuBrowserUrl.cs.meta b/VisualScripting/Utils/GetMenuBrowserUrl.cs.meta new file mode 100644 index 00000000..92e19427 --- /dev/null +++ b/VisualScripting/Utils/GetMenuBrowserUrl.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 64dd96650bdb3cd41923d591e13fa978 \ No newline at end of file From 21c0cfbb78378205a781880e5c3e63ed79ddcb82 Mon Sep 17 00:00:00 2001 From: Elin <37795467+Ladypoly@users.noreply.github.com> Date: Mon, 3 Nov 2025 14:55:57 +0100 Subject: [PATCH 3/3] Comment out MeshFilter hideFlags and add preprocessor guards Commented out lines that set MeshFilter hideFlags in BanterQuestHomeEditor and added preprocessor guard comments around the foldout section. These changes appear to be for debugging or conditional compilation purposes. --- Editor/Components/BanterQuestHomeEditor.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Editor/Components/BanterQuestHomeEditor.cs b/Editor/Components/BanterQuestHomeEditor.cs index bb566c8f..ced03dda 100644 --- a/Editor/Components/BanterQuestHomeEditor.cs +++ b/Editor/Components/BanterQuestHomeEditor.cs @@ -13,6 +13,7 @@ void OnEnable() if (target is BanterQuestHome) { var script = (BanterQuestHome)target; + // script.gameObject.GetComponent().hideFlags = HideFlags.HideInInspector; var path = AssetDatabase.GetAssetPath(script); } } @@ -21,6 +22,7 @@ public override VisualElement CreateInspectorGUI() { var script = (BanterQuestHome)target; Editor editor = Editor.CreateEditor(script); + // script.gameObject.GetComponent().hideFlags = HideFlags.HideInInspector; VisualElement myInspector = new VisualElement(); var _mainWindowStyleSheet = Resources.Load("BanterCustomInspector"); @@ -38,12 +40,14 @@ public override VisualElement CreateInspectorGUI() seeFields.style.color = Color.gray; myInspector.Add(seeFields); + //#if BANTER_EDITOR var foldout = new Foldout(); foldout.text = "Available Properties"; IMGUIContainer inspectorIMGUI = new IMGUIContainer(() => { editor.OnInspectorGUI(); }); foldout.value = false; foldout.Add(inspectorIMGUI); myInspector.Add(foldout); + //#endif return myInspector; }