diff --git a/.github/workflows/publish-workshop-build.yml b/.github/workflows/publish-workshop-build.yml new file mode 100644 index 0000000..f20bf66 --- /dev/null +++ b/.github/workflows/publish-workshop-build.yml @@ -0,0 +1,106 @@ +name: Publish Workshop build + +on: + push: + branches: [workshop] + workflow_dispatch: + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate Workshop build first + shell: bash + run: | + set -euo pipefail + test -f metamorph_creative_menu/mod.xml + test -f metamorph_creative_menu/mod_id.txt + test -f metamorph_creative_menu/init.lua + test -f metamorph_creative_menu/workshop.xml + test -f metamorph_creative_menu/workshop_preview_image.png + test ! -d metamorph_creative_menu/NoitaPatcher + if find metamorph_creative_menu -type f -iname '*.dll' | grep -q .; then + echo 'Workshop package contains a DLL' >&2 + exit 1 + fi + if grep -q 'request_no_api_restrictions' metamorph_creative_menu/mod.xml; then + echo 'Workshop mod.xml requests unrestricted API' >&2 + exit 1 + fi + + - name: Package ready-to-upload Workshop mod + shell: bash + run: | + set -euo pipefail + rm -rf dist + mkdir -p dist/metamorph_creative_menu + cp -a metamorph_creative_menu/. dist/metamorph_creative_menu/ + rm -rf \ + dist/metamorph_creative_menu/NoitaPatcher \ + dist/metamorph_creative_menu/tests \ + dist/metamorph_creative_menu/files/qa \ + dist/metamorph_creative_menu/files/diagnostics + rm -f dist/metamorph_creative_menu/README.txt + ( + cd dist + zip -qr ../Metamorph-Creative-Menu-Workshop.zip metamorph_creative_menu + ) + + python3 - <<'PY' + from pathlib import Path + import zipfile + + archive = Path('Metamorph-Creative-Menu-Workshop.zip') + with zipfile.ZipFile(archive) as z: + names = set(z.namelist()) + required = { + 'metamorph_creative_menu/mod.xml', + 'metamorph_creative_menu/mod_id.txt', + 'metamorph_creative_menu/init.lua', + 'metamorph_creative_menu/workshop.xml', + 'metamorph_creative_menu/workshop_preview_image.png', + } + missing = sorted(required - names) + if missing: + raise SystemExit('Missing required files in Workshop ZIP: ' + ', '.join(missing)) + forbidden = [ + n for n in names + if n.lower().endswith('.dll') + or n.startswith('metamorph_creative_menu/NoitaPatcher/') + or n.startswith('metamorph_creative_menu/tests/') + or n.startswith('metamorph_creative_menu/files/qa/') + or n.startswith('metamorph_creative_menu/files/diagnostics/') + ] + if forbidden: + raise SystemExit('Forbidden files in Workshop ZIP: ' + ', '.join(sorted(forbidden))) + print(f'Workshop ZIP: PASS bytes={archive.stat().st_size}') + PY + + - name: Publish stable Workshop download + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + TAG=workshop-latest + ASSET=Metamorph-Creative-Menu-Workshop.zip + + git tag -f "$TAG" "$GITHUB_SHA" + git push origin "refs/tags/$TAG" --force + + if gh release view "$TAG" >/dev/null 2>&1; then + gh release edit "$TAG" \ + --title "Latest Steam Workshop build" \ + --notes "Ready-to-upload Steam Workshop edition. This build intentionally excludes bundled NoitaPatcher/DLL and unrestricted API access." + else + gh release create "$TAG" \ + --title "Latest Steam Workshop build" \ + --notes "Ready-to-upload Steam Workshop edition. This build intentionally excludes bundled NoitaPatcher/DLL and unrestricted API access." + fi + + gh release upload "$TAG" "$ASSET" --clobber diff --git a/.github/workflows/replace-workshop-preview.yml b/.github/workflows/replace-workshop-preview.yml new file mode 100644 index 0000000..968de19 --- /dev/null +++ b/.github/workflows/replace-workshop-preview.yml @@ -0,0 +1,70 @@ +name: Replace Workshop preview + +on: + push: + branches: [workshop] + +permissions: + contents: write + +jobs: + replace-preview: + if: github.event.head_commit.message == 'Stage corrected Workshop preview' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Build the correct Workshop preview from the accepted MCM banner + shell: bash + run: | + set -euo pipefail + python3 -m pip install --quiet pillow + python3 - <<'PY' + from PIL import Image + from pathlib import Path + + src = Path('assets/metamorph-creative-menu-banner.jpg') + out = Path('metamorph_creative_menu/workshop_preview_image.png') + + img = Image.open(src).convert('RGB') + w, h = img.size + + # The accepted Social Preview artwork is the centered 2:1 crop of the + # repository banner. Preserve that composition, then pad to Noita's + # 16:9 Workshop preview without stretching the title artwork. + target_ratio = 2.0 + if w / h > target_ratio: + crop_w = int(round(h * target_ratio)) + left = (w - crop_w) // 2 + img = img.crop((left, 0, left + crop_w, h)) + else: + crop_h = int(round(w / target_ratio)) + top = (h - crop_h) // 2 + img = img.crop((0, top, w, top + crop_h)) + + img = img.resize((1280, 640), Image.Resampling.LANCZOS) + canvas = Image.new('RGB', (1280, 720), (0, 0, 0)) + canvas.paste(img, (0, 40)) + + # Indexed PNG keeps the Workshop preview compact while preserving the artwork. + preview = canvas.quantize( + colors=256, + method=Image.Quantize.MEDIANCUT, + dither=Image.Dither.FLOYDSTEINBERG, + ) + preview.save(out, 'PNG', optimize=True, compress_level=9) + print(f'preview={out} size={out.stat().st_size} bytes') + PY + + - name: Commit corrected preview and remove one-shot workflow + shell: bash + run: | + set -euo pipefail + rm -f .github/workflows/replace-workshop-preview.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add metamorph_creative_menu/workshop_preview_image.png .github/workflows/replace-workshop-preview.yml + git commit -m "Use correct MCM artwork for Workshop preview" + git push origin HEAD:workshop diff --git a/.github/workflows/workshop-validation.yml b/.github/workflows/workshop-validation.yml new file mode 100644 index 0000000..3444a3d --- /dev/null +++ b/.github/workflows/workshop-validation.yml @@ -0,0 +1,243 @@ +name: Validate Workshop build + +on: + push: + branches: [workshop] + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate Workshop metadata and package rules + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + import re + import struct + import xml.etree.ElementTree as ET + + root = Path('metamorph_creative_menu') + required = [ + root / 'mod.xml', + root / 'mod_id.txt', + root / 'init.lua', + root / 'settings.lua', + root / 'translations.csv', + root / 'workshop.xml', + root / 'workshop_preview_image.png', + root / 'dev_mode.lua', + ] + missing = [p.relative_to(root).as_posix() for p in required if not p.is_file()] + if missing: + raise SystemExit('Missing Workshop files: ' + ', '.join(missing)) + + if (root / 'NoitaPatcher').exists(): + raise SystemExit('Workshop branch must not contain NoitaPatcher/') + dlls = [p.relative_to(root).as_posix() for p in root.rglob('*.dll')] + if dlls: + raise SystemExit('Native DLLs are not allowed in Workshop build: ' + ', '.join(dlls)) + + mod_id = (root / 'mod_id.txt').read_text(encoding='utf-8').strip() + if mod_id != 'metamorph_creative_menu': + raise SystemExit(f'Unexpected mod_id.txt value: {mod_id!r}') + + dev_text = (root / 'dev_mode.lua').read_text(encoding='utf-8') + if not re.search(r'(?m)^\s*dev_mode\s*=\s*0\s*$', dev_text): + raise SystemExit('Workshop build must ship with dev_mode = 0') + + mod_root = ET.parse(root / 'mod.xml').getroot() + if mod_root.tag != 'Mod': + raise SystemExit('mod.xml root must be ') + if mod_root.attrib.get('name') != 'Metamorph: Creative Menu': + raise SystemExit('Unexpected mod.xml name') + if 'request_no_api_restrictions' in mod_root.attrib: + raise SystemExit('Workshop mod.xml must not request unrestricted API') + if mod_root.attrib.get('is_game_mode', '0') not in {'0', 'false'}: + raise SystemExit('Workshop build must not be a game mode') + + workshop_root = ET.parse(root / 'workshop.xml').getroot() + if workshop_root.tag != 'Mod': + raise SystemExit('workshop.xml root must be ') + if workshop_root.attrib.get('name') != 'Metamorph: Creative Menu': + raise SystemExit('Unexpected workshop.xml name') + if workshop_root.attrib.get('description') != '': + raise SystemExit('workshop.xml description must remain empty so Steam page text is not overwritten') + + documented_tags = { + 'gameplay','graphics','quality of life','translations','perks','spells', + 'player characters','loadouts','biomes','total conversions','game modes', + 'creatures','bosses','alchemy','tweaks','items','audio','cheats','funny', + 'streaming integration','mod dependencies', + } + tags = [x.strip() for x in workshop_root.attrib.get('tags', '').split(',') if x.strip()] + unknown_tags = sorted(set(tags) - documented_tags) + if unknown_tags: + raise SystemExit('Unknown Workshop tags: ' + ', '.join(unknown_tags)) + if 'gameplay' not in tags: + raise SystemExit('Workshop tags must include gameplay') + + def split_pipe(value): + return [x.strip().replace('\\', '/') for x in value.split('|') if x.strip()] + + excluded_dirs = set(split_pipe(workshop_root.attrib.get('dont_upload_folders', ''))) + excluded_files = set(split_pipe(workshop_root.attrib.get('dont_upload_files', ''))) + required_excluded_dirs = {'NoitaPatcher', 'tests', 'files/qa', 'files/diagnostics'} + missing_exclusions = sorted(required_excluded_dirs - excluded_dirs) + if missing_exclusions: + raise SystemExit('Missing Workshop folder exclusions: ' + ', '.join(missing_exclusions)) + if 'README.txt' not in excluded_files: + raise SystemExit('README.txt must be excluded from Workshop upload') + + preview = root / 'workshop_preview_image.png' + data = preview.read_bytes() + if len(data) < 24 or data[:8] != b'\x89PNG\r\n\x1a\n' or data[12:16] != b'IHDR': + raise SystemExit('workshop_preview_image.png is not a valid PNG with IHDR header') + width, height = struct.unpack('>II', data[16:24]) + if (width, height) != (1280, 720): + raise SystemExit(f'Workshop preview must be 1280x720, got {width}x{height}') + if width * 9 != height * 16: + raise SystemExit('Workshop preview must be exactly 16:9') + if preview.stat().st_size >= 1_000_000: + raise SystemExit('Workshop preview should stay below 1 MB') + + allowed = {'.txt','.csv','.xml','.json','.bmp','.png','.lua','.frag','.vert','.bank','.bin','.plz'} + bad = [] + uploaded = [] + symlinks = [] + for p in root.rglob('*'): + if not p.is_file(): + continue + rel = p.relative_to(root) + rel_s = rel.as_posix() + if p.is_symlink(): + symlinks.append(rel_s) + continue + if any(rel_s == d or rel_s.startswith(d + '/') for d in excluded_dirs): + continue + if rel_s in excluded_files: + continue + uploaded.append(rel_s) + if p.suffix.lower() not in allowed: + bad.append(rel_s) + if symlinks: + raise SystemExit('Symlinks are not allowed in Workshop package: ' + ', '.join(symlinks)) + if bad: + raise SystemExit('Unsupported Workshop file types: ' + ', '.join(bad)) + for essential in ['mod.xml','mod_id.txt','init.lua','settings.lua','translations.csv','workshop.xml','workshop_preview_image.png']: + if essential not in uploaded: + raise SystemExit('Essential file excluded from upload: ' + essential) + + if (root / 'workshop_id.txt').exists(): + print('Workshop metadata: existing workshop_id.txt detected (update mode)') + else: + print('Workshop metadata: no workshop_id.txt yet (first-publication mode)') + print(f'Workshop metadata/package: PASS uploaded_files={len(uploaded)} preview={width}x{height} preview_bytes={preview.stat().st_size}') + PY + + - name: Check unrestricted Lua API usage + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + import re + + root = Path('metamorph_creative_menu') + candidates = list((root / 'files').rglob('*.lua')) + [root / 'init.lua', root / 'settings.lua'] + pattern = re.compile(r'(?/dev/null + + - name: Run Workshop regression suite + working-directory: metamorph_creative_menu + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + import shutil + import subprocess + import sys + + root = Path('.').resolve() + texlua = shutil.which('texlua') + if not texlua: + raise SystemExit('texlua not found') + + # This contract intentionally belongs only to the full standalone build. + # Workshop must omit the bundled NoitaPatcher DLL, so only this one contract + # is excluded; all other discovered contracts and every behavioral Lua mock run. + excluded_contracts = {'standalone_patcher_contract.py'} + preferred = [ + 'syntax_contract.py', + 'architecture_contract.py', + 'behavior_coverage_contract.py', + 'localization_contract.py', + 'qa_phase_contract.py', + ] + + contracts = { + p.name: p for p in (root / 'tests').glob('*_contract.py') + if p.name not in excluded_contracts + } + ordered = [] + for name in preferred: + path = contracts.pop(name, None) + if path is not None: + ordered.append(path) + ordered.extend(contracts[name] for name in sorted(contracts)) + mocks = sorted((root / 'tests').glob('*_mock.lua'), key=lambda p: p.name) + + commands = [[sys.executable, str(p), str(root)] for p in ordered] + commands.extend([[texlua, str(p), str(root)] for p in mocks]) + for command in commands: + print('+', ' '.join(command), flush=True) + subprocess.run(command, check=True) + + print( + 'WORKSHOP_REGRESSION_TESTS=PASS ' + f'count={len(commands)} contracts={len(ordered)} behavioral_mocks={len(mocks)} ' + 'excluded=standalone_patcher_contract.py' + ) + PY diff --git a/STEAM_WORKSHOP.md b/STEAM_WORKSHOP.md new file mode 100644 index 0000000..654958b --- /dev/null +++ b/STEAM_WORKSHOP.md @@ -0,0 +1,70 @@ +# Steam Workshop release guide + +This branch is the Steam Workshop edition of **Metamorph: Creative Menu**. + +The full standalone build remains on `main` and includes the bundled NoitaPatcher runtime. The Workshop edition intentionally does **not** request unrestricted API access and does **not** contain the bundled native NoitaPatcher DLL, because Noita Workshop does not support mods that require `request_no_api_restrictions="1"`, and the Workshop uploader only accepts its documented file types. + +## Files prepared for Workshop + +Inside `metamorph_creative_menu/`: + +- `mod.xml` — Workshop-safe metadata; no `request_no_api_restrictions`. +- `workshop.xml` — Workshop title, tags and upload exclusions. +- `workshop_preview_image.png` — 1280×720, 16:9 preview image. +- `NoitaPatcher/` — intentionally absent from this branch. +- `tests/`, `files/qa/`, `files/diagnostics/` and `README.txt` remain available in the repository where applicable for development/review, but are excluded by `workshop.xml` from the Steam upload. +- Release runtime ships with `dev_mode = 0`. + +The Workshop validation workflow parses both XML files, checks the mod ID and release dev-mode flag, verifies that no DLL or unrestricted-API request is present, validates the actual PNG header/dimensions/size, simulates the upload exclusions and extension whitelist, scans the uploaded runtime for unrestricted Lua API usage, and runs the Workshop regression suite. + +## First publication + +1. Check out/download the `workshop` branch. +2. If a full standalone MCM copy is already installed locally, **delete the old `Noita/mods/metamorph_creative_menu` folder first**. Do not copy the Workshop build over it: otherwise a stale `NoitaPatcher/noitapatcher.dll` can remain on disk and invalidate the Workshop-only test. +3. Copy the complete `metamorph_creative_menu` folder from the `workshop` branch into `Noita/mods/`. +4. Start Noita once and test the Workshop build with **Unsafe mods disabled**. +5. Verify the menu opens with **TAB**. Smoke-test an item spawn, a wand/spell edit, perk apply/remove, weather, a supported World Rule, a normal transformation and **TAB return**. +6. Do not use transformed-form death as a Workshop acceptance test: the hard serialized death handoff is a NoitaPatcher-powered standalone feature and is not guaranteed in this build. +7. Close Noita. +8. From the Noita installation directory run `workshop_upload.bat`, or run `noita_dev.exe -workshop_upload`. +9. Select `metamorph_creative_menu` in the uploader and create the Workshop item. +10. After the first upload, keep the generated `workshop_id.txt`. It identifies the existing Workshop item for future updates. Do not replace it with another item's ID. +11. Open the new Steam Workshop page, add the full description/screenshots, then set visibility to Public when ready. + +## Suggested Workshop description + +**Metamorph: Creative Menu (MCM)** is a creative toolkit for Noita: edit wands and spells, spawn items and liquids, manage perks/effects, transform into supported creatures, possess existing creatures, control weather, use reversible World Rules and spawn a PLAYER-style companion. + +### Steam Workshop edition + +This Workshop build is intentionally compatible with Noita's Workshop restrictions. It does not bundle the native NoitaPatcher DLL and does not request unrestricted API access. + +Most normal menu/editor functionality remains available, but some advanced recovery, player-authority, exact entity serialization and other NoitaPatcher-powered paths are only available in the **Full Standalone Version**. Rules that require an unavailable native capability are presented as unsupported instead of pretending to work. + +**Full Standalone Version:** +https://github.com/zerodancing/Metamorph-Creative-Menu/releases/tag/latest-build + +**Source / Issues:** +https://github.com/zerodancing/Metamorph-Creative-Menu + +### Controls + +- **TAB** — open/close Creative Menu. +- **TAB while transformed** — request return to human form through the normal native polymorph path. +- **G** by default — possess a supported creature under the cursor. +- LMB/RMB actions are shown in the menu for each catalog entry. + +### Entangled Worlds + +Entangled Worlds integration is experimental. For multiplayer, use the same MCM build on every peer and a compatible EW setup. If another enabled environment exposes a compatible NoitaPatcher bridge, MCM can reuse capabilities that are actually present, but the Workshop package itself does not include or require that native provider. + +## Updating the Workshop item + +For updates, work from the `workshop` branch, preserve the Workshop item's `workshop_id.txt`, replace the local Noita mod folder with the updated Workshop folder and run the uploader again. Keep `description=""` in `workshop.xml` if you want Steam's manually edited Workshop description to remain untouched by uploads. + +## Branch policy + +- `main` = full standalone GitHub build. +- `workshop` = Steam Workshop-safe build. +- Do not merge the Workshop removal of NoitaPatcher back into `main`. +- When syncing future gameplay changes from `main` into `workshop`, re-check `mod.xml`, `workshop.xml`, the absence of `NoitaPatcher/`, `dev_mode = 0`, and test with Unsafe mods disabled before uploading. diff --git a/metamorph_creative_menu/NoitaPatcher/load.lua b/metamorph_creative_menu/NoitaPatcher/load.lua deleted file mode 100644 index 5456432..0000000 --- a/metamorph_creative_menu/NoitaPatcher/load.lua +++ /dev/null @@ -1,22 +0,0 @@ --- You're supposed to `dofile_once("path/to/load.lua")` this file. - -local orig_do_mod_appends = do_mod_appends - -do_mod_appends = function(filename, ...) - do_mod_appends = orig_do_mod_appends - do_mod_appends(filename, ...) - - local noitapatcher_path = string.match(filename, "(.*)/load.lua") - if not noitapatcher_path then - print("Couldn't detect NoitaPatcher path") - end - - __nsew_path = noitapatcher_path .. "/noitapatcher/nsew/" - - package.cpath = package.cpath .. ";./" .. noitapatcher_path .. "/?.dll" - package.path = package.path .. ";./" .. noitapatcher_path .. "/?.lua" - - -- Lua's loader should now be setup properly: - -- local np = require("noitapatcher") - -- local nsew = require("noitapatcher.nsew") -end diff --git a/metamorph_creative_menu/NoitaPatcher/noitapatcher.dll b/metamorph_creative_menu/NoitaPatcher/noitapatcher.dll deleted file mode 100644 index 8b44f4c..0000000 Binary files a/metamorph_creative_menu/NoitaPatcher/noitapatcher.dll and /dev/null differ diff --git a/metamorph_creative_menu/mod.xml b/metamorph_creative_menu/mod.xml index 78a53ba..f1d6b3b 100644 --- a/metamorph_creative_menu/mod.xml +++ b/metamorph_creative_menu/mod.xml @@ -1,7 +1,6 @@ diff --git a/metamorph_creative_menu/workshop.xml b/metamorph_creative_menu/workshop.xml new file mode 100644 index 0000000..cf832b6 --- /dev/null +++ b/metamorph_creative_menu/workshop.xml @@ -0,0 +1,8 @@ + + diff --git a/metamorph_creative_menu/workshop_preview_image.png b/metamorph_creative_menu/workshop_preview_image.png new file mode 100644 index 0000000..28f5e91 Binary files /dev/null and b/metamorph_creative_menu/workshop_preview_image.png differ