From 23e463737c27aa034894c8cd0ba7817bebc0c4c0 Mon Sep 17 00:00:00 2001 From: Dipshet <264011288+Dipshet@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:58:29 +0200 Subject: [PATCH 1/5] Fix cutscene depth-of-field striping and ghosting at render scale > 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cutscene DoF chain is authored for the 640×360 guest grid; at render scale > 1 its guest-granular blur kernels alias against host-granular content, visible as streaks and ghost outlines. Fix: snap the two CoC pre-blur passes to guest texel centers and densify the two 13-tap gather passes per axis. Hash-allowlisted, behind ac6_fix_dof (on), inert at 1x. --- src/main.cpp | 19 ++ .../rexglue-sdk/include/rex/graphics/flags.h | 3 + thirdparty/rexglue-sdk/src/graphics/flags.cpp | 29 +++ .../pipeline/shader/dxbc_translator_fetch.cpp | 168 +++++++++++++++++- 4 files changed, 217 insertions(+), 2 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 5fc78164..dcb6bff9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -19,6 +19,9 @@ REXCVAR_DECLARE(int32_t, draw_resolution_scale_y); REXCVAR_DECLARE(bool, param_gen_integer_guest_position); REXCVAR_DECLARE(bool, param_gen_host_subpixel_restore); REXCVAR_DECLARE(std::string, ac6_neutralize_deswizzle_hashes); +REXCVAR_DECLARE(std::string, ac6_snap_guest_texel_hashes); +REXCVAR_DECLARE(std::string, ac6_densify_x_fetch_hashes); +REXCVAR_DECLARE(std::string, ac6_densify_y_fetch_hashes); REXCVAR_DECLARE(std::string, log_file); REXCVAR_DECLARE(std::string, log_level); REXCVAR_DECLARE(bool, ac6_d3d_trace); @@ -70,6 +73,13 @@ REXCVAR_DEFINE_BOOL(ac6_fix_deswizzle, true, "AC6", "sub-tile de-swizzle, which is always a wrong texel permutation once the " "emulator detiles to linear. On by default; effective at all draw scales " "(the swizzle is present at 1x too)."); +REXCVAR_DEFINE_BOOL(ac6_fix_dof, true, "AC6", + "Fix the in-engine cutscene depth-of-field striping/ghosting at draw " + "resolution scale > 1: the 6-pass DoF chain is authored for the 640x360 " + "grid, so its guest-texel kernels alias against the extra detail of " + "scaled sources (combed CoC weights, ghost copies at the sparse gather " + "taps). Snaps the CoC pre-blurs to the guest grid and densifies the " + "gather taps along each pass's axis. On by default; inert at 1x."); #include "generated/ac6recomp_config.h" #include "generated/ac6recomp_init.h" @@ -187,6 +197,15 @@ void ApplyAc6FixDefaults() { AC6_SET_IF_UNSET(ac6_neutralize_deswizzle_hashes, "7d22894002d16018, 17e5e4ac3e713245:4"); } + // Cutscene DoF chain (CoC pre-blur pair snapped to the guest grid; the + // two 13-tap gathers densified along their pass axes). The translator + // only emits these at draw scale > 1, so no scale gate is needed here. + if (REXCVAR_GET(ac6_fix_dof)) { + AC6_SET_IF_UNSET(ac6_snap_guest_texel_hashes, + "85d733927e3bba9c, d050dfa6f58567f6"); + AC6_SET_IF_UNSET(ac6_densify_x_fetch_hashes, "6328f9c40913c82c"); + AC6_SET_IF_UNSET(ac6_densify_y_fetch_hashes, "5bd20f9d0d911687"); + } } #undef AC6_SET_IF_UNSET diff --git a/thirdparty/rexglue-sdk/include/rex/graphics/flags.h b/thirdparty/rexglue-sdk/include/rex/graphics/flags.h index 58d9bb87..1fc336b8 100644 --- a/thirdparty/rexglue-sdk/include/rex/graphics/flags.h +++ b/thirdparty/rexglue-sdk/include/rex/graphics/flags.h @@ -32,6 +32,9 @@ REXCVAR_DECLARE(bool, draw_resolution_scaled_texture_offsets); REXCVAR_DECLARE(bool, param_gen_integer_guest_position); REXCVAR_DECLARE(bool, param_gen_host_subpixel_restore); REXCVAR_DECLARE(std::string, ac6_neutralize_deswizzle_hashes); +REXCVAR_DECLARE(std::string, ac6_snap_guest_texel_hashes); +REXCVAR_DECLARE(std::string, ac6_densify_x_fetch_hashes); +REXCVAR_DECLARE(std::string, ac6_densify_y_fetch_hashes); REXCVAR_DECLARE(std::string, readback_resolve); REXCVAR_DECLARE(bool, readback_resolve_half_pixel_offset); REXCVAR_DECLARE(bool, readback_memexport); diff --git a/thirdparty/rexglue-sdk/src/graphics/flags.cpp b/thirdparty/rexglue-sdk/src/graphics/flags.cpp index da18cc0e..7d259ecd 100644 --- a/thirdparty/rexglue-sdk/src/graphics/flags.cpp +++ b/thirdparty/rexglue-sdk/src/graphics/flags.cpp @@ -65,6 +65,35 @@ REXCVAR_DEFINE_STRING(ac6_neutralize_deswizzle_hashes, "", "GPU/Shader", "Xenos tfetch slot 4 (needed when only some fetches de-swizzle, e.g. " "the AC6 cloud compositor: scene fetch de-swizzles, mask/cloud " "fetches use plain UVs and must be left alone). Runtime, no rebuild."); +REXCVAR_DEFINE_STRING(ac6_snap_guest_texel_hashes, "", "GPU/Shader", + "AC6: same \"[:[+...]]\" token list as " + "ac6_neutralize_deswizzle_hashes, naming guest pixel-shader ucode " + "hashes whose texture taps encode fractional guest-texel bilinear " + "positions (e.g. the cutscene depth-of-field gather pair). Sampling " + "a resolution-scaled texture with such a kernel reinterprets every " + "blend ratio against the finer host grid and alternates the kernel " + "phase per output pixel, aliasing into striping at >1x. For matching " + "fetches the normalized coordinate is snapped to the guest texel " + "center (where host bilinear of a scaled texture returns exactly the " + "guest texel's box average), restoring the guest sampling grid at any " + "draw resolution scale. Applied only at >1x and only to " + "resolution-scaled textures. Runtime, no rebuild."); +REXCVAR_DEFINE_STRING(ac6_densify_x_fetch_hashes, "", "GPU/Shader", + "AC6: \"[:[+...]]\" list for HORIZONTAL-axis " + "separable filter passes (e.g. the cutscene DoF H gather " + "6328f9c40913c82c). Each allowlisted fetch becomes the average of " + "2*draw_resolution_scale_x samples spread along X across one " + "guest-texel half-gap on each side of the original tap - the union " + "of all taps' cells covers the kernel span continuously, so ghost " + "copies of arbitrarily thin features merge at any resolution scale. " + "Convolving the authored kernel with the 2-texel cell box widens the " + "blur by ~2% (visually shape-faithful). Per-axis scale aware; no-op " + "when draw_resolution_scale_x is 1. Read at shader translation."); +REXCVAR_DEFINE_STRING(ac6_densify_y_fetch_hashes, "", "GPU/Shader", + "AC6: as ac6_densify_x_fetch_hashes, for VERTICAL-axis separable " + "filter passes (e.g. the cutscene DoF V gather 5bd20f9d0d911687): " + "2*draw_resolution_scale_y samples along Y per fetch. No-op when " + "draw_resolution_scale_y is 1."); REXCVAR_DEFINE_BOOL(ac6_flare_drop_quad2, true, "GPU/Shader", "AC6: cull the sun lens-flare's spurious second billboard " "(vertices 4-7) to remove the faint rectangle in the sky"); diff --git a/thirdparty/rexglue-sdk/src/graphics/pipeline/shader/dxbc_translator_fetch.cpp b/thirdparty/rexglue-sdk/src/graphics/pipeline/shader/dxbc_translator_fetch.cpp index 277c1305..c3b05e26 100644 --- a/thirdparty/rexglue-sdk/src/graphics/pipeline/shader/dxbc_translator_fetch.cpp +++ b/thirdparty/rexglue-sdk/src/graphics/pipeline/shader/dxbc_translator_fetch.cpp @@ -862,6 +862,63 @@ void DxbcShaderTranslator::ProcessTextureFetchInstruction( UcodeHashSlotInList(current_shader().ucode_data_hash(), tfetch_index, neutralize_hashes); } + // Guest-texel snap for scale-broken filter kernels: some game passes (AC6's + // cutscene depth-of-field gather pair) build their kernel out of fractional + // guest-texel tap offsets whose bilinear blend ratios are tuned for the + // guest-resolution texel grid. Against a resolution-scaled texture the same + // UVs land on the finer host grid: every ratio is reinterpreted and the + // kernel phase alternates per output pixel, aliasing into striping. For an + // allowlisted shader (same syntax as the de-swizzle neutralize) snap the + // final coordinate to the guest texel center, where host bilinear of a + // scaled texture returns exactly the guest texel's box average - the guest + // sampling grid at any scale. Unlike the passes above these shaders sample + // via interpolators, so no param_gen requirement. + bool apply_guest_texel_snap = false; + if (instr.opcode == FetchOpcode::kTextureFetch && + !instr.attributes.unnormalized_coordinates && + instr.dimension == xenos::FetchOpDimension::k2D && is_pixel_shader() && + (draw_resolution_scale_x_ > 1 || draw_resolution_scale_y_ > 1)) { + const std::string& snap_hashes = REXCVAR_GET(ac6_snap_guest_texel_hashes); + apply_guest_texel_snap = + !snap_hashes.empty() && + UcodeHashSlotInList(current_shader().ucode_data_hash(), tfetch_index, + snap_hashes); + } + // Tap-footprint box filter for sparse-kernel shaders (AC6 DoF gathers): + // taps several guest texels apart alias against the extra detail of a + // resolution-scaled source. Each allowlisted fetch becomes the average of a + // 4-sample cluster at (+/-0.25, +/-0.25) guest texels around the original + // position - a guest-Nyquist low-pass at the exact tap location, preserving + // the kernel's fractional placement (no output quantization, unlike the + // texel-center snap above). + // Axis densification for separable sparse-kernel passes (AC6 DoF gathers): + // each allowlisted fetch becomes the average of 2*scale_axis samples spread + // along the pass axis across one guest-texel half-gap on each side of the + // tap. The union of adjacent taps' cells covers the kernel span + // continuously, so ghost copies of features thinner than the tap spacing + // merge at any resolution scale; the effective kernel is the authored one + // convolved with a 2-guest-texel box (~2% wider - shape-faithful). Per-axis + // resolution scale aware (Xenia allows asymmetric X/Y scales); no-op when + // the relevant axis scale is 1. + uint32_t densify_axis_samples[2] = {0, 0}; + if (instr.opcode == FetchOpcode::kTextureFetch && + !instr.attributes.unnormalized_coordinates && + instr.dimension == xenos::FetchOpDimension::k2D && is_pixel_shader()) { + const std::string& densify_x_hashes = REXCVAR_GET(ac6_densify_x_fetch_hashes); + if (draw_resolution_scale_x_ > 1 && !densify_x_hashes.empty() && + UcodeHashSlotInList(current_shader().ucode_data_hash(), tfetch_index, + densify_x_hashes)) { + densify_axis_samples[0] = 2 * uint32_t(draw_resolution_scale_x_); + } + const std::string& densify_y_hashes = REXCVAR_GET(ac6_densify_y_fetch_hashes); + if (draw_resolution_scale_y_ > 1 && !densify_y_hashes.empty() && + UcodeHashSlotInList(current_shader().ucode_data_hash(), tfetch_index, + densify_y_hashes)) { + densify_axis_samples[1] = 2 * uint32_t(draw_resolution_scale_y_); + } + } + const bool apply_cluster_filter = + densify_axis_samples[0] != 0 || densify_axis_samples[1] != 0; uint32_t size_needed_components = 0b0000; if (instr.opcode == FetchOpcode::kGetTextureWeights) { // Size needed for denormalization for coordinate lerp factor. @@ -926,10 +983,12 @@ void DxbcShaderTranslator::ProcessTextureFetchInstruction( // the texture is 3D unconditionally. size_needed_components |= 0b1000; } - if (apply_host_subpixel_correction || apply_deswizzle_identity) { + if (apply_host_subpixel_correction || apply_deswizzle_identity || + apply_guest_texel_snap || apply_cluster_filter) { // Need the guest texture width/height (XY) to convert the host sub-pixel // offset (Tier B) or the SV_Position into normalized texture space - // (de-swizzle identity). + // (de-swizzle identity), or to locate the guest texel grid (texel snap / + // cluster filters). size_needed_components |= 0b0011; } uint32_t size_and_is_3d_temp = size_needed_components ? PushSystemTemp() : UINT32_MAX; @@ -1303,6 +1362,32 @@ void DxbcShaderTranslator::ProcessTextureFetchInstruction( // Release deswizzle_temp. PopSystemTemp(); } + if (apply_guest_texel_snap) { + // coord_and_sampler_temp.xy is the final normalized coordinate. Snap it + // to the guest texel center so the allowlisted kernel samples the guest + // grid regardless of the host storage resolution (see the gate above). + uint32_t snap_temp = PushSystemTemp(); + // snap_temp.xy = (floor(uv * guest_size) + 0.5) / guest_size. + a_.OpMul(dxbc::Dest::R(snap_temp, 0b0011), + dxbc::Src::R(coord_and_sampler_temp), dxbc::Src::R(size_and_is_3d_temp)); + a_.OpRoundNI(dxbc::Dest::R(snap_temp, 0b0011), dxbc::Src::R(snap_temp)); + a_.OpAdd(dxbc::Dest::R(snap_temp, 0b0011), dxbc::Src::R(snap_temp), + dxbc::Src::LF(0.5f, 0.5f, 0.0f, 0.0f)); + a_.OpDiv(dxbc::Dest::R(snap_temp, 0b0011), dxbc::Src::R(snap_temp), + dxbc::Src::R(size_and_is_3d_temp)); + // Only textures stored at host resolution present a finer grid than the + // kernel was tuned for; guest-resolution sources stay stock. + a_.OpAnd(dxbc::Dest::R(snap_temp, 0b0100), + LoadSystemConstant(SystemConstants::Index::kTexturesResolutionScaled, + offsetof(SystemConstants, textures_resolution_scaled), + dxbc::Src::kXXXX), + dxbc::Src::LU(uint32_t(1) << tfetch_index)); + a_.OpIf(true, dxbc::Src::R(snap_temp, dxbc::Src::kZZZZ)); + a_.OpMov(dxbc::Dest::R(coord_and_sampler_temp, 0b0011), dxbc::Src::R(snap_temp)); + a_.OpEndIf(); + // Release snap_temp. + PopSystemTemp(); + } switch (instr.dimension) { case xenos::FetchOpDimension::k1D: // Pad to 2D array coordinates. @@ -1957,6 +2042,65 @@ void DxbcShaderTranslator::ProcessTextureFetchInstruction( texture_binding_signed.bindful_srv_index, uint32_t(SRVMainRegister::kBindfulTexturesStart) + texture_binding_index_signed); } + // Tap densification (see the gate above): re-emit the whole sample + // block once per cluster offset around the original coordinate and + // average the RAW results (before format decode - consistent with how + // Xenos bilinear itself filters in storage space). Restricted to + // plain 2D fetches. Cluster geometry: 2*scale_axis samples per listed + // axis, spanning one guest texel each side of the tap (a grid if both + // axes are listed). + const bool cluster_this_srv = apply_cluster_filter && + srv_dimension == xenos::FetchOpDimension::k2D && + !layer_lerp_needed; + float cluster_dx[64]; + float cluster_dy[64]; + uint32_t cluster_count = 1; + cluster_dx[0] = 0.0f; + cluster_dy[0] = 0.0f; + if (cluster_this_srv) { + float xs[8], ys[8]; + uint32_t nx = 1, ny = 1; + xs[0] = 0.0f; + ys[0] = 0.0f; + if (densify_axis_samples[0]) { + nx = std::min(densify_axis_samples[0], 8u); + for (uint32_t j = 0; j < nx; ++j) { + xs[j] = (float(j) + 0.5f) * 2.0f / float(nx) - 1.0f; + } + } + if (densify_axis_samples[1]) { + ny = std::min(densify_axis_samples[1], 8u); + for (uint32_t j = 0; j < ny; ++j) { + ys[j] = (float(j) + 0.5f) * 2.0f / float(ny) - 1.0f; + } + } + cluster_count = 0; + for (uint32_t jy = 0; jy < ny; ++jy) { + for (uint32_t jx = 0; jx < nx; ++jx) { + cluster_dx[cluster_count] = xs[jx]; + cluster_dy[cluster_count] = ys[jy]; + ++cluster_count; + } + } + } + uint32_t cluster_accum_temp = UINT32_MAX; + uint32_t cluster_coord_temp = UINT32_MAX; + const uint32_t cluster_passes = cluster_this_srv ? cluster_count : 1; + if (cluster_this_srv) { + cluster_accum_temp = PushSystemTemp(); + cluster_coord_temp = PushSystemTemp(); + // The original tap coordinate (and the sampler index in W). + a_.OpMov(dxbc::Dest::R(cluster_coord_temp), dxbc::Src::R(coord_and_sampler_temp)); + } + for (uint32_t cluster_pass = 0; cluster_pass < cluster_passes; ++cluster_pass) { + if (cluster_this_srv) { + // coord.xy = original + cluster offset (guest texels) / guest_size. + a_.OpDiv(dxbc::Dest::R(coord_and_sampler_temp, 0b0011), + dxbc::Src::LF(cluster_dx[cluster_pass], cluster_dy[cluster_pass], 0.0f, 0.0f), + dxbc::Src::R(size_and_is_3d_temp)); + a_.OpAdd(dxbc::Dest::R(coord_and_sampler_temp, 0b0011), + dxbc::Src::R(coord_and_sampler_temp), dxbc::Src::R(cluster_coord_temp)); + } for (uint32_t layer = 0; layer < (layer_lerp_needed ? 2u : 1u); ++layer) { uint32_t layer_value_temp = system_temp_result_; if (layer) { @@ -2050,6 +2194,26 @@ void DxbcShaderTranslator::ProcessTextureFetchInstruction( PopSystemTemp(); } } + if (cluster_this_srv) { + if (cluster_pass == 0) { + a_.OpMov(dxbc::Dest::R(cluster_accum_temp, used_result_nonzero_components), + dxbc::Src::R(system_temp_result_)); + } else { + a_.OpAdd(dxbc::Dest::R(cluster_accum_temp, used_result_nonzero_components), + dxbc::Src::R(cluster_accum_temp), dxbc::Src::R(system_temp_result_)); + } + } + } // cluster_pass + if (cluster_this_srv) { + // The tap's value = the average of the cluster samples. + a_.OpMul(dxbc::Dest::R(system_temp_result_, used_result_nonzero_components), + dxbc::Src::R(cluster_accum_temp), + dxbc::Src::LF(1.0f / float(cluster_passes))); + // Restore the unoffset coordinate for any downstream use. + a_.OpMov(dxbc::Dest::R(coord_and_sampler_temp), dxbc::Src::R(cluster_coord_temp)); + // Release cluster_coord_temp and cluster_accum_temp. + PopSystemTemp(2); + } } if (instr.dimension == xenos::FetchOpDimension::k3DOrStacked) { // Close the stacked/3D check. From b6b94bac9180d8f48ad5e225a93dbd307e74905e Mon Sep 17 00:00:00 2001 From: Dipshet <264011288+Dipshet@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:22:41 +0200 Subject: [PATCH 2/5] Keep in-engine cutscene video locked to the audio clock after hitches A render hitch during an in-engine cutscene permanently desynced video from the audio, which is master and keeps playing. Weak-symbol wrappers around the two demo-manager Exec functions issue bounded catch-up ticks computed from the audio clients' consumed-sample clock (48 kHz, deficit threshold 2). Behind ac6_cutscene_resync (on); dev tooling default-off. --- CMakeLists.txt | 1 + src/ac6_backend_fixes/ac6_cutscene_resync.cpp | 187 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 src/ac6_backend_fixes/ac6_cutscene_resync.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e434ce6b..399fa47b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,7 @@ set(AC6RECOMP_SOURCES src/ac6_backend_fixes/ac6_backend_capture_bridge.cpp src/ac6_backend_fixes/ac6_backend_hooks.cpp src/ac6_backend_fixes/ac6_backend_pass_classifier.cpp + src/ac6_backend_fixes/ac6_cutscene_resync.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 diff --git a/src/ac6_backend_fixes/ac6_cutscene_resync.cpp b/src/ac6_backend_fixes/ac6_cutscene_resync.cpp new file mode 100644 index 00000000..f05607a9 --- /dev/null +++ b/src/ac6_backend_fixes/ac6_cutscene_resync.cpp @@ -0,0 +1,187 @@ +// Cutscene A/V resync (audio-master catch-up). +// +// The mechanism (confirmed in code and on real hardware): guest audio time = +// XAudioGetRenderDriverTic = host samples consumed INCLUDING injected +// underrun silence, so it tracks wall clock through any render hitch; the +// audio worker is its own thread, so a render stall does not even pause real +// audio. The in-engine cutscene sequencers (CAce6DemoManager::Exec "DD" +// 0x82184460 / CX360DemoManagerEM::Exec "EM" 0x821856F8) tick their timeline +// once per rendered frame. One long frame puts the video permanently behind +// the audio; sustained sub-30fps render turns the whole cutscene into slow +// motion against its soundtrack. +// +// This file wraps both Exec functions (weak symbols in the generated code, +// same override pattern as ac6_fps_physics_fix.cpp). Audio is the master +// clock and is NEVER touched - the cutscene catches up instead. Per rendered +// frame, deficit = ticks the audio clock says should have run minus ticks +// issued; while the deficit is >= 2 ticks, extra Exec calls run (capped per +// frame by ac6_cutscene_resync_max_ticks) - a bounded timeline frame-skip, +// the same audio-master pattern film players use. The >= 2 engage threshold +// keeps normal 30fps playback provably untouched (steady-state phase jitter +// is +/-1 tick and can never trigger it). The cap is also the slowest +// sustained render rate that stays synced: N extras per frame holds sync +// down to 30/(1+N) fps. 0 = uncapped (one hard jump-cut after a stall). +// +// Both Exec wrappers run on the guest game thread (the sole caller), so +// plain statics are safe throughout. + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +REXCVAR_DEFINE_BOOL(ac6_cutscene_resync, true, "AC6", + "Keep in-engine cutscene video locked to its audio. Audio " + "is the master clock and is never altered; after a render " + "hitch (or under sustained slow rendering) the cutscene " + "timeline catches up by running extra sequencer ticks - a " + "bounded frame-skip, like every film player's audio-master " + "sync. No effect on normal full-speed playback (catch-up " + "engages only past 2 ticks of drift; verified extras=0 in " + "steady state, injected-hitch recovery in 3 frames, and " + "sustained 14fps @ 5x draw scale staying locked)."); +REXCVAR_DEFINE_INT32(ac6_cutscene_resync_max_ticks, 3, "AC6", + "Max extra cutscene sequencer ticks per rendered frame " + "while catching up (ac6_cutscene_resync). Also sets the " + "slowest sustained render rate that stays in sync: N " + "extras holds sync down to 30/(1+N) fps (3 -> 7.5 fps). " + "Low = gentle brief fast-forward after a stall; 0 = " + "uncapped, one hard jump-cut."); + +PPC_EXTERN_FUNC(__imp__rex_sub_82184460); // CAce6DemoManager::Exec ("DD") +PPC_EXTERN_FUNC(__imp__rex_sub_821856F8); // CX360DemoManagerEM::Exec ("EM") + +namespace { + +using Clock = std::chrono::steady_clock; + +// Demo sequencers advance one timeline frame per Exec at the game's native +// 30fps cadence (cutscenes stay clamped to 30 under the FPS unlock). +constexpr uint64_t kSamplesPerDemoTick = rex::audio::kAudioFrameSampleRate / 30; // 1600 + +// An Exec gap this long means the demo session ended (menus/gameplay between +// cutscenes). Generous enough that a real mid-cutscene stall keeps its +// session - that stall is exactly what the resync must recover from. +constexpr int64_t kSessionResetMs = 1500; + +int64_t NowMs() { + return std::chrono::duration_cast( + Clock::now().time_since_epoch()) + .count(); +} + +// Reads the single audio client's consumed-samples clock - the exact value +// the guest sees through XAudioGetRenderDriverTic (48kHz sample units, +// advancing in real time through hitches because underrun silence counts as +// consumed). Returns false if the audio system/client is unavailable. +bool ReadAudioClockSamples(PPCContext& ctx, uint64_t* out_samples) { + if (!ctx.kernel_state) { + return false; + } + auto* native_audio = ctx.kernel_state->native_audio_system(); + if (!native_audio) { + return false; + } + const rex::audio::AudioClientTimingSnapshot timing = + native_audio->GetClientTimingSnapshot(0); + if (timing.consumed_samples == 0) { + // No audio consumed yet (startup) - no usable master clock this frame. + return false; + } + *out_samples = timing.consumed_samples; + return true; +} + +struct DemoSession { + const char* site = ""; + int64_t last_exec_ms = INT64_MIN; + bool clock_valid = false; + uint64_t baseline_samples = 0; + uint64_t ticks_issued = 0; +}; + +DemoSession g_session; + +void ExecWithResync(PPCContext& ctx, uint8_t* base, + void (*original)(PPCContext&, uint8_t*), const char* site) { + const int64_t now_ms = NowMs(); + + const bool new_session = g_session.site != site || + g_session.last_exec_ms == INT64_MIN || + (now_ms - g_session.last_exec_ms) > kSessionResetMs; + if (new_session) { + g_session = DemoSession{}; + g_session.site = site; + } + g_session.last_exec_ms = now_ms; + + uint64_t audio_samples = 0; + const bool clock_ok = ReadAudioClockSamples(ctx, &audio_samples); + if (clock_ok && !g_session.clock_valid) { + // First usable clock reading of this session: the tick issued this very + // frame corresponds to "now" on the audio timeline. + g_session.clock_valid = true; + g_session.baseline_samples = audio_samples; + g_session.ticks_issued = 0; + } + + const bool resync = REXCVAR_GET(ac6_cutscene_resync) && + g_session.clock_valid && clock_ok; + // The original's input registers, for re-issuing the call. The extra ticks + // must not see the first call's clobbered volatile registers. + PPCContext saved_ctx; + if (resync) { + saved_ctx = ctx; + } + + // The frame's own tick. + original(ctx, base); + ++g_session.ticks_issued; + + int64_t deficit_ticks = 0; + if (g_session.clock_valid && clock_ok) { + const uint64_t expected = + (audio_samples - g_session.baseline_samples) / kSamplesPerDemoTick; + deficit_ticks = static_cast(expected) - + static_cast(g_session.ticks_issued); + } + + // Catch-up: engage past 2 ticks of drift (steady-state jitter is +/-1 and + // must never trigger), then tick the timeline down to zero deficit, capped + // per frame. The deficit is recomputed from absolute clocks every frame, + // so any remainder past the cap carries automatically. + if (resync && deficit_ticks >= 2) { + const int32_t cap = REXCVAR_GET(ac6_cutscene_resync_max_ticks); + int64_t extras = deficit_ticks; + if (cap > 0) { + extras = std::min(extras, cap); + } + for (int64_t i = 0; i < extras; ++i) { + ctx = saved_ctx; + original(ctx, base); + ++g_session.ticks_issued; + } + REXLOG_DEBUG("[AC6-CUTSYNC] catch-up: site={} ran {} extra ticks (deficit was {}, cap {})", + site, extras, deficit_ticks, cap); + } +} + +} // namespace + +// CAce6DemoManager::Exec ("DD") - in-engine cutscene sequencer tick. +PPC_FUNC_IMPL(rex_sub_82184460) { + PPC_FUNC_PROLOGUE(); + ExecWithResync(ctx, base, __imp__rex_sub_82184460, "DD"); +} + +// CX360DemoManagerEM::Exec ("EM") - in-engine cutscene sequencer tick. +PPC_FUNC_IMPL(rex_sub_821856F8) { + PPC_FUNC_PROLOGUE(); + ExecWithResync(ctx, base, __imp__rex_sub_821856F8, "EM"); +} From 8c922abd7747387bf3a864b9781cc164acd08934 Mon Sep 17 00:00:00 2001 From: Dipshet <264011288+Dipshet@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:23:20 +0200 Subject: [PATCH 3/5] Preserve the XMA stream timeline across undecodable frames A frame the decoder backend rejects advances the read offset but emitted nothing, silently shortening that stream by 512 samples, permanent desync for multi-stream sources (5.1 premixes as parallel stereo XMA). Emit the frame as silence instead so the timeline holds. Robustness: no in-game trigger known in AC6. audio_xma_preserve_timeline (default on). --- .../src/native/audio/audio_system.cpp | 10 +++++++ .../src/native/audio/xma/context.cpp | 28 +++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/thirdparty/rexglue-sdk/src/native/audio/audio_system.cpp b/thirdparty/rexglue-sdk/src/native/audio/audio_system.cpp index bb1b29e6..10c5f301 100644 --- a/thirdparty/rexglue-sdk/src/native/audio/audio_system.cpp +++ b/thirdparty/rexglue-sdk/src/native/audio/audio_system.cpp @@ -19,6 +19,16 @@ REXCVAR_DEFINE_BOOL(audio_trace_render_driver_verbose, false, "Audio", "Trace render-driver activity"); REXCVAR_DEFINE_BOOL(audio_deep_trace, false, "Audio", "Enable verbose runtime audio tracing"); +REXCVAR_DEFINE_BOOL(audio_xma_preserve_timeline, true, "Audio", + "When the XMA decoder backend rejects a damaged frame, emit that " + "frame's worth of silence instead of dropping it. The input read " + "offset advances past the frame either way, so dropping the output " + "silently shortens the stream's timeline by 512 samples per damaged " + "frame - which would permanently desynchronize multi-stream sources " + "(5.1 premixes carried as parallel stereo streams, e.g. AC6's " + "cutscene mixes). Robustness fix - no in-game trigger is known in " + "AC6 (instrumented runs decode every frame). On by default; off " + "restores the legacy drop behavior."); namespace rex::audio { diff --git a/thirdparty/rexglue-sdk/src/native/audio/xma/context.cpp b/thirdparty/rexglue-sdk/src/native/audio/xma/context.cpp index 73994824..6550bc44 100644 --- a/thirdparty/rexglue-sdk/src/native/audio/xma/context.cpp +++ b/thirdparty/rexglue-sdk/src/native/audio/xma/context.cpp @@ -17,6 +17,7 @@ #include REXCVAR_DECLARE(bool, audio_deep_trace); +REXCVAR_DECLARE(bool, audio_xma_preserve_timeline); namespace rex::audio { @@ -853,11 +854,32 @@ void XmaContext::Decode(XMA_CONTEXT_DATA* data) { .sample_rate = static_cast(GetSampleRate(data->sample_rate)), .is_two_channel = bool(data->is_stereo), }; - if (decoder_backend_ && + const bool frame_decoded = + decoder_backend_ && decoder_backend_->DecodePacket( - decode_request, std::span(raw_frame_.data(), raw_frame_.size()))) { + decode_request, std::span(raw_frame_.data(), raw_frame_.size())); + if (!frame_decoded && REXCVAR_GET(audio_xma_preserve_timeline)) { + // A frame the decoder rejects still occupies exactly kSamplesPerFrame + // samples of the stream's timeline, and the read offset advances past it + // below either way. Dropping the output entirely (the legacy behavior) + // silently shortens the stream by one frame - a latent correctness bug + // for multi-stream sources that must stay sample-locked, e.g. 5.1 + // premixes carried as parallel stereo streams (AC6's cutscene mixes). + // Instrumented AC6 runs decode every cutscene frame successfully, so no + // in-game trigger is known - this is robustness against decode errors, + // not a fix for an audible symptom. Deliver the frame as silence + // instead - raw_frame_ is zero-filled above, so falling through emits + // one frame of silence in this stream (~10 ms, masked by the other + // streams) and the timeline holds. + REXAPU_DEBUG( + "XmaContext {}: undecodable frame (packet={} offset={} frame_bits={}) - " + "emitting silence to preserve the stream timeline", + id(), last_packet_index_, last_input_read_offset_before_, + packet_info.current_frame_size_); + } + if (frame_decoded || REXCVAR_GET(audio_xma_preserve_timeline)) { current_frame_remaining_subframes_ = 4 << data->is_stereo; - last_decode_succeeded_ = true; + last_decode_succeeded_ = frame_decoded; if (is_loop_end_frame) { loop_frame_output_limit_ = (data->loop_subframe_end + 1) << data->is_stereo; From 466cac7c0044516f3daaafb7d86870490cd3c2d4 Mon Sep 17 00:00:00 2001 From: Dipshet <264011288+Dipshet@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:24:07 +0200 Subject: [PATCH 4/5] Guard XMA loop handling against degenerate loop metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streamed voices carrying degenerate loop metadata (loop_count 0xFF with loop_start == loop_end == 0) re-fired the loop machinery on every input buffer swap, truncating or skipping subframes of the stream. Require a real loop window (loop_start < loop_end, read_offset ≥ loop_end) before engaging, as in Xenia PR #1808. 47 confirmed fires in AC6; default on. --- .../src/native/audio/audio_system.cpp | 10 +++++++ .../src/native/audio/xma/context.cpp | 29 +++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/thirdparty/rexglue-sdk/src/native/audio/audio_system.cpp b/thirdparty/rexglue-sdk/src/native/audio/audio_system.cpp index 10c5f301..5b0127ae 100644 --- a/thirdparty/rexglue-sdk/src/native/audio/audio_system.cpp +++ b/thirdparty/rexglue-sdk/src/native/audio/audio_system.cpp @@ -19,6 +19,16 @@ REXCVAR_DEFINE_BOOL(audio_trace_render_driver_verbose, false, "Audio", "Trace render-driver activity"); REXCVAR_DEFINE_BOOL(audio_deep_trace, false, "Audio", "Enable verbose runtime audio tracing"); +REXCVAR_DEFINE_BOOL(audio_xma_loop_guard, true, "Audio", + "Require a real loop window (loop_start < loop_end, " + "read_offset >= loop_end) before engaging XMA loop " + "handling, as in current Xenia master (PR #1808, debugged " + "on Ace Combat 6). Off: legacy behavior, where streamed " + "voices carrying degenerate loop metadata (loop_count=" + "0xff, loop_start == loop_end == 0) re-fire the loop " + "machinery on every input buffer swap, truncating/" + "skipping subframes of the stream (confirmed firing on " + "AC6 cutscene streams via the deep-trace counter)."); REXCVAR_DEFINE_BOOL(audio_xma_preserve_timeline, true, "Audio", "When the XMA decoder backend rejects a damaged frame, emit that " "frame's worth of silence instead of dropping it. The input read " diff --git a/thirdparty/rexglue-sdk/src/native/audio/xma/context.cpp b/thirdparty/rexglue-sdk/src/native/audio/xma/context.cpp index 6550bc44..cb39582a 100644 --- a/thirdparty/rexglue-sdk/src/native/audio/xma/context.cpp +++ b/thirdparty/rexglue-sdk/src/native/audio/xma/context.cpp @@ -1,4 +1,4 @@ -/** +/** * ReXGlue native audio runtime * Part of the AC6 Recompilation project */ @@ -17,6 +17,7 @@ #include REXCVAR_DECLARE(bool, audio_deep_trace); +REXCVAR_DECLARE(bool, audio_xma_loop_guard); REXCVAR_DECLARE(bool, audio_xma_preserve_timeline); namespace rex::audio { @@ -342,7 +343,31 @@ void XmaContext::UpdateLoopStatus(XMA_CONTEXT_DATA* data) { const uint32_t loop_start = std::max(kBitsPerPacketHeader, data->loop_start); const uint32_t loop_end = std::max(kBitsPerPacketHeader, data->loop_end); - if (data->input_buffer_read_offset != loop_end) { + if (REXCVAR_GET(audio_xma_loop_guard)) { + // Xenia master's TrySetupNextLoop guard (PR #1808, debugged on Ace + // Combat 6): streamed voices can carry degenerate loop metadata + // (loop_count=0xff, loop_start == loop_end == 0). The clamps above turn + // that into start == end == kBitsPerPacketHeader, and SwapInputBuffer + // resets the read offset to exactly kBitsPerPacketHeader, so without a + // real-window check every buffer swap re-fires the loop machinery - + // the frame output limit truncates that frame and the loop-start skip + // drops subframes. Require loop_start < loop_end (raw values) and use + // master's read_offset >= loop_end trigger. + if (data->loop_start >= data->loop_end || + data->input_buffer_read_offset < loop_end) { + if (data->loop_start >= data->loop_end && + data->input_buffer_read_offset == loop_end && IsDeepTraceEnabled()) { + REXAPU_DEBUG( + "XmaContext {}: loop guard suppressed degenerate loop fire " + "(loop_start={} loop_end={} loop_count={} read_offset={})", + id(), static_cast(data->loop_start), + static_cast(data->loop_end), + static_cast(data->loop_count), + static_cast(data->input_buffer_read_offset)); + } + return; + } + } else if (data->input_buffer_read_offset != loop_end) { return; } From edcb631442ba2d4bec56506117b21ba037671fc4 Mon Sep 17 00:00:00 2001 From: Dipshet <264011288+Dipshet@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:25:30 +0200 Subject: [PATCH 5/5] Fix doubled cutscene dialogue: fold only the fronts while a cutscene plays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC6's cutscene mixer submits its 5.1 premix to all six speaker slots as decorrelated near-copies of one mix; six real speakers separate them acoustically, but the port's 6→2 fold sums them, combing speech into the long-reported doubled dialogue. While the demo sequencer ticks, fold only the fronts (verified lossless). audio_cutscene_downmix (default on). --- src/ac6_backend_fixes/ac6_cutscene_resync.cpp | 7 ++ .../include/native/audio/conversion.h | 111 +++++++++++++++++- .../src/native/audio/audio_system.cpp | 41 +++++++ 3 files changed, 155 insertions(+), 4 deletions(-) diff --git a/src/ac6_backend_fixes/ac6_cutscene_resync.cpp b/src/ac6_backend_fixes/ac6_cutscene_resync.cpp index f05607a9..ed773a68 100644 --- a/src/ac6_backend_fixes/ac6_cutscene_resync.cpp +++ b/src/ac6_backend_fixes/ac6_cutscene_resync.cpp @@ -31,6 +31,7 @@ #include #include +#include #include #include #include @@ -121,6 +122,12 @@ void ExecWithResync(PPCContext& ctx, uint8_t* base, } g_session.last_exec_ms = now_ms; + // Stamp the cinematic-audio gate for the stereo fold-down (conversion.h): + // the demo wrappers tick only while an in-engine cutscene plays, so their + // freshness is the "cutscene audio active" signal. Unconditional - the + // stamp is independent of whether the resync behavior itself is enabled. + rex::audio::NotifyCinematicAudioTick(now_ms); + uint64_t audio_samples = 0; const bool clock_ok = ReadAudioClockSamples(ctx, &audio_samples); if (clock_ok && !g_session.clock_valid) { diff --git a/thirdparty/rexglue-sdk/include/native/audio/conversion.h b/thirdparty/rexglue-sdk/include/native/audio/conversion.h index 46c828cc..7a91375a 100644 --- a/thirdparty/rexglue-sdk/include/native/audio/conversion.h +++ b/thirdparty/rexglue-sdk/include/native/audio/conversion.h @@ -3,13 +3,40 @@ #pragma once +#include +#include +#include #include #include #include +#include #include #include +REXCVAR_DECLARE(bool, audio_cutscene_downmix); +REXCVAR_DECLARE(double, audio_downmix_center_gain); +REXCVAR_DECLARE(double, audio_downmix_surround_gain); +REXCVAR_DECLARE(double, audio_downmix_lfe_gain); +REXCVAR_DECLARE(double, audio_downmix_cutscene_center_gain); +REXCVAR_DECLARE(double, audio_downmix_cutscene_surround_gain); +REXCVAR_DECLARE(double, audio_downmix_cutscene_lfe_gain); +REXCVAR_DECLARE(double, audio_downmix_cutscene_trim); +REXCVAR_DECLARE(double, audio_downmix_cutscene_ramp_ms); + +namespace rex::audio { + +// Wall-clock ms of the last in-engine cutscene sequencer tick, stamped by the +// demo-tick hook (ac6_cutscene_resync). Lets the stereo fold-down apply +// cutscene-specific gains without reaching into game code. INT64_MIN = never. +inline std::atomic g_last_cinematic_audio_tick_ms{INT64_MIN}; + +inline void NotifyCinematicAudioTick(int64_t now_ms) { + g_last_cinematic_audio_tick_ms.store(now_ms, std::memory_order_relaxed); +} + +} // namespace rex::audio + namespace rex::audio::conversion { inline constexpr float kStereoDownmixCenterGain = 0.70710678f; @@ -20,6 +47,81 @@ inline constexpr float kStereoDownmixNormalize = 1.0f / (1.0f + kStereoDownmixCenterGain + kStereoDownmixSurroundGain + kStereoDownmixLfeGain); +// Live fold-down gains for the path AC6 actually plays through (the AMD64 +// planar fold below; the other variants keep the compile-time constants). +// The base gains default to those constants; the cutscene set applies while +// the demo sequencer is ticking, because AC6's cutscene mixer submits its premix +// spread across ALL six speaker slots as decorrelated near-copies of one mix +// (measured: equal RMS on every channel, inter-channel correlation 0.69-0.94 +// at exactly lag 0, identical structure across scenes). Real speakers +// separate the copies acoustically; an electrical 6-to-2 sum combs them - +// heard as doubled dialogue. The fronts alone carry the complete mix, so the +// cutscene defaults fold only the fronts. Gameplay audio (discrete channels) +// is bit-identical to the old constants. +struct StereoDownmixGains { + float center; + float surround; + float lfe; + float normalize; +}; + +inline StereoDownmixGains GetStereoDownmixGains() { + // The cutscene gains engage while demo-wrapper ticks are fresh; f slews + // over ramp_ms so fold changes never step. Known cosmetic (accepted): the + // wrapper keeps ticking through the gallery's menu->scene transitions, so + // the gallery's front-weighted transition SFX plays through the + // fronts-only fold hot; campaign flows are unaffected. + const int64_t now_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + const int64_t last_tick = + g_last_cinematic_audio_tick_ms.load(std::memory_order_relaxed); + const bool engaged = REXCVAR_GET(audio_cutscene_downmix) && + last_tick != INT64_MIN && (now_ms - last_tick) <= 250; + static float f_state = 0.0f; // 0 = base fold, 1 = full cutscene gains + static int64_t f_last_ms = INT64_MIN; + const double ramp = std::max(1.0, REXCVAR_GET(audio_downmix_cutscene_ramp_ms)); + float step = 1.0f; + if (f_last_ms != INT64_MIN && now_ms >= f_last_ms) { + step = float(std::min(1.0, double(now_ms - f_last_ms) / ramp)); + } + f_last_ms = now_ms; + const float target = engaged ? 1.0f : 0.0f; + if (target > f_state) { + f_state = std::min(target, f_state + step); + } else { + f_state = std::max(target, f_state - step); + } + const float f = f_state; + auto clamp_gain = [](double v) { + return std::min(2.0f, std::max(0.0f, float(v))); + }; + auto blend = [&](double cutscene_value, double base_value) { + const float base = clamp_gain(base_value); + const float cut = + cutscene_value >= 0.0 ? clamp_gain(cutscene_value) : base; + return base + (cut - base) * f; + }; + StereoDownmixGains gains; + gains.center = blend(REXCVAR_GET(audio_downmix_cutscene_center_gain), + REXCVAR_GET(audio_downmix_center_gain)); + gains.surround = blend(REXCVAR_GET(audio_downmix_cutscene_surround_gain), + REXCVAR_GET(audio_downmix_surround_gain)); + gains.lfe = blend(REXCVAR_GET(audio_downmix_cutscene_lfe_gain), + REXCVAR_GET(audio_downmix_lfe_gain)); + // Normalization follows the live gains so loudness stays consistent at any + // setting; at the stock base gains this equals the old fixed constant, so + // gameplay output is bit-identical. The near-copy cutscene mixes are + // self-correcting under it (fold of N unity-ish copies divided by the gain + // sum lands at the same level whichever channels fold); the measured + // residual vs the old fold is +0.6 dB, cancelled by the default trim. + gains.normalize = 1.0f / (1.0f + gains.center + gains.surround + gains.lfe); + const float trim = std::min( + 2.0f, std::max(0.0f, float(REXCVAR_GET(audio_downmix_cutscene_trim)))); + gains.normalize *= 1.0f + (trim - 1.0f) * f; + return gains; +} + inline float SanitizeGuestAudioSample(float sample) { if (!std::isfinite(sample)) { return 0.0f; @@ -70,10 +172,11 @@ inline void sequential_6_BE_to_interleaved_2_LE(float* output, const float* inpu const __m128i byte_swap_shuffle = _mm_set_epi8(12, 13, 14, 15, 8, 9, 10, 11, 4, 5, 6, 7, 0, 1, 2, 3); - const __m128 center_gain = _mm_set1_ps(kStereoDownmixCenterGain); - const __m128 surround_gain = _mm_set1_ps(kStereoDownmixSurroundGain); - const __m128 lfe_gain = _mm_set1_ps(kStereoDownmixLfeGain); - const __m128 normalize = _mm_set1_ps(kStereoDownmixNormalize); + const StereoDownmixGains live_gains = GetStereoDownmixGains(); + const __m128 center_gain = _mm_set1_ps(live_gains.center); + const __m128 surround_gain = _mm_set1_ps(live_gains.surround); + const __m128 lfe_gain = _mm_set1_ps(live_gains.lfe); + const __m128 normalize = _mm_set1_ps(live_gains.normalize); const __m128 peak_headroom = _mm_set1_ps(kStereoDownmixPeakHeadroom); const __m128 sign_mask = _mm_set1_ps(-0.0f); diff --git a/thirdparty/rexglue-sdk/src/native/audio/audio_system.cpp b/thirdparty/rexglue-sdk/src/native/audio/audio_system.cpp index 5b0127ae..df138624 100644 --- a/thirdparty/rexglue-sdk/src/native/audio/audio_system.cpp +++ b/thirdparty/rexglue-sdk/src/native/audio/audio_system.cpp @@ -19,6 +19,47 @@ REXCVAR_DEFINE_BOOL(audio_trace_render_driver_verbose, false, "Audio", "Trace render-driver activity"); REXCVAR_DEFINE_BOOL(audio_deep_trace, false, "Audio", "Enable verbose runtime audio tracing"); +REXCVAR_DEFINE_BOOL(audio_cutscene_downmix, true, "Audio", + "Master switch for the cutscene-specific stereo fold-down " + "(fronts-only fold while the demo sequencer is active - " + "removes the doubled/combed dialogue caused by summing the " + "cutscene mixer's six decorrelated near-copies to stereo). " + "false = the original summing fold everywhere; the " + "audio_downmix_cutscene_* cvars then have no effect. " + "Gameplay audio is identical either way."); +REXCVAR_DEFINE_DOUBLE(audio_downmix_center_gain, 0.70710678, "Audio", + "Front-center gain in the 6ch-to-stereo fold-down (was a " + "compile-time constant; default unchanged). Normalization " + "follows the live gains. Read continuously."); +REXCVAR_DEFINE_DOUBLE(audio_downmix_surround_gain, 0.5, "Audio", + "Rear-channel gain in the 6ch-to-stereo fold-down. See " + "audio_downmix_center_gain."); +REXCVAR_DEFINE_DOUBLE(audio_downmix_lfe_gain, 0.0, "Audio", + "LFE gain in the 6ch-to-stereo fold-down. See " + "audio_downmix_center_gain."); +REXCVAR_DEFINE_DOUBLE(audio_downmix_cutscene_center_gain, 0.0, "Audio", + "Front-center fold-down gain used ONLY while an in-engine " + "cutscene is playing (demo sequencer active); negative = " + "inherit audio_downmix_center_gain. AC6's cutscene mixer " + "spreads the premix across ALL six speaker slots as " + "decorrelated near-copies of one mix; the fronts alone " + "carry the complete mix, so the cutscene fold takes only " + "them - center and surround default to 0 here."); +REXCVAR_DEFINE_DOUBLE(audio_downmix_cutscene_surround_gain, 0.0, "Audio", + "Rear-channel fold-down gain during in-engine cutscenes; " + "negative = inherit. See audio_downmix_cutscene_center_gain."); +REXCVAR_DEFINE_DOUBLE(audio_downmix_cutscene_lfe_gain, -1.0, "Audio", + "LFE fold-down gain during in-engine cutscenes; negative = " + "inherit. See audio_downmix_cutscene_center_gain."); +REXCVAR_DEFINE_DOUBLE(audio_downmix_cutscene_trim, 0.933, "Audio", + "Extra output gain on the cutscene fold (ramped in with " + "the cutscene gains). Default 0.933 = the measured RMS " + "ratio of the fronts-only fold vs the stock summing fold " + "(-0.6 dB), so cutscene loudness matches the original " + "baseline; 1.0 = no trim."); +REXCVAR_DEFINE_DOUBLE(audio_downmix_cutscene_ramp_ms, 250.0, "Audio", + "Slew time between the base fold and the cutscene gains " + "when the demo-sequencer signal engages or releases."); REXCVAR_DEFINE_BOOL(audio_xma_loop_guard, true, "Audio", "Require a real loop window (loop_start < loop_end, " "read_offset >= loop_end) before engaging XMA loop "