diff --git a/source/Enderal DLL/src/Patches/HeroMenuPatch.cpp b/source/Enderal DLL/src/Patches/HeroMenuPatch.cpp index 287de09e6..e5cf744ac 100644 --- a/source/Enderal DLL/src/Patches/HeroMenuPatch.cpp +++ b/source/Enderal DLL/src/Patches/HeroMenuPatch.cpp @@ -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); } diff --git a/source/Enderal DLL/src/Patches/LoadScreenFrameFix.h b/source/Enderal DLL/src/Patches/LoadScreenFrameFix.h index 1f0cd9fc6..3fc0e9cb2 100644 --- a/source/Enderal DLL/src/Patches/LoadScreenFrameFix.h +++ b/source/Enderal DLL/src/Patches/LoadScreenFrameFix.h @@ -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& 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; } - // 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() + // 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; + + // 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 rejected; + std::vector 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,61 +354,52 @@ namespace LoadScreenFrameFix const auto manager = RE::UI3DSceneManager::GetSingleton(); const auto camera = manager ? manager->camera.get() : nullptr; - const auto art = detail::FindArt(); + if (!camera) { + return; + } - if (art.root && camera) { - if (detail::g_state.shape != art.shape) { - detail::g_state = {}; - detail::g_state.shape = art.shape; - } + // 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; + } - 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 }; + if (detail::g_state.shape != art.shape) { + detail::g_state = {}; + detail::g_state.shape = art.shape; + } - float x = 0.0f, y = 0.0f, z = 0.0f; - float probeX = 0.0f, probeY = 0.0f, probeZ = 0.0f; + // Size first - it moves the art, so centring has to measure afterwards + if (detail::Resize(art.root, art.radius, view.get(), aspect)) { + RE::NiUpdateData updateData{}; + art.root->Update(updateData); + } else { + const float delta = (0.5f - art.x) / art.perUnit; - 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; + // ~1 px at 3840 wide; below that the loop has converged + if (std::fabs(delta) > 0.05f) { + const float total = std::clamp(detail::g_state.shift + delta, -150.0f, 150.0f); + const float applied = total - detail::g_state.shift; - 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)) { - RE::NiUpdateData updateData{}; - art.root->Update(updateData); - } else { - const float delta = (0.5f - x) / perUnit; + if (std::fabs(applied) > 0.05f) { + detail::g_state.shift = total; + art.root->local.translate.x += applied; - // ~1 px at 3840 wide; below that the loop has converged - if (std::fabs(delta) > 0.05f) { - 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.root->Update(updateData); - if (std::fabs(applied) > 0.05f) { - detail::g_state.shift = total; - art.root->local.translate.x += applied; - - 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); - } - } + if (!detail::g_state.loggedCentre) { + detail::g_state.loggedCentre = true; + 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; + 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); } } } - } inline void AdvanceMovieEx(RE::IMenu* a_this, float a_interval, std::uint32_t a_currentTime); @@ -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 diff --git a/source/Enderal DLL/src/Patches/MenuAspectRatioFix.h b/source/Enderal DLL/src/Patches/MenuAspectRatioFix.h index 332a8c479..fbf53c4f8 100644 --- a/source/Enderal DLL/src/Patches/MenuAspectRatioFix.h +++ b/source/Enderal DLL/src/Patches/MenuAspectRatioFix.h @@ -1,9 +1,11 @@ #pragma once +#include "Util.h" + #include -// 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(state->screenWidth) / static_cast(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 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& a_viewOut, const char* a_fileName, ScaleModeType a_mode, float a_backgroundAlpha) { + std::optional 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 func; diff --git a/source/Enderal DLL/src/Util.h b/source/Enderal DLL/src/Util.h index 745d7ed48..62e8d3d24 100644 --- a/source/Enderal DLL/src/Util.h +++ b/source/Enderal DLL/src/Util.h @@ -28,6 +28,22 @@ inline std::uint32_t SemVerToInt(std::uint16_t major, std::uint16_t minor, std:: return (static_cast(major) << 24) | (static_cast(minor) << 16) | (static_cast(patch) << 8) | static_cast(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();