Compatibility with widescreen-authored UI mods

This commit is contained in:
Eddoursul 2026-08-23 04:51:52 +02:00
parent ca5d666b63
commit cf84e2f026
4 changed files with 234 additions and 75 deletions

View File

@ -86,8 +86,11 @@ void HeroMenuPatch::FillMenuValues()
return;
}
if (HeroMenuPatch::bAutoScaleHeroMenu && !REL::Module::IsVR()) {
// Fit the movie into the screen (widescreen support)
if (HeroMenuPatch::bAutoScaleHeroMenu && !REL::Module::IsVR() && !MovieStageIsWiderThan16x9(uiMovie.get())) {
// Fit the movie into the screen (widescreen support). A replacement 00e_heromenu.swf
// authored wider than 16:9 (Untarnished UI ports ship a 2560x720 one) is left alone:
// kShowAll letterboxes a wide stage into a band, and the wide-stage override in
// MenuAspectRatioFix already hands such movies the mode they are authored for.
uiMovie->SetViewScaleMode(RE::BSScaleformManager::ScaleModeType::kShowAll);
}

View File

@ -46,6 +46,14 @@
// clamped per art instance, and a clamp hit is logged as a warning: if that line ever appears, a
// measurement is wrong again - find out why before loosening the clamp.
//
// The billboard is also *selected* by measurement, not by position in the slot array: the art is
// the only billboard whose projection matches the known art laws, and menuObjects can hold
// foreign geometry during a load. The fog slot's NIF (intmenufogparticles.nif, an empty stub in
// stock Enderal) is replaceable by UI packs, and one shipped a stray EditorMarker shape in it;
// taking "first slot with geometry" then wrecked the art through the clamps (see FindArt).
// Candidates that fail the law are skipped, and logged once per menu open when a tick selects
// nothing at all.
//
// Note this leaves loadingmenu.swf and the movie's GFx viewport completely untouched. Moving the
// matte inside the movie is not an option in any case: it fades in through a timeline cxform ramp
// starting at alpha 0, and setting any property on a timeline instance from script (in-movie
@ -62,6 +70,9 @@ namespace LoadScreenFrameFix
{
RE::NiAVObject* root{ nullptr }; // the menuObjects attachment node - corrections are written here
RE::NiAVObject* shape{ nullptr }; // the billboard geometry - measurements are read here
float x{ 0.0f }; // projected screen x of the geometry's bound centre
float perUnit{ 0.0f }; // signed screen fraction travelled per world unit along X
float radius{ 0.0f }; // projected bound radius, as a fraction of the screen width
};
// Everything applied to one art instance so far. Long loads swap the art NIF mid-stream
@ -85,54 +96,127 @@ namespace LoadScreenFrameFix
inline State g_state;
// The largest non-particle geometry in the slot's subtree - the art billboard. Fog
// emitters are NiParticles-derived and must not contaminate the measurement: their
// bounds are computed in world space from the live particles, so they neither follow
// the attachment node's scale nor hold still.
inline RE::NiAVObject* FindShape(RE::NiAVObject* a_object)
// Every visible non-particle geometry in the subtree with a live bound. Fog emitters are
// NiParticles-derived and must not contaminate the measurement: their bounds are
// computed in world space from the live particles, so they neither follow the
// attachment node's scale nor hold still. Culled geometry is skipped too - invisible
// shapes are never the art, and the known foreign billboard (see FindArt) is a stray
// EditorMarker, which the engine hides by name.
inline void CollectShapes(RE::NiAVObject* a_object, std::vector<RE::NiAVObject*>& a_out)
{
if (!a_object) {
return nullptr;
if (!a_object || a_object->GetAppCulled()) {
return;
}
if (a_object->AsGeometry()) {
return a_object->AsParticlesGeom() ? nullptr : a_object;
if (!a_object->AsParticlesGeom() && a_object->worldBound.radius > 0.0f) {
a_out.push_back(a_object);
}
return;
}
RE::NiAVObject* best = nullptr;
if (const auto node = a_object->AsNode()) {
for (const auto& child : node->GetChildren()) {
const auto found = FindShape(child.get());
if (found && (!best || found->worldBound.radius > best->worldBound.radius)) {
best = found;
CollectShapes(child.get(), a_out);
}
}
}
return best;
}
// One flag per Loading Menu open: when a tick finds billboards but selects none, they
// are logged once, so a foreign object in the scene is named instead of silently skipped.
inline bool g_loggedRejects = false;
// The loading screen art is the only billboard attached to the UI3D scene while the
// Loading Menu is up; slots holding nothing, or only emitters, are skipped.
inline Art FindArt()
// Picks the billboard whose projection matches the loading screen art. The art is not
// the only thing that can sit in UI3DSceneManager::menuObjects during a load: the fog
// slot is loaded from meshes/interface/intmenufogparticles.nif, which Enderal ships as
// an empty stub - why "first slot with geometry" survived every earlier test - but which
// UI packs replace. "Vel'dun UI Enderal" repurposes a world snowfall effect there
// (FXAmbGentlyFallingSnow00) and left its EditorMarker BSTriShape in: a non-particle
// geometry, present on every load path, bound radius 261.45 x 0.5 root scale = 130.7
// world units - exactly the ~131 units back-calculated from the wreck's log. At 32:9 it
// measured projected radius 0.7349 at x -0.0817, and the loop shrank the wrong node to
// the clamp floor and dragged it sideways: savegame loads and cell transitions both
// showed the art at 1/3 size, pinned to the right edge of the frame hole.
//
// The art's projection is known, so every candidate is checked against it:
//
// - projected radius: 0.35617 * (16/9)/aspect when the engine fitted the model
// (1.6.x), 0.35617 flat when it did not (1.5.97), with authoring variance around
// either law (see Resize). The window [0.70 * fitted, 1.35 * unfitted] covers both
// laws, every mid-correction state between them, and the +27% outlier art measured
// in the wild - and rejects the logo at 2x its top.
// - projected centre x: the engine's drift keeps the art centre inside mid-screen
// (0.376 at 32:9 stock, 0.5 once corrected), so [0.25, 0.75] is generous.
//
// Among the survivors the largest wins - the art is the dominant legitimate billboard.
inline Art FindArt(RE::NiCamera* a_camera, float a_aspect)
{
const auto manager = RE::UI3DSceneManager::GetSingleton();
if (!manager) {
return {};
}
const float fitted = 0.35617f * (16.0f / 9.0f) / a_aspect;
constexpr float unfitted = 0.35617f;
struct Reject
{
RE::NiAVObject* root;
RE::NiAVObject* shape;
float radius;
float x;
};
Art best;
std::vector<Reject> rejected;
std::vector<RE::NiAVObject*> shapes;
for (const auto& slot : manager->menuObjects) {
const auto object = slot.get();
if (object) {
const auto shape = FindShape(object);
if (shape && shape->worldBound.radius > 0.0f) {
return { object, shape };
if (!object) {
continue;
}
shapes.clear();
CollectShapes(object, shapes);
for (const auto shape : shapes) {
const auto& centre = shape->worldBound.center;
const float radius = shape->worldBound.radius;
const RE::NiPoint3 probe{ centre.x + radius, centre.y, centre.z };
float x = 0.0f, y = 0.0f, z = 0.0f;
float probeX = 0.0f, probeY = 0.0f, probeZ = 0.0f;
if (!a_camera->WorldPtToScreenPt3(centre, x, y, z, 1e-5f) ||
!a_camera->WorldPtToScreenPt3(probe, probeX, probeY, probeZ, 1e-5f)) {
continue;
}
const float perUnit = (probeX - x) / radius;
const float projected = std::fabs(perUnit) * radius;
if (projected < 0.70f * fitted || projected > 1.35f * unfitted || x < 0.25f || x > 0.75f) {
rejected.push_back({ object, shape, projected, x });
continue;
}
if (std::fabs(perUnit) > 1e-8f && projected > best.radius) {
best = { object, shape, x, perUnit, projected };
}
}
}
return {};
// Logged only when nothing was selected: mid-attach art states can look odd for a
// frame, but they never end a tick empty-handed on a healthy load.
if (!best.shape && !rejected.empty() && !g_loggedRejects) {
g_loggedRejects = true;
for (const auto& reject : rejected) {
logger::info("Loading screen candidate rejected: {}/{} projects radius {:.4f} at x {:.4f}, not loading art (window {:.4f}..{:.4f}, x 0.25..0.75)", reject.root->name.c_str(), reject.shape->name.c_str(), reject.radius, reject.x, 0.70f * fitted, 1.35f * unfitted);
}
}
return best;
}
// 1.5.97 never fits the load screen model to the viewport at all - the billboard grows with
@ -157,6 +241,13 @@ namespace LoadScreenFrameFix
if (a_view->GetViewScaleMode() != RE::GFxMovieView::ScaleModeType::kShowAll) {
// Under kExactFit the stage is stretched to the whole screen, so the frame is as
// wide as the art already is; resizing to a kShowAll target would blow it up.
// kNoBorder means a third-party loadingmenu.swf authored wider than 16:9 (see
// the wide-stage override in MenuAspectRatioFix), running below its stage
// aspect. Skipping those is accepted: on 1.5.97 the oversized art then stays
// oversized, but such a frame covers the whole screen outside its hole, so the
// defect shows only as a zoomed-in picture inside the hole, never as spill -
// and the packs shipping these movies target 1.6.1130+ anyway. The centring
// half of the tick still runs for them, which it must: their holes are centred.
return false;
}
@ -263,34 +354,29 @@ namespace LoadScreenFrameFix
const auto manager = RE::UI3DSceneManager::GetSingleton();
const auto camera = manager ? manager->camera.get() : nullptr;
const auto art = detail::FindArt();
if (!camera) {
return;
}
// FindArt already projected the selected billboard, so its numbers are reused here.
// perUnit is the signed screen fraction per world unit along X - on this camera +X maps
// to screen left - so the solve below works whichever way the rig faces.
const auto art = detail::FindArt(camera, aspect);
if (!art.root) {
return;
}
if (art.root && camera) {
if (detail::g_state.shape != art.shape) {
detail::g_state = {};
detail::g_state.shape = art.shape;
}
const auto& centre = art.shape->worldBound.center;
const float radius = art.shape->worldBound.radius;
const RE::NiPoint3 probe{ centre.x + radius, centre.y, centre.z };
float x = 0.0f, y = 0.0f, z = 0.0f;
float probeX = 0.0f, probeY = 0.0f, probeZ = 0.0f;
if (camera->WorldPtToScreenPt3(centre, x, y, z, 1e-5f) &&
camera->WorldPtToScreenPt3(probe, probeX, probeY, probeZ, 1e-5f)) {
// Screen fraction travelled per world unit along X. Signed - on this camera +X
// maps to screen left - so the solve below works whichever way the rig faces.
const float perUnit = (probeX - x) / radius;
if (std::fabs(perUnit) > 1e-8f) {
// Size first - it moves the art, so centring has to measure afterwards
if (detail::Resize(art.root, std::fabs(perUnit) * radius, view.get(), aspect)) {
if (detail::Resize(art.root, art.radius, view.get(), aspect)) {
RE::NiUpdateData updateData{};
art.root->Update(updateData);
} else {
const float delta = (0.5f - x) / perUnit;
const float delta = (0.5f - art.x) / art.perUnit;
// ~1 px at 3840 wide; below that the loop has converged
if (std::fabs(delta) > 0.05f) {
@ -306,7 +392,7 @@ namespace LoadScreenFrameFix
if (!detail::g_state.loggedCentre) {
detail::g_state.loggedCentre = true;
logger::info("Loading screen art centred: projected x {:.4f}, moved {:.2f} units along X", x, applied);
logger::info("Loading screen art centred: projected x {:.4f}, moved {:.2f} units along X", art.x, applied);
}
} else if (!detail::g_state.warnedClamp) {
detail::g_state.warnedClamp = true;
@ -315,10 +401,6 @@ namespace LoadScreenFrameFix
}
}
}
}
}
}
inline void AdvanceMovieEx(RE::IMenu* a_this, float a_interval, std::uint32_t a_currentTime);
inline REL::Relocation<decltype(&AdvanceMovieEx)> _AdvanceMovie;
@ -350,6 +432,7 @@ namespace LoadScreenFrameFix
inline void OnMenuOpen()
{
detail::g_state = {};
detail::g_loggedRejects = false;
}
// Called from EventListener when the Main Menu opens. Until that first happens, any billboard

View File

@ -1,9 +1,11 @@
#pragma once
#include "Util.h"
#include <cctype>
// Two independent corrections to the Scaleform scale mode menus are loaded with, both applied
// from the same BSScaleformManager::LoadMovie hook:
// Three corrections to the Scaleform scale mode menus are loaded with, all applied from the same
// BSScaleformManager::LoadMovie hook:
//
// 1. Restore the modes used before 1.6.1130. That build switched nearly every menu to
// kExactFit, which stretches the movie across the whole viewport instead of preserving its
@ -12,6 +14,10 @@
// distorted. Only 1.6.1130 and later need this; see g_restoreScaleModes.
// 2. Stop kNoBorder from cropping menus on displays wider than 16:9. That one predates
// 1.6.1130 and is needed on every runtime, so it is not version gated; see ClampToViewport.
// 3. For a table menu whose movie file was replaced by a re-author on a stage wider than
// 16:9, override both of the above with a mode derived from the stage itself; see
// FitWideStage. Corrections 1 and 2 assume the 1280x720 authoring every vanilla, SkyUI and
// Enderal movie uses, and they break the wide re-authors at most aspect ratios.
//
// The table below is what 1.6.640 passes to LoadMovie, plus BookMenu and GiftMenu, which 1.6.1130
// left on kNoBorder and which need the clamp. Menus that want no help (Book, Console, CreditsMenu,
@ -128,17 +134,68 @@ namespace MenuAspectRatioFix
return viewportAspect > stageAspect ? ScaleModeType::kShowAll : a_mode;
}
// Third-party UI packs replace table movies with re-authors on stages wider than 16:9 - the
// Untarnished UI ports (for example "Vel'dun UI Enderal") ship 2560x720 movies for the
// inventory family, the dialogue menu, the loading menu and the custom hero menu. Those
// movies implement widescreen themselves, as a kNoBorder crop: the stage always renders at
// the height-fit scale, so every element keeps one physical size, and the extra stage width
// stays outside the viewport until the display is as wide as the stage (their loading menu
// frame keeps a constant 676-stage-unit hole this way, aligned with the engine's 3D art at
// every aspect ratio). Both modes the table can pick destroy that design: kShowAll must show
// the whole stage, which letterboxes a 32:9 stage into a half-height band on a 16:9 display,
// and kExactFit distorts it. So for these movies the mode comes from the stage instead -
// kNoBorder up to the stage aspect, kShowAll beyond it, where kNoBorder would start cropping
// again (the two coincide when viewport and stage aspect are equal, hence the loose
// comparison). Like the clamp, this is not gated on g_restoreScaleModes: pre-1.6.1130 call
// sites hand these movies an equally wrong mode, kShowAll for the loading menu for instance.
//
// The stage size is only readable from the loaded movie, so this runs after the original
// LoadMovie - which applies its mode argument before returning - and writes the override to
// the view directly.
void FitWideStage(RE::GFxMovieView* a_view, const char* a_fileName)
{
const auto def = a_view->GetMovieDef();
const auto state = RE::BSGraphics::State::GetSingleton();
if (!def || !state || state->screenHeight == 0) {
return;
}
const float stageAspect = def->GetWidth() / def->GetHeight();
const float viewportAspect = static_cast<float>(state->screenWidth) / static_cast<float>(state->screenHeight);
const auto mode = viewportAspect >= stageAspect - 0.005f ? ScaleModeType::kShowAll : ScaleModeType::kNoBorder;
a_view->SetViewScaleMode(mode);
// Menus reload on every open, so log each movie once
static std::set<std::string, CaseInsensitiveLess> logged;
if (logged.emplace(a_fileName).second) {
logger::info("{} is authored wider than 16:9 ({:.0f}x{:.0f}), overriding its scale mode to {}", a_fileName, def->GetWidth(), def->GetHeight(), mode == ScaleModeType::kShowAll ? "kShowAll" : "kNoBorder");
}
}
struct LoadMovie
{
static bool thunk(RE::BSScaleformManager* a_scaleformManager, RE::IMenu* a_menu, RE::GPtr<RE::GFxMovieView>& a_viewOut, const char* a_fileName, ScaleModeType a_mode, float a_backgroundAlpha)
{
std::optional<ScaleModeType> tableMode;
if (a_fileName && a_fileName[0]) {
if (const auto mode = GetScaleMode(a_fileName); mode) {
a_mode = ClampToViewport(g_restoreScaleModes ? *mode : a_mode);
tableMode = GetScaleMode(a_fileName);
if (tableMode) {
a_mode = ClampToViewport(g_restoreScaleModes ? *tableMode : a_mode);
}
}
return func(a_scaleformManager, a_menu, a_viewOut, a_fileName, a_mode, a_backgroundAlpha);
if (!func(a_scaleformManager, a_menu, a_viewOut, a_fileName, a_mode, a_backgroundAlpha)) {
return false;
}
// The table still decides which menus may be touched at all
if (tableMode && a_viewOut && MovieStageIsWiderThan16x9(a_viewOut.get())) {
FitWideStage(a_viewOut.get(), a_fileName);
}
return true;
}
static inline REL::Relocation<decltype(thunk)> func;

View File

@ -28,6 +28,22 @@ inline std::uint32_t SemVerToInt(std::uint16_t major, std::uint16_t minor, std::
return (static_cast<std::uint32_t>(major) << 24) | (static_cast<std::uint32_t>(minor) << 16) | (static_cast<std::uint32_t>(patch) << 8) | static_cast<std::uint32_t>(build);
}
// True when the movie behind the view is authored on a stage wider than 16:9. Every vanilla,
// SkyUI and Enderal menu is 1280x720; a wider stage marks a third-party widescreen re-author
// (Untarnished UI ports ship 2560x720 movies), which carries its own widescreen strategy and
// must not be given the scale modes the 1280x720 movies are authored for. See the wide-stage
// override in MenuAspectRatioFix for the mode such movies want.
inline bool MovieStageIsWiderThan16x9(RE::GFxMovieView* a_view)
{
const auto def = a_view ? a_view->GetMovieDef() : nullptr;
if (!def) {
return false;
}
const float height = def->GetHeight();
return height > 0.0f && def->GetWidth() / height > 1280.0f / 720.0f + 0.01f;
}
inline void CheckIncompatibleMods()
{
const auto pluginVersion = SKSE::PluginDeclaration::GetSingleton()->GetVersion();