Compatibility with widescreen-authored UI mods
This commit is contained in:
parent
ca5d666b63
commit
cf84e2f026
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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<RE::NiAVObject*>& 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<Reject> rejected;
|
||||
std::vector<RE::NiAVObject*> 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
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "Util.h"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
// 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<float>(state->screenWidth) / static_cast<float>(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<std::string, CaseInsensitiveLess> 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<RE::GFxMovieView>& a_viewOut, const char* a_fileName, ScaleModeType a_mode, float a_backgroundAlpha)
|
||||
{
|
||||
std::optional<ScaleModeType> 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<decltype(thunk)> func;
|
||||
|
||||
@ -28,6 +28,22 @@ inline std::uint32_t SemVerToInt(std::uint16_t major, std::uint16_t minor, std::
|
||||
return (static_cast<std::uint32_t>(major) << 24) | (static_cast<std::uint32_t>(minor) << 16) | (static_cast<std::uint32_t>(patch) << 8) | static_cast<std::uint32_t>(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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user