Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/release-notes/3.2.1-beta.2.md
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 3 additions & 1 deletion S1API.Tests/Entities/MugshotCapturePolicyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
25 changes: 25 additions & 0 deletions S1API/Avatar/BasicAvatarSettings.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
}

/// <summary>
/// Gender value (0 = male, 1 = female).
/// </summary>
Expand Down
112 changes: 103 additions & 9 deletions S1API/Entities/NPCAppearance.cs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<S1AvatarFramework.AvatarSettings>();
Expand Down Expand Up @@ -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)
{
Expand All @@ -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<Texture2D>)(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
}
Expand Down Expand Up @@ -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;

/// <summary>
/// INTERNAL: Applies the currently configured avatar settings to a runtime avatar instance.
Expand Down
5 changes: 4 additions & 1 deletion S1API/Entities/Player.cs
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,10 @@ public PropertyWrapper? LastVisitedProperty {
/// </summary>
public BasicAvatarSettings? GetCurrentBasicAvatarSettings()
{
return null;
var appearance = S1Player.CurrentAppearance;
return appearance == null
? null
: BasicAvatarSettings.FromPlayerAppearance(appearance);
}

/// <summary>
Expand Down
Loading
Loading