DS2Revival/mods/widescreen/camera.cpp
DS2Revival a1e2f8f9b5
Some checks failed
build / build (push) Failing after 1m35s
CI: build on the project's own runner at git.dsii.uk
2026-10-27 22:02:02 -04:00

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().Detail(fmt, base, now, w, h);
else ds2::log().Detail("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().Detail("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().Detail("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