diff --git a/features/Terrain Helper/Shaders/Features/TerrainHelper.ini b/features/Terrain Helper/Shaders/Features/TerrainHelper.ini index a791b0a489..9194f78993 100644 --- a/features/Terrain Helper/Shaders/Features/TerrainHelper.ini +++ b/features/Terrain Helper/Shaders/Features/TerrainHelper.ini @@ -1,8 +1,9 @@ [Info] Version = 1-0-1 +AuditVersion = false [Nexus] nexusmodid = 143149 nexusfilegroupid = 000000 nexusfilename = Terrain Helper -autoupload = false +autoupload = true diff --git a/package/Shaders/ISWaterBlend.hlsl b/package/Shaders/ISWaterBlend.hlsl index 637da4ddcc..f3d9018d4a 100644 --- a/package/Shaders/ISWaterBlend.hlsl +++ b/package/Shaders/ISWaterBlend.hlsl @@ -1,5 +1,6 @@ #include "Common/DummyVSTexCoord.hlsl" #include "Common/FrameBuffer.hlsli" +#include "Common/Math.hlsli" #include "Common/VR.hlsli" typedef VS_OUTPUT PS_INPUT; @@ -75,11 +76,16 @@ PS_OUTPUT main(PS_INPUT input) 0.1, 0.95); historyFactor = NearFar_Menu_DistanceFactor.w * (distanceFactor * (waterMask * -0.85 + 0.95)); } + // Un-premultiply history so bilinear filtering against cleared pixels does not darken water edges + float3 historyColor = waterHistory.xyz / max(waterHistory.w, EPSILON_DIVISION); + historyFactor *= waterHistory.w; - finalColor = lerp(sourceColor, waterHistory.xyz, historyFactor); + finalColor = lerp(sourceColor, historyColor, historyFactor); } - psout.Color1 = float4(finalColor, WaterBlend::GetWaterCoverage(waterMask)); + float waterCoverage = WaterBlend::GetWaterCoverage(waterMask); + // Store premultiplied history so transparent clears filter without dark outlines + psout.Color1 = float4(finalColor * waterCoverage, waterCoverage); psout.Color = finalColor; return psout; diff --git a/package/Shaders/Lighting.hlsl b/package/Shaders/Lighting.hlsl index 30c7fae290..b0e2e21943 100644 --- a/package/Shaders/Lighting.hlsl +++ b/package/Shaders/Lighting.hlsl @@ -1108,12 +1108,17 @@ PS_OUTPUT main(PS_INPUT input, bool frontFace : SV_IsFrontFace) if (SharedData::extendedMaterialSettings.EnableComplexMaterial) { const float kMaskEpsilon = (4.0 / 255.0); - complexMaterial = TexEnvMaskSampler.SampleLevel(SampEnvMaskSampler, uv, 15).w < (1.0 - kMaskEpsilon); - - // Detect texture saved in the wrong format - if ((abs(envMaskSample.x - envMaskSample.y) < kMaskEpsilon) && - (abs(envMaskSample.x - envMaskSample.z) < kMaskEpsilon) && - (abs(envMaskSample.y - envMaskSample.z) < kMaskEpsilon)) + const float4 mipSample = TexEnvMaskSampler.SampleLevel(SampEnvMaskSampler, uv, 15); + complexMaterial = mipSample.w < (1.0 - kMaskEpsilon); + + const bool grayscaleMask = (abs(mipSample.x - mipSample.y) < kMaskEpsilon) && + (abs(mipSample.x - mipSample.z) < kMaskEpsilon) && + (abs(mipSample.y - mipSample.z) < kMaskEpsilon); + // Preserve height-only masks while rejecting grayscale environment masks + const bool solidBlackHeightMask = all(mipSample.xyz < kMaskEpsilon) && + mipSample.w > kMaskEpsilon && + mipSample.w < (1.0 - kMaskEpsilon); + if (grayscaleMask && !solidBlackHeightMask) complexMaterial = false; if (complexMaterial) { diff --git a/src/Features/UnifiedWater.cpp b/src/Features/UnifiedWater.cpp index 3c151449b6..05e2c5e224 100644 --- a/src/Features/UnifiedWater.cpp +++ b/src/Features/UnifiedWater.cpp @@ -481,8 +481,10 @@ void UnifiedWater::BGSTerrainNode_UpdateWaterMeshSubVisibility::thunk(const RE:: bool cull = false; if (x >= 0 && y >= 0 && x < length && y < length) { - if (const auto cell = gridCells->GetCell(x, y); cell && cell->cellState.any(RE::TESObjectCELL::CellState::kAttached, static_cast(6))) - cull = true; + if (const auto cell = gridCells->GetCell(x, y); cell && cell->cellState.any(RE::TESObjectCELL::CellState::kAttached, static_cast(6))) { + // Keep LOD visible when a loaded dry cell has no active water to replace it + cull = cell->cellFlags.any(RE::TESObjectCELL::Flag::kHasWater); + } } child->SetAppCulled(cull); diff --git a/src/Hooks.cpp b/src/Hooks.cpp index a3131ce1d5..42cbf0b158 100644 --- a/src/Hooks.cpp +++ b/src/Hooks.cpp @@ -231,6 +231,28 @@ namespace GrassExtensions }; } +namespace WaterBlendHistory +{ + struct BSImagespaceShader_Render + { + static void thunk(void* imageSpaceShader, RE::BSTriShape* shape, RE::ImageSpaceEffectParam* param) + { + GET_INSTANCE_MEMBER(renderTargets, globals::game::shadowState) + + // Clear stale coverage left by discarded non-water pixels + const float clearColor[4] = { 0.f, 0.f, 0.f, 0.f }; + const auto target = renderTargets[1]; + globals::d3d::context->ClearRenderTargetView( + globals::game::renderer->GetRuntimeData().renderTargets[target].RTV, + clearColor); + + func(imageSpaceShader, shape, param); + } + + static inline REL::Relocation func; + }; +} + struct IDXGISwapChain_Present { static HRESULT WINAPI thunk(IDXGISwapChain* This, UINT SyncInterval, UINT Flags) @@ -559,6 +581,29 @@ namespace Hooks static inline REL::Relocation func; }; + // kSNOW / kSNOW_SWAP are created at R8G8B8A8_UNORM by vanilla; the snow shader + // writes accumulated wetness/sparkle values that exceed the 8-bit range and + // quantize into visible banding on tessellated snow. Promote to fp16 for headroom. + struct CreateRenderTarget_Snow + { + static void thunk(RE::BSGraphics::Renderer* This, RE::RENDER_TARGETS::RENDER_TARGET a_target, RE::BSGraphics::RenderTargetProperties* a_properties) + { + a_properties->format.set(RE::BSGraphics::Format::kR16G16B16A16_FLOAT); + func(This, a_target, a_properties); + } + static inline REL::Relocation func; + }; + + struct CreateRenderTarget_SnowSwap + { + static void thunk(RE::BSGraphics::Renderer* This, RE::RENDER_TARGETS::RENDER_TARGET a_target, RE::BSGraphics::RenderTargetProperties* a_properties) + { + a_properties->format.set(RE::BSGraphics::Format::kR16G16B16A16_FLOAT); + func(This, a_target, a_properties); + } + static inline REL::Relocation func; + }; + // kNORMAL_TAAMASK_SSRMASK and its swap need UAV bind because DeferredCompositeCS // writes vanilla-encoded normals through UAV1 (`normals.UAV` in Deferred::DeferredPasses), // which feeds the post-pass vanilla SSAO chain (ISSAORawAO -> ISSAOComposite). Without @@ -958,6 +1003,8 @@ namespace Hooks logger::info("Hooking BSShaderRenderTargets::Create::CreateRenderTarget(s)"); stl::write_thunk_call(REL::RelocationID(100458, 107175).address() + REL::Relocate(0x3F0, 0x3F3, 0x548)); + stl::write_thunk_call(REL::RelocationID(100458, 107175).address() + REL::Relocate(0x406, 0x409, 0x55E)); + stl::write_thunk_call(REL::RelocationID(100458, 107175).address() + REL::Relocate(0x41C, 0x41F, 0x574)); stl::write_thunk_call(REL::RelocationID(100458, 107175).address() + REL::Relocate(0x458, 0x45B, 0x5B0)); stl::write_thunk_call(REL::RelocationID(100458, 107175).address() + REL::Relocate(0x46B, 0x46E, 0x5C3)); stl::write_thunk_call(REL::RelocationID(100458, 107175).address() + REL::Relocate(0x4F0, 0x4EF, 0x64E)); @@ -977,6 +1024,7 @@ namespace Hooks logger::info("Hooking BSImagespaceShader"); stl::detour_thunk(REL::RelocationID(100952, 107734)); + stl::write_vfunc<0x1, WaterBlendHistory::BSImagespaceShader_Render>(RE::VTABLE_BSImagespaceShaderISWaterBlend[3]); logger::info("Hooking BSComputeShader"); stl::write_vfunc<0x02, CSShadersSupport::BSComputeShader_Dispatch>(RE::VTABLE_BSComputeShader[0]); diff --git a/tools/feature_version_audit.py b/tools/feature_version_audit.py index 16e52eb8d9..0823b5ed31 100644 --- a/tools/feature_version_audit.py +++ b/tools/feature_version_audit.py @@ -186,7 +186,7 @@ def get_feature_ini_metadata(feature_dir_or_ini_path): if not sections: sections = ['Info'] if parser.has_section('Info') else [] - metadata = {'auto_upload': False} + metadata = {'auto_upload': False, 'audit_version': True} for section in sections: if not parser.has_section(section): continue @@ -196,6 +196,15 @@ def get_feature_ini_metadata(feature_dir_or_ini_path): if auto_upload_str is not None: metadata['auto_upload'] = str(auto_upload_str).strip().lower() not in ('false', '0', 'no', 'off', '') + # Activation-only features keep all code in the core mod; their toggle + # .ini opts out of version auditing/bumping with `AuditVersion = false`. + # Auditing is on by default, so a blank value must NOT exclude the ini: + # only explicit falsy values opt out (unlike auto_upload, which is opt-in + # and treats blank as off). + audit_version_str = section_items.get('auditversion') or section_items.get('audit_version') + if audit_version_str is not None: + metadata['audit_version'] = str(audit_version_str).strip().lower() not in ('false', '0', 'no', 'off') + section_metadata = { 'mod_id': section_items.get('nexusmodid') or section_items.get('nexus_mod_id') or section_items.get('mod_id'), 'mod_filename': section_items.get('nexusfilename') or section_items.get('nexus_filename') or section_items.get('nexusmodfilename') or section_items.get('nexus_mod_filename') or section_items.get('mod_filename') or section_items.get('modname') or section_items.get('name'), @@ -630,6 +639,12 @@ def get_feature_key(feature_dir, feature_meta_map): meta = feature_meta_map.get(feature_key) ini_path = get_feature_ini(feature_dir) + # Activation-only features (e.g. Terrain Helper) opt out of version + # auditing via `AuditVersion = false` in their .ini: all their code lives + # in the core mod, so bumping the toggle .ini on every edit is meaningless + # churn. Skip them entirely from bump suggestions and PR-check failures. + if ini_path and not get_feature_ini_metadata(ini_path).get('audit_version', True): + continue # Use last release tag (version_ref) as the baseline for version proposals so that # multiple PRs between releases don't accumulate spurious bumps. prior_ver = get_prior_version(ini_path, version_ref) if ini_path else None