diff --git a/CMakeLists.txt b/CMakeLists.txt index 2b802bd5..23b026ff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,6 +43,7 @@ set(AC6RECOMP_SOURCES src/ac6_backend_fixes/ac6_cutscene_resync.cpp src/ac6_backend_fixes/ac6_effect_mode_fix.cpp src/ac6_backend_fixes/ac6_fps_physics_fix.cpp + src/ac6_backend_fixes/ac6_fullres_effects.cpp src/ac6_backend_fixes/ac6_kbm_input.cpp src/ac6_backend_fixes/ac6_widescreen.cpp ) diff --git a/README.md b/README.md index 41c6b42d..3fd6ddce 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ Settings live in **`ac6recomp.toml`** next to the executable. Most can also be c | `ac6_widescreen` | `false` | Ultrawide support (hor+) in missions. Menus and hangar stay 16:9 | | `ac6_widescreen_cinematics` | `true` | With ultrawide on, also widen in-engine cinematics. They are staged for 16:9, so this can expose set edges | | `ac6_terrain_hd` | `true` | Draw terrain at the full shipped resolution (2× what the console drew), which also removes terrain cracks | +| `ac6_fullres_effects` | `false` | Draw clouds, smokes, trails, and afterburner effect at native resolution (2× what the console drew), slightly affects performance | | `ac6_cursor_hide_seconds` | `3.0` | Hide the mouse cursor after this many idle seconds. `0` = never hide | | `ac6_kbm_enabled` | `false` | **Enable keyboard and mouse controls.** Off by default — controllers work out of the box | | `ac6_kbm_config` | `ac6_input.toml` | Path to the key bindings file. Edits are picked up live | diff --git a/src/ac6_backend_fixes/ac6_fullres_effects.cpp b/src/ac6_backend_fixes/ac6_fullres_effects.cpp new file mode 100644 index 00000000..46719b95 --- /dev/null +++ b/src/ac6_backend_fixes/ac6_fullres_effects.cpp @@ -0,0 +1,543 @@ +// AC6 enhancement: native-resolution effects (game-side patch). +// +// The game registers every render buffer once at init (guest sub_821D51C0) +// into a buffer-manager singleton through two thin wrappers: +// sub_82331F20(id, width, height, samples, bpp) -> mgr method CB48 +// sub_82331F48(id, width, height, a, b, c) -> mgr method CB90 +// The half-res effects chain is registered as +// id 0x8004 640x360 samples=1 bpp=32 (resolve texture) +// id 0x8008 640x360 samples=4 r7=0 (EDRAM surface, 4xMSAA) +// id 0x800A 640x360 r6=0x00030006 bpp=32 (packed variant) +// (matching RenderDoc: the effects surface is EDRAM base 0, pitch 16 tiles, +// 4xMSAA, k_8_8_8_8 - the scene is DOWNSCALED into it, effects draw on top, +// then it resolves and composites over the full-res scene.) +// +// Patch: rewrite those registrations to 1280x720 and drop 4xMSAA to 1x. +// 640x360@4xMSAA and 1280x720@1x occupy EXACTLY the same 720 EDRAM tiles +// (16-tile pitch both), so the game's EDRAM layout does not move; a +// 1280x720@4x surface could not exist anyway (2880 > 2048 tiles). Allocation +// sizes, texture headers, auto-viewports and resolve rects all flow from the +// registry, so the game stays self-consistent downstream. The emulator sees +// an ordinary full-res surface - no RT-cache changes needed. + +#define WIN32_LEAN_AND_MEAN +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include + +#include +#include +#include + +#include +#include +#include + +#include "ac6_fullres_effects.h" + +REXCVAR_DEFINE_BOOL(ac6_fullres_effects, false, "AC6/Enhancements", + "Draw the clouds, smoke and explosions at the scene resolution " + "instead of the half resolution the game uses, so they stop " + "looking soft next to the world at raised resolution scales.") + .lifecycle(rex::cvar::Lifecycle::kRequiresRestart); + +namespace { + +// The two halves of the half-res effects chain, and the only registry entries +// this touches. 8004 is the resolve texture, 8008 its EDRAM surface. +// Deliberately NOT 8009/800A: those are the silhouette pair, they are not sized +// from the registry anyway, and enlarging them only starves the texture pool +// until the game stops booting. +constexpr uint32_t kFxResolveTextureId = 0x8004; +constexpr uint32_t kFxEdramSurfaceId = 0x8008; + +// Extra bytes reserved for the game's shared texture pool. The enlarged buffers +// need about 19 MB more than vanilla; without the reserve the pool runs dry and +// whatever allocates last fails - in practice the 2048x2048 shadow map and the +// 1 MB effect buffers behind the afterburner and smoke. +constexpr uint32_t kFxTexturePoolBonus = 24u * 1024u * 1024u; + +bool FullresEffectsOn() { return REXCVAR_GET(ac6_fullres_effects); } + +bool FxBufIdSelected(uint32_t id) { + return id == kFxResolveTextureId || id == kFxEdramSurfaceId; +} + +} // namespace + +// One line the first time each mechanism engages - the feature's entire log +// surface. Enough to tell from a user's log which parts took effect, and +// nothing per frame. Error level so it survives ac6_performance_mode's +// log_level=error, like the other AC6 activation lines. +#define FX_LOG_ONCE(...) \ + do { \ + static std::atomic fx_logged_{false}; \ + if (!fx_logged_.exchange(true, std::memory_order_relaxed)) { \ + REXLOG_ERROR(__VA_ARGS__); \ + } \ + } while (false) + +// Buffer registration wrapper A: (id, width, height, samples, bpp). +PPC_EXTERN_FUNC(__imp__rex_sub_82331F20); +PPC_FUNC_IMPL(rex_sub_82331F20) { + PPC_FUNC_PROLOGUE(); + + if (FullresEffectsOn() && ctx.r4.u32 == 640 && ctx.r5.u32 == 360 && + FxBufIdSelected(ctx.r3.u32)) { + ctx.r4.u64 = 1280; + ctx.r5.u64 = 720; + if (ctx.r6.u32 == 4) { + // 4xMSAA half-res -> 1x full-res: the same EDRAM tiles, and the + // only option - a 1280x720 4xMSAA surface needs 2880 of 2048 tiles. + ctx.r6.u64 = 1; + } + FX_LOG_ONCE("[AC6 full-res effects] registry {:04X}/{:04X} -> 1280x720, 1xMSAA", + kFxResolveTextureId, kFxEdramSurfaceId); + } + + __imp__rex_sub_82331F20(ctx, base); +} + +namespace { + +// The game module that renders the effects chain (sub_820AF4C8 and its +// neighbors: downscale pass setup, viewport caller 820AF540, resolve callers +// 820AF5A8/5DC/634). Its per-frame 640x360 immediates are rewritten at the +// D3D API boundary, gated to callers from this range. +constexpr uint32_t kFxRenderModuleStart = 0x820AF000; +constexpr uint32_t kFxRenderModuleEnd = 0x820B0000; + +bool FxRenderModuleCaller(uint64_t guest_lr) { + const uint32_t lr = uint32_t(guest_lr); + return lr >= kFxRenderModuleStart && lr < kFxRenderModuleEnd; +} + +} // namespace + +// The one fx-module pass that must KEEP its 640x360 viewport: the silhouette +// DOWNSCALER (fixed 2:1 box filter with the x2 baked into its pixel shader; +// lifting its output viewport makes the baked x2 read texels 0..2560 of the +// 1280-wide full-res source -> wraps twice -> the 2x2 quad silhouette that +// haunted rounds 7-21). It is the only fx-module pass rendering into an +// R32_FLOAT target (Xenos k_32_FLOAT = 36): discriminate by the bound RT0 +// surface object's format field (packed fmt dword at obj+40, low 6 bits). +static bool FxRt0IsR32Float(uint8_t* base, uint32_t device) { + if (!device) { + return false; + } + const uint32_t rt0 = PPC_LOAD_U32(device + 12432); + if (!rt0) { + return false; + } + const uint32_t fmt_word = PPC_LOAD_U32(rt0 + 40); + return (fmt_word & 0x3F) == 36; // k_32_FLOAT +} + +// D3DDevice_SetViewport (int variant): r4 -> {X, Y, Width, Height, MinZ, +// MaxZ}. The effects module sets 640x360 from immediates each frame; rewrite +// to 1280x720 in the caller's struct (rebuilt every call - safe to mutate). +// 640x360 requests from other callers are left untouched. +PPC_EXTERN_FUNC(__imp__rex_sub_821DD028); +PPC_FUNC_IMPL(rex_sub_821DD028) { + PPC_FUNC_PROLOGUE(); + + const uint32_t vp_ptr = ctx.r4.u32; + bool patched = false; + if (FullresEffectsOn() && vp_ptr) { + const uint32_t w = PPC_LOAD_U32(vp_ptr + 8); + const uint32_t h = PPC_LOAD_U32(vp_ptr + 12); + if (w == 640 && h == 360) { + // fx render module ONLY. The orchestrator wrapper (8234E47C) must + // NOT be rewritten: it derives viewports from its objects' dims, + // so doubled objects request 1280x720 by themselves - the 640x360 + // requests left are passes whose targets legitimately stay small + // (the k_8 mask producers; force-doubling those crops the masks + // and kills the afterburner/smoke particles - round 8-11 bug). + // The DOWNSCALER (R32F target) must also keep 640x360: its 2:1 + // box filter has the x2 baked into the shader. The device slot + // can be stale at SetViewport time (game binds after setting the + // viewport), so check BOTH the freshly latched SetRenderTarget + // surface and the device slot. + bool downscaler = false; + if (FxRenderModuleCaller(ctx.lr)) { + const uint32_t latched_rt0 = ac6::backend::LastRt0Bound(); + const bool latch_r32f = + latched_rt0 && (PPC_LOAD_U32(latched_rt0 + 40) & 0x3F) == 36; + const bool slot_r32f = FxRt0IsR32Float(base, ctx.r3.u32); + downscaler = latch_r32f || slot_r32f; + } + patched = FxRenderModuleCaller(ctx.lr) && !downscaler; + if (patched) { + PPC_STORE_U32(vp_ptr + 8, 1280); + PPC_STORE_U32(vp_ptr + 12, 720); + FX_LOG_ONCE("[AC6 full-res effects] effects module viewport -> 1280x720"); + } + } + } + + __imp__rex_sub_821DD028(ctx, base); + + // The orchestrator wrapper may pass a viewport struct living inside a + // PERSISTENT object rather than a stack temp. The values are consumed + // synchronously inside the call above; restore them so CPU-side size + // math reading the same object never sees the doubled dims. + if (patched) { + PPC_STORE_U32(vp_ptr + 8, 640); + PPC_STORE_U32(vp_ptr + 12, 360); + } +} + +namespace { + +// The orchestrator's view-builder methods (8234E908 / 8234E9A0) construct +// textures from OBJECT fields: width at [obj+12], height at [obj+16], fmt at +// [obj+20], XG header at obj+28. The stencil resolve destination (e.g. the +// 640x360 texture the aircraft-silhouette mask lands in) is built here. +// Poking the fields at builder entry doubles the header AND any downstream +// size math reading the same fields. Persistent on the object - consistent +// across rebuilds. +// The builders' backing allocation is sized by a CALLER-provided argument +// (r4/r5), not by the XG header they create - doubling only the header made +// a later header-sized memcpy overrun the 640x360-sized buffer (ctd-3, AV in +// VCRUNTIME memcpy at boot). Known 640x360 byte sizes from the texhdr return +// logs: 0xF0000 (8888 tiled) and 0x43800 (k_8 tiled). Only when one of the +// args carries a recognized size is it scaled x4 alongside the dims poke; +// otherwise the object is left untouched (safe boot, evidence logged). +void FxPatchViewObjectDims(PPCContext& ctx, uint8_t* base) { + if (!FullresEffectsOn()) { + return; + } + const uint32_t obj = ctx.r3.u32; + if (!obj) { + return; + } + const uint32_t w = PPC_LOAD_U32(obj + 12); + const uint32_t h = PPC_LOAD_U32(obj + 16); + if (w != 640 || h != 360) { + return; + } + const uint32_t fmt = PPC_LOAD_U32(obj + 20); + auto is_known_size = [](uint32_t v) { return v == 0xF0000 || v == 0x43800; }; + if (is_known_size(ctx.r4.u32)) { + ctx.r4.u64 = uint64_t(ctx.r4.u32) * 4; + } else if (is_known_size(ctx.r5.u32)) { + ctx.r5.u64 = uint64_t(ctx.r5.u32) * 4; + } else { + // No recognized size argument: leave the object alone rather than + // enlarge a header over a buffer that stays 640x360-sized. + return; + } + PPC_STORE_U32(obj + 12, 1280); + PPC_STORE_U32(obj + 16, 720); + FX_LOG_ONCE("[AC6 full-res effects] orchestrator view objects -> 1280x720"); +} + +} // namespace + +PPC_EXTERN_FUNC(__imp__rex_sub_8234E908); +PPC_FUNC_IMPL(rex_sub_8234E908) { + PPC_FUNC_PROLOGUE(); + FxPatchViewObjectDims(ctx, base); + __imp__rex_sub_8234E908(ctx, base); +} + +// The shared texture pool's backing reserve (called at pool init with the +// reserve size in r3; physical alloc placed anywhere by the emulator). Grown by +// kFxTexturePoolBonus so the enlarged buffers stop starving the allocations +// that happen to come last (shadow map, effect buffers). +PPC_EXTERN_FUNC(__imp__rex_sub_8233C280); +PPC_FUNC_IMPL(rex_sub_8233C280) { + PPC_FUNC_PROLOGUE(); + + if (FullresEffectsOn()) { + ctx.r3.u64 = ctx.r3.u32 + kFxTexturePoolBonus; + FX_LOG_ONCE("[AC6 full-res effects] texture pool reserve +{} MB", + kFxTexturePoolBonus / (1024 * 1024)); + } + + __imp__rex_sub_8233C280(ctx, base); +} + +PPC_EXTERN_FUNC(__imp__rex_sub_8234E9A0); +PPC_FUNC_IMPL(rex_sub_8234E9A0) { + PPC_FUNC_PROLOGUE(); + FxPatchViewObjectDims(ctx, base); + __imp__rex_sub_8234E9A0(ctx, base); +} + +namespace ac6::backend { + +// RT0 latch (relocated from the removed recon module): the last surface bound +// through D3DDevice_SetRenderTarget slot 0. See ac6_fullres_effects.h. +namespace { +thread_local uint32_t g_last_rt0_bound = 0; +} // namespace +void NoteRt0Bound(uint32_t index, uint32_t surface) { + if (index == 0) { + g_last_rt0_bound = surface; + } +} +uint32_t LastRt0Bound() { return g_last_rt0_bound; } + +namespace { +// The downscaler-halving flags the next resolve, whose dest +// data base is the silhouette mask - the compositor's fetch at that base is +// cropped to 640x360. +std::atomic g_downscaler_resolve_pending{0}; +std::atomic g_mask_base{0}; + +bool SafeCopyU32Array(const uint32_t* host, uint32_t* out, uint32_t count) noexcept { + __try { + for (uint32_t i = 0; i < count; ++i) { + out[i] = host[i]; + } + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} +} // namespace + +bool FxFixDownscalerDraw(uint64_t ps_ucode_hash, uint32_t* surface_info, + uint32_t* vp_xscale, uint32_t* vp_yscale, + uint32_t* vp_xoffset, uint32_t* vp_yoffset) { + if (!FullresEffectsOn()) { + return false; + } + if (ps_ucode_hash != UINT64_C(0x8EE463763E880F35)) { + return false; + } + const uint32_t pitch = *surface_info & 0x3FFF; + if (pitch <= 640) { + return false; // already vanilla-sized (not doubled) - nothing to do + } + // Restore this draw's target to 640 wide: halve the surface pitch and the + // viewport so the 2:1 downscale reads its 1280-wide input exactly once. + *surface_info = (*surface_info & ~0x3FFFu) | (pitch / 2); + auto halve = [](uint32_t* reg_bits) { + float v; + std::memcpy(&v, reg_bits, sizeof(v)); + v *= 0.5f; + std::memcpy(reg_bits, &v, sizeof(v)); + }; + halve(vp_xscale); + halve(vp_yscale); + halve(vp_xoffset); + halve(vp_yoffset); + FX_LOG_ONCE("[AC6 full-res effects] silhouette downscaler kept at its native 640 wide"); + // Flag: the NEXT guest Resolve is this downscaler's - capture its dest as + // the mask base for the compositor crop. + g_downscaler_resolve_pending.store(1, std::memory_order_relaxed); + return true; +} + +// Called from the guest D3D Resolve hook with the dest-texture object pointer +// (D3DDevice_Resolve r6). If the downscaler just ran, this resolve writes the +// silhouette mask - record its guest data base for the compositor crop. The +// dest object embeds a GPU fetch constant; its base is dword_1 (offset 32) +// bits 12-31 (like the texture-header dumps). +void FxNoteResolveDest(uint32_t dest_obj, uint8_t* base) { + if (!FullresEffectsOn()) { + return; + } + if (g_downscaler_resolve_pending.exchange(0, std::memory_order_relaxed) == 0) { + return; + } + if (!dest_obj || dest_obj < 0x1000 || dest_obj >= 0xE0000000u) { + g_downscaler_resolve_pending.store(1, std::memory_order_relaxed); // keep looking + return; + } + uint32_t* host = reinterpret_cast(base + dest_obj); + uint32_t words[10]; + if (!SafeCopyU32Array(host, words, 10)) { + g_downscaler_resolve_pending.store(1, std::memory_order_relaxed); + return; + } + // words are big-endian; fetch dword_1 (base+fmt) at [8], dword_2 (dims) at [9]. + const uint32_t d1 = _byteswap_ulong(words[8]); + const uint32_t d2 = _byteswap_ulong(words[9]); + const uint32_t view_base = (d1 >> 12) << 12; + const uint32_t phys_base = view_base & 0x1FFFFFFFu; // strip the 0xA0/0xC0 view + const uint32_t w = (d2 & 0x1FFF) + 1; + // The mask is the full-res (~1280 wide) resolve dest; skip small bloom + // resolves. Keep the flag armed until we find it (or a frame passes). + if (w >= 1000) { + g_mask_base.store(phys_base, std::memory_order_relaxed); + } else { + g_downscaler_resolve_pending.store(1, std::memory_order_relaxed); // keep looking + } +} + +void FxCropCompositorMask(uint64_t vs_hash, uint64_t ps_hash, uint32_t* fetch_constants) { + if (!FullresEffectsOn() || + !fetch_constants) { + return; + } + // AUTO-DETECT the mask base (address-independent, updated whenever a draw + // shows the signature): the silhouette mask is the only texture the cloud + // draws bind at BOTH fetch slot 2 AND slot 13 with the same base (effect + // layers each sit at a single slot). Confirmed base 1AF09000 at 2+13. + // Stored globally so it is then cropped at EVERY draw that reads it - + // including cloud draws that read the mask WITHOUT the 2==13 signature + // (those uncropped draws are what regressed the per-draw version). + static std::atomic g_auto_mask_base{0}; + { + const uint32_t* f2 = &fetch_constants[2 * 6]; + const uint32_t* f13 = &fetch_constants[13 * 6]; + if ((f2[0] & 0x3) == 2 && (f13[0] & 0x3) == 2 && (f2[1] & 0x3F) == 6) { + const uint32_t b2 = ((f2[1] >> 12) << 12) & 0x1FFFFFFFu; + const uint32_t b13 = ((f13[1] >> 12) << 12) & 0x1FFFFFFFu; + const uint32_t w2 = (f2[2] & 0x1FFF) + 1; + if (b2 && b2 == b13 && w2 >= 1000) { + g_auto_mask_base.store(b2, std::memory_order_relaxed); + } + } + } + const uint32_t mask_base = g_auto_mask_base.load(std::memory_order_relaxed); + if (!mask_base) { + return; + } + for (uint32_t f = 0; f < 32; ++f) { + uint32_t* fc = &fetch_constants[f * 6]; + if ((fc[0] & 0x3) != 2) { + continue; + } + const uint32_t base = ((fc[1] >> 12) << 12) & 0x1FFFFFFFu; + if (base != mask_base) { + continue; + } + const uint32_t w = (fc[2] & 0x1FFF) + 1; + if (w >= 1000) { + // size_2d: width-1 (bits 0..12), height-1 (bits 13..25) -> 640x360. + fc[2] = (fc[2] & 0xFC000000u) | ((640u - 1u) & 0x1FFF) | (((360u - 1u) & 0x1FFF) << 13); + FX_LOG_ONCE("[AC6 full-res effects] silhouette mask {:08X} cropped to its " + "filled 640x360 quarter", + mask_base); + } + } +} + +// every call) to 1280x720. +void FullresFixResolveRect(PPCContext& ctx, uint8_t* base) { + if (!FullresEffectsOn()) { + return; + } + if (!FxRenderModuleCaller(ctx.lr)) { + return; + } + // The downscaler's resolve must keep its vanilla extents too. + if (FxRt0IsR32Float(base, ctx.r3.u32)) { + return; + } + const uint32_t rect_ptr = ctx.r5.u32; + if (!rect_ptr) { + return; + } + const int32_t left = int32_t(PPC_LOAD_U32(rect_ptr + 0)); + const int32_t top = int32_t(PPC_LOAD_U32(rect_ptr + 4)); + const int32_t right = int32_t(PPC_LOAD_U32(rect_ptr + 8)); + const int32_t bottom = int32_t(PPC_LOAD_U32(rect_ptr + 12)); + if (right - left == 640 && bottom - top == 360) { + PPC_STORE_U32(rect_ptr + 8, uint32_t(left + 1280)); + PPC_STORE_U32(rect_ptr + 12, uint32_t(top + 720)); + FX_LOG_ONCE("[AC6 full-res effects] effects module resolve rect -> 1280x720"); + } +} + +} // namespace ac6::backend + +namespace { + +// The two guest functions that build the compositor's effects input textures +// with hardcoded 640x360 dims (they do NOT read the patched registry): +// sub_820AE858 = boot path, XGSetTextureHeader-style views over the id-0x8008 +// block; sub_820AEAB8 = its sibling creating two textures. Only calls from +// inside them are rewritten - other callers of the same D3D functions (e.g. +// FMV video planes) keep their dims. +constexpr uint32_t kFxTexBuilderStart = 0x820AE858; +constexpr uint32_t kFxTexBuilderEnd = 0x820AEBD8; // next function after AB8 +// The orchestrator's companion-texture creations for the registry 8009/800A +// buffers (createtex call sites 82346F38 / 823470F4): hardcoded 640x360 args, +// self-sized internal allocation. Must double together with the registry +// entries or copies between the pair overrun (the round-1 boot CTD). +constexpr uint32_t kFxCompanionTexStart = 0x82346000; +constexpr uint32_t kFxCompanionTexEnd = 0x82348000; + +bool FxTexPatchWanted(const PPCContext& ctx) { + if (!FullresEffectsOn() || + ctx.r3.u32 != 640 || ctx.r4.u32 != 360) { + return false; + } + const uint32_t lr = uint32_t(ctx.lr); + // Round 22: back to the round-6 gate for good. The whole 8009/800A + + // companion campaign chased a mirage: the "silhouette buffer" (8009) is + // the OUTPUT of a fixed 2:1 downscaler whose input is already full-res in + // vanilla - nothing on that side ever needed doubling. The 2x2 quads were + // the downscaler's baked x2 wrapping after OUR module-wide viewport lift + // reached it (fixed by the R32F-target discriminator, FxRt0IsR32Float). + return lr >= kFxTexBuilderStart && lr < kFxTexBuilderEnd; +} + +} // namespace + +// XGSetTextureHeader-style: (width, height, levels, usage, ..., fmt, hdr...). +PPC_EXTERN_FUNC(__imp__rex_sub_821FBE70); +PPC_FUNC_IMPL(rex_sub_821FBE70) { + PPC_FUNC_PROLOGUE(); + + if (FxTexPatchWanted(ctx)) { + ctx.r3.u64 = 1280; + ctx.r4.u64 = 720; + FX_LOG_ONCE("[AC6 full-res effects] compositor input texture headers -> 1280x720"); + } + + __imp__rex_sub_821FBE70(ctx, base); +} + +// CreateTexture-style: (width, height, fmt, levels, pool, ...). Returns the +// texture object in r3; the game does NOT null-check it (a failed allocation +// surfaces later as a null surface deref inside D3D Resolve - the ctd-2 +// crash at exe+0xDF3E49). If the 1280x720 creation fails, retry at the +// original 640x360 so the game keeps running (quilted effects instead of a +// CTD) and log the failure loudly. +PPC_EXTERN_FUNC(__imp__rex_sub_821E0EC8); +PPC_FUNC_IMPL(rex_sub_821E0EC8) { + PPC_FUNC_PROLOGUE(); + + const bool patch = FxTexPatchWanted(ctx); + const uint32_t lr32 = uint32_t(ctx.lr); + const uint64_t saved_r3 = ctx.r3.u64, saved_r4 = ctx.r4.u64, saved_r5 = ctx.r5.u64, + saved_r6 = ctx.r6.u64, saved_r7 = ctx.r7.u64, saved_r8 = ctx.r8.u64, + saved_r9 = ctx.r9.u64, saved_r10 = ctx.r10.u64; + if (patch) { + ctx.r3.u64 = 1280; + ctx.r4.u64 = 720; + // r6 = D3DMULTISAMPLE enum (2 = 4 samples). 1280x720@4x = 2880 tiles + // forces the tile manager's >2048 spill path, whose physical backing + // allocation is what actually fails (round-4 telemetry: pool granted + // 2880, creation still returned null). 1280x720@1x = 720 tiles = + // exactly the old 640x360@4x footprint - no spill, no extra memory. + if (ctx.r6.u32 == 2) { + ctx.r6.u64 = 0; + } + FX_LOG_ONCE("[AC6 full-res effects] compositor input textures -> 1280x720, 1xMSAA"); + } + + __imp__rex_sub_821E0EC8(ctx, base); + + if (patch && ctx.r3.u32 == 0) { + REXLOG_ERROR("[AC6 full-res effects] a 1280x720 compositor texture failed to " + "allocate - falling back to 640x360 for it (lr={:08X})", lr32); + ctx.r3.u64 = saved_r3; + ctx.r4.u64 = saved_r4; + ctx.r5.u64 = saved_r5; + ctx.r6.u64 = saved_r6; + ctx.r7.u64 = saved_r7; + ctx.r8.u64 = saved_r8; + ctx.r9.u64 = saved_r9; + ctx.r10.u64 = saved_r10; + __imp__rex_sub_821E0EC8(ctx, base); + REXLOG_ERROR("[AC6 full-res effects] 640x360 fallback returned {:08X}", ctx.r3.u32); + } +} diff --git a/src/ac6_backend_fixes/ac6_fullres_effects.h b/src/ac6_backend_fixes/ac6_fullres_effects.h new file mode 100644 index 00000000..7e2b53a1 --- /dev/null +++ b/src/ac6_backend_fixes/ac6_fullres_effects.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +struct PPCContext; + +namespace ac6::backend { + +// Render-target-0 latch. d3d_hooks records the last surface bound through +// D3DDevice_SetRenderTarget slot 0 (the device's own slot can be stale at +// SetViewport time because the game sets the viewport before binding); the +// full-res effects viewport lift reads it to identify the R32_FLOAT +// silhouette downscaler. (Formerly EffectsReconNoteRtBind/EffectsReconLastRt0 +// in the removed recon module.) +void NoteRt0Bound(uint32_t index, uint32_t surface); +uint32_t LastRt0Bound(); + +// Full-res effects (ac6_fullres_effects): rewrite the effects module's +// per-frame 640x360 resolve source rect to 1280x720 in guest memory. Called +// from the D3D Resolve hook (d3d_hooks.cpp) before the original runs; no-op +// unless the caller is the effects render module and the rect matches. +void FullresFixResolveRect(PPCContext& ctx, uint8_t* base); + +// Silhouette crop (the shipped mask fix): FxNoteResolveDest (from the guest +// Resolve hook) records the downscaler's resolve dest as the mask base; +// FxCropCompositorMask (at the compositor draw) crops that fetch to 640x360 so +// only the filled upper-left quarter is sampled. +void FxNoteResolveDest(uint32_t dest_obj, uint8_t* base); +void FxCropCompositorMask(uint64_t vs_hash, uint64_t ps_hash, uint32_t* fetch_constants); + +// The silhouette downscaler fix: force-restore the 2:1 downscaler draw +// (PS 8EE463763E880F35) to its native 640-wide target so it downscales +// correctly again. Hash-gated (only this one draw). Returns true if fixed. +bool FxFixDownscalerDraw(uint64_t ps_ucode_hash, uint32_t* surface_info, + uint32_t* vp_xscale, uint32_t* vp_yscale, + uint32_t* vp_xoffset, uint32_t* vp_yoffset); + +} // namespace ac6::backend diff --git a/src/d3d_hooks.cpp b/src/d3d_hooks.cpp index 3112e594..c256567a 100644 --- a/src/d3d_hooks.cpp +++ b/src/d3d_hooks.cpp @@ -4,6 +4,8 @@ #include #include +#include "ac6_backend_fixes/ac6_fullres_effects.h" + #include #include #include @@ -321,6 +323,7 @@ void CaptureResolve(uint32_t device, const PPCContext& ctx) { } ac6::d3d::ResolveRecord record; + record.guest_lr = static_cast(ctx.lr); record.args = { ctx.r4.u32, ctx.r5.u32, @@ -439,6 +442,7 @@ PPC_FUNC_IMPL(rex_sub_821D95C8) { std::unique_lock lock(g_shadow_mutex); g_shadow.render_targets[index] = surface; } + ac6::backend::NoteRt0Bound(index, surface); g_live_stats.set_render_target_calls.fetch_add(1, std::memory_order_relaxed); if (REXCVAR_GET(ac6_d3d_trace)) { @@ -540,6 +544,11 @@ PPC_FUNC_IMPL(rex_sub_821E2BB8) { RememberDevice(ctx.r3.u32); g_live_stats.resolve_calls.fetch_add(1, std::memory_order_relaxed); + // D3DDevice_Resolve(pDevice=r3, Flags=r4, pSourceRect=r5, pDestTexture=r6, + // ...): record the dest as the silhouette mask base if the downscaler just + // ran, then lift the effects module's 640x360 source rect to 1280x720. + ac6::backend::FxNoteResolveDest(ctx.r6.u32, base); + ac6::backend::FullresFixResolveRect(ctx, base); CaptureResolve(ctx.r3.u32, ctx); if (REXCVAR_GET(ac6_d3d_trace)) { diff --git a/src/d3d_state.h b/src/d3d_state.h index 00efeb53..be501f33 100644 --- a/src/d3d_state.h +++ b/src/d3d_state.h @@ -149,6 +149,7 @@ struct ClearRecord { struct ResolveRecord { uint32_t sequence{0}; + uint32_t guest_lr{0}; // Game-code return address of the D3D Resolve call. std::array args{}; float depth_or_scale{0.0f}; ShadowState shadow_state{}; diff --git a/thirdparty/rexglue-sdk/src/graphics/d3d12/command_processor.cpp b/thirdparty/rexglue-sdk/src/graphics/d3d12/command_processor.cpp index ad302a67..66a31a52 100644 --- a/thirdparty/rexglue-sdk/src/graphics/d3d12/command_processor.cpp +++ b/thirdparty/rexglue-sdk/src/graphics/d3d12/command_processor.cpp @@ -34,6 +34,7 @@ #include #include "../../../../../src/ac6_backend_fixes/ac6_backend_hooks.h" +#include "../../../../../src/ac6_backend_fixes/ac6_fullres_effects.h" #include "../../../../../src/ac6_backend_fixes/ac6_widescreen.h" #include "../../../../../src/ac6_native_graphics.h" #include "../../../../../src/render_hooks.h" @@ -2722,6 +2723,14 @@ bool D3D12CommandProcessor::IssueDraw(xenos::PrimitiveType primitive_type, uint3 if (pixel_shader && pixel_shader->ucode_data_hash() == UINT64_C(0x17e5e4ac3e713245)) { ac6::NotifyWorldCompositorDraw(); } + // AC6 full-res effects (the silhouette fix): the compositor mask is + // sampled by whichever cloud draw binds it, not necessarily the world + // compositor - so crop the mask fetch to the filled 640x360 quarter on every + // draw that reads it. No-op unless ac6_fullres_effects + ac6_fullres_mask_crop. + ac6::backend::FxCropCompositorMask( + vertex_shader ? vertex_shader->ucode_data_hash() : 0, + pixel_shader ? pixel_shader->ucode_data_hash() : 0, + ®ister_file_->values[XE_GPU_REG_SHADER_CONSTANT_FETCH_00_0]); // 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 @@ -2741,6 +2750,20 @@ bool D3D12CommandProcessor::IssueDraw(xenos::PrimitiveType primitive_type, uint3 ac6_sub_viewport)) { cbuffer_binding_float_vertex_.up_to_date = false; } + + // AC6 full-res effects (THE silhouette fix): the 2:1 silhouette downscaler + // (PS 8EE463763E880F35) samples 2*coord from its full-res input; doubling the + // fx buffers made its output 1280 wide, so it reads 0..2560 from a 1280-wide + // input -> the right half wraps -> 2x2 ghost planes. Restore JUST this draw's + // target to 640 wide (hash-gated - no scene-wide effect). + ac6::backend::FxFixDownscalerDraw( + pixel_shader ? pixel_shader->ucode_data_hash() : 0, + ®ister_file_->values[XE_GPU_REG_RB_SURFACE_INFO], + ®ister_file_->values[XE_GPU_REG_PA_CL_VPORT_XSCALE], + ®ister_file_->values[XE_GPU_REG_PA_CL_VPORT_YSCALE], + ®ister_file_->values[XE_GPU_REG_PA_CL_VPORT_XOFFSET], + ®ister_file_->values[XE_GPU_REG_PA_CL_VPORT_YOFFSET]); + // 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 diff --git a/thirdparty/rexglue-sdk/src/graphics/util/draw.cpp b/thirdparty/rexglue-sdk/src/graphics/util/draw.cpp index 1117ba00..c45005fb 100644 --- a/thirdparty/rexglue-sdk/src/graphics/util/draw.cpp +++ b/thirdparty/rexglue-sdk/src/graphics/util/draw.cpp @@ -11,6 +11,9 @@ #include #include +#include +#include +#include #include #include @@ -1014,8 +1017,24 @@ bool GetResolveInfo(const RegisterFile& regs, const memory::Memory& memory, int32_t surface_pitch_aligned = int32_t(rb_surface_info.surface_pitch & ~uint32_t(xenos::kResolveAlignmentPixels - 1)); if (x1 > surface_pitch_aligned) { - REXGPU_ERROR("Resolve region {} <= x < {} is outside the surface pitch {}", x0, x1, - surface_pitch_aligned); + // A guest can do this every frame - a resolve whose region is legitimately + // wider than the surface it runs against, where the clamp below is the + // intended behaviour rather than a fault. Reporting it per frame buries the + // rest of the log (~20 MB/hour at 60 fps, which rotates the boot half away + // inside minutes), so report each distinct region/pitch combination once. + // A genuinely new one still shows up immediately. + static std::mutex reported_mutex; + static std::set> reported; + bool first = false; + { + std::lock_guard lock(reported_mutex); + first = reported.size() < 32 && reported.emplace(x0, x1, surface_pitch_aligned).second; + } + if (first) { + REXGPU_ERROR("Resolve region {} <= x < {} is outside the surface pitch {} - clamping " + "(logged once per distinct region)", + x0, x1, surface_pitch_aligned); + } x0 = std::min(x0, surface_pitch_aligned); x1 = std::min(x1, surface_pitch_aligned); }