From 1a228f6683fb87b95f20109c0efee6f96beed53f Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 19 Sep 2026 20:15:55 -0700 Subject: [PATCH] fix(avatar): restore beta portraits and preview poses --- .github/release-notes/3.2.1-beta.2.md | 20 + .../Entities/MugshotCapturePolicyTests.cs | 4 +- S1API/Avatar/BasicAvatarSettings.cs | 25 + S1API/Entities/NPCAppearance.cs | 112 +++- S1API/Entities/Player.cs | 5 +- .../Compatibility/AvatarCompatibility.cs | 553 +++++++++++++++++- S1API/Internal/Lifecycle/SceneStateCleaner.cs | 2 + .../NPCWorkbench/NPCWorkbenchPreview.cs | 31 +- .../NPCWorkbenchRuntimeAdapter.cs | 6 +- S1API/Rendering/IconFactory.cs | 98 +++- S1API/S1API.cs | 4 +- S1API/S1API.csproj | 2 +- S1API/UI/CharacterCreatorManager.cs | 9 +- S1API/docs/appearance-customization.md | 15 + S1API/docs/npc-workbench.md | 6 + 15 files changed, 851 insertions(+), 41 deletions(-) create mode 100644 .github/release-notes/3.2.1-beta.2.md diff --git a/.github/release-notes/3.2.1-beta.2.md b/.github/release-notes/3.2.1-beta.2.md new file mode 100644 index 00000000..23534545 --- /dev/null +++ b/.github/release-notes/3.2.1-beta.2.md @@ -0,0 +1,20 @@ +## Avatar rendering on the Schedule I 0.4.7 beta + +- Restore generated NPC mugshots and accessory icons through a detached avatar and portrait camera. +- Keep portraits facing forward, with settled animation and appearance state. Remove inherited attachments and prevent player look targets, blinking, and distance impostors from affecting captures. +- Restore the NPC appearance workbench, including native appearance import, full-body framing, pose controls, and reset. +- Reset copied body-shape caches so newly applied clothing fits the preview body. +- Resolve legacy hair, clothing, face-layer, and accessory resources through typed Mono and IL2CPP APIs. +- Restore current player and active character-creator appearance getters. + +## Compatibility and validation + +- Existing public method signatures and explicit NPC icon precedence are preserved. +- Built against the Schedule I 0.4.7f5 Mono and IL2CPP beta assemblies. Rendered captures were checked in both runtimes, including the requested accessory, forward-facing portraits, clothing, workbench framing, and pose reset. +- Updated the appearance-customization and NPC workbench documentation. +- This is a GitHub-only prerelease for the game beta. It does not publish to NuGet, Nexus Mods, or Thunderstore. +- Tracks [issue #305](https://github.com/ifBars/S1API/issues/305). + +## Downloads + +- [Download S1API-Forked-3.2.1-beta.2.zip](https://github.com/ifBars/S1API/releases/download/v3.2.1-beta.2/S1API-Forked-3.2.1-beta.2.zip) diff --git a/S1API.Tests/Entities/MugshotCapturePolicyTests.cs b/S1API.Tests/Entities/MugshotCapturePolicyTests.cs index a4f50ed5..eea04d5f 100644 --- a/S1API.Tests/Entities/MugshotCapturePolicyTests.cs +++ b/S1API.Tests/Entities/MugshotCapturePolicyTests.cs @@ -6,9 +6,11 @@ public sealed class MugshotCapturePolicyTests { [Theory] [InlineData(500, 4096, 0.60f, 0.90f, true)] - [InlineData(500, 4096, 0.60f, 0.80f, false)] + [InlineData(500, 4096, 0.60f, 0.80f, true)] [InlineData(200, 4096, 0.60f, 0.90f, false)] [InlineData(500, 4096, 0.30f, 0.90f, false)] + [InlineData(500, 4096, 0.60f, 0.74f, false)] + [InlineData(300, 4096, 0.60f, 0.90f, false)] [InlineData(500, 4096, 0.60f, 0.40f, false)] [InlineData(500, 0, 0.60f, 0.80f, false)] public void PortraitCoverageRequiresSubstantialVisibleBounds( diff --git a/S1API/Avatar/BasicAvatarSettings.cs b/S1API/Avatar/BasicAvatarSettings.cs index b28bb5b5..c55c9fa1 100644 --- a/S1API/Avatar/BasicAvatarSettings.cs +++ b/S1API/Avatar/BasicAvatarSettings.cs @@ -1,8 +1,10 @@ #if (IL2CPPMELON) using S1Customization = Il2CppScheduleOne.AvatarFramework.Customization; +using S1PlayerAppearance = Il2CppScheduleOne.Avatar.Player.PlayerAppearance; using Il2CppCollectionsGeneric = Il2CppSystem.Collections.Generic; #elif MONOMELON using S1Customization = ScheduleOne.AvatarFramework.Customization; +using S1PlayerAppearance = ScheduleOne.Avatar.Player.PlayerAppearance; #endif using System.Collections.Generic; @@ -40,6 +42,29 @@ public static BasicAvatarSettings Create() return new BasicAvatarSettings(settings); } + internal static BasicAvatarSettings FromPlayerAppearance(S1PlayerAppearance appearance) + { + var settings = Create(); + settings.Gender = (int)appearance.Gender; + settings.Weight = appearance.Weight; + settings.SkinColor = appearance.SkinColor; + settings.HairStyle = appearance.HairStyleId ?? string.Empty; + settings.HairColor = appearance.HairColor; + settings.Mouth = appearance.FaceId ?? string.Empty; + settings.FacialHair = appearance.FacialHairId ?? string.Empty; + settings.FacialDetails = appearance.FacialDetailId ?? string.Empty; + settings.FacialDetailsIntensity = appearance.FacialDetailIntensity; + settings.EyeballColor = appearance.EyeballColor; + settings.PupilDilation = appearance.PupilDilation; + settings.UpperEyeLidRestingPosition = appearance.UpperEyelidPosition; + settings.LowerEyeLidRestingPosition = appearance.LowerEyelidPosition; + settings.EyebrowScale = appearance.EyebrowScale; + settings.EyebrowThickness = appearance.EyebrowThickness; + settings.EyebrowRestingHeight = appearance.EyebrowHeight; + settings.EyebrowRestingAngle = appearance.EyebrowAngle; + return settings; + } + /// /// Gender value (0 = male, 1 = female). /// diff --git a/S1API/Entities/NPCAppearance.cs b/S1API/Entities/NPCAppearance.cs index 115c8ea1..d56e6eeb 100644 --- a/S1API/Entities/NPCAppearance.cs +++ b/S1API/Entities/NPCAppearance.cs @@ -1,9 +1,11 @@ #if (IL2CPPMELON) using S1AvatarFramework = Il2CppScheduleOne.AvatarFramework; +using S1AvatarTools = Il2CppScheduleOne.Avatar.Tools; using S1Map = Il2CppScheduleOne.Map; using S1NPCs = Il2CppScheduleOne.NPCs; #elif MONOMELON using S1AvatarFramework = ScheduleOne.AvatarFramework; +using S1AvatarTools = ScheduleOne.Avatar.Tools; using S1Map = ScheduleOne.Map; using S1NPCs = ScheduleOne.NPCs; #endif @@ -63,13 +65,12 @@ internal NPCAppearance(NPC npc, S1AvatarFramework.Avatar? runtimeAvatar) if (_runtimeAvatar != null) { - sourceSettings = global::S1API.Internal.Utils.ReflectionUtils.TryGetFieldOrProperty( - _runtimeAvatar, - "InitialAvatarSettings") as S1AvatarFramework.AvatarSettings; + sourceSettings = global::S1API.Internal.Compatibility.AvatarCompatibility + .CaptureLegacySettings(_runtimeAvatar); } if (sourceSettings != null) - _customAvatarSettings = ScriptableObject.Instantiate(sourceSettings); + _customAvatarSettings = sourceSettings; else { _customAvatarSettings = ScriptableObject.CreateInstance(); @@ -445,8 +446,11 @@ private static IEnumerator ProcessMugshotQueueCore() yield return new WaitForSeconds(0.1f); } #else + var renderingEpoch = global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch; while (true) { + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + yield break; NPCAppearance? next; lock (_mugshotQueueLock) { @@ -459,8 +463,98 @@ private static IEnumerator ProcessMugshotQueueCore() next = _mugshotQueue.Dequeue(); } - next.MarkMugshotCompleted(); - yield return null; + if (next.NPC.HasExplicitIcon) + { + next.MarkMugshotCompleted(); + continue; + } + + S1AvatarTools.MugshotGenerator? generator = null; + while (generator == null) + { + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + yield break; + generator = global::S1API.Internal.Compatibility.AvatarCompatibility + .FindMugshotGenerator(next._runtimeAvatar); + if (generator == null) + yield return null; + } + + while (!global::S1API.Internal.Compatibility.AvatarCompatibility + .TryAcquireMugshotGenerator(generator)) + { + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + yield break; + yield return null; + } + + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + { + global::S1API.Internal.Compatibility.AvatarCompatibility.ReleaseMugshotGenerator(); + yield break; + } + + _activeMugshot = next; + global::S1API.Internal.Compatibility.AvatarCompatibility.CreateRenderInputs( + next._customAvatarSettings, + out var appearance, + out var outfit); + + bool completed = false; + Texture2D? generatedMugshot = null; + try + { + global::S1API.Internal.Compatibility.AvatarCompatibility.StartPortraitCapture( + generator, + appearance, + outfit, + (Action)(texture => + { + generatedMugshot = texture; + completed = true; + })); + } + catch (Exception ex) + { + _logger.Error($"[Mugshot] Native capture failed to start: {ex.Message}"); + completed = true; + } + + var waitFrames = 0; + while (!completed && waitFrames++ < 300) + yield return null; + + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + { + UnityEngine.Object.Destroy(outfit); + yield break; + } + + global::S1API.Internal.Compatibility.AvatarCompatibility.ReleaseMugshotGenerator(); + UnityEngine.Object.Destroy(outfit); + generatedMugshot?.Apply(); + if (generatedMugshot != null) + { + var source = generatedMugshot; + generatedMugshot = global::S1API.Internal.Compatibility.AvatarCompatibility + .ResizePortrait(source, 512); + UnityEngine.Object.Destroy(source); + } + if (generatedMugshot != null && HasPortraitContent(generatedMugshot, 0.01f)) + { + var cropRect = new Rect(0, 0, generatedMugshot.width, generatedMugshot.height); + var iconSprite = Sprite.Create(generatedMugshot, cropRect, Vector2.zero); + next.NPC.ApplyGeneratedIcon(iconSprite); + UpdatePoiIcons(next.NPC.S1NPC, iconSprite); + } + else + { + _logger.Error( + $"[Mugshot] {next.NPC.FirstName}: capture returned no complete portrait; keeping the existing icon"); + } + + CompleteActiveMugshot(); + yield return new WaitForSeconds(0.05f); } #endif } @@ -579,9 +673,9 @@ internal static bool IsPortraitCoverageSufficient( float contentWidth, float contentHeight) => totalSamples > 0 && - visibleSamples >= totalSamples * 0.1f && - contentWidth >= 0.4f && - contentHeight >= 0.82f; + visibleSamples >= totalSamples * 0.10f && + contentWidth >= 0.35f && + contentHeight >= 0.75f; /// /// INTERNAL: Applies the currently configured avatar settings to a runtime avatar instance. diff --git a/S1API/Entities/Player.cs b/S1API/Entities/Player.cs index de269c74..4638abd7 100644 --- a/S1API/Entities/Player.cs +++ b/S1API/Entities/Player.cs @@ -284,7 +284,10 @@ public PropertyWrapper? LastVisitedProperty { /// public BasicAvatarSettings? GetCurrentBasicAvatarSettings() { - return null; + var appearance = S1Player.CurrentAppearance; + return appearance == null + ? null + : BasicAvatarSettings.FromPlayerAppearance(appearance); } /// diff --git a/S1API/Internal/Compatibility/AvatarCompatibility.cs b/S1API/Internal/Compatibility/AvatarCompatibility.cs index 89fc226c..906b3900 100644 --- a/S1API/Internal/Compatibility/AvatarCompatibility.cs +++ b/S1API/Internal/Compatibility/AvatarCompatibility.cs @@ -1,13 +1,379 @@ #if IL2CPPMELON using S1AvatarFramework = Il2CppScheduleOne.AvatarFramework; +using S1AvatarTools = Il2CppScheduleOne.Avatar.Tools; +using S1Avatar = Il2CppScheduleOne.Avatar; +using S1CoreAvatar = Il2CppScheduleOne.Core.Avatar; +using S1CharacterCreator = Il2CppScheduleOne.CharacterCreator.CharacterCreator; +using Il2CppInterop.Runtime.InteropTypes.Arrays; #elif MONOMELON using S1AvatarFramework = ScheduleOne.AvatarFramework; +using S1AvatarTools = ScheduleOne.Avatar.Tools; +using S1Avatar = ScheduleOne.Avatar; +using S1CoreAvatar = ScheduleOne.Core.Avatar; +using S1CharacterCreator = ScheduleOne.CharacterCreator.CharacterCreator; #endif +using System.Collections.Generic; +using System.Collections; +using System; +using UnityEngine; +using Object = UnityEngine.Object; + namespace S1API.Internal.Compatibility { internal static class AvatarCompatibility { + private static readonly object MugshotLock = new object(); + private static bool _mugshotInUse; + private static S1AvatarTools.MugshotGenerator? _fallbackMugshotGenerator; + private static object? _captureCoroutine; + internal static int RenderingEpoch { get; private set; } + + internal static void ResetRendering() + { + RenderingEpoch++; + if (_captureCoroutine != null) + { + MelonLoader.MelonCoroutines.Stop(_captureCoroutine); + _captureCoroutine = null; + } + if (_fallbackMugshotGenerator != null) + Object.Destroy(_fallbackMugshotGenerator.gameObject); + _fallbackMugshotGenerator = null; + ReleaseMugshotGenerator(); + } + + internal static void StartPortraitCapture( + S1AvatarTools.MugshotGenerator generator, + S1CoreAvatar.NakedAppearance appearance, + S1CoreAvatar.Outfit outfit, + Action callback) + { + _captureCoroutine = MelonLoader.MelonCoroutines.Start( + CapturePortrait(generator, appearance, outfit, callback)); + } + + internal static S1AvatarTools.MugshotGenerator? FindMugshotGenerator( + S1AvatarFramework.Avatar? preferredAvatar = null) + { + var active = Object.FindObjectOfType(); + if (active != null) + return active; + + foreach (var candidate in Resources.FindObjectsOfTypeAll()) + { + if (candidate != null && candidate.gameObject.scene.IsValid()) + return candidate; + } + + if (_fallbackMugshotGenerator != null) + return _fallbackMugshotGenerator; + + foreach (var creator in Resources.FindObjectsOfTypeAll()) + { + var creatorAvatar = Utils.ReflectionUtils.TryGetFieldOrProperty(creator, "_avatar") + as S1AvatarFramework.Avatar; + var sourceCamera = Utils.ReflectionUtils.TryGetFieldOrProperty(creator, "_cameraPosition") + as Transform; + if (creatorAvatar == null || sourceCamera == null) + continue; + + var sourceAvatar = preferredAvatar ?? FindRuntimeAvatar() ?? creatorAvatar; + + var root = new GameObject("S1API Avatar Rendering Rig") + { + hideFlags = HideFlags.HideAndDontSave + }; + Object.DontDestroyOnLoad(root); + + var cameraForward = sourceCamera.forward; + var horizontalForward = new Vector3(cameraForward.x, 0f, cameraForward.z).normalized; + if (horizontalForward.sqrMagnitude < 0.01f) + horizontalForward = Vector3.forward; + root.transform.position = + sourceCamera.position + horizontalForward * 1.4f - Vector3.up * 1.55f; + root.transform.rotation = Quaternion.LookRotation(-horizontalForward, Vector3.up); + + var avatarObject = Object.Instantiate(sourceAvatar.gameObject); + avatarObject.name = "Avatar"; + avatarObject.hideFlags = HideFlags.HideAndDontSave; + avatarObject.transform.SetParent(root.transform, false); + avatarObject.transform.localPosition = Vector3.zero; + avatarObject.transform.localRotation = Quaternion.identity; + avatarObject.SetActive(true); + + var cameraObject = new GameObject("Camera Position") + { + hideFlags = HideFlags.HideAndDontSave + }; + cameraObject.transform.SetParent(root.transform, false); + cameraObject.transform.position = sourceCamera.position; + cameraObject.transform.rotation = Quaternion.LookRotation( + root.transform.position + Vector3.up * 1.45f - sourceCamera.position, + Vector3.up); + + AddPreviewLight( + root.transform, + "Key Light", + Quaternion.Euler(28f, 205f, 0f), + 0.65f, + new Color(1f, 0.9f, 0.82f)); + AddPreviewLight( + root.transform, + "Fill Light", + Quaternion.Euler(20f, 25f, 0f), + 0.35f, + new Color(0.7f, 0.82f, 1f)); + AddCameraFillLight(root.transform, cameraObject.transform); + + var generator = root.AddComponent(); + var avatar = avatarObject.GetComponent(); + if (avatar != null) + PrepareDetachedAvatar(avatar); + if (avatar == null || + !Utils.ReflectionUtils.TrySetFieldOrProperty(generator, "_avatar", avatar) || + !Utils.ReflectionUtils.TrySetFieldOrProperty( + generator, + "_cameraPosition", + cameraObject.transform)) + { + Object.Destroy(root); + return null; + } + + _fallbackMugshotGenerator = generator; + return generator; + } + + return null; + } + + private static S1AvatarFramework.Avatar? FindRuntimeAvatar() + { + foreach (var avatar in Object.FindObjectsOfType()) + { + if (avatar != null && avatar.gameObject.activeInHierarchy) + return avatar; + } + + return null; + } + + internal static void PrepareDetachedAvatar(S1AvatarFramework.Avatar avatar) + { + // Serialized mesh blend weights survive cloning; the native value caches do not. + Utils.ReflectionUtils.TrySetFieldOrProperty(avatar.Appearance, "_appliedGender", -1f); + Utils.ReflectionUtils.TrySetFieldOrProperty(avatar.Appearance, "_appliedWeight", -1f); + foreach (var loader in avatar.GetComponentsInChildren(true)) + loader.enabled = false; + foreach (var loader in avatar.GetComponentsInChildren(true)) + loader.enabled = false; + // Runtime clones must not follow the player or switch to a distance impostor. + avatar.CancelInvoke("UpdateAnimationActive"); + avatar.SetVisible(true); + if (Utils.ReflectionUtils.TryGetFieldOrProperty(avatar, "_impostor") is Component impostor) + impostor.gameObject.SetActive(false); + + if (avatar.LookController != null) + { + avatar.LookController.CancelInvoke(); + avatar.LookController.enabled = false; + if (Utils.ReflectionUtils.TryGetFieldOrProperty(avatar.LookController, "Aim") is Behaviour aim) + aim.enabled = false; + } + if (avatar.Eyes != null) + Utils.ReflectionUtils.TrySetFieldOrProperty(avatar.Eyes, "blinkingEnabled", false); + if (avatar.EmotionManager != null) + { + avatar.EmotionManager.CancelInvoke(); + avatar.EmotionManager.enabled = false; + } + + // Unity clones the hierarchy but not AvatarAppearance's private runtime lists. + // Remove copied objects so subsequent appearance applications own every instance. + foreach (var attachment in avatar.GetComponentsInChildren(true)) + { + attachment.gameObject.SetActive(false); + Object.Destroy(attachment.gameObject); + } + if (Utils.ReflectionUtils.TryGetFieldOrProperty(avatar.Appearance, "_avatarObjectsContainer") + is Transform objects) + { + for (var index = objects.childCount - 1; index >= 0; index--) + { + var child = objects.GetChild(index).gameObject; + child.SetActive(false); + Object.Destroy(child); + } + } + + foreach (var animator in avatar.GetComponentsInChildren(true)) + { + animator.cullingMode = AnimatorCullingMode.AlwaysAnimate; + animator.Rebind(); + animator.Update(0f); + } + foreach (var lod in avatar.GetComponentsInChildren(true)) + lod.ForceLOD(0); + foreach (var renderer in avatar.GetComponentsInChildren(true)) + renderer.updateWhenOffscreen = true; + } + + private static void AddPreviewLight( + Transform parent, + string name, + Quaternion rotation, + float intensity, + Color color) + { + var lightObject = new GameObject(name) + { + hideFlags = HideFlags.HideAndDontSave, + layer = 20 + }; + lightObject.transform.SetParent(parent, false); + lightObject.transform.rotation = rotation; + var light = lightObject.AddComponent(); + light.type = LightType.Directional; + light.intensity = intensity; + light.color = color; + light.cullingMask = 1 << 20; + light.shadows = LightShadows.None; + } + + private static void AddCameraFillLight(Transform parent, Transform cameraPosition) + { + var lightObject = new GameObject("Camera Fill Light") + { + hideFlags = HideFlags.HideAndDontSave, + layer = 20 + }; + lightObject.transform.SetParent(parent, false); + lightObject.transform.position = cameraPosition.position; + var light = lightObject.AddComponent(); + light.type = LightType.Point; + light.range = 6f; + light.intensity = 0.25f; + light.color = new Color(1f, 0.95f, 0.9f); + light.cullingMask = 1 << 20; + light.shadows = LightShadows.None; + } + + internal static bool TryAcquireMugshotGenerator(S1AvatarTools.MugshotGenerator generator) + { + lock (MugshotLock) + { + if (_mugshotInUse || + Utils.ReflectionUtils.TryGetFieldOrProperty(generator, "_mugshotRoutine") != null) + { + return false; + } + + _mugshotInUse = true; + return true; + } + } + + private static IEnumerator CapturePortrait( + S1AvatarTools.MugshotGenerator generator, + S1CoreAvatar.NakedAppearance appearance, + S1CoreAvatar.Outfit outfit, + Action callback) + { + var avatar = Utils.ReflectionUtils.TryGetFieldOrProperty(generator, "_avatar") + as S1AvatarFramework.Avatar; + if (avatar == null) + yield break; + + var cameraObject = new GameObject("S1API Portrait Camera"); + var camera = cameraObject.AddComponent(); + var target = new RenderTexture(512, 512, 24, RenderTextureFormat.ARGB32); + var delivered = false; + try + { + var normalized = appearance.Clone(); + normalized.Height = 1.875f; + avatar.Appearance.ApplyNakedAppearance(normalized); + avatar.Appearance.ApplyOutfit(outfit); + avatar.SetVisible(true); + foreach (var transform in avatar.GetComponentsInChildren(true)) + transform.gameObject.layer = 20; + foreach (var lod in avatar.GetComponentsInChildren(true)) + lod.ForceLOD(0); + camera.enabled = false; + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = Color.clear; + camera.cullingMask = 1 << 20; + camera.orthographic = true; + camera.orthographicSize = 0.34f; + camera.nearClipPlane = 0.05f; + camera.farClipPlane = 5f; + var focus = avatar.transform.position + Vector3.up * 1.7f; + camera.transform.position = focus + avatar.transform.forward * 2f; + camera.transform.LookAt(focus); + target.Create(); + camera.targetTexture = target; + // Let both animation and deferred attachment destruction settle before capture. + yield return null; + yield return new WaitForEndOfFrame(); + camera.Render(); + var previous = RenderTexture.active; + try + { + RenderTexture.active = target; + var texture = new Texture2D(512, 512, TextureFormat.RGBA32, false); + texture.ReadPixels(new Rect(0, 0, 512, 512), 0, 0); + texture.Apply(); + delivered = true; + callback(texture); + } + finally + { + RenderTexture.active = previous; + } + } + finally + { + _captureCoroutine = null; + camera.targetTexture = null; + target.Release(); + Object.Destroy(target); + Object.Destroy(cameraObject); + if (!delivered) + callback(null!); + } + } + + internal static void ReleaseMugshotGenerator() + { + lock (MugshotLock) + _mugshotInUse = false; + } + + internal static Texture2D ResizePortrait(Texture2D source, int size) + { + var target = RenderTexture.GetTemporary(size, size, 0, RenderTextureFormat.ARGB32); + var previous = RenderTexture.active; + try + { + Graphics.Blit( + source, + target, + Vector2.one, + Vector2.zero); + RenderTexture.active = target; + var portrait = new Texture2D(size, size, TextureFormat.RGBA32, false); + portrait.ReadPixels(new Rect(0, 0, size, size), 0, 0); + portrait.Apply(); + return portrait; + } + finally + { + RenderTexture.active = previous; + RenderTexture.ReleaseTemporary(target); + } + } + internal static void ApplyLegacySettings( S1AvatarFramework.Avatar avatar, S1AvatarFramework.AvatarSettings settings) @@ -15,13 +381,188 @@ internal static void ApplyLegacySettings( if (avatar?.Appearance == null || settings == null) return; - var nakedAppearance = settings.EquivalentNakedAppearance; - if (nakedAppearance != null) - avatar.Appearance.ApplyNakedAppearance(nakedAppearance.Appearance); + CreateRenderInputs(settings, out var nakedAppearance, out var outfit); + avatar.Appearance.ApplyNakedAppearance(nakedAppearance); + avatar.Appearance.ApplyOutfit(outfit); + Object.Destroy(outfit); + } - var outfit = settings.EquivalentOutfit; - if (outfit != null) - avatar.Appearance.ApplyOutfit(outfit); + internal static S1AvatarFramework.AvatarSettings? CaptureLegacySettings(S1AvatarFramework.Avatar avatar) + { + var appearance = avatar.Appearance?.AppliedNakedAppearance; + if (appearance == null) + return null; + + var settings = ScriptableObject.CreateInstance(); + settings.Gender = appearance.Gender; + settings.Height = appearance.Height / 1.875f; + settings.Weight = appearance.Weight; + settings.SkinColor = appearance.SkinColor; + settings.HairColor = appearance.HairColor; + settings.EyeBallTint = appearance.LeftEyeSettings.EyeballColor; + settings.PupilDilation = appearance.LeftEyeSettings.PupilDilation; + settings.LeftEyeRestingState = new S1AvatarFramework.Eye.EyeLidConfiguration + { + topLidOpen = appearance.LeftEyelidSettings.RestingState.TopLidOpenness, + bottomLidOpen = appearance.LeftEyelidSettings.RestingState.BottomLidOpenness + }; + settings.RightEyeRestingState = new S1AvatarFramework.Eye.EyeLidConfiguration + { + topLidOpen = appearance.RightEyelidSettings.RestingState.TopLidOpenness, + bottomLidOpen = appearance.RightEyelidSettings.RestingState.BottomLidOpenness + }; + settings.EyebrowScale = appearance.LeftEyebrowSettings.Scale; + settings.EyebrowThickness = appearance.LeftEyebrowSettings.Thickness; + settings.EyebrowRestingHeight = appearance.LeftEyebrowSettings.RestingHeight; + settings.EyebrowRestingAngle = appearance.LeftEyebrowSettings.RestingAngle; + foreach (var obj in avatar.GetComponentsInChildren(true)) + { + var serialized = obj.Serialize(); + var color = serialized.Colors != null && serialized.Colors.Length > 0 + ? serialized.Colors[0].Value : Color.white; +#if IL2CPPMELON + var isHair = obj.TryCast() != null; + var isFace = obj.TryCast() != null; +#else + var isHair = obj is S1CoreAvatar.HairAvatarObject; + var isFace = obj is S1CoreAvatar.FaceAvatarObject; +#endif + if (isHair) + settings.HairPath = obj.Id; + else if (obj.PrimaryType == S1CoreAvatar.AvatarObject.EType.Worn) + settings.AccessorySettings.Add(new S1AvatarFramework.AvatarSettings.AccessorySetting + { path = obj.Id, color = color }); + else if (isFace) + settings.FaceLayerSettings.Add(new S1AvatarFramework.AvatarSettings.LayerSetting + { layerPath = obj.Id, layerTint = color }); + else + settings.BodyLayerSettings.Add(new S1AvatarFramework.AvatarSettings.LayerSetting + { layerPath = obj.Id, layerTint = color }); + } + return settings; + } + + internal static void CreateRenderInputs( + S1AvatarFramework.AvatarSettings settings, + out S1CoreAvatar.NakedAppearance nakedAppearance, + out S1CoreAvatar.Outfit outfit) + { + nakedAppearance = settings.EquivalentNakedAppearance?.Appearance != null + ? settings.EquivalentNakedAppearance.Appearance.Clone() + : CreateNakedAppearance(settings); + outfit = settings.EquivalentOutfit != null + ? Object.Instantiate(settings.EquivalentOutfit) + : CreateOutfit(settings); + } + + internal static bool TryResolveAvatarObject( + string resourcePath, + Color color, + out S1CoreAvatar.SerializedAvatarObject serialized) + { + serialized = S1CoreAvatar.SerializedAvatarObject.Null; + if (string.IsNullOrWhiteSpace(resourcePath)) + return false; + + var prefab = Resources.Load(resourcePath); + var accessory = prefab != null ? prefab.GetComponent() : null; + S1CoreAvatar.AvatarObject? avatarObject = accessory != null + ? accessory.AvatarObjectEquivalent : null; + if (avatarObject == null) + { + var layer = Resources.Load(resourcePath); + if (layer != null) + avatarObject = layer.AvatarObjectEquivalent; + } + + if (avatarObject == null && + !S1Avatar.AvatarObjectLibrary.TryGetAvatarObjectById(resourcePath, out avatarObject)) + { + var finalSegment = resourcePath.Substring(resourcePath.LastIndexOf('/') + 1); + S1Avatar.AvatarObjectLibrary.TryGetAvatarObjectById(finalSegment, out avatarObject); + } + + if (avatarObject == null) + return false; + + serialized = avatarObject.SerializeWithPrimaryColor(color); + return true; + } + + private static S1CoreAvatar.NakedAppearance CreateNakedAppearance( + S1AvatarFramework.AvatarSettings settings) + { + var legacy = ScriptableObject.CreateInstance(); + legacy.Gender = settings.Gender < 0.35f ? 0 : 1; + legacy.Weight = settings.Weight; + legacy.SkinColor = settings.SkinColor; + legacy.HairStyle = settings.HairPath ?? string.Empty; + legacy.HairColor = settings.HairColor; + legacy.EyeballColor = settings.EyeBallTint; + legacy.PupilDilation = settings.PupilDilation; + legacy.UpperEyeLidRestingPosition = settings.UpperEyelidRestingPosition; + legacy.LowerEyeLidRestingPosition = settings.LowerEyelidRestingPosition; + legacy.EyebrowScale = settings.EyebrowScale; + legacy.EyebrowThickness = settings.EyebrowThickness; + legacy.EyebrowRestingHeight = settings.EyebrowRestingHeight; + legacy.EyebrowRestingAngle = settings.EyebrowRestingAngle; + + var appearance = S1AvatarTools.BasicAvatarSettingsConverter.ConvertToNakedAppearance( + legacy, + includeUnderwear: true, + includeNipples: true, + includeEyeShadow: true); + Object.Destroy(legacy); + + appearance.Gender = settings.Gender; + appearance.Height = settings.Height > 0f ? settings.Height * 1.875f : 1.875f; + + if (TryResolveAvatarObject(settings.HairPath ?? string.Empty, settings.HairColor, out var hair)) + appearance.AddAvatarObject(hair); + + if (settings.FaceLayerSettings != null) + { + foreach (var layer in settings.FaceLayerSettings) + { + if (TryResolveAvatarObject(layer.layerPath, layer.layerTint, out var avatarObject)) + appearance.AddAvatarObject(avatarObject); + } + } + + if (settings.BodyLayerSettings != null) + { + foreach (var layer in settings.BodyLayerSettings) + { + if (TryResolveAvatarObject(layer.layerPath, layer.layerTint, out var avatarObject)) + appearance.AddAvatarObject(avatarObject); + } + } + + return appearance; + } + + private static S1CoreAvatar.Outfit CreateOutfit(S1AvatarFramework.AvatarSettings settings) + { + var serialized = new List(); + if (settings.AccessorySettings != null) + { + foreach (var accessory in settings.AccessorySettings) + { + if (TryResolveAvatarObject(accessory.path, accessory.color, out var avatarObject)) + serialized.Add(avatarObject); + } + } + + var outfit = ScriptableObject.CreateInstance(); +#if IL2CPPMELON + var objects = new Il2CppReferenceArray(serialized.Count); + for (var index = 0; index < serialized.Count; index++) + objects[index] = serialized[index]; + outfit.AvatarObjects = objects; +#else + outfit.AvatarObjects = serialized.ToArray(); +#endif + return outfit; } } } diff --git a/S1API/Internal/Lifecycle/SceneStateCleaner.cs b/S1API/Internal/Lifecycle/SceneStateCleaner.cs index b5a3f704..338b68a0 100644 --- a/S1API/Internal/Lifecycle/SceneStateCleaner.cs +++ b/S1API/Internal/Lifecycle/SceneStateCleaner.cs @@ -91,6 +91,8 @@ internal static void ResetForSceneChange(string sceneName, bool afterUnload) WeatherRuntime.ResetBindings(); HomeScreenScrollPatch.ResetInitializationState(); NPCAppearance.ResetMugshotState(); + Compatibility.AvatarCompatibility.ResetRendering(); + global::S1API.Rendering.IconFactory.ResetAccessoryIconState(); LoadingScreenPatches.ResetState(); DialogueInjector.ResetState(); DialogueChoiceListener.ResetState(); diff --git a/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs b/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs index ba01edb4..1b8b42fc 100644 --- a/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs +++ b/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs @@ -1,7 +1,9 @@ #if IL2CPPMELON using S1AvatarFramework = Il2CppScheduleOne.AvatarFramework; +using S1AvatarTools = Il2CppScheduleOne.Avatar.Tools; #elif MONOMELON using S1AvatarFramework = ScheduleOne.AvatarFramework; +using S1AvatarTools = ScheduleOne.Avatar.Tools; #endif using System; @@ -26,9 +28,9 @@ internal sealed class NPCWorkbenchPreview : IDisposable private NPCWorkbenchDraft? _pendingDraft; private int _settleFrames; private int _rendererRefreshFrames; - private float _yaw = 180f; + private float _yaw; private float _pitch; - private float _distance = 2.8f; + private float _distance = 3.8f; private bool _disposed; private NPCWorkbenchPreview( @@ -52,13 +54,11 @@ private NPCWorkbenchPreview( internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string failure) { preview = null; - failure = "NPC avatar previews are unavailable with the Schedule I 0.4.7 avatar pipeline."; - return false; - -#if false failure = string.Empty; - var generator = S1AvatarFramework.MugshotGenerator.Instance; - var source = generator != null ? generator.MugshotRig : null; + var generator = Compatibility.AvatarCompatibility.FindMugshotGenerator(); + var source = generator != null + ? Utils.ReflectionUtils.TryGetFieldOrProperty(generator, "_avatar") as S1AvatarFramework.Avatar + : null; if (source == null) { failure = "The native avatar preview rig is not ready. Load a save, then reopen the workbench."; @@ -78,6 +78,8 @@ internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string fail var avatarObject = Object.Instantiate(source.gameObject, root.transform, false); avatarObject.name = "Detached Avatar"; + avatarObject.transform.localPosition = Vector3.zero; + avatarObject.transform.localRotation = Quaternion.identity; avatarObject.SetActive(true); SetLayerRecursively(avatarObject, layer); @@ -85,6 +87,8 @@ internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string fail if (avatar == null) throw new InvalidOperationException("The cloned native preview rig has no Avatar component."); + Compatibility.AvatarCompatibility.PrepareDetachedAvatar(avatar); + avatar.SetVisible(true); foreach (var renderer in avatarObject.GetComponentsInChildren(true)) renderer.updateWhenOffscreen = true; @@ -109,13 +113,14 @@ internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string fail var keyObject = new GameObject("Key Light"); keyObject.transform.SetParent(root.transform, false); - keyObject.transform.localPosition = new Vector3(-2f, 3f, -3f); + keyObject.transform.localPosition = new Vector3(-2f, 3f, 3f); keyObject.transform.LookAt(root.transform.position + Vector3.up); var key = keyObject.AddComponent(); key.type = LightType.Directional; key.intensity = 1.25f; key.color = new Color(1f, 0.88f, 0.76f); key.cullingMask = 1 << layer; + key.shadows = LightShadows.None; var fillObject = new GameObject("Fill Light"); fillObject.transform.SetParent(root.transform, false); @@ -125,6 +130,7 @@ internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string fail fill.intensity = 0.65f; fill.color = new Color(0.55f, 0.72f, 1f); fill.cullingMask = 1 << layer; + fill.shadows = LightShadows.None; preview = new NPCWorkbenchPreview(root, avatar, camera, texture, layer); return true; @@ -142,7 +148,6 @@ internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string fail Object.Destroy(root); return false; } -#endif } internal void ScheduleApply(NPCWorkbenchDraft draft) @@ -195,9 +200,9 @@ internal void SetPose(int pose) internal void ResetView() { - _yaw = 180f; + _yaw = 0f; _pitch = 0f; - _distance = 2.8f; + _distance = 3.8f; SetPose(0); UpdateCamera(); } @@ -226,6 +231,8 @@ private void Apply(NPCWorkbenchDraft draft) private void RefreshRenderers() { SetLayerRecursively(_avatar.gameObject, _renderLayer); + foreach (var lod in _avatar.gameObject.GetComponentsInChildren(true)) + lod.ForceLOD(0); foreach (var renderer in _avatar.gameObject.GetComponentsInChildren(true)) renderer.updateWhenOffscreen = true; } diff --git a/S1API/Internal/NPCWorkbench/NPCWorkbenchRuntimeAdapter.cs b/S1API/Internal/NPCWorkbench/NPCWorkbenchRuntimeAdapter.cs index a6e75c6b..18dd7120 100644 --- a/S1API/Internal/NPCWorkbench/NPCWorkbenchRuntimeAdapter.cs +++ b/S1API/Internal/NPCWorkbench/NPCWorkbenchRuntimeAdapter.cs @@ -95,9 +95,7 @@ private static NPCWorkbenchDraft ImportNative(string id) S1AvatarFramework.AvatarSettings? sourceSettings = null; if (sourceSettings == null && avatar != null) { - sourceSettings = ReflectionUtils.TryGetFieldOrProperty( - avatar, - "InitialAvatarSettings") as S1AvatarFramework.AvatarSettings; + sourceSettings = Compatibility.AvatarCompatibility.CaptureLegacySettings(avatar); } if (sourceSettings == null) throw new InvalidOperationException($"Native NPC '{id}' has no available appearance settings."); @@ -109,7 +107,7 @@ private static NPCWorkbenchDraft ImportNative(string id) SourceDisplayName = $"{GetDisplayName(npc)} ({id})" }; - using (var settings = new AvatarSettingsScope(ScriptableObject.Instantiate(sourceSettings))) + using (var settings = new AvatarSettingsScope(sourceSettings)) CopyAppearance(settings.Value, draft.Appearance); return draft; diff --git a/S1API/Rendering/IconFactory.cs b/S1API/Rendering/IconFactory.cs index 7546893c..fd723ef6 100644 --- a/S1API/Rendering/IconFactory.cs +++ b/S1API/Rendering/IconFactory.cs @@ -69,7 +69,7 @@ internal static bool IsItemIconGeneratorReady /// INTERNAL: Reference to the game's MugshotGenerator instance. /// internal static S1AvatarTools.MugshotGenerator? S1MugshotGenerator => - UnityEngine.Object.FindObjectOfType(); + global::S1API.Internal.Compatibility.AvatarCompatibility.FindMugshotGenerator(); /// /// Generates a preview texture for the specified model. @@ -795,8 +795,11 @@ private static IEnumerator ProcessAccessoryIconQueue() yield return new WaitForSeconds(0.05f); } #else + var renderingEpoch = global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch; while (true) { + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + yield break; AccessoryIconRequest? next; lock (_accessoryIconQueueLock) { @@ -809,10 +812,86 @@ private static IEnumerator ProcessAccessoryIconQueue() next = _accessoryIconQueue.Dequeue(); } - Logger.Warning( - "Accessory icon generation is unavailable with the Schedule I 0.4.7 avatar pipeline."); - next.Callback?.Invoke(null); - yield return null; + var generator = S1MugshotGenerator; + while (generator == null) + { + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + yield break; + yield return null; + generator = S1MugshotGenerator; + } + + while (!global::S1API.Internal.Compatibility.AvatarCompatibility + .TryAcquireMugshotGenerator(generator)) + { + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + yield break; + yield return null; + } + + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + { + global::S1API.Internal.Compatibility.AvatarCompatibility.ReleaseMugshotGenerator(); + yield break; + } + + var settings = CreateMinimalAvatarSettings(next.AccessoryPath, next.AccessoryColor); + global::S1API.Internal.Compatibility.AvatarCompatibility.CreateRenderInputs( + settings, + out var appearance, + out var outfit); + + bool completed = false; + Texture2D? capturedTexture = null; + try + { + global::S1API.Internal.Compatibility.AvatarCompatibility.StartPortraitCapture( + generator, + appearance, + outfit, + (Action)(texture => + { + capturedTexture = texture; + completed = true; + })); + } + catch (Exception ex) + { + Logger.Error($"Failed to start accessory icon capture: {ex.Message}"); + completed = true; + } + + var waitFrames = 0; + while (!completed && waitFrames++ < 300) + yield return null; + + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + { + UnityEngine.Object.Destroy(outfit); + UnityEngine.Object.Destroy(settings); + yield break; + } + + global::S1API.Internal.Compatibility.AvatarCompatibility.ReleaseMugshotGenerator(); + UnityEngine.Object.Destroy(outfit); + UnityEngine.Object.Destroy(settings); + capturedTexture?.Apply(); + if (capturedTexture != null) + { + var source = capturedTexture; + capturedTexture = global::S1API.Internal.Compatibility.AvatarCompatibility + .ResizePortrait(source, next.IconSize); + UnityEngine.Object.Destroy(source); + } + if (capturedTexture == null || !HasVisibleContent(capturedTexture)) + { + Logger.Error($"Failed to generate accessory icon for '{next.AccessoryPath}'."); + next.Callback?.Invoke(null); + continue; + } + + next.Callback?.Invoke(capturedTexture); + yield return new WaitForSeconds(0.05f); } #endif } @@ -848,6 +927,15 @@ private static S1AvatarFramework.AvatarSettings CreateMinimalAvatarSettings(stri return settings; } + internal static void ResetAccessoryIconState() + { + lock (_accessoryIconQueueLock) + { + _accessoryIconQueue.Clear(); + _isProcessingAccessoryIcons = false; + } + } + /// /// Creates fallback minimal AvatarSettings when player settings are unavailable. /// diff --git a/S1API/S1API.cs b/S1API/S1API.cs index 9190e353..8dbc4f66 100644 --- a/S1API/S1API.cs +++ b/S1API/S1API.cs @@ -12,7 +12,7 @@ using S1API.Lifecycle; using S1API.Map; -[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.1", "KaBooMa")] +[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.2", "KaBooMa")] [assembly: MelonPriority(Int32.MinValue)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member namespace S1API @@ -37,6 +37,8 @@ public override void OnInitializeMelon() public override void OnDeinitializeMelon() { + Internal.Compatibility.AvatarCompatibility.ResetRendering(); + Rendering.IconFactory.ResetAccessoryIconState(); WeatherRuntime.ResetBindings(); NPCWorkbenchRuntime.Close(); PresentationWorkbenchRuntime.Close(); diff --git a/S1API/S1API.csproj b/S1API/S1API.csproj index ad528183..dc172cc6 100644 --- a/S1API/S1API.csproj +++ b/S1API/S1API.csproj @@ -23,7 +23,7 @@ $(NoWarn);1591 true latest - 3.2.1-beta.1 + 3.2.1-beta.2 diff --git a/S1API/UI/CharacterCreatorManager.cs b/S1API/UI/CharacterCreatorManager.cs index 77e84229..955746b3 100644 --- a/S1API/UI/CharacterCreatorManager.cs +++ b/S1API/UI/CharacterCreatorManager.cs @@ -78,7 +78,14 @@ public static BasicAvatarSettings? ActiveSettings get { EnsureInitialized(); - return null; + if (_s1Creator == null || !_s1Creator.IsOpen) + return null; + + var state = ReflectionUtils.TryGetFieldOrProperty(_s1Creator, "_currentState") + as S1Customization.CharacterCreatorState; + return state?.Appearance == null + ? null + : BasicAvatarSettings.FromPlayerAppearance(state.Appearance); } } diff --git a/S1API/docs/appearance-customization.md b/S1API/docs/appearance-customization.md index e7e11900..f3bc29e0 100644 --- a/S1API/docs/appearance-customization.md +++ b/S1API/docs/appearance-customization.md @@ -2,6 +2,21 @@ The `NPCAppearance` system allows you to customize your NPC's visual appearance, including physical features, clothing, and accessories. +### Schedule I 0.4.7 beta + +The beta uses the game's new naked-appearance and outfit system. Existing +appearance builders and S1API resource-path constants remain the entry points. +S1API translates those settings before applying them to an avatar. + +`Build()` queues portrait generation until a render rig is available. Portraits +use a detached avatar with a forward-facing pose; player look targets, blinking, +and distance impostors do not control the capture. An explicitly assigned NPC +icon still takes precedence. A failed capture leaves the existing icon intact. + +The NPC workbench can import a native NPC's current beta appearance. Imported +layers may use native avatar-object IDs where no legacy resource path is available. +These IDs can also be passed to the appearance layer helpers. + ## Table of Contents 1. [Overview](#overview) diff --git a/S1API/docs/npc-workbench.md b/S1API/docs/npc-workbench.md index a51bd7e8..c8e76735 100644 --- a/S1API/docs/npc-workbench.md +++ b/S1API/docs/npc-workbench.md @@ -30,6 +30,12 @@ Start from default values or import the current appearance of a native or config The preview clones the game's dedicated avatar preview rig into a private render layer. It uses a private camera, lighting, and render texture and never registers or spawns an NPC. Drag the preview to orbit, use the mouse wheel to zoom, and switch between standing, sitting, and crouching poses. +On the Schedule I 0.4.7 beta, S1API creates a detached compatibility rig when +the game has no active mugshot rig. The initial view faces the avatar and includes +the full body. Copied accessories, look targets, distance impostors, and cached +body-shape values are reset before the selected appearance is applied. Reset +returns to the forward-facing standing view. + ## Export Select **Copy C#** to copy an appearance-only builder block: