Added LoadScreenFrameFix

This commit is contained in:
Eddoursul 2026-07-31 00:13:40 +02:00
parent 503d18ab34
commit 9cc519a8aa
5 changed files with 208 additions and 1 deletions

View File

@ -10,3 +10,4 @@ AutoScaleHeroMenu = true
WarnFormTypeCollisions = true
GogVramLeakFix = true
MenuAspectRatioFix = true
LoadScreenFrameFix = true

View File

@ -57,6 +57,8 @@ auto EventListener::ProcessEvent(
}
HeroMenuPatch::FillMenuValues();
}
} else if (a_event->menuName == RE::LoadingMenu::MENU_NAME) {
LoadScreenFrameFix::OnMenuOpen();
}
} else {
if (a_event->menuName == RE::DialogueMenu::MENU_NAME) {

View File

@ -2,6 +2,7 @@
#include "Patches/DialogueMenuPatch.h"
#include "Patches/HeroMenuPatch.h"
#include "Patches/LoadScreenFrameFix.h"
#include "Util.h"
#include <shellapi.h>

View File

@ -33,7 +33,8 @@ static std::map<std::string, bool> g_settings{
{ "AttachLightHitEffectCrashFix", true },
{ "AutoScaleHeroMenu", true },
{ "GogVramLeakFix", true },
{ "MenuAspectRatioFix", true }
{ "MenuAspectRatioFix", true },
{ "LoadScreenFrameFix", true }
};
namespace {
@ -258,6 +259,7 @@ SKSEPluginLoad(const LoadInterface* skse) {
if (g_settings.at("MenuAspectRatioFix") && REL::Module::get().version() >= REL::Version(1, 6, 1130, 0)) {
MenuAspectRatioFix::Install();
}
LoadScreenFrameFix::Install(g_settings.at("LoadScreenFrameFix"));
}
logger::info("{} has finished loading.", plugin->GetName());

View File

@ -0,0 +1,201 @@
#pragma once
// Fits the 3D loading screen art to Enderal's Celtic frame (painted by loadingmenu.swf) on
// displays wider than 16:9. Two different engine defects, one per runtime family:
//
// 1.6.1170 - the art is the right size but drawn left of centre, so the frame sits beside the
// picture instead of around it. Corrected by translating the art (see below).
// 1.5.97 - the art is centred but never fitted to the viewport at all: the billboard grows
// with the framebuffer, so at 32:9 it is drawn about twice its intended size and
// spills past the black matte on both sides. Corrected by scaling the art, see
// detail::Resize. This is the same "cover instead of fit" that makes the 1.5.97 main
// menu background crop where 1.6.1170 letterboxes it.
//
// Both corrections run through the same measured-projection loop, so neither needs a runtime
// version gate: each is a no-op on the runtime that does not have that defect.
//
// The cause is an engine bug in how the load screen model is fitted to the viewport. The art is an
// ordinary billboard in the UI3D scene - UI3DSceneManager::menuObjects holds the attachment node,
// whose child is the load screen NIF root (e.g. LoadScreenDawnStarShield) - and above 16:9 the
// engine multiplies that node's **entire local transform** by (16/9)/aspect, translation included,
// not just its scale. The UI3D camera does not sit on the world origin (measured at
// (-50, 600, 80) in 1.6.1170), so scaling the translation about the origin also drags the art off
// the camera axis: its world X moves from -50, which is exactly the camera's X and therefore dead
// centre, towards 0. What is left is a drift of 50 * (1 - (16/9)/aspect) world units - 24.87 at
// 32:9, which projects to the (width - height * 16/9) / 4 px measured on screen. Vertically the
// same scaling applies but the art is authored above centre anyway, and the residual is small
// enough to stay behind the frame, so only X is corrected here.
//
// Rather than hardcode any of that, the correction is measured from the engine's own projection
// each frame: project the art's world bound centre and the same point offset by its bound radius
// along world X, which gives the screen fraction per world unit, then solve for the translation
// that puts the projected centre on 0.5. That is self-calibrating - it needs no addresses, no
// version gate, and no knowledge of the camera or the aspect - and it is a feedback loop, so once
// the art is centred the computed correction is ~0 and nothing more is written. It also converges
// again by itself if the engine re-applies its own transform.
//
// 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
// ActionScript and engine-side SetVariable alike - both were shipped, both failed identically)
// detaches the instance from timeline control and freezes it at alpha 0, i.e. invisible.
namespace LoadScreenFrameFix
{
inline bool g_enabled = false;
inline void Install(bool a_enabled)
{
g_enabled = a_enabled;
if (a_enabled) {
logger::info("Loading screen frame fix armed");
}
}
namespace detail
{
// The loading screen billboard is the only thing attached to the UI3D scene while the
// Loading Menu is up, and the only node there with a non-empty bound - the sibling fog
// emitter has none.
inline RE::NiAVObject* FindArt()
{
const auto manager = RE::UI3DSceneManager::GetSingleton();
if (!manager) {
return nullptr;
}
for (const auto& slot : manager->menuObjects) {
const auto object = slot.get();
if (object && object->worldBound.radius > 0.0f) {
return object;
}
}
return nullptr;
}
// 1.5.97 never fits the load screen model to the viewport at all - the billboard grows with
// the framebuffer, so above 16:9 it is drawn far larger than the frame and spills past the
// matte on both sides (the same "cover instead of fit" the 1.5.97 main menu background
// shows). 1.6.1170 shrinks the node by (16/9)/aspect and needs no help here.
//
// Rather than gate on a runtime version - the build that introduced the fit is not known -
// the wanted size is derived from the stage the frame is painted on, and compared with what
// the engine actually produced. The art is authored 794.7 stage units wide against the
// movie's 1280, and the bound is a sphere around a 16:9 plane, so its projected radius
// should be (794.7 / 1280 / 2) * sqrt(1 + (9/16)^2) = 0.35617 of the stage's screen width.
// Verified against 1.6.1170 at 3840x1080: predicted 0.1781, measured 0.1777.
//
// Returns true when it changed the node, so the caller re-measures before centring.
inline bool Resize(RE::NiAVObject* a_art, float a_projectedRadius)
{
if (a_projectedRadius <= 0.0f) {
return false;
}
const auto view = RE::UI::GetSingleton()->GetMovieView(RE::LoadingMenu::MENU_NAME);
if (!view || 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.
return false;
}
RE::GViewport viewport;
view->GetViewport(&viewport);
if (viewport.height <= 0 || viewport.width <= 0) {
return false;
}
// kShowAll fits the 1280x720 stage to the shorter axis, so above 16:9 the stage covers
// (16/9)/aspect of the screen width, and at or below it the full width.
const float aspect = static_cast<float>(viewport.width) / static_cast<float>(viewport.height);
const float stageWidth = aspect > (16.0f / 9.0f) ? (16.0f / 9.0f) / aspect : 1.0f;
const float target = 0.35617f * stageWidth;
const float ratio = target / a_projectedRadius;
// Wide deadband: 1.6.1170 lands within 0.2%, so it never trips this
if (ratio > 0.95f && ratio < 1.05f) {
return false;
}
a_art->local.scale *= ratio;
static bool logged = false;
if (!logged) {
logged = true;
logger::info("Loading screen art resized: projected radius {:.4f}, target {:.4f}, scaled by {:.3f}", a_projectedRadius, target, ratio);
}
return true;
}
}
// Runs once per frame for as long as the Loading Menu is up, started from EventListener
inline void Centre(std::uint32_t a_pass)
{
if (!g_enabled) {
return;
}
const auto ui = RE::UI::GetSingleton();
if (!ui || !ui->IsMenuOpen(RE::LoadingMenu::MENU_NAME)) {
return;
}
const auto manager = RE::UI3DSceneManager::GetSingleton();
const auto camera = manager ? manager->camera.get() : nullptr;
const auto art = detail::FindArt();
if (art && camera) {
const auto& centre = art->worldBound.center;
const float radius = art->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, std::fabs(perUnit) * radius)) {
RE::NiUpdateData updateData{};
art->Update(updateData);
} else {
const float delta = (0.5f - x) / perUnit;
// ~1 px at 3840 wide; below that the loop has converged
if (std::fabs(delta) > 0.05f) {
art->local.translate.x += delta;
RE::NiUpdateData updateData{};
art->Update(updateData);
static bool logged = false;
if (!logged) {
logged = true;
logger::info("Loading screen art centred: projected x {:.4f}, moved {:.2f} units along X", x, delta);
}
}
}
}
}
}
// Loading screens outlast any fixed budget, so this simply follows the menu; the cap is
// only a backstop in case the menu is somehow never reported closed.
if (a_pass < 7200) {
SKSE::GetTaskInterface()->AddUITask([a_pass]() { Centre(a_pass + 1); });
}
}
// Called from EventListener when the Loading Menu opens
inline void OnMenuOpen()
{
Centre(0);
}
}