From ab90b54713e5889f33eee1cc8681dae89fe83d1e Mon Sep 17 00:00:00 2001 From: Dipshet <264011288+Dipshet@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:12:03 +0200 Subject: [PATCH] Render world effects (clouds/smoke/explosions) at scene resolution AC6 renders its cloud/smoke/explosion chain into half-res buffers, which look soft once the world is upscaled. Re-register those buffers at 1280x720 and 1x instead of 640x360 and 4x MSAA, the same EDRAM tiles, so nothing downstream moves, plus the pool reserve, compositor inputs and module viewport the enlarged chain needs, and keep the fixed 2:1 silhouette downscaler at its native size so its baked x2 cannot wrap. cvar: ac6_fullres_effects, off by default. --- CMakeLists.txt | 1 + README.md | 1 + src/ac6_backend_fixes/ac6_fullres_effects.cpp | 543 ++++++++++++++++++ src/ac6_backend_fixes/ac6_fullres_effects.h | 38 ++ src/d3d_hooks.cpp | 9 + src/d3d_state.h | 1 + .../src/graphics/d3d12/command_processor.cpp | 23 + .../rexglue-sdk/src/graphics/util/draw.cpp | 23 +- 8 files changed, 637 insertions(+), 2 deletions(-) create mode 100644 src/ac6_backend_fixes/ac6_fullres_effects.cpp create mode 100644 src/ac6_backend_fixes/ac6_fullres_effects.h 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); }