Add ultrawide support: hor+ in missions

ac6_widescreen = true (default false), renders missions at the window's aspect ratio (hor+) by patching the game's per-camera aspect data, so FOV, culling and every camera follow. Menus, hangar, briefing and FMV revert to 16:9 and letterbox. The HUD is pre-squeezed at  draw time to stay crisp, and marker art is squared without moving the aim points. ac6_widescreen_cinematics enables it in in-game cutscenes(default true, gated).
This commit is contained in:
Dipshet
2026-07-29 04:35:39 +02:00
parent 3de677ef0d
commit cc9d8aa54e
9 changed files with 1259 additions and 2 deletions
+1
View File
@@ -42,6 +42,7 @@ set(AC6RECOMP_SOURCES
src/ac6_backend_fixes/ac6_backend_pass_classifier.cpp
src/ac6_backend_fixes/ac6_fps_physics_fix.cpp
src/ac6_backend_fixes/ac6_kbm_input.cpp
src/ac6_backend_fixes/ac6_widescreen.cpp
)
if(WIN32)
+956
View File
@@ -0,0 +1,956 @@
// AC6 enhancement: arbitrary aspect ratio (ultrawide) - camera aspect patcher.
//
// Discovery (exchange/ultrawide/ac6recomp.log, 2026-07-03): the game keeps the
// display aspect ratio 16:9 as 1.7777778f (big-endian 0x3FE38E39) per camera
// object, laid out around the aspect field as
// [-0x04] fov (~0.40 / 0.44 / 0.68 observed; changes with zoom)
// [+0x00] aspect 1.7777778f
// [+0x04] near (0.1 / 1.0 observed)
// [+0x08] far (24000.0 observed)
// with the camera's view rotation basis a few rows below. A global camera
// template lives at guest 0x82A160C8, live cameras on the physically-backed
// heap, and the game's static 16:9 default constant sits in a data table in
// the XEX image at 0x8206A0F4 (and 0x9206A0F4 through the second view).
//
// Mechanism: a background thread polls the game's mode-task state machine
// (the runtime-verified chain [0x8293B930] -> +0x8 -> vtable, see
// docs/re/subsystems/selftest_macro.md) every 250 ms and, on the 2 s cadence
// or immediately on a mission transition,
// 1. (re)patches the static default at fixed addresses, so cameras created
// afterwards are born with the current target aspect, and
// 2. signature-scans committed guest memory for camera objects carrying the
// previous aspect and pokes their aspect field.
// INSIDE a mission (mode task CModeTaskGame: gameplay, in-engine cutscenes,
// pause) the target is the wide aspect - the game builds its own wider
// projection, the compressed 16:9 guest output is presented stretched to the
// window (via the presenter's letterbox override), and the draw-time UI
// shrink keeps the 2D layer proportioned. OUTSIDE a mission everything is reverted to 16:9 and
// the presenter is forced to letterbox: menus, hangar, briefing, FMV and the
// attract demo render exactly vanilla.
#define WIN32_LEAN_AND_MEAN
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <mutex>
#include <vector>
#include <native/ui/presenter.h>
#include <rex/cvar.h>
#include <rex/logging.h>
#include <rex/system/xmemory.h>
#include "../render_hooks.h"
#include "ac6_widescreen.h"
REXCVAR_DEFINE_BOOL(ac6_widescreen, false, "AC6",
"Arbitrary aspect ratio (hor+), in-mission only. The target aspect "
"is derived from the actual window size (re-checked continuously; "
"a live resize adapts). While the game's mode task is the mission "
"(gameplay, in-engine cutscenes, pause), cameras are patched from "
"16:9 to the window aspect, the presenter fills the window, and "
"the 2D layer is pre-squeezed at draw time for a crisp 16:9 HUD "
"(the game-placed target-marker shader stays full-width by design; "
"its art is squared by narrowing the geometry). Everywhere else "
"(menus, hangar, briefing, FMV, attract) and at 16:9-or-narrower "
"windows, cameras stay 16:9 and the presentation letterboxes - "
"vanilla. While enabled the feature drives the presenter's "
"letterbox decision itself; it never writes the present_letterbox "
"cvar, so nothing leaks into a saved config.");
REXCVAR_DEFINE_BOOL(ac6_widescreen_cinematics, true, "AC6",
"With ac6_widescreen: render in-engine cinematics wide too, even "
"outside the mission mode task (detected via the demo-manager "
"cinematic signal, the same one the cutscene frame-rate clamp "
"uses - so ac6_cutscene_clamp=false disables this detection). "
"Cinematics are staged and framed for 16:9, so widening can "
"expose things outside that frame (set edges, actors popping in "
"at the frustum border). Off = cinematics letterbox at 16:9 "
"as staged.");
// Read (never written) only to report it in the activation log.
REXCVAR_DECLARE(bool, present_letterbox);
namespace {
constexpr float kNativeAspect = 1.7777778f; // BE 0x3FE38E39, exactly what the game stores
constexpr uint64_t kGuestScanEnd = 0xC0000000ull; // C0/E0 views alias A0 - skip them
// Mode poll every 250 ms (a cheap 3-dereference guest read) so mission
// transitions re-aim the cameras promptly; the full memory sweep still runs on
// the 2 s cadence (8 polls) or immediately on a transition.
constexpr DWORD kModePollMs = 250;
constexpr uint32_t kSweepEveryPolls = 8;
// The game's front end is a mode-task state machine (docs/re/subsystems/
// selftest_macro.md, runtime-verified): [0x8293B930] -> CTaskModeManager
// singleton, +0x8 -> the currently-running CModeTask, +0x0 -> its vtable
// pointer, which is a static per-class address and therefore a screen id.
// 0x820642F4 = CModeTaskGame, the in-mission task (gameplay, in-engine
// cutscenes and the pause menu all run under it). The +0x8 slot is briefly
// null while the manager swaps tasks - treated as "hold the previous state".
constexpr uint32_t kModeManagerPtrEA = 0x8293B930;
constexpr uint32_t kModeTaskSlotOffset = 0x8;
constexpr uint32_t kModeTaskGameVtable = 0x820642F4;
std::atomic<rex::memory::Memory*> g_ws_memory{nullptr};
// Bits of the UI X-shrink factor (16:9 / target aspect), published by the
// patcher thread for the per-draw ortho patch; 0 = UI patching disabled.
std::atomic<uint32_t> g_ui_shrink_bits{0};
// Whether the current scene should render/present wide: the mode task is the
// mission, or an in-engine cinematic is playing with
// ac6_widescreen_cinematics on. Published by the patcher thread's poll.
// Drives BOTH halves of the policy: the presentation (fill when wide,
// letterbox everywhere else) and the camera aspect target (wide vs native).
std::atomic<bool> g_wide_scene{false};
// Whether a valid WIDER-than-16:9 target is currently in effect (published by
// the patcher thread). False at 16:9 and at NARROWER windows - there the
// presentation must letterbox even in-mission, or the 16:9 guest frame would
// stretch vertically to fill a narrow window. (Narrower-than-16:9 rendering
// itself is not supported: the world could widen vertically by the same
// camera mechanism, but the 1280x720 UI cannot be expanded horizontally
// without pushing corner-anchored elements off-screen.)
std::atomic<bool> g_target_wide{false};
// Whether the UI shrink applies to the CURRENT scene (shared by the
// constant-level patch and the sub-viewport rect shrink; must stay in sync
// with the apply logic in WidescreenPatchUiOrtho). Wide scenes only: during
// world rendering (the crisp-HUD config) and in-mission without world draws
// (the pause menu over the frozen frame); outside wide scenes everything
// presents letterboxed vanilla and nothing is shrunk.
bool UiShrinkSceneActive() {
return g_wide_scene.load(std::memory_order_relaxed);
}
// The target-marker vertex shader (guest ucode hash) is never UI-shrunk: the
// game places markers by projecting world coordinates through the widened
// camera, so they are already positioned for the full-width display -
// shrinking them would pull them off-target toward screen center. Their box
// graphics render proportionally wider instead; positions are exact.
constexpr uint64_t kMarkerVsUcodeHash = 0xB686E181ACD543E9ull;
// Per-swap "already narrowed" bookkeeping for the marker-quad fix. The game
// rotates three ~692 KB vertex arenas; 16384 vertices covers one at the
// observed 52-byte stride with room to spare. Command-processor thread only.
constexpr uint32_t kMarkerMaxVertices = 16384;
constexpr uint32_t kMarkerBitmapWords = kMarkerMaxVertices / 64;
struct MarkerArenaGuard {
uint32_t base = 0;
uint64_t bits[kMarkerBitmapWords] = {};
// Returns true if the vertex was already narrowed this swap.
bool TestAndSet(uint32_t vertex_index) {
if (vertex_index >= kMarkerMaxVertices) {
return true; // Out of range: treat as done, i.e. leave it alone.
}
uint64_t& word = bits[vertex_index >> 6];
uint64_t bit = uint64_t(1) << (vertex_index & 63);
bool was_set = (word & bit) != 0;
word |= bit;
return was_set;
}
void Clear() { std::memset(bits, 0, sizeof(bits)); }
};
MarkerArenaGuard g_marker_guards[4];
MarkerArenaGuard& MarkerGuardFor(uint32_t arena_base) {
for (MarkerArenaGuard& g : g_marker_guards) {
if (g.base == arena_base) {
return g;
}
}
// Claim a free slot, or recycle the last one (the game uses three arenas).
for (MarkerArenaGuard& g : g_marker_guards) {
if (g.base == 0) {
g.base = arena_base;
g.Clear();
return g;
}
}
MarkerArenaGuard& g = g_marker_guards[rex::countof(g_marker_guards) - 1];
g.base = arena_base;
g.Clear();
return g;
}
void MarkerGuardsResetForSwap() {
for (MarkerArenaGuard& g : g_marker_guards) {
g.Clear();
}
}
uint32_t HostBitsOf(float value) {
uint32_t bits;
std::memcpy(&bits, &value, sizeof(bits));
return _byteswap_ulong(bits); // little-endian dword whose bytes read big-endian
}
// SEH-safe single-word accessors for the poke paths. Note the SDK's vectored
// handler runs BEFORE these __except filters, so a write fault on a
// GPU-write-watched physical page is still recovered transparently (like any
// guest write); only genuinely unrecoverable access violations land here and
// are reported as failure instead of crashing the process.
bool SafeReadU32(const uint32_t* p, uint32_t* out) noexcept {
__try {
*out = *p;
return true;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return false;
}
}
bool SafeWriteU32(uint32_t* p, uint32_t value) noexcept {
__try {
*p = value;
return true;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return false;
}
}
// Big-endian guest dword read through the translated view, SEH-safe.
bool SafeReadGuestU32(rex::memory::Memory* memory, uint32_t guest_ea, uint32_t* out) {
if (!guest_ea) {
return false;
}
uint32_t raw;
if (!SafeReadU32(memory->TranslateVirtual<const uint32_t*>(guest_ea), &raw)) {
return false;
}
*out = _byteswap_ulong(raw);
return true;
}
// The current mode task's vtable pointer (= screen id), or 0 while unknown
// (manager not up yet, slot mid-swap, or the read faulted).
uint32_t ReadCurrentScreenId(rex::memory::Memory* memory) {
uint32_t manager, task, vtable;
if (!SafeReadGuestU32(memory, kModeManagerPtrEA, &manager) || !manager) {
return 0;
}
if (!SafeReadGuestU32(memory, manager + kModeTaskSlotOffset, &task) || !task) {
return 0;
}
if (!SafeReadGuestU32(memory, task, &vtable)) {
return 0;
}
return vtable;
}
// The raw signature scan, SEH-guarded against pages vanishing mid-read.
// Scalar-only frame so __try is legal. Finds dwords equal to the big-endian
// 16:9 aspect whose neighbors look like a camera (fov, near, far in sane
// ranges); returns match count, stores up to max_out dword indices.
size_t WideScanRegionRaw(const uint32_t* words, size_t count, uint32_t aspect_pattern,
uint32_t prev_pattern, uint32_t* out_indices,
size_t max_out) noexcept {
size_t n = 0;
__try {
for (size_t i = 1; i + 2 < count; ++i) {
if (words[i] != aspect_pattern && (!prev_pattern || words[i] != prev_pattern)) {
continue;
}
uint32_t w;
float fov, near_plane, far_plane;
w = _byteswap_ulong(words[i - 1]);
std::memcpy(&fov, &w, sizeof(fov));
w = _byteswap_ulong(words[i + 1]);
std::memcpy(&near_plane, &w, sizeof(near_plane));
w = _byteswap_ulong(words[i + 2]);
std::memcpy(&far_plane, &w, sizeof(far_plane));
if (fov > 0.05f && fov < 2.0f && near_plane > 0.005f && near_plane < 10.0f &&
far_plane > 1000.0f && far_plane < 200000.0f) {
if (n < max_out) {
out_indices[n] = uint32_t(i);
}
++n;
}
}
} __except (EXCEPTION_EXECUTE_HANDLER) {
}
return n;
}
// The previously applied target's big-endian pattern (0 = none) - lets the
// static defaults be retargeted when the auto-derived aspect changes (window
// resized mid-session).
uint32_t g_prev_target_bits = 0; // Patcher thread only.
// The game's static 16:9 default aspect constant in the XEX image (and its
// second-view alias): cameras copy their initial aspect from here.
constexpr uint32_t kStaticDefaultAddrs[] = {0x8206A0F4u, 0x9206A0F4u};
// Keep the game's static 16:9 default constant(s) patched. Re-checked every
// sweep in case the game rewrites them (e.g. applying video settings).
void PatchStaticDefaults(rex::memory::Memory* memory, uint32_t aspect_pattern,
uint32_t target_bits, float target) {
static uint32_t patch_logs = 0;
static uint32_t unexpected_logs = 0;
for (uint32_t guest : kStaticDefaultAddrs) {
uint32_t* host = memory->TranslateVirtual<uint32_t*>(guest);
// Early in boot the page may not be committed yet (this runs from ~2s
// after graphics init, during the startup logo) - skip and retry on the
// next sweep rather than touching it.
MEMORY_BASIC_INFORMATION mbi{};
if (!VirtualQuery(host, &mbi, sizeof(mbi)) || mbi.State != MEM_COMMIT ||
(mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD))) {
continue;
}
uint32_t cur;
if (!SafeReadU32(host, &cur)) {
continue;
}
if (cur == target_bits) {
continue; // Already patched.
}
bool retarget = g_prev_target_bits && cur == g_prev_target_bits;
if (cur != aspect_pattern && !retarget) {
// Not the value we expect - wrong address for this build/version, or
// the game stores something else here right now. Don't touch it.
if (unexpected_logs < 4) {
++unexpected_logs;
REXLOG_ERROR("[AC6-WIDE] static default @ 0x{:08X}: unexpected 0x{:08X} "
"(expected 16:9), skipping",
guest, _byteswap_ulong(cur));
}
continue;
}
// The XEX image copy may be mapped read-only - unprotect before writing
// (kept writable; this field is re-checked every sweep anyway).
bool need_unprotect =
!(mbi.Protect & (PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE |
PAGE_EXECUTE_WRITECOPY));
if (need_unprotect) {
DWORD old_protect;
if (!VirtualProtect(host, sizeof(uint32_t), PAGE_READWRITE, &old_protect)) {
if (unexpected_logs < 4) {
++unexpected_logs;
REXLOG_ERROR("[AC6-WIDE] static default @ 0x{:08X}: read-only and unprotect "
"failed, skipping",
guest);
}
continue;
}
}
if (SafeWriteU32(host, target_bits) && patch_logs < 8) {
++patch_logs;
REXLOG_ERROR("[AC6-WIDE] static default @ 0x{:08X}: 1.77778 -> {:g}{}", guest, target,
need_unprotect ? " (page unprotected)" : "");
}
}
}
// Signature-scan committed guest memory for camera objects whose aspect field
// matches match_a (or match_b, 0 = unused) and poke it to to_bits. Returns the
// number of fields patched. Used in both directions: native -> wide entering a
// mission (plus stale previous-wide values after a window resize), and
// wide -> native leaving one.
uint32_t WidescreenSweep(rex::memory::Memory* memory, uint32_t match_a, uint32_t match_b,
uint32_t to_bits, float to_value) {
// One-time heartbeat pair: if the process dies between these two lines, the
// log pinpoints the sweep as the culprit.
static bool first_sweep = true;
if (first_sweep) {
REXLOG_ERROR("[AC6-WIDE] first sweep starting");
}
static uint32_t poke_logs = 0;
static uint32_t sweep_logs = 0;
static uint32_t skipped_ro_logs = 0;
constexpr size_t kMaxRegionMatches = 64;
uint32_t indices[kMaxRegionMatches];
uint32_t patched = 0;
uint64_t guest = 0x00010000;
while (guest < kGuestScanEnd) {
uint8_t* host = memory->TranslateVirtual(uint32_t(guest));
MEMORY_BASIC_INFORMATION mbi{};
if (!VirtualQuery(host, &mbi, sizeof(mbi)) || !mbi.RegionSize) {
break;
}
uint64_t skip = uint64_t(host - static_cast<uint8_t*>(mbi.BaseAddress));
uint64_t len = std::min(uint64_t(mbi.RegionSize) - skip, kGuestScanEnd - guest);
if (!len) {
break;
}
bool readable =
mbi.State == MEM_COMMIT && !(mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD)) &&
(mbi.Protect & (PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ |
PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY));
bool writable = (mbi.Protect & (PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE |
PAGE_EXECUTE_WRITECOPY)) != 0;
if (readable) {
size_t found = WideScanRegionRaw(reinterpret_cast<const uint32_t*>(host), size_t(len / 4),
match_a, match_b, indices, kMaxRegionMatches);
size_t stored = std::min(found, kMaxRegionMatches);
for (size_t h = 0; h < stored; ++h) {
uint32_t hit_guest = uint32_t(guest + uint64_t(indices[h]) * 4);
// Physical-view pages (>= 0xA0000000) may be read-only due to the
// SDK's GPU write watching; the poke faults and the SDK's handler
// recovers it like any guest write. A read-only page in a plain
// virtual heap has no such recovery - skip those.
if (!writable && hit_guest < 0xA0000000u) {
if (skipped_ro_logs < 4) {
++skipped_ro_logs;
REXLOG_ERROR("[AC6-WIDE] camera @ 0x{:08X} in read-only region, skipping",
hit_guest);
}
continue;
}
uint32_t* field = reinterpret_cast<uint32_t*>(host + uint64_t(indices[h]) * 4);
uint32_t fov_word = 0;
SafeReadU32(field - 1, &fov_word);
uint32_t w = _byteswap_ulong(fov_word);
float fov;
std::memcpy(&fov, &w, sizeof(fov));
if (!SafeWriteU32(field, to_bits)) {
continue;
}
++patched;
if (poke_logs < 32) {
++poke_logs;
REXLOG_ERROR("[AC6-WIDE] camera aspect @ 0x{:08X} (fov={:g}) -> {:g}", hit_guest,
fov, to_value);
}
}
}
guest += len;
}
if (first_sweep) {
first_sweep = false;
REXLOG_ERROR("[AC6-WIDE] first sweep done");
}
if (patched && sweep_logs < 16) {
++sweep_logs;
REXLOG_ERROR("[AC6-WIDE] sweep: patched {} camera aspect field(s) -> {:g}", patched,
to_value);
}
return patched;
}
DWORD WINAPI WidescreenThread(LPVOID) {
const uint32_t native_bits = HostBitsOf(kNativeAspect);
uint32_t poll_count = 0;
bool last_in_mission = false;
bool last_wide_scene = false;
uint32_t last_screen_id = 0;
// The wide pattern last written anywhere (0 = never widened). Kept across
// reverts so widen sweeps also convert stale leftovers.
uint32_t applied_wide_bits = 0;
// Consecutive sweeps that patched nothing since the last transition; out of
// the mission the scan stops after two clean passes (the statics recheck is
// cheap and continues) so the front end is not scanned forever.
uint32_t clean_reverts = 0;
for (;;) {
Sleep(kModePollMs);
bool enabled = REXCVAR_GET(ac6_widescreen);
rex::memory::Memory* memory = g_ws_memory.load(std::memory_order_acquire);
// Mode poll, every cycle: cheap SEH-safe 3-dereference guest read. A null
// read (manager not up, task slot mid-swap) holds the previous state.
bool in_mission = last_in_mission;
if (enabled && memory) {
uint32_t id = ReadCurrentScreenId(memory);
if (id != 0) {
in_mission = (id == kModeTaskGameVtable);
if (id != last_screen_id) {
last_screen_id = id;
static uint32_t mode_logs = 0;
if (mode_logs < 32) {
++mode_logs;
// The id log exists so an unlisted mode task that SHOULD present
// wide (if some in-mission path swaps tasks) can be identified
// from a user log and whitelisted.
REXLOG_ERROR("[AC6-WIDE] mode task 0x{:08X} ({})", id,
in_mission ? "mission" : "front-end");
}
}
}
} else {
in_mission = false;
}
last_in_mission = in_mission;
// Opt-in: in-engine cinematics outside the mission task (story scenes in
// the campaign flow) render wide too. Same demo-manager signal as the
// cutscene frame-rate clamp, ~300 ms decay - the 250 ms poll tracks it.
bool wide_scene =
in_mission || (enabled && REXCVAR_GET(ac6_widescreen_cinematics) &&
ac6::IsCinematicActive());
g_wide_scene.store(wide_scene, std::memory_order_relaxed);
bool transition = wide_scene != last_wide_scene;
last_wide_scene = wide_scene;
++poll_count;
if (!transition && poll_count < kSweepEveryPolls) {
continue; // Sweep on the 2 s cadence or immediately on a transition.
}
poll_count = 0;
// The target aspect is the actual window's, re-derived each sweep (a live
// resize adapts). At or narrower than 16:9 the widening disables (clamped
// to native -> target invalid -> letterboxed presentation).
float target = 0.0f;
if (enabled) {
uint32_t surface_w, surface_h;
if (rex::ui::GetPresentSurfaceSize(&surface_w, &surface_h) && surface_h) {
target = float(surface_w) / float(surface_h);
if (target > 8.0f) {
target = 8.0f;
}
if (target < kNativeAspect) {
target = kNativeAspect;
}
static float last_logged_target = 0.0f;
static uint32_t auto_logs = 0;
if (std::fabs(target - last_logged_target) > 1e-3f && auto_logs < 8) {
++auto_logs;
last_logged_target = target;
REXLOG_ERROR("[AC6-WIDE] auto aspect: window {}x{} -> target {:g}", surface_w,
surface_h, target);
}
}
// No surface yet (very early boot): retry next sweep.
}
bool target_valid =
target > 0.5f && target < 8.0f && std::fabs(target - kNativeAspect) >= 1e-4f;
g_target_wide.store(enabled && target_valid, std::memory_order_relaxed);
// Publish the UI shrink factor for the per-draw ortho patch (0 = off).
// Scene gating (mission / world) happens at the consumers.
uint32_t shrink_bits = 0;
if (enabled && target_valid) {
float shrink = kNativeAspect / target;
std::memcpy(&shrink_bits, &shrink, sizeof(shrink_bits));
}
g_ui_shrink_bits.store(shrink_bits, std::memory_order_relaxed);
if (!enabled || !memory) {
continue;
}
bool widen = wide_scene && target_valid;
// Any change in what the cameras should be aimed at re-arms the sweep
// (scene transitions AND target flips, e.g. a mid-mission resize to or
// from a <=16:9 window).
static bool last_widen = false;
if (widen != last_widen) {
clean_reverts = 0;
}
last_widen = widen;
if (widen) {
// Entering / inside a wide scene (mission, or an opted-in cinematic):
// keep the static defaults patched (so cameras are born wide) and
// convert any native or stale-wide cameras.
uint32_t to_bits = HostBitsOf(target);
if (to_bits != applied_wide_bits) {
clean_reverts = 0;
}
PatchStaticDefaults(memory, native_bits, to_bits, target);
uint32_t stale =
(applied_wide_bits && applied_wide_bits != to_bits) ? applied_wide_bits : 0;
WidescreenSweep(memory, native_bits, stale, to_bits, target);
applied_wide_bits = to_bits;
g_prev_target_bits = to_bits;
} else if (applied_wide_bits) {
// Out of every wide scene (or the target became native, e.g. a live
// resize to 16:9 or narrower): restore the static defaults and revert
// wide cameras so everything renders vanilla 16:9.
PatchStaticDefaults(memory, native_bits, native_bits, kNativeAspect);
if (clean_reverts < 2) {
uint32_t patched =
WidescreenSweep(memory, applied_wide_bits, 0, native_bits, kNativeAspect);
clean_reverts = patched ? 0 : clean_reverts + 1;
}
}
}
return 0;
}
} // namespace
namespace ac6 {
void WidescreenInit(rex::memory::Memory* memory) {
if (!memory) {
return;
}
g_ws_memory.store(memory, std::memory_order_release);
static std::once_flag once;
std::call_once(once, [] {
// No cvar is written here, deliberately. The feature drives presentation
// through the presenter's letterbox OVERRIDE instead (see
// WidescreenNotifySwapSource): writing present_letterbox would leak into
// the user's saved config - the in-game settings menu persists current
// cvar values - and would then keep the game stretched after the feature
// was switched off again.
if (REXCVAR_GET(ac6_widescreen)) {
// present_letterbox is REPORTED here, never written: it must still read
// exactly what the user configured (default true) with the feature on,
// so an in-game settings save can never persist a value we chose.
REXLOG_ERROR("[AC6-WIDE] widescreen active: cinematics={} (present_letterbox cvar "
"untouched at {}; presentation driven by the override)",
REXCVAR_GET(ac6_widescreen_cinematics) ? 1 : 0,
REXCVAR_GET(present_letterbox) ? 1 : 0);
}
CreateThread(nullptr, 0, WidescreenThread, nullptr, 0, nullptr);
});
}
bool WidescreenPatchUiOrtho(uint32_t* vs_float_constants, uint64_t vs_ucode_hash,
bool sub_viewport) {
uint32_t shrink_bits = g_ui_shrink_bits.load(std::memory_order_relaxed);
if (!shrink_bits) {
return false;
}
float shrink;
std::memcpy(&shrink, &shrink_bits, sizeof(shrink));
bool world = ac6::WorldRenderActiveRecently();
float* c = reinterpret_cast<float*>(vs_float_constants);
// Scene logic (shared helper): wide scenes only - the shrink exists to
// cancel the fill-window stretch, which is active exactly there.
bool apply = UiShrinkSceneActive();
// Sub-viewport draws (radar window, PiP inset) are placed by their
// VIEWPORT - the viewport rect is shrunk instead (WidescreenViewportShrinkX
// consumed in UpdateFixedFunctionState); their constants stay untouched.
// World scenes only, matching the viewport-shrink gate: menu/hangar
// sub-viewport panels keep their ordinary constant-level treatment.
if (sub_viewport && world) {
apply = false;
}
// The game-placed target markers stay full-width (see kMarkerVsUcodeHash).
if (vs_ucode_hash == kMarkerVsUcodeHash) {
apply = false;
}
// Runs on the command processor thread only.
static uint32_t patch_logs = 0;
// Idempotency ring: (a, tx) bit patterns this patch has produced. The
// register file persists across draws, and unlike the old exact-value
// match, the generalized shape rule would re-match its own output and
// compound the shrink every draw that reuses stale constants - so anything
// we ever emitted is recognized and skipped.
static uint64_t shrunk_keys[256] = {};
static uint32_t shrunk_key_count = 0;
static uint32_t shrunk_key_next = 0;
bool patched = false;
// Generalized screen-space 2D transform detection: any 4-vec4 block shaped
// r0 = (m00, m01, 0, tx) r1 = (m10, m11, 0, ty)
// r2 = (0, 0, c, tz) r3 = (0, 0, 0, 1) exactly
// with a tiny 2x2 (all |m| < 0.05 - screen transforms are 2/width-sized;
// excludes identity, world matrices and perspective/billboard blocks, whose
// w row is never (0,0,0,1)). Rotation is allowed - the radar map/blips spin
// with heading. Covers the plain 1280x720 UI ortho AND composed 2D
// transforms (radar contents, PiP window), so nested elements shrink
// consistently with their frames. Scaling the whole X output row (m00, m01,
// tx) shrinks around NDC 0 = screen center. The matrix needs 4 vec4s
// starting at n, so scan c0..c60.
for (uint32_t n = 0; n <= 60; ++n) {
float* r0 = c + 4 * n;
const float* r1 = r0 + 4;
const float* r2 = r0 + 8;
const float* r3 = r0 + 12;
if (r3[0] != 0.0f || r3[1] != 0.0f || r3[2] != 0.0f || r3[3] != 1.0f) {
continue;
}
if (r0[2] != 0.0f || r1[2] != 0.0f || r2[0] != 0.0f || r2[1] != 0.0f) {
continue;
}
float m00_abs = std::fabs(r0[0]);
float m01_abs = std::fabs(r0[1]);
float m10_abs = std::fabs(r1[0]);
float m11_abs = std::fabs(r1[1]);
float x_row_max = m00_abs > m01_abs ? m00_abs : m01_abs;
float y_row_max = m10_abs > m11_abs ? m10_abs : m11_abs;
float all_max = x_row_max > y_row_max ? x_row_max : y_row_max;
if (!(all_max < 0.05f && x_row_max > 1e-7f && y_row_max > 1e-7f)) {
continue;
}
float* tx = &r0[3];
// +-8: composed small-scale transforms overshoot +-1 considerably (the
// PiP window quad sits at ty ~ 5.8).
if (*tx < -8.0f || *tx > 8.0f || r1[3] < -8.0f || r1[3] > 8.0f) {
continue;
}
// Skip transforms this patch already shrank (see the ring above): hash
// the X output row we mutate (m00, m01, tx).
uint32_t m00_bits, m01_bits, tx_bits;
std::memcpy(&m00_bits, &r0[0], sizeof(m00_bits));
std::memcpy(&m01_bits, &r0[1], sizeof(m01_bits));
std::memcpy(&tx_bits, tx, sizeof(tx_bits));
uint64_t key = 1469598103934665603ull;
key = (key ^ m00_bits) * 1099511628211ull;
key = (key ^ m01_bits) * 1099511628211ull;
key = (key ^ tx_bits) * 1099511628211ull;
bool already_shrunk = false;
for (uint32_t i = 0; i < shrunk_key_count; ++i) {
if (shrunk_keys[i] == key) {
already_shrunk = true;
break;
}
}
if (already_shrunk || !apply) {
continue;
}
r0[0] *= shrink;
r0[1] *= shrink;
*tx *= shrink;
patched = true;
// Remember the shrunk output so it is never shrunk again.
std::memcpy(&m00_bits, &r0[0], sizeof(m00_bits));
std::memcpy(&m01_bits, &r0[1], sizeof(m01_bits));
std::memcpy(&tx_bits, tx, sizeof(tx_bits));
key = 1469598103934665603ull;
key = (key ^ m00_bits) * 1099511628211ull;
key = (key ^ m01_bits) * 1099511628211ull;
key = (key ^ tx_bits) * 1099511628211ull;
shrunk_keys[shrunk_key_next] = key;
shrunk_key_next = (shrunk_key_next + 1) & 255;
if (shrunk_key_count < 256) {
++shrunk_key_count;
}
if (patch_logs < 8) {
++patch_logs;
REXLOG_ERROR("[AC6-WIDE] screen transform @ c{} (m00={:g} tx={:g}) shrunk x{:g}", n,
r0[0] / shrink, *tx / shrink, shrink);
}
}
return patched;
}
bool WidescreenWantsMarkerQuadFix(uint64_t vs_ucode_hash) {
return vs_ucode_hash == kMarkerVsUcodeHash &&
g_ui_shrink_bits.load(std::memory_order_relaxed) != 0 &&
g_wide_scene.load(std::memory_order_relaxed);
}
void WidescreenShrinkMarkerQuads(uint8_t* vertices, uint32_t vertex_stride, uint32_t pos_offset,
const uint8_t* indices, bool indices_32bit, uint32_t count,
uint32_t arena_base) {
uint32_t shrink_bits = g_ui_shrink_bits.load(std::memory_order_relaxed);
if (!vertices || !vertex_stride || count < 4 || !shrink_bits) {
return;
}
float shrink;
std::memcpy(&shrink, &shrink_bits, sizeof(shrink));
if (!(shrink > 0.0f) || shrink >= 0.999f) {
return;
}
MarkerArenaGuard& guard = MarkerGuardFor(arena_base);
uint32_t quads = count / 4;
if (!quads) {
return;
}
// Read every quad's bounds up front. Text is drawn one quad per GLYPH, so a
// quad is not an element: narrowing each glyph about its own centre leaves
// the string's letter spacing at full width (letters end up thin and spread
// out). Elements are recovered below by grouping.
struct QuadBounds {
uint32_t vi[4];
float xlo, xhi, ylo, yhi;
bool valid;
};
static std::vector<QuadBounds> bounds; // CP thread only; reused per draw.
bounds.clear();
bounds.reserve(quads);
for (uint32_t q = 0; q < quads; ++q) {
QuadBounds b{};
b.valid = true;
float x[4], y[4];
for (uint32_t c = 0; c < 4 && b.valid; ++c) {
uint32_t at = q * 4 + c;
uint32_t vi;
if (!indices) {
vi = at;
} else if (indices_32bit) {
uint32_t raw;
std::memcpy(&raw, indices + at * 4, sizeof(raw));
vi = _byteswap_ulong(raw);
} else {
uint16_t raw;
std::memcpy(&raw, indices + at * 2, sizeof(raw));
vi = _byteswap_ushort(raw);
}
if (vi >= kMarkerMaxVertices) {
b.valid = false;
break;
}
b.vi[c] = vi;
const uint8_t* vp = vertices + size_t(vi) * vertex_stride + pos_offset;
uint32_t raw_x, raw_y;
std::memcpy(&raw_x, vp, sizeof(raw_x));
std::memcpy(&raw_y, vp + sizeof(float), sizeof(raw_y));
raw_x = _byteswap_ulong(raw_x);
raw_y = _byteswap_ulong(raw_y);
std::memcpy(&x[c], &raw_x, sizeof(x[c]));
std::memcpy(&y[c], &raw_y, sizeof(y[c]));
if (!std::isfinite(x[c]) || !std::isfinite(y[c])) {
b.valid = false;
}
}
if (b.valid) {
b.xlo = b.xhi = x[0];
b.ylo = b.yhi = y[0];
for (uint32_t c = 1; c < 4; ++c) {
b.xlo = x[c] < b.xlo ? x[c] : b.xlo;
b.xhi = x[c] > b.xhi ? x[c] : b.xhi;
b.ylo = y[c] < b.ylo ? y[c] : b.ylo;
b.yhi = y[c] > b.yhi ? y[c] : b.yhi;
}
}
bounds.push_back(b);
}
// Group consecutive quads into elements. The game emits a string's glyphs
// back to back, on one baseline, with a small kerning gap (measured: 8 px
// glyphs on a 10 px pitch, i.e. 2 px gaps), so a run of quads sharing a Y
// span and separated by less than kElementGapPx is one element. Everything
// else stays its own element, including a lone box quad.
constexpr float kElementGapPx = 6.0f;
constexpr float kBaselineEpsPx = 1.0f;
struct Element {
uint32_t first, last;
float xlo, xhi;
};
static std::vector<Element> elements; // CP thread only; reused per draw.
elements.clear();
for (uint32_t q = 0; q < quads;) {
if (!bounds[q].valid) {
++q;
continue;
}
Element e{};
e.first = e.last = q;
e.xlo = bounds[q].xlo;
e.xhi = bounds[q].xhi;
const float line_ylo = bounds[q].ylo;
const float line_yhi = bounds[q].yhi;
for (uint32_t n = q + 1; n < quads; ++n) {
const QuadBounds& b = bounds[n];
if (!b.valid) {
break;
}
bool same_line = std::fabs(b.ylo - line_ylo) <= kBaselineEpsPx &&
std::fabs(b.yhi - line_yhi) <= kBaselineEpsPx;
if (!same_line || b.xlo < e.xhi - kBaselineEpsPx || b.xlo - e.xhi > kElementGapPx) {
break;
}
e.xhi = b.xhi > e.xhi ? b.xhi : e.xhi;
e.xlo = b.xlo < e.xlo ? b.xlo : e.xlo;
e.last = n;
}
elements.push_back(e);
q = e.last + 1;
}
// Every element narrows about its OWN centre.
//
// Pivoting a label about its nearest box instead (so the label's offset
// from the box would shrink too) was tried and REVERTED: which marker a
// label belongs to is not encoded in the vertex data, and proximity is not
// a stable substitute - overlapping markers sit as little as 39 px apart,
// so under aircraft roll the nearest-box choice flips from frame to frame
// and the label visibly jumps between two spacings. A static, slightly wide
// label-to-box gap beats a moving one. Fixing the gap properly needs the
// game-side marker/label association, not screen geometry.
uint32_t narrowed_elements = 0;
for (const Element& e : elements) {
const float cx = 0.5f * (e.xlo + e.xhi);
for (uint32_t n = e.first; n <= e.last; ++n) {
const QuadBounds& b = bounds[n];
for (uint32_t c = 0; c < 4; ++c) {
if (guard.TestAndSet(b.vi[c])) {
continue; // Already narrowed this swap - never compound.
}
uint8_t* vp = vertices + size_t(b.vi[c]) * vertex_stride + pos_offset;
uint32_t raw;
std::memcpy(&raw, vp, sizeof(raw));
raw = _byteswap_ulong(raw);
float vx;
std::memcpy(&vx, &raw, sizeof(vx));
float nx = cx + (vx - cx) * shrink;
std::memcpy(&raw, &nx, sizeof(raw));
raw = _byteswap_ulong(raw);
std::memcpy(vp, &raw, sizeof(raw));
}
}
++narrowed_elements;
}
static uint32_t fix_logs = 0;
if (fix_logs < 4) {
++fix_logs;
REXLOG_ERROR("[AC6-WIDE] marker elements narrowed x{:g} ({} elements from {} quads, "
"arena 0x{:08X})",
shrink, narrowed_elements, quads, arena_base);
}
}
float WidescreenViewportShrinkX() {
// Cheapest test first: the shrink factor is zero unless the feature is
// enabled AND a wider-than-16:9 target is in effect, so a disabled build
// costs one relaxed atomic load per draw and never reads the clock.
uint32_t shrink_bits = g_ui_shrink_bits.load(std::memory_order_relaxed);
if (!shrink_bits) {
return 1.0f;
}
// World scenes ONLY: the sub-viewport misregistration matters for the
// in-mission radar/PiP insets. Menus and the hangar use sub-viewports for
// ordinary panels - scaling those wrecks their layout (learned the hard
// way), and their existing constant-level treatment is already correct.
if (!ac6::WorldRenderActiveRecently()) {
return 1.0f;
}
float shrink;
std::memcpy(&shrink, &shrink_bits, sizeof(shrink));
return shrink;
}
void WidescreenNotifySwapSource(bool gpu_composed, bool classification_valid) {
// Mode-classified presentation: wide-scene frames (mission, opted-in
// cinematics) fill the widened window; everything else (menus, hangar,
// briefing, FMV, attract - whose cameras the patcher keeps at native 16:9)
// presents letterboxed, i.e. vanilla. CPU-written frontbuffers (loading
// images, FMV frames) letterbox even in wide scenes: those pixels are
// 16:9-authored and must never stretch. And when no wider-than-16:9 target
// is in effect (a 16:9 or NARROWER window), everything letterboxes - at
// 16:9 that is pixel-identical to fill, and narrower windows get proper
// bars instead of a vertical stretch.
// Disabled: do no per-frame work at all. (Runs on the command processor
// thread, once per swap.) The one thing a disabled build still owes is
// releasing the presenter override if the cvar was switched off at
// runtime - done once on the transition, not every frame.
static bool s_was_enabled = false;
if (!REXCVAR_GET(ac6_widescreen)) {
if (s_was_enabled) {
s_was_enabled = false;
rex::ui::SetPresentLetterboxOverride(rex::ui::PresentLetterboxOverride::kUseCVar);
}
return;
}
s_was_enabled = true;
// New frame: marker quads may be narrowed again (same CP thread as the
// draws, so no synchronisation needed).
MarkerGuardsResetForSwap();
bool wide_scene = g_wide_scene.load(std::memory_order_relaxed);
bool cpu_frame = classification_valid && !gpu_composed;
// While the feature is on it owns the decision outright - fill only for a
// wide scene rendered through the widened cameras, letterbox otherwise -
// so the user's present_letterbox value is never consulted here.
bool force = !wide_scene || cpu_frame || !g_target_wide.load(std::memory_order_relaxed);
rex::ui::SetPresentLetterboxOverride(force ? rex::ui::PresentLetterboxOverride::kForceLetterbox
: rex::ui::PresentLetterboxOverride::kForceFill);
// Starts at 0 = "off", the presenter's actual initial state, so a disabled
// build never logs a spurious first transition.
static std::atomic<int> last_state{0};
int state = force ? 1 : 0;
if (last_state.exchange(state, std::memory_order_relaxed) != state) {
static std::atomic<uint32_t> transition_logs{0};
if (transition_logs.fetch_add(1, std::memory_order_relaxed) < 16) {
REXLOG_ERROR("[AC6-WIDE] presenter letterbox {} ({})", force ? "ON" : "off",
wide_scene ? (cpu_frame ? "wide-scene cpu-frame" : "wide-scene")
: "front-end");
}
}
}
} // namespace ac6
+82
View File
@@ -0,0 +1,82 @@
#pragma once
#include <cstdint>
namespace rex::memory {
class Memory;
}
namespace ac6 {
// AC6 enhancement: arbitrary aspect ratio (ultrawide), in-mission only.
// Called once from the D3D12 command processor's SetupContext; applies the
// one-switch config preset (this runs after the toml is loaded, unlike app
// create) and spawns a background thread that, while the ac6_widescreen cvar
// is enabled, polls the game's mode-task state machine and aims the cameras
// accordingly: inside a mission (mode task CModeTaskGame - gameplay,
// in-engine cutscenes and pause) the stored 16:9 aspect (the static default
// in the XEX image plus live camera objects found by signature scan) is
// patched to ac6_widescreen_aspect so the game builds its own projection
// wider; outside a mission everything is reverted to 16:9 so the front end
// (menus, hangar, briefing, attract) renders vanilla. In-engine cinematics
// outside the mission task can opt in via ac6_widescreen_cinematics. See
// ac6_widescreen.cpp for the discovered memory layout and the mode-task
// chain.
void WidescreenInit(rex::memory::Memory* memory);
// The 2D/UI half of the ultrawide feature. Called per draw from the D3D12
// command processor's IssueDraw with the vertex-shader float constant block
// (256 vec4s, host-endian, mutable) and the vertex shader's guest ucode hash
// (the game-placed target-marker shader is left full-width so markers stay
// on target). Detects screen-space 2D transforms among the constants and
// scales their X output row around screen center by 16:9 / target,
// pre-squeezing 2D rendering so the presenter's fill-window stretch cancels
// out. Applies only in wide scenes, where the fill presentation is active
// (world rendering, and in-mission non-world draws like the pause menu).
// Returns true if constants were modified - the caller must then invalidate
// the vertex float constant buffer binding. No-op (atomic load + compare)
// unless ac6_widescreen is on.
// sub_viewport: the draw uses a sub-screen guest viewport (radar window,
// PiP inset) - placed by the viewport, so its constants are never shrunk
// (the viewport rect is shrunk instead; see WidescreenViewportShrinkX).
bool WidescreenPatchUiOrtho(uint32_t* vs_float_constants, uint64_t vs_ucode_hash,
bool sub_viewport);
// Called by the D3D12 texture cache whenever the frontbuffer texture is
// requested for a swap. gpu_composed = the frontbuffer range holds GPU-written
// pages (shared-memory tracking); classification_valid = the caller actually
// computed it. Drives the mode-classified presentation: in-mission GPU frames
// fill the window, everything else - the whole front end, and CPU-written
// frames (FMV, loading images) even in-mission - presents letterboxed at
// 16:9, i.e. vanilla.
void WidescreenNotifySwapSource(bool gpu_composed, bool classification_valid);
// Whether the current draw is a target-marker draw whose quads should be
// narrowed by WidescreenShrinkMarkerQuads (marker shader, wide scene, shrink
// active). Cheap: an atomic load plus a hash compare.
bool WidescreenWantsMarkerQuadFix(uint64_t vs_ucode_hash);
// Marker-quad geometry fix. The game bakes marker box corners as screen-space
// positions computed through the WIDENED camera, so the positions are already
// right for the full-width display but the box art inherits the presenter's
// horizontal stretch (~1.34x wide at 21.5:9). Narrowing each quad's X extent
// about its OWN centre - not screen centre - cancels that stretch for the art
// while leaving the centre, i.e. the aim point, exactly where the game put it.
// A per-swap bitmap makes it idempotent when the same geometry is drawn more
// than once in a frame.
// vertices: host pointer to the guest vertex data; stride/pos_offset in bytes
// (position = two big-endian floats). indices: host pointer to the guest index
// buffer, or null for sequential vertices. Quads are 4 consecutive indices.
void WidescreenShrinkMarkerQuads(uint8_t* vertices, uint32_t vertex_stride,
uint32_t pos_offset, const uint8_t* indices,
bool indices_32bit, uint32_t count, uint32_t arena_base);
// The X shrink to apply to SUB-VIEWPORT rects (radar window, PiP inset) for
// the current scene, or 1.0. Sub-viewport elements are placed by their
// viewport, not their constants - the viewport rect must move with the
// uniformly shrunk full-screen UI (scaled around the render target center)
// while their constants stay untouched. World scenes with the in-world
// shrink only.
float WidescreenViewportShrinkX();
} // namespace ac6
+24
View File
@@ -46,6 +46,30 @@ class Presenter;
class Window;
class Win32Window;
// Runtime override of the letterbox presentation decision, for a game-side
// classifier that knows more about the frame than the present_letterbox cvar
// does. AC6 ultrawide drives it per frame: fill while a mission renders
// through its widened cameras, letterbox for 16:9-authored output (the whole
// front end, and CPU-written FMV/loading frames even in-mission).
//
// The override REPLACES the cvar for as long as it is set, and is deliberately
// separate from it: a feature must never write present_letterbox itself, or
// the value leaks into the user's saved config and outlives the feature being
// switched off. kUseCVar restores stock behaviour.
// Thread-safe; read once per paint.
enum class PresentLetterboxOverride : uint32_t {
kUseCVar = 0,
kForceLetterbox,
kForceFill,
};
void SetPresentLetterboxOverride(PresentLetterboxOverride mode);
// Last known presentation surface size in physical pixels, stamped every
// guest output paint. Returns false until the first paint. Thread-safe; used
// by the AC6 ultrawide feature to derive the target aspect ratio from the
// actual window instead of a manually configured value.
bool GetPresentSurfaceSize(uint32_t* width, uint32_t* height);
class UIDrawContext {
public:
UIDrawContext(const UIDrawContext& context) = delete;
@@ -96,6 +96,12 @@ class SharedMemory {
// regions in those pages.
void RangeWrittenByGpu(uint32_t start, uint32_t length);
// Whether any page in the range currently holds GPU-generated data (set by
// RangeWrittenByGpu, cleared when the CPU overwrites the page). Used by the
// AC6 ultrawide swap classification: a presented frontbuffer with no
// GPU-written pages was composed by the CPU (FMV frames, loading images).
bool IsAnyPageGpuWritten(uint32_t start, uint32_t length);
protected:
SharedMemory(memory::Memory& memory);
// Call in implementation-specific initialization.
@@ -34,6 +34,7 @@
#include <rex/ui/d3d12/d3d12_util.h>
#include "../../../../../src/ac6_backend_fixes/ac6_backend_hooks.h"
#include "../../../../../src/ac6_backend_fixes/ac6_widescreen.h"
#include "../../../../../src/ac6_native_graphics.h"
#include "../../../../../src/render_hooks.h"
@@ -1119,6 +1120,10 @@ bool D3D12CommandProcessor::SetupContext() {
VertexBufferMemoryInvalidationCallbackThunk, this);
}
// AC6: start the ultrawide camera-aspect patcher (idles unless the
// ac6_widescreen cvar is enabled).
ac6::WidescreenInit(memory_);
// Initialize the render target cache before configuring binding - need to
// know if using rasterizer-ordered views for the bindless root signature.
render_target_cache_ = std::make_unique<D3D12RenderTargetCache>(
@@ -2674,6 +2679,59 @@ bool D3D12CommandProcessor::IssueDraw(xenos::PrimitiveType primitive_type, uint3
ac6::NotifyWorldCompositorDraw();
}
// AC6 ultrawide: pre-squeeze the game's screen-space 2D transforms in the
// VS float constants so the presenter's fill-window stretch cancels out
// where the fill presentation is active (in-mission; the gameplay HUD's
// excluded marker shader is left full-width so world-projected target
// markers stay aligned). No-op unless ac6_widescreen + ac6_widescreen_ui
// are enabled.
// Sub-screen guest viewport (radar window, PiP inset): |x scale| is half
// the viewport width in guest pixels - well under the full 640.
float ac6_vp_xscale;
std::memcpy(&ac6_vp_xscale, &regs.values[XE_GPU_REG_PA_CL_VPORT_XSCALE],
sizeof(ac6_vp_xscale));
float ac6_vp_xscale_abs = ac6_vp_xscale < 0.0f ? -ac6_vp_xscale : ac6_vp_xscale;
bool ac6_sub_viewport = ac6_vp_xscale_abs >= 1.0f && ac6_vp_xscale_abs < 576.0f;
if (ac6::WidescreenPatchUiOrtho(&register_file_->values[XE_GPU_REG_SHADER_CONSTANT_000_X],
vertex_shader ? vertex_shader->ucode_data_hash() : 0,
ac6_sub_viewport)) {
cbuffer_binding_float_vertex_.up_to_date = false;
}
// AC6 ultrawide: narrow target-marker quads about their own centres so the
// fill-window stretch renders them square while the game-computed aim points
// stay put (see WidescreenShrinkMarkerQuads). The game draws them as a quad
// list of screen-space positions in a CPU-written arena, so the edit is made
// in guest memory here - before the vertex buffers are requested below - and
// is naturally transient (the arena is rewritten every frame).
if (vertex_shader && primitive_type == xenos::PrimitiveType::kQuadList &&
ac6::WidescreenWantsMarkerQuadFix(vertex_shader->ucode_data_hash())) {
for (const Shader::VertexBinding& vb : vertex_shader->vertex_bindings()) {
if (vb.attributes.empty() || !vb.stride_words) {
continue;
}
xenos::xe_gpu_vertex_fetch_t vf = regs.GetVertexFetch(vb.fetch_constant);
if (vf.type != xenos::FetchConstantType::kVertex) {
continue;
}
uint32_t arena_base = vf.address << 2;
uint8_t* vertex_data = memory_->TranslatePhysical(arena_base);
if (!vertex_data) {
continue;
}
const uint8_t* index_data = nullptr;
bool indices_32bit = false;
if (index_buffer_info && index_buffer_info->guest_base) {
index_data = memory_->TranslatePhysical(index_buffer_info->guest_base);
indices_32bit = index_buffer_info->format == xenos::IndexFormat::kInt32;
}
// The position attribute is the one at offset 0 of the vertex.
ac6::WidescreenShrinkMarkerQuads(vertex_data, vb.stride_words * sizeof(uint32_t),
uint32_t(vb.attributes[0].fetch_instr.attributes.offset) *
sizeof(uint32_t),
index_data, indices_32bit, index_count, arena_base);
}
}
if (!BeginSubmission(true)) {
return false;
}
@@ -4072,6 +4130,30 @@ void D3D12CommandProcessor::UpdateFixedFunctionState(
viewport.Height = float(viewport_info.xy_extent[1]);
viewport.MinDepth = viewport_info.z_min;
viewport.MaxDepth = viewport_info.z_max;
// AC6 ultrawide: sub-viewport draws (radar window, PiP inset) are placed
// by their VIEWPORT rect, not their shader constants - shrinking their
// constants moves content around the viewport center instead of the screen
// center (the radar-misregistration bug). Shrink the viewport rect (and
// its scissor) around the render target center instead; the constant-level
// patch skips these draws.
bool ac6_vp_scaled = false;
float ac6_vp_shrink = ac6::WidescreenViewportShrinkX();
if (ac6_vp_shrink != 1.0f && !normalized_depth_control.z_enable &&
(viewport.TopLeftX >= 8.0f || viewport.TopLeftY >= 8.0f)) {
// Placed UI insets only (radar window, PiP): inset-sized, depth
// disabled, and offset from the origin. Internal render-to-texture
// passes (half-res effects, shadows, EDRAM ops) are depth-enabled
// and/or origin-anchored - scaling THEIR viewports white-outs the world
// (learned the hard way).
float ac6_full_width = 1280.0f * float(texture_cache_->draw_resolution_scale_x());
if (viewport.Width >= 1.0f && viewport.Width < ac6_full_width * 0.35f) {
float ac6_center_x = ac6_full_width * 0.5f;
viewport.TopLeftX = ac6_center_x + (viewport.TopLeftX - ac6_center_x) * ac6_vp_shrink;
viewport.Width *= ac6_vp_shrink;
ac6_vp_scaled = true;
}
}
SetViewport(viewport);
// Scissor.
@@ -4080,6 +4162,13 @@ void D3D12CommandProcessor::UpdateFixedFunctionState(
scissor_rect.top = LONG(scissor.offset[1]);
scissor_rect.right = LONG(scissor.offset[0] + scissor.extent[0]);
scissor_rect.bottom = LONG(scissor.offset[1] + scissor.extent[1]);
if (ac6_vp_scaled) {
float ac6_center_x = 1280.0f * float(texture_cache_->draw_resolution_scale_x()) * 0.5f;
scissor_rect.left = LONG(ac6_center_x + (float(scissor_rect.left) - ac6_center_x) *
ac6_vp_shrink);
scissor_rect.right = LONG(ac6_center_x + (float(scissor_rect.right) - ac6_center_x) *
ac6_vp_shrink + 0.5f);
}
SetScissorRect(scissor_rect);
if (render_target_cache_->GetPath() == RenderTargetCache::Path::kHostRenderTargets) {
@@ -38,8 +38,11 @@
#include <rex/hash.h>
#include "../../../../../src/ac6_backend_fixes/ac6_backend_hooks.h"
#include "../../../../../src/ac6_backend_fixes/ac6_widescreen.h"
#include "../../../../../src/ac6_texture_overrides.h"
REXCVAR_DECLARE(bool, ac6_widescreen);
namespace rex::graphics::d3d12 {
REXCVAR_DEFINE_BOOL(d3d12_log_bc1_diagnostics, false, "GPU/D3D12",
@@ -1600,6 +1603,37 @@ ID3D12Resource* D3D12TextureCache::RequestSwapTexture(D3D12_SHADER_RESOURCE_VIEW
// Only texture->key, not the result of BindingInfoFromFetchConstant, contains
// whether the texture is scaled.
key = texture->key();
// AC6 ultrawide: classify this swap for the presenter via the shared
// memory's GPU-written page tracking (authoritative: RangeWrittenByGpu sets
// it on resolves, a CPU overwrite clears it - unlike the scaled-resolve
// marking, which can serve upsampled CPU data while still reading as
// "scaled"). A presented frontbuffer with no GPU-written pages was composed
// by the CPU (FMV frames, loading images) - 16:9-authored pixels that must
// be letterboxed rather than stretched to the window.
{
bool gpu_composed = false;
// The page query and its log only matter (and only cost) with the
// feature on; the notify itself early-outs when disabled.
if (REXCVAR_GET(ac6_widescreen)) {
texture_util::TextureGuestLayout swap_layout = key.GetGuestLayout();
uint32_t swap_extent = swap_layout.base.level_data_extent_bytes;
gpu_composed =
swap_extent && shared_memory().IsAnyPageGpuWritten(key.base_page << 12, swap_extent);
// Log frontbuffer address + composition on state change (capped).
static uint32_t ac6_last_swap_state = UINT32_MAX;
static uint32_t ac6_swap_state_logs = 0;
uint32_t ac6_swap_state = (uint32_t(key.base_page) << 1) | (gpu_composed ? 1u : 0u);
if (ac6_swap_state != ac6_last_swap_state) {
ac6_last_swap_state = ac6_swap_state;
if (ac6_swap_state_logs < 32) {
++ac6_swap_state_logs;
REXGPU_ERROR("[AC6-SWAP] frontbuffer base_page={:05X} gpu_written={} extent=0x{:X}",
uint32_t(key.base_page), gpu_composed ? 1 : 0, swap_extent);
}
}
}
ac6::WidescreenNotifySwapSource(gpu_composed, true);
}
if (width_unscaled_out) {
*width_unscaled_out = key.GetWidth();
}
+25
View File
@@ -380,6 +380,31 @@ void SharedMemory::MakeRangeValid(uint32_t start, uint32_t length, bool written_
}
}
bool SharedMemory::IsAnyPageGpuWritten(uint32_t start, uint32_t length) {
if (!length || start >= kBufferSize) {
return false;
}
length = std::min(length, kBufferSize - start);
uint32_t page_first = start >> page_size_log2_;
uint32_t page_last = (start + length - 1) >> page_size_log2_;
uint32_t block_first = page_first >> 6;
uint32_t block_last = page_last >> 6;
auto global_lock = global_critical_region_.Acquire();
for (uint32_t i = block_first; i <= block_last; ++i) {
uint64_t bits = UINT64_MAX;
if (i == block_first) {
bits &= ~((uint64_t(1) << (page_first & 63)) - 1);
}
if (i == block_last && (page_last & 63) != 63) {
bits &= (uint64_t(1) << ((page_last & 63) + 1)) - 1;
}
if (system_page_flags_valid_and_gpu_written_[i] & bits) {
return true;
}
}
return false;
}
void SharedMemory::UnlinkWatchRange(WatchRange* range) {
uint32_t bucket = range->page_first << page_size_log2_ >> kWatchBucketSizeLog2;
WatchNode* node = range->node_first;
+42 -2
View File
@@ -284,6 +284,41 @@ GuestOutputPaintConfig BuildGuestOutputPaintConfigFromCVar() {
namespace rex {
namespace ui {
// See SetPresentLetterboxOverride in the header. Stamped from the GPU
// subsystem (swap-source classification), read by GetGuestOutputPaintFlow.
static std::atomic<PresentLetterboxOverride> g_present_letterbox_override{
PresentLetterboxOverride::kUseCVar};
void SetPresentLetterboxOverride(PresentLetterboxOverride mode) {
g_present_letterbox_override.store(mode, std::memory_order_relaxed);
}
// Whether the letterbox path is enabled for this paint: the override when one
// is set, the cvar otherwise.
static bool PresentLetterboxEnabled() {
switch (g_present_letterbox_override.load(std::memory_order_relaxed)) {
case PresentLetterboxOverride::kForceLetterbox:
return true;
case PresentLetterboxOverride::kForceFill:
return false;
default:
return REXCVAR_GET(present_letterbox);
}
}
// See GetPresentSurfaceSize in the header. Width in the high 32 bits.
static std::atomic<uint64_t> g_present_surface_size{0};
bool GetPresentSurfaceSize(uint32_t* width, uint32_t* height) {
uint64_t packed = g_present_surface_size.load(std::memory_order_relaxed);
if (!packed) {
return false;
}
*width = uint32_t(packed >> 32);
*height = uint32_t(packed);
return true;
}
void Presenter::FatalErrorHostGpuLossCallback([[maybe_unused]] bool is_responsible,
[[maybe_unused]] bool statically_from_ui_thread) {
rex::FatalError("Graphics device lost (probably due to an internal error)");
@@ -880,6 +915,11 @@ Presenter::GuestOutputPaintFlow Presenter::GetGuestOutputPaintFlow(
return flow;
}
// Publish the surface size for aspect-ratio auto-detection (AC6 ultrawide).
g_present_surface_size.store((uint64_t(surface_width_in_paint_connection_) << 32) |
surface_height_in_paint_connection_,
std::memory_order_relaxed);
flow.properties = properties;
// Multiplication-division rounding to the nearest.
@@ -929,7 +969,7 @@ Presenter::GuestOutputPaintFlow Presenter::GetGuestOutputPaintFlow(
output_height = rescale_unsigned(surface_height_in_paint_connection_, 100, present_safe_area);
letterbox = true;
}
if (letterbox && REXCVAR_GET(present_letterbox)) {
if (letterbox && PresentLetterboxEnabled()) {
output_width = rescale_unsigned(surface_height_in_paint_connection_ * 100,
properties.display_aspect_ratio_x,
properties.display_aspect_ratio_y * present_safe_area);
@@ -964,7 +1004,7 @@ Presenter::GuestOutputPaintFlow Presenter::GetGuestOutputPaintFlow(
output_width = rescale_unsigned(surface_width_in_paint_connection_, 100, present_safe_area);
letterbox = true;
}
if (letterbox && REXCVAR_GET(present_letterbox)) {
if (letterbox && PresentLetterboxEnabled()) {
output_height = rescale_unsigned(surface_width_in_paint_connection_ * 100,
properties.display_aspect_ratio_y,
properties.display_aspect_ratio_x * present_safe_area);