DS2Revival/mods/widescreen/photo.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

755 lines
39 KiB
C++

// Photo mode ([Photo] section of DS2Widescreen.ini), Dungeon Siege II (Steam v2.3) and Broken World.
// See docs/architecture/photo-mode.md and docs/roadmap/photo-mode.md.
//
// One key (default Insert) toggles photo mode, a second key (default Return) saves a PNG, Escape leaves it:
// - single player: the game is paused (AppModule::UserPause, the engine's own pause; if the player had paused the
// game already it stays paused afterwards). Multiplayer: nothing is paused, the world goes on;
// - the interface is hidden: UIShell::SetVisible(false) (the windows: HUD, portraits, tooltips, console text),
// UIGame::SetRadarVisible(false), and in multiplayer the other players' names above their heads (UIGame flag
// +0x4c0, tested by the interface pass; it is the same switch as the "toggle_player_labels" key);
// - the camera limits are widened through camera.cpp's shadowing (Limits): further zoom, flatter and steeper
// pitch; the field of view is the photo mode's own (Fov, adjustable with two keys). camera.cpp puts the
// game's own values back when photo mode ends, so nothing of the game's camera data is rewritten by hand;
// - left and right clicks are not passed on (a click would otherwise order the party about);
// - the picture is the back buffer at the point where the 3D world is finished and the interface not drawn yet
// (the shared hub's world-finished point, d3dhub.h: the same as the anti-aliasing of DS2Graphics, which runs first):
// no interface pixel can be in it. It is
// written as a PNG into the folder the engine takes its own screenshots to (RapiAppModule's "%shots_path%").
// The point exists in every [Display] Mode; only if the hub cannot install it there is no picture (log line).
// On leaving, the pause, the interface, the radar, the names and the camera state (the camera data as it was
// when photo mode began) are put back.
#include "../common/patch.h"
#include <d3d9.h>
#include <cctype>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <ctime>
namespace widescreen::photo {
void Install(const ds2::Ini& ini);
bool Wanted();
bool Active();
void NoZoom();
bool Limits(void* cam, float* zoom, float* minPitch, float* maxPitch);
void Fov(float gameFov, float aspect, float* out);
void OnUpdate(void* cam, DWORD fovOff, DWORD limitsOff);
void WorldDone(IDirect3DDevice9* dev);
} // namespace widescreen::photo
namespace {
constexpr float kPi = 3.14159265f;
constexpr float kDeg = kPi / 180.0f;
// ---- settings ------------------------------------------------------------------------------------------
bool g_wanted = false;
int g_keyToggle = VK_INSERT, g_keyCapture = VK_RETURN, g_keyFovWider = VK_PRIOR, g_keyFovNarrower = VK_NEXT, g_keyFree = VK_DELETE;
float g_freeSpeed = 6.0f, g_freeRadius = 0.0f; // free camera: metres per second; 0 = from the map's world frustum radius
float g_zoom = 3.0f, g_minPitch = 10.0f, g_maxPitch = 89.0f;
float g_fovMin = 20.0f, g_fovMax = 110.0f, g_fovSpeed = 30.0f; // vertical degrees; degrees per second
// ---- engine entry points (exports; found by name in both exes) --------------------------------------------
using Singleton_t = void*(__cdecl*)();
using ThisBool_t = void(__fastcall*)(void* self, void* edx, int value);
using ThisRet_t = int(__fastcall*)(void* self, void* edx); // bool results are in AL only: mask with & 0xFF
using Free_t = int(__cdecl*)();
using FreeInt_t = int(__cdecl*)(int);
using GetCamera_t = void*(__fastcall*)(void* engine, void* edx);
GetCamera_t g_getCamera; // siege::SiegeEngine::GetCamera: the game's one main camera (the update hook also sees the interface's own cameras)
Singleton_t g_engineSingleton, g_appSingleton, g_shellSingleton, g_gameSingleton, g_uiCameraSingleton, g_worldStateSingleton, g_rapiSingleton;
ThisBool_t g_userPause, g_setVisible, g_setRadarVisible;
ThisRet_t g_isUserPaused, g_currentState;
Free_t g_isMultiPlayer;
FreeInt_t g_inGameInteractive;
using Convert_t = void(__cdecl*)(void* pos, const void* node); // ConvertSiegePosToNode(SiegePos&, database_guid const&): in place
using PtrThis_t = void*(__fastcall*)(void* self, void* edx);
using FloatThis_t = float(__fastcall*)(void* self, void* edx);
Convert_t g_convert;
Singleton_t g_serverSingleton, g_worldMapSingleton;
PtrThis_t g_getScreenHero, g_getPlacement, g_getPosition;
FloatThis_t g_frustumRadius;
DWORD o_camSiegePos = 0, o_tgtSiegePos = 0; // SiegeCamera: the camera's and the target's SiegePos (0x28 bytes: x y z, pad, node guid at +0x10)
DWORD o_shellVisible = 0; // UIShell: the flag UIShell::SetVisible writes
DWORD o_radar = 0; // UIGame: the radar object (radar+0 = visible)
DWORD o_labels = 0; // UIGame: show names of the other players (multiplayer), tested by the interface pass
bool g_hooked = false; // the hub's world-finished point (d3dhub.h) has been reached at least once
ds2::Ini* g_ini = nullptr;
// [PhotoDebug] switches (read at every toggle; diagnostics): SkipPause, SkipShell, SkipRadar, SkipLabels, SkipCameraRestore
bool Dbg(const char* key) { return g_ini && g_ini->Bool("PhotoDebug", key, false); }
template <class T>
T Export(const char* name) {
return reinterpret_cast<T>(GetProcAddress(GetModuleHandleA(nullptr), name));
}
bool Readable(const void* p, size_t n) {
MEMORY_BASIC_INFORMATION mbi;
if (!p || !VirtualQuery(p, &mbi, sizeof(mbi)) || mbi.State != MEM_COMMIT || (mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD))) return false;
return static_cast<const BYTE*>(p) + n <= static_cast<const BYTE*>(mbi.BaseAddress) + mbi.RegionSize;
}
// ---- state ---------------------------------------------------------------------------------------------
bool g_on = false;
bool g_mp = false;
bool g_pausedByUs = false, g_wasPaused = false;
bool g_shellWas = true, g_radarWas = true, g_labelsWas = false;
void* g_cam = nullptr; // SiegeCamera (from camera.cpp's update hook)
DWORD g_fovOff = 0, g_limitsOff = 0;
constexpr DWORD kCamFrom = 0x18, kUiFrom = 0x10, kUiTo = 0x200;
BYTE g_camSave[0x400], g_uiSave[0x400]; // the camera data as it was when photo mode began
void* g_uiCam = nullptr;
float g_fovV = 0; // vertical degrees in photo mode
bool g_fovInit = false;
bool g_fovWider = false, g_fovNarrower = false;
LARGE_INTEGER g_fovT{};
volatile LONG g_shotRequested = 0;
// Selection rings and the hover outline are world effects drawn before the picture point. For the picture only they are switched
// off for two frames: the rings by UIGame's own flag (+0x85, tested by the ring drawing, Steam sub_46fdd0), the outline by skipping the
// screen-space quad that fills it (Steam call site 0x6db0c9 of sub_6da860 -> sub_52bb20). Both are put back right after the picture.
bool g_hideSelection = true;
DWORD o_ringOff = 0; // UIGame: "do not draw the rings"
using OutlineQuad_t = void(__cdecl*)(int, int, int, int, int, int, int);
OutlineQuad_t g_outlineQuad = nullptr;
bool g_outlineHidden = false;
int g_shotStage = 0; // 0 idle, then frames until the picture is taken
BYTE g_ringWas = 0;
void __cdecl HookOutlineQuad(int a, int b, int c, int d, int e, int f, int g) {
if (!g_outlineHidden && g_outlineQuad) g_outlineQuad(a, b, c, d, e, f, g);
}
bool g_keyDown[256];
// free camera (K2)
bool g_free = false, g_atEdge = false, g_look = false;
BYTE g_sp[2][0x28]; // camera and target SiegePos in the node of the camera at detach
float g_yaw = 0, g_pitch = 0, g_wheel = 0, g_mouseDx = 0, g_mouseDy = 0;
LARGE_INTEGER g_freeT{};
POINT g_lookLast{};
HWND g_wnd = nullptr;
WNDPROC g_orig = nullptr;
void* Obj(Singleton_t s) { return s ? s() : nullptr; }
bool InteractiveGame() {
void* ws = Obj(g_worldStateSingleton);
return ws && g_currentState && g_inGameInteractive && (g_inGameInteractive(g_currentState(ws, nullptr)) & 0xFF) != 0;
}
BYTE* ShellFlag() {
void* s = Obj(g_shellSingleton);
return s && o_shellVisible ? static_cast<BYTE*>(s) + o_shellVisible : nullptr;
}
BYTE* RadarFlag() {
void* g = Obj(g_gameSingleton);
if (!g || !o_radar) return nullptr;
BYTE* radar = *reinterpret_cast<BYTE**>(static_cast<BYTE*>(g) + o_radar);
return Readable(radar, 4) ? radar : nullptr;
}
BYTE* LabelsFlag() {
void* g = Obj(g_gameSingleton);
return g && o_labels ? static_cast<BYTE*>(g) + o_labels : nullptr;
}
void SetUserPaused(bool pause) {
void* app = Obj(g_appSingleton);
if (app) g_userPause(app, nullptr, pause ? 1 : 0);
}
bool UserPaused() {
void* app = Obj(g_appSingleton);
return app && (g_isUserPaused(app, nullptr) & 0xFF) != 0;
}
// ---- free camera (K2) --------------------------------------------------------------------------------------
// The camera and the target are SiegePos (x y z in the local space of a node, plus the node's guid). At detach both are
// taken into the space of the camera's node (ConvertSiegePosToNode) and from then on the fly controller writes them before
// every engine camera update, which turns them into world positions and builds the culling frustum: the game's own tracking
// of the party goes on underneath and is overwritten each time. A node's local space is a translation and a turn about the
// vertical axis of the world, so the controller moves in it directly (y is up). The camera may not leave the loaded part of
// the world: it is held within a radius of the party (the map's world_frustum_radius x 0.85).
bool HeroSiegePos(BYTE* out) {
void* server = Obj(g_serverSingleton);
if (!server || !g_getScreenHero || !g_getPlacement || !g_getPosition) return false;
void* hero = g_getScreenHero(server, nullptr);
void* placement = hero ? g_getPlacement(hero, nullptr) : nullptr;
const BYTE* pos = placement ? static_cast<const BYTE*>(g_getPosition(placement, nullptr)) : nullptr;
if (!pos || !Readable(pos, 0x28)) return false;
std::memcpy(out, pos, 0x28);
return true;
}
float FreeRadius() {
if (g_freeRadius > 0) return g_freeRadius;
void* wm = Obj(g_worldMapSingleton);
float r = wm && g_frustumRadius ? g_frustumRadius(wm, nullptr) : 0.0f;
return r > 8.0f && r < 500.0f ? r * 0.85f : 30.0f;
}
void EnterFree() {
if (g_free || !g_cam || !o_camSiegePos || !o_tgtSiegePos || !g_convert) return;
BYTE* c = static_cast<BYTE*>(g_cam);
std::memcpy(g_sp[0], c + o_camSiegePos, 0x28);
std::memcpy(g_sp[1], c + o_tgtSiegePos, 0x28);
g_convert(g_sp[1], g_sp[0] + 0x10); // the target in the camera's node
const float* p = reinterpret_cast<const float*>(g_sp[0]);
const float* t = reinterpret_cast<const float*>(g_sp[1]);
float dx = t[0] - p[0], dy = t[1] - p[1], dz = t[2] - p[2], len = std::sqrt(dx * dx + dy * dy + dz * dz);
if (len < 1e-3f) dx = 0, dy = -0.6f, dz = 0.8f, len = 1.0f;
g_yaw = std::atan2(dx, dz);
g_pitch = std::asin(dy / len);
g_wheel = g_mouseDx = g_mouseDy = 0;
g_look = false;
g_atEdge = false;
QueryPerformanceCounter(&g_freeT);
g_free = true;
ds2::log().Detail("photo: free camera on at %.2f %.2f %.2f (node space of the camera), yaw %.0f pitch %.0f deg, held within %.1f m of the party", p[0], p[1], p[2],
g_yaw / kDeg, g_pitch / kDeg, FreeRadius());
}
void LeaveFree() {
if (!g_free) return;
g_free = false;
g_look = false;
const float* p = reinterpret_cast<const float*>(g_sp[0]);
ds2::log().Detail("photo: free camera off (it was at %.2f %.2f %.2f); the party camera comes back", p[0], p[1], p[2]);
}
// The main camera, before each engine update while the free camera is on.
void FreeStep(void* cam) {
LARGE_INTEGER now, freq;
QueryPerformanceCounter(&now);
QueryPerformanceFrequency(&freq);
float dt = static_cast<float>(now.QuadPart - g_freeT.QuadPart) / static_cast<float>(freq.QuadPart);
g_freeT = now;
if (dt > 0.1f) dt = 0.1f;
if (dt < 0) dt = 0;
auto key = [](int vk) { return g_keyDown[vk] ? 1.0f : 0.0f; };
g_yaw += (key(VK_RIGHT) - key(VK_LEFT)) * 1.2f * dt + g_mouseDx * 0.004f;
g_pitch += (key(VK_UP) - key(VK_DOWN)) * 0.8f * dt - g_mouseDy * 0.004f;
g_mouseDx = g_mouseDy = 0;
if (g_pitch > 1.5f) g_pitch = 1.5f;
if (g_pitch < -1.5f) g_pitch = -1.5f;
float speed = g_freeSpeed * (g_keyDown[VK_SHIFT] ? 4.0f : 1.0f) * (g_keyDown[VK_CONTROL] ? 0.25f : 1.0f);
float fwd = key('W') - key('S'), side = key('D') - key('A'), vert = key('R') - key('F');
float notch = g_wheel * 3.0f; // a wheel notch = 3 m along the view direction
g_wheel = 0;
float sy = std::sin(g_yaw), cy = std::cos(g_yaw), cp = std::cos(g_pitch), sp = std::sin(g_pitch);
float* p = reinterpret_cast<float*>(g_sp[0]);
float* t = reinterpret_cast<float*>(g_sp[1]);
float step = speed * dt;
p[0] += (sy * fwd + cy * side) * step + sy * cp * notch; // forward over the ground, right = (cos, 0, -sin)
p[2] += (cy * fwd - sy * side) * step + cy * cp * notch;
p[1] += vert * step + sp * notch;
// the loaded part of the world: within the radius of the party over the ground plane, and not far above or below it
BYTE hero[0x28];
if (HeroSiegePos(hero)) {
g_convert(hero, g_sp[0] + 0x10);
const float* h = reinterpret_cast<const float*>(hero);
float dx = p[0] - h[0], dz = p[2] - h[2], r = FreeRadius(), d = std::sqrt(dx * dx + dz * dz);
bool edge = false;
if (d > r) {
p[0] = h[0] + dx * r / d;
p[2] = h[2] + dz * r / d;
edge = true;
}
if (p[1] > h[1] + r) p[1] = h[1] + r, edge = true;
if (p[1] < h[1] - 6.0f) p[1] = h[1] - 6.0f, edge = true;
if (edge && !g_atEdge) ds2::log().Detail("photo: free camera at the edge of the loaded area (%.1f m from the party): it stops here", d > r ? r : d);
g_atEdge = edge;
}
t[0] = p[0] + sy * cp * 10.0f;
t[1] = p[1] + sp * 10.0f;
t[2] = p[2] + cy * cp * 10.0f;
BYTE* c = static_cast<BYTE*>(cam);
std::memcpy(c + o_camSiegePos, g_sp[0], 0x28);
std::memcpy(c + o_tgtSiegePos, g_sp[1], 0x28);
}
void LogCamera(const char* when) {
if (!g_cam || !Readable(g_cam, 0x1f0)) return;
const float* f = static_cast<const float*>(g_cam);
const float* u = g_uiCam && Readable(g_uiCam, 0x200) ? static_cast<const float*>(g_uiCam) : nullptr;
ds2::log().Detail("photo: camera %s: position %.2f %.2f %.2f, target %.2f %.2f %.2f, distance field %.2f, limits %.0f..%.0f %.1f..%.1f%s", when,
f[0x178 / 4], f[0x17C / 4], f[0x180 / 4], f[0x184 / 4], f[0x188 / 4], f[0x18C / 4], f[0x19C / 4], f[g_limitsOff / 4 + 1],
f[g_limitsOff / 4], f[g_limitsOff / 4 + 2], f[g_limitsOff / 4 + 3], u ? "" : " (no UI camera)");
if (u) ds2::log().Detail("photo: UICamera %s: orientation %.3f %.3f %.3f %.3f, +1f0 %.2f", when, u[0x60 / 4], u[0x64 / 4], u[0x68 / 4], u[0x6C / 4], u[0x1F0 / 4]);
}
void Enter() {
if (g_on) return;
void* engine = Obj(g_engineSingleton);
g_cam = engine ? g_getCamera(engine, nullptr) : nullptr;
if (!g_cam || !g_limitsOff || !InteractiveGame() || !Readable(g_cam, g_limitsOff)) {
ds2::log().Write("photo: not now (%s)", InteractiveGame() ? "camera not seen yet" : "not in a game");
return;
}
g_uiCam = Obj(g_uiCameraSingleton);
if (g_uiCam && !Readable(static_cast<BYTE*>(g_uiCam) + kUiFrom, kUiTo - kUiFrom)) g_uiCam = nullptr;
LogCamera("before");
// the camera data as it is now (put back when photo mode ends)
std::memcpy(g_camSave, g_cam, g_limitsOff);
if (g_uiCam) std::memcpy(g_uiSave, g_uiCam, kUiTo);
g_mp = g_isMultiPlayer ? (g_isMultiPlayer() & 0xFF) != 0 : true; // unknown: never pause
g_wasPaused = UserPaused();
g_pausedByUs = false;
if (!g_mp && !g_wasPaused && !Dbg("SkipPause")) {
SetUserPaused(true);
g_pausedByUs = UserPaused();
}
if (BYTE* f = ShellFlag()) g_shellWas = *f != 0;
if (g_setVisible && Obj(g_shellSingleton) && !Dbg("SkipShell")) g_setVisible(Obj(g_shellSingleton), nullptr, 0);
if (BYTE* r = RadarFlag()) g_radarWas = *r != 0;
if (g_setRadarVisible && Obj(g_gameSingleton) && !Dbg("SkipRadar")) g_setRadarVisible(Obj(g_gameSingleton), nullptr, 0);
if (BYTE* l = LabelsFlag()) {
g_labelsWas = *l != 0;
if (!Dbg("SkipLabels")) *l = 0;
}
g_fovInit = false;
g_on = true;
ds2::log().Write("photo: on (%s, %s; interface hidden, shell was %s, radar was %s, names were %s)", g_mp ? "multiplayer" : "single player",
g_pausedByUs ? "game paused" : g_mp ? "game not paused" : "game was paused already", g_shellWas ? "shown" : "hidden",
g_radarWas ? "shown" : "hidden", g_labelsWas ? "shown" : "hidden");
}
void Leave(const char* why) {
if (!g_on) return;
g_on = false; // camera.cpp's next update no longer widens anything and puts the game's values back
g_fovInit = false;
g_fovWider = g_fovNarrower = false;
LeaveFree();
void* engine = Obj(g_engineSingleton);
bool sameWorld = InteractiveGame() && g_cam && engine && g_getCamera(engine, nullptr) == g_cam; // not freed by a map change
LogCamera("at the end of photo mode");
if (sameWorld && !Dbg("SkipCameraRestore") && !Dbg("SkipSiegeRestore")) {
// camera state back to what it was; the field of view and the limits are camera.cpp's (shadowed)
BYTE* c = static_cast<BYTE*>(g_cam);
std::memcpy(c + kCamFrom, g_camSave + kCamFrom, g_fovOff - kCamFrom);
std::memcpy(c + g_fovOff + 4, g_camSave + g_fovOff + 4, g_limitsOff - (g_fovOff + 4));
}
if (sameWorld && !Dbg("SkipCameraRestore") && !Dbg("SkipUiRestore") && g_uiCam && g_uiCam == Obj(g_uiCameraSingleton) && Readable(static_cast<BYTE*>(g_uiCam) + kUiFrom, kUiTo - kUiFrom))
std::memcpy(static_cast<BYTE*>(g_uiCam) + kUiFrom, g_uiSave + kUiFrom, kUiTo - kUiFrom);
if (BYTE* l = LabelsFlag()) *l = g_labelsWas ? 1 : 0;
if (g_radarWas && g_setRadarVisible && Obj(g_gameSingleton)) g_setRadarVisible(Obj(g_gameSingleton), nullptr, 1);
if (g_shellWas && g_setVisible && Obj(g_shellSingleton)) g_setVisible(Obj(g_shellSingleton), nullptr, 1);
if (g_pausedByUs && UserPaused()) SetUserPaused(false);
g_pausedByUs = false;
LogCamera("after the restore");
ds2::log().Write("photo: off (%s)", why);
}
// ---- keys --------------------------------------------------------------------------------------------------
int ParseKey(const char* s, int def) {
static const struct {
const char* name;
int vk;
} names[] = {{"insert", VK_INSERT}, {"delete", VK_DELETE}, {"home", VK_HOME}, {"end", VK_END}, {"pageup", VK_PRIOR},
{"pagedown", VK_NEXT}, {"return", VK_RETURN}, {"enter", VK_RETURN}, {"space", VK_SPACE}, {"pause", VK_PAUSE},
{"scrolllock", VK_SCROLL}, {"tab", VK_TAB}, {"backspace", VK_BACK}, {"left", VK_LEFT}, {"right", VK_RIGHT},
{"up", VK_UP}, {"down", VK_DOWN}, {"apps", VK_APPS}, {"printscreen", VK_SNAPSHOT}};
if (!*s) return def;
if (!_stricmp(s, "none")) return 0;
for (auto& n : names)
if (!_stricmp(s, n.name)) return n.vk;
if (s[0] == 'F' || s[0] == 'f') {
int n = std::atoi(s + 1);
if (n >= 1 && n <= 24) return VK_F1 + n - 1;
}
if (std::strlen(s) == 1 && std::isalnum(static_cast<unsigned char>(s[0]))) return std::toupper(static_cast<unsigned char>(s[0]));
int v = static_cast<int>(std::strtol(s, nullptr, 0));
return v > 0 && v < 256 ? v : def;
}
bool OurKey(WPARAM vk) {
if (g_free && (vk == 'W' || vk == 'A' || vk == 'S' || vk == 'D' || vk == 'R' || vk == 'F' || vk == VK_LEFT || vk == VK_RIGHT || vk == VK_UP || vk == VK_DOWN))
return true; // the free camera's own keys: the game's camera keys and potion keys do not see them
return vk != 0 && (vk == static_cast<WPARAM>(g_keyToggle) || vk == static_cast<WPARAM>(g_keyCapture) || vk == static_cast<WPARAM>(g_keyFovWider) ||
vk == static_cast<WPARAM>(g_keyFovNarrower) || vk == static_cast<WPARAM>(g_keyFree) || vk == VK_ESCAPE);
}
// The picture is taken at the hub's world-finished point (d3dhub.h), which exists in every [Display] Mode. Without it there is no
// picture (the engine's own ScreenShot, tried instead, raised an exception from inside the game): the game's own PrintScreen key
// does the same job with the interface already hidden.
void NoPicture() {
ds2::log().Write("photo: no picture: the world-finished point is missing (the Direct3D hub could not install it); the game's PrintScreen key works while photo mode is on");
}
LRESULT CALLBACK PhotoProc(HWND h, UINT msg, WPARAM wp, LPARAM lp) {
switch (msg) {
case WM_KEYDOWN:
case WM_KEYUP: {
bool down = msg == WM_KEYDOWN, repeat = down && (lp & 0x40000000);
if (wp < 256) g_keyDown[wp] = down;
if (wp == static_cast<WPARAM>(g_keyToggle) && g_keyToggle) { // always ours: the key is not passed on
if (down && !repeat) {
if (g_on) Leave("key");
else Enter();
}
return 0;
}
if (!g_on || !OurKey(wp)) break;
if (down && !repeat) {
if (wp == static_cast<WPARAM>(g_keyCapture)) {
if (g_hooked) InterlockedExchange(&g_shotRequested, 1);
else NoPicture();
}
else if (wp == VK_ESCAPE) Leave("escape");
else if (wp == static_cast<WPARAM>(g_keyFree) && g_keyFree) {
if (g_free) LeaveFree();
else EnterFree();
}
}
if (wp == static_cast<WPARAM>(g_keyFovWider)) g_fovWider = down;
if (wp == static_cast<WPARAM>(g_keyFovNarrower)) g_fovNarrower = down;
return 0;
}
case WM_CHAR:
if (g_on && wp == '\r' && g_keyCapture == VK_RETURN) return 0;
break;
case WM_LBUTTONDOWN:
case WM_LBUTTONDBLCLK:
case WM_RBUTTONDOWN:
case WM_RBUTTONDBLCLK:
if (g_on) return 0; // a click would order the party about
break;
case WM_MOUSEWHEEL: // free camera: the wheel flies forward / back
if (g_free) {
g_wheel += static_cast<float>(static_cast<short>(HIWORD(wp))) / 120.0f;
return 0;
}
break;
case WM_MBUTTONDOWN: // free camera: middle button + drag looks around
case WM_MBUTTONUP:
if (g_free) {
g_look = msg == WM_MBUTTONDOWN;
g_lookLast = {static_cast<short>(LOWORD(lp)), static_cast<short>(HIWORD(lp))};
return 0;
}
break;
case WM_MOUSEMOVE:
if (g_free && g_look) {
POINT now = {static_cast<short>(LOWORD(lp)), static_cast<short>(HIWORD(lp))};
g_mouseDx += static_cast<float>(now.x - g_lookLast.x);
g_mouseDy += static_cast<float>(now.y - g_lookLast.y);
g_lookLast = now;
}
break;
case WM_KILLFOCUS:
std::memset(g_keyDown, 0, sizeof(g_keyDown));
g_fovWider = g_fovNarrower = false;
break;
default:
break;
}
return CallWindowProcA(g_orig, h, msg, wp, lp);
}
BOOL CALLBACK FindGameWindow(HWND h, LPARAM out) {
char cls[64];
if (GetClassNameA(h, cls, sizeof(cls)) && _strnicmp(cls, "gpgwndclass_", 12) == 0) {
*reinterpret_cast<HWND*>(out) = h;
return FALSE;
}
return TRUE;
}
void HookWindow() {
if (g_wnd) return;
HWND h = nullptr;
EnumThreadWindows(GetCurrentThreadId(), FindGameWindow, reinterpret_cast<LPARAM>(&h));
if (!h) return;
g_orig = reinterpret_cast<WNDPROC>(SetWindowLongA(h, GWL_WNDPROC, reinterpret_cast<LONG>(PhotoProc)));
if (g_orig) {
g_wnd = h;
ds2::log().Detail("photo: keys hooked on window %p", static_cast<void*>(h));
}
}
// ---- capture ---------------------------------------------------------------------------------------------
// The engine's screenshot folder: RapiAppModule holds the expanded "%shots_path%" (".../Screenshots") as a wide string
// (set where the game builds its paths, Steam sub_430f0d -> sub_4305d8, object +0x1d0). The offset is found by looking for
// that string so the same code works in both exes.
bool ShotsFolder(wchar_t* out, size_t cap) {
static DWORD off = 0;
BYTE* rapi = static_cast<BYTE*>(Obj(g_rapiSingleton));
if (!rapi) return false;
auto good = [&](DWORD o) {
const wchar_t* s = *reinterpret_cast<const wchar_t* const*>(rapi + o);
if (!Readable(s, 4)) return false;
size_t n = 0;
while (n < 600 && Readable(s + n, 2) && s[n]) ++n;
if (n < 12 || n >= 600 || !Readable(s, (n + 1) * 2)) return false;
return _wcsicmp(s + n - 11, L"Screenshots") == 0;
};
if (off && !good(off)) off = 0;
for (DWORD o = 0x40; !off && o < 0x400; o += 4)
if (Readable(rapi + o, 4) && good(o)) off = o;
if (!off) return false;
wcsncpy_s(out, cap, *reinterpret_cast<const wchar_t* const*>(rapi + off), _TRUNCATE);
return true;
}
using SaveW_t = HRESULT(WINAPI*)(const wchar_t*, DWORD, IDirect3DSurface9*, const PALETTEENTRY*, const RECT*);
SaveW_t FindD3dxSave() {
static SaveW_t save = nullptr;
for (int v = 43; !save && v >= 24; --v) {
char dll[32];
sprintf_s(dll, "d3dx9_%d.dll", v);
HMODULE m = GetModuleHandleA(dll);
if (!m && v != 29) continue; // the game loads d3dx9_29; Broken World links D3DX statically
if (!m) m = LoadLibraryA(dll);
if (m) save = reinterpret_cast<SaveW_t>(GetProcAddress(m, "D3DXSaveSurfaceToFileW"));
}
for (int v = 43; !save && v >= 24; --v) {
char dll[32];
sprintf_s(dll, "d3dx9_%d.dll", v);
if (HMODULE m = LoadLibraryA(dll)) save = reinterpret_cast<SaveW_t>(GetProcAddress(m, "D3DXSaveSurfaceToFileW"));
}
return save;
}
void TakePicture(IDirect3DDevice9* dev) {
wchar_t dir[MAX_PATH], path[MAX_PATH + 64];
if (!ShotsFolder(dir, MAX_PATH)) {
ds2::log().Write("photo: the engine's screenshot folder was not found - no picture");
return;
}
CreateDirectoryW(dir, nullptr);
SYSTEMTIME t;
GetLocalTime(&t);
swprintf_s(path, L"%ls\\DS2 Photo %04d-%02d-%02d %02d-%02d-%02d.png", dir, t.wYear, t.wMonth, t.wDay, t.wHour, t.wMinute, t.wSecond);
for (int n = 2; GetFileAttributesW(path) != INVALID_FILE_ATTRIBUTES && n < 100; ++n)
swprintf_s(path, L"%ls\\DS2 Photo %04d-%02d-%02d %02d-%02d-%02d (%d).png", dir, t.wYear, t.wMonth, t.wDay, t.wHour, t.wMinute, t.wSecond, n);
IDirect3DSurface9 *rt = nullptr, *resolved = nullptr, *sys = nullptr;
HRESULT hr = dev->GetRenderTarget(0, &rt);
D3DSURFACE_DESC d{};
if (SUCCEEDED(hr)) hr = rt->GetDesc(&d);
IDirect3DSurface9* src = rt;
if (SUCCEEDED(hr) && d.MultiSampleType != D3DMULTISAMPLE_NONE) {
hr = dev->CreateRenderTarget(d.Width, d.Height, d.Format, D3DMULTISAMPLE_NONE, 0, FALSE, &resolved, nullptr);
if (SUCCEEDED(hr)) hr = dev->StretchRect(rt, nullptr, resolved, nullptr, D3DTEXF_POINT);
src = resolved;
}
if (SUCCEEDED(hr)) hr = dev->CreateOffscreenPlainSurface(d.Width, d.Height, d.Format, D3DPOOL_SYSTEMMEM, &sys, nullptr);
if (SUCCEEDED(hr)) hr = dev->GetRenderTargetData(src, sys);
if (SUCCEEDED(hr) && d.Format == D3DFMT_A8R8G8B8) { // opaque picture
D3DLOCKED_RECT lr{};
if (SUCCEEDED(sys->LockRect(&lr, nullptr, 0))) {
for (UINT y = 0; y < d.Height; ++y) {
DWORD* row = reinterpret_cast<DWORD*>(static_cast<BYTE*>(lr.pBits) + static_cast<size_t>(y) * lr.Pitch);
for (UINT x = 0; x < d.Width; ++x) row[x] |= 0xFF000000u;
}
sys->UnlockRect();
}
}
SaveW_t save = SUCCEEDED(hr) ? FindD3dxSave() : nullptr;
if (SUCCEEDED(hr) && save) hr = save(path, 3 /* D3DXIFF_PNG */, sys, nullptr, nullptr);
else if (SUCCEEDED(hr)) hr = E_FAIL;
char u8[MAX_PATH * 4];
WideCharToMultiByte(CP_UTF8, 0, path, -1, u8, sizeof(u8), nullptr, nullptr);
if (SUCCEEDED(hr)) ds2::log().Write("photo: picture %ux%u saved: %s", d.Width, d.Height, u8);
else ds2::log().Write("photo: picture failed (0x%08lX, D3DX %s): %s", static_cast<unsigned long>(hr), save ? "found" : "missing", u8);
if (sys) sys->Release();
if (resolved) resolved->Release();
if (rt) rt->Release();
}
} // namespace
namespace widescreen::photo {
bool Wanted() { return g_wanted; }
bool Active() { return g_on; }
void NoZoom() { g_zoom = 1.0f; }
void Install(const ds2::Ini& ini) {
g_ini = new ds2::Ini(ini);
char v[32];
auto key = [&](const char* name, int def) {
ini.String("Photo", name, "", v, sizeof(v));
return ParseKey(v, def);
};
auto num = [&](const char* name, float def, float lo, float hi) {
ini.String("Photo", name, "", v, sizeof(v));
float f = v[0] ? static_cast<float>(std::atof(v)) : def;
return f < lo ? lo : f > hi ? hi : f;
};
g_keyToggle = key("Key", VK_INSERT);
g_keyCapture = key("CaptureKey", VK_RETURN);
g_keyFovWider = key("FovWiderKey", VK_PRIOR);
g_keyFovNarrower = key("FovNarrowerKey", VK_NEXT);
g_keyFree = key("FreeCameraKey", VK_DELETE);
g_zoom = num("MaxZoom", 3.0f, 1.0f, 5.0f);
g_minPitch = num("MinPitch", 10.0f, 5.0f, 60.0f);
g_maxPitch = num("MaxPitch", 89.0f, 60.0f, 89.0f);
g_fovMin = num("FovMin", 20.0f, 10.0f, 60.0f);
g_fovMax = num("FovMax", 110.0f, 60.0f, 140.0f);
g_fovSpeed = num("FovSpeed", 30.0f, 5.0f, 120.0f);
g_freeSpeed = num("FreeCameraSpeed", 6.0f, 0.5f, 60.0f);
ini.String("Photo", "HideSelection", "1", v, sizeof(v));
g_hideSelection = std::atoi(v) != 0;
g_freeRadius = num("FreeCameraRadius", 0.0f, 0.0f, 500.0f);
if (!g_keyToggle) {
ds2::log().Write("photo: off ([Photo] Key=none)");
return;
}
g_engineSingleton = Export<Singleton_t>("?FUBI_GetClassSingleton@SiegeEngine@siege@@CAPAV12@XZ");
g_getCamera = Export<GetCamera_t>("?GetCamera@SiegeEngine@siege@@QBEABVSiegeCamera@2@XZ");
g_serverSingleton = Export<Singleton_t>("?FUBI_GetClassSingleton@Server@@CAPAV1@XZ");
g_worldMapSingleton = Export<Singleton_t>("?FUBI_GetClassSingleton@WorldMap@@CAPAV1@XZ");
g_getScreenHero = Export<PtrThis_t>("?GetScreenHero@Server@@QAEPAVGo@@XZ");
g_getPlacement = Export<PtrThis_t>("?GetPlacement@Go@@QAEPAVGoPlacement@@XZ");
g_getPosition = Export<PtrThis_t>("?GetPosition@GoPlacement@@QBEABUSiegePos@@XZ");
g_frustumRadius = Export<FloatThis_t>("?GetWorldFrustumRadius@WorldMap@@QBEMXZ");
g_convert = Export<Convert_t>("?ConvertSiegePosToNode@@YAXAAUSiegePos@@ABVdatabase_guid@siege@@@Z");
auto getterOffset = [](const char* name) -> DWORD { // "lea eax,[ecx+disp32]; ret"
auto p = reinterpret_cast<const BYTE*>(GetProcAddress(GetModuleHandleA(nullptr), name));
return p && p[0] == 0x8D && p[1] == 0x81 && p[6] == 0xC3 ? *reinterpret_cast<const DWORD*>(p + 2) : 0;
};
o_camSiegePos = getterOffset("?GetCameraSiegePos@SiegeCamera@siege@@QBEABUSiegePos@@XZ");
o_tgtSiegePos = getterOffset("?GetTargetSiegePos@SiegeCamera@siege@@QBEABUSiegePos@@XZ");
if (!g_convert || !o_camSiegePos || !o_tgtSiegePos || !g_serverSingleton || !g_getScreenHero || !g_getPlacement || !g_getPosition)
ds2::log().Write("photo: free camera unavailable (engine entry points missing: convert %p, camera SiegePos +%lX, target +%lX)", reinterpret_cast<void*>(g_convert),
o_camSiegePos, o_tgtSiegePos);
g_appSingleton = Export<Singleton_t>("?FUBI_GetClassSingleton@AppModule@@CAPAV1@XZ");
g_shellSingleton = Export<Singleton_t>("?FUBI_GetClassSingleton@UIShell@@CAPAV1@XZ");
g_gameSingleton = Export<Singleton_t>("?FUBI_GetClassSingleton@UIGame@@CAPAV1@XZ");
g_uiCameraSingleton = Export<Singleton_t>("?FUBI_GetClassSingleton@UICamera@@CAPAV1@XZ");
g_worldStateSingleton = Export<Singleton_t>("?FUBI_GetClassSingleton@WorldState@@CAPAV1@XZ");
g_rapiSingleton = Export<Singleton_t>("?FUBI_GetClassSingleton@RapiAppModule@@CAPAV1@XZ");
g_userPause = Export<ThisBool_t>("?UserPause@AppModule@@QAEX_N@Z");
g_isUserPaused = Export<ThisRet_t>("?IsUserPaused@AppModule@@QAE_NXZ");
g_setVisible = Export<ThisBool_t>("?SetVisible@UIShell@@QAEX_N@Z");
g_setRadarVisible = Export<ThisBool_t>("?SetRadarVisible@UIGame@@QAEX_N@Z");
g_currentState = Export<ThisRet_t>("?GetCurrentState@WorldState@@QBE?AW4eWorldState@@XZ");
g_inGameInteractive = Export<FreeInt_t>("?IsInGameInteractive@@YA_NW4eWorldState@@@Z");
g_isMultiPlayer = Export<Free_t>("?IsMultiPlayer@@YA_NXZ");
if (!g_engineSingleton || !g_getCamera || !g_appSingleton || !g_shellSingleton || !g_gameSingleton || !g_uiCameraSingleton || !g_worldStateSingleton || !g_rapiSingleton ||
!g_userPause || !g_isUserPaused || !g_setVisible || !g_setRadarVisible || !g_currentState || !g_inGameInteractive || !g_isMultiPlayer) {
ds2::log().Write("SKIP photo: an engine export is missing - photo mode off");
return;
}
// UIShell::SetVisible = "mov al,[ebp+8]; mov [ecx+disp32],al" (Steam 0x425c3d, Broken World 0x4281a2)
auto sv = reinterpret_cast<const BYTE*>(g_setVisible);
if (sv[0] == 0x55 && sv[1] == 0x8B && sv[2] == 0xEC && sv[3] == 0x8A && sv[4] == 0x45 && sv[5] == 0x08 && sv[6] == 0x88 && sv[7] == 0x81)
o_shellVisible = *reinterpret_cast<const DWORD*>(sv + 8);
// Selection rings: the ring drawing (Steam sub_46fdd0) starts with the tests "options flag, UIGame +0x85"; the offset is read from it.
DWORD ring = ds2::FindUnique("55 8B EC 83 EC 2C A1 ?? ?? ?? ?? 57 33 FF 39 3D ?? ?? ?? ?? 89 45 FC 89 4D E0 0F 84 ?? ?? ?? ?? A1 ?? ?? ?? ?? 80 78 53 00 0F 84 ?? ?? ?? ?? 80 B9 ?? ?? 00 00 00");
if (ring) o_ringOff = *reinterpret_cast<const DWORD*>(ring + 49);
// The hover outline: its quad is drawn from the siege render (Steam 0x6db0c9, "push esi; call <quad>; add esp,0x1c").
DWORD outline = ds2::FindUnique("0B D0 52 8B 96 ?? ?? 00 00 51 52 6A 00 6A 00 56 E8 ?? ?? ?? ?? 83 C4 1C 8B CE");
if (outline) {
DWORD site = outline + 16, target = site + 5 + *reinterpret_cast<const LONG*>(site + 1);
g_outlineQuad = reinterpret_cast<OutlineQuad_t>(target);
if (!ds2::RedirectCall(site, target, reinterpret_cast<DWORD>(&HookOutlineQuad), "photo: hover outline quad (hidden in the picture)")) g_outlineQuad = nullptr;
}
ds2::log().Detail("photo: selection rings %s (UIGame +%lX), hover outline %s; HideSelection=%d", o_ringOff ? "found" : "NOT found", o_ringOff, g_outlineQuad ? "found" : "NOT found",
g_hideSelection ? 1 : 0);
// The interface pass (hud.cpp finds it the same way): "mov ecx,[esi+0x4c]" is the radar, "cmp byte [esi+disp32],0" the
// multiplayer names switch.
DWORD pass = ds2::FindUnique("A1 ?? ?? ?? ?? 83 78 1C 2C 56 8B F1 74 ?? 8B 4E 3C E8 ?? ?? ?? ?? 8B 4E 4C E8 ?? ?? ?? ?? A1");
if (pass) {
auto p = reinterpret_cast<const BYTE*>(pass);
o_radar = p[0x18];
for (int i = 0x20; i < 0x60 && !o_labels; ++i)
if (p[i] == 0x80 && p[i + 1] == 0xBE && p[i + 4] == 0 && p[i + 5] == 0 && p[i + 6] == 0) o_labels = *reinterpret_cast<const DWORD*>(p + i + 2);
}
ds2::log().Detail("photo: key %d (capture %d), UIShell flag +%lX, UIGame radar +%lX, names +%lX; max zoom x%.1f, pitch %.0f..%.0f", g_keyToggle,
g_keyCapture, o_shellVisible, o_radar, o_labels, g_zoom, g_minPitch, g_maxPitch);
if (!o_shellVisible) {
ds2::log().Write("SKIP photo: UIShell::SetVisible not as expected - photo mode off");
return;
}
g_wanted = true;
}
// camera.cpp, every camera update: the camera is known, keys are hooked, photo mode ends with the game.
void OnUpdate(void* cam, DWORD fovOff, DWORD limitsOff) {
if (!g_wanted) return;
g_fovOff = fovOff;
g_limitsOff = limitsOff;
if (!g_wnd) HookWindow();
if (!g_on) return;
if (!InteractiveGame()) { // the game ended, a cutscene began, the player left to the menu
Leave("the game is no longer interactive");
return;
}
if (BYTE* f = ShellFlag()) *f = 0; // the interface stays hidden whatever the game does
if (g_free && cam == g_cam) FreeStep(cam);
}
// camera.cpp: widened limits while photo mode is on (widen only).
bool Limits(void* cam, float* zoom, float* minPitch, float* maxPitch) {
if (!g_on || cam != g_cam) return false; // the main camera only: the interface's 3D views keep the game's limits
if (g_zoom > *zoom) *zoom = g_zoom;
*minPitch = *minPitch > 0 && *minPitch < g_minPitch ? *minPitch : g_minPitch;
if (g_maxPitch > *maxPitch) *maxPitch = g_maxPitch;
return true;
}
// camera.cpp: the field of view while photo mode is on. gameFov is what the camera would have without photo mode
// (horizontal radians); the photo mode keeps its own vertical angle, starting at the one in use.
void Fov(float gameFov, float aspect, float* out) {
LARGE_INTEGER now, freq;
QueryPerformanceCounter(&now);
QueryPerformanceFrequency(&freq);
if (!g_fovInit) {
g_fovV = 2.0f * std::atan(std::tan(gameFov * 0.5f) / aspect) / kDeg;
g_fovInit = true;
g_fovT = now;
ds2::log().Detail("photo: field of view starts at %.1f deg vertical (%.1f horizontal, aspect %.3f)", g_fovV, gameFov / kDeg, aspect);
}
float dt = static_cast<float>(now.QuadPart - g_fovT.QuadPart) / static_cast<float>(freq.QuadPart);
if (dt > 0.001f) { // the camera update runs several times per frame
g_fovT = now;
if (dt > 0.1f) dt = 0.1f;
float old = g_fovV;
if (g_fovWider) g_fovV += g_fovSpeed * dt;
if (g_fovNarrower) g_fovV -= g_fovSpeed * dt;
if (g_fovV < g_fovMin) g_fovV = g_fovMin;
if (g_fovV > g_fovMax) g_fovV = g_fovMax;
if (std::fabs(g_fovV - old) > 1e-4f && (static_cast<int>(old / 10.0f) != static_cast<int>(g_fovV / 10.0f)))
ds2::log().Detail("photo: field of view %.0f deg vertical", g_fovV);
}
float h = 2.0f * std::atan(std::tan(g_fovV * kDeg * 0.5f) * aspect);
*out = h > 170.0f * kDeg ? 170.0f * kDeg : h;
}
// hud.cpp, at the interface pass: the 3D world is finished (anti-aliased), the interface not drawn yet.
void WorldDone(IDirect3DDevice9* dev) {
g_hooked = true;
if (g_shotStage == 0) {
if (!InterlockedExchange(&g_shotRequested, 0)) return;
if (!g_on || !dev) return;
if (g_ini) g_hideSelection = g_ini->Bool("Photo", "HideSelection", true); // read at every picture: a settings file edit takes effect at once
BYTE* ring = o_ringOff && g_hideSelection ? static_cast<BYTE*>(Obj(g_gameSingleton)) : nullptr;
if (ring) ring += o_ringOff;
if (!ring && !(g_outlineQuad && g_hideSelection)) {
TakePicture(dev);
return;
}
if (ring) g_ringWas = *ring, *ring = 1;
g_outlineHidden = g_outlineQuad && g_hideSelection;
g_shotStage = 1; // the next frame draws without them; the picture follows one frame later
return;
}
if (++g_shotStage < 3) return;
g_shotStage = 0;
if (g_on && dev) TakePicture(dev);
BYTE* ring = o_ringOff ? static_cast<BYTE*>(Obj(g_gameSingleton)) : nullptr;
if (ring) ring[o_ringOff] = g_ringWas;
g_outlineHidden = false;
}
} // namespace widescreen::photo
// For other plugins (the controller, later): is photo mode on? Non-zero while it is; its world actions (a click, a walk order) are
// not delivered as window messages and so cannot be swallowed here.
#pragma comment(linker, "/EXPORT:DS2PhotoMode=_DS2PhotoMode@0")
extern "C" int __stdcall DS2PhotoMode() { return g_on ? 1 : 0; }