Files
PSPRecomp/profiles/vcs/host/vcs_draw_distance_patch.cpp
Jessica_Natalia 37e5469cbd initial release
initial release
2026-08-12 13:56:12 -03:00

471 lines
20 KiB
C++

// GTA Vice City Stories (PSP, ULUS-10160) - VCSNative draw-distance patch
// Target: VCSRecomp Stage 40 / load base 0x08804000
//
// INI (VCSNative.ini):
//
// [DrawDistance]
// Enabled = 1
// World = 1.50
// Vehicles = 1.50
// NPCs = 1.50
//
// 1.0 = original PSP distance. Values below 1.0 are clamped to 1.0.
// World is allowed up to 8.0; Vehicles/NPCs up to 4.0.
// For mission compatibility, keep Vehicles/NPCs <= 2.0 unless tested.
//
// Integration (no header required):
// 1) Add this .cpp to VCSNative's target sources.
// 2) In host/main.cpp, after register_generated_functions(runtime), declare/call:
// namespace vcs {
// void install_draw_distance_patch(psprecomp::Runtime &,
// const std::filesystem::path &);
// }
// vcs::install_draw_distance_patch(runtime, configuration.source_path);
//
// The install call MUST be after register_generated_functions(runtime), because this
// file intentionally replaces a few AOT entry labels in Runtime's function table.
#include "psprecomp/runtime.hpp"
#include "psprecomp/common.hpp" // hex32, para o relatorio de hooks vivos/mortos
#include <algorithm>
#include <bit>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <limits>
#include <string>
#include <string_view>
#include <unordered_map>
namespace vcs {
namespace {
struct DrawDistanceConfig {
bool enabled{false};
float world{1.0f};
float vehicles{1.0f};
float npcs{1.0f};
};
DrawDistanceConfig g_config{};
// ULUS-10160 / VCSNative AOT guest addresses.
constexpr std::uint32_t kFarClipSetter = 0x08A1AD6Cu;
constexpr std::uint32_t kEntityLodSetup = 0x08A24128u;
constexpr std::uint32_t kEntityLodSetupContinue = 0x08A24138u;
constexpr std::uint32_t kNpcRangeSetup = 0x089CB38Cu;
constexpr std::uint32_t kNpcRangeContinue = 0x089CB3C8u;
constexpr std::uint32_t kVehicleRangeSetup = 0x08B45AC0u;
constexpr std::uint32_t kVehicleRangeContinue = 0x08B45AC8u;
constexpr std::uint32_t kIdeInitEpilogue = 0x08AEC918u;
// VCS globals, addressed from $gp (r28).
constexpr std::uint32_t kGpFarClipOffset = 7796u; // CDraw::ms_fFarClipZ
constexpr std::uint32_t kGpIdeCountOffset = 7656u; // IDE/model-info slot count
constexpr std::uint32_t kGpIdeTableOffset = 24u; // IDE/model-info pointer table
// Runtime entity fields used by the original VCS WidescreenFix LOD patch.
constexpr std::uint32_t kEntityLodDistance = 0x7A0u;
constexpr std::uint32_t kEntityBaseLodDistance = 0x7A8u;
// VCS CBaseModelInfo-like layout exposed by the IDE table.
constexpr std::uint32_t kModelHashOffset = 0x08u;
constexpr std::uint32_t kModelTypeOffset = 0x10u;
constexpr std::uint32_t kDrawDist1Offset = 0x2Cu;
constexpr std::uint32_t kDrawDist2Offset = 0x30u;
constexpr std::uint32_t kDrawDist3Offset = 0x34u;
constexpr std::uint8_t kIdeTypeObject = 1u;
constexpr std::uint8_t kIdeTypeTimedObj = 3u;
struct OriginalWorldModel {
std::uint32_t hash{};
std::uint8_t type{};
float d1{};
float d2{};
float d3{};
};
std::unordered_map<std::uint32_t, OriginalWorldModel> g_original_world_models;
std::string trim_copy(std::string value) {
auto is_space = [](unsigned char c) { return std::isspace(c) != 0; };
while (!value.empty() && is_space(static_cast<unsigned char>(value.front())))
value.erase(value.begin());
while (!value.empty() && is_space(static_cast<unsigned char>(value.back())))
value.pop_back();
return value;
}
std::string lower_copy(std::string value) {
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
return value;
}
std::string strip_comment(std::string value) {
bool quoted = false;
char quote = '\0';
for (std::size_t i = 0; i < value.size(); ++i) {
const char c = value[i];
if (c == '\'' || c == '"') {
if (!quoted) {
quoted = true;
quote = c;
} else if (quote == c) {
quoted = false;
}
} else if (!quoted && (c == ';' || c == '#')) {
value.resize(i);
break;
}
}
return trim_copy(std::move(value));
}
bool parse_bool(std::string value, bool &out) {
value = lower_copy(trim_copy(std::move(value)));
if (value == "1" || value == "true" || value == "yes" || value == "on") {
out = true;
return true;
}
if (value == "0" || value == "false" || value == "no" || value == "off") {
out = false;
return true;
}
return false;
}
bool parse_float(std::string value, float &out) {
value = trim_copy(std::move(value));
if (value.empty()) return false;
char *end = nullptr;
const float v = std::strtof(value.c_str(), &end);
if (end == value.c_str() || *end != '\0' || !std::isfinite(v)) return false;
out = v;
return true;
}
DrawDistanceConfig load_config(const std::filesystem::path &path) {
DrawDistanceConfig cfg{};
std::ifstream in(path);
if (!in) return cfg;
bool in_section = false;
std::string line;
while (std::getline(in, line)) {
line = strip_comment(std::move(line));
if (line.empty()) continue;
if (line.front() == '[' && line.back() == ']') {
const std::string section = lower_copy(trim_copy(line.substr(1, line.size() - 2)));
in_section = (section == "drawdistance" || section == "draw distance");
continue;
}
if (!in_section) continue;
const std::size_t eq = line.find('=');
if (eq == std::string::npos) continue;
const std::string key = lower_copy(trim_copy(line.substr(0, eq)));
const std::string value = trim_copy(line.substr(eq + 1));
if (key == "enabled") {
parse_bool(value, cfg.enabled);
} else if (key == "world" || key == "worldmultiplier") {
parse_float(value, cfg.world);
} else if (key == "vehicles" || key == "cars" || key == "vehiclemultiplier") {
parse_float(value, cfg.vehicles);
} else if (key == "npcs" || key == "peds" || key == "npcmultiplier") {
parse_float(value, cfg.npcs);
}
}
cfg.world = std::clamp(cfg.world, 1.0f, 8.0f);
cfg.vehicles = std::clamp(cfg.vehicles, 1.0f, 4.0f);
cfg.npcs = std::clamp(cfg.npcs, 1.0f, 4.0f);
return cfg;
}
float load_float(psprecomp::Runtime &runtime, std::uint32_t address) {
return std::bit_cast<float>(runtime.memory().load32(address));
}
void store_float(psprecomp::Runtime &runtime, std::uint32_t address, float value) {
runtime.memory().store32(address, std::bit_cast<std::uint32_t>(value));
}
bool sane_draw_distance(float value) {
// Zero/negative values can be sentinels in model-info. Leave them untouched.
return std::isfinite(value) && value > 0.0f && value < 100000.0f;
}
bool nearly_equal(float a, float b) {
const float scale = std::max({1.0f, std::fabs(a), std::fabs(b)});
return std::fabs(a - b) <= scale * 1.0e-4f;
}
float scaled_or_original(float original, float multiplier) {
return sane_draw_distance(original) ? original * multiplier : original;
}
// Returns true once it has actually written entries, so the caller can stop
// retrying. Called every frame from SetFarClipZ until it succeeds.
bool patch_world_model_table(psprecomp::Runtime &runtime, const psprecomp::AllegrexContext &ctx) {
if (g_config.world <= 1.0f) return false;
const std::uint32_t gp = ctx.gpr[28];
const std::uint32_t count = runtime.memory().load32(gp + kGpIdeCountOffset);
const std::uint32_t table = runtime.memory().load32(gp + kGpIdeTableOffset);
// Corrupt/uninitialized globals must never turn this optional patch into a crash.
const bool bad = count == 0u || count > 32768u || table == 0u ||
!runtime.memory().contains(table, static_cast<std::size_t>(count) * 4u);
// The guard sits before the "entries patched" banner, so a wrong GP offset
// used to look exactly like the hook never running. Report the values once.
static bool guard_logged = false;
if (!guard_logged) {
guard_logged = true;
std::cerr << "[draw-distance] ide_init gp=" << psprecomp::hex32(gp)
<< " count@" << kGpIdeCountOffset << "=" << count
<< " table@" << kGpIdeTableOffset << "=" << psprecomp::hex32(table)
<< (bad ? " -> GUARD REJEITOU" : " -> ok") << "\n";
}
if (bad) return false;
std::uint32_t patched = 0u;
for (std::uint32_t i = 0; i < count; ++i) {
const std::uint32_t info = runtime.memory().load32(table + i * 4u);
if (info == 0u || !runtime.memory().contains(info, 0x38u)) continue;
const std::uint8_t type = runtime.memory().load8(info + kModelTypeOffset);
if (type != kIdeTypeObject && type != kIdeTypeTimedObj) continue;
const std::uint32_t hash = runtime.memory().load32(info + kModelHashOffset);
const float cur1 = load_float(runtime, info + kDrawDist1Offset);
const float cur2 = load_float(runtime, info + kDrawDist2Offset);
const float cur3 = load_float(runtime, info + kDrawDist3Offset);
auto found = g_original_world_models.find(info);
if (found == g_original_world_models.end() || found->second.hash != hash || found->second.type != type) {
found = g_original_world_models.emplace(
info, OriginalWorldModel{hash, type, cur1, cur2, cur3}).first;
} else {
// If the game reloaded/rebuilt the same model-info address, refresh the baseline
// instead of multiplying an already multiplied value again.
const OriginalWorldModel &old = found->second;
const bool still_patched =
nearly_equal(cur1, scaled_or_original(old.d1, g_config.world)) &&
nearly_equal(cur2, scaled_or_original(old.d2, g_config.world)) &&
nearly_equal(cur3, scaled_or_original(old.d3, g_config.world));
const bool still_original =
nearly_equal(cur1, old.d1) && nearly_equal(cur2, old.d2) && nearly_equal(cur3, old.d3);
if (!still_patched && !still_original) {
found->second = OriginalWorldModel{hash, type, cur1, cur2, cur3};
}
}
const OriginalWorldModel &base = found->second;
store_float(runtime, info + kDrawDist1Offset, scaled_or_original(base.d1, g_config.world));
store_float(runtime, info + kDrawDist2Offset, scaled_or_original(base.d2, g_config.world));
store_float(runtime, info + kDrawDist3Offset, scaled_or_original(base.d3, g_config.world));
++patched;
}
static bool logged = false;
if (!logged && patched != 0u) {
std::cerr << "[draw-distance] world model-info entries patched=" << patched
<< " multiplier=" << g_config.world << "\n";
// "entries patched" only proves the writes happened. Print a couple of
// real before/after pairs so the magnitude is visible, and print the far
// clip alongside -- if the values grow but nothing appears on screen, the
// limit is fog or the renderer reads other fields, not this table.
std::uint32_t shown = 0u;
for (std::uint32_t i = 0; i < count && shown < 3u; ++i) {
const std::uint32_t info = runtime.memory().load32(table + i * 4u);
if (info == 0u || !runtime.memory().contains(info, 0x38u)) continue;
const auto found = g_original_world_models.find(info);
if (found == g_original_world_models.end()) continue;
std::cerr << " [dd-amostra] info=" << psprecomp::hex32(info)
<< " d1 " << found->second.d1 << " -> "
<< load_float(runtime, info + kDrawDist1Offset)
<< " | d2 " << found->second.d2 << " -> "
<< load_float(runtime, info + kDrawDist2Offset) << "\n";
++shown;
}
std::cerr << " [dd-amostra] far_clip@" << kGpFarClipOffset << "="
<< load_float(runtime, gp + kGpFarClipOffset) << "\n";
logged = true;
}
// Re-check on a later pass whether our values survived. If the game
// repopulates the table, they will have gone back to the originals.
static std::uint32_t verifications = 0u;
if (logged && patched != 0u && verifications < 3u) {
++verifications;
std::uint32_t intact = 0u, reverted = 0u;
for (const auto &[info, base] : g_original_world_models) {
const float now = load_float(runtime, info + kDrawDist1Offset);
if (nearly_equal(now, scaled_or_original(base.d1, g_config.world))) ++intact;
else if (nearly_equal(now, base.d1)) ++reverted;
}
std::cerr << " [dd-verifica] passada=" << verifications
<< " intactos=" << intact << " revertidos=" << reverted << "\n";
}
return patched != 0u;
}
// CDraw::SetFarClipZ(float): keep the game's dynamic far-clip selection, but scale
// every value written through the real setter. This avoids a static far-clip hack.
void far_clip_setter_patch(psprecomp::Runtime &runtime, psprecomp::AllegrexContext &ctx) {
const float far_clip = ctx.fpr[12] * g_config.world;
store_float(runtime, ctx.gpr[28] + kGpFarClipOffset, far_clip);
// The model-info table is patched from here, not from the IDE epilogue.
//
// 0x08AEC918 IS a registered AOT entry point -- has_function() confirms it --
// but it is never dispatched: control reaches it as a local label inside its
// own generated unit, and a `goto L_xxxx` does not consult the runtime's
// function table, so the replacement is bypassed. Same mechanism the fast
// path at 0x088B1554 documents. Measured: the hook installs, reports OK, and
// its body never runs.
//
// SetFarClipZ runs every frame and already carries gp in $28, which is all
// patch_world_model_table needs. Retry until the table is populated -- at the
// first calls the globals are still zero -- then stop, and refresh
// periodically so a streamed-in reload does not stay unpatched.
static std::uint64_t calls = 0u;
static bool table_done = false;
++calls;
if (!table_done || (calls % 600u) == 0u) {
if (patch_world_model_table(runtime, ctx)) table_done = true;
}
ctx.pc = ctx.gpr[31];
}
// Equivalent to ThirteenAG's VCS WidescreenFix entity LOD hook, adapted to the
// static AOT recomp. The original PSP patch uses one LOD multiplier for cars+peds;
// use max(Vehicles,NPCs) here, while their actual despawn/culling ranges remain
// independently controlled below.
void entity_lod_setup_patch(psprecomp::Runtime &runtime, psprecomp::AllegrexContext &ctx) {
const float multiplier = std::max(g_config.vehicles, g_config.npcs);
const std::uint32_t entity = ctx.gpr[16]; // s0 in this VCS call site
if (entity != 0u && runtime.memory().contains(entity + kEntityLodDistance, 12u)) {
const float base = load_float(runtime, entity + kEntityBaseLodDistance);
store_float(runtime, entity + kEntityLodDistance, base * multiplier);
}
ctx.fpr[0] = multiplier; // helper's return value; next block scales +0x7A8
ctx.set_gpr(31, kEntityLodSetupContinue); // preserve JAL-visible RA semantics
ctx.pc = kEntityLodSetupContinue;
}
// VCS vehicle off-screen despawn/culling constant: original 60.0f.
void vehicle_range_patch(psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
ctx.fpr[12] = 60.0f * g_config.vehicles;
ctx.pc = kVehicleRangeContinue;
}
// VCS population/ped range block. Re-emulates the whole original AOT label,
// changing only 51/25/80; the original 120 constant and integer setup are preserved.
void npc_range_patch(psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
ctx.set_gpr(19, ctx.gpr[29] + 64u);
ctx.set_gpr(30, ctx.gpr[29] + 16u);
ctx.set_gpr(23, ctx.gpr[29] + 32u);
ctx.fpr[22] = 120.0f;
ctx.fpr[28] = 51.0f * g_config.npcs;
ctx.fpr[26] = 25.0f * g_config.npcs;
ctx.fpr[24] = 80.0f * g_config.npcs;
ctx.set_gpr(4, ctx.gpr[18] << 5u);
ctx.set_gpr(20, ctx.gpr[4]);
ctx.set_gpr(4, ctx.gpr[4] << 4u);
ctx.set_gpr(20, ctx.gpr[20] + ctx.gpr[4]);
ctx.pc = kNpcRangeContinue;
}
// Runs at the real IDE initialization epilogue, once the game's pointer table has
// been installed. Patch only map objects (OBJ=1, TOBJ=3), not vehicle/ped model-info.
void ide_init_epilogue_patch(psprecomp::Runtime &runtime, psprecomp::AllegrexContext &ctx) {
patch_world_model_table(runtime, ctx);
// Exact original epilogue for 0x08AEC918.
ctx.set_gpr(16, runtime.memory().load32(ctx.gpr[29] + 0u));
ctx.set_gpr(17, runtime.memory().load32(ctx.gpr[29] + 4u));
ctx.set_gpr(18, runtime.memory().load32(ctx.gpr[29] + 8u));
ctx.set_gpr(31, runtime.memory().load32(ctx.gpr[29] + 12u));
const std::uint32_t return_pc = ctx.gpr[31];
ctx.set_gpr(29, ctx.gpr[29] + 16u);
ctx.pc = return_pc;
}
} // namespace
void install_draw_distance_patch(psprecomp::Runtime &runtime,
const std::filesystem::path &ini_path) {
g_config = load_config(ini_path);
g_original_world_models.clear();
if (!g_config.enabled) {
std::cerr << "[draw-distance] disabled\n";
return;
}
// These replacements are intentionally registered AFTER generated functions.
// Runtime::register_function overwrites the existing address in both the hash
// registry and direct dispatch table.
//
// But it only has any effect when the address already IS an AOT entry point.
// Registering an address that lands mid-block succeeds silently and is never
// dispatched -- vcs_project2dfx.cpp hit exactly this with the heli-height
// kit's continuation address. The header of this file says it targets
// "Stage 40 / load base 0x08804000", i.e. a different recompilation, so every
// address here is a candidate. Report which ones are live instead of leaving
// a dead hook looking installed.
std::uint32_t live = 0u;
std::uint32_t dead = 0u;
const auto hook = [&](std::uint32_t address, psprecomp::Runtime::RecompiledFunction function,
std::string name, const char *what) {
const bool exists = runtime.has_function(address);
if (exists) {
runtime.register_function(address, function, std::move(name));
++live;
} else {
++dead;
}
std::cerr << "[draw-distance] hook " << what << " em " << psprecomp::hex32(address)
<< (exists ? " OK" : " MORTO (nao e ponto de entrada AOT nesta recompilacao)")
<< "\n";
};
if (g_config.world > 1.0f) {
hook(kFarClipSetter, &far_clip_setter_patch, "vcs_draw_distance_far_clip", "far_clip");
hook(kIdeInitEpilogue, &ide_init_epilogue_patch, "vcs_draw_distance_world_ide", "ide_init");
}
if (g_config.vehicles > 1.0f || g_config.npcs > 1.0f) {
hook(kEntityLodSetup, &entity_lod_setup_patch, "vcs_draw_distance_entity_lod", "entity_lod");
}
if (g_config.vehicles > 1.0f) {
hook(kVehicleRangeSetup, &vehicle_range_patch, "vcs_draw_distance_vehicle_range", "vehicle_range");
}
if (g_config.npcs > 1.0f) {
hook(kNpcRangeSetup, &npc_range_patch, "vcs_draw_distance_npc_range", "npc_range");
}
std::cerr << "[draw-distance] hooks vivos=" << live << " mortos=" << dead << "\n";
std::cerr << "[draw-distance] enabled"
<< " world=" << g_config.world
<< " vehicles=" << g_config.vehicles
<< " npcs=" << g_config.npcs
<< " entity_lod=" << std::max(g_config.vehicles, g_config.npcs)
<< "\n";
}
} // namespace vcs