From 04f77d48587022206b19299d77fbbf9d2ed8449a Mon Sep 17 00:00:00 2001 From: Skrubby Skrub In A Shrub <87662196+SkrubbySkrubInAShrub@users.noreply.github.com> Date: Sun, 31 May 2026 14:06:48 +0200 Subject: [PATCH 1/5] ci: fix release build and nexus upload pipeline after 1.6 (#2444) (cherry picked from commit 4c0f278229c4d6594187ac09fe9ac8e3b3d23a37) --- .github/workflows/maint-cleanup-releases.yaml | 5 ++++- .github/workflows/nexus-upload.yaml | 18 +++++++++++----- .github/workflows/release-build.yaml | 18 +++++++++------- .github/workflows/release-hotfix.yaml | 11 +++------- .github/workflows/release-semantic.yaml | 21 +++++++++++++++++++ .../Shaders/Features/TerrainHelper.ini | 3 ++- tools/feature_version_audit.py | 17 ++++++++++++++- 7 files changed, 69 insertions(+), 24 deletions(-) diff --git a/.github/workflows/maint-cleanup-releases.yaml b/.github/workflows/maint-cleanup-releases.yaml index e934cf7085..069b78eced 100644 --- a/.github/workflows/maint-cleanup-releases.yaml +++ b/.github/workflows/maint-cleanup-releases.yaml @@ -57,7 +57,10 @@ jobs: # Get all git tags that look like pre-releases but may not have GitHub releases git fetch --tags - orphaned_tags=$(git tag -l | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-(pr[0-9]+|rc[0-9]+|alpha[0-9]*|beta[0-9]*|dev[0-9]*)' || true) + # Optional '.' before the number so semantic-release's dotted + # prerelease tags (e.g. v1.6.0-rc.1) are matched, not just + # v1.6.0-rc1. + orphaned_tags=$(git tag -l | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-(pr|rc|alpha|beta|dev)\.?[0-9]+' || true) # Combine both lists and remove duplicates all_prerelease_tags=$(echo -e "$releases\n$orphaned_tags" | sort | uniq | grep -v '^$' || true) diff --git a/.github/workflows/nexus-upload.yaml b/.github/workflows/nexus-upload.yaml index cfff0dd547..aa27d0da8e 100644 --- a/.github/workflows/nexus-upload.yaml +++ b/.github/workflows/nexus-upload.yaml @@ -497,11 +497,19 @@ jobs: fi echo "file=$FILE" >> $GITHUB_OUTPUT LINK="https://github.com/${REPO}/releases/tag/${RELEASE_TAG}" - if [ -n "$FILE_DESC" ]; then - printf 'description=%s Full changelog: %s\n' "$FILE_DESC" "$LINK" >> $GITHUB_OUTPUT - else - printf 'description=Full changelog: %s\n' "$LINK" >> $GITHUB_OUTPUT - fi + # file_description is multi-line (per-feature compatibility + # bullets), so write it with the heredoc-delimiter form. A plain + # 'description=...' breaks $GITHUB_OUTPUT on the embedded newlines + # ("Error: Invalid format '• HDR 1.0.2'"). + { + echo 'description<> "$GITHUB_OUTPUT" - name: Validate file_group_id if: steps.version-check.outputs.should_upload == 'true' diff --git a/.github/workflows/release-build.yaml b/.github/workflows/release-build.yaml index 72bbcbcf6f..b75b8f81b4 100644 --- a/.github/workflows/release-build.yaml +++ b/.github/workflows/release-build.yaml @@ -35,6 +35,10 @@ concurrency: jobs: build: name: Build + # Build on dispatch / tag push / a non-draft `release: created`, but NOT + # on `release: published`: the artifacts were already built and attached + # when the draft was created, so publishing must not rebuild. + if: github.event.action != 'published' uses: ./.github/workflows/_shared-build.yaml with: ref: ${{ github.ref }} @@ -46,7 +50,7 @@ jobs: feature-audit: name: Feature Version Audit (Release) - if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'release' + if: (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'release') && github.event.action != 'published' runs-on: ubuntu-latest continue-on-error: true steps: @@ -78,6 +82,7 @@ jobs: if: > always() && !cancelled() && needs.build.outputs.cpp-built == 'true' && + github.event.action != 'published' && (github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))) @@ -187,16 +192,13 @@ jobs: allowUpdates: true nexus-dry-run: - # Chained from `release` to avoid the artifact-attachment race a - # parallel `release: published` trigger on a separate workflow had: - # the prior release-nexus-trigger.yaml fired in parallel with this - # build and tried to download artifacts before they existed. + # Runs only on publish, independently of the build jobs (which are + # skipped on publish). Artifacts were already built and attached to the + # release when its draft was created, and nexus-upload downloads them + # from the release assets — so there is nothing to wait on and no rebuild. # Stable tags only (no '-' in the tag name). name: Nexus Upload (dry run) - needs: [release] if: > - always() && !cancelled() && - needs.release.result == 'success' && github.event_name == 'release' && github.event.action == 'published' && !contains(github.event.release.tag_name, '-') diff --git a/.github/workflows/release-hotfix.yaml b/.github/workflows/release-hotfix.yaml index 1125bea95c..3add2299f2 100644 --- a/.github/workflows/release-hotfix.yaml +++ b/.github/workflows/release-hotfix.yaml @@ -286,14 +286,9 @@ jobs: - name: Open PR against hotfix branch if: ${{ !inputs.dry_run && steps.cherry.outputs.picked_count != '0' }} env: - # Use GITHUB_TOKEN (not RELEASE_PAT). The workflow declares - # `permissions: pull-requests: write`, which is sufficient - # for creating the candidate PR — no branch-protection - # bypass is required here, so there's no need to spend - # RELEASE_PAT's broader scope (and avoids depending on the - # PAT having pull_requests:write granted, which a previous - # run hit: "Resource not accessible by personal access - # token (createPullRequest)"). + # GITHUB_TOKEN suffices here: the workflow declares + # `permissions: pull-requests: write`, and creating the + # candidate PR requires no branch-protection bypass. GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Pass via env (not ${{ }} interpolation) so the literal # backticks inside SOURCE_DESC don't get re-evaluated by diff --git a/.github/workflows/release-semantic.yaml b/.github/workflows/release-semantic.yaml index 13cf0edc82..9911c4159a 100644 --- a/.github/workflows/release-semantic.yaml +++ b/.github/workflows/release-semantic.yaml @@ -19,6 +19,9 @@ on: permissions: contents: write + # actions:write lets this job dispatch release-build.yaml after cutting a + # release (the tag push can't trigger it — see the step below). + actions: write concurrency: # Serialize promotions so two concurrent dispatches on main can't race @@ -173,6 +176,24 @@ jobs: # (release-build.yaml); GITHUB_TOKEN pushes do not. GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + - name: Build artifacts for the new release + # The release tag does NOT auto-trigger release-build.yaml: it sits + # on a `chore(release): … [skip ci]` commit (the tag push is skipped) + # and the GitHub release is created as a draft (no `release: created` + # event fires). So dispatch the build explicitly here to populate the + # draft with .7z artifacts. Publishing the draft later does NOT + # rebuild — release-build.yaml skips its build job on the + # `release: published` event — it just publishes the already-attached + # artifacts and fires the Nexus dry-run via `release: published`. + if: steps.semantic.outputs.new_release_published == 'true' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.semantic.outputs.new_release_git_tag }} + run: | + gh workflow run release-build.yaml \ + --repo "${{ github.repository }}" \ + --ref "${TAG}" + - name: Reconcile dev with release commit (promotion mode, dev source only) if: ${{ inputs.ff_target != '' && steps.semantic.outputs.new_release_published == 'true' && steps.validate.outputs.source == 'dev' }} env: diff --git a/features/Terrain Helper/Shaders/Features/TerrainHelper.ini b/features/Terrain Helper/Shaders/Features/TerrainHelper.ini index 9dbee137a6..321113bad1 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 = true +autoupload = false diff --git a/tools/feature_version_audit.py b/tools/feature_version_audit.py index e9959a90cb..08bfd1e48a 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 From ff59e0da3d5fc0b89d40f391f7684a107fd20d83 Mon Sep 17 00:00:00 2001 From: doodlum <15017472+doodlum@users.noreply.github.com> Date: Sun, 31 May 2026 14:14:00 +0100 Subject: [PATCH 2/5] fix(hooks): promote snow render targets to fp16 for banding (#2410) (cherry picked from commit ca1c494f265971b79a1450783c7dfbf6a7907b80) --- src/Hooks.cpp | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/Hooks.cpp b/src/Hooks.cpp index afe2d4cda7..5bbf1b2a72 100644 --- a/src/Hooks.cpp +++ b/src/Hooks.cpp @@ -512,6 +512,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 @@ -856,6 +879,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)); From 3b4de85369f0c64c7465892cae16e2bf093e2be5 Mon Sep 17 00:00:00 2001 From: FIocker Date: Mon, 1 Jun 2026 18:27:20 +0200 Subject: [PATCH 3/5] fix(emat): allow solid-black height-only masks (#2441) (cherry picked from commit 055d508a85b322e3f67dc3fab6c1f80e1ac072a9) --- package/Shaders/Lighting.hlsl | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/package/Shaders/Lighting.hlsl b/package/Shaders/Lighting.hlsl index 60d66b7ecf..6c4d1ee2af 100644 --- a/package/Shaders/Lighting.hlsl +++ b/package/Shaders/Lighting.hlsl @@ -1110,12 +1110,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) { From 5950a0c268503e3dd02e861814f9c30bc8fd5652 Mon Sep 17 00:00:00 2001 From: FIocker Date: Mon, 1 Jun 2026 19:22:09 +0200 Subject: [PATCH 4/5] fix(water): fix water blending (ghosting) and LOD gaps (#2440) (cherry picked from commit 22ea0567d146873a7251e3a5cff4335a5c7c17de) --- package/Shaders/ISWaterBlend.hlsl | 10 ++++++++-- src/Features/UnifiedWater.cpp | 6 ++++-- src/Hooks.cpp | 23 +++++++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) 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/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 5bbf1b2a72..8957a7d80e 100644 --- a/src/Hooks.cpp +++ b/src/Hooks.cpp @@ -230,6 +230,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) @@ -898,6 +920,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]); From 5e1b095f566aac30ca8ad760ddf85aed6aef43bb Mon Sep 17 00:00:00 2001 From: Skrubby Skrub In A Shrub <87662196+SkrubbySkrubInAShrub@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:16:22 +0200 Subject: [PATCH 5/5] ci: fix perm for hotfix workflow (#2451) (cherry picked from commit 26c10c40d435e2625fe08dcd4889f4b64ff5fae0) --- .github/workflows/release-hotfix.yaml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/release-hotfix.yaml b/.github/workflows/release-hotfix.yaml index 3add2299f2..43dc5c9eb3 100644 --- a/.github/workflows/release-hotfix.yaml +++ b/.github/workflows/release-hotfix.yaml @@ -286,10 +286,7 @@ jobs: - name: Open PR against hotfix branch if: ${{ !inputs.dry_run && steps.cherry.outputs.picked_count != '0' }} env: - # GITHUB_TOKEN suffices here: the workflow declares - # `permissions: pull-requests: write`, and creating the - # candidate PR requires no branch-protection bypass. - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} # Pass via env (not ${{ }} interpolation) so the literal # backticks inside SOURCE_DESC don't get re-evaluated by # bash as command substitution.