Compare commits

...

5 Commits

8 changed files with 393 additions and 1 deletions

View File

@ -9,3 +9,5 @@ AttachLightHitEffectCrashFix = true
AutoScaleHeroMenu = true AutoScaleHeroMenu = true
WarnFormTypeCollisions = true WarnFormTypeCollisions = true
GogVramLeakFix = true GogVramLeakFix = true
MenuAspectRatioFix = true
LoadScreenFrameFix = true

BIN
interface/inventory components/bottombar.swf (Stored with Git LFS) Normal file

Binary file not shown.

View File

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

View File

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

View File

@ -17,6 +17,7 @@
#include "Patches/FormTypeCollisionDetector.h" #include "Patches/FormTypeCollisionDetector.h"
#include "Patches/GogVramLeakFix.h" #include "Patches/GogVramLeakFix.h"
#include "Patches/ForceLanguage.h" #include "Patches/ForceLanguage.h"
#include "Patches/MenuAspectRatioFix.h"
using namespace SKSE; using namespace SKSE;
@ -31,7 +32,9 @@ static std::map<std::string, bool> g_settings{
{ "ForceBorderless", true }, { "ForceBorderless", true },
{ "AttachLightHitEffectCrashFix", true }, { "AttachLightHitEffectCrashFix", true },
{ "AutoScaleHeroMenu", true }, { "AutoScaleHeroMenu", true },
{ "GogVramLeakFix", true } { "GogVramLeakFix", true },
{ "MenuAspectRatioFix", true },
{ "LoadScreenFrameFix", true }
}; };
namespace { namespace {
@ -252,6 +255,10 @@ SKSEPluginLoad(const LoadInterface* skse) {
logger::info("Initializing Stay At The System Page..."); logger::info("Initializing Stay At The System Page...");
JournalMenuEx::InstallHooks(); JournalMenuEx::InstallHooks();
} }
if (g_settings.at("MenuAspectRatioFix")) {
MenuAspectRatioFix::Install();
}
LoadScreenFrameFix::Install(g_settings.at("LoadScreenFrameFix"));
} }
logger::info("{} has finished loading.", plugin->GetName()); logger::info("{} has finished loading.", plugin->GetName());

View File

@ -37,4 +37,41 @@ namespace SKSE::stl
auto& trampoline = SKSE::GetTrampoline(); auto& trampoline = SKSE::GetTrampoline();
T::func = trampoline.write_branch<N>(a_src, T::thunk); T::func = trampoline.write_branch<N>(a_src, T::thunk);
} }
// Detours a whole function: the first BYTES bytes are copied into the trampoline and followed by
// a jump back into the original, so T::func is a callable stand-in for the untouched function.
// BYTES must cover complete, position-independent instructions and be at least 5.
// Costs 14 bytes of trampoline for the branch island plus BYTES + 14 for the stub.
// Thanks Nukem and po3, via Widescreen Scale Removed by SkyHorizon (GPL-3.0).
template <class T, std::size_t BYTES>
void hook_function_prologue(std::uintptr_t a_src)
{
static_assert(BYTES >= 5, "not enough room for a jump");
// Xbyak::CodeGenerator has a member function std() - the STD instruction - which hides the
// namespace inside the class body, hence the leading :: on every std:: name below
struct Stub : Xbyak::CodeGenerator
{
Stub(std::uintptr_t a_target, std::size_t a_size)
{
for (::std::size_t i = 0; i < a_size; ++i) {
db(*reinterpret_cast<const ::std::uint8_t*>(a_target + i));
}
jmp(ptr[rip]);
dq(a_target + a_size);
}
};
Stub stub(a_src, BYTES);
stub.ready();
auto& trampoline = SKSE::GetTrampoline();
auto* mem = trampoline.allocate(stub.getSize());
std::memcpy(mem, stub.getCode(), stub.getSize());
// Publish the stand-in before redirecting the function, the thunk needs it on its first call
T::func = reinterpret_cast<std::uintptr_t>(mem);
trampoline.write_branch<5>(a_src, T::thunk);
}
} }

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);
}
}

View File

@ -0,0 +1,139 @@
#pragma once
// Two independent corrections to the Scaleform scale mode menus are loaded with, both 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
// aspect ratio. Enderal's menus - including the SkyUI-derived widescreen versions shipped
// with them - are authored for the old modes, so on anything that is not 16:9 they come out
// 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.
//
// 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,
// CursorMenu, FaderMenu, StartMenu) are absent and keep whatever the caller asked for. It doubles
// as the list of menus this patch is allowed to touch at all, so on older runtimes - where the
// engine already passes these very modes - a third-party menu loaded with kNoBorder is left alone.
//
// Based on Widescreen Scale Removed by SkyHorizon (GPL-3.0)
// https://www.nexusmods.com/skyrimspecialedition/mods/136793
namespace MenuAspectRatioFix
{
using ScaleModeType = RE::GFxMovieView::ScaleModeType;
// True on the runtimes that stretch menus to fill the viewport, i.e. 1.6.1130 and later. Below
// that the engine already asks for the modes in the table, so only the clamp has work to do.
inline bool g_restoreScaleModes = false;
std::optional<ScaleModeType> GetScaleMode(const char* a_fileName)
{
static const std::map<std::string_view, ScaleModeType> modes{
{ "FavoritesMenu"sv, ScaleModeType::kShowAll },
{ "HUDMenu"sv, ScaleModeType::kShowAll },
{ "LevelUpMenu"sv, ScaleModeType::kShowAll },
{ "LoadWaitSpinner"sv, ScaleModeType::kShowAll },
{ "LoadingMenu"sv, ScaleModeType::kShowAll },
{ "Map"sv, ScaleModeType::kShowAll },
{ "Quest_Journal"sv, ScaleModeType::kShowAll },
{ "SafeZone"sv, ScaleModeType::kShowAll },
{ "SleepWaitMenu"sv, ScaleModeType::kShowAll },
{ "Titles"sv, ScaleModeType::kShowAll },
{ "TutorialMenu"sv, ScaleModeType::kShowAll },
{ "TweenMenu"sv, ScaleModeType::kShowAll },
{ "BarterMenu"sv, ScaleModeType::kNoBorder },
{ "BookMenu"sv, ScaleModeType::kNoBorder },
{ "ContainerMenu"sv, ScaleModeType::kNoBorder },
{ "CraftingMenu"sv, ScaleModeType::kNoBorder },
{ "DialogueMenu"sv, ScaleModeType::kNoBorder },
{ "GiftMenu"sv, ScaleModeType::kNoBorder },
{ "InventoryMenu"sv, ScaleModeType::kNoBorder },
{ "LockpickingMenu"sv, ScaleModeType::kNoBorder },
{ "MagicMenu"sv, ScaleModeType::kNoBorder },
{ "MessageBox"sv, ScaleModeType::kNoBorder },
{ "RaceSex_menu"sv, ScaleModeType::kNoBorder },
{ "StatsMenu"sv, ScaleModeType::kNoBorder },
{ "TrainingMenu"sv, ScaleModeType::kNoBorder },
// Replaces CraftingMenu, https://www.nexusmods.com/skyrimspecialedition/mods/81409
{ "ConstructibleObjectMenu"sv, ScaleModeType::kNoBorder }
};
const auto it = modes.find(a_fileName);
return it != modes.end() ? std::optional{ it->second } : std::nullopt;
}
// The movie gets the whole framebuffer as its viewport, so kNoBorder scales a 1280x720 menu
// until it covers the full width and crops whatever no longer fits: at 32:9 that is a 2x zoom
// with half the menu off screen. Past 16:9 it can only ever crop, so hand out kShowAll instead -
// that keeps the 16:9 scale and merely leaves more of the stage surroundings visible, which is
// exactly what SkyUI's widescreen menus stretch their bars over (they cover up to 32:9).
// Narrower than 16:9 kNoBorder is still the better answer: it hides the strips above and below
// the stage that kShowAll would expose.
ScaleModeType ClampToViewport(ScaleModeType a_mode)
{
if (a_mode != ScaleModeType::kNoBorder) {
return a_mode;
}
const auto state = RE::BSGraphics::State::GetSingleton();
if (!state || state->screenHeight == 0) {
return a_mode;
}
// Every vanilla menu is authored on a 1280x720 stage
constexpr float stageAspect = 1280.0f / 720.0f;
const float viewportAspect = static_cast<float>(state->screenWidth) / static_cast<float>(state->screenHeight);
return viewportAspect > stageAspect ? ScaleModeType::kShowAll : a_mode;
}
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)
{
if (a_fileName && a_fileName[0]) {
if (const auto mode = GetScaleMode(a_fileName); mode) {
a_mode = ClampToViewport(g_restoreScaleModes ? *mode : a_mode);
}
}
return func(a_scaleformManager, a_menu, a_viewOut, a_fileName, a_mode, a_backgroundAlpha);
}
static inline REL::Relocation<decltype(thunk)> func;
};
void Install()
{
g_restoreScaleModes = REL::Module::get().version() >= REL::Version(1, 6, 1130, 0);
// Checked: 1.5.97, 1.6.640, 1.6.659, 1.6.1130, 1.6.1170, 1.6.1179
const auto target = REL::RelocationID(80302, 82325).address();
// mov rax,rsp followed by one argument spill - 7 bytes of position-independent code, which
// the hook relocates into the trampoline. The spilled argument differs between the runtime
// families, so accept either and bail out rather than corrupt the function if a future
// build changes the prologue again.
constexpr std::uint8_t prologueAE[]{ 0x48, 0x8B, 0xC4, 0x48, 0x89, 0x50, 0x10 }; // mov [rax+10h],rdx
constexpr std::uint8_t prologueSE[]{ 0x48, 0x8B, 0xC4, 0x4C, 0x89, 0x40, 0x18 }; // mov [rax+18h],r8
static_assert(sizeof(prologueAE) == sizeof(prologueSE));
const auto prologue = reinterpret_cast<const void*>(target);
if (!target ||
(std::memcmp(prologue, prologueAE, sizeof(prologueAE)) != 0 &&
std::memcmp(prologue, prologueSE, sizeof(prologueSE)) != 0)) {
logger::error("Unexpected BSScaleformManager::LoadMovie prologue, menu aspect ratio fix is disabled");
return;
}
SKSE::AllocTrampoline(64);
SKSE::stl::hook_function_prologue<LoadMovie, sizeof(prologueAE)>(target);
logger::info("Applied menu aspect ratio fix{}", g_restoreScaleModes ? "" : " (viewport clamp only)");
}
}