345 lines
18 KiB
C++
345 lines
18 KiB
C++
// Camera options ([Camera] section of DS2Widescreen.ini), Dungeon Siege II (Steam v2.3) and Broken World.
|
|
// See docs/architecture/camera.md.
|
|
//
|
|
// The game camera (siege::SiegeCamera) holds the limits the map's main.gas [camera] block sets at load
|
|
// (WorldMap camera loader, Steam sub_7a3514): max_distance / min_distance (metres), max_azimuth /
|
|
// min_azimuth (degrees above the ground) and fov (degrees, stored in radians). The wheel zoom
|
|
// (UICamera::MouseDeltaZ) and the pitch drag clamp against those fields directly, and every consumer of the
|
|
// field of view (projection, culling frustum, mouse picking) reads the camera's own value, so changing the
|
|
// camera fields keeps the view, the culling and the picking consistent. The field offsets are read at run time
|
|
// from the exported getters (SiegeCamera::GetMaxDistance etc. are "fld [ecx+N]; ret").
|
|
//
|
|
// Once per frame the camera's update (Steam sub_6d2980, called from the frame function: builds the culling
|
|
// frustum and the projection) is hooked. Before it runs:
|
|
// MaxZoom=F the max distance becomes F x the game's value (whatever set it: map load, scripts);
|
|
// MinPitch=D / MaxPitch=D the pitch limits are widened to D (never narrowed);
|
|
// FOV=auto|D|game the game's FOV is horizontal (projection _11 = cot(fov/2), _22 = _11 x aspect), designed
|
|
// for 4:3, so wide screens lose height (21:9: 27 deg vertical instead of 47). auto (default) keeps
|
|
// the 4:3 vertical view (Hor+); a number sets the vertical FOV of the normal game camera (60 deg map
|
|
// FOV), other FOVs (cutscenes) are scaled alike; only the full-screen view is changed;
|
|
// ZoomStep=P fraction of the distance one wheel notch zooms (camera.gas zoom_delta_percentage, 0.25).
|
|
// MaxZoom, MinPitch, MaxPitch and ZoomStep are off unless set.
|
|
// Each field is "shadowed": the value the game last wrote is the base, our value is derived from it, and a
|
|
// value that differs from what we wrote is taken as a new base (map change, scripted camera).
|
|
//
|
|
// Zooming out further must not put the hero into the fog. Fog near/far and the far clip plane are distances from
|
|
// the camera (Rapi::SetFogNearFar, Steam sub_507370; Rapi::SetFarClip, sub_5046c0; with fog on, the projection's
|
|
// far plane is the fog's far distance). The mood update (Steam sub_90734e) already moves the fog with the zoom:
|
|
// every frame it calls SetFogNearFar with the mood's distances plus (camera distance - the camera's max
|
|
// distance), so the fog is tuned for full zoom-out and comes closer when zoomed in. With a raised max distance
|
|
// that offset would pull the fog in by the added range, so the hook adds the added range back: the fog then
|
|
// sits where the game puts it for its own max distance, extended beyond it. Without fog the far clip (the map's
|
|
// farclip, set once at load) is moved out by how far the camera is beyond the game's own max distance. Within
|
|
// the game's own zoom range nothing changes.
|
|
#include "../common/patch.h"
|
|
#include <cmath>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
|
|
namespace widescreen::photo { // photo.cpp: wider limits and its own field of view while photo mode is on
|
|
bool Wanted();
|
|
void NoZoom();
|
|
bool Limits(void* cam, float* zoom, float* minPitch, float* maxPitch); // only for the game's main camera
|
|
void Fov(float gameFov, float aspect, float* out);
|
|
void OnUpdate(void* cam, DWORD fovOff, DWORD limitsOff);
|
|
} // namespace widescreen::photo
|
|
|
|
namespace {
|
|
|
|
constexpr float kPi = 3.14159265f;
|
|
constexpr float kDeg = kPi / 180.0f;
|
|
|
|
// settings (0 = leave the game's value)
|
|
float g_maxZoom = 1.0f;
|
|
float g_minPitch = 0, g_maxPitch = 0;
|
|
int g_fovMode = 0; // 0 game, 1 auto (Hor+), 2 vertical degrees
|
|
float g_fovVertical = 0;
|
|
float g_zoomStep = 0;
|
|
|
|
// SiegeCamera field offsets (from the exported getters)
|
|
DWORD o_fov, o_maxAz, o_minAz, o_maxDist, o_camPos, o_targetPos;
|
|
using ViewportSize_t = unsigned(__fastcall*)(void* cam, void* edx);
|
|
ViewportSize_t g_viewportW, g_viewportH;
|
|
using ViewportNorm_t = float(__fastcall*)(void* cam, void* edx);
|
|
ViewportNorm_t g_viewportNW, g_viewportNH;
|
|
using Singleton_t = void*(__cdecl*)();
|
|
Singleton_t g_uiCamera;
|
|
DWORD o_zoomStep = 0; // UICamera: zoom_delta_percentage (read from UICamera::MouseDeltaZ, which multiplies by it)
|
|
|
|
struct Shadow {
|
|
bool valid = false;
|
|
float base = 0, written = 0;
|
|
// The game's current value: a value we did not write is a new base.
|
|
float Base(float now) {
|
|
if (!valid || now != written) base = now, valid = true;
|
|
return base;
|
|
}
|
|
};
|
|
Shadow s_zoomStep;
|
|
|
|
// The update hook sees several cameras (the world's, the interface's 3D views): each has its own shadows and its own
|
|
// "we wrote this last" flags, so a value written for one is never taken for another's, and photo mode's values
|
|
// (main camera only) are always put back by the camera they were written to.
|
|
struct CamLog {
|
|
float base = 0, now = 0;
|
|
int count = 0;
|
|
};
|
|
struct CamState {
|
|
void* cam = nullptr;
|
|
CamLog log[5]; // what LogChange last wrote for this camera, per field
|
|
Shadow fov, maxAz, minAz, maxDist;
|
|
bool zoomOn = false, minOn = false, maxOn = false, fovOn = false, logged = false;
|
|
};
|
|
CamState g_cams[8];
|
|
int g_camNext = 0;
|
|
|
|
CamState& StateOf(void* cam) {
|
|
for (CamState& c : g_cams)
|
|
if (c.cam == cam) return c;
|
|
CamState& c = g_cams[g_camNext];
|
|
g_camNext = (g_camNext + 1) % 8;
|
|
c = CamState();
|
|
c.cam = cam;
|
|
return c;
|
|
}
|
|
|
|
float& F(void* obj, DWORD off) { return *reinterpret_cast<float*>(static_cast<BYTE*>(obj) + off); }
|
|
|
|
// ---- fog and far clip ------------------------------------------------------------------------------
|
|
using FogNearFar_t = DWORD(__fastcall*)(void* rapi, void* edx, float nearDist, float farDist);
|
|
using FarClip_t = DWORD(__fastcall*)(void* rapi, void* edx, const float* farDist);
|
|
FogNearFar_t g_fogNearFar;
|
|
FarClip_t g_farClip;
|
|
void* g_rapi = nullptr;
|
|
constexpr DWORD kRapiFarClip = 0x109C; // the renderer's far clip (written by SetFarClip; part of its signature)
|
|
float g_extra = 0; // metres the camera is beyond the game's max distance (far clip)
|
|
float g_fogShift = 0; // metres the max distance was raised by (fog: undoes the mood's camera offset)
|
|
|
|
// Only the mood update calls this (both calls include its camera-distance offset).
|
|
DWORD __fastcall HookFogNearFar(void* rapi, void* edx, float nearDist, float farDist) {
|
|
return g_fogNearFar(rapi, edx, nearDist + g_fogShift, farDist + g_fogShift);
|
|
}
|
|
|
|
// Far clip values we passed on and the game's value each came from. The 3D views in the interface (Steam
|
|
// sub_4705a6) save the renderer's far clip, set their own and restore the saved one: that is our value coming
|
|
// back, not a new game value, and must not get the extra distance a second time.
|
|
struct FarWrite {
|
|
float written, base;
|
|
};
|
|
FarWrite g_farWrites[8];
|
|
int g_farNext = 0;
|
|
|
|
float FarBase(float v) {
|
|
for (const FarWrite& w : g_farWrites)
|
|
if (w.written == v && v != 0) return w.base;
|
|
return v;
|
|
}
|
|
|
|
DWORD SetFar(void* rapi, void* edx, float base) {
|
|
float v = base + g_extra;
|
|
g_farWrites[g_farNext] = {v, base};
|
|
g_farNext = (g_farNext + 1) % 8;
|
|
return g_farClip(rapi, edx, &v);
|
|
}
|
|
|
|
DWORD __fastcall HookFarClip(void* rapi, void* edx, const float* farDist) {
|
|
g_rapi = rapi;
|
|
return SetFar(rapi, edx, FarBase(*farDist));
|
|
}
|
|
|
|
void ApplyExtra(float extra) {
|
|
if (std::fabs(extra - g_extra) < 0.25f && !(extra == 0 && g_extra != 0)) return;
|
|
g_extra = extra;
|
|
if (g_rapi) SetFar(g_rapi, nullptr, FarBase(F(g_rapi, kRapiFarClip)));
|
|
}
|
|
|
|
// ---- field of view ---------------------------------------------------------------------------------
|
|
// The game's FOV is horizontal for the whole (any-aspect) screen and was designed on 4:3.
|
|
float WantedFov(float gameFov, float aspect) {
|
|
if (!(gameFov > 0.01f && gameFov < 3.1f) || !(aspect > 0.2f && aspect < 10.0f)) return gameFov;
|
|
float tanV = std::tan(gameFov * 0.5f) * 0.75f; // vertical half-angle tangent the 4:3 design shows
|
|
if (g_fovMode == 2) {
|
|
const float tanRef = std::tan(60.0f * kDeg * 0.5f) * 0.75f; // the maps' 60 deg -> 46.8 deg vertical
|
|
tanV *= std::tan(g_fovVertical * kDeg * 0.5f) / tanRef;
|
|
}
|
|
float h = 2.0f * std::atan(tanV * aspect);
|
|
return h > 170.0f * kDeg ? 170.0f * kDeg : h;
|
|
}
|
|
|
|
// ---- per frame: the camera's update ------------------------------------------------------------------
|
|
using Update_t = DWORD(__fastcall*)(void* cam, void* edx);
|
|
Update_t g_update;
|
|
|
|
// Log a field once per new (game value, our value) pair, per camera (two cameras - or one camera whose value the game
|
|
// sets differently for two passes - alternating every frame must not log every frame: a two-hour game wrote a 66 MB log),
|
|
// and at most kMaxLogged lines per camera and field: after that one closing line says that the rest is not logged.
|
|
constexpr int kMaxLogged = 12;
|
|
void LogChange(CamState& st, int i, const char* fmt, float base, float now, unsigned w = 0, unsigned h = 0) {
|
|
CamLog& l = st.log[i];
|
|
if (l.count > kMaxLogged) return;
|
|
if (std::fabs(l.base - base) > 1e-3f || std::fabs(l.now - now) > 1e-3f) {
|
|
l.base = base, l.now = now;
|
|
if (l.count++ < kMaxLogged) ds2::log().Write(fmt, base, now, w, h);
|
|
else ds2::log().Write("camera: camera %p changes too often - further changes of this value are not logged", st.cam);
|
|
}
|
|
}
|
|
|
|
DWORD __fastcall HookUpdate(void* cam, void* edx) {
|
|
unsigned w = g_viewportW(cam, nullptr), h = g_viewportH(cam, nullptr);
|
|
bool full = w && h && g_viewportNW(cam, nullptr) > 0.99f && g_viewportNH(cam, nullptr) > 0.99f; // the main view
|
|
widescreen::photo::OnUpdate(cam, o_fov, o_maxAz < o_minAz ? o_maxAz : o_minAz);
|
|
CamState& st = StateOf(cam);
|
|
if (!st.logged) { // which cameras the game runs through this update (the world's and the interface's 3D views)
|
|
st.logged = true;
|
|
ds2::log().Write("camera: update hook sees camera %p, viewport %ux%u (%.2f x %.2f of the screen)", cam, w, h, g_viewportNW(cam, nullptr), g_viewportNH(cam, nullptr));
|
|
}
|
|
float zoom = g_maxZoom > 1.0f ? g_maxZoom : 1.0f, minPitch = g_minPitch, maxPitch = g_maxPitch;
|
|
bool photo = widescreen::photo::Limits(cam, &zoom, &minPitch, &maxPitch); // photo mode widens the main camera's limits (never narrows)
|
|
if (zoom > 1.0f || st.zoomOn) {
|
|
float& f = F(cam, o_maxDist);
|
|
float base = st.maxDist.Base(f);
|
|
if (base > 0.5f && base < 1000.0f) {
|
|
f = st.maxDist.written = base * zoom;
|
|
st.zoomOn = zoom > 1.0f;
|
|
g_fogShift = f - base;
|
|
if (zoom > 1.0f) LogChange(st, 0, "camera: max zoom distance %.1f -> %.1f m", base, f);
|
|
const float* c = &F(cam, o_camPos);
|
|
const float* t = &F(cam, o_targetPos);
|
|
float dx = c[0] - t[0], dy = c[1] - t[1], dz = c[2] - t[2];
|
|
float dist = std::sqrt(dx * dx + dy * dy + dz * dz), extra = dist - base;
|
|
float most = f - base;
|
|
ApplyExtra(extra < 0 ? 0 : extra > most ? most : extra);
|
|
}
|
|
}
|
|
if (minPitch > 0 || st.minOn) {
|
|
float& f = F(cam, o_minAz);
|
|
float base = st.minAz.Base(f);
|
|
f = st.minAz.written = minPitch > 0 && minPitch < base ? minPitch : base;
|
|
st.minOn = minPitch > 0;
|
|
if (g_minPitch > 0) LogChange(st, 1, "camera: flattest pitch %.0f -> %.0f deg", base, f);
|
|
}
|
|
if (maxPitch > 0 || st.maxOn) {
|
|
float& f = F(cam, o_maxAz);
|
|
float base = st.maxAz.Base(f);
|
|
f = st.maxAz.written = maxPitch > base ? maxPitch : base;
|
|
st.maxOn = maxPitch > 0;
|
|
if (g_maxPitch > 0) LogChange(st, 2, "camera: steepest pitch %.0f -> %.0f deg", base, f);
|
|
}
|
|
if (g_fovMode || photo || st.fovOn) {
|
|
float& f = F(cam, o_fov);
|
|
float base = st.fov.Base(f);
|
|
if (full) {
|
|
float aspect = static_cast<float>(w) / static_cast<float>(h);
|
|
float want = g_fovMode ? WantedFov(base, aspect) : base;
|
|
if (photo) widescreen::photo::Fov(want, aspect, &want);
|
|
f = st.fov.written = want;
|
|
st.fovOn = g_fovMode || photo;
|
|
if (g_fovMode && !photo) LogChange(st, 3, "camera: horizontal FOV %.1f -> %.1f deg (viewport %ux%u)", base / kDeg, f / kDeg, w, h);
|
|
}
|
|
}
|
|
if (g_zoomStep > 0 && g_uiCamera && o_zoomStep) {
|
|
if (void* ui = g_uiCamera()) {
|
|
float& f = F(ui, o_zoomStep);
|
|
float base = s_zoomStep.Base(f);
|
|
if (base > 0.01f && base < 1.0f) {
|
|
f = s_zoomStep.written = g_zoomStep;
|
|
LogChange(st, 4, "camera: zoom step %.2f -> %.2f", base, f);
|
|
}
|
|
}
|
|
}
|
|
return g_update(cam, edx);
|
|
}
|
|
|
|
// "fld dword [ecx+disp32]; ret" (D9 81) or "lea eax,[ecx+disp32]; ret" (8D 81) at an exported getter.
|
|
DWORD GetterOffset(const char* name, BYTE opcode) {
|
|
auto p = reinterpret_cast<const BYTE*>(GetProcAddress(GetModuleHandleA(nullptr), name));
|
|
if (!p || p[0] != opcode || p[1] != 0x81 || p[6] != 0xC3) return 0;
|
|
return *reinterpret_cast<const DWORD*>(p + 2);
|
|
}
|
|
|
|
float Setting(const ds2::Ini& ini, const char* key) {
|
|
char v[32];
|
|
ini.String("Camera", key, "", v, sizeof(v));
|
|
return static_cast<float>(std::atof(v));
|
|
}
|
|
|
|
bool DetourAt(const char* pattern, size_t len, void* hook, void** original, const char* what) {
|
|
DWORD site = ds2::FindUnique(pattern);
|
|
if (!site) {
|
|
ds2::log().Write("SKIP camera: %s not found", what);
|
|
return false;
|
|
}
|
|
BYTE expect[16];
|
|
std::memcpy(expect, reinterpret_cast<const void*>(site), len);
|
|
*original = ds2::Detour(site, expect, len, hook, what);
|
|
return *original != nullptr;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
namespace widescreen {
|
|
|
|
void InstallCamera(const ds2::Ini& ini) {
|
|
g_maxZoom = Setting(ini, "MaxZoom");
|
|
if (g_maxZoom > 4.0f) g_maxZoom = 4.0f;
|
|
g_minPitch = Setting(ini, "MinPitch");
|
|
if (g_minPitch > 0 && g_minPitch < 5.0f) g_minPitch = 5.0f;
|
|
g_maxPitch = Setting(ini, "MaxPitch");
|
|
if (g_maxPitch > 89.0f) g_maxPitch = 89.0f;
|
|
char fov[32];
|
|
ini.String("Camera", "FOV", "auto", fov, sizeof(fov)); // game = the original (wide screens lose height)
|
|
float deg = static_cast<float>(std::atof(fov));
|
|
if (_stricmp(fov, "auto") == 0) g_fovMode = 1;
|
|
else if (deg >= 20.0f) g_fovMode = 2, g_fovVertical = deg > 100.0f ? 100.0f : deg;
|
|
g_zoomStep = Setting(ini, "ZoomStep");
|
|
if (g_zoomStep > 0.9f) g_zoomStep = 0.9f;
|
|
bool photo = widescreen::photo::Wanted(); // photo mode (photo.cpp) needs the same hooks
|
|
if (!(g_maxZoom > 1.0f) && !g_minPitch && !g_maxPitch && !g_fovMode && !(g_zoomStep > 0) && !photo) return;
|
|
|
|
o_fov = GetterOffset("?GetFieldOfView@SiegeCamera@siege@@QAEMXZ", 0xD9);
|
|
o_maxAz = GetterOffset("?GetMaxAzimuth@SiegeCamera@siege@@QAEMXZ", 0xD9);
|
|
o_minAz = GetterOffset("?GetMinAzimuth@SiegeCamera@siege@@QAEMXZ", 0xD9);
|
|
o_maxDist = GetterOffset("?GetMaxDistance@SiegeCamera@siege@@QAEMXZ", 0xD9);
|
|
o_camPos = GetterOffset("?GetCameraPosition@SiegeCamera@siege@@QBEABUvector_3@@XZ", 0x8D);
|
|
o_targetPos = GetterOffset("?GetTargetPosition@SiegeCamera@siege@@QBEABUvector_3@@XZ", 0x8D);
|
|
HMODULE exe = GetModuleHandleA(nullptr);
|
|
g_viewportW = reinterpret_cast<ViewportSize_t>(GetProcAddress(exe, "?GetViewportWidth@SiegeCamera@siege@@QAEIXZ"));
|
|
g_viewportH = reinterpret_cast<ViewportSize_t>(GetProcAddress(exe, "?GetViewportHeight@SiegeCamera@siege@@QAEIXZ"));
|
|
g_viewportNW = reinterpret_cast<ViewportNorm_t>(GetProcAddress(exe, "?GetViewportNormalizedWidth@SiegeCamera@siege@@QAEMXZ"));
|
|
g_viewportNH = reinterpret_cast<ViewportNorm_t>(GetProcAddress(exe, "?GetViewportNormalizedHeight@SiegeCamera@siege@@QAEMXZ"));
|
|
g_uiCamera = reinterpret_cast<Singleton_t>(GetProcAddress(exe, "?FUBI_GetClassSingleton@UICamera@@CAPAV1@XZ"));
|
|
if (!o_fov || !o_maxAz || !o_minAz || !o_maxDist || !o_camPos || !o_targetPos || !g_viewportW || !g_viewportH ||
|
|
!g_viewportNW || !g_viewportNH) {
|
|
ds2::log().Write("SKIP camera: SiegeCamera getters not as expected - camera options off");
|
|
return;
|
|
}
|
|
ds2::log().Write("camera: SiegeCamera fov +%lX, pitch +%lX..+%lX, max distance +%lX, position +%lX, target +%lX",
|
|
o_fov, o_minAz, o_maxAz, o_maxDist, o_camPos, o_targetPos);
|
|
// UICamera::MouseDeltaZ: "fsqrt; fst [ebp-4]; fmul dword [esi+disp32]" = distance x zoom_delta_percentage.
|
|
if (g_zoomStep > 0) {
|
|
auto z = reinterpret_cast<const BYTE*>(GetProcAddress(exe, "?MouseDeltaZ@UICamera@@QAEMH@Z"));
|
|
for (int i = 0; z && i < 0x100 && !o_zoomStep; ++i)
|
|
if (!std::memcmp(z + i, "\xD9\xFA\xD9\x55\xFC\xD8\x8E", 7)) o_zoomStep = *reinterpret_cast<const DWORD*>(z + i + 7);
|
|
if (!o_zoomStep || !g_uiCamera) ds2::log().Write("SKIP camera: ZoomStep (UICamera zoom code not as expected)");
|
|
}
|
|
|
|
// Further zoom needs the fog and far clip moved out with the camera.
|
|
if (g_maxZoom > 1.0f || photo) {
|
|
bool fog = DetourAt("55 8B EC 51 D9 45 0C 56 D8 5D 08 8B F1 DF E0 F6 C4 41 7B 60 D9 05 ?? ?? ?? ?? C7 45 FC 00 00 00 00",
|
|
7, reinterpret_cast<void*>(HookFogNearFar), reinterpret_cast<void**>(&g_fogNearFar),
|
|
"camera: Rapi::SetFogNearFar (fog follows the zoom)");
|
|
bool clip = DetourAt("55 8B EC 56 8B F1 8B 4D 08 D9 01 D8 9E 98 10 00 00 DF E0 F6 C4 05 0F 8B 8F 00 00 00 8B 01 89 86 9C 10 00 00",
|
|
6,
|
|
reinterpret_cast<void*>(HookFarClip), reinterpret_cast<void**>(&g_farClip),
|
|
"camera: Rapi::SetFarClip (far plane follows the zoom)");
|
|
if (!fog || !clip) {
|
|
ds2::log().Write("SKIP camera: MaxZoom needs both fog hooks - zoom limit left alone");
|
|
g_maxZoom = 1.0f;
|
|
widescreen::photo::NoZoom();
|
|
}
|
|
}
|
|
DetourAt("55 8B EC 83 EC 20 53 56 8B F1 57 8D 8E 38 01 00 00 E8 ?? ?? ?? ?? 84 C0 8D 8E 28 01 00 00 74 23", 6,
|
|
reinterpret_cast<void*>(HookUpdate), reinterpret_cast<void**>(&g_update), "camera: SiegeCamera update");
|
|
}
|
|
|
|
} // namespace widescreen
|