From 9cc519a8aa32c27540ad8a6df0967d72e1eecfae Mon Sep 17 00:00:00 2001 From: Eddoursul Date: Fri, 31 Jul 2026 00:13:40 +0200 Subject: [PATCH] Added LoadScreenFrameFix --- SKSE/Plugins/EnderalSE.ini | 1 + source/Enderal DLL/src/EventListener.cpp | 2 + source/Enderal DLL/src/EventListener.h | 1 + source/Enderal DLL/src/Main.cpp | 4 +- .../src/Patches/LoadScreenFrameFix.h | 201 ++++++++++++++++++ 5 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 source/Enderal DLL/src/Patches/LoadScreenFrameFix.h diff --git a/SKSE/Plugins/EnderalSE.ini b/SKSE/Plugins/EnderalSE.ini index a602008df..107aa9974 100644 --- a/SKSE/Plugins/EnderalSE.ini +++ b/SKSE/Plugins/EnderalSE.ini @@ -10,3 +10,4 @@ AutoScaleHeroMenu = true WarnFormTypeCollisions = true GogVramLeakFix = true MenuAspectRatioFix = true +LoadScreenFrameFix = true diff --git a/source/Enderal DLL/src/EventListener.cpp b/source/Enderal DLL/src/EventListener.cpp index cdb9f5756..d0d48ed0c 100644 --- a/source/Enderal DLL/src/EventListener.cpp +++ b/source/Enderal DLL/src/EventListener.cpp @@ -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) { diff --git a/source/Enderal DLL/src/EventListener.h b/source/Enderal DLL/src/EventListener.h index 6de877d2b..8fd52aa32 100644 --- a/source/Enderal DLL/src/EventListener.h +++ b/source/Enderal DLL/src/EventListener.h @@ -2,6 +2,7 @@ #include "Patches/DialogueMenuPatch.h" #include "Patches/HeroMenuPatch.h" +#include "Patches/LoadScreenFrameFix.h" #include "Util.h" #include diff --git a/source/Enderal DLL/src/Main.cpp b/source/Enderal DLL/src/Main.cpp index e2070551d..6efe29b1a 100644 --- a/source/Enderal DLL/src/Main.cpp +++ b/source/Enderal DLL/src/Main.cpp @@ -33,7 +33,8 @@ static std::map 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()); diff --git a/source/Enderal DLL/src/Patches/LoadScreenFrameFix.h b/source/Enderal DLL/src/Patches/LoadScreenFrameFix.h new file mode 100644 index 000000000..91f4f64ed --- /dev/null +++ b/source/Enderal DLL/src/Patches/LoadScreenFrameFix.h @@ -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(viewport.width) / static_cast(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); + } +}