diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6b36f84 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,200 @@ +name: Release + +# Packs the mod into an installable .zip and publishes it as a GitHub Release, +# once per push to main. +# +# Archive layout: every mod file at the archive root, manifest.json included. +# That is one of the two shapes the game accepts on MODS > Import mod .zip +# (src/mods/LauncherMods.lua locateRoot: manifest at the root, or inside a +# single top-level folder). Nothing else is added, so the archive stays +# installable by hand too. +# +# Versioning, first rule that applies wins: +# 1. the "version" input of a manual run, +# 2. "[release X.Y.Z]" anywhere in the commit message, +# 3. manifest.json's own version, when it is ahead of every existing tag, +# so bumping the manifest is the normal way to cut a release, +# 4. otherwise the newest vX.Y.Z tag with its patch incremented +# (0.2.99 rolls over to 0.3.0). +# Whichever wins is written into the manifest.json inside the archive, so a +# shipped mod never reports a different version than the release it came from. +# +# Generated by: python3 tools/modkit.py add-release-workflow +# MOD_ID below is stamped to this mod's id when the file is copied. + +on: + push: + branches: [master] + paths-ignore: + - '.github/**' + - '**.md' + workflow_dispatch: + inputs: + version: + description: "Exact version to release (e.g. 0.3.0). Leave blank to auto-resolve." + required: false + default: "" + +permissions: + contents: write + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine version + id: ver + env: + DISPATCH_VERSION: ${{ github.event.inputs.version }} + run: | + set -euo pipefail + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json, os, re, subprocess, sys + + SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") + + def sh(*args): + return subprocess.run(args, capture_output=True, text=True).stdout.strip() + + def parse(text): + m = SEMVER.match(text) + return tuple(int(p) for p in m.groups()) if m else None + + def die(msg): + print(f"::error::{msg}", file=sys.stderr) + raise SystemExit(1) + + with open("manifest.json", encoding="utf-8") as fh: + manifest_version = str(json.load(fh).get("version", "")) + + released = sorted( + v for v in (parse(tag[1:]) for tag in sh("git", "tag", "-l", "v*").splitlines()) if v + ) + latest = released[-1] if released else None + + override = os.environ.get("DISPATCH_VERSION", "").strip() + if not override: + found = re.search(r"\[release\s+(\d+\.\d+\.\d+)\]", sh("git", "log", "-1", "--pretty=%B")) + override = found.group(1) if found else "" + + manifest_ver = parse(manifest_version) + if override: + version = parse(override) or die(f"invalid version override {override!r} (expected X.Y.Z)") + source = "the override" + elif manifest_ver and (latest is None or manifest_ver > latest): + version = manifest_ver + source = "manifest.json" + elif latest: + major, minor, patch = latest + patch += 1 + if patch > 99: + minor, patch = minor + 1, 0 + version = (major, minor, patch) + source = "a patch bump on v%d.%d.%d" % latest + else: + die(f"manifest.json version {manifest_version!r} is not X.Y.Z " + "and there is no vX.Y.Z tag to count from") + + text = "%d.%d.%d" % version + print(f"Releasing {text}, from {source}.", file=sys.stderr) + print(f"version={text}") + print(f"tag=v{text}") + PY + + - name: Refuse to clobber an existing release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.ver.outputs.tag }} + run: | + set -euo pipefail + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "::error::Tag $TAG already exists. Pick a different version." + exit 1 + fi + if gh release view "$TAG" >/dev/null 2>&1; then + echo "::error::Release $TAG already exists. Pick a different version." + exit 1 + fi + + - name: Build the mod .zip + env: + VERSION: ${{ steps.ver.outputs.version }} + MOD_ID: "DRAMATIC_SHAPE" + run: | + set -euo pipefail + staging="$RUNNER_TEMP/pkg" + out="$GITHUB_WORKSPACE/dist" + rm -rf "$staging" "$out" + mkdir -p "$staging" "$out" + + git archive HEAD | tar -x -C "$staging" + + rm -rf "$staging/.github" "$staging/.gitattributes" \ + "$staging/.gitignore" "$staging/.luarc.json" + + python3 - "$staging/manifest.json" "$VERSION" <<'PY' + import json, sys + path, version = sys.argv[1], sys.argv[2] + with open(path, encoding="utf-8") as fh: + manifest = json.load(fh) + manifest["version"] = version + with open(path, "w", encoding="utf-8") as fh: + json.dump(manifest, fh, indent=2, ensure_ascii=False) + fh.write("\n") + PY + + zip_path="$out/${MOD_ID}-${VERSION}.zip" + (cd "$staging" && zip -qr "$zip_path" .) + unzip -l "$zip_path" + + unzip -p "$zip_path" manifest.json > "$RUNNER_TEMP/packed-manifest.json" + python3 - "$RUNNER_TEMP/packed-manifest.json" "$VERSION" <<'PY' + import json, sys + path, expected = sys.argv[1], sys.argv[2] + with open(path, encoding="utf-8") as fh: + version = json.load(fh)["version"] + if version != expected: + raise SystemExit(f"::error::packed manifest says {version}, expected {expected}") + print(f"manifest.json is at the archive root and reports {version}") + PY + + (cd "$out" && sha256sum "${MOD_ID}"-*.zip > sha256sums.txt) + cat "$out/sha256sums.txt" + + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.ver.outputs.version }} + TAG: ${{ steps.ver.outputs.tag }} + MOD_ID: "DRAMATIC_SHAPE" + run: | + set -euo pipefail + + prev="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${TAG}$" | head -1 || true)" + range="${prev:+${prev}..}$GITHUB_SHA" + changes="$(git log --no-merges --pretty='- %s' "$range" | head -50 || true)" + + notes=$'Download the .zip and install it from the game: MODS > Import mod .zip.' + if [ -n "$changes" ]; then + notes+=$'\n\n## Changes\n\n'"$changes" + fi + printf 'Release notes:\n%s\n' "$notes" + + gh release create "$TAG" \ + --target "$GITHUB_SHA" \ + --title "$VERSION" \ + --notes "$notes" \ + "dist/${MOD_ID}-${VERSION}.zip" \ + "dist/sha256sums.txt" + + echo "Published release $TAG" diff --git a/CHANGELOG.md b/CHANGELOG.md index d03334c..3511684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,33 @@ painted. The reflection lands a hair off the sprite's own depth and exactly on its colour, which at a lake's worth of wave is the same picture. +### Fixed + +- **On Android the water stayed flat, as if the row were off.** Two GLSL ES + defaults the desktop never exercises, both in the water shader: + + Fragment floats default to **mediump** on GLSL ES, and the vertex stage's + default is highp -- and the water shader is the mod's first to declare the + same uniform, the frame's `vp` matrix, in BOTH stages, one on each default. + GLSL ES refuses to link a uniform whose precision the stages disagree on, + so the whole shader failed to build and the pass fell back -- quietly, by + design -- to the flat water the mode always drew. The pixel stage now lifts + its float default to highp (guarded, so a GPU without fragment highp still + compiles and falls back flat), which settles the link and is also simply + needed: the march works in world coordinates that run to a few thousand, + where fp16 has no fraction left, and the world-position varying is + qualified highp for the same reason the wireframe's has been all along. + Samplers default to **lowp** no matter what floats are set to, so the depth + read is lifted too -- eight bits of depth is a march with nothing to land + on. + + And the readable depth canvas -- the one hardware requirement the rest of + the mode does not already have -- now tries four formats before giving up: + depth24, depth24 riding a stencil (a pairing some mobile drivers will + texture when they refuse the bare format), depth32f, and depth16 as the + floor every GLES3 device can read. Refused all four, the reflections are + lost and nothing else, exactly as before. + ### Known - Screen-space reflections can only reflect what is in the frame. A tree just diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index df2f311..471a688 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -295,15 +295,24 @@ local active = false -- never be sampled. An explicit canvas is the same buffer with a texture -- handle on it, and costs the same memory. -- --- nil where the driver will not make one -- the format is optional in GLES --- and a canvas is the only honest test of it -- and beginScene falls --- straight back to the internal buffer, which is exactly the old behaviour --- minus the reflections. +-- nil where the driver will not make one -- every depth format is optional +-- in GLES and a canvas is the only honest test of any of them, so this asks +-- for several in order of preference: 24 bits, the same 24 riding a stencil +-- (a pairing some mobile drivers will texture when the bare format they +-- refuse), 32-bit float, and 16 as the floor every GLES3 device can read. +-- Refused all four, beginScene falls straight back to the internal buffer, +-- which is exactly the old behaviour minus the reflections. +local DEPTH_FORMATS = { "depth24", "depth24stencil8", "depth32f", "depth16" } + local function newDepth(w, h) if not (love.graphics and love.graphics.newCanvas) then return nil end - local ok, c = pcall(love.graphics.newCanvas, w, h, - { format = "depth24", readable = true }) - if not (ok and c) then return nil end + local c = nil + for _, format in ipairs(DEPTH_FORMATS) do + local ok, made = pcall(love.graphics.newCanvas, w, h, + { format = format, readable = true }) + if ok and made then c = made break end + end + if not c then return nil end -- nearest: a depth is a distance, and a blend of two of them is a -- distance to nothing. The march wants the texel it landed on. pcall(c.setFilter, c, "nearest", "nearest") diff --git a/lib/Water.lua b/lib/Water.lua index 96f5368..77a32e6 100644 --- a/lib/Water.lua +++ b/lib/Water.lua @@ -345,7 +345,12 @@ Water.EDGE_FADE = 0.14 -- reflection eased off over this much of the fra local SHADER_SRC = [[ varying float vShade; varying vec3 vSun; -varying vec3 vBent; // world position, as drawn +// World position, as drawn -- and a varying that cannot ride GLSL ES's +// mediump fragment default: everything below floors it into columns and +// marches it through the frame's matrices, and a route's coordinates run +// to a few thousand, where fp16 has no fraction left at all. The same +// reasoning the scene shader's vGrid states at length. +varying LOVE_HIGHP_OR_MEDIUMP vec3 vBent; #ifdef VERTEX uniform mat4 vp; @@ -368,6 +373,21 @@ vec4 position(mat4 transform_projection, vec4 vertex_position) { #endif #ifdef PIXEL +// Everything below works in WORLD units through the frame's own matrices, +// and GLSL ES defaults fragment floats to mediump -- fp16, out of fraction +// by a coordinate of two thousand and quantising a depth into steps the +// march falls straight through. Worse than wrong pictures: `vp` is +// declared by BOTH stages, the vertex side's default is highp, and GLSL ES +// refuses to LINK a uniform whose precision the two stages disagree on -- +// which is not broken water but NO water shader at all, the flat fallback +// with nothing in the log. One statement lifts the whole stage; the guard +// keeps the odd GPU without fragment highp compiling, and such a driver +// falls back to flat water exactly as it did before this pass existed. +#ifdef GL_ES +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#endif +#endif uniform mat4 vp; uniform vec3 eye; uniform vec2 screen; // the canvas, in pixels @@ -381,9 +401,12 @@ uniform float sunBias; uniform vec2 sunTexel; uniform vec3 dayTint; -// the frame as it stood before the water went down, and its depth +// the frame as it stood before the water went down, and its depth. The +// depth sampler is qualified because GLSL ES defaults samplers to LOWP no +// matter what floats are set to, and eight bits of depth is a march with +// nothing to land on. The frame copy is honest 8-bit colour and can stay. uniform Image reflectTex; -uniform Image depthTex; +uniform LOVE_HIGHP_OR_MEDIUMP Image depthTex; uniform float rays; // 0 = sky only, 1 = march the screen too uniform vec3 lookFlat; // the way the horizon lies from this camera diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index c9f0d0e..2f8bd76 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1778,6 +1778,25 @@ T.check(gridded:find("#define VOXEL_GRID", 1, true) ~= nil, T.check(plain:find("//@CRATERS", 1, true) == nil, "and the crater placeholder is gone by the time a driver sees the source") +-- ANDROID. GLSL ES defaults fragment floats to mediump and samplers to +-- lowp, and this shader is the one place in the mod where both defaults +-- are fatal: world coordinates run past fp16's fraction, the depth read +-- rounds to steps the march falls straight through, and -- the sharp edge +-- -- `vp` is declared by BOTH stages, whose defaults disagree, which GLSL +-- ES answers by refusing to LINK the shader at all. Flat lakes, empty log. +-- The sky's band ramp is this same lesson learned once already. +T.check(plain:find("precision highp float;", 1, true) ~= nil, + "the pixel stage lifts GLSL ES's mediump default to highp, so the march " + .. "keeps its fraction and the dual-declared vp links at one precision") +T.check(plain:find("GL_FRAGMENT_PRECISION_HIGH", 1, true) ~= nil, + "guarded, so the odd GPU without fragment highp still compiles and " + .. "falls back flat instead of failing loudly") +T.check(plain:find("LOVE_HIGHP_OR_MEDIUMP vec3 vBent", 1, true) ~= nil, + "the world-position varying is qualified like the scene shader's vGrid " + .. "rather than left to the fragment default") +T.check(plain:find("LOVE_HIGHP_OR_MEDIUMP Image depthTex", 1, true) ~= nil, + "and the depth sampler is lifted off lowp, which is eight bits of depth") + -- ------- the lift itself -- -- A pond in a field: four water cells recessed below flat ground. The diff --git a/tests/water_reflect_probe.lua b/tests/water_reflect_probe.lua index f02040f..4dcf36a 100644 --- a/tests/water_reflect_probe.lua +++ b/tests/water_reflect_probe.lua @@ -74,7 +74,8 @@ return function(game) -- hardware requirement the rest of the mode does not already have. say("depth canvas readable: %s", tostring(Voxel3D.depthReadable())) if not Voxel3D.depthReadable() then - say("STOP: no readable depth canvas on this driver (depth24/readable).") + say("STOP: no readable depth canvas on this driver (tried depth24, ") + say(" depth24stencil8, depth32f and depth16).") say(" The water falls back to the flat scene shader, which is") say(" exactly what it looked like before this feature existed.") return