diff --git a/source/Enderal DLL/src/EventListener.cpp b/source/Enderal DLL/src/EventListener.cpp index d0d48ed0c..8f2dd8c9d 100644 --- a/source/Enderal DLL/src/EventListener.cpp +++ b/source/Enderal DLL/src/EventListener.cpp @@ -59,6 +59,8 @@ auto EventListener::ProcessEvent( } } else if (a_event->menuName == RE::LoadingMenu::MENU_NAME) { LoadScreenFrameFix::OnMenuOpen(); + } else if (a_event->menuName == RE::MainMenu::MENU_NAME) { + LoadScreenFrameFix::OnMainMenuOpen(); } } else { if (a_event->menuName == RE::DialogueMenu::MENU_NAME) { diff --git a/source/Enderal DLL/src/Patches/LoadScreenFrameFix.h b/source/Enderal DLL/src/Patches/LoadScreenFrameFix.h index 91f4f64ed..1f0cd9fc6 100644 --- a/source/Enderal DLL/src/Patches/LoadScreenFrameFix.h +++ b/source/Enderal DLL/src/Patches/LoadScreenFrameFix.h @@ -34,6 +34,18 @@ // 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. // +// The measurements are read from the billboard *geometry's* bound, never the attachment node's, +// although corrections are written to the attachment node. The attachment node's worldBound +// merges every child, and Enderal's loadscreen NIFs carry fog emitters whose bounds are +// world-space particle bounds: empty on the first frames of a main-menu load, but alive during +// gameplay cell transitions, and they do not follow the node's scale. Measuring the merged bound +// therefore turned Resize into a runaway on cell transitions - shrink the node, re-measure a +// bound the shrink did not change, shrink again - until the art collapsed onto the attachment +// origin (which also collapses the child NIF's translation; the origin projects left of centre +// at mid-height, exactly where the wreckage sat). Belt and braces, every correction is also +// 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. +// // 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 @@ -42,36 +54,85 @@ 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"); - } - } + inline bool g_mainMenuSeen = false; 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() + struct Art + { + RE::NiAVObject* root{ nullptr }; // the menuObjects attachment node - corrections are written here + RE::NiAVObject* shape{ nullptr }; // the billboard geometry - measurements are read here + }; + + // Everything applied to one art instance so far. Long loads swap the art NIF mid-stream + // and the engine can re-apply its own transform underneath, so the totals are keyed on + // the shape pointer (unique per NIF, unlike the attachment node) and reset when it + // changes. + struct State + { + const RE::NiAVObject* shape{ nullptr }; // identity only, never dereferenced + + float scale{ 1.0f }; // cumulative resize factor, clamped to [1/3, 3] + float shift{ 0.0f }; // cumulative X translation, clamped to +/-150 units + + float lastRatio{ 0.0f }; // resize stability gate, see Resize + std::uint32_t stableTicks{ 0 }; + + bool loggedResize{ false }; + bool loggedCentre{ false }; + bool warnedClamp{ false }; + }; + + 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) + { + if (!a_object) { + return nullptr; + } + + if (a_object->AsGeometry()) { + return a_object->AsParticlesGeom() ? nullptr : a_object; + } + + 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; + } + } + } + + return best; + } + + // 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() { const auto manager = RE::UI3DSceneManager::GetSingleton(); if (!manager) { - return nullptr; + return {}; } for (const auto& slot : manager->menuObjects) { const auto object = slot.get(); - if (object && object->worldBound.radius > 0.0f) { - return object; + if (object) { + const auto shape = FindShape(object); + if (shape && shape->worldBound.radius > 0.0f) { + return { object, shape }; + } } } - return nullptr; + return {}; } // 1.5.97 never fits the load screen model to the viewport at all - the billboard grows with @@ -87,42 +148,63 @@ namespace LoadScreenFrameFix // 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) + inline bool Resize(RE::NiAVObject* a_root, float a_projectedRadius, RE::GFxMovieView* a_view, float a_aspect) { 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) { + 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. 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; + // kShowAll fits the 1280x720 stage to the shorter axis; the caller guarantees the + // viewport is wider than 16:9, so the stage covers (16/9)/aspect of the screen width. + const float stageWidth = (16.0f / 9.0f) / a_aspect; const float target = 0.35617f * stageWidth; - const float ratio = target / a_projectedRadius; + 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) { + // Wide deadband: the arts are not all authored at the 794.7-unit width the target is + // derived from (a correct 16:9 load measured 9% under it), while the smallest real + // 1.5.97 defect, 21:9, still needs a ratio of 0.75. + if (ratio > 0.85f && ratio < 1.15f) { + g_state.stableTicks = 0; return false; } - a_art->local.scale *= ratio; + // A measurement taken mid-flight looks exactly like the 1.5.97 defect for a frame or + // two: cell transitions attach the art before the engine's aspect squeeze lands, and + // long loads fade a swapped art in. Only act on a value that has held still for + // several consecutive frames - the real 1.5.97 oversize is permanent, so the one + // legitimate fix is merely delayed ~80 ms, well inside the fade. + if (std::fabs(ratio - g_state.lastRatio) > 0.02f) { + g_state.lastRatio = ratio; + g_state.stableTicks = 1; + return false; + } + if (++g_state.stableTicks < 5) { + return false; + } + g_state.stableTicks = 0; - static bool logged = false; - if (!logged) { - logged = true; + const float total = std::clamp(g_state.scale * ratio, 1.0f / 3.0f, 3.0f); + ratio = total / g_state.scale; + + if (ratio > 0.999f && ratio < 1.001f) { + if (!g_state.warnedClamp) { + g_state.warnedClamp = true; + logger::warn("Loading screen art correction clamped ({:.3f}x scale, {:.1f} units of shift applied) - a measurement is off, not converging", g_state.scale, g_state.shift); + } + return false; + } + + g_state.scale = total; + a_root->local.scale *= ratio; + + if (!g_state.loggedResize) { + g_state.loggedResize = true; logger::info("Loading screen art resized: projected radius {:.4f}, target {:.4f}, scaled by {:.3f}", a_projectedRadius, target, ratio); } @@ -130,15 +212,52 @@ namespace LoadScreenFrameFix } } - // Runs once per frame for as long as the Loading Menu is up, started from EventListener - inline void Centre(std::uint32_t a_pass) + // Runs once per frame from the LoadingMenu::AdvanceMovie hook below. Earlier versions + // self-scheduled through AddUITask instead, seeded from the MenuOpenCloseEvent: that worked + // for main-menu loads, but whatever pumps the SKSE UI task queue does not run during gameplay + // cell transitions - there the loop measured exactly once, at menu-open, *before* the engine + // applies its (16/9)/aspect squeeze, saw the art dead centre, and never ran again to see it + // drift. AdvanceMovie is called every frame the menu animates, on every load path. + inline void Tick() { if (!g_enabled) { return; } + // The Main Menu's 3D background lives in the same menuObjects array, and on a load + // started from the main menu it is the only billboard there until the menu is gone - + // the art attaches later. Correcting it visibly dragged the menu background to centre + // the moment loading began, so while the Main Menu is open (including the tail of a + // quit-to-main-menu load) the scene is left entirely alone; the engine's own placement + // of that background is acceptable on every runtime. const auto ui = RE::UI::GetSingleton(); - if (!ui || !ui->IsMenuOpen(RE::LoadingMenu::MENU_NAME)) { + if (!ui || ui->IsMenuOpen(RE::MainMenu::MENU_NAME)) { + return; + } + + // Game startup runs a Loading Menu as well, and there the background is attached + // *before* the Main Menu first registers as open, where the gate above cannot see it. + // Nothing framed ever loads that early, so stay idle until the Main Menu has existed. + if (!g_mainMenuSeen) { + return; + } + + // At 16:9 and narrower there is nothing to fix on any runtime - the X drift and the + // missing 1.5.97 fit both exist only above 16:9 - while measurement noise (authoring + // variance, mid-attach states) is still there. Stay out entirely. + const auto view = ui->GetMovieView(RE::LoadingMenu::MENU_NAME); + if (!view) { + return; + } + + RE::GViewport viewport; + view->GetViewport(&viewport); + if (viewport.width <= 0 || viewport.height <= 0) { + return; + } + + const float aspect = static_cast(viewport.width) / static_cast(viewport.height); + if (aspect <= 16.0f / 9.0f + 0.005f) { return; } @@ -146,9 +265,14 @@ namespace LoadScreenFrameFix 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; + 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; @@ -162,23 +286,31 @@ namespace LoadScreenFrameFix 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)) { + if (detail::Resize(art.root, std::fabs(perUnit) * radius, view.get(), aspect)) { RE::NiUpdateData updateData{}; - art->Update(updateData); + art.root->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; + const float total = std::clamp(detail::g_state.shift + delta, -150.0f, 150.0f); + const float applied = total - detail::g_state.shift; - RE::NiUpdateData updateData{}; - art->Update(updateData); + if (std::fabs(applied) > 0.05f) { + detail::g_state.shift = total; + art.root->local.translate.x += applied; - static bool logged = false; - if (!logged) { - logged = true; - logger::info("Loading screen art centred: projected x {:.4f}, moved {:.2f} units along X", x, delta); + RE::NiUpdateData updateData{}; + art.root->Update(updateData); + + 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); + } + } else if (!detail::g_state.warnedClamp) { + detail::g_state.warnedClamp = true; + logger::warn("Loading screen art correction clamped ({:.3f}x scale, {:.1f} units of shift applied) - a measurement is off, not converging", detail::g_state.scale, detail::g_state.shift); } } } @@ -186,16 +318,44 @@ namespace LoadScreenFrameFix } } - // 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 AdvanceMovieEx(RE::IMenu* a_this, float a_interval, std::uint32_t a_currentTime); + inline REL::Relocation _AdvanceMovie; + + inline void AdvanceMovieEx(RE::IMenu* a_this, float a_interval, std::uint32_t a_currentTime) + { + _AdvanceMovie(a_this, a_interval, a_currentTime); + Tick(); + } + + // VR is left alone: a headset has no pillarbox, and the rest of the widescreen work + // (MenuAspectRatioFix) is desktop-only as well + inline void Install(bool a_enabled) + { + if (!a_enabled || REL::Module::IsVR()) { + return; + } + + g_enabled = true; + + REL::Relocation vtbl(RE::VTABLE_LoadingMenu[0]); + _AdvanceMovie = vtbl.write_vfunc(0x5, &AdvanceMovieEx); + + logger::info("Loading screen frame fix armed"); + } + + // Called from EventListener when the Loading Menu opens: forget the previous load's totals. + // Node addresses can be reused across loads, so the pointer-identity reset alone is not enough. inline void OnMenuOpen() { - Centre(0); + detail::g_state = {}; + } + + // Called from EventListener when the Main Menu opens. Until that first happens, any billboard + // in the UI3D scene is the menu background being staged during startup, not loading art. + inline void OnMainMenuOpen() + { + g_mainMenuSeen = true; } }