diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index ec64267..0000000 --- a/.gitattributes +++ /dev/null @@ -1,30 +0,0 @@ -* text=auto - -*.lua text eol=lf -*.py text eol=lf -*.md text eol=lf -*.txt text eol=lf -*.xml text eol=lf -*.yml text eol=lf -*.yaml text eol=lf -*.csv text eol=lf -*.c text eol=lf -*.h text eol=lf -*.patch text eol=lf -*.sh text eol=lf -*.ps1 text eol=crlf -*.bat text eol=crlf -*.cmd text eol=crlf - -*.dll binary -*.lib binary -*.obj binary -*.pdb binary -*.exp binary -*.ilk binary -*.zip binary -*.png binary -*.jpg binary -*.jpeg binary -*.gif binary -*.webp binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 6f74e01..0000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @zerodancing diff --git a/.github/workflows/check-readmes.yml b/.github/workflows/check-readmes.yml deleted file mode 100644 index 5e47a5a..0000000 --- a/.github/workflows/check-readmes.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Check multilingual READMEs - -on: - push: - branches: [main] - paths: - - 'README*.md' - - 'tools/check_readme_consistency.py' - - '.github/workflows/check-readmes.yml' - pull_request: - paths: - - 'README*.md' - - 'tools/check_readme_consistency.py' - - '.github/workflows/check-readmes.yml' - workflow_dispatch: - -permissions: - contents: read - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - name: Check README set - run: python tools/check_readme_consistency.py . diff --git a/.github/workflows/check-repository-tools.yml b/.github/workflows/check-repository-tools.yml deleted file mode 100644 index 718a8eb..0000000 --- a/.github/workflows/check-repository-tools.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Check repository tooling - -on: - push: - branches: [main] - paths: - - 'tools/*.py' - - '.github/workflows/check-repository-tools.yml' - pull_request: - paths: - - 'tools/*.py' - - '.github/workflows/check-repository-tools.yml' - workflow_dispatch: - -permissions: - contents: read - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - name: Compile repository Python tools - run: python -m compileall -q tools - - name: Run tooling contract tests - run: python tools/test_repository_tools.py diff --git a/.github/workflows/check-source.yml b/.github/workflows/check-source.yml deleted file mode 100644 index b1c02ab..0000000 --- a/.github/workflows/check-source.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Check full source - -on: - push: - branches: [main] - paths: - - 'metamorph_creative_menu/**' - - 'tools/check_source_hygiene.py' - - '.github/workflows/check-source.yml' - pull_request: - paths: - - 'metamorph_creative_menu/**' - - 'tools/check_source_hygiene.py' - - '.github/workflows/check-source.yml' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: check-full-source-${{ github.ref }} - cancel-in-progress: true - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Install Lua test runtime - shell: bash - run: | - if ! command -v texlua >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y texlive-binaries - fi - - - name: Check source hygiene - run: python tools/check_source_hygiene.py . - - - name: Run regression suite - run: python metamorph_creative_menu/tests/run_all.py metamorph_creative_menu - - - name: Verify deterministic source archive build - run: python metamorph_creative_menu/tools/build_release.py /tmp/Metamorph-Creative-Menu-source-check.zip diff --git a/.github/workflows/import-mod-archive.yml b/.github/workflows/import-mod-archive.yml deleted file mode 100644 index 3e4497a..0000000 --- a/.github/workflows/import-mod-archive.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: Import full MCM source archive - -on: - push: - paths: - - 'Metamorph-Creative-Menu-v*.zip' - - 'Metamorph-Creative-Menu-v*.ZIP' - - 'tools/import_source_archive.py' - - 'tools/test_repository_tools.py' - - '.github/workflows/import-mod-archive.yml' - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: import-mcm-source - cancel-in-progress: false - -jobs: - import: - runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Check repository tooling - run: | - python -m compileall -q tools - python tools/test_repository_tools.py - - - name: Import source archive - id: import - run: python tools/import_source_archive.py - - - name: Install Lua test runtime - if: steps.import.outputs.changed == 'true' - shell: bash - run: | - if ! command -v texlua >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y texlive-binaries - fi - - - name: Check source hygiene - if: steps.import.outputs.changed == 'true' - run: python tools/check_source_hygiene.py . - - - name: Run regression suite - if: steps.import.outputs.changed == 'true' - run: python metamorph_creative_menu/tests/run_all.py metamorph_creative_menu - - - name: Verify full source archive build - if: steps.import.outputs.changed == 'true' - run: python metamorph_creative_menu/tools/build_release.py /tmp/Metamorph-Creative-Menu-source-check.zip - - - name: Verify player package build - if: steps.import.outputs.changed == 'true' - run: python tools/build_user_release.py metamorph_creative_menu /tmp/Metamorph-Creative-Menu-player-check.zip - - - name: Validate player package - if: steps.import.outputs.changed == 'true' - run: python tools/validate_user_release.py /tmp/Metamorph-Creative-Menu-player-check.zip - - - name: Commit imported source - if: steps.import.outputs.changed == 'true' - shell: bash - run: | - git config user.name "zerodancing" - git config user.email "194230808+zerodancing@users.noreply.github.com" - git add -A - if git diff --cached --quiet; then - echo "Source archive already matches the repository." - exit 0 - fi - git commit -m "Import Metamorph Creative Menu source ${{ steps.import.outputs.version }}" - git push - - - name: Remove rejected source archive - if: failure() - shell: bash - run: | - mapfile -t archives < <( - git ls-tree -r --name-only HEAD \ - | grep -Ei '^Metamorph-Creative-Menu-v.*\.zip$' \ - || true - ) - if [ "${#archives[@]}" -eq 0 ]; then - echo "No tracked source archive needs cleanup." - exit 0 - fi - - git restore --source=HEAD --staged --worktree -- metamorph_creative_menu - for archive in "${archives[@]}"; do - rm -f -- "$archive" - done - git add -u -- . - - if git diff --cached --quiet; then - echo "No rejected archive cleanup was needed." - exit 0 - fi - - git config user.name "zerodancing" - git config user.email "194230808+zerodancing@users.noreply.github.com" - git commit -m "Remove rejected Metamorph Creative Menu source archive" - git push diff --git a/.github/workflows/publish-ready-build.yml b/.github/workflows/publish-ready-build.yml deleted file mode 100644 index 31538de..0000000 --- a/.github/workflows/publish-ready-build.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: Publish ready-to-install build - -on: - push: - branches: [main] - paths: - - 'metamorph_creative_menu/**' - - '!metamorph_creative_menu/tests/**' - - '!metamorph_creative_menu/files/qa/**' - - '!metamorph_creative_menu/files/diagnostics/**' - - '!metamorph_creative_menu/native_src/**' - - '!metamorph_creative_menu/tools/**' - - '!metamorph_creative_menu/README.txt' - - '!metamorph_creative_menu/BUILD.txt' - - '!metamorph_creative_menu/CHANGELOG.txt' - - '!metamorph_creative_menu/dev_mode.lua' - - '!metamorph_creative_menu/mod_id.txt' - - '!metamorph_creative_menu/mcm_native_gameover.lib' - - '!metamorph_creative_menu/files/core/bounded_log.lua' - - '!metamorph_creative_menu/files/features/creatures/diagnostics.lua' - - '!metamorph_creative_menu/files/integrations/ew/bridge/qa.lua' - - '!metamorph_creative_menu/files/integrations/ew/bridge/qa_reserved.lua' - - 'metamorph_creative_menu/tools/player_release/*.patch' - - 'tools/build_user_release.py' - - 'tools/validate_user_release.py' - - '.github/workflows/publish-ready-build.yml' - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: publish-ready-build - cancel-in-progress: true - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Check repository tooling - run: | - python -m compileall -q tools - python tools/test_repository_tools.py - - - name: Install Lua test runtime - shell: bash - run: | - if ! command -v texlua >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y texlive-binaries - fi - - - name: Check source hygiene - run: python tools/check_source_hygiene.py . - - - name: Run regression suite - run: python metamorph_creative_menu/tests/run_all.py metamorph_creative_menu - - - name: Build player package - run: python tools/build_user_release.py metamorph_creative_menu Metamorph-Creative-Menu.zip - - - name: Validate player package - run: python tools/validate_user_release.py Metamorph-Creative-Menu.zip - - - name: Publish stable download URL - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - version="$(tr -d '\r\n ' < metamorph_creative_menu/VERSION.txt)" - notes="Ready-to-install Metamorph: Creative Menu ${version}. Source commit: ${GITHUB_SHA}. Development and QA files are excluded from this archive." - - if gh release view latest-build >/dev/null 2>&1; then - gh release upload latest-build Metamorph-Creative-Menu.zip --clobber - gh release edit latest-build --title "Metamorph: Creative Menu ${version}" --notes "$notes" - else - gh release create latest-build Metamorph-Creative-Menu.zip --target "$GITHUB_SHA" --title "Metamorph: Creative Menu ${version}" --notes "$notes" - fi diff --git a/.github/workflows/workshop-validation.yml b/.github/workflows/workshop-validation.yml new file mode 100644 index 0000000..acd31e6 --- /dev/null +++ b/.github/workflows/workshop-validation.yml @@ -0,0 +1,366 @@ +name: Workshop build + +on: + push: + branches: [workshop] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: workshop-build + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Validate exact Workshop payload + 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_source = [ + 'mod.xml', + 'mod_id.txt', + 'init.lua', + 'settings.lua', + 'translations.csv', + 'workshop.xml', + 'workshop_preview_image.png', + 'README.txt', + ] + missing = [name for name in required_source if not (root / name).is_file()] + if missing: + raise SystemExit('Missing Workshop source files: ' + ', '.join(missing)) + + if (root / 'README.txt').read_text(encoding='utf-8') != 'Creator: zerodancing\n': + raise SystemExit("Workshop source README.txt must contain only 'Creator: zerodancing'") + + if (root / 'NoitaPatcher').exists(): + raise SystemExit('Workshop branch must not contain NoitaPatcher/') + dlls = sorted(p.relative_to(root).as_posix() for p in root.rglob('*.dll')) + if dlls: + raise SystemExit('Native DLLs are not allowed in Workshop branch: ' + ', '.join(dlls)) + + if (root / 'mod_id.txt').read_text(encoding='utf-8').strip() != 'metamorph_creative_menu': + raise SystemExit('Unexpected mod_id.txt value') + + mod_root = ET.parse(root / 'mod.xml').getroot() + if mod_root.tag != 'Mod' or mod_root.attrib.get('name') != 'Metamorph: Creative Menu': + raise SystemExit('Unexpected mod.xml metadata') + 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 = ET.parse(root / 'workshop.xml').getroot() + if workshop.tag != 'Mod' or workshop.attrib.get('name') != 'Metamorph: Creative Menu': + raise SystemExit('Unexpected workshop.xml metadata') + if workshop.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.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.attrib.get('dont_upload_folders', ''))) + excluded_files = set(split_pipe(workshop.attrib.get('dont_upload_files', ''))) + required_excluded_dirs = {'NoitaPatcher', 'tests', 'files/qa', 'files/diagnostics'} + required_excluded_files = { + 'README.txt', + 'dev_mode.lua', + 'files/core/bounded_log.lua', + 'files/features/creatures/diagnostics.lua', + 'files/integrations/ew/bridge/qa.lua', + 'files/integrations/ew/bridge/qa_reserved.lua', + } + missing_dir_exclusions = sorted(required_excluded_dirs - excluded_dirs) + missing_file_exclusions = sorted(required_excluded_files - excluded_files) + if missing_dir_exclusions: + raise SystemExit('Missing Workshop folder exclusions: ' + ', '.join(missing_dir_exclusions)) + if missing_file_exclusions: + raise SystemExit('Missing Workshop file exclusions: ' + ', '.join(missing_file_exclusions)) + + 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) or width * 9 != height * 16: + raise SystemExit(f'Workshop preview must be exactly 1280x720 (16:9), got {width}x{height}') + if preview.stat().st_size >= 1_000_000: + raise SystemExit('Workshop preview should stay below 1 MB') + + def excluded(relative): + name = relative.as_posix() + return name in excluded_files or any(name == d or name.startswith(d + '/') for d in excluded_dirs) + + allowed_suffixes = {'.txt','.csv','.xml','.json','.bmp','.png','.lua','.frag','.vert','.bank','.bin','.plz'} + uploaded = [] + bad_types = [] + symlinks = [] + for path in sorted(root.rglob('*')): + if not path.is_file(): + continue + relative = path.relative_to(root) + if path.is_symlink(): + symlinks.append(relative.as_posix()) + continue + if excluded(relative): + continue + uploaded.append(relative.as_posix()) + if path.suffix.lower() not in allowed_suffixes: + bad_types.append(relative.as_posix()) + + if symlinks: + raise SystemExit('Symlinks are not allowed in Workshop payload: ' + ', '.join(symlinks)) + if bad_types: + raise SystemExit('Unsupported Workshop file types: ' + ', '.join(bad_types)) + + uploaded_set = set(uploaded) + essential = {'mod.xml','mod_id.txt','init.lua','settings.lua','translations.csv','workshop.xml','workshop_preview_image.png'} + missing_essential = sorted(essential - uploaded_set) + if missing_essential: + raise SystemExit('Essential files excluded from Workshop upload: ' + ', '.join(missing_essential)) + + forbidden_upload = sorted( + name for name in uploaded + if name in required_excluded_files + or name.lower().endswith('.dll') + or name.startswith('NoitaPatcher/') + or name.startswith('tests/') + or name.startswith('files/qa/') + or name.startswith('files/diagnostics/') + ) + if forbidden_upload: + raise SystemExit('Developer/native files leaked into Workshop payload: ' + ', '.join(forbidden_upload)) + + strong_rules = [ + ('numbered test chronology', re.compile(r'\bTEST\s+\d+\b', re.I)), + ('test-only production API', re.compile(r'\b[A-Za-z0-9_]+_for_test\b')), + ('conversation-specific wording', re.compile(r"\b(?:the\s+)?user['’]s\s+build\b|\bat\s+the\s+user['’]s\s+request\b", re.I)), + ('numbered runtime label', re.compile(r'\[MCM\s*(?:TEST\s*)?\d{2,}\]', re.I)), + ('AI attribution residue', re.compile(r'\b(?:ChatGPT|OpenAI|Claude|Gemini|Copilot|LLM|AI[- ]generated|generated\s+by\s+AI)\b', re.I)), + ('debug-named runtime API', re.compile(r'\b(?:debug_[A-Za-z0-9_]+|[A-Za-z0-9_]+_debug|[A-Za-z0-9_]+\.debug(?:_[A-Za-z0-9_]+)?)\b')), + ('development mode wiring', re.compile(r'\b(?:dev_mode|DEV_MODE|METAMORPH_CREATIVE_MENU_DEV_MODE)\b')), + ('diagnostics runtime hook', re.compile(r'METAMORPH_CREATIVE_MENU_DIAGNOSTICS|\bdiagnostic_event\b')), + ('UI action audit hook', re.compile(r'\bui\.audit\b|\blocal\s+audit\s*=\s*ui\.audit\b')), + ] + residue = [] + for name in uploaded: + if not name.endswith('.lua'): + continue + text = (root / name).read_text(encoding='utf-8', errors='replace') + for lineno, line in enumerate(text.splitlines(), 1): + for label, pattern in strong_rules: + if pattern.search(line): + residue.append(f'{name}:{lineno}: {label}: {line.strip()}') + if residue: + raise SystemExit('Workshop runtime residue detected:\n' + '\n'.join(residue)) + + unsafe_api = re.compile(r'(? {dependency}') + if missing_refs: + raise SystemExit('Workshop payload references missing local files:\n' + '\n'.join(sorted(set(missing_refs)))) + + mode = 'update' if (root / 'workshop_id.txt').exists() else 'first-publication' + print( + f'Workshop payload: PASS mode={mode} files={len(uploaded)} ' + f'preview={width}x{height} preview_bytes={preview.stat().st_size}' + ) + PY + + - name: Install texlua + shell: bash + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends texlive-binaries >/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') + + 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(path), str(root)] for path in ordered] + commands.extend([[texlua, str(path), str(root)] for path 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 + + publish: + needs: validate + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + + - name: Package exact Workshop upload set + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo + import xml.etree.ElementTree as ET + + root = Path('metamorph_creative_menu') + archive_path = Path('Metamorph-Creative-Menu-Workshop.zip') + workshop = ET.parse(root / 'workshop.xml').getroot() + + def split_pipe(value): + return [x.strip().replace('\\', '/') for x in value.split('|') if x.strip()] + + excluded_dirs = set(split_pipe(workshop.attrib.get('dont_upload_folders', ''))) + excluded_files = set(split_pipe(workshop.attrib.get('dont_upload_files', ''))) + + def excluded(relative): + name = relative.as_posix() + return name in excluded_files or any(name == d or name.startswith(d + '/') for d in excluded_dirs) + + files = [path for path in sorted(root.rglob('*')) if path.is_file() and not excluded(path.relative_to(root))] + with ZipFile(archive_path, 'w', compression=ZIP_DEFLATED, compresslevel=9) as archive: + for path in files: + relative = Path('metamorph_creative_menu') / path.relative_to(root) + info = ZipInfo(relative.as_posix(), date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + archive.writestr(info, path.read_bytes()) + + with ZipFile(archive_path) as archive: + names = {name for name in archive.namelist() if not name.endswith('/')} + required = { + 'metamorph_creative_menu/mod.xml', + 'metamorph_creative_menu/mod_id.txt', + 'metamorph_creative_menu/init.lua', + 'metamorph_creative_menu/settings.lua', + 'metamorph_creative_menu/translations.csv', + '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_exact = { + 'metamorph_creative_menu/README.txt', + 'metamorph_creative_menu/dev_mode.lua', + 'metamorph_creative_menu/files/core/bounded_log.lua', + 'metamorph_creative_menu/files/features/creatures/diagnostics.lua', + 'metamorph_creative_menu/files/integrations/ew/bridge/qa.lua', + 'metamorph_creative_menu/files/integrations/ew/bridge/qa_reserved.lua', + } + forbidden = sorted( + name for name in names + if name in forbidden_exact + or name.lower().endswith('.dll') + or name.startswith('metamorph_creative_menu/NoitaPatcher/') + or name.startswith('metamorph_creative_menu/tests/') + or name.startswith('metamorph_creative_menu/files/qa/') + or name.startswith('metamorph_creative_menu/files/diagnostics/') + ) + if forbidden: + raise SystemExit('Forbidden files in Workshop ZIP: ' + ', '.join(forbidden)) + broken = archive.testzip() + if broken is not None: + raise SystemExit('Workshop ZIP CRC failure: ' + broken) + print(f'Workshop ZIP: PASS files={len(files)} bytes={archive_path.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 + notes="Ready-to-upload Steam Workshop edition. Source commit: ${GITHUB_SHA}. This build intentionally excludes bundled NoitaPatcher/DLL and unrestricted API access." + + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" "$ASSET" --clobber + gh release edit "$TAG" --title "Latest Steam Workshop build" --notes "$notes" + else + gh release create "$TAG" "$ASSET" --target "$GITHUB_SHA" --title "Latest Steam Workshop build" --notes "$notes" + fi diff --git a/.gitignore b/.gitignore index 5ca6381..ab6baeb 100644 --- a/.gitignore +++ b/.gitignore @@ -14,12 +14,8 @@ desktop.ini *.bak *~ -# Generated build output -dist/ - # Python test artifacts __pycache__/ -.pytest_cache/ *.py[cod] # Runtime / diagnostic output diff --git a/README.de.md b/README.de.md deleted file mode 100644 index 1bba1c9..0000000 --- a/README.de.md +++ /dev/null @@ -1,586 +0,0 @@ - - -[English](README.md) · [Русский](README.ru.md) · [Português (Brasil)](README.pt-BR.md) · [Español](README.es.md) · [**Deutsch**](README.de.md) · [Français](README.fr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) · [한국어](README.ko.md) - -

Metamorph: Creative Menu

- -

Ein Kreativmenü und Sandbox-Werkzeugpaket für Noita: Zauber, Zauberstäbe, Gegenstände, Materialien, Perks, Effekte, Kreaturen, Verwandlungen, Besitzergreifung, Teleportation, Wetter, Weltregeln, Mehrspieler-Integration und Wiederherstellungswerkzeuge.

- -

Ersteller und Maintainer: zerodancing

- ---- - -# Download - -Für normales Spielen verwende den installationsfertigen Build: - -[**⬇️ Aktuellen Build herunterladen**](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/download/latest-build/Metamorph-Creative-Menu.zip) - -[Seite des aktuellen Builds](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/tag/latest-build) · [Changelog](metamorph_creative_menu/CHANGELOG.txt) - -Der GitHub-Release wird automatisch aus dem vollständigen Entwicklungsbaum erzeugt. Tests, QA-Werkzeuge, Diagnostik, nativer Quellcode und Build-Werkzeuge bleiben im Repository, werden aber aus dem Spielerarchiv entfernt. - -Der eigenständige GitHub-Build enthält NoitaPatcher und native Wiederherstellungsfunktionen. Daher müssen **Unsafe Mods erlaubt** sein. - -# Installation - -1. Lade `Metamorph-Creative-Menu.zip` über den Link oben herunter. -2. Starte Noita und öffne im Hauptmenü **Mods**. -3. Klicke auf **Open mods folder**. -4. Entpacke oder verschiebe den Ordner `metamorph_creative_menu` in den geöffneten `mods`-Ordner. Der endgültige Pfad muss direkt `metamorph_creative_menu/mod.xml` enthalten, ohne zusätzlichen Archiv-Unterordner. -5. Wenn bereits eine ältere Kopie vorhanden ist, ersetze den gesamten Ordner `metamorph_creative_menu`, statt alte und neue Dateien zusammenzuführen. -6. Kehre zu Noita zurück und aktualisiere die Mod-Liste. -7. Erlaube **Unsafe Mods**. -8. Aktiviere **Metamorph: Creative Menu** und starte ein Spiel mit aktiven Mods. - -Aktiviere den eigenständigen GitHub-Build und die Steam-Workshop-Version nicht gleichzeitig. - -# Eigenständiger Build und Steam Workshop - -Der über dieses GitHub-Repository verteilte Build ist der vollständige Standalone-Build. Er enthält NoitaPatcher und Funktionen, die uneingeschränkten Mod-API-Zugriff benötigen, darunter Low-Level-Materialoperationen und native Game-Over-Wiederherstellung. - -Der [Steam-Workshop-Build](https://steamcommunity.com/sharedfiles/filedetails/?id=3785170245) wird separat installiert. Er enthält nicht die nativen Komponenten, die für die nur im Standalone-Build verfügbaren Funktionen benötigt werden. - -Beide Builds verwenden dieselbe Mod-Identität. Eine gleichzeitige Installation kann daher doppelte oder kollidierende Dateien erzeugen und wird nicht unterstützt. - -# Über den Mod - -**Metamorph: Creative Menu (MCM)** ist ein Kreativmenü und Sandbox-Werkzeugpaket für Noita. - -Es bündelt Werkzeuge für: - -- Zauber und Zauberinventare; -- Zauberstab-Bearbeitung und wiederverwendbare Presets; -- Gegenstände und Flüssigkeitsbehälter; -- den vollständigen Materialkatalog und Materialmalerei; -- Perks und unterstütztes Entfernen von Perks; -- Status-Effekte und GameEffect-Entitäten; -- Kreaturen, Verwandlungen und Besitzergreifung; -- Wetter und Zeit; -- globale Weltregeln; -- Teleportation; -- optionale Entangled-Worlds-Integration; -- Wiederherstellung nach Verwandlung, Formtod und Game Over. - -MCM versucht, auf echtem Noita-Zustand zu arbeiten, statt alles durch dekorative Kopien zu ersetzen. Vorhandene Zauberkarten werden als Entitäten bewegt, Gegenstandsübergaben beachten die Inventarstruktur, Zauberstabänderungen nutzen Commit-/Rollback-Pfade, Materialien bleiben echte simulierte Materialien, und reversible Weltregeln bewahren genügend Ursprungszustand, um unterstützte Einstellungen später wiederherzustellen. - -Entangled Worlds ist optional. Ohne EW bleibt MCM ein vollständiger Einzelspieler-Mod. - -# Steuerung - -Standardbelegung: - -| Aktion | Standard | -| --- | --- | -| Kreativmenü öffnen / schließen | **F4** | -| Während einer Verwandlung zur Menschenform zurückkehren | **TAB** | -| Eine Kreatur in der Welt übernehmen | **G** | -| Mit dem ausgewählten Material malen | **Mittlere Maustaste** | - -Das Kreativfenster ist auch über die normale Noita-Inventaroberfläche erreichbar. - -Belegungen können im MCM-Bereich **CONTROLS** und in Noitas Mod-Einstellungen geändert werden. Tastaturtasten, Maustasten und exakte **CTRL / SHIFT / ALT**-Kombinationen werden unterstützt. - -Während der Belegung: - -- **DELETE / BACKSPACE** löscht die Belegung; -- **ESC** bricht ab; -- **R** stellt die Standardbelegung dieser Aktion wieder her; -- **RESET ALL** stellt nach Bestätigung alle Standardbelegungen wieder her. - -Doppelte Belegungen bleiben editierbar, MCM zeigt den Konflikt jedoch an, statt eine andere Aktion still zu überschreiben. - -Menünavigation, Bereiche, Rückkehr aus einer Form, Besitzergreifung, Materialmalerei, Effektbereinigung, Wetterfreigabe, Zurücksetzen von Weltregeln und unterstützte Mehrspieleraktionen können neu belegt werden. - -# Creative-Menu-Fenster - -Das direkte Kreativmenü ist ein persistentes, skalierbares Fenster und kein festes Debug-Overlay. - -Es kann: - -- über den Titel verschoben werden; -- an Kanten und Ecken skaliert werden; -- minimiert werden; -- geschlossen werden; -- auf das Standardlayout zurückgesetzt werden. - -Position, Breite, Höhe und der zuletzt geöffnete Bereich werden zwischen Sitzungen gespeichert. Nach Auflösungsänderungen wird die gespeicherte Geometrie wieder in den sichtbaren GUI-Bereich geklemmt. - -Listen und Kataloge verwenden vermessene Layouts und Noita-Scrollcontainer. Eine Größenänderung des Fensters verändert sofort die sichtbare Inhaltsmenge, und übersetzte Beschriftungen dürfen umbrechen, ohne benachbarte Bedienelemente zu überlagern. Schmale Layouts verteilen Controls auf zusätzliche Zeilen, statt sie übereinanderzuzeichnen. - -Das bloße Öffnen oder Überfahren des abgelösten Fensters deaktiviert das Gameplay nicht dauerhaft. Wenn ein Klick, Drag oder fokussiertes Textfeld sonst gleichzeitig den Spieler auslösen würde, unterdrückt MCM die betroffenen Spielersteuerungen vorübergehend und stellt sie anschließend wieder her. - -# Suche und Lokalisierung - -Die Suche ist in den wichtigsten Katalogen verfügbar, darunter Zauber, Gegenstände, Materialien, Perks und Kreaturen. - -Je nach Eintrag kann sie Folgendes durchsuchen: - -- den Namen in der aktuellen Oberflächensprache; -- den englischen Namen; -- Lokalisierungsschlüssel; -- technische Kennungen; -- XML-Pfade. - -Die Suche ignoriert Groß-/Kleinschreibung, normalisiert gängige Akzente und Trennzeichen und toleriert kleine Tippfehler in längeren Suchbegriffen. - -Die eigene MCM-Oberfläche ist lokalisiert auf: - -- Englisch; -- Russisch; -- Brasilianisches Portugiesisch; -- Spanisch; -- Deutsch; -- Französisch; -- Italienisch; -- Polnisch; -- Vereinfachtes Chinesisch; -- Japanisch; -- Koreanisch. - -Für regulären Noita-Inhalt verwendet der Mod nach Möglichkeit die Lokalisierungsschlüssel des Spiels, statt doppelte Namenslisten zu pflegen. - -# Zauber - -Der Zauberbereich arbeitet sowohl mit dem Katalog als auch mit bereits vorhandenen Zauberentitäten des Spielers. - -Der Hauptarbeitsbereich enthält: - -- die normalen Slots des aktiven Zauberstabs; -- **ALWAYS CAST**-Karten; -- das Zauberinventar des Spielers; -- den durchsuchbaren Zauberkatalog. - -## Schnelles Ersetzen des ausgewählten Slots - -Ein kurzer Klick wählt einen Zauberstabslot. Danach ersetzt ein kurzer LMB-Klick auf einen Katalogzauber diesen Slot. - -Dies ist der schnelle Weg für normale Bearbeitung. Präzises Verschieben erfolgt per Drag-and-drop. - -## Transaktionales Drag-and-drop - -Vorhandene Karten können verschoben werden: - -- zwischen Zauberstabslots; -- von normalen Slots zu **ALWAYS CAST**; -- von **ALWAYS CAST** zurück zu normalen Slots; -- in einen exakten Zauberinventar-Slot; -- vom Inventar zurück auf den Zauberstab; -- in die Spielwelt; -- in den Papierkorb, sofern unterstützt. - -Bei einer vorhandenen Karte verschiebt MCM nach Möglichkeit die echte Entität. Veränderlicher Zustand, verbleibende Verwendungen und von anderen Mods hinzugefügte Daten gehen daher nicht nur durch einen Positionswechsel verloren. - -Die Quelle bleibt unverändert, bis die Zieltransaktion bestätigt wurde. Ungültige oder unbekannte Drop-Ziele brechen den Vorgang ab, statt die ursprüngliche Karte zu löschen. Eine Mausfreigabe führt höchstens eine bestätigte Operation aus. - -Katalogkarten sind Vorlagen und werden beim Ziehen nie verbraucht. - -## Always Cast - -Always-Cast-Karten haben eine eigene Leiste. Promotion, Rückstufung und Tausch berücksichtigen die effektive Kapazität normaler Slots, damit keine ungültige Zauberstabstruktur entsteht. - -## Rückgängig und Wiederholen - -Interne Zauberstabänderungen besitzen eine begrenzte **UNDO / REDO**-Historie. - -Vorgänge, die eine echte Entität in die Außenwelt oder in ein anderes Inventar übergeben, lassen sich nicht immer sicher aus einem Zauberstab-Snapshot zurückholen. Solche externen Übergaben werden daher nicht als universell rückgängig machbar versprochen. - -# Zauberstäbe - -Der Zauberstab-Arbeitsbereich bearbeitet den aktuell gehaltenen Zauberstab. - -Unterstützte Werte umfassen: - -- Kapazität / Slots; -- Zauber pro Schuss; -- Aufladezeit; -- Verzögerung zwischen Schüssen; -- Streuung; -- Geschwindigkeitsmultiplikator für Projektile; -- maximales Mana; -- Mana-Aufladung; -- Rückstoß-Erholung; -- Zauberstab-Level; -- Shuffle; -- Never-Reload-Verhalten. - -MCM bearbeitet außerdem Darstellung und Metadaten: - -- angezeigter Name; -- Sperren für Zauberstab und Karten; -- Sprite-Pfad; -- Sprite-Offsets; -- Schussposition. - -Ein visueller Erscheinungskatalog folgt vorhandenen Zauberstab-XML-Daten, wenn verfügbar. - -## Zauberstab-Presets - -Zauberstäbe können als persistente benannte Presets gespeichert und in späteren Welten oder Noita-Sitzungen wiederverwendet werden. - -Ein Preset kann speichern: - -- Zauberstabwerte; -- Mana-Werte; -- visuelle Metadaten; -- normale Zauberkarten; -- Always-Cast-Karten; -- Slotpositionen; -- verbleibende Verwendungen; -- eingefrorenen Kartenstatus. - -Jedes Preset bietet zwei getrennte Aktionen: - -- **APPLY** schreibt den gespeicherten Blueprint auf den aktuell gehaltenen Zauberstab; -- **GET COPY** baut einen neuen Zauberstab aus demselben Blueprint. - -Eine Kopie wird nach Möglichkeit in einen freien Zauberstabslot des Schnellinventars gelegt. Gibt es keinen geeigneten Slot, bleibt der fertig gebaute Zauberstab in der Welt beim Spieler. - -Zauberstabersetzung und Preset-Laden nutzen Commit-/Rollback-Pfade. Wenn Konstruktion oder Platzierung nicht abgeschlossen werden können, versucht MCM den unvollständigen Entitätsbaum zu entfernen, statt einen defekten Teil-Zauberstab zurückzulassen. - -# Gegenstände und Flüssigkeiten - -## Gegenstände - -Ein kurzer **LMB**-Klick auf einen Katalogeintrag erzeugt einen unterstützten Gegenstand in der Nähe des Spielers. - -**RMB** versucht, ihn in den passenden Inventarbereich zu legen. - -Katalogeinträge können außerdem gezogen werden: - -- auf ein passendes Ziel im Schnellinventar; -- aus dem Menü hinaus an eine exakte Weltposition. - -Wird eine Karte innerhalb des Menüs ohne gültiges Ziel losgelassen, wird der Vorgang abgebrochen. Die Katalogkarte ist nur eine Vorlage und bleibt verfügbar. - -MCM respektiert Noitas normale Trennung des Schnellinventars in Zauberstab- und Gegenstandsslots. Ein fehlgeschlagenes XML-Laden, Befüllen mit Flüssigkeit, Inventar-Handoff oder optionaler Mehrspieler-Handoff entfernt die neu erzeugte Entität nach Möglichkeit wieder. - -Einige echte Inventargegenstände liegen im Spiel in kreaturenorientierten Verzeichnissen. MCM klassifiziert bekannte Fälle nach Verhalten, statt aus dem Ordnernamen allein abzuleiten, ob etwas Gegenstand oder Kreatur ist. - -## Flüssigkeiten - -Flüssigkeitseinträge erzeugen echte gefüllte Noita-Behälter und keine dekorativen UI-Objekte. - -Der resultierende Behälter kann getragen, fallen gelassen, zerbrochen und ausgeschüttet werden; sein Inhalt nimmt an normalen Materialreaktionen teil. - -# Materialien - -Der Materials-Bereich ist ein Weltmalwerkzeug auf Basis von Noitas echtem Materialregister. - -Der Katalog wird aus registrierten Flüssigkeiten, Sanden / Pulvern, Gasen, Feuern, Feststoffen sowie verwandten statischen oder Effektmaterialien aufgebaut. Korrekt registrierte Materialien anderer aktiver Mods können deshalb automatisch erscheinen. - -Materialerkennung und teure Validierung werden auf begrenzte Arbeit verteilt, statt den gesamten Katalog in einem einzigen UI-Frame zu scannen. - -## Materialdarstellung - -Flüssigkeiten verwenden dieselbe Darstellung gefüllter Behälter wie der Gegenstandsbereich. - -Bei Nicht-Flüssigkeiten bevorzugt MCM definierte Texturen und Tint-Daten aus `materials.xml`, einschließlich geerbter Definitionen. Fehlt eine definierte Textur, basiert der Fallback auf der tatsächlichen Engine-Farbe des Materials und nicht auf einer willkürlichen Vorschaufarbe. - -## Malen - -1. Material auswählen. -2. Pinselgröße wählen. -3. Malmodus aktivieren. -4. Inventar schließen. -5. Die konfigurierte Mal-Eingabe in der Welt gedrückt halten. - -Das Öffnen des Inventars beendet den aktiven Malmodus. - -Malen erzeugt nicht nur dekorative Partikel. MCM setzt echte Zellen über einen zur Engine passenden Pfad in die Welt. Dynamische Materialien folgen weiterhin der Noita-Simulation: Flüssigkeiten fließen, Pulver fallen, Gase bewegen sich, Feuer reagiert und instabile Stoffe können sich durch Materialreaktionen verändern. - -Unterschiedliche Materialklassen benötigen unterschiedliche Platzierungsstrategien. Der Standalone-Build kann NoitaPatchers direkten Weltgitterzugriff und einen kleinen PixelScene-Fallback für definierte Fälle verwenden, die Noita an einer bestimmten Texturkoordinate nicht direkt konstruieren kann. - -Arbeitswarteschlangen sind begrenzt, damit ein großer gehaltenener Pinsel nicht absichtlich unbegrenzte Arbeit in einem einzigen Frame ausführt. - -# Perks - -## Perks erzeugen und erhalten - -**LMB** erzeugt einen normalen Pickup des ausgewählten Perks in der Welt. - -Die Take-Aktion kann den Perk einzeln oder in größeren Mengen gewähren. Massenaktionen werden als begrenzte Jobs verarbeitet, statt alle Kopien in einem einzigen UI-Frame anzuwenden. - -Die Oberfläche zeigt den Fortschritt, und noch ausstehende Arbeit kann abgebrochen werden. Bereits bestätigte Kopien bleiben nach dem Abbruch erhalten. - -Jede gewährte Kopie durchläuft weiterhin den normalen Perk-Anwendungspfad, statt den Endzustand direkt zu fälschen. - -## Perks entfernen - -Einen Perk zu entfernen ist wesentlich schwieriger, als ihn zu gewähren. Perks können Globals, Komponenten, Entitäten, Spielerwerte und lang laufende Mechaniken verändern; Noita bietet dafür keine universelle inverse Operation. - -MCM entfernt deshalb nur Zustand, für den eine ausreichend sichere verfolgte Gegenoperation existiert. Das Transaktionsjournal versucht, nur Zustand dieser konkreten Perk-Anwendung zu entfernen, ohne unabhängigen Spielerzustand zurückzusetzen. - -Wenn eine Bereinigung nur teilweise gelingt oder nicht als vollständig bewiesen werden kann, bleibt sie als unvollständig behandelt, statt still als erfolgreich zu gelten. - -Ein Drittanbieter-Perk kann vergebbar sein, ohne korrekt entfernbar zu sein. - -# Effekte - -Der Effects-Bereich wendet unterstützte Materialstatus und GameEffect-Entitäten an und entfernt sie. - -Die Entfernung berücksichtigt nach Möglichkeit Besitz. MCM löscht nicht wahllos ähnliche versteckte Effekte, die Perks, dem Spiel oder einem anderen System gehören. - -Persistente von MCM erzeugte Effekte verwenden begrenzte Bereinigung / Ablaufbehandlung, damit das Entfernen eines MCM-Effekts keinen fremden Zustand zurücksetzt. - -# Kreaturen - -Der Kreaturenkatalog behält exakte XML-Pfade bei, statt alle ähnlich benannten Entitäten zusammenzuführen. - -Unterstützte Interaktionen: - -- **LMB** — erzeugt die ausgewählte definierte Entität beim Spieler; -- Drag aus dem Menü — erzeugt sie an der bestätigten Weltcursorposition; -- **RMB** — verwandelt den aktuellen Spieler in eine unterstützte Form; -- der spezielle Eintrag **PLAYER** — erzeugt oder stellt Spielerzustand wieder her, wie unten beschrieben. - -Wird eine gezogene Karte wieder über dem Menü losgelassen, wird der Welt-Spawn abgebrochen. - -Kompatibilitätsregeln für gefährliche oder ungewöhnliche Formen verwenden exakte Pfade. Ein Dateiname, der nur ein bekanntes Wort enthält, macht eine Entität nicht automatisch zu einer gleichwertigen Form. - -# Verwandlungen und Rückkehr zur Menschenform - -Spielbare Formen behalten nützliche native Bewegung, Angriffe, Darstellung und Physik, soweit praktikabel. Komponenten, die direkt mit Spielereingabe konkurrieren, können deaktiviert oder angepasst werden, solange die Form vom Spieler gesteuert wird. - -Einige komplexe Kreaturen benötigen zusätzliche Kompatibilitätslogik. Bosse, skriptgesteuerte Wrapper und stark physikabhängige Entitäten müssen sich als Spielerform nicht exakt wie ihre KI-gesteuerte Originalversion verhalten. - -Die konfigurierte Rückkehraktion — standardmäßig **TAB** — nutzt zuerst den normalen Pfad zum Beenden der Verwandlung. Reicht das nicht aus, besitzt der Standalone-Build zusätzliche NoitaPatcher-gestützte Wiederherstellungspfade. - -Bei unterstützten Fällen tödlichen Schadens versucht MCM: - -- die tote temporäre Form bzw. Leiche in der Welt zu belassen, wenn passend; -- eine menschliche Spielerentität wiederherzustellen; -- Authority und Steuerung zurückzugeben; -- das Inventar zu bewahren; -- relevanten Spielerzustand wiederherzustellen. - -Das ist Wiederherstellungslogik, keine absolute Unsterblichkeit. Ein Drittanbieter-Kill-Script, inkompatibler Engine-Zustand oder Prozessabsturz kann den unterstützten Handoff umgehen. - -# Besitzergreifung - -Besitzergreifung steuert eine bereits in der Welt existierende Kreatur, statt eine Form aus dem Katalog zu wählen. - -Die Standardtaste ist **G**. - -Ziele auf eine geeignete Kreatur und benutze die Besitzergreifungsaktion. MCM prüft das Ziel, bereitet eine kompatible Formtransaktion vor und entfernt bzw. retired die ursprüngliche Weltentität erst, nachdem der neue spielergesteuerte Zustand bestätigt wurde. - -Scheitert der Übergang, sollte die ursprüngliche Kreatur nicht einfach verschwinden. - -Die Funktion ist nicht auf MCMs eingebauten Katalog begrenzt. Eine kompatible Kreatur eines anderen Mods kann funktionieren, universelle Kompatibilität mit allen Drittanbieterentitäten wird jedoch nicht garantiert. - -# Player-Eintrag - -**PLAYER** ist ein spezieller Eintrag im Kreaturenkatalog und kein normaler Polymorph-Zieltyp. - -Seine Spawn-Aktion erzeugt einen getrennten spielerähnlichen Charakter und versucht geeignete Darstellung sowie Informationen zur maximalen Gesundheit zu kopieren. - -Die Transformationsaktion auf **PLAYER** verwandelt einen bereits menschlichen Spieler nicht in ein Duplikat. Befindet sich der Spieler in einer anderen Form, dient die Aktion als Rückkehr zur Menschenform. - -# Game-Over-Wiederherstellung im Einzelspieler - -Der Standalone-Einzelspieler-Build enthält einen zusätzlichen Wiederherstellungspfad für Noitas normalen Game-Over-Bildschirm. - -Wenn die native Integration die benötigten Spielstrukturen sicher erkennen kann, fügt MCM dem Game-Over-Interface die Aktion **„I didn't die“** hinzu. - -MCM hält während des Spiels eine fortlaufend aktualisierte Spieler-Sicherung. Das Auslösen der Wiederherstellung fordert die Wiederherstellung über MCMs normalen Update-Pfad an, statt den kompletten Spieler direkt im UI-Klickhandler neu aufzubauen. - -Eine unterstützte Wiederherstellung versucht: - -- eine lebende Spielerentität wiederherzustellen oder zu erhalten; -- sie wieder authoritative zu machen; -- den Game-Over-Zustand der Engine zu löschen; -- Steuerung und nutzbaren Spielerzustand zurückzugeben; -- Game-Over-Audio, Musik und UI bestmöglich zu bereinigen; -- nach der Wiederherstellung ein kurzes Schutzfenster zu geben. - -Der native Helper ist fail-closed ausgelegt. Er sucht im laufenden unterstützten Noita-Executable nach bekannten Strukturen, statt dauerhaft auf eine einzige hart codierte Adresse zu schreiben. Können die erwarteten Strukturen nach einem Spielupdate nicht sicher erkannt werden, wird die optionale Wiederherstellung nicht benutzt, statt an eine unsichere Adresse zu schreiben. - -# Wetter und Zeit - -MCM kann unterstützten Wetter- und Zeitstatus steuern, einschließlich Presets und einzelner Parameter der aktuellen Implementierung. - -Erzwungener Zustand kann später wieder an die normale Spielsteuerung freigegeben werden. Nach dem Erzwingen einer Tageszeit kann MCM beispielsweise diese Einstellung wieder loslassen, damit Noitas natürlicher Zeitfluss weiterläuft. - -Wetteränderungen werden als zustandsbehaftete Steuerung behandelt und nicht als einmalige Einweg-Konsolenbefehle. - -# Weltregeln - -Der Bereich **RULES** verändert unterstütztes globales Spielverhalten. - -Regeln decken unter anderem ab: - -- Beziehungen zwischen Kreaturen; -- Goldverhalten; -- Zaubernutzung; -- Fog of War; -- ausgewählte Kill-Belohnungen; -- Heilungsdrops; -- blutbezogenes Verhalten; -- Gravitation; -- Physik; -- Trittstärke; -- Physikgelenke; -- Tag-Nacht-Zyklus; -- weitere unterstützte globale Parameter. - -Das wichtigste Ziel ist Reversibilität. - -Für unterstützte Regeln zeichnet MCM den Ursprungszustand auf oder leitet ihn ab, damit die Einstellung später zurückgesetzt werden kann. Multiplikator-Regler werden relativ zum Ursprungswert angewandt und nicht wiederholt auf bereits veränderte Ergebnisse multipliziert. - -Regeln, die viele Entitäten oder Physikobjekte anfassen müssen, nutzen begrenzte Update-Arbeit über mehrere Frames, statt die gesamte Welt synchron durch einen einzigen Menüklick umzuschreiben. - -# Teleportation - -Der Teleportbereich bietet vorbereitete Ziele in der Welt, darunter Punkte auf der Hauptroute, Holy Mountains, größere Nebenbereiche und weitere unterstützte Orte. - -Vor dem Versetzen kann MCM das Laden des Zielgebiets anfordern und sucht nutzbaren freien Raum in der Nähe, statt den Spieler absichtlich direkt in festes Terrain zu setzen. - -Teleportation hängt weiterhin davon ab, dass die Welt laden und ein gültiges Ziel bereitstellen kann. Stark modifizierte Welten können Fallback-Verhalten benötigen. - -# Entangled Worlds - -**Entangled Worlds / Noita Proxy ist optional.** MCM funktioniert ohne EW. - -Wenn EW vorhanden ist, aktiviert MCM zusätzliche mehrspielerbewusste Funktionen. Alle Peers sollten kompatible MCM-Builds verwenden, wenn sie auf MCM-spezifisch synchronisierten Zustand angewiesen sind. - -## Authority und Formen - -Spielerformen benötigen spezielle Ownership-Behandlung, weil ein verwandelter Spieler nicht versehentlich eine zweite Netzwerk-Authority hinterlassen darf. - -MCM koordiniert Ownership, Retirement und Rückkehr zur Menschenform mit EW, soweit unterstützt. Boss- und Kolmi-artige Entitäten besitzen zusätzliche Lifecycle-Behandlung, die doppelte Authorities und veraltete netzwerkgesteuerte Kopien vermeiden soll. - -Der normale EW-Todespfad bleibt für Entitäten zuständig, die nicht als MCM-eigener Formzustand erkannt werden. - -## Gegenstände, Zauberstäbe und Zauber - -Wo möglich nutzt MCM EWs normale Item-/Inventarmechanismen, statt ein paralleles Transportsystem zu erfinden. - -Bestätigte Zauberstab- und Zauberinventaränderungen fordern die passende Multiplayer-Aktualisierung an, wenn die Integration verfügbar ist. Von MCM erzeugte Weltgegenstände können an EWs normalen World-Item-Pfad übergeben werden. - -## Perks - -Normale Perk-Pickups können EWs normale World-Item-Synchronisation verwenden. MCMs eigene Perk-Zustandslogik koordiniert Refresh und begrenzte Operationen, damit Massenaktionen nicht für jede Kopie einen teuren globalen Refresh auslösen. - -## Materialien - -Materialmalerei besitzt einen eigenen Kompatibilitätspfad, weil Weltzellenänderungen keine normalen Item-Entitäten sind. - -MCM hält Malarbeit begrenzt, trennt Arbeit an Chunk-Grenzen und koordiniert erforderliche EW-World-Frame-/Persistenzschritte, bevor synchronisierte Konvertierungsarbeit freigegeben wird. Ein noch nicht geladener Streaming-Rand-Chunk wird verschoben, statt den gesamten aktuellen Pinselstrich zu blockieren. - -Das Ziel ist, dass nahe EW-Peers unterstützten gemalten Weltzustand sehen können, ohne die normale MCM-UI-Aktion remote als rein dateinamenbasierten PixelScene-Aufruf nachzuspielen. - -Dabei gelten weiterhin EWs Material-ID-Annahmen: Ein empfangendes Spiel kann kein Material korrekt erzeugen, das dort nicht existiert oder dessen Engine-Materialregister inkompatibel ist. - -## Wetter, Besitzergreifung und Weltzustand - -Unterstützter MCM-Multiplayerzustand umfasst außerdem Koordination für Wetter, Besitzergreifung und ausgewähltes Weltregel-/Lifecycle-Verhalten. Authority-Prüfungen verhindern, dass zwei Peers gleichzeitig Besitzer desselben Zustands werden. - -EW-Unterstützung ist absichtlich konservativ. Wenn die Integration keinen sicheren Synchronisationspfad beweisen kann, bevorzugt MCM korrektes lokales Verhalten, statt jede Einzelspieleroperation fälschlich als automatisch mehrspielersicher darzustellen. - -# Kompatibilität und Einschränkungen - -Noita stellt viele Systeme über lose gekoppelte Entitäten, XML, Lua-Komponenten und natives Engine-Verhalten bereit. MCM kann deshalb keine universelle Kompatibilität mit jeder modifizierten Entität oder jedem zukünftigen Spielupdate garantieren. - -Wichtige Einschränkungen: - -- Eine Kreatur kann spawnbar sein, ohne eine sichere Spielerform zu sein. -- Ein Perk kann vergebbar sein, ohne eine zuverlässige inverse Operation zu besitzen. -- Externe Zauber- oder Gegenstandsübergaben lassen sich nicht immer aus einem internen Snapshot rückgängig machen. -- Drittanbieter-Skripte können unterstützte Todes- und Wiederherstellungspfade umgehen. -- Native Wiederherstellungsfunktionen hängen von unterstütztem Noita-Executable-Verhalten ab und deaktivieren sich fail-closed, wenn benötigte Strukturen nicht sicher erkennbar sind. -- Entangled Worlds kann kein Material synchronisieren, das im Register des empfangenden Spiels fehlt. -- Stark modifizierte Inventare, Entitäten oder Weltregeln können mod-spezifische Kompatibilitätsarbeit benötigen. - -MCM versucht Ursprungszustand zu bewahren und fehlgeschlagene Mutationen zurückzurollen, aber ein Sandbox-Werkzeug, das live Spielzustand verändert, kann nicht jede Drittanbieter-Mod-Kombination vollständig transaktional machen. - -# Gespeicherte Daten - -MCM speichert benutzerbezogenen Zustand, der Sitzungen überdauern soll, darunter unterstützte Einstellungen, Tastenbelegungen, Menü-Layout und Zauberstab-Presets. - -Die Mod-Identität bleibt stabil, damit normale Updates unterstützte gespeicherte Daten behalten. Beim Installieren eines neuen Standalone-Builds wird trotzdem empfohlen, den ganzen Mod-Ordner zu ersetzen, da das Zusammenführen alter und neuer Dateien veraltete Runtime-Dateien hinterlassen kann. - -# Fehlerbehebung - -## Der Mod erscheint nicht - -Prüfe, ob der Pfad so endet: - -`mods/metamorph_creative_menu/mod.xml` - -Ein zusätzlicher Archivordner oberhalb von `metamorph_creative_menu` verhindert, dass Noita den Mod korrekt erkennt. - -## Native oder Materialfunktionen funktionieren nicht - -Prüfe, ob **Unsafe Mods** erlaubt sind und der Standalone-GitHub-Build installiert ist, ohne Dateien mit dem Workshop-Build zu mischen. - -## Das Menü öffnet sich, aber gleichzeitig löst eine Spielaktion aus - -Prüfe benutzerdefinierte Belegungen auf Konflikte. MCM zeigt doppelte Belegungen an, erlaubt aber bewusst, sie beizubehalten. - -## Eine Kreatur lässt sich nicht sicher verwandeln - -Nicht jede spawnfähige XML-Entität ist eine unterstützte Spielerform. Für Kreaturen mit Spezialbehandlung existieren Regeln nach exaktem Pfad. - -## Ein Perk lässt sich nicht entfernen - -Entfernung wird nur angeboten, wenn MCM eine unterstützte inverse Operation für den verfolgten Zustand besitzt. Das ist absichtlich so; geratenes Cleanup kann fremden Spielerzustand beschädigen. - -## Multiplayer verhält sich zwischen Peers unterschiedlich - -Verwende auf allen Teilnehmern kompatible MCM-Builds und eine kompatible Noita-/Entangled-Worlds-Umgebung. MCM kann kein inkompatibles Materialregister oder beliebige Netzwerkänderungen anderer Mods reparieren. - -# Fehler melden - -Ein nützlicher Bugreport sollte enthalten: - -- was du tun wolltest; -- den exakten MCM-Bereich und die Aktion; -- ob das Problem im Einzelspieler, mit Entangled Worlds oder in beiden Fällen auftritt; -- ob Standalone- oder Workshop-Build installiert ist; -- ob weitere Gameplay-Mods aktiv sind; -- zuverlässige Reproduktionsschritte; -- relevante Noita-/EW-Logs, wenn vorhanden. - -Bei Problemen mit Verwandlung, Besitzergreifung, Gegenständen oder Materialien gib nach Möglichkeit die exakte Entität bzw. das Material an. Technische Kennungen sind oft hilfreicher als ein übersetzter Anzeigename. - -# Repository und Entwicklungsquelle - -Das Repository enthält absichtlich den **vollständigen Entwicklungsbaum** und nicht dasselbe reduzierte Archiv, das Spieler herunterladen. - -`metamorph_creative_menu/` enthält Runtime-Code zusammen mit: - -- automatisierten Tests; -- QA-Werkzeugen; -- Diagnostik; -- nativem Quellcode; -- Build-Werkzeugen; -- Release-Cleanup-Regeln; -- Entwicklungsdokumentation. - -Diese Dateien sind für Entwicklung und Regressionstests nützlich und bleiben daher im GitHub-Source. Das installationsfertige Spieler-ZIP wird separat erzeugt und schließt entwicklungsbezogenen Inhalt aus. - -Das Spielerpaket erhält außerdem release-spezifische Bereinigung einschließlich des minimalen Player-`README.txt`, während der Source-Baum seine Entwicklungsdokumentation behält. - -# Tests - -Die automatisierte Testsuite liegt unter `metamorph_creative_menu/tests/` und kombiniert Python-Contract-Checks mit Lua-Mocktests. - -Vom Repository-Root aus führt der Release-Workflow die Suite gegen den vollständig importierten Source aus, bevor ein Spieler-Build veröffentlicht wird. Für die Lua-Mocktests wird `texlua` benötigt. - -Source-Hygiene-Prüfungen schützen außerdem produktionsnahe Dateien und Dokumentation vor Resten der Entwicklungshistorie, veralteten Debug-Oberflächen und versehentlichen Prozessartefakten. - -# Source-Archiv-Import und Release-Prozess - -Vollständiger Entwicklungs-Source kann aus einem Archiv der Familie `Metamorph-Creative-Menu-v...zip` importiert werden. - -Der Import-Workflow prüft die Archivstruktur, verlangt die vollständigen Entwicklungsbestandteile, führt Source-Hygiene und Regressionstests aus und committet erst danach den importierten Baum. - -Ein ModWorkshop-/Spieler-Archiv wird nicht als Entwicklungsquelle behandelt. - -Der öffentliche `latest-build`-Release wird anschließend durch einen getrennten Player-Builder aus dem vollständigen Source erzeugt. Dieser entfernt QA, Tests, Diagnostik, nativen Quellcode und andere rein entwicklungsbezogene Inhalte, wendet Release-Cleanup-Regeln an, validiert das Ergebnis und aktualisiert erst danach das stabile Download-Asset. - -Diese Trennung hält das Repository vollständig für Entwicklung nutzbar und den normalen Spielerdownload gleichzeitig klein und frei von Entwicklungsinstrumentierung. - -# Drittanbieter-Komponenten - -Drittanbieter-Komponenten, gebündelte Abhängigkeiten und Upstream-Projekte sind in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) dokumentiert. diff --git a/README.es.md b/README.es.md deleted file mode 100644 index 5a9c8d4..0000000 --- a/README.es.md +++ /dev/null @@ -1,586 +0,0 @@ - - -[English](README.md) · [Русский](README.ru.md) · [Português (Brasil)](README.pt-BR.md) · [**Español**](README.es.md) · [Deutsch](README.de.md) · [Français](README.fr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) · [한국어](README.ko.md) - -

Metamorph: Creative Menu

- -

Un menú creativo y conjunto de herramientas sandbox para Noita: hechizos, varitas, objetos, materiales, perks, efectos, criaturas, transformaciones, posesión, teletransporte, clima, reglas del mundo, integración multijugador y herramientas de recuperación.

- -

Creador y mantenedor: zerodancing

- ---- - -# Descargar - -Para jugar normalmente, usa la compilación lista para instalar: - -[**⬇️ Descargar la compilación más reciente**](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/download/latest-build/Metamorph-Creative-Menu.zip) - -[Página de la compilación más reciente](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/tag/latest-build) · [Registro de cambios](metamorph_creative_menu/CHANGELOG.txt) - -La release de GitHub se genera automáticamente a partir del árbol completo de desarrollo. Las pruebas, herramientas de QA, diagnósticos, código fuente nativo y herramientas de build permanecen en el repositorio, pero se excluyen del archivo para jugadores. - -La compilación standalone de GitHub incluye NoitaPatcher y soporte nativo de recuperación, por lo que **Unsafe Mods debe estar permitido**. - -# Instalación - -1. Descarga `Metamorph-Creative-Menu.zip` desde el enlace anterior. -2. Inicia Noita y abre **Mods** desde el menú principal. -3. Pulsa **Open mods folder**. -4. Extrae o mueve la carpeta `metamorph_creative_menu` a la carpeta `mods`. La ruta final debe contener directamente `metamorph_creative_menu/mod.xml`, sin una carpeta extra del archivo comprimido. -5. Si ya hay una copia antigua, sustituye toda la carpeta `metamorph_creative_menu` en lugar de mezclar archivos antiguos y nuevos. -6. Vuelve a Noita y actualiza la lista de mods. -7. Permite **Unsafe Mods**. -8. Activa **Metamorph: Creative Menu** e inicia una partida con los mods activos. - -No actives al mismo tiempo la compilación standalone de GitHub y la versión de Steam Workshop. - -# Compilación standalone y Steam Workshop - -La compilación distribuida en este repositorio de GitHub es la compilación standalone completa. Incluye NoitaPatcher y funciones que requieren acceso sin restricciones a la API de mods, incluidas operaciones de bajo nivel con materiales y recuperación nativa tras Game Over. - -La [compilación de Steam Workshop](https://steamcommunity.com/sharedfiles/filedetails/?id=3785170245) se instala por separado. No incluye los componentes nativos necesarios para las funciones exclusivas de la compilación standalone. - -Ambas compilaciones usan la misma identidad de mod. Instalarlas simultáneamente puede producir archivos duplicados o en conflicto y no está soportado. - -# Acerca del mod - -**Metamorph: Creative Menu (MCM)** es un menú creativo y toolkit sandbox para Noita. - -Reúne herramientas para: - -- hechizos e inventario de hechizos; -- edición de varitas y presets reutilizables; -- objetos y recipientes con líquidos; -- catálogo completo de materiales y pintura de materiales; -- perks y eliminación soportada de perks; -- estados y entidades GameEffect; -- criaturas, transformaciones y posesión; -- clima y hora; -- reglas globales del mundo; -- teletransporte; -- integración opcional con Entangled Worlds; -- recuperación tras transformaciones, muerte de formas y Game Over. - -MCM intenta trabajar con el estado real de Noita en vez de sustituirlo todo por copias decorativas. Las cartas de hechizo existentes se mueven como entidades, la entrega de objetos respeta la estructura del inventario, los cambios de varita usan rutas de commit/rollback, los materiales siguen siendo materiales reales simulados y las reglas reversibles conservan suficiente estado original para restaurar después los ajustes soportados. - -Entangled Worlds es opcional. Sin él, MCM sigue siendo un mod completo para un jugador. - -# Controles - -Controles predeterminados: - -| Acción | Entrada predeterminada | -| --- | --- | -| Abrir / cerrar el menú creativo | **F4** | -| Volver a forma humana durante una transformación | **TAB** | -| Poseer una criatura del mundo | **G** | -| Dibujar con el material seleccionado | **Botón central del ratón** | - -El panel creativo también está disponible a través de la interfaz normal del inventario de Noita. - -Las asignaciones pueden cambiarse en la sección **CONTROLS** de MCM y en los ajustes de mod de Noita. Se admiten teclas, botones del ratón y combinaciones exactas con **CTRL / SHIFT / ALT**. - -Durante la captura de una asignación: - -- **DELETE / BACKSPACE** borra la asignación; -- **ESC** cancela; -- **R** restaura la entrada predeterminada de esa acción; -- **RESET ALL** restaura todas las asignaciones predeterminadas tras confirmación. - -Las asignaciones duplicadas siguen siendo editables, pero MCM muestra el conflicto en vez de sustituir silenciosamente otra acción. - -Se pueden reasignar acciones de navegación del menú, secciones, retorno de forma, posesión, pintura de materiales, limpieza de efectos, liberación del clima, reinicio de reglas del mundo y acciones multijugador soportadas. - -# Ventana de Creative Menu - -El panel creativo directo es una ventana persistente y redimensionable, no un overlay de depuración fijo. - -Se puede: - -- mover por el título; -- redimensionar desde bordes y esquinas; -- minimizar; -- cerrar; -- restaurar al diseño predeterminado. - -Su posición, anchura, altura y última sección abierta se recuerdan entre ejecuciones. Tras cambios de resolución, la geometría guardada se ajusta para seguir dentro del área visible de la interfaz. - -Las listas y catálogos usan diseños medidos y contenedores de scroll de Noita. Redimensionar la ventana cambia inmediatamente cuánto contenido es visible, y las etiquetas traducidas pueden ocupar varias líneas sin solaparse con controles vecinos. En diseños estrechos, los controles pasan a filas adicionales en lugar de dibujarse unos encima de otros. - -Abrir o simplemente pasar el cursor sobre el menú separado no desactiva permanentemente el gameplay. Cuando un clic, arrastre o campo de texto enfocado también podría activar al jugador, MCM suprime temporalmente los controles relevantes y los restaura después. - -# Búsqueda y localización - -La búsqueda está disponible en los catálogos principales, incluidos hechizos, objetos, materiales, perks y criaturas. - -Según la entrada, puede coincidir con: - -- el nombre en el idioma actual de la interfaz; -- el nombre en inglés; -- claves de localización; -- identificadores técnicos; -- rutas XML. - -La búsqueda no distingue mayúsculas y minúsculas, normaliza acentos y separadores comunes y tolera pequeños errores tipográficos en consultas largas. - -La interfaz propia de MCM está localizada en: - -- inglés; -- ruso; -- portugués brasileño; -- español; -- alemán; -- francés; -- italiano; -- polaco; -- chino simplificado; -- japonés; -- coreano. - -Para contenido normal de Noita, el mod reutiliza las claves de localización del propio juego siempre que sea posible, en lugar de mantener nombres duplicados. - -# Hechizos - -La sección de hechizos trabaja tanto con el catálogo como con las entidades de hechizo ya existentes del jugador. - -El área principal contiene: - -- los slots normales de la varita activa; -- las cartas **ALWAYS CAST**; -- el inventario de hechizos del jugador; -- el catálogo de hechizos con búsqueda. - -## Sustitución rápida del slot seleccionado - -Un clic corto selecciona un slot de la varita. Después, un clic corto con LMB en un hechizo del catálogo sustituye ese slot. - -Es la ruta rápida para edición normal. El movimiento preciso usa drag-and-drop. - -## Drag-and-drop transaccional - -Las cartas existentes pueden arrastrarse: - -- entre slots de la varita; -- de slots normales a **ALWAYS CAST**; -- de **ALWAYS CAST** a slots normales; -- a un slot exacto del inventario de hechizos; -- del inventario de vuelta a la varita; -- al mundo; -- a la papelera cuando esté soportado. - -Para una carta existente, MCM mueve la entidad real siempre que sea posible. Así, el estado mutable, los usos restantes y los datos añadidos por otros mods no se pierden solo porque la carta cambie de ubicación. - -El origen permanece intacto hasta que la transacción de destino se confirma. Los destinos inválidos o desconocidos cancelan la operación en lugar de borrar la carta original. Una sola liberación del ratón realiza como máximo una operación confirmada. - -Las cartas del catálogo son plantillas y nunca se consumen al arrastrar. - -## Always Cast - -Las cartas Always Cast tienen su propia franja. Promoción, degradación e intercambio tienen en cuenta la capacidad efectiva de slots normales para evitar una estructura inválida de varita. - -## Deshacer y rehacer - -Las mutaciones internas de la varita tienen un historial limitado de **UNDO / REDO**. - -Las operaciones que entregan una entidad real al mundo exterior o a otro inventario no siempre pueden revertirse con seguridad desde un snapshot de la varita, por lo que esas transferencias externas no se prometen como universalmente reversibles. - -# Varitas - -El área de varita edita la varita que el jugador sostiene actualmente. - -Las estadísticas soportadas incluyen: - -- capacidad / slots; -- hechizos por lanzamiento; -- tiempo de recarga; -- retraso entre lanzamientos; -- dispersión; -- multiplicador de velocidad de proyectil; -- mana máxima; -- velocidad de recarga de mana; -- recuperación de retroceso; -- nivel de la varita; -- shuffle; -- comportamiento sin recarga. - -MCM también edita presentación y metadatos relacionados: - -- nombre mostrado; -- bloqueos de varita y cartas; -- ruta del sprite; -- offsets del sprite; -- posición de disparo. - -Un catálogo visual de apariencias sigue los datos de XML de varitas cuando están disponibles. - -## Presets de varita - -Las varitas pueden guardarse como presets persistentes con nombre y reutilizarse en otros mundos o futuras sesiones de Noita. - -Un preset puede conservar: - -- estadísticas de la varita; -- valores de mana; -- metadatos visuales; -- cartas normales; -- cartas Always Cast; -- posiciones de slots; -- usos restantes; -- estado congelado de las cartas. - -Cada preset tiene dos operaciones distintas: - -- **APPLY** escribe el blueprint guardado en la varita que sostiene el jugador; -- **GET COPY** construye una nueva varita con el mismo blueprint. - -La copia se coloca en un slot libre de varita del inventario rápido cuando es posible. Si no hay un slot apropiado, la varita terminada se deja en el mundo cerca del jugador. - -La sustitución de varitas y carga de presets usan rutas de commit/rollback. Si la construcción o colocación no puede completarse, MCM intenta eliminar el árbol de entidad incompleto en vez de dejar una varita parcial rota. - -# Objetos y líquidos - -## Objetos - -Un clic corto con **LMB** en una entrada del catálogo crea un objeto soportado cerca del jugador. - -**RMB** intenta entregar el objeto al área apropiada del inventario. - -Las entradas del catálogo también pueden arrastrarse: - -- a un destino compatible del inventario rápido; -- fuera del menú a una posición exacta del mundo. - -Soltar una carta dentro del menú sin un destino válido cancela la operación. La carta del catálogo es solo una plantilla y permanece disponible. - -MCM respeta la separación normal del inventario rápido de Noita entre slots de varita y slots de objeto. Un fallo al cargar XML, llenar un líquido, entregar al inventario o realizar una transferencia multijugador opcional elimina la nueva entidad cuando es posible. - -Algunos objetos reales de inventario viven en directorios del juego orientados a criaturas. MCM clasifica los casos conocidos por su comportamiento en vez de asumir que el nombre de una carpeta por sí solo determina si algo es objeto o criatura. - -## Líquidos - -Las entradas de líquidos crean recipientes reales de Noita ya llenos, no objetos decorativos de la interfaz. - -El recipiente resultante puede llevarse, soltarse, romperse y derramar su contenido, que participa en las reacciones normales de materiales. - -# Materiales - -La sección Materials es una herramienta de pintura del mundo basada en el registro real de materiales de Noita. - -El catálogo se construye a partir de líquidos, arenas / polvos, gases, fuegos, sólidos y materiales estáticos o de efectos registrados por el engine. Los materiales añadidos correctamente por otros mods activos pueden aparecer automáticamente. - -El descubrimiento de materiales y la validación costosa se reparten en trabajo limitado en vez de recorrer todo el catálogo en un solo frame de UI. - -## Presentación de materiales - -Los líquidos usan la misma presentación de recipiente lleno que la sección de objetos. - -Para materiales no líquidos, MCM prefiere texturas y tint definidos en `materials.xml`, incluidas definiciones heredadas. Si no hay textura definida, el fallback se deriva del color real del material en el engine, no de un color de preview arbitrario. - -## Pintura - -1. Selecciona un material. -2. Selecciona el tamaño del pincel. -3. Activa el modo de pintura. -4. Cierra el inventario. -5. Mantén pulsada la entrada configurada de dibujo en el mundo. - -Abrir el inventario detiene el modo de pintura activo. - -La pintura no emite simplemente partículas decorativas. MCM coloca celdas reales en el mundo mediante una ruta apropiada para el engine. Los materiales dinámicos siguen la simulación normal de Noita: los líquidos fluyen, los polvos caen, los gases se mueven, el fuego reacciona y las sustancias inestables pueden transformarse mediante reacciones de materiales. - -Distintas clases de material requieren estrategias de colocación diferentes. La compilación standalone puede usar acceso directo de NoitaPatcher a la cuadrícula del mundo y un pequeño fallback de PixelScene para casos definidos que Noita se niega a construir directamente en una coordenada concreta de textura. - -Las colas están limitadas para que mantener un pincel grande no ejecute deliberadamente una cantidad ilimitada de trabajo en un solo frame. - -# Perks - -## Crear y recibir perks - -**LMB** crea un pickup normal del perk seleccionado en el mundo. - -La acción de recibir puede conceder el perk de forma individual o en lote. Las operaciones por lotes se procesan como jobs limitados en vez de aplicar todas las copias en un único frame de UI. - -La interfaz muestra el progreso y el trabajo todavía pendiente puede cancelarse. Las copias ya confirmadas antes de la cancelación permanecen aplicadas. - -Cada copia concedida sigue usando la ruta normal de aplicación del perk en vez de falsificar directamente el estado final. - -## Eliminar perks - -Eliminar un perk es mucho más complicado que concederlo. Los perks pueden modificar globals, componentes, entidades, estadísticas del jugador y mecánicas de larga duración, y Noita no ofrece una operación inversa universal. - -Por eso MCM solo elimina estado para el que dispone de una inversa rastreada suficientemente segura. El journal de la transacción intenta quitar solo el estado perteneciente a esa aplicación del perk sin resetear estado no relacionado del jugador. - -Si una limpieza es parcial o no puede demostrarse completa, sigue tratándose como incompleta en lugar de declararse silenciosamente exitosa. - -Un perk de terceros puede poder concederse sin ser eliminable de forma correcta. - -# Efectos - -La sección Effects aplica y elimina estados de material y entidades GameEffect soportados. - -La eliminación tiene en cuenta la propiedad cuando es posible. MCM evita borrar indiscriminadamente efectos ocultos similares que pertenecen a perks, al juego o a otro sistema. - -Los efectos persistentes creados por MCM usan limpieza / expiración limitada para que quitar un efecto de MCM no resetee estado ajeno. - -# Criaturas - -El catálogo de criaturas conserva rutas XML exactas en vez de fusionar todas las entidades con nombres parecidos. - -Interacciones soportadas: - -- **LMB** — crea la entidad definida seleccionada cerca del jugador; -- arrastrar fuera del menú — crea en la posición confirmada del cursor del mundo; -- **RMB** — transforma al jugador actual en una forma soportada; -- entrada especial **PLAYER** — crea o restaura estado de jugador como se describe abajo. - -Soltar una carta arrastrada de vuelta sobre el menú cancela la creación en el mundo. - -Las reglas de compatibilidad para formas peligrosas o inusuales usan rutas exactas. Que un nombre de archivo contenga una palabra familiar no hace que la entidad se trate automáticamente como otra forma equivalente. - -# Transformaciones y regreso a forma humana - -Las formas jugables conservan movimiento nativo útil, ataques, presentación y física cuando resulta práctico. Los componentes que compiten directamente con la entrada del jugador pueden desactivarse o adaptarse mientras la forma está controlada por el jugador. - -Algunas criaturas complejas requieren lógica adicional. Bosses, wrappers con scripts y entidades muy dependientes de física no están garantizados a comportarse exactamente como sus versiones controladas por IA cuando se usan como forma del jugador. - -La acción configurada de regreso — **TAB por defecto** — usa primero la ruta normal de finalización de transformación. Cuando eso no basta, la compilación standalone dispone de rutas adicionales de restauración mediante NoitaPatcher. - -En casos soportados de daño fatal, MCM intenta: - -- dejar la forma temporal muerta o cadáver en el mundo cuando corresponda; -- restaurar una entidad humana del jugador; -- devolver authority y controles; -- conservar el inventario; -- restaurar estado relevante del jugador. - -Esto es lógica de recuperación, no inmortalidad absoluta. Un kill script de terceros, un estado incompatible del engine o un crash del proceso puede saltarse el handoff soportado. - -# Posesión - -La posesión controla una criatura que ya existe en el mundo en lugar de elegir una forma desde el catálogo. - -La entrada predeterminada es **G**. - -Apunta a una criatura adecuada y usa la acción de posesión. MCM valida el objetivo, prepara una transición compatible y elimina o retira la entidad original del mundo solo después de confirmar el nuevo estado controlado por el jugador. - -Si la transición falla, la criatura original no debería simplemente desaparecer. - -La posesión no se limita al catálogo interno de MCM. Una criatura compatible creada por otro mod puede funcionar, pero no se garantiza compatibilidad universal con todas las entidades de terceros. - -# Entrada Player - -**PLAYER** es una entrada especial del catálogo de criaturas, no un objetivo normal de polymorph. - -Su acción de creación genera un personaje separado similar al jugador e intenta copiar presentación apropiada e información de salud máxima. - -Usar la acción de transformación en **PLAYER** no convierte a un jugador ya humano en un duplicado. Si el jugador está en otra forma, la acción sirve para volver a forma humana. - -# Recuperación de Game Over en un jugador - -La compilación standalone para un jugador incluye una ruta adicional de recuperación para la pantalla estándar de Game Over de Noita. - -Cuando la integración nativa puede identificar con seguridad las estructuras necesarias del juego, MCM añade una acción **“I didn't die”** a la interfaz de Game Over. - -MCM mantiene una copia rotativa del estado del jugador mientras la partida está en curso. Activar la recuperación solicita la restauración a través de la ruta normal de actualización de MCM en vez de reconstruir todo el jugador directamente desde el handler del clic de UI. - -Una recuperación soportada intenta: - -- restaurar u obtener una entidad de jugador viva; -- volver a hacerla authoritative; -- limpiar el estado Game Over del engine; -- devolver controles y estado utilizable del jugador; -- hacer limpieza best-effort del audio, música e interfaz de Game Over; -- proporcionar una breve ventana de protección después de la restauración. - -El helper nativo está diseñado para fallar de forma segura. Escanea el ejecutable soportado de Noita en ejecución buscando estructuras conocidas en vez de escribir en una única dirección permanentemente codificada. Si las estructuras esperadas no pueden identificarse con seguridad después de una actualización, la recuperación opcional no se usa en vez de escribir en una ubicación incierta. - -# Clima y hora - -MCM puede controlar estado soportado de clima y hora, incluidos presets y parámetros individuales expuestos por la implementación actual. - -Un estado forzado puede liberarse después para devolver el control normal al juego. Por ejemplo, tras fijar una hora concreta, MCM puede dejar de poseer ese ajuste para que continúe el ciclo natural de Noita. - -Los cambios de clima se tratan como estado controlado, no como comandos de consola de una sola dirección. - -# Reglas del mundo - -La sección **RULES** cambia comportamiento global soportado del juego. - -Las reglas cubren áreas como: - -- relaciones entre criaturas; -- comportamiento del oro; -- uso de hechizos; -- fog of war; -- determinadas recompensas por muertes; -- drops de curación; -- comportamiento de sangre; -- gravedad; -- física; -- fuerza de patada; -- juntas físicas; -- ciclo día/noche; -- otros parámetros globales soportados. - -El objetivo principal es la reversibilidad. - -Para reglas soportadas, MCM registra o deriva el estado original para poder restaurarlo después. Los controles multiplicadores se aplican respecto al valor original en vez de multiplicar repetidamente un resultado ya modificado. - -Las reglas que necesitan tocar muchas entidades u objetos físicos usan trabajo limitado a lo largo de frames en vez de intentar reescribir todo el mundo sincrónicamente con un clic. - -# Teletransporte - -La sección de teletransporte ofrece destinos preparados por el mundo, incluidos puntos de la ruta principal, Holy Mountains, grandes áreas laterales y otros lugares soportados. - -Antes de mover al jugador, MCM puede solicitar la carga del área de destino y busca espacio utilizable cercano en vez de colocar al jugador deliberadamente dentro de terreno sólido. - -El teletransporte sigue dependiendo de que el mundo pueda cargar y proporcionar un destino válido. Los mundos muy modificados pueden requerir comportamiento de fallback. - -# Entangled Worlds - -**Entangled Worlds / Noita Proxy es opcional.** MCM funciona sin él. - -Cuando EW está presente, MCM activa comportamiento adicional consciente de multijugador. Todos los peers deberían usar compilaciones compatibles de MCM al depender de estado sincronizado específico de MCM. - -## Authority y formas - -Las formas de jugador requieren manejo especial de ownership porque un jugador transformado no debe dejar accidentalmente una segunda authority de red. - -MCM coordina ownership, retirement y retorno a forma humana con EW donde está soportado. Entidades de boss y tipo Kolmi tienen tratamiento adicional de lifecycle destinado a evitar authorities duplicadas y copias antiguas controladas por red. - -La ruta normal de muerte de EW sigue a cargo de entidades que no se reconocen como estado de forma perteneciente a MCM. - -## Objetos, varitas y hechizos - -Cuando es posible, MCM usa los mecanismos normales de objetos / inventario de EW en vez de inventar un sistema de transporte paralelo. - -Las mutaciones confirmadas de varita e inventario de hechizos solicitan el refresh multijugador adecuado cuando la integración está disponible. Los objetos creados por MCM en el mundo pueden entregarse a la ruta estándar de world items de EW. - -## Perks - -Los pickups normales de perks pueden usar la sincronización estándar de world items de EW. El manejo de estado de perks de MCM coordina refresh y operaciones limitadas para que las acciones en lote no intenten emitir un refresh global costoso por cada copia. - -## Materiales - -La pintura de materiales tiene una ruta especial de compatibilidad porque los cambios de celdas del mundo no son entidades de objeto normales. - -MCM mantiene el trabajo de pintura limitado, separa el trabajo en fronteras de chunks y coordina pasos necesarios de world-frame / persistencia de EW antes de liberar trabajo de conversión sincronizada. Un chunk de borde todavía no cargado se aplaza en vez de bloquear todo el trazo activo. - -El objetivo es que peers cercanos puedan observar el estado pintado soportado sin tener que reproducir remotamente la acción normal de UI de MCM como una llamada PixelScene basada solo en filename. - -Esto sigue heredando las suposiciones de material id de EW: un juego receptor no puede crear correctamente un material que no exista allí o cuyo registro de materiales del engine sea incompatible. - -## Clima, posesión y estado del mundo - -El estado multijugador soportado de MCM también incluye coordinación para clima, posesión y determinado comportamiento de reglas / lifecycle. Las comprobaciones de authority evitan que dos peers intenten poseer el mismo estado a la vez. - -El soporte de EW es deliberadamente conservador. Cuando la integración no puede demostrar una ruta sincronizada segura, MCM prefiere el comportamiento local soportado en vez de fingir que toda operación de un jugador es automáticamente segura en multijugador. - -# Compatibilidad y limitaciones - -Noita expone muchos sistemas mediante entidades débilmente acopladas, XML, componentes Lua y comportamiento nativo del engine. Por ello MCM no puede prometer compatibilidad universal con todas las entidades modificadas ni con todas las futuras actualizaciones del juego. - -Limitaciones importantes: - -- que una criatura pueda crearse no significa que sea una forma de jugador segura; -- que un perk pueda concederse no significa que tenga una inversa fiable; -- las transferencias externas de hechizos u objetos no siempre pueden deshacerse desde un snapshot interno; -- scripts de terceros pueden saltarse rutas soportadas de muerte y recuperación; -- las funciones nativas de recuperación dependen de comportamiento soportado del ejecutable de Noita y fallan de forma segura si las estructuras necesarias no pueden identificarse; -- Entangled Worlds no puede sincronizar un material ausente del registro del juego receptor; -- inventarios, entidades o reglas muy modificadas pueden necesitar compatibilidad específica con ese mod. - -MCM intenta preservar estado original y revertir mutaciones fallidas, pero una herramienta sandbox que modifica estado vivo del juego no puede hacer totalmente transaccional cualquier combinación de mods de terceros. - -# Datos guardados - -MCM persiste estado de usuario que debe sobrevivir entre ejecuciones, incluidos ajustes soportados, asignaciones, diseño del menú y presets de varita. - -La identidad del mod permanece estable para que las actualizaciones normales conserven datos soportados. Aun así, se recomienda sustituir toda la carpeta del mod al instalar una nueva compilación standalone, porque mezclar archivos antiguos y nuevos puede dejar runtime obsoleto. - -# Solución de problemas - -## El mod no aparece - -Comprueba que la estructura termina en: - -`mods/metamorph_creative_menu/mod.xml` - -Una carpeta extra por encima de `metamorph_creative_menu` impide que Noita vea el mod correctamente. - -## Las funciones nativas o de materiales no funcionan - -Comprueba que **Unsafe Mods** está permitido y que instalaste la compilación standalone de GitHub sin mezclar archivos de Workshop. - -## El menú se abre pero también se dispara una acción del juego - -Revisa conflictos en las asignaciones personalizadas. MCM muestra duplicados, pero permite mantenerlos si así lo deseas. - -## Una criatura no puede transformarse de forma segura - -No toda entidad XML que puede crearse es una forma de jugador soportada. Existen reglas por ruta exacta para criaturas que necesitan tratamiento especial. - -## Un perk no puede eliminarse - -La eliminación solo se ofrece donde MCM tiene una operación inversa soportada para el estado rastreado. Es intencional: adivinar una limpieza puede dañar estado no relacionado del jugador. - -## El multijugador se comporta distinto entre peers - -Usa compilaciones compatibles de MCM en todos los participantes y mantén un entorno compatible de Noita / Entangled Worlds. MCM no puede corregir un registro de materiales incompatible ni modificaciones de red arbitrarias de otros mods. - -# Informar de errores - -Un informe útil debería incluir: - -- qué intentabas hacer; -- la sección y acción exactas de MCM; -- si ocurre en un jugador, Entangled Worlds o ambos; -- si está instalada la compilación standalone o Workshop; -- si hay otros mods de gameplay activos; -- pasos fiables para reproducirlo; -- logs relevantes de Noita / EW cuando estén disponibles. - -Para problemas de transformación, posesión, objetos o materiales, incluye la entidad o material exacto cuando sea posible. Los identificadores técnicos suelen ser más útiles que un nombre mostrado traducido. - -# Repositorio y fuente de desarrollo - -El repositorio contiene intencionadamente el **árbol completo de desarrollo**, no el mismo archivo reducido que descargan los jugadores. - -`metamorph_creative_menu/` contiene código runtime junto con: - -- pruebas automatizadas; -- herramientas de QA; -- diagnósticos; -- código fuente nativo; -- herramientas de build; -- reglas de limpieza de release; -- documentación de desarrollo. - -Estos archivos son útiles para desarrollo y pruebas de regresión, por lo que permanecen en el source de GitHub. El ZIP listo para jugadores se genera por separado y excluye contenido exclusivo de desarrollo. - -El paquete de jugador también recibe limpieza específica de release, incluido el `README.txt` mínimo del paquete, mientras que el árbol source mantiene su documentación de desarrollo. - -# Pruebas - -La suite automatizada está en `metamorph_creative_menu/tests/` y combina verificaciones de contrato en Python con mocks Lua. - -Desde la raíz del repositorio, el workflow de release ejecuta la suite contra el source completo importado antes de publicar la compilación para jugadores. `texlua` es necesario para la parte de mocks Lua. - -Las comprobaciones de source hygiene también protegen los archivos orientados a producción y la documentación contra residuos del historial de desarrollo, superficies de debug obsoletas y artefactos accidentales del proceso. - -# Importación del source y proceso de release - -El source completo de desarrollo puede importarse desde un archivo de la familia `Metamorph-Creative-Menu-v...zip`. - -El workflow de importación verifica la estructura, exige los componentes completos de desarrollo, ejecuta source hygiene y la suite de regresión antes de hacer commit del árbol importado. - -Un archivo de estilo ModWorkshop / player no se trata como source de desarrollo. - -La release pública `latest-build` se produce después desde el source completo mediante un builder separado. Este elimina QA, tests, diagnósticos, código fuente nativo y otro payload exclusivo de desarrollo, aplica reglas de limpieza, valida el archivo resultante y solo entonces actualiza el asset estable de descarga. - -Esta separación permite que el repositorio siga siendo útil para desarrollo mientras la descarga normal del jugador permanece pequeña y libre de instrumentación de desarrollo. - -# Componentes de terceros - -Los componentes de terceros, dependencias incluidas y proyectos upstream están documentados en [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). diff --git a/README.fr.md b/README.fr.md deleted file mode 100644 index e9f337e..0000000 --- a/README.fr.md +++ /dev/null @@ -1,586 +0,0 @@ - - -[English](README.md) · [Русский](README.ru.md) · [Português (Brasil)](README.pt-BR.md) · [Español](README.es.md) · [Deutsch](README.de.md) · [**Français**](README.fr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) · [한국어](README.ko.md) - -

Metamorph: Creative Menu

- -

Un menu créatif et une boîte à outils sandbox pour Noita : sorts, baguettes, objets, matériaux, perks, effets, créatures, transformations, possession, téléportation, météo, règles du monde, intégration multijoueur et outils de récupération.

- -

Créateur et mainteneur : zerodancing

- ---- - -# Télécharger - -Pour jouer normalement, utilisez la build prête à installer : - -[**⬇️ Télécharger la build la plus récente**](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/download/latest-build/Metamorph-Creative-Menu.zip) - -[Page de la dernière build](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/tag/latest-build) · [Changelog](metamorph_creative_menu/CHANGELOG.txt) - -La release GitHub est générée automatiquement à partir de l'arbre de développement complet. Les tests, outils QA, diagnostics, sources natives et outils de build restent dans le dépôt, mais sont exclus de l'archive destinée aux joueurs. - -La build standalone GitHub inclut NoitaPatcher et la récupération native, donc **Unsafe Mods doit être autorisé**. - -# Installation - -1. Téléchargez `Metamorph-Creative-Menu.zip` avec le lien ci-dessus. -2. Lancez Noita et ouvrez **Mods** depuis le menu principal. -3. Cliquez sur **Open mods folder**. -4. Extrayez ou déplacez le dossier `metamorph_creative_menu` dans le dossier `mods`. Le chemin final doit contenir directement `metamorph_creative_menu/mod.xml`, sans dossier supplémentaire créé par l'archive. -5. Si une ancienne copie est déjà installée, remplacez entièrement le dossier `metamorph_creative_menu` au lieu de fusionner anciens et nouveaux fichiers. -6. Revenez dans Noita et actualisez la liste des mods. -7. Autorisez **Unsafe Mods**. -8. Activez **Metamorph: Creative Menu** puis démarrez une partie avec les mods actifs. - -N'activez pas en même temps la build standalone GitHub et la version Steam Workshop. - -# Build standalone et Steam Workshop - -La build distribuée par ce dépôt GitHub est la build standalone complète. Elle contient NoitaPatcher et les fonctions qui exigent un accès non restreint à l'API de mods, notamment les opérations bas niveau sur les matériaux et la récupération native après Game Over. - -La [build Steam Workshop](https://steamcommunity.com/sharedfiles/filedetails/?id=3785170245) s'installe séparément. Elle n'inclut pas les composants natifs nécessaires aux fonctions réservées à la build standalone. - -Les deux builds utilisent la même identité de mod. Les installer en même temps peut produire des fichiers dupliqués ou en conflit et n'est pas pris en charge. - -# À propos du mod - -**Metamorph: Creative Menu (MCM)** est un menu créatif et une boîte à outils sandbox pour Noita. - -Il réunit des outils pour : - -- les sorts et l'inventaire de sorts ; -- l'édition des baguettes et les presets réutilisables ; -- les objets et conteneurs de liquides ; -- le catalogue complet des matériaux et la peinture de matériaux ; -- les perks et la suppression prise en charge des perks ; -- les statuts et entités GameEffect ; -- les créatures, transformations et la possession ; -- la météo et le temps ; -- les règles globales du monde ; -- la téléportation ; -- l'intégration optionnelle avec Entangled Worlds ; -- les chemins de récupération après transformation, mort de forme et Game Over. - -MCM essaie d'agir sur l'état réel de Noita au lieu de tout remplacer par des copies décoratives. Les cartes de sort existantes sont déplacées comme entités, la remise d'objets respecte la structure de l'inventaire, les modifications de baguette utilisent des chemins commit/rollback, les matériaux restent de vrais matériaux simulés et les règles réversibles conservent assez d'état d'origine pour restaurer plus tard les paramètres pris en charge. - -Entangled Worlds est optionnel. Sans lui, MCM reste un mod solo complet. - -# Contrôles - -Contrôles par défaut : - -| Action | Entrée par défaut | -| --- | --- | -| Ouvrir / fermer le menu créatif | **F4** | -| Revenir à la forme humaine pendant une transformation | **TAB** | -| Posséder une créature dans le monde | **G** | -| Peindre avec le matériau sélectionné | **Bouton central de la souris** | - -Le panneau créatif est aussi disponible via l'interface d'inventaire normale de Noita. - -Les touches peuvent être modifiées dans la section **CONTROLS** de MCM et dans les paramètres de mod de Noita. Les touches clavier, boutons de souris et combinaisons exactes avec **CTRL / SHIFT / ALT** sont pris en charge. - -Pendant la capture d'une touche : - -- **DELETE / BACKSPACE** efface l'assignation ; -- **ESC** annule ; -- **R** restaure la touche par défaut de cette action ; -- **RESET ALL** restaure toutes les touches par défaut après confirmation. - -Les assignations identiques restent modifiables, mais MCM signale le conflit au lieu de remplacer silencieusement une autre action. - -La navigation du menu, les sections, le retour depuis une forme, la possession, la peinture de matériaux, le nettoyage des effets, la libération de la météo, la réinitialisation des règles du monde et les actions multijoueur prises en charge peuvent être réassignés. - -# Fenêtre Creative Menu - -Le panneau créatif direct est une fenêtre redimensionnable et persistante, pas un overlay de debug fixe. - -Elle peut être : - -- déplacée par sa barre de titre ; -- redimensionnée depuis les bords et les coins ; -- minimisée ; -- fermée ; -- restaurée à sa disposition par défaut. - -Sa position, sa largeur, sa hauteur et la dernière section ouverte sont conservées entre les sessions. Après un changement de résolution, la géométrie enregistrée est ramenée dans la zone visible de l'interface. - -Les listes et catalogues utilisent des mises en page mesurées et les conteneurs de défilement de Noita. Redimensionner la fenêtre change immédiatement la quantité de contenu visible, et les libellés traduits peuvent passer sur plusieurs lignes sans recouvrir les contrôles voisins. Dans les dispositions étroites, les contrôles passent sur des lignes supplémentaires plutôt que de se superposer. - -Ouvrir ou simplement survoler la fenêtre détachée ne désactive pas durablement le gameplay. Lorsqu'un clic, un drag ou un champ texte actif pourrait aussi déclencher une action du joueur, MCM désactive temporairement les contrôles concernés puis les restaure. - -# Recherche et localisation - -La recherche est disponible dans les principaux catalogues, notamment les sorts, objets, matériaux, perks et créatures. - -Selon l'entrée, elle peut correspondre à : - -- son nom dans la langue actuelle de l'interface ; -- son nom anglais ; -- une clé de localisation ; -- un identifiant technique ; -- un chemin XML. - -La recherche ignore la casse, normalise les accents et séparateurs courants et tolère de petites fautes de frappe dans les requêtes longues. - -L'interface propre à MCM est localisée en : - -- anglais ; -- russe ; -- portugais brésilien ; -- espagnol ; -- allemand ; -- français ; -- italien ; -- polonais ; -- chinois simplifié ; -- japonais ; -- coréen. - -Pour le contenu normal de Noita, le mod réutilise autant que possible les clés de localisation du jeu au lieu de maintenir des noms dupliqués. - -# Sorts - -La section des sorts travaille à la fois avec le catalogue et avec les entités de sort déjà détenues par le joueur. - -L'espace principal contient : - -- les slots normaux de la baguette active ; -- les cartes **ALWAYS CAST** ; -- l'inventaire de sorts du joueur ; -- le catalogue de sorts avec recherche. - -## Remplacement rapide du slot sélectionné - -Un clic court sélectionne un slot de baguette. Ensuite, un clic LMB court sur un sort du catalogue remplace ce slot. - -C'est le chemin rapide pour l'édition ordinaire. Le déplacement précis utilise le drag-and-drop. - -## Drag-and-drop transactionnel - -Les cartes existantes peuvent être déplacées : - -- entre les slots de la baguette ; -- des slots normaux vers **ALWAYS CAST** ; -- de **ALWAYS CAST** vers les slots normaux ; -- vers un slot précis de l'inventaire de sorts ; -- de l'inventaire vers la baguette ; -- vers le monde ; -- vers la corbeille lorsque c'est pris en charge. - -Pour une carte existante, MCM déplace l'entité réelle autant que possible. L'état mutable, les utilisations restantes et les données ajoutées par d'autres mods ne sont donc pas perdus simplement parce que la carte change d'emplacement. - -La source reste intacte jusqu'à validation de la transaction de destination. Une cible invalide ou inconnue annule l'opération au lieu de supprimer la carte originale. Un relâchement de souris effectue au maximum une opération validée. - -Les cartes du catalogue sont des modèles et ne sont jamais consommées par le drag. - -## Always Cast - -Les cartes Always Cast ont leur propre bande. Promotion, rétrogradation et échange tiennent compte de la capacité effective des slots normaux afin d'éviter une structure de baguette invalide. - -## Annuler et rétablir - -Les mutations internes de la baguette disposent d'un historique limité **UNDO / REDO**. - -Les opérations qui remettent une vraie entité au monde extérieur ou à un autre inventaire ne peuvent pas toujours être inversées de manière sûre depuis un snapshot de baguette. Ces transferts externes ne sont donc pas promis comme universellement annulables. - -# Baguettes - -L'espace baguette édite la baguette actuellement tenue par le joueur. - -Les statistiques prises en charge incluent : - -- capacité / slots ; -- sorts par tir ; -- temps de recharge ; -- délai entre les tirs ; -- dispersion ; -- multiplicateur de vitesse des projectiles ; -- mana maximal ; -- recharge de mana ; -- récupération du recul ; -- niveau de la baguette ; -- shuffle ; -- comportement sans recharge. - -MCM édite aussi la présentation et les métadonnées associées : - -- nom affiché ; -- verrous de la baguette et des cartes ; -- chemin du sprite ; -- offsets du sprite ; -- position de tir. - -Un catalogue visuel d'apparences suit les données XML des baguettes lorsqu'elles sont disponibles. - -## Presets de baguette - -Les baguettes peuvent être enregistrées sous forme de presets persistants nommés et réutilisées dans d'autres mondes ou sessions de Noita. - -Un preset peut conserver : - -- les statistiques de la baguette ; -- les valeurs de mana ; -- les métadonnées visuelles ; -- les cartes normales ; -- les cartes Always Cast ; -- les positions de slots ; -- les utilisations restantes ; -- l'état gelé des cartes. - -Chaque preset propose deux opérations séparées : - -- **APPLY** écrit le blueprint enregistré sur la baguette actuellement tenue ; -- **GET COPY** construit une nouvelle baguette à partir du même blueprint. - -Une copie est placée dans un slot libre de baguette de l'inventaire rapide lorsque c'est possible. Sans slot adapté, la baguette terminée est laissée dans le monde près du joueur. - -Le remplacement de baguette et le chargement de preset utilisent des chemins commit/rollback. Si la construction ou le placement ne peut pas être terminé, MCM tente de supprimer l'arbre d'entité incomplet au lieu de laisser une baguette partielle cassée. - -# Objets et liquides - -## Objets - -Un clic court **LMB** sur une entrée de catalogue crée un objet pris en charge près du joueur. - -**RMB** tente de remettre l'objet à la zone appropriée de l'inventaire. - -Les entrées du catalogue peuvent aussi être glissées : - -- vers une cible compatible de l'inventaire rapide ; -- hors du menu vers une position exacte du monde. - -Relâcher une carte dans le menu sans cible valide annule l'opération. La carte du catalogue n'est qu'un modèle et reste disponible. - -MCM respecte la séparation normale de l'inventaire rapide de Noita entre slots de baguettes et d'objets. Un échec de chargement XML, de remplissage de liquide, de transfert vers l'inventaire ou de transfert multijoueur optionnel supprime la nouvelle entité lorsque c'est possible. - -Certains vrais objets d'inventaire vivent dans des répertoires du jeu orientés créatures. MCM classe les cas connus par comportement plutôt que de supposer que le nom du dossier suffit à déterminer s'il s'agit d'un objet ou d'une créature. - -## Liquides - -Les entrées de liquide créent de vrais conteneurs Noita remplis, pas des objets décoratifs d'interface. - -Le conteneur peut être transporté, jeté, cassé et renversé, et son contenu participe aux réactions normales de matériaux. - -# Matériaux - -La section Materials est un outil de peinture du monde basé sur le registre réel des matériaux de Noita. - -Le catalogue est construit à partir des liquides, sables / poudres, gaz, feux, solides et matériaux statiques ou d'effets enregistrés par le moteur. Les matériaux correctement ajoutés par d'autres mods actifs peuvent donc apparaître automatiquement. - -La découverte et la validation coûteuse des matériaux sont réparties en travail borné au lieu de scanner tout le catalogue en une seule frame UI. - -## Présentation des matériaux - -Les liquides utilisent la même présentation de conteneur rempli que la section Objets. - -Pour les matériaux non liquides, MCM privilégie les textures et teintes définies dans `materials.xml`, y compris les définitions héritées. Si aucune texture n'est définie, le fallback vient de la couleur réelle du matériau dans le moteur plutôt que d'une couleur de preview arbitraire. - -## Peinture - -1. Sélectionnez un matériau. -2. Sélectionnez la taille du pinceau. -3. Activez le mode peinture. -4. Fermez l'inventaire. -5. Maintenez l'entrée de dessin configurée dans le monde. - -Ouvrir l'inventaire arrête le mode peinture actif. - -La peinture ne crée pas seulement des particules décoratives. MCM place de vraies cellules dans le monde via un chemin adapté au moteur. Les matériaux dynamiques continuent à suivre la simulation Noita : les liquides coulent, les poudres tombent, les gaz se déplacent, le feu réagit et les substances instables peuvent se transformer via les réactions de matériaux. - -Les différentes classes de matériaux nécessitent des stratégies de placement différentes. La build standalone peut utiliser l'accès direct à la grille du monde de NoitaPatcher et un petit fallback PixelScene pour les cas définis que Noita refuse de construire directement à une coordonnée de texture donnée. - -Les files de travail sont bornées afin qu'un gros pinceau maintenu n'exécute pas volontairement une quantité illimitée de travail en une seule frame. - -# Perks - -## Créer et recevoir des perks - -**LMB** crée un pickup normal du perk sélectionné dans le monde. - -L'action de réception peut accorder le perk individuellement ou en lot. Les opérations en lot sont traitées comme des jobs bornés au lieu d'appliquer toutes les copies dans une seule frame UI. - -L'interface affiche la progression, et le travail encore en attente peut être annulé. Les copies déjà validées avant l'annulation restent appliquées. - -Chaque copie accordée passe toujours par le chemin normal d'application du perk au lieu de simuler directement l'état final. - -## Supprimer des perks - -Supprimer un perk est bien plus complexe que l'accorder. Les perks peuvent modifier des globals, composants, entités, statistiques du joueur et mécaniques persistantes, et Noita ne fournit pas d'opération inverse universelle. - -MCM ne supprime donc que l'état pour lequel il dispose d'une inversion suivie suffisamment sûre. Le journal de transaction tente d'enlever uniquement l'état appartenant à cette application du perk sans réinitialiser l'état indépendant du joueur. - -Si un nettoyage est partiel ou ne peut pas être prouvé complet, il reste traité comme incomplet au lieu d'être silencieusement annoncé comme réussi. - -Un perk tiers peut être accordable sans être supprimable correctement. - -# Effets - -La section Effects applique et retire les statuts de matériaux et entités GameEffect pris en charge. - -La suppression tient compte de la propriété lorsque c'est possible. MCM évite d'effacer aveuglément des effets cachés similaires appartenant à des perks, au jeu ou à un autre système. - -Les effets persistants créés par MCM utilisent un nettoyage / une expiration bornés afin que retirer un effet MCM ne réinitialise pas l'état d'autres systèmes. - -# Créatures - -Le catalogue de créatures conserve les chemins XML exacts au lieu de fusionner toutes les entités aux noms similaires. - -Interactions prises en charge : - -- **LMB** — crée l'entité définie sélectionnée près du joueur ; -- drag hors du menu — crée l'entité à la position confirmée du curseur monde ; -- **RMB** — transforme le joueur actuel en une forme prise en charge ; -- entrée spéciale **PLAYER** — crée ou restaure un état de joueur comme décrit ci-dessous. - -Relâcher une carte glissée au-dessus du menu annule le spawn dans le monde. - -Les règles de compatibilité pour les formes dangereuses ou inhabituelles utilisent des chemins exacts. Un nom de fichier contenant simplement un mot familier ne rend pas automatiquement l'entité équivalente à une autre forme. - -# Transformations et retour à la forme humaine - -Les formes jouables conservent les mouvements natifs utiles, attaques, présentation et physique lorsque c'est pratique. Les composants qui entrent directement en conflit avec l'entrée du joueur peuvent être désactivés ou adaptés tant que la forme est contrôlée par le joueur. - -Certaines créatures complexes exigent une logique supplémentaire. Les boss, wrappers scriptés et entités très dépendantes de la physique ne sont pas garantis de se comporter exactement comme leur version contrôlée par l'IA lorsqu'ils servent de forme au joueur. - -L'action de retour configurée — **TAB** par défaut — utilise d'abord le chemin normal de fin de transformation. Si cela ne suffit pas, la build standalone possède des chemins supplémentaires de restauration via NoitaPatcher. - -Dans les cas de dégâts mortels pris en charge, MCM tente de : - -- laisser la forme temporaire morte ou son cadavre dans le monde lorsque c'est approprié ; -- restaurer une entité humaine du joueur ; -- rendre l'authority et les contrôles ; -- préserver l'inventaire ; -- restaurer l'état pertinent du joueur. - -Il s'agit d'une logique de récupération, pas d'une immortalité absolue. Un kill script tiers, un état moteur incompatible ou un crash du processus peut contourner le handoff pris en charge. - -# Possession - -La possession contrôle une créature déjà présente dans le monde au lieu de choisir une forme dans le catalogue. - -La touche par défaut est **G**. - -Visez une créature adaptée puis utilisez l'action de possession. MCM valide la cible, prépare une transition compatible et ne supprime ou retire l'entité d'origine qu'après confirmation du nouvel état contrôlé par le joueur. - -Si la transition échoue, la créature d'origine ne doit pas simplement disparaître. - -La possession n'est pas limitée au catalogue interne de MCM. Une créature compatible créée par un autre mod peut fonctionner, mais la compatibilité universelle avec toutes les entités tierces n'est pas garantie. - -# Entrée Player - -**PLAYER** est une entrée spéciale du catalogue de créatures et non une cible de polymorph ordinaire. - -Son action de spawn crée un personnage séparé de type joueur et essaie de copier une présentation appropriée ainsi que les informations de santé maximale. - -Utiliser l'action de transformation sur **PLAYER** ne transforme pas un joueur déjà humain en doublon. Si le joueur se trouve dans une autre forme, cette action sert à revenir à la forme humaine. - -# Récupération Game Over en solo - -La build standalone solo inclut un chemin supplémentaire de récupération pour l'écran Game Over standard de Noita. - -Lorsque l'intégration native peut identifier en toute sécurité les structures nécessaires du jeu, MCM ajoute une action **« I didn't die »** à l'interface Game Over. - -MCM conserve une sauvegarde glissante de l'état du joueur pendant la partie. Activer la récupération demande la restauration via le chemin de mise à jour normal de MCM au lieu de reconstruire tout le joueur directement dans le handler du clic UI. - -Une récupération prise en charge tente de : - -- restaurer ou obtenir une entité de joueur vivante ; -- la rendre authoritative à nouveau ; -- effacer l'état Game Over du moteur ; -- rendre les contrôles et un état jouable ; -- nettoyer au mieux l'audio, la musique et l'interface Game Over ; -- offrir une courte fenêtre de protection après restauration. - -Le helper natif est conçu en fail-closed. Il analyse l'exécutable Noita pris en charge en cours d'exécution pour y trouver des structures connues au lieu d'écrire à une adresse unique codée en dur. Si les structures attendues ne peuvent plus être identifiées en sécurité après une mise à jour, la récupération optionnelle n'est pas utilisée plutôt que d'écrire dans une zone incertaine. - -# Météo et temps - -MCM peut contrôler l'état météo et temporel pris en charge, y compris des presets et paramètres individuels exposés par l'implémentation actuelle. - -Un état forcé peut ensuite être relâché vers le contrôle normal du jeu. Par exemple, après avoir forcé une heure précise, MCM peut abandonner ce paramètre afin que le cycle naturel de Noita reprenne. - -Les changements météo sont traités comme un état contrôlé et non comme des commandes console à sens unique. - -# Règles du monde - -La section **RULES** modifie le comportement global pris en charge du jeu. - -Les règles couvrent notamment : - -- les relations entre créatures ; -- le comportement de l'or ; -- l'utilisation des sorts ; -- le fog of war ; -- certaines récompenses de kill ; -- les drops de soin ; -- le comportement lié au sang ; -- la gravité ; -- la physique ; -- la force du coup de pied ; -- les joints physiques ; -- le cycle jour/nuit ; -- d'autres paramètres globaux pris en charge. - -Le principal objectif est la réversibilité. - -Pour les règles prises en charge, MCM enregistre ou dérive l'état d'origine afin de pouvoir restaurer le paramètre plus tard. Les multiplicateurs sont appliqués par rapport à la valeur d'origine au lieu de multiplier à répétition une valeur déjà modifiée. - -Les règles qui doivent toucher beaucoup d'entités ou d'objets physiques utilisent un travail borné réparti sur les frames au lieu de tenter de réécrire le monde entier synchroniquement en un clic. - -# Téléportation - -La section de téléportation propose des destinations préparées dans le monde, notamment des points de la route principale, les Holy Mountains, de grandes zones latérales et d'autres lieux pris en charge. - -Avant de déplacer le joueur, MCM peut demander le chargement de la zone cible et cherche un espace libre utilisable à proximité au lieu de placer volontairement le joueur dans un terrain solide. - -La téléportation dépend toujours de la capacité du monde à charger et fournir une destination valide. Les mondes fortement modifiés peuvent nécessiter un comportement de fallback. - -# Entangled Worlds - -**Entangled Worlds / Noita Proxy est optionnel.** MCM fonctionne sans lui. - -Lorsque EW est présent, MCM active des comportements supplémentaires adaptés au multijoueur. Tous les peers devraient utiliser des builds MCM compatibles lorsqu'ils dépendent d'un état synchronisé propre à MCM. - -## Authority et formes - -Les formes de joueur nécessitent un traitement particulier de l'ownership, car un joueur transformé ne doit pas laisser accidentellement une deuxième authority réseau. - -MCM coordonne l'ownership, le retirement et le retour à la forme humaine avec EW lorsque c'est pris en charge. Les entités de boss et de type Kolmi disposent d'un traitement lifecycle supplémentaire destiné à éviter les authorities dupliquées et les anciennes copies contrôlées par le réseau. - -Le chemin de mort normal d'EW reste responsable des entités qui ne sont pas reconnues comme état de forme appartenant à MCM. - -## Objets, baguettes et sorts - -Lorsque c'est possible, MCM utilise les mécanismes normaux d'objets / inventaire d'EW au lieu d'inventer un système de transport parallèle. - -Les mutations validées de baguette et d'inventaire de sorts demandent le refresh multijoueur approprié lorsque l'intégration est disponible. Les objets de monde créés par MCM peuvent être remis au chemin world-item standard d'EW. - -## Perks - -Les pickups normaux de perks peuvent utiliser la synchronisation world-item standard d'EW. La gestion de l'état des perks par MCM coordonne refresh et opérations bornées afin que les actions en lot n'émettent pas un refresh global coûteux pour chaque copie. - -## Matériaux - -La peinture de matériaux possède un chemin de compatibilité dédié, car les modifications de cellules du monde ne sont pas des entités d'objets normales. - -MCM garde le travail de peinture borné, sépare le travail aux frontières de chunks et coordonne les étapes world-frame / persistance nécessaires d'EW avant de libérer le travail de conversion synchronisé. Un chunk de bord encore non chargé est reporté plutôt que de bloquer tout le trait actif. - -L'objectif est que les peers EW proches puissent voir l'état peint pris en charge sans devoir rejouer à distance l'action UI normale de MCM sous forme d'un appel PixelScene basé uniquement sur un filename. - -Cela hérite toujours des hypothèses d'identifiants de matériaux d'EW : un jeu récepteur ne peut pas créer correctement un matériau absent de son registre ou dont le registre moteur est incompatible. - -## Météo, possession et état du monde - -L'état multijoueur pris en charge par MCM inclut aussi la coordination de la météo, de la possession et de certains comportements de règles / lifecycle. Des contrôles d'authority évitent que deux peers essaient de posséder le même état simultanément. - -Le support EW est volontairement conservateur. Lorsque l'intégration ne peut pas prouver un chemin de synchronisation sûr, MCM préfère le comportement local pris en charge plutôt que de prétendre que toute opération solo est automatiquement sûre en multijoueur. - -# Compatibilité et limites - -Noita expose de nombreux systèmes via des entités faiblement couplées, XML, composants Lua et comportement natif du moteur. MCM ne peut donc pas garantir une compatibilité universelle avec toutes les entités modifiées ni toutes les futures mises à jour du jeu. - -Limites importantes : - -- une créature peut être spawnable sans être une forme de joueur sûre ; -- un perk peut être accordable sans disposer d'une inversion fiable ; -- les transferts externes de sorts ou objets ne peuvent pas toujours être annulés depuis un snapshot interne ; -- des scripts tiers peuvent contourner les chemins de mort et récupération pris en charge ; -- les fonctions natives de récupération dépendent d'un comportement pris en charge de l'exécutable Noita et se désactivent en fail-closed si les structures nécessaires ne peuvent pas être identifiées ; -- Entangled Worlds ne peut pas synchroniser un matériau absent du registre du jeu récepteur ; -- des inventaires, entités ou règles fortement modifiés peuvent nécessiter un travail de compatibilité propre au mod concerné. - -MCM essaie de préserver l'état d'origine et de rollback les mutations échouées, mais un outil sandbox qui modifie l'état vivant du jeu ne peut pas rendre toute combinaison de mods tiers entièrement transactionnelle. - -# Données sauvegardées - -MCM conserve l'état utilisateur qui doit survivre entre les sessions, notamment les paramètres pris en charge, les touches, la disposition du menu et les presets de baguette. - -L'identité du mod reste stable afin que les mises à jour normales conservent les données prises en charge. Il est néanmoins recommandé de remplacer entièrement le dossier du mod lors de l'installation d'une nouvelle build standalone, car fusionner anciens et nouveaux fichiers peut laisser des fichiers runtime obsolètes. - -# Dépannage - -## Le mod n'apparaît pas - -Vérifiez que la structure se termine par : - -`mods/metamorph_creative_menu/mod.xml` - -Un dossier d'archive supplémentaire au-dessus de `metamorph_creative_menu` empêche Noita de détecter correctement le mod. - -## Les fonctions natives ou de matériaux ne marchent pas - -Vérifiez que **Unsafe Mods** est autorisé et que vous avez installé la build standalone GitHub sans mélanger de fichiers de la Workshop. - -## Le menu s'ouvre mais une action du jeu se déclenche aussi - -Vérifiez les conflits de touches personnalisées. MCM affiche les doublons mais permet volontairement de les conserver si vous le souhaitez. - -## Une créature ne peut pas être utilisée comme transformation sûre - -Toute entité XML spawnable n'est pas nécessairement une forme de joueur prise en charge. Des règles par chemin exact existent pour les créatures nécessitant un traitement spécial. - -## Un perk ne peut pas être retiré - -La suppression n'est proposée que lorsque MCM dispose d'une opération inverse prise en charge pour l'état suivi. C'est volontaire : deviner un nettoyage peut endommager un état du joueur sans rapport. - -## Le multijoueur se comporte différemment entre les peers - -Utilisez des builds MCM compatibles sur tous les participants et un environnement Noita / Entangled Worlds compatible. MCM ne peut pas réparer un registre de matériaux incompatible ni des modifications réseau arbitraires d'autres mods. - -# Signaler un bug - -Un bon rapport doit inclure : - -- ce que vous essayiez de faire ; -- la section et l'action exactes de MCM ; -- si le problème se produit en solo, avec Entangled Worlds ou dans les deux cas ; -- si la build standalone ou Workshop est installée ; -- si d'autres mods de gameplay sont actifs ; -- des étapes fiables de reproduction ; -- les logs Noita / EW pertinents lorsqu'ils sont disponibles. - -Pour les problèmes de transformation, possession, objet ou matériau, indiquez l'entité ou le matériau exact si possible. Les identifiants techniques sont souvent plus utiles qu'un nom traduit affiché. - -# Dépôt et source de développement - -Le dépôt contient volontairement **l'arbre de développement complet**, et non la même archive réduite téléchargée par les joueurs. - -`metamorph_creative_menu/` contient le code runtime avec : - -- les tests automatisés ; -- les outils QA ; -- les diagnostics ; -- les sources natives ; -- les outils de build ; -- les règles de nettoyage de release ; -- la documentation de développement. - -Ces fichiers sont utiles au développement et aux tests de régression, ils restent donc dans le source GitHub. Le ZIP prêt pour les joueurs est généré séparément et exclut le contenu réservé au développement. - -Le paquet joueur reçoit aussi un nettoyage spécifique au release, notamment le `README.txt` minimal du paquet, tandis que l'arbre source conserve sa documentation de développement. - -# Tests - -La suite automatisée se trouve dans `metamorph_creative_menu/tests/` et combine des vérifications de contrat Python avec des mocks Lua. - -Depuis la racine du dépôt, le workflow de release exécute la suite sur le source importé complet avant de publier la build joueur. `texlua` est requis pour la partie mocks Lua. - -Les vérifications de source hygiene protègent également les fichiers orientés production et la documentation contre les restes d'historique de développement, anciennes surfaces de debug et artefacts accidentels de processus. - -# Import du source et processus de release - -Le source de développement complet peut être importé depuis une archive de la famille `Metamorph-Creative-Menu-v...zip`. - -Le workflow d'import vérifie la structure, exige les composants complets de développement, exécute source hygiene et la suite de régression avant de commit l'arbre importé. - -Une archive ModWorkshop / player-style n'est pas traitée comme source de développement. - -La release publique `latest-build` est ensuite produite depuis le source complet par un player-builder séparé. Celui-ci retire QA, tests, diagnostics, source natif et autres payloads réservés au développement, applique les règles de nettoyage, valide l'archive résultante puis seulement met à jour l'asset de téléchargement stable. - -Cette séparation permet au dépôt de rester complet pour le développement tout en gardant le téléchargement normal des joueurs compact et sans instrumentation de développement. - -# Composants tiers - -Les composants tiers, dépendances incluses et projets upstream sont documentés dans [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). diff --git a/README.it.md b/README.it.md deleted file mode 100644 index 485c769..0000000 --- a/README.it.md +++ /dev/null @@ -1,586 +0,0 @@ - - -[English](README.md) · [Русский](README.ru.md) · [Português (Brasil)](README.pt-BR.md) · [Español](README.es.md) · [Deutsch](README.de.md) · [Français](README.fr.md) · [**Italiano**](README.it.md) · [Polski](README.pl.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) · [한국어](README.ko.md) - -

Metamorph: Creative Menu

- -

Un menu creativo e toolkit sandbox per Noita: incantesimi, bacchette, oggetti, materiali, perk, effetti, creature, trasformazioni, possessione, teletrasporto, meteo, regole del mondo, integrazione multiplayer e strumenti di recupero.

- -

Creatore e maintainer: zerodancing

- ---- - -# Download - -Per giocare normalmente, usa la build pronta da installare: - -[**⬇️ Scarica la build più recente**](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/download/latest-build/Metamorph-Creative-Menu.zip) - -[Pagina della build più recente](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/tag/latest-build) · [Changelog](metamorph_creative_menu/CHANGELOG.txt) - -La release GitHub viene generata automaticamente dall'albero completo di sviluppo. Test, strumenti QA, diagnostica, sorgenti native e strumenti di build restano nel repository ma vengono esclusi dall'archivio per i giocatori. - -La build standalone GitHub include NoitaPatcher e supporto nativo di recupero, quindi **Unsafe Mods deve essere consentito**. - -# Installazione - -1. Scarica `Metamorph-Creative-Menu.zip` dal link qui sopra. -2. Avvia Noita e apri **Mods** dal menu principale. -3. Fai clic su **Open mods folder**. -4. Estrai o sposta la cartella `metamorph_creative_menu` nella cartella `mods`. Il percorso finale deve contenere direttamente `metamorph_creative_menu/mod.xml`, senza una cartella extra creata dall'archivio. -5. Se è già presente una copia vecchia, sostituisci l'intera cartella `metamorph_creative_menu` invece di unire file vecchi e nuovi. -6. Torna in Noita e aggiorna la lista dei mod. -7. Consenti **Unsafe Mods**. -8. Attiva **Metamorph: Creative Menu** e avvia una partita con i mod attivi. - -Non attivare contemporaneamente la build standalone GitHub e la versione Steam Workshop. - -# Build standalone e Steam Workshop - -La build distribuita tramite questo repository GitHub è la build standalone completa. Include NoitaPatcher e funzioni che richiedono accesso non limitato alla mod API, comprese operazioni a basso livello sui materiali e recupero nativo dopo Game Over. - -La [build Steam Workshop](https://steamcommunity.com/sharedfiles/filedetails/?id=3785170245) viene installata separatamente. Non include i componenti nativi necessari alle funzioni disponibili solo nella build standalone. - -Le due build usano la stessa identità del mod. Installarle entrambe può quindi causare file duplicati o in conflitto e non è supportato. - -# Informazioni sul mod - -**Metamorph: Creative Menu (MCM)** è un menu creativo e toolkit sandbox per Noita. - -Riunisce strumenti per: - -- incantesimi e inventario degli incantesimi; -- modifica delle bacchette e preset riutilizzabili; -- oggetti e contenitori di liquidi; -- catalogo completo dei materiali e pittura con materiali; -- perk e rimozione supportata dei perk; -- status ed entità GameEffect; -- creature, trasformazioni e possessione; -- meteo e tempo; -- regole globali del mondo; -- teletrasporto; -- integrazione opzionale con Entangled Worlds; -- percorsi di recupero dopo trasformazioni, morte della forma e Game Over. - -MCM prova a lavorare sullo stato reale di Noita invece di sostituire tutto con copie decorative. Le carte di incantesimo esistenti vengono spostate come entità, la consegna degli oggetti rispetta la struttura dell'inventario, le modifiche alle bacchette usano percorsi di commit/rollback, i materiali restano materiali realmente simulati e le regole reversibili conservano abbastanza stato originale da ripristinare in seguito le impostazioni supportate. - -Entangled Worlds è opzionale. Senza EW, MCM rimane un mod single-player completo. - -# Controlli - -Controlli predefiniti: - -| Azione | Input predefinito | -| --- | --- | -| Apri / chiudi il menu creativo | **F4** | -| Torna alla forma umana durante una trasformazione | **TAB** | -| Possiedi una creatura nel mondo | **G** | -| Dipingi con il materiale selezionato | **Tasto centrale del mouse** | - -Il pannello creativo è disponibile anche tramite la normale interfaccia inventario di Noita. - -Le associazioni possono essere modificate nella sezione **CONTROLS** di MCM e nelle impostazioni mod di Noita. Sono supportati tasti, pulsanti del mouse e combinazioni esatte con **CTRL / SHIFT / ALT**. - -Durante l'assegnazione: - -- **DELETE / BACKSPACE** cancella l'associazione; -- **ESC** annulla; -- **R** ripristina il default per quell'azione; -- **RESET ALL** ripristina tutte le associazioni predefinite dopo conferma. - -Le associazioni duplicate restano modificabili, ma MCM mostra il conflitto invece di sostituire silenziosamente un'altra azione. - -Navigazione del menu, sezioni, ritorno dalla forma, possessione, pittura dei materiali, pulizia degli effetti, rilascio del meteo, reset delle regole del mondo e azioni multiplayer supportate possono essere riassegnati. - -# Finestra Creative Menu - -Il pannello creativo diretto è una finestra persistente e ridimensionabile, non un overlay di debug fisso. - -Può essere: - -- spostata tramite la barra del titolo; -- ridimensionata da bordi e angoli; -- minimizzata; -- chiusa; -- ripristinata al layout predefinito. - -Posizione, larghezza, altezza e ultima sezione aperta vengono ricordate tra le sessioni. Dopo un cambio di risoluzione, la geometria salvata viene riportata nell'area visibile della GUI. - -Liste e cataloghi usano layout misurati e contenitori di scorrimento di Noita. Ridimensionare la finestra cambia immediatamente la quantità di contenuto visibile, mentre le etichette tradotte possono andare a capo senza sovrapporsi ai controlli vicini. Nei layout stretti i controlli passano su righe aggiuntive invece di essere disegnati uno sopra l'altro. - -Aprire o semplicemente passare il mouse sopra il menu separato non disabilita permanentemente il gameplay. Quando un clic, un drag o un campo di testo attivo potrebbe anche azionare il giocatore, MCM sopprime temporaneamente i controlli rilevanti e li ripristina subito dopo. - -# Ricerca e localizzazione - -La ricerca è disponibile nei cataloghi principali, inclusi incantesimi, oggetti, materiali, perk e creature. - -A seconda della voce può corrispondere a: - -- nome nella lingua corrente dell'interfaccia; -- nome inglese; -- chiavi di localizzazione; -- identificatori tecnici; -- percorsi XML. - -La ricerca non distingue maiuscole/minuscole, normalizza accenti e separatori comuni e tollera piccoli errori di battitura nelle query più lunghe. - -L'interfaccia propria di MCM è localizzata in: - -- inglese; -- russo; -- portoghese brasiliano; -- spagnolo; -- tedesco; -- francese; -- italiano; -- polacco; -- cinese semplificato; -- giapponese; -- coreano. - -Per il contenuto normale di Noita, il mod riusa quando possibile le chiavi di localizzazione del gioco invece di mantenere nomi duplicati. - -# Incantesimi - -La sezione degli incantesimi lavora sia con il catalogo sia con le entità incantesimo già possedute dal giocatore. - -L'area principale contiene: - -- slot normali della bacchetta attiva; -- carte **ALWAYS CAST**; -- inventario degli incantesimi del giocatore; -- catalogo ricercabile degli incantesimi. - -## Sostituzione rapida dello slot selezionato - -Un clic breve seleziona uno slot della bacchetta. Poi un clic LMB breve su un incantesimo del catalogo sostituisce quello slot. - -È il percorso rapido per la modifica normale. Per gli spostamenti precisi si usa il drag-and-drop. - -## Drag-and-drop transazionale - -Le carte esistenti possono essere trascinate: - -- tra gli slot della bacchetta; -- dagli slot normali a **ALWAYS CAST**; -- da **ALWAYS CAST** agli slot normali; -- in uno slot preciso dell'inventario incantesimi; -- dall'inventario alla bacchetta; -- nel mondo; -- nel cestino quando supportato. - -Per una carta esistente, MCM sposta l'entità reale quando possibile. Stato mutabile, usi rimanenti e dati aggiunti da altri mod non vengono quindi persi solo perché la carta cambia posizione. - -La sorgente resta intatta finché la transazione di destinazione non viene confermata. Destinazioni non valide o sconosciute annullano l'operazione invece di cancellare la carta originale. Un rilascio del mouse esegue al massimo una singola operazione confermata. - -Le carte del catalogo sono template e non vengono mai consumate trascinandole. - -## Always Cast - -Le carte Always Cast hanno una fascia dedicata. Promozione, retrocessione e scambio tengono conto della capacità effettiva degli slot normali per evitare una struttura di bacchetta non valida. - -## Annulla e ripeti - -Le mutazioni interne della bacchetta dispongono di una cronologia limitata **UNDO / REDO**. - -Le operazioni che consegnano una vera entità al mondo esterno o a un altro inventario non possono sempre essere invertite in sicurezza da uno snapshot della bacchetta, quindi questi trasferimenti esterni non sono garantiti come sempre annullabili. - -# Bacchette - -L'area bacchetta modifica la bacchetta attualmente impugnata dal giocatore. - -Le statistiche supportate includono: - -- capacità / slot; -- incantesimi per cast; -- tempo di ricarica; -- ritardo tra i cast; -- dispersione; -- moltiplicatore velocità proiettili; -- mana massimo; -- velocità di ricarica mana; -- recupero rinculo; -- livello della bacchetta; -- shuffle; -- comportamento senza ricarica. - -MCM modifica anche presentazione e metadati correlati: - -- nome mostrato; -- blocchi della bacchetta e delle carte; -- percorso sprite; -- offset dello sprite; -- posizione di sparo. - -Un catalogo visivo di aspetto segue i dati XML delle bacchette quando disponibili. - -## Preset delle bacchette - -Le bacchette possono essere salvate come preset persistenti con nome e riutilizzate in altri mondi o sessioni future di Noita. - -Un preset può conservare: - -- statistiche della bacchetta; -- valori di mana; -- metadati visivi; -- carte normali; -- carte Always Cast; -- posizioni degli slot; -- usi rimanenti; -- stato congelato delle carte. - -Ogni preset ha due operazioni separate: - -- **APPLY** scrive il blueprint salvato sulla bacchetta attualmente impugnata; -- **GET COPY** costruisce una nuova bacchetta dallo stesso blueprint. - -La copia viene inserita in uno slot libero per bacchette dell'inventario rapido quando possibile. Se non c'è uno slot adatto, la bacchetta completa viene lasciata nel mondo vicino al giocatore. - -Sostituzione della bacchetta e caricamento dei preset usano percorsi di commit/rollback. Se costruzione o posizionamento non possono essere completati, MCM prova a rimuovere l'albero entità incompleto invece di lasciare una bacchetta parziale danneggiata. - -# Oggetti e liquidi - -## Oggetti - -Un clic breve **LMB** su una voce del catalogo crea un oggetto supportato vicino al giocatore. - -**RMB** tenta di consegnarlo alla zona corretta dell'inventario. - -Le voci del catalogo possono anche essere trascinate: - -- verso una destinazione compatibile dell'inventario rapido; -- fuori dal menu verso una posizione esatta del mondo. - -Rilasciare la carta dentro il menu senza una destinazione valida annulla l'operazione. La carta del catalogo è solo un template e resta disponibile. - -MCM rispetta la normale separazione dell'inventario rapido di Noita tra slot per bacchette e slot per oggetti. Un errore nel caricamento XML, riempimento di liquidi, consegna all'inventario o handoff multiplayer opzionale rimuove la nuova entità quando possibile. - -Alcuni veri oggetti da inventario si trovano in directory del gioco orientate alle creature. MCM classifica i casi noti in base al comportamento invece di assumere che il nome della cartella da solo determini se qualcosa sia un oggetto o una creatura. - -## Liquidi - -Le voci dei liquidi creano veri contenitori Noita già riempiti, non oggetti decorativi dell'interfaccia. - -Il contenitore può essere portato, lasciato cadere, rotto e versato, e il contenuto partecipa alle normali reazioni dei materiali. - -# Materiali - -La sezione Materials è uno strumento di pittura del mondo basato sul vero registro dei materiali di Noita. - -Il catalogo viene costruito da liquidi, sabbie / polveri, gas, fuochi, solidi e materiali statici o di effetti registrati dal motore. I materiali aggiunti correttamente da altri mod attivi possono quindi comparire automaticamente. - -La scoperta e la validazione costosa dei materiali sono distribuite come lavoro limitato invece di analizzare l'intero catalogo in un singolo frame UI. - -## Presentazione dei materiali - -I liquidi usano la stessa presentazione con contenitore pieno della sezione Oggetti. - -Per i materiali non liquidi, MCM preferisce texture e dati tint definiti in `materials.xml`, incluse le definizioni ereditate. Se non esiste una texture definita, il fallback deriva dal vero colore del materiale nel motore e non da un colore preview arbitrario. - -## Pittura - -1. Seleziona un materiale. -2. Seleziona la dimensione del pennello. -3. Attiva la modalità pittura. -4. Chiudi l'inventario. -5. Tieni premuto l'input di disegno configurato nel mondo. - -Aprire l'inventario interrompe la modalità pittura attiva. - -La pittura non emette semplicemente particelle decorative. MCM inserisce celle reali nel mondo tramite un percorso appropriato al motore. I materiali dinamici continuano a seguire la simulazione di Noita: i liquidi scorrono, le polveri cadono, i gas si muovono, il fuoco reagisce e le sostanze instabili possono trasformarsi tramite reazioni materiali. - -Classi diverse richiedono strategie di posizionamento diverse. La build standalone può usare l'accesso diretto di NoitaPatcher alla griglia del mondo e un piccolo fallback PixelScene per casi definiti che Noita rifiuta di costruire direttamente in una determinata coordinata della texture. - -Le code di lavoro sono limitate, così mantenere un pennello grande non esegue intenzionalmente una quantità illimitata di lavoro in un singolo frame. - -# Perk - -## Creare e ricevere perk - -**LMB** crea un pickup normale del perk selezionato nel mondo. - -L'azione di ricezione può concedere il perk singolarmente o in blocco. Le operazioni in blocco vengono processate come job limitati invece di applicare tutte le copie in un solo frame UI. - -L'interfaccia mostra l'avanzamento e il lavoro ancora in attesa può essere annullato. Le copie già confermate prima dell'annullamento restano applicate. - -Ogni copia concessa continua a usare il normale percorso di applicazione del perk invece di simulare direttamente lo stato finale. - -## Rimuovere perk - -Rimuovere un perk è molto più complesso che concederlo. I perk possono modificare globals, componenti, entità, statistiche del giocatore e meccaniche persistenti, e Noita non offre un'operazione inversa universale. - -Per questo MCM rimuove soltanto lo stato per cui dispone di un'inversione tracciata sufficientemente sicura. Il journal della transazione prova a rimuovere solo lo stato appartenente a quella specifica applicazione del perk, senza resettare stato non correlato del giocatore. - -Se una pulizia è parziale o non può essere dimostrata completa, resta trattata come incompleta invece di essere riportata silenziosamente come riuscita. - -Un perk di terze parti può essere concedibile senza essere correttamente rimovibile. - -# Effetti - -La sezione Effects applica e rimuove status dei materiali ed entità GameEffect supportate. - -La rimozione tiene conto della proprietà quando possibile. MCM evita di cancellare indiscriminatamente effetti nascosti simili appartenenti a perk, al gioco o a un altro sistema. - -Gli effetti persistenti creati da MCM usano pulizia / scadenza limitata, in modo che rimuovere un effetto MCM non resetti stato altrui. - -# Creature - -Il catalogo delle creature conserva percorsi XML esatti invece di unire tutte le entità con nomi simili. - -Interazioni supportate: - -- **LMB** — crea l'entità definita selezionata vicino al giocatore; -- trascina fuori dal menu — crea alla posizione confermata del cursore nel mondo; -- **RMB** — trasforma il giocatore attuale in una forma supportata; -- voce speciale **PLAYER** — crea o ripristina stato del giocatore come descritto sotto. - -Rilasciare una carta trascinata di nuovo sopra il menu annulla lo spawn nel mondo. - -Le regole di compatibilità per forme pericolose o insolite usano percorsi esatti. Un nome file che contiene semplicemente una parola familiare non rende automaticamente l'entità equivalente a un'altra forma. - -# Trasformazioni e ritorno alla forma umana - -Le forme giocabili conservano movimento nativo utile, attacchi, presentazione e fisica quando pratico. I componenti che competono direttamente con l'input del giocatore possono essere disabilitati o adattati mentre la forma è controllata dal giocatore. - -Alcune creature complesse richiedono logica aggiuntiva. Boss, wrapper scriptati ed entità fortemente dipendenti dalla fisica non sono garantiti a comportarsi esattamente come le versioni controllate dall'IA quando usati come forma del giocatore. - -L'azione di ritorno configurata — **TAB** per default — usa prima il normale percorso di fine trasformazione. Quando non basta, la build standalone dispone di percorsi aggiuntivi di ripristino tramite NoitaPatcher. - -Nei casi supportati di danno letale, MCM prova a: - -- lasciare la forma temporanea morta o il cadavere nel mondo quando appropriato; -- ripristinare un'entità umana del giocatore; -- restituire authority e controlli; -- preservare l'inventario; -- ripristinare stato rilevante del giocatore. - -Questa è logica di recupero, non immortalità assoluta. Un kill script di terze parti, stato incompatibile del motore o crash del processo può aggirare l'handoff supportato. - -# Possessione - -La possessione controlla una creatura che esiste già nel mondo invece di scegliere una forma dal catalogo. - -Il tasto predefinito è **G**. - -Punta una creatura adatta e usa l'azione di possessione. MCM valida il bersaglio, prepara una transizione compatibile e rimuove o ritira l'entità originale dal mondo solo dopo aver confermato il nuovo stato controllato dal giocatore. - -Se la transizione fallisce, la creatura originale non dovrebbe semplicemente sparire. - -La possessione non è limitata al catalogo interno di MCM. Una creatura compatibile creata da un altro mod può funzionare, ma non è garantita compatibilità universale con ogni entità di terze parti. - -# Voce Player - -**PLAYER** è una voce speciale del catalogo creature, non un normale bersaglio polymorph. - -La sua azione di spawn crea un personaggio separato simile al giocatore e prova a copiare presentazione appropriata e informazioni sulla salute massima. - -Usare l'azione di trasformazione su **PLAYER** non trasforma un giocatore già umano in un duplicato. Se il giocatore è in un'altra forma, l'azione viene usata per tornare alla forma umana. - -# Recupero Game Over in single-player - -La build standalone single-player include un percorso aggiuntivo di recupero per la schermata Game Over standard di Noita. - -Quando l'integrazione nativa riesce a identificare in sicurezza le strutture necessarie del gioco, MCM aggiunge l'azione **“I didn't die”** all'interfaccia Game Over. - -MCM mantiene un backup aggiornato dello stato del giocatore durante la partita. Attivare il recupero richiede il ripristino tramite il normale percorso di aggiornamento di MCM invece di ricostruire tutto il giocatore direttamente nel gestore del clic UI. - -Un recupero supportato prova a: - -- ripristinare o ottenere un'entità giocatore viva; -- renderla di nuovo authoritative; -- cancellare lo stato Game Over del motore; -- restituire controlli e stato utilizzabile; -- eseguire pulizia best-effort di audio, musica e interfaccia Game Over; -- fornire una breve finestra di protezione dopo il ripristino. - -L'helper nativo è progettato in fail-closed. Analizza l'eseguibile Noita supportato in esecuzione alla ricerca di strutture note invece di scrivere a un singolo indirizzo codificato per sempre. Se le strutture attese non possono essere identificate in sicurezza dopo un aggiornamento, il recupero opzionale non viene usato invece di scrivere in una posizione incerta. - -# Meteo e tempo - -MCM può controllare stato meteo e temporale supportato, inclusi preset e singoli parametri esposti dall'implementazione corrente. - -Uno stato forzato può essere poi rilasciato al normale controllo del gioco. Per esempio, dopo aver fissato un'ora precisa, MCM può smettere di possedere quell'impostazione in modo che il flusso naturale del tempo di Noita riprenda. - -Le modifiche meteo vengono trattate come stato controllato, non come comandi console a senso unico. - -# Regole del mondo - -La sezione **RULES** modifica il comportamento globale supportato del gioco. - -Le regole coprono aree come: - -- relazioni tra creature; -- comportamento dell'oro; -- uso degli incantesimi; -- fog of war; -- ricompense selezionate per uccisioni; -- drop di cura; -- comportamento legato al sangue; -- gravità; -- fisica; -- forza del calcio; -- giunti fisici; -- ciclo giorno/notte; -- altri parametri globali supportati. - -L'obiettivo principale è la reversibilità. - -Per le regole supportate, MCM registra o deriva lo stato originale per poter ripristinare l'impostazione in seguito. I controlli moltiplicatori vengono applicati rispetto al valore originale invece di moltiplicare ripetutamente un risultato già modificato. - -Le regole che devono toccare molte entità o oggetti fisici usano lavoro limitato distribuito sui frame invece di riscrivere sincronicamente l'intero mondo con un clic. - -# Teletrasporto - -La sezione teletrasporto offre destinazioni preparate nel mondo, inclusi punti lungo il percorso principale, Holy Mountains, grandi aree laterali e altri luoghi supportati. - -Prima di spostare il giocatore, MCM può richiedere il caricamento dell'area di destinazione e cerca spazio libero utilizzabile nelle vicinanze invece di posizionare intenzionalmente il giocatore dentro terreno solido. - -Il teletrasporto dipende comunque dalla capacità del mondo di caricarsi e fornire una destinazione valida. Mondi fortemente modificati possono richiedere comportamento di fallback. - -# Entangled Worlds - -**Entangled Worlds / Noita Proxy è opzionale.** MCM funziona senza EW. - -Quando EW è presente, MCM abilita comportamenti aggiuntivi consapevoli del multiplayer. Tutti i peer dovrebbero usare build MCM compatibili quando dipendono da stato sincronizzato specifico di MCM. - -## Authority e forme - -Le forme del giocatore richiedono gestione speciale dell'ownership perché un giocatore trasformato non deve lasciare accidentalmente una seconda authority di rete. - -MCM coordina ownership, retirement e ritorno alla forma umana con EW dove supportato. Entità di boss e tipo Kolmi dispongono di trattamento lifecycle aggiuntivo pensato per evitare authority duplicate e vecchie copie controllate dalla rete. - -Il normale percorso di morte di EW resta responsabile delle entità non riconosciute come stato di forma appartenente a MCM. - -## Oggetti, bacchette e incantesimi - -Quando possibile, MCM usa i normali meccanismi di item / inventario di EW invece di inventare un sistema di trasporto parallelo. - -Le modifiche confermate a bacchette e inventario incantesimi richiedono l'aggiornamento multiplayer appropriato quando l'integrazione è disponibile. Gli oggetti del mondo creati da MCM possono essere consegnati al normale percorso world-item di EW. - -## Perk - -I pickup normali dei perk possono usare la sincronizzazione world-item standard di EW. La gestione dello stato dei perk da parte di MCM coordina refresh e operazioni limitate, così le azioni in blocco non tentano un costoso refresh globale per ogni copia. - -## Materiali - -La pittura dei materiali ha un percorso dedicato di compatibilità perché le modifiche alle celle del mondo non sono normali entità oggetto. - -MCM mantiene il lavoro di pittura limitato, separa il lavoro ai confini dei chunk e coordina i passaggi world-frame / persistenza richiesti da EW prima di rilasciare lavoro di conversione sincronizzato. Un chunk di bordo non ancora caricato viene rimandato invece di bloccare l'intero tratto attivo. - -L'obiettivo è permettere ai peer EW vicini di vedere lo stato dipinto supportato senza dover riprodurre a distanza la normale azione UI di MCM come chiamata PixelScene basata solo sul filename. - -Restano comunque le assunzioni di EW sugli ID dei materiali: un gioco ricevente non può creare correttamente un materiale che non esiste lì o il cui registro materiali del motore è incompatibile. - -## Meteo, possessione e stato del mondo - -Lo stato multiplayer supportato di MCM include anche coordinamento per meteo, possessione e alcuni comportamenti di regole / lifecycle. I controlli di authority evitano che due peer provino a possedere lo stesso stato contemporaneamente. - -Il supporto EW è intenzionalmente conservativo. Quando l'integrazione non può dimostrare un percorso di sincronizzazione sicuro, MCM preferisce il comportamento locale supportato invece di fingere che ogni operazione single-player sia automaticamente sicura in multiplayer. - -# Compatibilità e limitazioni - -Noita espone molti sistemi tramite entità debolmente accoppiate, XML, componenti Lua e comportamento nativo del motore. MCM non può quindi promettere compatibilità universale con ogni entità modificata o ogni futuro aggiornamento del gioco. - -Limitazioni importanti: - -- una creatura può essere spawnabile senza essere una forma giocatore sicura; -- un perk può essere concedibile senza avere un'inversione affidabile; -- trasferimenti esterni di incantesimi o oggetti non possono sempre essere annullati da uno snapshot interno; -- script di terze parti possono aggirare percorsi supportati di morte e recupero; -- le funzioni native di recupero dipendono da comportamento supportato dell'eseguibile Noita e falliscono in sicurezza se le strutture richieste non possono essere identificate; -- Entangled Worlds non può sincronizzare un materiale assente dal registro del gioco ricevente; -- inventari, entità o regole fortemente modificati possono richiedere compatibilità specifica per quel mod. - -MCM prova a preservare lo stato originale e fare rollback delle mutazioni fallite, ma uno strumento sandbox che modifica stato vivo del gioco non può rendere completamente transazionale ogni combinazione di mod di terze parti. - -# Dati salvati - -MCM persiste lo stato utente che deve sopravvivere tra le sessioni, comprese impostazioni supportate, associazioni, layout del menu e preset delle bacchette. - -L'identità del mod rimane stabile così gli aggiornamenti normali possono conservare i dati supportati. È comunque consigliato sostituire l'intera cartella del mod quando si installa una nuova build standalone, perché unire file vecchi e nuovi può lasciare runtime obsoleto. - -# Risoluzione dei problemi - -## Il mod non compare - -Verifica che il percorso termini con: - -`mods/metamorph_creative_menu/mod.xml` - -Una cartella extra sopra `metamorph_creative_menu` impedisce a Noita di vedere correttamente il mod. - -## Le funzioni native o dei materiali non funzionano - -Verifica che **Unsafe Mods** sia consentito e che sia installata la build standalone GitHub senza mescolare file della Workshop. - -## Il menu si apre ma si attiva anche un'azione di gioco - -Controlla i conflitti nelle associazioni personalizzate. MCM mostra i duplicati ma permette intenzionalmente di mantenerli se desiderato. - -## Una creatura non può essere trasformata in sicurezza - -Non ogni entità XML spawnabile è una forma giocatore supportata. Esistono regole per percorso esatto per le creature che richiedono gestione speciale. - -## Un perk non può essere rimosso - -La rimozione è disponibile solo dove MCM possiede un'operazione inversa supportata per lo stato tracciato. È intenzionale: indovinare la pulizia può danneggiare stato non correlato del giocatore. - -## Il multiplayer si comporta diversamente tra i peer - -Usa build MCM compatibili su tutti i partecipanti e un ambiente Noita / Entangled Worlds compatibile. MCM non può correggere un registro materiali incompatibile o modifiche di rete arbitrarie di altri mod. - -# Segnalare bug - -Una segnalazione utile dovrebbe includere: - -- cosa stavi cercando di fare; -- sezione e azione esatte di MCM; -- se il problema avviene in single-player, Entangled Worlds o entrambi; -- se è installata la build standalone o Workshop; -- se sono attivi altri gameplay mod; -- passaggi affidabili per riprodurre il problema; -- log rilevanti di Noita / EW quando disponibili. - -Per problemi di trasformazione, possessione, oggetti o materiali, indica se possibile l'entità o il materiale esatto. Gli identificatori tecnici sono spesso più utili di un nome visualizzato tradotto. - -# Repository e sorgente di sviluppo - -Il repository contiene intenzionalmente **l'albero completo di sviluppo**, non lo stesso archivio ridotto scaricato dai giocatori. - -`metamorph_creative_menu/` contiene codice runtime insieme a: - -- test automatizzati; -- strumenti QA; -- diagnostica; -- sorgenti native; -- strumenti di build; -- regole di pulizia della release; -- documentazione di sviluppo. - -Questi file sono utili per sviluppo e test di regressione, quindi restano nel source GitHub. Lo ZIP pronto per i giocatori viene generato separatamente ed esclude il contenuto solo sviluppo. - -Il pacchetto giocatore riceve inoltre pulizia specifica della release, incluso il `README.txt` minimo del pacchetto, mentre l'albero source mantiene la documentazione di sviluppo. - -# Test - -La suite automatizzata si trova in `metamorph_creative_menu/tests/` e combina controlli di contratto Python con mock test Lua. - -Dalla root del repository, il workflow di release esegue la suite sul source completo importato prima di pubblicare la build per i giocatori. `texlua` è necessario per la parte di mock Lua. - -I controlli di source hygiene proteggono inoltre file orientati alla produzione e documentazione da residui della cronologia di sviluppo, vecchie superfici di debug e artefatti accidentali del processo. - -# Importazione del source e processo di release - -Il source completo di sviluppo può essere importato da un archivio della famiglia `Metamorph-Creative-Menu-v...zip`. - -Il workflow di importazione verifica la struttura, richiede tutti i componenti di sviluppo, esegue source hygiene e la suite di regressione prima di committare l'albero importato. - -Un archivio ModWorkshop / player-style non viene trattato come source di sviluppo. - -La release pubblica `latest-build` viene poi prodotta dal source completo tramite un player-builder separato. Il builder rimuove QA, test, diagnostica, sorgenti native e altro payload solo sviluppo, applica le regole di pulizia, valida l'archivio risultante e solo allora aggiorna l'asset di download stabile. - -Questa separazione mantiene il repository utile per lo sviluppo lasciando il download normale dei giocatori piccolo e privo di strumentazione di sviluppo. - -# Componenti di terze parti - -Componenti di terze parti, dipendenze incluse e progetti upstream sono documentati in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). diff --git a/README.ja.md b/README.ja.md deleted file mode 100644 index d7583cd..0000000 --- a/README.ja.md +++ /dev/null @@ -1,586 +0,0 @@ - - -[English](README.md) · [Русский](README.ru.md) · [Português (Brasil)](README.pt-BR.md) · [Español](README.es.md) · [Deutsch](README.de.md) · [Français](README.fr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [简体中文](README.zh-CN.md) · [**日本語**](README.ja.md) · [한국어](README.ko.md) - -

Metamorph: Creative Menu

- -

Noita 向けのクリエイティブメニュー兼サンドボックスツールキット。呪文、杖、アイテム、素材、パーク、効果、クリーチャー、変身、憑依、テレポート、天候、ワールドルール、マルチプレイ連携、復旧機能をまとめて扱えます。

- -

作者・メンテナ: zerodancing

- ---- - -# ダウンロード - -通常のプレイには、インストール可能なビルドを使用してください。 - -[**⬇️ 最新ビルドをダウンロード**](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/download/latest-build/Metamorph-Creative-Menu.zip) - -[最新ビルドのページ](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/tag/latest-build) · [変更履歴](metamorph_creative_menu/CHANGELOG.txt) - -GitHub の release は完全な開発ソースツリーから自動生成されます。テスト、QA ツール、診断、ネイティブソース、ビルドツールはリポジトリに残りますが、プレイヤー向けアーカイブには含まれません。 - -GitHub の standalone ビルドには NoitaPatcher とネイティブ復旧機能が含まれるため、**Unsafe Mods を許可**する必要があります。 - -# インストール - -1. 上のリンクから `Metamorph-Creative-Menu.zip` をダウンロードします。 -2. Noita を起動し、メインメニューから **Mods** を開きます。 -3. **Open mods folder** を押します。 -4. `metamorph_creative_menu` フォルダを `mods` に展開または移動します。最終的なパスは余分なフォルダを挟まず、直接 `metamorph_creative_menu/mod.xml` を含む必要があります。 -5. 古いコピーがある場合は、新旧ファイルを混在させず `metamorph_creative_menu` フォルダ全体を置き換えてください。 -6. Noita に戻り、Mod 一覧を更新します。 -7. **Unsafe Mods** を許可します。 -8. **Metamorph: Creative Menu** を有効にして、Mod 有効状態でゲームを開始します。 - -GitHub standalone ビルドと Steam Workshop ビルドを同時に有効にしないでください。 - -# Standalone ビルドと Steam Workshop - -この GitHub リポジトリで配布されるものは完全な standalone ビルドです。NoitaPatcher と、制限のない mod API アクセスを必要とする機能を含みます。たとえば低レベル素材操作や、Game Over 後のネイティブ復旧です。 - -[Steam Workshop ビルド](https://steamcommunity.com/sharedfiles/filedetails/?id=3785170245) は別にインストールされます。Standalone 専用機能に必要なネイティブコンポーネントは含みません。 - -両方のビルドは同じ Mod ID を使います。同時インストールはファイルの重複や競合を起こす可能性があり、サポートされません。 - -# Mod について - -**Metamorph: Creative Menu (MCM)** は Noita のクリエイティブメニュー兼サンドボックスツールキットです。 - -次の機能を 1 つの UI にまとめます。 - -- 呪文と呪文インベントリ -- 杖編集と再利用可能な杖プリセット -- アイテムと液体容器 -- 完全な素材カタログと素材ペイント -- パークと、対応しているパークの解除 -- ステータス効果と GameEffect エンティティ -- クリーチャー、変身、憑依 -- 天候と時間 -- グローバルなワールドルール -- テレポート -- 任意の Entangled Worlds 連携 -- 変身、フォーム死亡、Game Over からの復旧経路 - -MCM は見た目だけのコピーで置き換えるのではなく、できる限り Noita の実際の状態を操作します。既存の呪文カードは可能な限り実体エンティティのまま移動し、アイテム受け渡しはインベントリ構造を尊重し、杖変更は commit/rollback を使い、素材は本物のシミュレーション対象として残り、可逆なワールドルールは復元に必要な元状態を保持します。 - -Entangled Worlds は必須ではありません。導入していなくても MCM は完全なシングルプレイ Mod として動作します。 - -# 操作 - -デフォルト操作: - -| 操作 | デフォルト入力 | -| --- | --- | -| クリエイティブメニューを開く / 閉じる | **F4** | -| 変身中に人間へ戻る | **TAB** | -| ワールド内のクリーチャーに憑依 | **G** | -| 選択素材で描画 | **マウス中ボタン** | - -クリエイティブパネルは Noita の通常インベントリ UI からも利用できます。 - -キー割り当ては MCM の **CONTROLS** セクションと Noita の Mod 設定から変更できます。キーボード、マウスボタン、正確な **CTRL / SHIFT / ALT** 組み合わせをサポートします。 - -キー設定中: - -- **DELETE / BACKSPACE** — 割り当てを削除 -- **ESC** — キャンセル -- **R** — その操作のデフォルトに戻す -- **RESET ALL** — 確認後、すべてのデフォルト割り当てを復元 - -同じキーを複数操作に割り当てること自体は可能ですが、MCM は別操作を黙って上書きせず競合を表示します。 - -メニュー移動、各セクション、フォーム解除、憑依、素材描画、効果クリーンアップ、天候の制御解除、ワールドルール初期化、対応マルチプレイ操作などは再割り当てできます。 - -# Creative Menu ウィンドウ - -直接開くクリエイティブパネルは、固定の debug overlay ではなく、状態を保存するリサイズ可能なウィンドウです。 - -次の操作ができます。 - -- タイトル部分をドラッグして移動 -- 辺や角からリサイズ -- 最小化 -- 閉じる -- デフォルトレイアウトへ戻す - -位置、幅、高さ、最後に開いていたセクションはセッション間で保存されます。解像度変更後は、保存された座標が見える GUI 領域内に収まるよう補正されます。 - -リストやカタログは測定済みレイアウトと Noita のスクロールコンテナを使います。ウィンドウサイズを変えると表示量も即座に変わり、長い翻訳ラベルは隣の UI に重ならず折り返されます。狭い幅ではコントロールが次の行へ回り、重なりません。 - -ウィンドウを開いたりカーソルを置いただけでゲーム操作が恒久的に無効になることはありません。クリック、ドラッグ、入力中のテキスト欄がプレイヤー操作と競合する場面だけ、MCM が関連操作を一時的に抑制し、その後復元します。 - -# 検索とローカライズ - -呪文、アイテム、素材、パーク、クリーチャーなど主要カタログで検索できます。 - -項目によっては次の情報を検索対象にします。 - -- 現在の UI 言語での名前 -- 英語名 -- ローカライズキー -- 技術 ID -- XML パス - -検索は大文字小文字を区別せず、一般的なアクセントや区切りを正規化し、長い検索語では軽いタイプミスも許容します。 - -MCM 自身の UI は次の言語に対応します。 - -- 英語 -- ロシア語 -- ブラジルポルトガル語 -- スペイン語 -- ドイツ語 -- フランス語 -- イタリア語 -- ポーランド語 -- 簡体中国語 -- 日本語 -- 韓国語 - -通常の Noita コンテンツについては、可能な限りゲーム本体のローカライズキーを再利用し、名前の重複管理を避けます。 - -# 呪文 - -呪文セクションはカタログだけでなく、プレイヤーが実際に持っている呪文エンティティも扱います。 - -メイン領域には次が表示されます。 - -- 現在の杖の通常スロット -- **ALWAYS CAST** カード -- プレイヤーの呪文インベントリ -- 検索可能な呪文カタログ - -## 選択スロットのクイック置換 - -杖スロットを短くクリックすると選択状態になります。その後カタログの呪文を短く LMB クリックすると、そのスロットを置き換えます。 - -これは通常編集向けの高速操作です。正確な移動には drag-and-drop を使います。 - -## トランザクション式 drag-and-drop - -既存カードは次の間で移動できます。 - -- 杖スロット間 -- 通常スロットから **ALWAYS CAST** -- **ALWAYS CAST** から通常スロット -- 呪文インベントリの特定スロット -- インベントリから杖 -- ゲームワールド -- 対応している場合はゴミ箱 - -既存カードは可能な限り実際のエンティティそのものを移動します。そのため可変状態、残り使用回数、他 Mod が追加した状態を単なる位置変更で失いにくくなっています。 - -移動元は、移動先のトランザクションが確定するまで保持されます。不正または未知のドロップ先では削除せずキャンセルします。1 回のマウスリリースで確定する処理は最大 1 件です。 - -カタログカードはテンプレートであり、ドラッグで消費されません。 - -## Always Cast - -Always Cast には専用列があります。通常スロットとの昇格、降格、交換では通常スロットの実効容量を考慮し、不正な杖構造を作らないようにします。 - -## Undo / Redo - -杖内部の変更には有限の **UNDO / REDO** 履歴があります。 - -実体エンティティをワールドや別インベントリに渡した処理は、杖の snapshot だけから安全に回収できるとは限りません。そのため外部 handoff は常に取り消せる機能としては扱いません。 - -# 杖 - -杖ワークスペースでは、現在手に持っている杖を編集します。 - -対応パラメータ: - -- 容量 / スロット数 -- 1 回の詠唱で使う呪文数 -- リチャージ時間 -- 詠唱遅延 -- 拡散 -- 投射物速度倍率 -- 最大マナ -- マナ回復速度 -- 反動回復 -- 杖レベル -- shuffle -- no-reload 動作 - -外観や関連メタデータも編集できます。 - -- 表示名 -- 杖 / カードのロック -- sprite パス -- sprite オフセット -- 発射位置 - -利用可能な場合、外観カタログは杖 XML に定義されたデータを利用します。 - -## 杖プリセット - -杖は名前付きの永続プリセットとして保存でき、別ワールドや後の Noita セッションでも再利用できます。 - -プリセットに保存できるもの: - -- 杖ステータス -- マナ値 -- 見た目のメタデータ -- 通常呪文カード -- Always Cast カード -- スロット配置 -- 残り使用回数 -- カードの frozen 状態 - -各プリセットには 2 種類の処理があります。 - -- **APPLY** — 保存した blueprint を現在の杖に適用 -- **GET COPY** — 同じ blueprint から新しい杖を作成 - -空いている杖用クイックスロットがあればコピーをそこへ入れます。適切なスロットがなければ、完成した杖をプレイヤー付近のワールドへ置きます。 - -杖置換とプリセット読み込みは commit/rollback を利用します。構築や配置が完了できない場合は、壊れた半完成杖を残す代わりに不完全なエンティティツリーを削除しようとします。 - -# アイテムと液体 - -## アイテム - -カタログ項目を短く **LMB** クリックすると、対応アイテムをプレイヤー付近に 1 個生成します。 - -**RMB** は適切なインベントリ領域への格納を試みます。 - -カタログ項目を次の場所へドラッグすることもできます。 - -- 対応するクイックインベントリ領域 -- メニュー外の正確なワールド位置 - -有効な対象がないままメニュー内で離すとキャンセルされます。カタログカードはテンプレートなので残ります。 - -MCM は Noita のクイックインベントリにおける杖とアイテムの区分を尊重します。XML 読み込み、液体充填、インベントリ受け渡し、任意の multiplayer handoff に失敗した場合、新規エンティティは可能な限り削除されます。 - -一部の実インベントリアイテムは、ゲーム上ではクリーチャー系ディレクトリに配置されています。MCM はフォルダ名だけで判断せず、既知のケースを挙動に基づいて分類します。 - -## 液体 - -液体項目は UI 上だけの表示物ではなく、実際に中身の入った Noita コンテナを生成します。 - -その容器は所持、投下、破壊、内容物の放出ができ、中身は通常の素材反応に参加します。 - -# 素材 - -Materials セクションは Noita の実際の素材レジストリを使うワールド描画ツールです。 - -カタログは engine に登録された液体、砂 / 粉末、ガス、火、固体、関連する静的素材やエフェクト素材から構成されます。他の有効 Mod が正しく登録した素材も自動的に現れることがあります。 - -素材探索や高コスト検証は 1 UI フレームですべて行わず、制限された処理量へ分散します。 - -## 素材表示 - -液体は Items セクションと同じ、内容入り容器の表示を使います。 - -非液体は `materials.xml` の定義済みテクスチャと tint を優先し、継承定義にも対応します。定義テクスチャがない場合のみ、任意のプレビュー色ではなく engine 上の実際の素材色を fallback に使います。 - -## ペイント - -1. 素材を選びます。 -2. ブラシサイズを選びます。 -3. ペイントモードを有効化します。 -4. インベントリを閉じます。 -5. ワールドで設定済みの描画入力を押し続けます。 - -インベントリを開くとペイントモードは停止します。 - -ペイントは装飾粒子を出すだけではありません。MCM は engine に適した方法で実際のセルをワールドへ置きます。動的素材はその後も Noita のシミュレーションに従い、液体は流れ、粉末は落ち、ガスは移動し、火は反応し、不安定物質は素材反応で変化します。 - -素材クラスによって配置方法が異なります。Standalone ビルドは NoitaPatcher の直接ワールドグリッドアクセスを利用でき、Noita が特定のテクスチャ座標で直接構築を拒否する定義済みケースでは小型 PixelScene fallback を使います。 - -作業キューには上限があり、大きなブラシを押し続けても 1 フレームに無制限の処理を行う設計にはなっていません。 - -# パーク - -## パークの生成と取得 - -**LMB** は選択中パークの通常 pickup をワールドに生成します。 - -取得操作は 1 個ずつでもまとめてでも実行できます。大量処理は 1 UI フレームですべて適用せず、上限付き job として分散処理されます。 - -UI は進行状況を表示し、未処理分はキャンセルできます。キャンセル前にすでに確定した分は残ります。 - -各コピーは最終状態を直接偽装するのではなく、通常のパーク取得経路を通ります。 - -## パークの削除 - -パークの削除は付与よりずっと複雑です。パークは globals、コンポーネント、エンティティ、プレイヤーステータス、長時間のゲームメカニクスを変更することがあり、Noita に万能な逆操作はありません。 - -そのため MCM は、十分安全に追跡できる逆処理を持つ状態だけを削除します。トランザクション journal は、そのパーク適用に所有される状態だけを消し、無関係なプレイヤー状態をリセットしないようにします。 - -クリーンアップが一部しか完了しない、または完全性を確認できない場合は、成功扱いにはせず未完了として残します。 - -第三者 Mod のパークは付与できても正しく削除できるとは限りません。 - -# 効果 - -Effects セクションでは、対応している素材ステータスと GameEffect エンティティを適用・削除します。 - -可能な場合は状態の所有元を考慮して削除します。MCM はパーク、ゲーム、別システムに属する類似の隠し効果を無差別に削除しません。 - -MCM が作成した永続効果は上限付きのクリーンアップ / 期限処理を使い、1 つの MCM 効果を消すだけで無関係な状態までリセットしないようにします。 - -# クリーチャー - -クリーチャーカタログは正確な XML パスを保持し、名前が似ているだけのエンティティをまとめません。 - -対応操作: - -- **LMB** — 選択中の定義済みエンティティをプレイヤー付近に生成 -- メニュー外へドラッグ — 確定したワールドカーソル位置に生成 -- **RMB** — 現在のプレイヤーを対応フォームへ変身 -- 特殊 **PLAYER** 項目 — 下記のとおりプレイヤー関連状態を生成・復元 - -ドラッグしたカードをメニュー上へ戻して離すとワールド生成はキャンセルされます。 - -危険または特殊な変身対象の互換ルールは正確なパスで定義されます。ファイル名に似た単語が含まれるだけでは、別のフォームと同等とは扱いません。 - -# 変身と人間への復帰 - -プレイヤー用フォームは、実用的な範囲でネイティブの移動、攻撃、見た目、物理挙動を維持します。プレイヤー入力と直接競合するコンポーネントは、操作中だけ無効化または調整される場合があります。 - -複雑なクリーチャーには追加互換ロジックが必要です。Boss、スクリプト wrapper、物理依存の大きいエンティティは、プレイヤーフォーム時に元の AI 制御版と完全に同じ挙動を保証しません。 - -復帰操作——デフォルト **TAB**——はまず通常の変身終了処理を使います。それだけで足りない場合、standalone ビルドでは NoitaPatcher を使う追加復旧経路があります。 - -対応している致死ダメージでは、MCM は次を試みます。 - -- 必要なら死亡した一時フォームや死体をワールドに残す -- 人間のプレイヤーエンティティを復元 -- authority と操作権を返す -- インベントリを保持 -- 必要なプレイヤー状態を復元 - -これは復旧ロジックであって絶対的な不死ではありません。第三者の kill script、互換性のない engine 状態、プロセスクラッシュは対応 handoff を回避する可能性があります。 - -# 憑依 - -憑依はカタログからフォームを選ぶ代わりに、すでにワールドに存在するクリーチャーを操作します。 - -デフォルト入力は **G** です。 - -対象クリーチャーにカーソルを合わせ、憑依操作を使います。MCM は対象を確認し、互換変身を準備し、新しいプレイヤー制御状態が確定してから元のワールドエンティティを削除または retirement します。 - -切り替えに失敗した場合、元クリーチャーが単に消えることは避けます。 - -憑依は MCM 内蔵カタログだけに限定されません。他 Mod の互換クリーチャーでも動作する可能性がありますが、すべての第三者エンティティとの互換性は保証されません。 - -# Player 項目 - -**PLAYER** はクリーチャーカタログの特殊項目で、通常の polymorph 対象ではありません。 - -Spawn 操作では別個のプレイヤー風キャラクターを作成し、適切な見た目と最大 HP 情報のコピーを試みます。 - -すでに人間のプレイヤーに **PLAYER** の変身操作を使っても複製にはなりません。現在別フォームなら、人間へ戻る操作として使われます。 - -# シングルプレイ Game Over 復旧 - -Standalone のシングルプレイビルドには、Noita 標準 Game Over 画面向けの追加復旧経路があります。 - -ネイティブ連携が必要なゲーム構造を安全に識別できた場合、MCM は Game Over UI に **「I didn't die」** アクションを追加します。 - -プレイ中、MCM はプレイヤー状態のローリングバックアップを維持します。復旧ボタンは UI クリック内で直接すべてを再構築せず、MCM の通常更新経路へ復旧要求を送ります。 - -対応復旧では次を試みます。 - -- 生存プレイヤーエンティティを復元または取得 -- そのエンティティを再び authoritative にする -- engine の Game Over 状態を解除 -- 操作権と利用可能なプレイヤー状態を戻す -- Game Over の音声、音楽、UI 状態を可能な範囲でクリーンアップ -- 復旧直後に短い保護時間を与える - -ネイティブ helper は fail-closed 設計です。固定アドレスへ永続的に書き込むのではなく、実行中の対応 Noita executable 内で既知構造を探索します。ゲーム更新後に期待構造を安全に識別できない場合は、不明な場所へ書き込まず任意復旧機能を使用しません。 - -# 天候と時間 - -MCM は現在の実装で公開されるプリセットや個別パラメータを含む、対応天候・時間状態を制御できます。 - -強制状態はあとからゲーム本来の制御へ解放できます。たとえば特定時刻を固定したあと、その設定を MCM 管理から外し、Noita の自然な時間進行へ戻せます。 - -天候変更は一方向のコンソールコマンドではなく、管理される状態として扱われます。 - -# ワールドルール - -**RULES** セクションは対応するグローバルなゲーム挙動を変更します。 - -対象例: - -- クリーチャー間関係 -- ゴールドの挙動 -- 呪文使用 -- fog of war -- 一部キル報酬 -- 回復ドロップ -- 血液関連挙動 -- 重力 -- 物理挙動 -- キック力 -- 物理ジョイント -- 昼夜サイクル -- その他の対応グローバル設定 - -重要な設計目標は可逆性です。 - -対応ルールでは元状態を保存または導出し、あとで戻せるようにします。倍率系は変更済み値へ繰り返し掛けるのではなく、元の値に対して適用します。 - -多数のエンティティや物理オブジェクトに触れるルールは、メニュークリック 1 回で世界全体を同期的に書き換えず、上限付き処理を複数フレームに分散します。 - -# テレポート - -テレポートセクションには、メインルート、Holy Mountains、主要なサイドエリア、その他対応地点など、ワールド内の準備済み目的地があります。 - -プレイヤーを動かす前に、MCM は目的地周辺の読み込みを要求し、固体地形の中へ直接置く代わりに近くの利用可能な空間を探します。 - -テレポートは、ワールド側が実際に読み込みと有効地点提供を行えることに依存します。大きく改変されたワールドでは fallback が必要な場合があります。 - -# Entangled Worlds - -**Entangled Worlds / Noita Proxy は任意です。** EW がなくても MCM は動作します。 - -EW がある場合、MCM は追加の multiplayer-aware 挙動を有効化します。MCM 独自の同期状態を利用する場合、すべての peer で互換性のある MCM ビルドを使うことを推奨します。 - -## Authority とフォーム - -変身プレイヤーが意図せず第 2 のネットワーク authority を残さないよう、プレイヤーフォームには特別な ownership 処理が必要です。 - -対応範囲で MCM は EW とフォーム ownership、retirement、人間への復帰を調整します。Boss や Kolmi 系エンティティには、authority 重複や古いネットワーク制御コピーを防ぐための追加 lifecycle 処理があります。 - -MCM-owned フォーム状態と判断されないエンティティでは、通常の EW 死亡処理が引き続き担当します。 - -## アイテム、杖、呪文 - -可能な限り、MCM は独自の平行転送システムではなく EW 標準の item / inventory 機構を利用します。 - -確定した杖や呪文インベントリ変更では、連携が利用可能なら適切な multiplayer refresh を要求します。MCM が作成したワールドアイテムは EW 標準 world-item 経路へ渡せます。 - -## パーク - -通常のパーク pickup は EW 標準 world-item 同期を利用できます。MCM のパーク状態処理は refresh と上限付き操作を調整し、大量処理でコピーごとに高コストのグローバル更新を出さないようにします。 - -## 素材 - -素材ペイントはワールドセル変更であり通常アイテムではないため、専用互換経路があります。 - -MCM はペイント処理量を制限し、chunk 境界で作業を分割し、同期変換処理を出す前に EW に必要な world-frame / persistence 手順を調整します。まだ読み込まれていない streaming-edge chunk は、現在のストローク全体を止めず後回しにされます。 - -狙いは、近くの EW peer が MCM の通常 UI 操作を filename-only PixelScene 呼び出しとして遠隔再生しなくても、対応している描画済みワールド状態を見られることです。 - -ただし EW の material id 前提はそのままです。受信側に存在しない素材や、engine の素材レジストリが互換でない素材を正しく生成することはできません。 - -## 天候、憑依、ワールド状態 - -対応する MCM multiplayer 状態には、天候、憑依、一部ワールドルール / lifecycle 挙動の調整も含まれます。Authority チェックにより、2 つの peer が同じ状態を同時に所有しようとすることを避けます。 - -EW 対応は意図的に保守的です。安全な同期経路を証明できない場合、すべてのシングルプレイ操作が multiplayer-safe であるかのように振る舞わず、正しいローカル動作を優先します。 - -# 互換性と制限 - -Noita の多くのシステムは、疎結合なエンティティ、XML、Lua コンポーネント、ネイティブ engine 挙動で構成されています。そのため MCM はすべての改造エンティティや将来のゲーム更新との完全互換を保証できません。 - -主な制限: - -- Spawn できるクリーチャーが安全なプレイヤーフォームとは限らない -- パークを付与できても信頼できる逆操作があるとは限らない -- 外部への呪文 / アイテム handoff は内部 snapshot から常に Undo できるとは限らない -- 第三者スクリプトは対応している死亡・復旧経路を回避する場合がある -- ネイティブ復旧は対応 Noita executable の挙動に依存し、必要構造が識別できなければ fail-closed する -- Entangled Worlds は受信側レジストリにない素材を同期できない -- 大きく変更されたインベントリ、エンティティ、ワールドルールは Mod 固有の互換対応が必要な場合がある - -MCM は元状態の保持と失敗した変更の rollback を重視しますが、リアルタイムのゲーム状態を書き換えるサンドボックスツールが、あらゆる第三者 Mod 組み合わせを完全トランザクション化することはできません。 - -# 保存データ - -MCM はセッションをまたいで保持すべきユーザー状態を保存します。対応設定、キー割り当て、メニューレイアウト、杖プリセットなどが含まれます。 - -Mod ID は安定しているため、通常更新では対応保存データを引き継げます。ただし新しい standalone ビルド導入時は Mod フォルダ全体の置き換えを推奨します。新旧ファイルを混在させると古い runtime ファイルが残る場合があります。 - -# トラブルシューティング - -## Mod が表示されない - -パスの末尾が次になっているか確認してください。 - -`mods/metamorph_creative_menu/mod.xml` - -`metamorph_creative_menu` より上に余分なアーカイブフォルダがあると、Noita は Mod を正しく認識できません。 - -## ネイティブ機能や素材機能が動かない - -**Unsafe Mods** が許可され、Workshop ファイルと混在していない GitHub standalone ビルドを導入しているか確認してください。 - -## メニューを開くとゲーム側操作も発動する - -カスタムキー割り当ての競合を確認してください。MCM は重複を表示しますが、意図して使う場合は保持できます。 - -## クリーチャーへ安全に変身できない - -Spawn 可能な XML エンティティすべてがプレイヤーフォーム対応ではありません。特別対応が必要なクリーチャーには正確なパスベースのルールがあります。 - -## パークを削除できない - -削除は、追跡状態に対して MCM が対応逆操作を持つ場合のみ提供されます。これは意図的です。推測によるクリーンアップは無関係なプレイヤー状態を壊す可能性があります。 - -## Multiplayer で peer ごとに挙動が違う - -全参加者で互換 MCM ビルドを使い、Noita / Entangled Worlds 環境も互換にしてください。MCM は不一致な素材レジストリや、他 Mod による任意のネットワーク変更を修復できません。 - -# バグ報告 - -有用な報告には次を含めてください。 - -- 何をしようとしていたか -- MCM の正確なセクションと操作 -- 問題が single-player、Entangled Worlds、または両方で起きるか -- standalone / Workshop のどちらを使っているか -- 他の gameplay Mod が有効か -- 安定した再現手順 -- 可能であれば関連 Noita / EW ログ - -変身、憑依、アイテム、素材の問題では、可能なら正確なエンティティや素材を示してください。翻訳表示名より技術 ID の方が有用な場合が多いです。 - -# リポジトリと開発ソース - -このリポジトリは、プレイヤーがダウンロードする削減済みアーカイブではなく、**完全な開発ツリー**を意図的に保持します。 - -`metamorph_creative_menu/` には runtime コードに加えて次が含まれます。 - -- 自動テスト -- QA ツール -- 診断 -- ネイティブソース -- ビルドツール -- release cleanup ルール -- 開発ドキュメント - -これらは開発と回帰テストに必要なので GitHub source に残ります。インストール用 player ZIP は別に生成され、development-only 内容を除外します。 - -Player パッケージには release 専用クリーンアップも適用され、最小限の player `README.txt` が生成されます。一方 source tree では開発ドキュメントを保持します。 - -# テスト - -自動テストは `metamorph_creative_menu/tests/` にあり、Python の contract check と Lua mock test を組み合わせています。 - -Release workflow はリポジトリルートから完全な imported source に対してテストを実行し、合格後に player build を公開します。Lua mock 部分には `texlua` が必要です。 - -Source hygiene チェックは production-facing なファイルとドキュメントについて、開発履歴の残骸、古い debug surface、偶発的なプロセス生成物の混入も防ぎます。 - -# Source アーカイブの import と release 流程 - -完全な開発 source は `Metamorph-Creative-Menu-v...zip` 系のアーカイブから import できます。 - -Import workflow はアーカイブ構造を確認し、完全な開発コンポーネントを要求し、source hygiene と回帰テストを実行してから imported tree を commit します。 - -ModWorkshop / player-style アーカイブは開発 source として扱われません。 - -公開 `latest-build` release は、その完全 source から別の player-builder で生成されます。Builder は QA、テスト、診断、ネイティブソース、その他 development-only payload を削除し、release cleanup ルールを適用し、最終アーカイブを検証してから安定 download asset を更新します。 - -この分離により、リポジトリは開発に必要な完全性を保ちながら、通常のプレイヤーダウンロードは小さく、開発用 instrumentation を含まない状態にできます。 - -# サードパーティコンポーネント - -サードパーティコンポーネント、同梱依存関係、upstream プロジェクトは [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) に記載しています。 diff --git a/README.ko.md b/README.ko.md deleted file mode 100644 index e792c86..0000000 --- a/README.ko.md +++ /dev/null @@ -1,586 +0,0 @@ - - -[English](README.md) · [Русский](README.ru.md) · [Português (Brasil)](README.pt-BR.md) · [Español](README.es.md) · [Deutsch](README.de.md) · [Français](README.fr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) · [**한국어**](README.ko.md) - -

Metamorph: Creative Menu

- -

Noita용 크리에이티브 메뉴 및 샌드박스 툴킷: 주문, 완드, 아이템, 재료, 퍽, 효과, 생물, 변신, 빙의, 순간이동, 날씨, 월드 규칙, 멀티플레이 연동 및 복구 도구.

- -

제작 및 유지보수: zerodancing

- ---- - -# 다운로드 - -일반 플레이에는 바로 설치할 수 있는 빌드를 사용하세요. - -[**⬇️ 최신 빌드 다운로드**](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/download/latest-build/Metamorph-Creative-Menu.zip) - -[최신 빌드 페이지](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/tag/latest-build) · [변경 내역](metamorph_creative_menu/CHANGELOG.txt) - -GitHub release는 전체 개발 소스 트리에서 자동으로 생성됩니다. 테스트, QA 도구, 진단 코드, 네이티브 소스 및 빌드 도구는 저장소에 남지만 플레이어용 아카이브에는 포함되지 않습니다. - -GitHub standalone 빌드에는 NoitaPatcher와 네이티브 복구 기능이 포함되므로 **Unsafe Mods를 허용**해야 합니다. - -# 설치 - -1. 위 링크에서 `Metamorph-Creative-Menu.zip`을 다운로드합니다. -2. Noita를 실행하고 메인 메뉴에서 **Mods**를 엽니다. -3. **Open mods folder**를 누릅니다. -4. `metamorph_creative_menu` 폴더를 `mods` 폴더에 압축 해제하거나 이동합니다. 최종 경로는 추가 압축 폴더 없이 바로 `metamorph_creative_menu/mod.xml`을 포함해야 합니다. -5. 이전 사본이 있다면 오래된 파일과 새 파일을 합치지 말고 `metamorph_creative_menu` 폴더 전체를 교체하세요. -6. Noita로 돌아가 모드 목록을 새로고침합니다. -7. **Unsafe Mods**를 허용합니다. -8. **Metamorph: Creative Menu**를 활성화하고 모드를 켠 상태로 게임을 시작합니다. - -GitHub standalone 빌드와 Steam Workshop 빌드를 동시에 활성화하지 마세요. - -# Standalone 빌드와 Steam Workshop - -이 GitHub 저장소에서 배포되는 빌드는 완전한 standalone 빌드입니다. NoitaPatcher와 제한 없는 mod API 접근이 필요한 기능을 포함하며, 저수준 재료 조작 및 Game Over 이후 네이티브 복구 기능도 포함됩니다. - -[Steam Workshop 빌드](https://steamcommunity.com/sharedfiles/filedetails/?id=3785170245)는 별도로 설치됩니다. Standalone 전용 기능에 필요한 네이티브 구성 요소는 포함하지 않습니다. - -두 빌드는 같은 모드 ID를 사용합니다. 동시에 설치하면 파일 중복이나 충돌이 생길 수 있으며 지원되지 않습니다. - -# 모드 소개 - -**Metamorph: Creative Menu (MCM)**은 Noita용 크리에이티브 메뉴이자 샌드박스 툴킷입니다. - -다음 기능을 하나의 인터페이스에 모읍니다. - -- 주문 및 주문 인벤토리 -- 완드 편집 및 재사용 가능한 완드 프리셋 -- 아이템 및 액체 용기 -- 전체 재료 카탈로그와 재료 페인팅 -- 퍽 및 지원되는 퍽 제거 -- 상태 효과 및 GameEffect 엔티티 -- 생물, 변신 및 빙의 -- 날씨와 시간 -- 전역 월드 규칙 -- 순간이동 -- 선택적 Entangled Worlds 연동 -- 변신, 폼 사망 및 Game Over 이후 복구 경로 - -MCM은 가능한 한 장식용 복사본으로 대체하지 않고 Noita의 실제 상태를 직접 다룹니다. 기존 주문 카드는 실제 엔티티로 이동하고, 아이템 전달은 인벤토리 구조를 존중하며, 완드 변경은 commit/rollback 경로를 사용하고, 재료는 실제 시뮬레이션 대상 재료로 유지됩니다. 또한 되돌릴 수 있는 월드 규칙은 나중에 복원할 수 있도록 충분한 원래 상태를 보존합니다. - -Entangled Worlds는 선택 사항입니다. 없어도 MCM은 완전한 싱글플레이 모드로 동작합니다. - -# 조작 - -기본 입력: - -| 동작 | 기본 입력 | -| --- | --- | -| 크리에이티브 메뉴 열기 / 닫기 | **F4** | -| 변신 중 인간 형태로 복귀 | **TAB** | -| 월드의 생물에 빙의 | **G** | -| 선택한 재료로 그리기 | **마우스 가운데 버튼** | - -크리에이티브 패널은 Noita의 일반 인벤토리 인터페이스에서도 사용할 수 있습니다. - -키 바인딩은 MCM의 **CONTROLS** 섹션과 Noita 모드 설정에서 바꿀 수 있습니다. 키보드 키, 마우스 버튼, 정확한 **CTRL / SHIFT / ALT** 조합을 지원합니다. - -키를 지정하는 동안: - -- **DELETE / BACKSPACE** — 바인딩 삭제 -- **ESC** — 취소 -- **R** — 해당 동작의 기본값 복원 -- **RESET ALL** — 확인 후 모든 기본 바인딩 복원 - -중복 바인딩은 그대로 둘 수 있지만, MCM은 다른 동작을 조용히 덮어쓰지 않고 충돌을 표시합니다. - -메뉴 이동, 섹션 전환, 폼 복귀, 빙의, 재료 페인팅, 효과 정리, 날씨 제어 해제, 월드 규칙 초기화 및 지원되는 멀티플레이 동작을 재지정할 수 있습니다. - -# Creative Menu 창 - -직접 여는 크리에이티브 패널은 고정 debug overlay가 아니라 상태가 저장되는 크기 조절 가능 창입니다. - -다음이 가능합니다. - -- 제목 부분을 잡고 이동 -- 가장자리와 모서리에서 크기 조절 -- 최소화 -- 닫기 -- 기본 레이아웃으로 복원 - -위치, 너비, 높이, 마지막으로 연 섹션은 실행 사이에 저장됩니다. 해상도가 바뀌면 저장된 창 위치는 보이는 GUI 영역 안으로 다시 제한됩니다. - -목록과 카탈로그는 측정된 레이아웃과 Noita 스크롤 컨테이너를 사용합니다. 창 크기를 바꾸면 보이는 콘텐츠 양도 즉시 바뀌고, 긴 번역 라벨은 다른 컨트롤을 덮지 않고 줄바꿈됩니다. 좁은 레이아웃에서는 컨트롤이 다음 줄로 재배치됩니다. - -창을 열거나 마우스를 올리는 것만으로 게임 조작이 계속 비활성화되지는 않습니다. 클릭, 드래그, 텍스트 입력 포커스가 플레이어 입력과 충돌할 수 있는 경우에만 관련 입력을 잠시 막았다가 다시 복원합니다. - -# 검색과 현지화 - -주문, 아이템, 재료, 퍽, 생물 등 주요 카탈로그에서 검색할 수 있습니다. - -항목에 따라 다음 정보를 검색 대상으로 사용합니다. - -- 현재 UI 언어 이름 -- 영어 이름 -- 현지화 키 -- 기술 식별자 -- XML 경로 - -검색은 대소문자를 구분하지 않고 일반적인 악센트와 구분자를 정규화하며, 긴 검색어의 작은 오타를 일부 허용합니다. - -MCM 자체 인터페이스는 다음 언어를 지원합니다. - -- 영어 -- 러시아어 -- 브라질 포르투갈어 -- 스페인어 -- 독일어 -- 프랑스어 -- 이탈리아어 -- 폴란드어 -- 중국어 간체 -- 일본어 -- 한국어 - -일반적인 Noita 콘텐츠는 가능하면 게임 자체의 현지화 키를 재사용하여 중복 이름 목록을 유지하지 않습니다. - -# 주문 - -주문 섹션은 카탈로그뿐 아니라 플레이어가 실제로 가진 주문 엔티티도 다룹니다. - -주 작업 영역에는 다음이 표시됩니다. - -- 현재 완드의 일반 슬롯 -- **ALWAYS CAST** 카드 -- 플레이어 주문 인벤토리 -- 검색 가능한 주문 카탈로그 - -## 선택 슬롯 빠른 교체 - -완드 슬롯을 짧게 클릭하면 선택됩니다. 이후 카탈로그 주문을 짧게 LMB 클릭하면 해당 슬롯을 교체합니다. - -일반 편집을 위한 빠른 방식이며, 정확한 이동은 drag-and-drop을 사용합니다. - -## 트랜잭션 방식 drag-and-drop - -기존 카드는 다음 위치 사이에서 이동할 수 있습니다. - -- 완드 슬롯 사이 -- 일반 슬롯에서 **ALWAYS CAST**로 -- **ALWAYS CAST**에서 일반 슬롯으로 -- 주문 인벤토리의 특정 슬롯으로 -- 인벤토리에서 완드로 -- 게임 월드로 -- 지원되는 경우 휴지통으로 - -기존 카드는 가능하면 실제 엔티티 자체를 이동합니다. 따라서 변경 가능한 상태, 남은 사용 횟수, 다른 모드가 추가한 데이터가 위치를 바꿨다는 이유만으로 사라지지 않습니다. - -목적지 트랜잭션이 확정되기 전까지 원본은 유지됩니다. 잘못되거나 알 수 없는 드롭 대상은 원본을 삭제하지 않고 작업을 취소합니다. 한 번의 마우스 릴리스에서 최대 한 개의 작업만 커밋됩니다. - -카탈로그 카드는 템플릿이므로 드래그해도 소모되지 않습니다. - -## Always Cast - -Always Cast는 전용 영역을 가집니다. 일반 슬롯과의 승격, 복귀, 교환은 일반 슬롯의 유효 용량을 고려해 잘못된 완드 구조를 만들지 않습니다. - -## Undo / Redo - -완드 내부 변경은 제한된 **UNDO / REDO** 기록을 가집니다. - -실제 엔티티를 월드나 다른 인벤토리로 전달한 작업은 완드 snapshot만으로 항상 안전하게 되돌릴 수 없습니다. 따라서 이런 외부 handoff는 항상 취소 가능하다고 보장하지 않습니다. - -# 완드 - -완드 작업 영역에서는 플레이어가 현재 들고 있는 완드를 편집합니다. - -지원하는 수치: - -- 용량 / 슬롯 -- 한 번에 시전하는 주문 수 -- 재충전 시간 -- 시전 지연 -- 분산 -- 투사체 속도 배수 -- 최대 마나 -- 마나 충전 속도 -- 반동 회복 -- 완드 레벨 -- shuffle -- no-reload 동작 - -외형 및 관련 메타데이터도 편집할 수 있습니다. - -- 표시 이름 -- 완드 및 카드 잠금 -- sprite 경로 -- sprite 오프셋 -- 발사 위치 - -데이터가 있으면 시각적 외형 카탈로그는 완드 XML 정의를 따릅니다. - -## 완드 프리셋 - -완드를 이름 있는 영구 프리셋으로 저장하고 다른 월드나 이후 Noita 세션에서 재사용할 수 있습니다. - -프리셋은 다음을 저장할 수 있습니다. - -- 완드 스탯 -- 마나 값 -- 시각 메타데이터 -- 일반 주문 카드 -- Always Cast 카드 -- 슬롯 위치 -- 남은 사용 횟수 -- 카드의 frozen 상태 - -각 프리셋에는 두 가지 별도 동작이 있습니다. - -- **APPLY** — 저장된 blueprint를 현재 들고 있는 완드에 적용 -- **GET COPY** — 같은 blueprint로 새 완드 생성 - -빠른 인벤토리에 빈 완드 슬롯이 있으면 복사본을 그곳에 넣습니다. 적절한 슬롯이 없으면 완성된 완드를 플레이어 근처 월드에 둡니다. - -완드 교체와 프리셋 불러오기는 commit/rollback 경로를 사용합니다. 구성이나 배치에 실패하면 고장 난 반쪽 완드를 남기기보다 불완전한 엔티티 트리를 제거하려고 시도합니다. - -# 아이템과 액체 - -## 아이템 - -카탈로그 항목을 짧게 **LMB** 클릭하면 지원되는 아이템 하나를 플레이어 근처에 생성합니다. - -**RMB**는 적절한 인벤토리 영역으로 전달을 시도합니다. - -카탈로그 항목을 다음 위치로 드래그할 수도 있습니다. - -- 호환되는 빠른 인벤토리 대상 -- 메뉴 밖의 정확한 월드 위치 - -메뉴 안에서 유효한 대상 없이 놓으면 취소됩니다. 카탈로그 카드는 템플릿이므로 그대로 남습니다. - -MCM은 Noita 빠른 인벤토리의 완드 슬롯과 아이템 슬롯 구분을 존중합니다. XML 로드, 액체 채우기, 인벤토리 전달 또는 선택적 multiplayer handoff에 실패하면 새 엔티티를 가능한 경우 제거합니다. - -일부 실제 인벤토리 아이템은 게임의 생물 계열 디렉터리에 위치합니다. MCM은 폴더 이름만 보고 아이템/생물을 판단하지 않고 알려진 동작을 기준으로 분류합니다. - -## 액체 - -액체 항목은 장식용 UI 오브젝트가 아니라 실제 내용물이 든 Noita 컨테이너를 생성합니다. - -컨테이너는 들고 다니거나, 떨어뜨리거나, 깨뜨리거나, 내용물을 쏟을 수 있으며 액체는 정상 재료 반응에 참여합니다. - -# 재료 - -Materials 섹션은 Noita의 실제 재료 레지스트리를 이용하는 월드 페인팅 도구입니다. - -카탈로그는 engine에 등록된 액체, 모래 / 가루, 기체, 불, 고체 및 관련 정적/효과 재료에서 구성됩니다. 다른 활성 모드가 올바르게 등록한 재료도 자동으로 나타날 수 있습니다. - -재료 발견과 비용이 큰 검증은 한 UI 프레임에서 전체 카탈로그를 처리하지 않고 제한된 작업으로 나눠 수행됩니다. - -## 재료 표시 - -액체는 아이템 섹션과 동일한 내용물 용기 표시를 사용합니다. - -비액체 재료는 `materials.xml`에 정의된 텍스처와 tint를 우선하며 상속된 정의도 포함합니다. 정의된 텍스처가 없을 때만 임의의 preview 색이 아니라 engine의 실제 재료 색을 fallback으로 사용합니다. - -## 페인팅 - -1. 재료를 선택합니다. -2. 브러시 크기를 선택합니다. -3. 페인팅 모드를 켭니다. -4. 인벤토리를 닫습니다. -5. 월드에서 설정된 그리기 입력을 누르고 있습니다. - -인벤토리를 다시 열면 활성 페인팅 모드가 종료됩니다. - -페인팅은 장식용 파티클만 뿌리는 기능이 아닙니다. MCM은 engine에 맞는 방식으로 실제 cell을 월드에 배치합니다. 동적 재료는 이후에도 Noita 시뮬레이션을 따릅니다. 액체는 흐르고, 가루는 떨어지고, 기체는 이동하고, 불은 반응하며, 불안정 재료는 반응으로 변환될 수 있습니다. - -재료 클래스마다 다른 배치 방식이 필요합니다. Standalone 빌드는 NoitaPatcher의 직접 월드 그리드 접근을 사용할 수 있고, Noita가 특정 텍스처 좌표에서 정의 재료의 직접 생성을 거부하는 경우 작은 PixelScene fallback을 사용합니다. - -작업 큐에는 상한이 있어 큰 브러시를 오래 누른다고 한 프레임에 무제한 작업을 수행하지 않습니다. - -# 퍽 - -## 퍽 생성과 획득 - -**LMB**는 선택한 퍽의 일반 pickup을 월드에 생성합니다. - -획득 동작은 한 개씩 또는 일괄로 퍽을 부여할 수 있습니다. 일괄 처리는 한 UI 프레임에 모두 적용하지 않고 제한된 job으로 나눠 처리합니다. - -인터페이스는 진행 상태를 표시하며, 아직 처리되지 않은 작업은 취소할 수 있습니다. 취소 전에 이미 커밋된 퍽은 그대로 남습니다. - -각 복사본은 최종 상태를 직접 위조하지 않고 정상적인 퍽 적용 경로를 거칩니다. - -## 퍽 제거 - -퍽 제거는 부여보다 훨씬 복잡합니다. 퍽은 globals, 컴포넌트, 엔티티, 플레이어 스탯, 장기 메커니즘을 바꿀 수 있으며 Noita에는 범용 역연산이 없습니다. - -따라서 MCM은 충분히 안전하게 추적 가능한 역처리가 있는 상태만 제거합니다. 트랜잭션 journal은 해당 퍽 적용이 소유한 상태만 제거하고 관계없는 플레이어 상태를 초기화하지 않도록 시도합니다. - -정리가 일부만 되거나 완전히 끝났다고 증명할 수 없으면 성공으로 숨기지 않고 미완료 상태로 남깁니다. - -서드파티 퍽은 부여할 수 있어도 올바르게 제거할 수 있다고 보장하지 않습니다. - -# 효과 - -Effects 섹션은 지원되는 재료 상태와 GameEffect 엔티티를 적용하고 제거합니다. - -가능한 경우 상태 소유권을 고려합니다. MCM은 퍽, 게임 자체, 다른 시스템에 속한 비슷한 숨은 효과를 무차별 삭제하지 않습니다. - -MCM이 만든 지속 효과는 제한된 정리 / 만료 처리를 사용해 MCM 효과 하나를 제거할 때 관계없는 상태까지 초기화하지 않습니다. - -# 생물 - -생물 카탈로그는 정확한 XML 경로를 유지하며 이름이 비슷하다는 이유로 여러 엔티티를 합치지 않습니다. - -지원 동작: - -- **LMB** — 선택한 정의 엔티티를 플레이어 근처에 생성 -- 메뉴 밖으로 드래그 — 확인된 월드 커서 위치에 생성 -- **RMB** — 현재 플레이어를 지원되는 폼으로 변신 -- 특수 **PLAYER** 항목 — 아래 설명대로 플레이어 관련 상태 생성 또는 복구 - -드래그한 카드를 메뉴 위에 다시 놓으면 월드 생성은 취소됩니다. - -위험하거나 특수한 변신 대상의 호환 규칙은 정확한 경로를 기준으로 합니다. 파일명에 익숙한 단어가 들어 있다고 다른 폼과 자동으로 같은 것으로 취급하지 않습니다. - -# 변신과 인간 형태 복귀 - -플레이 가능한 폼은 가능한 범위에서 유용한 네이티브 이동, 공격, 외형, 물리 동작을 유지합니다. 플레이어 입력과 직접 경쟁하는 컴포넌트는 플레이어가 해당 폼을 조종하는 동안 비활성화되거나 조정될 수 있습니다. - -일부 복잡한 생물은 추가 호환 로직이 필요합니다. Boss, 스크립트 wrapper, 물리 의존도가 높은 엔티티는 플레이어 폼으로 사용할 때 원래 AI 버전과 완전히 동일하게 동작한다고 보장하지 않습니다. - -복귀 동작 — 기본 **TAB** — 은 먼저 정상적인 변신 종료 경로를 사용합니다. 충분하지 않으면 standalone 빌드가 NoitaPatcher 기반 추가 복구 경로를 사용합니다. - -지원되는 치명 피해 상황에서 MCM은 다음을 시도합니다. - -- 적절한 경우 죽은 임시 폼이나 시체를 월드에 남김 -- 인간 플레이어 엔티티 복원 -- authority와 조작권 반환 -- 인벤토리 보존 -- 관련 플레이어 상태 복원 - -이는 복구 로직이지 절대적인 불사 기능이 아닙니다. 서드파티 kill script, 호환되지 않는 engine 상태 또는 프로세스 크래시는 지원 handoff를 우회할 수 있습니다. - -# 빙의 - -빙의는 카탈로그에서 폼을 고르는 대신 이미 월드에 존재하는 생물을 직접 조종합니다. - -기본 입력은 **G**입니다. - -적절한 생물을 가리키고 빙의 동작을 사용합니다. MCM은 대상을 검증하고 호환되는 전환을 준비하며, 새 플레이어 제어 상태가 확인된 뒤에만 기존 월드 엔티티를 제거하거나 retirement 처리합니다. - -전환이 실패하면 원래 생물이 그냥 사라지지 않도록 합니다. - -빙의는 MCM 내장 카탈로그에만 제한되지 않습니다. 다른 모드가 만든 호환 생물도 동작할 수 있지만 모든 서드파티 엔티티와의 호환성을 보장하지는 않습니다. - -# Player 항목 - -**PLAYER**는 생물 카탈로그의 특수 항목이며 일반 polymorph 대상이 아닙니다. - -Spawn 동작은 별도의 플레이어형 캐릭터를 만들고 가능한 플레이어 외형 및 최대 체력 정보를 복사하려고 합니다. - -이미 인간인 플레이어에게 **PLAYER** 변신 동작을 사용해도 복제본으로 변하지 않습니다. 현재 다른 폼이라면 인간 형태로 돌아가는 동작으로 사용됩니다. - -# 싱글플레이 Game Over 복구 - -Standalone 싱글플레이 빌드는 Noita 기본 Game Over 화면에 추가 복구 경로를 제공합니다. - -네이티브 연동이 필요한 게임 구조를 안전하게 식별할 수 있으면 MCM은 Game Over UI에 **“I didn't die”** 동작을 추가합니다. - -게임 중 MCM은 플레이어 상태의 롤링 백업을 유지합니다. 복구 동작은 UI 클릭 handler에서 플레이어 전체를 바로 재구성하지 않고 MCM의 정상 업데이트 경로로 복구 요청을 보냅니다. - -지원되는 복구는 다음을 시도합니다. - -- 살아 있는 플레이어 엔티티 복원 또는 획득 -- 해당 엔티티를 다시 authoritative 상태로 설정 -- engine Game Over 상태 해제 -- 조작권과 사용 가능한 플레이어 상태 복원 -- Game Over 오디오, 음악, UI 상태를 가능한 범위에서 정리 -- 복구 직후 짧은 보호 시간 제공 - -네이티브 helper는 fail-closed 방식으로 설계되었습니다. 하나의 주소에 영구적으로 쓰지 않고 실행 중인 지원 Noita executable에서 알려진 구조를 찾습니다. 게임 업데이트 뒤 예상 구조를 안전하게 식별할 수 없으면 불확실한 위치에 쓰는 대신 선택적 복구 기능을 사용하지 않습니다. - -# 날씨와 시간 - -MCM은 현재 구현이 제공하는 preset 및 개별 파라미터를 포함해 지원되는 날씨와 시간 상태를 제어할 수 있습니다. - -강제로 설정한 상태는 나중에 게임의 정상 제어로 돌려줄 수 있습니다. 예를 들어 특정 시간을 고정한 뒤 MCM이 해당 설정을 놓으면 Noita의 자연 시간 흐름이 다시 진행됩니다. - -날씨 변경은 일회성 콘솔 명령이 아니라 제어되는 상태로 취급됩니다. - -# 월드 규칙 - -**RULES** 섹션은 지원되는 전역 게임 동작을 변경합니다. - -예시: - -- 생물 관계 -- 골드 동작 -- 주문 사용 -- fog of war -- 일부 처치 보상 -- 회복 드롭 -- 혈액 관련 동작 -- 중력 -- 물리 동작 -- 발차기 힘 -- 물리 조인트 -- 낮밤 주기 -- 기타 지원 전역 파라미터 - -핵심 설계 목표는 되돌릴 수 있다는 것입니다. - -지원되는 규칙은 원래 상태를 기록하거나 유도해 나중에 복원할 수 있게 합니다. 배수형 컨트롤은 이미 수정된 값에 계속 곱하지 않고 원래 값을 기준으로 계산합니다. - -많은 엔티티나 물리 오브젝트를 처리해야 하는 규칙은 한 번의 메뉴 클릭으로 월드 전체를 동기적으로 다시 쓰지 않고 제한된 업데이트 작업을 여러 프레임에 나눕니다. - -# 순간이동 - -순간이동 섹션은 메인 경로, Holy Mountains, 주요 사이드 지역 및 기타 지원 지점 등 준비된 월드 목적지를 제공합니다. - -플레이어를 이동하기 전에 MCM은 목적지 주변 로드를 요청하고, 플레이어를 고체 지형 안에 직접 넣는 대신 주변의 사용 가능한 빈 공간을 찾습니다. - -순간이동은 여전히 월드가 실제로 로드되고 유효 위치를 제공할 수 있어야 합니다. 크게 변경된 월드에서는 fallback 동작이 필요할 수 있습니다. - -# Entangled Worlds - -**Entangled Worlds / Noita Proxy는 선택 사항입니다.** EW 없이도 MCM은 작동합니다. - -EW가 있으면 MCM은 추가 multiplayer-aware 동작을 활성화합니다. MCM 고유 동기화 상태를 사용하는 경우 모든 peer가 호환되는 MCM 빌드를 사용하는 것이 좋습니다. - -## Authority와 폼 - -변신 플레이어가 실수로 두 번째 네트워크 authority를 남기지 않도록 플레이어 폼은 특별한 ownership 처리가 필요합니다. - -지원되는 범위에서 MCM은 EW와 폼 ownership, retirement, 인간 형태 복귀를 조정합니다. Boss 및 Kolmi 계열 엔티티에는 중복 authority와 오래된 네트워크 제어 복사본을 막기 위한 추가 lifecycle 처리가 있습니다. - -MCM-owned 폼 상태로 인식되지 않은 엔티티는 EW의 일반 사망 경로가 계속 담당합니다. - -## 아이템, 완드, 주문 - -가능한 경우 MCM은 별도 전송 시스템을 만들지 않고 EW의 정상 item / inventory 메커니즘을 사용합니다. - -커밋된 완드 및 주문 인벤토리 변경은 연동이 있을 때 적절한 multiplayer refresh를 요청합니다. MCM이 만든 월드 아이템은 EW의 표준 world-item 경로에 넘길 수 있습니다. - -## 퍽 - -일반 퍽 pickup은 EW의 표준 world-item 동기화를 사용할 수 있습니다. MCM의 퍽 상태 처리는 refresh와 제한된 작업을 조정해 일괄 작업이 복사본마다 비싼 전역 갱신을 수행하지 않도록 합니다. - -## 재료 - -재료 페인팅은 월드 cell 변경이며 일반 item entity가 아니므로 전용 호환 경로가 있습니다. - -MCM은 페인팅 작업량을 제한하고 chunk 경계에서 작업을 분리하며, 동기화 변환 작업을 내보내기 전에 EW가 요구하는 world-frame / persistence 단계를 조정합니다. 아직 로드되지 않은 streaming edge chunk는 현재 스트로크 전체를 막지 않고 뒤로 미뤄집니다. - -목표는 근처 EW peer가 MCM의 일반 UI 동작을 filename-only PixelScene 호출로 원격 재생하지 않아도 지원되는 페인팅 월드 상태를 볼 수 있게 하는 것입니다. - -다만 EW의 material id 가정은 그대로 적용됩니다. 수신 측에 없는 재료나 engine 재료 레지스트리가 호환되지 않는 재료는 올바르게 생성할 수 없습니다. - -## 날씨, 빙의와 월드 상태 - -지원되는 MCM 멀티플레이 상태에는 날씨, 빙의, 일부 월드 규칙 / lifecycle 동작의 조정도 포함됩니다. Authority 체크는 두 peer가 같은 상태를 동시에 소유하려는 상황을 막습니다. - -EW 지원은 의도적으로 보수적입니다. 안전한 동기화 경로를 증명할 수 없으면 모든 싱글플레이 작업이 멀티플레이에서도 자동으로 안전한 것처럼 처리하지 않고 올바른 로컬 동작을 우선합니다. - -# 호환성과 제한 - -Noita의 많은 시스템은 느슨하게 연결된 엔티티, XML, Lua 컴포넌트 및 네이티브 engine 동작으로 구성됩니다. 따라서 MCM은 모든 수정 엔티티나 미래 게임 업데이트와의 완전한 호환을 보장할 수 없습니다. - -중요한 제한: - -- Spawn 가능한 생물이 안전한 플레이어 폼이라는 뜻은 아닙니다. -- 부여 가능한 퍽에 항상 신뢰할 수 있는 역처리가 있는 것은 아닙니다. -- 외부로 넘긴 주문/아이템은 내부 snapshot에서 항상 Undo할 수 없습니다. -- 서드파티 스크립트는 지원되는 사망/복구 경로를 우회할 수 있습니다. -- 네이티브 복구는 지원되는 Noita executable 동작에 의존하며 필요한 구조를 식별할 수 없으면 fail-closed 합니다. -- Entangled Worlds는 수신 게임의 레지스트리에 없는 재료를 동기화할 수 없습니다. -- 크게 변경된 인벤토리, 엔티티, 월드 규칙은 특정 모드에 맞춘 호환 처리가 필요할 수 있습니다. - -MCM은 원래 상태 보존과 실패한 변경 rollback을 우선하지만, 실시간 게임 상태를 수정하는 샌드박스 도구가 모든 서드파티 모드 조합을 완전히 트랜잭션 방식으로 만들 수는 없습니다. - -# 저장 데이터 - -MCM은 실행 사이에 유지되어야 하는 사용자 상태를 저장합니다. 지원되는 설정, 키 바인딩, 메뉴 레이아웃, 완드 프리셋 등이 포함됩니다. - -모드 ID는 안정적으로 유지되므로 정상 업데이트에서 지원되는 저장 데이터를 계속 사용할 수 있습니다. 다만 새 standalone 빌드를 설치할 때는 모드 폴더 전체 교체를 권장합니다. 신구 파일을 합치면 오래된 runtime 파일이 남을 수 있습니다. - -# 문제 해결 - -## 모드가 보이지 않음 - -경로가 다음으로 끝나는지 확인하세요. - -`mods/metamorph_creative_menu/mod.xml` - -`metamorph_creative_menu` 위에 추가 압축 폴더가 있으면 Noita가 모드를 올바르게 인식하지 못합니다. - -## 네이티브 기능이나 재료 기능이 작동하지 않음 - -**Unsafe Mods**가 허용되어 있고 Workshop 파일과 섞이지 않은 GitHub standalone 빌드를 설치했는지 확인하세요. - -## 메뉴를 열면 게임 동작도 함께 실행됨 - -사용자 지정 바인딩 충돌을 확인하세요. MCM은 중복을 표시하지만 원하면 그대로 둘 수 있습니다. - -## 특정 생물로 안전하게 변신할 수 없음 - -Spawn 가능한 모든 XML 엔티티가 지원되는 플레이어 폼은 아닙니다. 특별 처리가 필요한 생물은 정확한 경로 기반 규칙을 사용합니다. - -## 퍽을 제거할 수 없음 - -제거는 MCM이 추적 상태에 대해 지원되는 역연산을 가진 경우에만 제공됩니다. 이는 의도된 제한입니다. 추측식 정리는 관계없는 플레이어 상태를 손상시킬 수 있습니다. - -## 멀티플레이에서 peer마다 동작이 다름 - -모든 참가자가 호환되는 MCM 빌드를 사용하고 Noita / Entangled Worlds 환경도 호환되도록 유지하세요. MCM은 호환되지 않는 재료 레지스트리나 다른 모드의 임의 네트워크 변경을 수정할 수 없습니다. - -# 버그 제보 - -유용한 제보에는 다음을 포함하세요. - -- 무엇을 하려 했는지 -- 정확한 MCM 섹션과 동작 -- 문제가 single-player, Entangled Worlds, 또는 둘 다에서 발생하는지 -- standalone / Workshop 중 어떤 빌드인지 -- 다른 gameplay 모드가 활성화되어 있는지 -- 안정적인 재현 단계 -- 가능하면 관련 Noita / EW 로그 - -변신, 빙의, 아이템 또는 재료 문제라면 가능한 경우 정확한 엔티티나 재료를 적어 주세요. 번역된 표시 이름보다 기술 ID가 더 유용한 경우가 많습니다. - -# 저장소와 개발 소스 - -이 저장소는 플레이어가 받는 축소 아카이브가 아니라 **전체 개발 트리**를 의도적으로 보존합니다. - -`metamorph_creative_menu/`에는 runtime 코드와 함께 다음이 있습니다. - -- 자동 테스트 -- QA 도구 -- 진단 코드 -- 네이티브 소스 -- 빌드 도구 -- release cleanup 규칙 -- 개발 문서 - -개발과 회귀 테스트에 필요한 파일들이므로 GitHub source에 남습니다. 플레이어 설치용 ZIP은 별도로 만들어지고 development-only 콘텐츠를 제외합니다. - -플레이어 패키지는 release 전용 정리도 적용받아 최소한의 player `README.txt`가 생성되며, source tree에는 개발 문서가 유지됩니다. - -# 테스트 - -자동 테스트는 `metamorph_creative_menu/tests/`에 있으며 Python contract 검사와 Lua mock 테스트를 함께 사용합니다. - -Release workflow는 저장소 루트에서 전체 imported source를 대상으로 테스트를 실행하고 통과한 뒤 player build를 배포합니다. Lua mock 부분에는 `texlua`가 필요합니다. - -Source hygiene 검사는 production-facing 파일과 문서에 개발 이력 찌꺼기, 오래된 debug surface, 우발적 프로세스 산출물이 다시 들어오는 것도 막습니다. - -# Source 아카이브 import와 release 과정 - -전체 개발 source는 `Metamorph-Creative-Menu-v...zip` 계열 아카이브에서 import할 수 있습니다. - -Import workflow는 아카이브 구조를 확인하고 전체 개발 구성요소를 요구하며 source hygiene과 회귀 테스트를 실행한 뒤 imported tree를 commit합니다. - -ModWorkshop / player-style 아카이브는 개발 source로 취급하지 않습니다. - -공개 `latest-build` release는 전체 source에서 별도 player-builder로 생성됩니다. Builder는 QA, 테스트, 진단, 네이티브 소스 및 기타 development-only payload를 제거하고 release cleanup 규칙을 적용한 뒤 결과 아카이브를 검증하고 마지막으로 안정 다운로드 asset을 갱신합니다. - -이 분리를 통해 저장소는 개발에 필요한 전체 상태를 유지하면서도 일반 플레이어 다운로드는 작고 개발 instrumentation 없이 유지됩니다. - -# 서드파티 구성 요소 - -서드파티 구성 요소, 포함된 의존성 및 upstream 프로젝트는 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)에 정리되어 있습니다. diff --git a/README.md b/README.md index 6a3ce92..f4666c5 100644 --- a/README.md +++ b/README.md @@ -1,586 +1,83 @@ - +

+ Metamorph: Creative Menu +

-[**English**](README.md) · [Русский](README.ru.md) · [Português (Brasil)](README.pt-BR.md) · [Español](README.es.md) · [Deutsch](README.de.md) · [Français](README.fr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) · [한국어](README.ko.md) +

Metamorph: Creative Menu — Steam Workshop build

-

Metamorph: Creative Menu

+

Creative tools for Noita by zerodancing.

-

A creative and sandbox toolkit for Noita: spells, wands, items, materials, perks, effects, creatures, transformations, possession, teleportation, weather, world rules, multiplayer integration and recovery tools.

+This branch contains the **Steam Workshop build** of Metamorph: Creative Menu. It is intentionally different from the full standalone GitHub build: native NoitaPatcher-powered recovery and other standalone-only native features are not shipped here. -

Creator and maintainer: zerodancing

+## Install ---- - -# Download - -For normal play, use the ready-to-install build: - -[**⬇️ Download the latest build**](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/download/latest-build/Metamorph-Creative-Menu.zip) - -[Latest build page](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/tag/latest-build) · [Changelog](metamorph_creative_menu/CHANGELOG.txt) - -The GitHub release is built automatically from the complete development source. Tests, QA utilities, diagnostics, native source files and build tools stay in the repository but are excluded from the player archive. - -The standalone GitHub build includes NoitaPatcher and native recovery support, so **Unsafe Mods must be allowed**. - -# Installation - -1. Download `Metamorph-Creative-Menu.zip` from the latest build link above. -2. Launch Noita and open **Mods** from the main menu. -3. Click **Open mods folder**. -4. Extract or move the `metamorph_creative_menu` folder into the opened `mods` directory. The final path must contain `metamorph_creative_menu/mod.xml` directly, without an extra nested archive folder. -5. If an older copy is already installed, replace the whole `metamorph_creative_menu` folder instead of merging files into it. -6. Return to Noita and refresh the mod list. -7. Allow **Unsafe Mods**. -8. Enable **Metamorph: Creative Menu** and start a game with active mods. - -Do not enable the standalone GitHub build and the Steam Workshop build at the same time. - -# Standalone build and Steam Workshop build - -The build distributed through this GitHub repository is the complete standalone build. It contains NoitaPatcher and features that require unrestricted mod API access, including low-level material operations and native Game Over recovery. - -The [Steam Workshop build](https://steamcommunity.com/sharedfiles/filedetails/?id=3785170245) is installed separately. It does not ship the native components required by the standalone-only features. - -Both builds use the same mod identity. Installing both at once can therefore produce duplicated or conflicting files and is not supported. - -# About the mod - -**Metamorph: Creative Menu (MCM)** is a creative menu and sandbox toolkit for Noita. - -It brings together tools for: - -- spells and spell inventories; -- wand editing and reusable wand presets; -- items and liquid containers; -- the complete material catalog and material painting; -- perks and supported perk removal; -- status effects and GameEffect entities; -- creatures, transformations and possession; -- weather and time; -- global world rules; -- teleportation; -- optional Entangled Worlds integration; -- transformation, death and Game Over recovery paths. - -MCM tries to operate on real Noita state instead of replacing everything with decorative copies. Existing spell cards are moved as entities, item delivery respects inventory structure, wand changes use commit/rollback paths, materials remain real simulated materials, and reversible world rules keep enough original state to restore supported settings later. - -Entangled Worlds is optional. MCM remains a complete single-player mod without it. - -# Controls - -Default controls are: - -| Action | Default input | -| --- | --- | -| Open / close the creative menu | **F4** | -| Return to human form while transformed | **TAB** | -| Possess a creature in the world | **G** | -| Draw with the selected material | **Middle mouse button** | - -The creative panel is also available through Noita's inventory interface. - -Bindings can be changed from MCM's **CONTROLS** section and from Noita's mod settings. Keyboard keys, mouse buttons and exact **CTRL / SHIFT / ALT** combinations are supported. - -During binding capture: - -- **DELETE / BACKSPACE** clears the binding; -- **ESC** cancels capture; -- **R** restores the default for that action; -- **RESET ALL** restores all default bindings after confirmation. - -Duplicate bindings remain editable, but MCM reports conflicts instead of silently replacing another action. - -Actions for menu navigation, sections, form return, possession, material painting, effect cleanup, weather release, world-rule reset and supported multiplayer actions can be rebound. - -# Creative Menu window - -The direct creative panel is a persistent resizable window rather than a fixed debug overlay. - -It can be: - -- moved by its title handle; -- resized from edges and corners; -- minimized; -- closed; -- restored to its default layout. - -Its position, width, height and last open section are remembered between runs. The saved geometry is clamped back into the visible GUI area after resolution changes. - -Lists and catalogs use measured layouts and Noita scroll containers. Resizing the window changes the visible amount of content immediately, and translated labels are allowed to wrap without overlapping neighboring controls. Narrow layouts reflow controls into additional rows instead of drawing them on top of each other. - -Opening or merely hovering the detached menu does not permanently disable gameplay. When a click, drag or focused text field would otherwise also trigger the player, MCM temporarily suppresses the relevant player controls and restores them afterward. - -# Search and localization - -Search is available for the major catalogs, including spells, items, materials, perks and creatures. - -Depending on the entry, search can match: - -- the current interface language; -- the English name; -- localization keys; -- technical identifiers; -- XML paths. - -Search is case-insensitive, normalizes common accents and separators, and tolerates small spelling mistakes in longer queries. - -MCM's own interface is localized into: - -- English; -- Russian; -- Brazilian Portuguese; -- Spanish; -- German; -- French; -- Italian; -- Polish; -- Simplified Chinese; -- Japanese; -- Korean. - -For ordinary Noita content, the mod reuses the game's localization keys whenever possible instead of maintaining duplicate names. - -# Spells - -The Spells section works with both the catalog and the player's existing spell entities. - -The main spell workspace contains: - -- the active wand's ordinary slots; -- the wand's **ALWAYS CAST** cards; -- the player's spell inventory; -- the searchable spell catalog. - -## Selected-slot replacement - -A short click selects a wand slot. A short LMB click on a catalog spell then replaces that selected slot. - -This is the fast path for ordinary editing. More precise movement uses drag-and-drop. - -## Transactional drag-and-drop - -Existing spell cards can be dragged: - -- between wand slots; -- from ordinary slots to **ALWAYS CAST**; -- from **ALWAYS CAST** back to ordinary slots; -- into an exact spell-inventory slot; -- from inventory back to the wand; -- into the game world; -- into the trash where supported. - -For an existing card, MCM moves the actual entity whenever possible. Mutable card state, remaining uses and state added by other mods are therefore not discarded merely because the card changed location. - -The source remains intact until the destination transaction commits. Invalid or unknown drop targets cancel the operation rather than deleting the original card. One mouse release performs at most one committed operation. - -Catalog cards are templates and are never consumed by dragging. - -## Always Cast - -Always Cast cards have their own strip. Promotion, demotion and swapping take the wand's effective ordinary-slot capacity into account so the wand does not end up with an invalid slot structure. - -## Undo and redo - -Internal wand mutations have a bounded **UNDO / REDO** history. - -Operations that hand a real entity to the outside world or to another inventory cannot always be reversed safely from a wand snapshot, so those external handoffs are intentionally not promised as universally undoable. - -# Wands - -The wand workspace edits the currently held wand. - -Supported statistics include: - -- capacity / slots; -- spells per cast; -- recharge time; -- cast delay; -- spread; -- projectile speed multiplier; -- maximum mana; -- mana charge speed; -- recoil recovery; -- wand level; -- shuffle; -- never-reload behavior. - -MCM also edits wand presentation and related metadata: - -- displayed name; -- wand and card locks; -- sprite path; -- sprite offsets; -- firing / shoot position. - -A visual appearance catalog follows authored wand XML data when available. - -## Wand presets - -Wands can be stored as persistent named presets and reused in later worlds or later Noita sessions. - -A preset can retain: - -- wand statistics; -- mana values; -- visual metadata; -- ordinary spell cards; -- Always Cast cards; -- slot positions; -- remaining uses; -- frozen card state. - -Each preset has two separate operations: - -- **APPLY** writes the stored blueprint to the wand currently held by the player; -- **GET COPY** constructs a new wand from the same blueprint. - -A copied wand is placed into a free quick-inventory wand slot when possible. If no suitable slot is available, the completed wand is left in the world near the player. - -Wand replacement and preset loading use commit/rollback paths. If construction or placement cannot be completed, MCM attempts to remove the incomplete entity tree instead of leaving a broken partial wand behind. - -# Items and liquids - -## Items - -A short **LMB** click on an item catalog entry spawns one supported item near the player. - -**RMB** attempts to deliver the item to the appropriate inventory area. - -Catalog entries can also be dragged: - -- onto a matching quick-inventory target; -- outside the menu to an exact world position. - -Releasing a dragged catalog card inside the menu without a valid target cancels the operation. The catalog card itself is only a template and remains available. - -MCM respects Noita's normal quick-inventory separation between wand slots and item slots. A failed XML load, liquid fill, inventory handoff or optional multiplayer handoff removes the newly created entity when possible. - -Some genuine inventory items live under creature-oriented game directories. MCM classifies known cases by behavior instead of assuming that a directory name alone determines whether something is an item or a creature. - -## Liquids - -Liquid entries create real filled Noita containers rather than decorative menu objects. - -The resulting container can be carried, dropped, broken and spilled, and its contents participate in normal material reactions. - -# Materials - -The Materials section is a world-painting tool backed by Noita's actual material registry. - -The catalog is assembled from the engine's registered liquids, sands / powders, gases, fires, solids and related static or particle-effect materials. Materials added correctly by other active mods can therefore appear automatically. - -Material discovery and expensive validation are spread over bounded work instead of performing the whole catalog scan in a single UI frame. - -## Material presentation - -Liquids use the same filled-container presentation as the Items section. - -For non-liquids, MCM prefers authored `materials.xml` textures and tint information, including inherited definitions. If no authored texture is available, the fallback is derived from the material's actual engine color rather than an arbitrary preview color. - -## Painting - -1. Select a material. -2. Select the brush size. -3. Enable painting mode. -4. Close the inventory. -5. Hold the configured drawing input in the game world. - -Opening the inventory stops active painting mode. +Use the [Steam Workshop page](https://steamcommunity.com/sharedfiles/filedetails/?id=3785170245) to subscribe to this build. Steam manages the Workshop files; do not copy the standalone GitHub archive over the Workshop installation. -Painting does not simply emit decorative particles. MCM places real cells into the world using an engine-appropriate path. Dynamic materials continue to obey Noita simulation: liquids flow, powders fall, gases move, fire reacts, and unstable substances can transform through material reactions. +After subscribing, enable **Metamorph: Creative Menu** in Noita and start a run. Press **TAB** to open or close the menu. -Different material classes require different placement strategies. The standalone build can use NoitaPatcher's direct world-grid access and a small PixelScene fallback for authored cases that Noita refuses to construct directly at a particular texture coordinate. +## Main controls -Work queues are bounded so holding a large brush does not intentionally perform an unbounded amount of work in a single frame. +- **TAB** — open or close the Creative Menu. +- **TAB while transformed** — return to human form where the Workshop-safe path supports it. +- **G** by default — possess a supported creature under the cursor; the binding can be changed in MCM settings. +- Catalog entries may expose different **LMB/RMB** actions; the UI shows the active action. -# Perks +## What this build includes -## Spawning and taking perks +The Workshop build keeps the normal creative editors and gameplay tools that work without the standalone native components, including supported spawning/editing for spells, wands, items, perks, effects, creatures/forms, weather and World Rules. -**LMB** spawns a normal perk pickup in the world. +Some entities and recovery paths are inherently engine-sensitive. A feature that depends on the standalone native bridge may be unavailable or use a more limited safe fallback in this branch. -The take action can grant the selected perk in small or bulk quantities. Bulk actions are processed as bounded jobs rather than applying every copy in one UI frame. +## Standalone build -The interface reports job progress, and pending work can be canceled. Copies already committed before cancellation remain applied. +For the complete build, including the bundled NoitaPatcher runtime and native Game Over/recovery support, use the repository's ready-to-install GitHub release: -Each granted copy still uses the normal perk application path instead of faking the final state directly. +- [Latest standalone release](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/tag/latest-build) +- [Direct standalone ZIP](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/download/latest-build/Metamorph-Creative-Menu.zip) -## Removing perks +Do **not** install the Workshop and standalone builds at the same time. They use the same mod identity and can conflict. -Removing a perk is fundamentally more complicated than granting it. Perks may change globals, components, entities, player statistics or long-running mechanics, and Noita does not provide one universal inverse operation. +## Entangled Worlds -MCM therefore removes only state for which it has a sufficiently safe tracked inverse. The transaction journal attempts to remove state owned by that perk application without resetting unrelated player state. +Entangled Worlds integration is optional and experimental. When using multiplayer, keep MCM versions compatible between peers and expect some Noita/EW edge cases to remain engine-dependent. -If a cleanup is partial or cannot be proven complete, it remains treated as incomplete rather than being silently reported as successful. +## Reporting problems -Third-party perks are not guaranteed to be removable even when they can be granted. +When reporting a bug, specify that you are using the **Steam Workshop build**, describe the action that failed, and include the exact creature/item/effect or XML path when relevant. -# Effects +Issues: https://github.com/zerodancing/Metamorph-Creative-Menu/issues -The Effects section applies and removes supported material statuses and GameEffect entities. - -Removal is ownership-aware where possible. MCM avoids indiscriminately deleting similar hidden effects that belong to perks, the game or another system. - -Persistent effects created by MCM use bounded cleanup / expiry handling so clearing one MCM-owned effect does not intentionally reset unrelated state. - -# Creatures - -The creature catalog preserves exact XML paths instead of merging all similarly named entities together. - -Supported interactions include: - -- **LMB** — spawn the selected authored entity near the player; -- drag outside the menu — spawn at the confirmed world cursor position; -- **RMB** — transform the current player into a supported form; -- the special **PLAYER** entry — create or restore player-related state as described below. - -Dropping a dragged creature card back over the menu cancels world spawning. - -Compatibility rules for dangerous or unusual transformation targets are exact-path data. A filename that merely contains a familiar word is not automatically treated as the same creature. - -# Transformations and returning to human form - -Playable forms keep useful native movement, attacks, presentation and physics where practical. Components that directly compete with player input may be disabled or adapted while the form is player-controlled. - -Some complex creatures require additional compatibility logic. Bosses, scripted wrappers and physics-heavy entities are not guaranteed to behave exactly like their original AI-controlled versions while used as a player form. - -The configured return action — **TAB by default** — first uses the normal transformation-ending path. When that is not enough, the standalone build has additional NoitaPatcher-backed restoration paths. - -For supported fatal-damage cases, MCM attempts to: - -- leave the dead temporary form or corpse in the world when appropriate; -- restore a human player entity; -- return player authority and controls; -- preserve inventory; -- restore relevant player state. - -This is recovery logic, not absolute immortality. A third-party kill script, incompatible engine state or process crash can bypass the supported handoff. - -# Possession - -Possession controls a creature that already exists in the world instead of selecting a form from the catalog. - -The default input is **G**. - -Point at a suitable creature and use the possession action. MCM validates the target, prepares a compatible form transition and removes or retires the original world entity only after the new player-controlled state has been confirmed. - -If the transition fails, the original creature should not simply disappear. - -Possession is not limited to MCM's built-in catalog. A compatible creature created by another mod may work, but universal compatibility with every third-party entity is not guaranteed. - -# Player entry - -**PLAYER** is a special entry in the creature catalog rather than an ordinary polymorph target. - -Its spawn action creates a separate player-like character and attempts to copy appropriate player presentation and maximum-health information. - -Using the transform action on **PLAYER** does not transform an already-human player into a duplicate. If the player is currently in another form, the action is used as a return-to-human operation. - -# Single-player Game Over recovery - -The standalone single-player build includes an additional recovery path for the stock Noita Game Over screen. - -When the native integration can safely identify the required game structures, MCM adds an **“I didn't die”** action to the Game Over interface. - -MCM keeps a rolling player backup while the game is running. Activating the recovery action requests restoration through the normal MCM update path rather than rebuilding the whole player directly inside the UI click handler. - -A supported recovery attempts to: - -- restore or acquire a live player entity; -- make that entity authoritative again; -- clear the engine Game Over state; -- restore controls and usable player state; -- perform best-effort cleanup of Game Over audio, music and interface state; -- provide a short protection window after restoration. - -The native helper is designed to fail closed. It scans the running supported Noita executable for known structures instead of writing to one permanently hard-coded address. If the expected structures cannot be identified safely after a game update, the optional recovery action is not used rather than writing to an uncertain location. - -# Weather and time - -MCM can control supported weather and time state, including presets and individual parameters exposed by the current implementation. - -Forced state can later be released back to normal game control. For example, after forcing a specific time of day, MCM can stop owning that setting so Noita's natural time flow resumes. - -Weather changes are treated as stateful controls rather than one-way console commands. - -# World rules - -The **RULES** section changes supported global game behavior. - -Rules cover areas such as: - -- creature relationships; -- gold behavior; -- spell use; -- fog of war; -- selected kill rewards; -- healing drops; -- blood-related behavior; -- gravity; -- physics behavior; -- kick force; -- physics joints; -- the day-night cycle; -- other supported global parameters. - -The important design goal is reversibility. - -For supported rules, MCM records or derives the original state so the setting can later be restored. Multiplier-style controls are applied relative to the original value instead of repeatedly multiplying an already modified result. - -Rules that need to touch many entities or physics objects use bounded update work rather than trying to rewrite the entire world synchronously from one menu click. - -# Teleportation - -The teleport section provides prepared destinations around the world, including locations on the main route, Holy Mountains, major side areas and other supported landmarks. - -Before moving the player, MCM can request destination-area loading and searches for nearby usable space rather than intentionally placing the player directly inside solid terrain. - -Teleportation still depends on the world actually being able to load and provide a valid destination. Heavily modified worlds can therefore require fallback behavior. - -# Entangled Worlds - -**Entangled Worlds / Noita Proxy is optional.** MCM works without it. - -When EW is present, MCM enables additional multiplayer-aware behavior. All peers should use compatible MCM builds when relying on MCM-specific synchronized state. - -## Authority and forms - -Player forms require special ownership handling because a transformed player must not accidentally leave a second network authority behind. - -MCM coordinates form ownership, retirement and return-to-human paths with EW where supported. Boss and Kolmi-style entities have additional lifecycle handling intended to prevent duplicate authorities and stale network-controlled copies. - -The ordinary EW death path remains in charge for entities that are not recognized as MCM-owned form state. - -## Items, wands and spells - -Where possible, MCM uses EW's normal item / inventory mechanisms rather than inventing a parallel transport system. - -Committed wand and spell inventory mutations request the appropriate multiplayer refresh when that integration is available. World items created by MCM can be handed to the standard EW world-item path. - -## Perks - -Normal perk pickups can use EW's regular world-item synchronization. MCM's own perk state handling coordinates refresh and bounded operations so bulk actions do not intentionally emit one expensive global refresh for every copy. - -## Materials - -Material painting has a dedicated compatibility path because world-cell updates are not ordinary item entities. - -MCM keeps paint work bounded, separates work at chunk boundaries and coordinates required EW world-frame / persistence steps before releasing synchronized conversion work. An unloaded streaming-edge chunk is deferred instead of blocking the whole active stroke. - -The goal is that nearby EW peers can observe supported painted world state without requiring the normal MCM UI action to be replayed remotely as a filename-only PixelScene call. - -This still inherits EW's material-id assumptions: a receiving game cannot correctly create a material that does not exist there or whose engine material registry is incompatible. - -## Weather, possession and world state - -Supported MCM multiplayer state also includes coordination for weather, possession and selected world-rule or lifecycle behavior. Ownership checks are used to avoid both peers trying to become authoritative for the same state at once. - -EW support is intentionally conservative. When the integration cannot prove a safe synchronized path, MCM prefers the local supported behavior over pretending that every single-player operation is automatically multiplayer-safe. - -# Compatibility and limitations - -Noita exposes many systems through loosely coupled entities, XML, Lua components and native engine behavior. MCM therefore cannot promise universal compatibility with every modded entity or every future game update. - -Important limitations include: - -- a creature being spawnable does not automatically make it a safe player form; -- a perk being grantable does not automatically mean it has a reliable inverse; -- external spell or item handoffs cannot always be undone from an internal snapshot; -- third-party scripts can bypass supported death and recovery paths; -- native recovery features depend on supported Noita executable behavior and fail closed if the required structures cannot be identified; -- Entangled Worlds cannot synchronize a material absent from the receiving game's registry; -- heavily modified inventories, entities or world rules may require compatibility work specific to that mod. - -MCM tries to preserve original state and roll back failed mutations, but a sandbox tool that modifies live game state cannot make every third-party combination transactional. - -# Saved data - -MCM persists user-facing state that is expected to survive between runs, including supported settings, key bindings, menu layout and wand presets. - -The mod identity remains stable so normal upgrades can keep supported saved state. Replacing the whole mod folder is still recommended when installing a new standalone build, because merging old and new files can leave obsolete runtime files behind. - -# Troubleshooting - -## The mod does not appear - -Confirm that the folder structure ends in: - -`mods/metamorph_creative_menu/mod.xml` - -An extra archive directory above `metamorph_creative_menu` prevents Noita from seeing the mod correctly. - -## Native or material features do not work - -Confirm that **Unsafe Mods** are allowed and that you installed the standalone GitHub build rather than mixing it with the Workshop build. - -## The menu opens but a control also fires in-game - -Check custom bindings for conflicts. MCM reports duplicate bindings, but it intentionally allows you to keep them if desired. - -## A creature cannot be transformed into safely - -Not every spawnable XML entity is a supported player form. Exact-path compatibility rules exist for creatures that need special handling. - -## A perk cannot be removed - -Removal is only offered where MCM has a supported inverse for the tracked state. This is intentional; guessing at cleanup can damage unrelated player state. - -## Multiplayer behavior differs between peers - -Use compatible MCM builds on all participating peers and keep the underlying Noita / Entangled Worlds environment compatible. MCM cannot correct an incompatible material registry or arbitrary third-party network modifications. - -# Reporting bugs - -A useful bug report should include: - -- what you were trying to do; -- the exact MCM section and action involved; -- whether the problem occurs in single-player, Entangled Worlds, or both; -- whether the standalone or Workshop build is installed; -- whether other gameplay mods are active; -- reliable reproduction steps; -- relevant Noita / EW logs when available. - -For transformation, possession, item or material problems, include the exact entity or material when possible. Exact technical identifiers are often more useful than a translated display name. - -# Repository and development source - -The repository intentionally contains the **complete development tree**, not the same stripped archive that players download. - -`metamorph_creative_menu/` contains runtime code together with: +--- -- automated tests; -- QA utilities; -- diagnostics; -- native source; -- build tooling; -- release-cleanup rules; -- development documentation. +## Русский -Those files are useful for development and regression testing, so they remain in GitHub source. The ready-to-install player ZIP is generated separately and excludes development-only content. +Эта ветка содержит **Steam Workshop-сборку** Metamorph: Creative Menu. Она специально отличается от полной standalone-сборки GitHub: сюда не входят нативные возможности, завязанные на встроенный NoitaPatcher, включая расширенные standalone-only сценарии восстановления. -The player package also receives release-only cleanup, including the minimal player `README.txt`, while the source tree keeps its development documentation. +### Установка -# Tests +Подпишитесь на мод на [странице Steam Workshop](https://steamcommunity.com/sharedfiles/filedetails/?id=3785170245). Файлы Workshop устанавливает Steam; не копируйте поверх них standalone-архив GitHub. -The automated test suite lives under `metamorph_creative_menu/tests/` and combines Python contract checks with Lua mock tests. +Включите **Metamorph: Creative Menu** в Noita, начните забег и нажмите **TAB**. -From the repository root, the release workflow runs the suite against the complete imported source before publishing a player build. `texlua` is required for the Lua mock portion. +### Управление -Source hygiene checks also guard production-facing files and documentation against development-history debris, stale debug surfaces and accidental process artifacts. +- **TAB** — открыть или закрыть Creative Menu. +- **TAB во время превращения** — вернуться в человеческую форму там, где это поддерживает Workshop-safe путь. +- **G** по умолчанию — занять тело поддерживаемого существа под курсором; клавиша меняется в настройках MCM. +- Для элементов каталогов ЛКМ/ПКМ могут выполнять разные действия; текущая подсказка показывается в интерфейсе. -# Source archive import and release process +### Возможности -Development source can be imported from a full archive whose name follows the `Metamorph-Creative-Menu-v...zip` family. +Workshop-сборка сохраняет обычные creative-инструменты, которые работают без standalone native-компонентов: поддерживаемое редактирование/создание заклинаний, посохов, предметов, перков, эффектов, существ и форм, а также управление погодой и World Rules. -The import workflow verifies the archive structure, requires the full development components, checks source hygiene and runs the regression suite before committing the imported tree. +Функции, которым нужен standalone native bridge, в этой ветке могут быть недоступны или использовать более ограниченный безопасный fallback. -A ModWorkshop / player-style archive is not treated as development source. +### Полная standalone-сборка -The public `latest-build` release is then produced from the complete source by a separate player builder. The builder removes QA, tests, diagnostics, native source and other development-only payload, applies the release cleanup rules, validates the resulting archive and only then updates the stable download asset. +Полная версия со встроенным NoitaPatcher и расширенным Game Over/recovery доступна в GitHub Releases: -This separation allows the repository to stay useful for development while keeping the normal player download small and free of development instrumentation. +- [Последняя standalone-сборка](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/tag/latest-build) +- [Прямая ссылка на ZIP](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/download/latest-build/Metamorph-Creative-Menu.zip) -# Third-party components +Не устанавливайте Workshop и standalone одновременно: они используют один и тот же mod identity и могут конфликтовать. -Third-party components, bundled dependencies and upstream projects are documented in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). +**Creator: zerodancing** diff --git a/README.pl.md b/README.pl.md deleted file mode 100644 index ca41a72..0000000 --- a/README.pl.md +++ /dev/null @@ -1,586 +0,0 @@ - - -[English](README.md) · [Русский](README.ru.md) · [Português (Brasil)](README.pt-BR.md) · [Español](README.es.md) · [Deutsch](README.de.md) · [Français](README.fr.md) · [Italiano](README.it.md) · [**Polski**](README.pl.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) · [한국어](README.ko.md) - -

Metamorph: Creative Menu

- -

Kreatywne menu i zestaw narzędzi sandbox dla Noita: zaklęcia, różdżki, przedmioty, materiały, perki, efekty, stworzenia, przemiany, przejmowanie stworzeń, teleportacja, pogoda, zasady świata, integracja multiplayer i narzędzia odzyskiwania.

- -

Twórca i maintainer: zerodancing

- ---- - -# Pobieranie - -Do normalnej gry użyj gotowej do instalacji paczki: - -[**⬇️ Pobierz najnowszy build**](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/download/latest-build/Metamorph-Creative-Menu.zip) - -[Strona najnowszego buildu](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/tag/latest-build) · [Lista zmian](metamorph_creative_menu/CHANGELOG.txt) - -Release GitHub jest tworzony automatycznie z pełnego drzewa deweloperskiego. Testy, narzędzia QA, diagnostyka, natywny kod źródłowy i narzędzia build pozostają w repozytorium, ale nie trafiają do archiwum dla graczy. - -Samodzielny build z GitHub zawiera NoitaPatcher i natywne funkcje odzyskiwania, dlatego **Unsafe Mods musi być dozwolone**. - -# Instalacja - -1. Pobierz `Metamorph-Creative-Menu.zip` z linku powyżej. -2. Uruchom Noita i otwórz **Mods** w menu głównym. -3. Kliknij **Open mods folder**. -4. Wypakuj lub przenieś folder `metamorph_creative_menu` do folderu `mods`. Końcowa ścieżka musi bezpośrednio zawierać `metamorph_creative_menu/mod.xml`, bez dodatkowego katalogu z archiwum. -5. Jeśli starsza kopia jest już zainstalowana, zastąp cały folder `metamorph_creative_menu` zamiast łączyć stare i nowe pliki. -6. Wróć do Noita i odśwież listę modów. -7. Zezwól na **Unsafe Mods**. -8. Włącz **Metamorph: Creative Menu** i rozpocznij grę z aktywnymi modami. - -Nie uruchamiaj jednocześnie samodzielnego buildu z GitHub i wersji Steam Workshop. - -# Build standalone i Steam Workshop - -Build dostępny w tym repozytorium GitHub jest pełną wersją standalone. Zawiera NoitaPatcher i funkcje wymagające nieograniczonego dostępu do mod API, w tym niskopoziomowe operacje na materiałach i natywne odzyskiwanie po Game Over. - -[Build Steam Workshop](https://steamcommunity.com/sharedfiles/filedetails/?id=3785170245) instaluje się osobno. Nie zawiera natywnych komponentów wymaganych przez funkcje dostępne wyłącznie w buildzie standalone. - -Oba buildy używają tej samej tożsamości moda. Jednoczesna instalacja może powodować zduplikowane lub konfliktujące pliki i nie jest obsługiwana. - -# O modzie - -**Metamorph: Creative Menu (MCM)** to kreatywne menu i zestaw narzędzi sandbox dla Noita. - -Łączy narzędzia do: - -- zaklęć i ekwipunku zaklęć; -- edycji różdżek i wielokrotnego używania presetów; -- przedmiotów i pojemników z cieczami; -- pełnego katalogu materiałów i malowania materiałami; -- perków i obsługiwanego usuwania perków; -- statusów i encji GameEffect; -- stworzeń, przemian i przejmowania stworzeń; -- pogody i czasu; -- globalnych zasad świata; -- teleportacji; -- opcjonalnej integracji z Entangled Worlds; -- odzyskiwania po przemianie, śmierci formy i Game Over. - -MCM stara się działać na rzeczywistym stanie Noita zamiast zastępować wszystko dekoracyjnymi kopiami. Istniejące karty zaklęć są przenoszone jako encje, przekazywanie przedmiotów respektuje strukturę ekwipunku, zmiany różdżek korzystają z commit/rollback, materiały pozostają prawdziwymi symulowanymi materiałami, a odwracalne zasady świata zachowują wystarczająco dużo stanu początkowego, aby później przywrócić obsługiwane ustawienia. - -Entangled Worlds jest opcjonalny. Bez niego MCM nadal działa jako pełny mod single-player. - -# Sterowanie - -Domyślne sterowanie: - -| Akcja | Domyślne wejście | -| --- | --- | -| Otwórz / zamknij kreatywne menu | **F4** | -| Wróć do ludzkiej formy podczas przemiany | **TAB** | -| Przejmij stworzenie w świecie | **G** | -| Maluj wybranym materiałem | **Środkowy przycisk myszy** | - -Panel kreatywny jest też dostępny przez normalny interfejs ekwipunku Noita. - -Przypisania można zmieniać w sekcji **CONTROLS** MCM i ustawieniach moda Noita. Obsługiwane są klawisze, przyciski myszy oraz dokładne kombinacje **CTRL / SHIFT / ALT**. - -Podczas przypisywania: - -- **DELETE / BACKSPACE** usuwa przypisanie; -- **ESC** anuluje; -- **R** przywraca domyślne przypisanie danej akcji; -- **RESET ALL** po potwierdzeniu przywraca wszystkie domyślne przypisania. - -Duplikaty przypisań nadal można edytować, ale MCM pokazuje konflikt zamiast po cichu zastępować inną akcję. - -Można zmieniać akcje nawigacji menu, sekcji, powrotu z formy, przejmowania stworzeń, malowania materiałami, czyszczenia efektów, zwalniania pogody, resetu zasad świata i obsługiwanych akcji multiplayer. - -# Okno Creative Menu - -Bezpośredni panel kreatywny jest trwałym, skalowalnym oknem, a nie stałym overlayem debug. - -Można go: - -- przesuwać za pasek tytułu; -- skalować za krawędzie i rogi; -- minimalizować; -- zamykać; -- przywracać do domyślnego układu. - -Pozycja, szerokość, wysokość i ostatnio otwarta sekcja są zapamiętywane pomiędzy uruchomieniami. Po zmianie rozdzielczości zapisane położenie jest ograniczane do widocznego obszaru GUI. - -Listy i katalogi korzystają z mierzonych layoutów i kontenerów przewijania Noita. Zmiana rozmiaru okna natychmiast zmienia ilość widocznej treści, a tłumaczone etykiety mogą zawijać się na kilka linii bez nachodzenia na sąsiednie kontrolki. W wąskich układach kontrolki przechodzą do kolejnych wierszy zamiast rysować się jedna na drugiej. - -Samo otwarcie lub najechanie kursorem na oddzielne okno nie wyłącza trwale gameplayu. Gdy kliknięcie, przeciąganie lub aktywne pole tekstowe mogłoby jednocześnie uruchomić akcję gracza, MCM tymczasowo blokuje odpowiednie sterowanie i później je przywraca. - -# Wyszukiwanie i lokalizacja - -Wyszukiwanie działa w głównych katalogach, w tym zaklęć, przedmiotów, materiałów, perków i stworzeń. - -W zależności od wpisu może dopasować: - -- nazwę w bieżącym języku interfejsu; -- nazwę angielską; -- klucz lokalizacji; -- identyfikator techniczny; -- ścieżkę XML. - -Wyszukiwanie ignoruje wielkość liter, normalizuje typowe akcenty i separatory oraz toleruje niewielkie literówki w dłuższych zapytaniach. - -Własny interfejs MCM jest zlokalizowany na: - -- angielski; -- rosyjski; -- portugalski brazylijski; -- hiszpański; -- niemiecki; -- francuski; -- włoski; -- polski; -- chiński uproszczony; -- japoński; -- koreański. - -Dla zwykłej zawartości Noita mod używa kluczy lokalizacji samej gry, gdy jest to możliwe, zamiast utrzymywać duplikaty nazw. - -# Zaklęcia - -Sekcja zaklęć pracuje zarówno z katalogiem, jak i z istniejącymi encjami zaklęć gracza. - -Główny obszar zawiera: - -- zwykłe sloty aktywnej różdżki; -- karty **ALWAYS CAST**; -- ekwipunek zaklęć gracza; -- przeszukiwalny katalog zaklęć. - -## Szybka zamiana wybranego slotu - -Krótki klik wybiera slot różdżki. Następnie krótki klik LMB na zaklęciu z katalogu zastępuje wybrany slot. - -To szybka ścieżka zwykłej edycji. Precyzyjne przenoszenie korzysta z drag-and-drop. - -## Transakcyjny drag-and-drop - -Istniejące karty można przeciągać: - -- między slotami różdżki; -- ze zwykłych slotów do **ALWAYS CAST**; -- z **ALWAYS CAST** z powrotem do zwykłych slotów; -- do dokładnego slotu ekwipunku zaklęć; -- z ekwipunku na różdżkę; -- do świata; -- do kosza, jeśli jest to obsługiwane. - -Dla istniejącej karty MCM przenosi prawdziwą encję, kiedy to możliwe. Dzięki temu zmienny stan karty, pozostałe użycia i dane dodane przez inne mody nie są tracone tylko dlatego, że karta zmieniła położenie. - -Źródło pozostaje nietknięte, dopóki transakcja celu nie zostanie zatwierdzona. Nieprawidłowy lub nieznany cel anuluje operację zamiast usuwać oryginalną kartę. Jedno zwolnienie myszy wykonuje najwyżej jedną zatwierdzoną operację. - -Karty katalogu są szablonami i nigdy nie są zużywane przez przeciąganie. - -## Always Cast - -Karty Always Cast mają własny pasek. Promowanie, cofanie i zamiana uwzględniają efektywną pojemność zwykłych slotów, aby nie powstała nieprawidłowa struktura różdżki. - -## Cofnij i ponów - -Wewnętrzne zmiany różdżki mają ograniczoną historię **UNDO / REDO**. - -Operacje przekazujące prawdziwą encję do świata lub innego ekwipunku nie zawsze mogą być bezpiecznie cofnięte na podstawie snapshotu różdżki, dlatego zewnętrzne handoffy nie są obiecywane jako zawsze odwracalne. - -# Różdżki - -Obszar różdżki edytuje różdżkę trzymaną aktualnie przez gracza. - -Obsługiwane statystyki obejmują: - -- pojemność / sloty; -- zaklęcia na strzał; -- czas ładowania; -- opóźnienie między strzałami; -- rozrzut; -- mnożnik prędkości pocisków; -- maksymalną manę; -- szybkość ładowania many; -- odzyskiwanie odrzutu; -- poziom różdżki; -- shuffle; -- zachowanie bez przeładowania. - -MCM edytuje też wygląd i powiązane metadane: - -- wyświetlaną nazwę; -- blokady różdżki i kart; -- ścieżkę sprite'a; -- przesunięcia sprite'a; -- pozycję strzału. - -Wizualny katalog wyglądu korzysta z danych XML różdżek, gdy są dostępne. - -## Presety różdżek - -Różdżki można zapisywać jako trwałe nazwane presety i używać ich w kolejnych światach lub późniejszych sesjach Noita. - -Preset może zachować: - -- statystyki różdżki; -- wartości many; -- metadane wizualne; -- zwykłe karty; -- karty Always Cast; -- pozycje slotów; -- pozostałe użycia; -- zamrożony stan kart. - -Każdy preset ma dwie osobne operacje: - -- **APPLY** zapisuje blueprint na aktualnie trzymanej różdżce; -- **GET COPY** buduje nową różdżkę z tego samego blueprintu. - -Kopia trafia do wolnego slotu różdżki w szybkim ekwipunku, jeśli to możliwe. Jeśli nie ma odpowiedniego slotu, ukończona różdżka pozostaje w świecie obok gracza. - -Zastępowanie różdżek i ładowanie presetów korzysta z commit/rollback. Jeśli budowa lub umieszczenie się nie powiedzie, MCM próbuje usunąć niekompletne drzewo encji zamiast zostawić uszkodzoną częściową różdżkę. - -# Przedmioty i ciecze - -## Przedmioty - -Krótki **LMB** na wpisie katalogu tworzy jeden obsługiwany przedmiot obok gracza. - -**RMB** próbuje przekazać go do odpowiedniej części ekwipunku. - -Wpisy katalogu można też przeciągać: - -- na zgodny cel w szybkim ekwipunku; -- poza menu do dokładnej pozycji w świecie. - -Upuszczenie karty wewnątrz menu bez prawidłowego celu anuluje operację. Karta katalogu jest tylko szablonem i pozostaje dostępna. - -MCM respektuje normalny podział szybkiego ekwipunku Noita na sloty różdżek i przedmiotów. Nieudane wczytanie XML, wypełnienie cieczą, przekazanie do ekwipunku lub opcjonalny handoff multiplayer usuwa nową encję, jeśli jest to możliwe. - -Niektóre prawdziwe przedmioty ekwipunku znajdują się w katalogach gry zorientowanych na stworzenia. MCM klasyfikuje znane przypadki po zachowaniu, zamiast zakładać, że sama nazwa folderu przesądza, czy coś jest przedmiotem czy stworzeniem. - -## Ciecze - -Wpisy cieczy tworzą prawdziwe wypełnione pojemniki Noita, a nie dekoracyjne obiekty interfejsu. - -Taki pojemnik można nosić, rzucać, rozbić i wylać, a jego zawartość uczestniczy w normalnych reakcjach materiałów. - -# Materiały - -Sekcja Materials to narzędzie do malowania świata oparte na rzeczywistym rejestrze materiałów Noita. - -Katalog jest tworzony z płynów, piasków / proszków, gazów, ognia, ciał stałych oraz powiązanych materiałów statycznych i efektowych zarejestrowanych przez engine. Materiały poprawnie dodane przez inne aktywne mody mogą więc pojawiać się automatycznie. - -Odkrywanie materiałów i kosztowna walidacja są rozkładane na ograniczoną pracę zamiast skanować cały katalog w jednej klatce UI. - -## Wygląd materiałów - -Ciecze używają tej samej prezentacji wypełnionego pojemnika co sekcja przedmiotów. - -Dla pozostałych materiałów MCM preferuje tekstury i dane tint z `materials.xml`, w tym definicje dziedziczone. Jeśli brak zdefiniowanej tekstury, fallback korzysta z prawdziwego koloru materiału w engine, a nie losowego koloru podglądu. - -## Malowanie - -1. Wybierz materiał. -2. Wybierz rozmiar pędzla. -3. Włącz tryb malowania. -4. Zamknij ekwipunek. -5. Przytrzymaj skonfigurowane wejście rysowania w świecie. - -Otwarcie ekwipunku zatrzymuje aktywny tryb malowania. - -Malowanie nie emituje tylko dekoracyjnych cząstek. MCM umieszcza prawdziwe komórki w świecie odpowiednią dla engine ścieżką. Dynamiczne materiały nadal podlegają symulacji Noita: ciecze płyną, proszki spadają, gazy się przemieszczają, ogień reaguje, a niestabilne substancje mogą zmieniać się przez reakcje materiałowe. - -Różne klasy materiałów wymagają różnych strategii umieszczania. Build standalone może użyć bezpośredniego dostępu NoitaPatcher do siatki świata i małego fallbacku PixelScene w przypadkach, których Noita odmawia utworzyć bezpośrednio przy danej współrzędnej tekstury. - -Kolejki pracy są ograniczone, aby trzymanie dużego pędzla nie wykonywało celowo nieograniczonej pracy w jednej klatce. - -# Perki - -## Tworzenie i otrzymywanie perków - -**LMB** tworzy normalny pickup wybranego perka w świecie. - -Akcja otrzymania może nadać perk pojedynczo albo seriami. Operacje seryjne są obsługiwane jako ograniczone joby zamiast zastosowania wszystkich kopii w jednej klatce UI. - -Interfejs pokazuje postęp, a pozostałą pracę można anulować. Kopie już zatwierdzone przed anulowaniem pozostają zastosowane. - -Każda przyznana kopia nadal przechodzi normalną ścieżkę stosowania perka zamiast bezpośrednio udawać stan końcowy. - -## Usuwanie perków - -Usunięcie perka jest znacznie trudniejsze niż jego przyznanie. Perki mogą zmieniać globals, komponenty, encje, statystyki gracza i długotrwałe mechaniki, a Noita nie oferuje jednej uniwersalnej operacji odwrotnej. - -Dlatego MCM usuwa tylko stan, dla którego ma wystarczająco bezpieczną śledzoną odwrotność. Dziennik transakcji próbuje usunąć tylko stan należący do konkretnego zastosowania perka, bez resetowania niezależnego stanu gracza. - -Jeśli czyszczenie jest częściowe lub nie da się udowodnić jego pełności, pozostaje traktowane jako nieukończone zamiast po cichu zgłaszane jako udane. - -Perk z innego moda może dać się przyznać, ale niekoniecznie poprawnie usunąć. - -# Efekty - -Sekcja Effects stosuje i usuwa obsługiwane statusy materiałowe i encje GameEffect. - -Usuwanie uwzględnia własność stanu, kiedy to możliwe. MCM nie usuwa bezmyślnie podobnych ukrytych efektów należących do perków, gry lub innego systemu. - -Trwałe efekty utworzone przez MCM używają ograniczonego czyszczenia / wygasania, aby usunięcie jednego efektu MCM nie resetowało stanu innych systemów. - -# Stworzenia - -Katalog stworzeń zachowuje dokładne ścieżki XML zamiast łączyć wszystkie encje o podobnych nazwach. - -Obsługiwane działania: - -- **LMB** — tworzy wybraną zdefiniowaną encję obok gracza; -- przeciągnięcie poza menu — tworzy ją w potwierdzonej pozycji kursora świata; -- **RMB** — zmienia gracza w obsługiwaną formę; -- specjalny wpis **PLAYER** — tworzy lub przywraca stan gracza opisany niżej. - -Upuszczenie przeciąganej karty z powrotem nad menu anuluje spawn w świecie. - -Reguły zgodności dla niebezpiecznych lub nietypowych form opierają się na dokładnych ścieżkach. Sam znajomy fragment nazwy pliku nie sprawia, że encja jest automatycznie traktowana jak inna równoważna forma. - -# Przemiany i powrót do ludzkiej formy - -Grywalne formy zachowują użyteczny natywny ruch, ataki, wygląd i fizykę, gdy jest to praktyczne. Komponenty bezpośrednio konkurujące z wejściem gracza mogą być wyłączane lub dostosowywane, kiedy formą steruje gracz. - -Niektóre złożone stworzenia wymagają dodatkowej logiki zgodności. Bossowie, skryptowe wrappery i mocno fizyczne encje nie muszą zachowywać się dokładnie jak ich oryginalne wersje sterowane przez AI, gdy służą jako forma gracza. - -Skonfigurowana akcja powrotu — domyślnie **TAB** — najpierw używa normalnej ścieżki zakończenia przemiany. Jeśli to nie wystarcza, build standalone ma dodatkowe ścieżki odzyskiwania oparte na NoitaPatcher. - -W obsługiwanych przypadkach śmiertelnego uszkodzenia MCM próbuje: - -- pozostawić martwą tymczasową formę lub ciało w świecie, gdy jest to właściwe; -- przywrócić ludzką encję gracza; -- zwrócić authority i sterowanie; -- zachować ekwipunek; -- przywrócić istotny stan gracza. - -To logika odzyskiwania, a nie absolutna nieśmiertelność. Zewnętrzny kill script, niezgodny stan engine lub crash procesu może ominąć obsługiwaną ścieżkę. - -# Przejmowanie stworzeń - -Przejmowanie pozwala sterować stworzeniem już istniejącym w świecie zamiast wybierać formę z katalogu. - -Domyślny klawisz to **G**. - -Wskaż odpowiednie stworzenie i użyj akcji przejęcia. MCM sprawdza cel, przygotowuje zgodne przejście i usuwa lub wycofuje oryginalną encję świata dopiero po potwierdzeniu nowego stanu sterowanego przez gracza. - -Jeśli przejście się nie powiedzie, oryginalne stworzenie nie powinno po prostu zniknąć. - -Funkcja nie ogranicza się do katalogu MCM. Zgodne stworzenie z innego moda może działać, ale uniwersalna zgodność ze wszystkimi encjami zewnętrznymi nie jest gwarantowana. - -# Wpis Player - -**PLAYER** jest specjalnym wpisem katalogu stworzeń, a nie zwykłym celem polymorph. - -Jego akcja spawn tworzy osobną postać podobną do gracza i próbuje skopiować odpowiedni wygląd oraz informacje o maksymalnym zdrowiu. - -Użycie akcji transformacji na **PLAYER** nie zamienia już ludzkiego gracza w duplikat. Jeśli gracz jest w innej formie, akcja służy do powrotu do postaci ludzkiej. - -# Odzyskiwanie po Game Over w single-player - -Build standalone dla single-player zawiera dodatkową ścieżkę odzyskiwania z normalnego ekranu Game Over Noita. - -Gdy natywna integracja potrafi bezpiecznie rozpoznać wymagane struktury gry, MCM dodaje do interfejsu Game Over akcję **„I didn't die”**. - -Podczas gry MCM utrzymuje aktualizowaną kopię stanu gracza. Uruchomienie odzyskiwania zgłasza prośbę przez zwykłą ścieżkę aktualizacji MCM, zamiast odbudowywać całego gracza bezpośrednio w handlerze kliknięcia UI. - -Obsługiwane odzyskiwanie próbuje: - -- przywrócić lub uzyskać żywą encję gracza; -- uczynić ją ponownie authoritative; -- wyczyścić stan Game Over engine; -- zwrócić sterowanie i używalny stan gracza; -- wykonać best-effort czyszczenie audio, muzyki i interfejsu Game Over; -- zapewnić krótkie okno ochronne po odzyskaniu. - -Natywny helper działa fail-closed. Skanuje uruchomiony obsługiwany executable Noita w poszukiwaniu znanych struktur zamiast pisać pod jeden na stałe zakodowany adres. Jeśli po aktualizacji nie da się bezpiecznie zidentyfikować wymaganych struktur, opcjonalne odzyskiwanie nie jest używane zamiast zapisywać w niepewne miejsce. - -# Pogoda i czas - -MCM może kontrolować obsługiwany stan pogody i czasu, w tym presety i indywidualne parametry dostępne w bieżącej implementacji. - -Wymuszony stan można później oddać normalnej kontroli gry. Na przykład po ustawieniu konkretnej pory MCM może zwolnić to ustawienie, aby naturalny przepływ czasu Noita został wznowiony. - -Zmiany pogody są traktowane jako stan sterowany, a nie jednorazowe polecenia konsolowe. - -# Zasady świata - -Sekcja **RULES** zmienia obsługiwane globalne zachowanie gry. - -Reguły obejmują między innymi: - -- relacje między stworzeniami; -- zachowanie złota; -- używanie zaklęć; -- fog of war; -- wybrane nagrody za zabójstwa; -- dropy leczenia; -- zachowanie związane z krwią; -- grawitację; -- fizykę; -- siłę kopnięcia; -- połączenia fizyczne; -- cykl dnia i nocy; -- inne obsługiwane parametry globalne. - -Głównym celem jest odwracalność. - -Dla obsługiwanych reguł MCM zapisuje lub wyprowadza stan początkowy, aby można było później przywrócić ustawienie. Sterowanie mnożnikiem działa względem wartości pierwotnej, zamiast wielokrotnie mnożyć już zmieniony wynik. - -Reguły wymagające dotknięcia wielu encji lub obiektów fizycznych korzystają z ograniczonej pracy rozłożonej na klatki zamiast próbować synchronicznie przepisać cały świat jednym kliknięciem. - -# Teleportacja - -Sekcja teleportacji oferuje przygotowane cele na świecie, w tym miejsca na głównej trasie, Holy Mountains, większe obszary boczne i inne obsługiwane lokacje. - -Przed przeniesieniem gracza MCM może poprosić o załadowanie obszaru docelowego i szuka wolnego miejsca w pobliżu zamiast celowo umieszczać gracza w stałym terenie. - -Teleportacja nadal zależy od tego, czy świat potrafi załadować i dostarczyć prawidłowe miejsce. Mocno zmodyfikowane światy mogą wymagać zachowania fallback. - -# Entangled Worlds - -**Entangled Worlds / Noita Proxy jest opcjonalny.** MCM działa bez niego. - -Gdy EW jest obecny, MCM włącza dodatkowe funkcje świadome multiplayera. Wszyscy peerzy powinni korzystać ze zgodnych buildów MCM, jeśli opierają się na stanie synchronizowanym przez MCM. - -## Authority i formy - -Formy gracza wymagają specjalnej obsługi ownership, ponieważ przemieniony gracz nie powinien przypadkowo zostawiać drugiej sieciowej authority. - -MCM koordynuje ownership, retirement i powrót do ludzkiej formy z EW, gdy jest to obsługiwane. Encje bossów i podobne do Kolmi mają dodatkową obsługę lifecycle, której celem jest unikanie zduplikowanych authority i starych kopii kontrolowanych przez sieć. - -Zwykła ścieżka śmierci EW pozostaje odpowiedzialna za encje nierozpoznane jako stan formy należący do MCM. - -## Przedmioty, różdżki i zaklęcia - -Gdy to możliwe, MCM używa normalnych mechanizmów item / inventory EW zamiast tworzyć równoległy system transportu. - -Zatwierdzone zmiany różdżek i ekwipunku zaklęć żądają odpowiedniego odświeżenia multiplayer, jeśli integracja jest dostępna. Przedmioty świata utworzone przez MCM mogą zostać przekazane do standardowej ścieżki world-item EW. - -## Perki - -Normalne pickupy perków mogą korzystać ze standardowej synchronizacji world-item EW. Własna obsługa stanu perków MCM koordynuje refresh i ograniczone operacje, aby akcje seryjne nie wykonywały kosztownego globalnego odświeżenia dla każdej kopii. - -## Materiały - -Malowanie materiałami ma dedykowaną ścieżkę zgodności, ponieważ zmiany komórek świata nie są normalnymi encjami przedmiotów. - -MCM utrzymuje ograniczoną pracę malowania, rozdziela ją na granicach chunków i koordynuje wymagane kroki world-frame / persistence EW przed wypuszczeniem zsynchronizowanej konwersji. Nienaładowany chunk na krawędzi streamingu jest odkładany zamiast blokować cały aktywny ruch pędzla. - -Celem jest umożliwienie pobliskim peerom EW zobaczenia obsługiwanego namalowanego stanu świata bez zdalnego odtwarzania zwykłej akcji UI MCM jako wywołania PixelScene opartego tylko na nazwie pliku. - -Nadal obowiązują założenia EW dotyczące material id: gra odbierająca nie może poprawnie utworzyć materiału, którego nie ma w jej rejestrze albo którego registry engine jest niezgodne. - -## Pogoda, przejmowanie i stan świata - -Obsługiwany stan multiplayer MCM obejmuje również koordynację pogody, przejmowania oraz wybranych reguł / lifecycle. Kontrole authority zapobiegają sytuacji, w której dwóch peerów próbuje jednocześnie posiadać ten sam stan. - -Wsparcie EW jest celowo konserwatywne. Gdy integracja nie może wykazać bezpiecznej ścieżki synchronizacji, MCM wybiera poprawne lokalne zachowanie zamiast udawać, że każda operacja single-player jest automatycznie bezpieczna w multiplayer. - -# Zgodność i ograniczenia - -Noita udostępnia wiele systemów przez luźno powiązane encje, XML, komponenty Lua i natywne zachowanie engine. MCM nie może więc zagwarantować uniwersalnej zgodności z każdą zmodyfikowaną encją ani przyszłą aktualizacją gry. - -Ważne ograniczenia: - -- możliwość stworzenia stworzenia nie oznacza, że jest bezpieczną formą gracza; -- możliwość przyznania perka nie oznacza istnienia niezawodnej operacji odwrotnej; -- zewnętrznych transferów zaklęć lub przedmiotów nie zawsze da się cofnąć z wewnętrznego snapshotu; -- zewnętrzne skrypty mogą ominąć obsługiwane ścieżki śmierci i odzyskiwania; -- natywne odzyskiwanie zależy od obsługiwanego zachowania executable Noita i wyłącza się fail-closed, jeśli potrzebnych struktur nie da się rozpoznać; -- Entangled Worlds nie może zsynchronizować materiału nieobecnego w rejestrze gry odbierającej; -- mocno zmienione ekwipunki, encje lub reguły świata mogą wymagać zgodności specyficznej dla konkretnego moda. - -MCM próbuje zachować stan pierwotny i wycofywać nieudane mutacje, ale narzędzie sandbox modyfikujące żywy stan gry nie może zapewnić pełnej transakcyjności każdej kombinacji zewnętrznych modów. - -# Zapisane dane - -MCM zachowuje stan użytkownika, który powinien przeżyć kolejne uruchomienia, w tym obsługiwane ustawienia, przypisania, układ menu i presety różdżek. - -Tożsamość moda pozostaje stabilna, więc normalne aktualizacje mogą zachować obsługiwane dane. Przy instalacji nowego buildu standalone nadal zaleca się zastąpić cały folder moda, ponieważ łączenie starych i nowych plików może pozostawić przestarzały runtime. - -# Rozwiązywanie problemów - -## Mod się nie pojawia - -Sprawdź, czy ścieżka kończy się na: - -`mods/metamorph_creative_menu/mod.xml` - -Dodatkowy folder archiwum nad `metamorph_creative_menu` uniemożliwia Noita poprawne wykrycie moda. - -## Funkcje natywne lub materiałowe nie działają - -Sprawdź, czy **Unsafe Mods** jest dozwolone i czy zainstalowany jest build standalone GitHub bez mieszania plików Workshop. - -## Menu się otwiera, ale uruchamia się również akcja w grze - -Sprawdź konflikty własnych przypisań. MCM pokazuje duplikaty, ale pozwala je zachować, jeśli tego chcesz. - -## Nie można bezpiecznie przemienić się w stworzenie - -Nie każda spawnująca się encja XML jest obsługiwaną formą gracza. Dla stworzeń wymagających specjalnej obsługi istnieją reguły oparte na dokładnych ścieżkach. - -## Nie można usunąć perka - -Usuwanie jest dostępne tylko tam, gdzie MCM ma obsługiwaną operację odwrotną dla śledzonego stanu. To celowe — zgadywane czyszczenie może uszkodzić niezwiązany stan gracza. - -## Multiplayer działa inaczej między peerami - -Używaj zgodnych buildów MCM u wszystkich uczestników i zgodnego środowiska Noita / Entangled Worlds. MCM nie może naprawić niezgodnego rejestru materiałów ani dowolnych zmian sieciowych innych modów. - -# Zgłaszanie błędów - -Dobry raport powinien zawierać: - -- co próbowałeś zrobić; -- dokładną sekcję i akcję MCM; -- czy problem występuje w single-player, Entangled Worlds czy obu; -- czy zainstalowany jest build standalone czy Workshop; -- czy aktywne są inne mody gameplay; -- pewne kroki reprodukcji; -- odpowiednie logi Noita / EW, jeśli są dostępne. - -Przy problemach z przemianą, przejmowaniem, przedmiotem lub materiałem podaj dokładną encję lub materiał, jeśli to możliwe. Identyfikator techniczny jest często bardziej przydatny niż przetłumaczona nazwa wyświetlana. - -# Repozytorium i źródła deweloperskie - -Repozytorium celowo zawiera **pełne drzewo deweloperskie**, a nie to samo odchudzone archiwum pobierane przez graczy. - -`metamorph_creative_menu/` zawiera kod runtime razem z: - -- testami automatycznymi; -- narzędziami QA; -- diagnostyką; -- natywnym kodem źródłowym; -- narzędziami build; -- regułami czyszczenia release; -- dokumentacją deweloperską. - -Te pliki są potrzebne do rozwoju i testów regresji, dlatego pozostają w source GitHub. Gotowy ZIP dla graczy jest generowany osobno i wyklucza zawartość tylko deweloperską. - -Paczka gracza otrzymuje też czyszczenie specyficzne dla release, w tym minimalny player `README.txt`, podczas gdy drzewo source zachowuje dokumentację deweloperską. - -# Testy - -Automatyczny zestaw testów znajduje się w `metamorph_creative_menu/tests/` i łączy testy kontraktowe Python z mockami Lua. - -Z root repozytorium workflow release uruchamia testy na pełnym zaimportowanym source przed opublikowaniem buildu dla graczy. `texlua` jest wymagane dla części mocków Lua. - -Kontrole source hygiene chronią też pliki skierowane do produkcji i dokumentację przed resztkami historii rozwoju, starymi powierzchniami debug i przypadkowymi artefaktami procesu. - -# Import source i proces release - -Pełny source deweloperski może zostać zaimportowany z archiwum rodziny `Metamorph-Creative-Menu-v...zip`. - -Workflow importu sprawdza strukturę, wymaga pełnych komponentów deweloperskich, uruchamia source hygiene i zestaw regresyjny przed commitowaniem zaimportowanego drzewa. - -Archiwum w stylu ModWorkshop / player nie jest traktowane jako source deweloperski. - -Publiczny release `latest-build` jest następnie tworzony z pełnego source przez oddzielny player-builder. Usuwa on QA, testy, diagnostykę, natywny kod źródłowy i inne payloady deweloperskie, stosuje reguły czyszczenia, waliduje wynikowe archiwum i dopiero wtedy aktualizuje stabilny asset do pobrania. - -To rozdzielenie pozwala repozytorium pozostać pełną bazą do rozwoju, a normalnemu downloadowi gracza być małym i pozbawionym instrumentacji deweloperskiej. - -# Komponenty zewnętrzne - -Komponenty firm trzecich, dołączone zależności i projekty upstream są opisane w [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). diff --git a/README.pt-BR.md b/README.pt-BR.md deleted file mode 100644 index 4c690e1..0000000 --- a/README.pt-BR.md +++ /dev/null @@ -1,586 +0,0 @@ - - -[English](README.md) · [Русский](README.ru.md) · [**Português (Brasil)**](README.pt-BR.md) · [Español](README.es.md) · [Deutsch](README.de.md) · [Français](README.fr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) · [한국어](README.ko.md) - -

Metamorph: Creative Menu

- -

Um menu criativo e conjunto de ferramentas sandbox para Noita: feitiços, varinhas, itens, materiais, perks, efeitos, criaturas, transformações, possessão, teleporte, clima, regras do mundo, integração multiplayer e ferramentas de recuperação.

- -

Criador e mantenedor: zerodancing

- ---- - -# Download - -Para jogar normalmente, use a build pronta para instalar: - -[**⬇️ Baixar a build mais recente**](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/download/latest-build/Metamorph-Creative-Menu.zip) - -[Página da build mais recente](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/tag/latest-build) · [Changelog](metamorph_creative_menu/CHANGELOG.txt) - -A release do GitHub é gerada automaticamente a partir da árvore completa de desenvolvimento. Testes, ferramentas de QA, diagnósticos, código-fonte nativo e ferramentas de build permanecem no repositório, mas são excluídos do arquivo para jogadores. - -A build standalone do GitHub inclui NoitaPatcher e suporte nativo de recuperação, portanto **Unsafe Mods precisa estar permitido**. - -# Instalação - -1. Baixe `Metamorph-Creative-Menu.zip` pelo link acima. -2. Inicie Noita e abra **Mods** no menu principal. -3. Clique em **Open mods folder**. -4. Extraia ou mova a pasta `metamorph_creative_menu` para a pasta `mods`. O caminho final deve conter `metamorph_creative_menu/mod.xml` diretamente, sem uma pasta extra criada pelo arquivo compactado. -5. Se já houver uma cópia antiga, substitua a pasta `metamorph_creative_menu` inteira em vez de mesclar arquivos antigos e novos. -6. Volte ao Noita e atualize a lista de mods. -7. Permita **Unsafe Mods**. -8. Ative **Metamorph: Creative Menu** e inicie um jogo com os mods ativos. - -Não ative a build standalone do GitHub e a versão da Steam Workshop ao mesmo tempo. - -# Build standalone e Steam Workshop - -A build distribuída por este repositório do GitHub é a build standalone completa. Ela inclui NoitaPatcher e recursos que precisam de acesso irrestrito à API de mods, incluindo operações de baixo nível com materiais e recuperação nativa após Game Over. - -A [build da Steam Workshop](https://steamcommunity.com/sharedfiles/filedetails/?id=3785170245) é instalada separadamente. Ela não inclui os componentes nativos necessários para os recursos exclusivos da build standalone. - -As duas builds usam a mesma identidade de mod. Instalar ambas ao mesmo tempo pode causar arquivos duplicados ou conflitantes e não é suportado. - -# Sobre o mod - -**Metamorph: Creative Menu (MCM)** é um menu criativo e toolkit sandbox para Noita. - -Ele reúne ferramentas para: - -- feitiços e inventário de feitiços; -- edição de varinhas e presets reutilizáveis; -- itens e recipientes com líquidos; -- catálogo completo de materiais e pintura de materiais; -- perks e remoção suportada de perks; -- efeitos de status e entidades GameEffect; -- criaturas, transformações e possessão; -- clima e horário; -- regras globais do mundo; -- teleporte; -- integração opcional com Entangled Worlds; -- recuperação após transformação, morte da forma e Game Over. - -O MCM tenta operar sobre o estado real do Noita em vez de substituir tudo por cópias decorativas. Cartas de feitiço existentes são movidas como entidades, a entrega de itens respeita a estrutura do inventário, alterações de varinhas usam caminhos de commit/rollback, materiais continuam sendo materiais simulados de verdade e regras reversíveis preservam estado original suficiente para restaurar configurações suportadas depois. - -Entangled Worlds é opcional. Sem ele, o MCM continua sendo um mod completo para single-player. - -# Controles - -Controles padrão: - -| Ação | Entrada padrão | -| --- | --- | -| Abrir / fechar o menu criativo | **F4** | -| Voltar à forma humana durante uma transformação | **TAB** | -| Possuir uma criatura no mundo | **G** | -| Desenhar com o material selecionado | **Botão do meio do mouse** | - -O painel criativo também fica disponível pela interface normal do inventário do Noita. - -As teclas podem ser alteradas na seção **CONTROLS** do MCM e nas configurações de mod do Noita. São suportadas teclas, botões do mouse e combinações exatas de **CTRL / SHIFT / ALT**. - -Durante a captura de uma tecla: - -- **DELETE / BACKSPACE** limpa a associação; -- **ESC** cancela; -- **R** restaura o padrão daquela ação; -- **RESET ALL** restaura todas as associações padrão após confirmação. - -Associações duplicadas continuam editáveis, mas o MCM mostra o conflito em vez de substituir silenciosamente outra ação. - -Ações de navegação do menu, seções, retorno da forma, possessão, pintura de materiais, limpeza de efeitos, liberação do clima, reset de regras do mundo e ações multiplayer suportadas podem ser remapeadas. - -# Janela do Creative Menu - -O painel criativo direto é uma janela redimensionável e persistente, não um overlay de debug fixo. - -Ela pode ser: - -- movida pela barra de título; -- redimensionada pelas bordas e cantos; -- minimizada; -- fechada; -- restaurada para o layout padrão. - -Posição, largura, altura e última seção aberta são lembradas entre execuções. Depois de mudanças de resolução, a geometria salva é ajustada para permanecer dentro da área visível da interface. - -Listas e catálogos usam layouts medidos e contêineres de rolagem do Noita. Redimensionar a janela muda imediatamente quanto conteúdo cabe na tela, e textos traduzidos podem quebrar em mais linhas sem sobrepor controles vizinhos. Em layouts estreitos, os controles passam para novas linhas em vez de se desenharem uns sobre os outros. - -Apenas abrir ou passar o mouse sobre o menu destacado não desativa permanentemente o gameplay. Quando um clique, arrasto ou campo de texto focado também poderia acionar o personagem, o MCM suprime temporariamente os controles relevantes e os restaura em seguida. - -# Busca e localização - -A busca está disponível nos principais catálogos, incluindo feitiços, itens, materiais, perks e criaturas. - -Dependendo da entrada, ela pode encontrar: - -- o nome no idioma atual da interface; -- o nome em inglês; -- chaves de localização; -- identificadores técnicos; -- caminhos XML. - -A busca ignora maiúsculas/minúsculas, normaliza acentos e separadores comuns e tolera pequenos erros de digitação em consultas mais longas. - -A interface própria do MCM é localizada em: - -- inglês; -- russo; -- português brasileiro; -- espanhol; -- alemão; -- francês; -- italiano; -- polonês; -- chinês simplificado; -- japonês; -- coreano. - -Para conteúdo comum do Noita, o mod reutiliza as chaves de localização do próprio jogo sempre que possível em vez de manter nomes duplicados. - -# Feitiços - -A seção de feitiços trabalha tanto com o catálogo quanto com entidades de feitiço que já pertencem ao jogador. - -A área principal mostra: - -- os slots normais da varinha ativa; -- as cartas **ALWAYS CAST**; -- o inventário de feitiços do jogador; -- o catálogo pesquisável de feitiços. - -## Substituição rápida do slot selecionado - -Um clique curto seleciona um slot da varinha. Depois disso, um clique curto com LMB em um feitiço do catálogo substitui o slot selecionado. - -Esse é o caminho rápido para edição comum. Movimentos precisos usam drag-and-drop. - -## Drag-and-drop transacional - -Cartas existentes podem ser arrastadas: - -- entre slots da varinha; -- de slots comuns para **ALWAYS CAST**; -- de **ALWAYS CAST** de volta para slots comuns; -- para um slot exato do inventário de feitiços; -- do inventário de volta para a varinha; -- para o mundo; -- para a lixeira quando suportado. - -Para uma carta existente, o MCM move a entidade real sempre que possível. Assim, estado mutável, usos restantes e dados adicionados por outros mods não são perdidos apenas porque a carta mudou de lugar. - -A origem permanece intacta até a transação de destino ser confirmada. Alvos inválidos ou desconhecidos cancelam a operação em vez de apagar a carta original. Uma soltura do mouse executa no máximo uma operação confirmada. - -Cartas do catálogo são apenas templates e nunca são consumidas ao arrastar. - -## Always Cast - -Cartas Always Cast têm sua própria faixa. Promoção, remoção e troca levam em conta a capacidade efetiva dos slots comuns para evitar uma estrutura inválida de varinha. - -## Desfazer e refazer - -Mudanças internas da varinha têm um histórico limitado de **UNDO / REDO**. - -Operações que entregam uma entidade real ao mundo ou a outro inventário nem sempre podem ser revertidas com segurança a partir de um snapshot da varinha, então essas transferências externas não são prometidas como universalmente reversíveis. - -# Varinhas - -A área de varinha edita a varinha atualmente segurada pelo jogador. - -Estatísticas suportadas incluem: - -- capacidade / slots; -- feitiços por disparo; -- tempo de recarga; -- atraso entre disparos; -- dispersão; -- multiplicador de velocidade de projétil; -- mana máxima; -- velocidade de recarga de mana; -- recuperação de recuo; -- nível da varinha; -- shuffle; -- comportamento sem recarga. - -O MCM também edita apresentação e metadados relacionados: - -- nome exibido; -- bloqueios da varinha e das cartas; -- caminho do sprite; -- offsets do sprite; -- posição de disparo. - -Um catálogo visual de aparências segue dados de XML de varinhas quando disponíveis. - -## Presets de varinha - -Varinhas podem ser salvas como presets nomeados persistentes e reutilizadas em outros mundos ou futuras sessões de Noita. - -Um preset pode guardar: - -- estatísticas da varinha; -- valores de mana; -- metadados visuais; -- cartas comuns; -- cartas Always Cast; -- posições dos slots; -- usos restantes; -- estado congelado das cartas. - -Cada preset tem duas operações diferentes: - -- **APPLY** grava o blueprint salvo na varinha atualmente segurada; -- **GET COPY** constrói uma nova varinha com o mesmo blueprint. - -A cópia vai para um slot livre de varinha no inventário rápido quando possível. Se não houver slot adequado, a varinha concluída é deixada no mundo perto do jogador. - -Substituição de varinha e carregamento de preset usam caminhos de commit/rollback. Se a construção ou colocação falhar, o MCM tenta remover a árvore de entidade incompleta em vez de deixar uma varinha parcial quebrada. - -# Itens e líquidos - -## Itens - -Um clique curto com **LMB** em uma entrada do catálogo cria um item suportado perto do jogador. - -**RMB** tenta entregar o item à área adequada do inventário. - -Entradas do catálogo também podem ser arrastadas: - -- para um alvo compatível no inventário rápido; -- para fora do menu, em uma posição exata do mundo. - -Soltar a carta dentro do menu sem um destino válido cancela a operação. A carta do catálogo é apenas um template e permanece disponível. - -O MCM respeita a separação normal do inventário rápido do Noita entre slots de varinha e slots de item. Se houver falha ao carregar XML, preencher líquido, transferir para o inventário ou fazer uma transferência multiplayer opcional, a nova entidade é removida quando possível. - -Alguns itens de inventário legítimos ficam em diretórios do jogo orientados a criaturas. O MCM classifica casos conhecidos pelo comportamento em vez de assumir que o nome da pasta sozinho define se algo é item ou criatura. - -## Líquidos - -Entradas de líquido criam recipientes reais do Noita já preenchidos, não objetos decorativos da interface. - -O recipiente resultante pode ser carregado, jogado, quebrado e derramado, e seu conteúdo participa das reações normais de materiais. - -# Materiais - -A seção Materials é uma ferramenta de pintura do mundo baseada no registro real de materiais do Noita. - -O catálogo é montado a partir de líquidos, areias / pós, gases, fogos, sólidos e materiais estáticos ou de efeitos registrados pelo engine. Materiais adicionados corretamente por outros mods ativos podem aparecer automaticamente. - -Descoberta e validação cara de materiais são distribuídas em trabalho limitado, em vez de varrer todo o catálogo em um único frame da interface. - -## Apresentação dos materiais - -Líquidos usam a mesma apresentação de recipiente preenchido da seção de itens. - -Para materiais não líquidos, o MCM prefere texturas e tint definidos em `materials.xml`, incluindo definições herdadas. Se não houver textura autoral, o fallback vem da cor real do material no engine e não de uma cor de preview arbitrária. - -## Pintura - -1. Escolha um material. -2. Escolha o tamanho do pincel. -3. Ative o modo de pintura. -4. Feche o inventário. -5. Segure a entrada configurada de desenho no mundo. - -Abrir o inventário interrompe o modo de pintura ativo. - -A pintura não emite apenas partículas decorativas. O MCM coloca células reais no mundo por um caminho apropriado ao engine. Materiais dinâmicos continuam seguindo a simulação do Noita: líquidos fluem, pós caem, gases se movem, fogo reage e substâncias instáveis podem se transformar por reações materiais. - -Classes diferentes exigem estratégias diferentes de colocação. A build standalone pode usar acesso direto à grade do mundo por NoitaPatcher e um pequeno fallback de PixelScene para casos autorais que o Noita recusa construir diretamente em determinada coordenada da textura. - -As filas são limitadas para que manter um pincel grande pressionado não execute deliberadamente trabalho ilimitado em um único frame. - -# Perks - -## Criar e receber perks - -**LMB** cria um pickup normal do perk selecionado no mundo. - -A ação de receber pode conceder o perk individualmente ou em lote. Operações em lote são processadas como jobs limitados, em vez de aplicar todas as cópias em um único frame de UI. - -A interface mostra o progresso, e o trabalho ainda pendente pode ser cancelado. Cópias já confirmadas antes do cancelamento permanecem aplicadas. - -Cada cópia concedida ainda passa pelo caminho normal de aplicação de perk em vez de falsificar diretamente o estado final. - -## Remover perks - -Remover um perk é muito mais difícil do que concedê-lo. Perks podem alterar globals, componentes, entidades, estatísticas do jogador e mecânicas de longa duração, e o Noita não fornece uma operação inversa universal. - -Por isso o MCM só remove estado para o qual tenha uma inversão rastreada considerada suficientemente segura. O journal da transação tenta retirar apenas o estado pertencente àquela aplicação específica do perk, sem resetar estado não relacionado do jogador. - -Se a limpeza ficar parcial ou não puder ser provada como completa, ela continua marcada como incompleta em vez de ser relatada silenciosamente como bem-sucedida. - -Perks de terceiros podem ser concedidos sem necessariamente poderem ser removidos corretamente. - -# Efeitos - -A seção Effects aplica e remove status de materiais e entidades GameEffect suportadas. - -A remoção considera propriedade quando possível. O MCM evita apagar indiscriminadamente efeitos ocultos semelhantes que pertencem a perks, ao jogo ou a outro sistema. - -Efeitos persistentes criados pelo MCM usam limpeza / expiração limitada para que remover um efeito do MCM não resete estado alheio. - -# Criaturas - -O catálogo de criaturas preserva caminhos XML exatos em vez de juntar todas as entidades com nomes parecidos. - -Interações suportadas: - -- **LMB** — cria a entidade autoral selecionada perto do jogador; -- arrastar para fora do menu — cria na posição confirmada do cursor no mundo; -- **RMB** — transforma o jogador atual em uma forma suportada; -- entrada especial **PLAYER** — cria ou restaura estado de jogador como descrito abaixo. - -Soltar uma carta arrastada de volta sobre o menu cancela a criação no mundo. - -Regras de compatibilidade para formas perigosas ou incomuns usam caminhos exatos. Um nome de arquivo que apenas contenha uma palavra familiar não faz a entidade ser automaticamente tratada como outra forma equivalente. - -# Transformações e retorno à forma humana - -Formas jogáveis mantêm movimento nativo útil, ataques, aparência e física quando prático. Componentes que competem diretamente com o input do jogador podem ser desativados ou adaptados enquanto a forma estiver sob controle do jogador. - -Algumas criaturas complexas exigem lógica adicional. Bosses, wrappers scriptados e entidades pesadas de física não são garantidos a se comportar exatamente como suas versões controladas pela IA quando usados como forma do jogador. - -A ação configurada de retorno — **TAB por padrão** — primeiro usa o caminho normal de encerramento da transformação. Quando isso não basta, a build standalone possui caminhos adicionais de restauração via NoitaPatcher. - -Em casos suportados de dano fatal, o MCM tenta: - -- deixar a forma temporária morta ou cadáver no mundo quando apropriado; -- restaurar uma entidade humana do jogador; -- devolver authority e controles; -- preservar o inventário; -- restaurar estado relevante do jogador. - -Isso é lógica de recuperação, não imortalidade absoluta. Um kill script de terceiro, estado incompatível do engine ou crash do processo pode ignorar esse handoff suportado. - -# Possessão - -Possessão controla uma criatura que já existe no mundo em vez de escolher uma forma pelo catálogo. - -A tecla padrão é **G**. - -Aponte para uma criatura adequada e use a ação de possessão. O MCM valida o alvo, prepara uma transição compatível e remove ou aposenta a entidade original do mundo apenas depois de confirmar o novo estado controlado pelo jogador. - -Se a transição falhar, a criatura original não deve simplesmente desaparecer. - -A possessão não se limita ao catálogo interno do MCM. Uma criatura compatível criada por outro mod pode funcionar, mas compatibilidade universal com toda entidade de terceiros não é garantida. - -# Entrada Player - -**PLAYER** é uma entrada especial do catálogo de criaturas, não um alvo comum de polymorph. - -Sua ação de spawn cria um personagem separado semelhante ao jogador e tenta copiar apresentação apropriada e informação de vida máxima. - -Usar a ação de transformação em **PLAYER** não transforma um jogador já humano em uma duplicata. Se o jogador estiver em outra forma, a ação serve como retorno à forma humana. - -# Recuperação de Game Over em single-player - -A build standalone para single-player inclui um caminho adicional de recuperação para a tela padrão de Game Over do Noita. - -Quando a integração nativa consegue identificar com segurança as estruturas necessárias do jogo, o MCM adiciona a ação **“I didn't die”** à interface de Game Over. - -Durante o jogo, o MCM mantém um backup rotativo do estado do jogador. Ativar a recuperação solicita a restauração pelo caminho normal de atualização do MCM, em vez de reconstruir o jogador inteiro diretamente no handler do clique da interface. - -Uma recuperação suportada tenta: - -- restaurar ou obter uma entidade viva do jogador; -- torná-la authoritative novamente; -- limpar o estado Game Over do engine; -- devolver controles e estado utilizável do jogador; -- fazer limpeza best-effort de áudio, música e interface do Game Over; -- fornecer uma curta janela de proteção após a restauração. - -O helper nativo foi projetado para falhar de forma segura. Ele procura estruturas conhecidas no executável suportado do Noita em execução em vez de escrever para um único endereço fixo para sempre. Se as estruturas esperadas não puderem ser identificadas com segurança após uma atualização do jogo, a recuperação opcional não é usada em vez de escrever em um endereço incerto. - -# Clima e horário - -O MCM controla estado suportado de clima e horário, incluindo presets e parâmetros individuais expostos pela implementação atual. - -Um estado forçado pode ser liberado de volta para o controle normal do jogo. Por exemplo, depois de fixar um horário específico, o MCM pode parar de possuir essa configuração para que o ciclo natural do Noita continue. - -Mudanças climáticas são tratadas como estado controlado, não como comandos de console sem retorno. - -# Regras do mundo - -A seção **RULES** altera comportamento global suportado do jogo. - -As regras abrangem áreas como: - -- relações entre criaturas; -- comportamento do ouro; -- uso de feitiços; -- fog of war; -- recompensas específicas de morte; -- drops de cura; -- comportamento de sangue; -- gravidade; -- física; -- força do chute; -- juntas físicas; -- ciclo dia/noite; -- outros parâmetros globais suportados. - -O objetivo principal é reversibilidade. - -Para regras suportadas, o MCM registra ou deriva o estado original para poder restaurá-lo depois. Controles multiplicadores são aplicados em relação ao valor original em vez de multiplicar repetidamente um resultado já modificado. - -Regras que precisam tocar muitas entidades ou objetos físicos usam trabalho limitado ao longo dos frames em vez de tentar reescrever o mundo inteiro de forma síncrona com um clique. - -# Teleporte - -A seção de teleporte oferece destinos preparados pelo mundo, incluindo pontos na rota principal, Holy Mountains, áreas laterais importantes e outros locais suportados. - -Antes de mover o jogador, o MCM pode solicitar o carregamento da área e procura espaço utilizável próximo em vez de colocar o jogador diretamente dentro de terreno sólido. - -O teleporte ainda depende de o mundo conseguir carregar e fornecer um destino válido. Mundos muito modificados podem exigir comportamento de fallback. - -# Entangled Worlds - -**Entangled Worlds / Noita Proxy é opcional.** O MCM funciona sem ele. - -Quando EW está presente, o MCM habilita comportamento adicional consciente de multiplayer. Todos os peers devem usar builds compatíveis do MCM ao depender de estado sincronizado específico do MCM. - -## Authority e formas - -Formas do jogador exigem tratamento especial de ownership porque um jogador transformado não deve deixar acidentalmente uma segunda authority de rede. - -O MCM coordena ownership, retirement e retorno à forma humana com EW onde suportado. Entidades de boss e do tipo Kolmi têm tratamento adicional de lifecycle destinado a impedir authorities duplicadas e cópias antigas controladas pela rede. - -O caminho normal de morte do EW continua responsável por entidades que não são reconhecidas como estado de forma pertencente ao MCM. - -## Itens, varinhas e feitiços - -Quando possível, o MCM usa os mecanismos normais de item / inventário do EW em vez de inventar um sistema de transporte paralelo. - -Mutações confirmadas de varinha e inventário de feitiços solicitam o refresh multiplayer apropriado quando a integração está disponível. Itens de mundo criados pelo MCM podem ser entregues ao caminho padrão de world items do EW. - -## Perks - -Pickups normais de perks podem usar a sincronização padrão de world items do EW. O tratamento de estado de perk do MCM coordena refresh e operações limitadas para que ações em lote não tentem emitir um refresh global caro para cada cópia. - -## Materiais - -Pintura de materiais tem um caminho especial de compatibilidade porque mudanças em células do mundo não são entidades de item comuns. - -O MCM mantém o trabalho de pintura limitado, separa trabalho nas fronteiras de chunks e coordena etapas necessárias de world-frame / persistência do EW antes de liberar trabalho de conversão sincronizada. Um chunk de borda ainda não carregado é adiado em vez de bloquear todo o traço atual. - -O objetivo é permitir que peers próximos vejam o estado pintado suportado sem reproduzir remotamente a ação normal da interface do MCM como uma chamada PixelScene baseada apenas em filename. - -Isso ainda herda as suposições de material id do EW: um jogo receptor não pode criar corretamente um material inexistente ou cujo registro de materiais do engine seja incompatível. - -## Clima, possessão e estado do mundo - -Estado multiplayer suportado do MCM também inclui coordenação de clima, possessão e comportamento selecionado de regras / lifecycle. Verificações de authority evitam que dois peers tentem possuir o mesmo estado ao mesmo tempo. - -O suporte a EW é deliberadamente conservador. Quando a integração não consegue provar um caminho sincronizado seguro, o MCM prefere o comportamento local suportado em vez de fingir que toda operação single-player é automaticamente segura em multiplayer. - -# Compatibilidade e limitações - -Noita expõe muitos sistemas por entidades fracamente acopladas, XML, componentes Lua e comportamento nativo do engine. Por isso o MCM não pode prometer compatibilidade universal com toda entidade modificada ou toda futura atualização do jogo. - -Limitações importantes: - -- uma criatura poder ser criada não significa que seja uma forma jogável segura; -- um perk poder ser concedido não significa que tenha inversão confiável; -- transferências externas de feitiços ou itens nem sempre podem ser desfeitas a partir de um snapshot interno; -- scripts de terceiros podem ignorar caminhos suportados de morte e recuperação; -- recursos nativos de recuperação dependem de comportamento suportado do executável do Noita e falham de forma segura se as estruturas necessárias não puderem ser identificadas; -- Entangled Worlds não consegue sincronizar um material ausente no registro do jogo receptor; -- inventários, entidades ou regras muito modificados podem exigir compatibilidade específica para aquele mod. - -O MCM tenta preservar estado original e reverter mutações que falham, mas uma ferramenta sandbox que altera o estado vivo do jogo não pode tornar toda combinação de mods de terceiros totalmente transacional. - -# Dados salvos - -O MCM persiste estado de usuário que deve sobreviver entre execuções, incluindo configurações suportadas, teclas, layout do menu e presets de varinha. - -A identidade do mod permanece estável para que atualizações normais preservem dados suportados. Ainda é recomendado substituir a pasta inteira do mod ao instalar uma nova build standalone, pois mesclar arquivos antigos e novos pode deixar runtime obsoleto para trás. - -# Solução de problemas - -## O mod não aparece - -Confirme que a estrutura termina em: - -`mods/metamorph_creative_menu/mod.xml` - -Uma pasta extra acima de `metamorph_creative_menu` impede o Noita de encontrar o mod corretamente. - -## Recursos nativos ou de materiais não funcionam - -Confirme que **Unsafe Mods** está permitido e que você instalou a build standalone do GitHub, sem misturar arquivos da Workshop. - -## O menu abre, mas uma ação do jogo também dispara - -Verifique conflitos nas teclas personalizadas. O MCM mostra associações duplicadas, mas permite mantê-las se você realmente quiser. - -## Uma criatura não pode ser transformada com segurança - -Nem toda entidade XML que pode ser criada é uma forma de jogador suportada. Regras por caminho exato existem para criaturas que precisam de tratamento especial. - -## Um perk não pode ser removido - -A remoção só está disponível onde o MCM possui uma operação inversa suportada para o estado rastreado. Isso é intencional; tentar adivinhar uma limpeza pode danificar estado não relacionado do jogador. - -## O multiplayer se comporta diferente entre peers - -Use builds compatíveis do MCM em todos os participantes e mantenha Noita / Entangled Worlds compatíveis. O MCM não pode corrigir um registro de materiais incompatível nem alterações de rede arbitrárias de outros mods. - -# Relatando bugs - -Um bom relatório deve incluir: - -- o que você estava tentando fazer; -- a seção e ação exatas do MCM; -- se ocorre em single-player, Entangled Worlds ou ambos; -- se a build standalone ou Workshop está instalada; -- se outros mods de gameplay estão ativos; -- passos confiáveis de reprodução; -- logs relevantes de Noita / EW quando disponíveis. - -Para problemas de transformação, possessão, item ou material, inclua a entidade ou material exato quando possível. Identificadores técnicos normalmente são mais úteis que o nome traduzido exibido. - -# Repositório e fonte de desenvolvimento - -O repositório contém intencionalmente a **árvore completa de desenvolvimento**, e não o mesmo arquivo reduzido que os jogadores baixam. - -`metamorph_creative_menu/` contém código runtime junto com: - -- testes automatizados; -- ferramentas de QA; -- diagnósticos; -- código-fonte nativo; -- ferramentas de build; -- regras de limpeza de release; -- documentação de desenvolvimento. - -Esses arquivos são úteis para desenvolvimento e testes de regressão, por isso permanecem no source do GitHub. O ZIP pronto para jogadores é gerado separadamente e exclui conteúdo exclusivo de desenvolvimento. - -O pacote de jogador também recebe limpeza específica de release, incluindo o `README.txt` mínimo do pacote, enquanto a árvore source mantém sua documentação de desenvolvimento. - -# Testes - -A suíte automatizada fica em `metamorph_creative_menu/tests/` e combina verificações de contrato em Python com mocks em Lua. - -A partir da raiz do repositório, o workflow de release executa a suíte contra o source completo importado antes de publicar a build de jogador. `texlua` é necessário para a parte dos mocks Lua. - -Verificações de source hygiene também protegem arquivos voltados à produção e documentação contra restos de histórico de desenvolvimento, interfaces antigas de debug e artefatos acidentais do processo. - -# Importação do source e processo de release - -O source completo de desenvolvimento pode ser importado de um arquivo da família `Metamorph-Creative-Menu-v...zip`. - -O workflow de importação verifica a estrutura, exige os componentes completos de desenvolvimento, executa source hygiene e a suíte de regressão antes de commitar a árvore importada. - -Um arquivo ModWorkshop / player-style não é tratado como source de desenvolvimento. - -A release pública `latest-build` é então produzida a partir do source completo por um builder separado. Ele remove QA, testes, diagnósticos, código-fonte nativo e outro payload exclusivo de desenvolvimento, aplica regras de limpeza, valida o arquivo final e só então atualiza o asset estável de download. - -Essa separação permite que o repositório continue útil para desenvolvimento enquanto o download normal do jogador permanece pequeno e sem instrumentação de desenvolvimento. - -# Componentes de terceiros - -Componentes de terceiros, dependências incluídas e projetos upstream são documentados em [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). diff --git a/README.ru.md b/README.ru.md deleted file mode 100644 index 1220c72..0000000 --- a/README.ru.md +++ /dev/null @@ -1,586 +0,0 @@ - - -[English](README.md) · [**Русский**](README.ru.md) · [Português (Brasil)](README.pt-BR.md) · [Español](README.es.md) · [Deutsch](README.de.md) · [Français](README.fr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) · [한국어](README.ko.md) - -

Metamorph: Creative Menu

- -

Креативное меню и набор sandbox-инструментов для Noita: заклинания, жезлы, предметы, материалы, перки, эффекты, существа, превращения, вселение, телепортация, погода, правила мира, мультиплеерная интеграция и средства восстановления.

- -

Автор и сопровождающий: zerodancing

- ---- - -# Скачать - -Для обычной игры используй готовую к установке сборку: - -[**⬇️ Скачать последнюю сборку**](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/download/latest-build/Metamorph-Creative-Menu.zip) - -[Страница последней сборки](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/tag/latest-build) · [Список изменений](metamorph_creative_menu/CHANGELOG.txt) - -GitHub-сборка автоматически создаётся из полного дерева разработки. Тесты, QA-инструменты, диагностика, исходники нативного модуля и build-инструменты остаются в репозитории, но не попадают в архив для игроков. - -Standalone-сборка с GitHub включает NoitaPatcher и нативное восстановление, поэтому необходимо разрешить **Unsafe Mods**. - -# Установка - -1. Скачай `Metamorph-Creative-Menu.zip` по ссылке выше. -2. Запусти Noita и открой **Mods** в главном меню. -3. Нажми **Open mods folder**. -4. Распакуй или перемести папку `metamorph_creative_menu` в открывшуюся папку `mods`. Итоговый путь должен напрямую содержать `metamorph_creative_menu/mod.xml`, без лишней вложенной папки от архива. -5. Если старая копия уже установлена, замени папку `metamorph_creative_menu` целиком, а не объединяй старые и новые файлы. -6. Вернись в Noita и обнови список модов. -7. Разреши **Unsafe Mods**. -8. Включи **Metamorph: Creative Menu** и начни игру с активными модами. - -Не включай standalone-сборку с GitHub и Steam Workshop-версию одновременно. - -# Standalone-сборка и Steam Workshop - -Сборка, распространяемая через этот GitHub-репозиторий, является полной standalone-сборкой. Она включает NoitaPatcher и функции, которым нужен неограниченный mod API, в том числе низкоуровневую работу с материалами и нативное восстановление после Game Over. - -[Steam Workshop-сборка](https://steamcommunity.com/sharedfiles/filedetails/?id=3785170245) устанавливается отдельно. В ней нет нативных компонентов, необходимых для функций, доступных только в standalone-сборке. - -Обе сборки используют один и тот же идентификатор мода, поэтому одновременная установка может привести к дублированию или конфликту файлов и не поддерживается. - -# О моде - -**Metamorph: Creative Menu (MCM)** — креативное меню и sandbox-набор для Noita. - -В одном интерфейсе собраны инструменты для: - -- заклинаний и инвентаря заклинаний; -- редактирования жезлов и переиспользуемых пресетов; -- предметов и ёмкостей с жидкостями; -- полного каталога материалов и рисования материалами; -- перков и поддерживаемого удаления перков; -- статусов и GameEffect-сущностей; -- существ, превращений и вселения; -- погоды и времени; -- глобальных правил мира; -- телепортации; -- опциональной интеграции с Entangled Worlds; -- восстановления после превращения, смерти формы и Game Over. - -MCM старается работать с настоящим состоянием Noita, а не заменять всё декоративными копиями. Существующие карточки заклинаний перемещаются как сущности, выдача предметов учитывает структуру инвентаря, изменения жезла проходят через commit/rollback, материалы остаются настоящими симулируемыми материалами, а обратимые правила мира сохраняют достаточно исходного состояния, чтобы поддерживаемые параметры можно было вернуть. - -Entangled Worlds не обязателен. Без него MCM остаётся полноценным одиночным модом. - -# Управление - -Стандартные клавиши: - -| Действие | По умолчанию | -| --- | --- | -| Открыть / закрыть креативное меню | **F4** | -| Вернуться в человеческую форму во время превращения | **TAB** | -| Вселиться в существо в мире | **G** | -| Рисовать выбранным материалом | **Средняя кнопка мыши** | - -Панель MCM также доступна через обычный интерфейс инвентаря Noita. - -Назначения можно менять в разделе **CONTROLS** и в настройках мода Noita. Поддерживаются клавиши, кнопки мыши и точные комбинации с **CTRL / SHIFT / ALT**. - -Во время назначения: - -- **DELETE / BACKSPACE** очищает привязку; -- **ESC** отменяет ввод; -- **R** возвращает стандартную клавишу для выбранного действия; -- **RESET ALL** после подтверждения восстанавливает все стандартные привязки. - -Одинаковые привязки можно оставить, но MCM показывает конфликт вместо тихой замены другого действия. - -Можно переназначить действия навигации меню, открытия разделов, возврата из формы, вселения, рисования материалами, очистки эффектов, освобождения погоды, сброса правил мира и поддерживаемые мультиплеерные действия. - -# Окно Creative Menu - -Отдельная панель MCM — это сохраняемое изменяемое окно, а не фиксированный debug-overlay. - -Его можно: - -- перемещать за заголовок; -- растягивать за края и углы; -- сворачивать; -- закрывать; -- возвращать к стандартной раскладке. - -Положение, ширина, высота и последний открытый раздел сохраняются между запусками. После изменения разрешения сохранённая геометрия подгоняется так, чтобы окно оставалось в видимой области GUI. - -Списки и каталоги используют измеряемые раскладки и scroll-контейнеры Noita. При изменении размеров сразу меняется объём видимого содержимого, а переведённые подписи могут переноситься на несколько строк без наложения на соседние элементы. На узком окне элементы переходят на новые строки вместо того, чтобы рисоваться поверх друг друга. - -Само открытие или наведение на отдельное окно не должно надолго отключать управление игроком. Когда клик, перетаскивание или активное текстовое поле могли бы одновременно сработать и в игре, MCM временно подавляет нужное управление игроком и затем восстанавливает его. - -# Поиск и локализация - -Поиск доступен в основных каталогах, включая заклинания, предметы, материалы, перки и существ. - -В зависимости от записи поиск умеет учитывать: - -- название на текущем языке интерфейса; -- английское название; -- ключ локализации; -- технический идентификатор; -- XML-путь. - -Поиск не зависит от регистра, нормализует распространённые диакритические знаки и разделители и допускает небольшие опечатки в длинных запросах. - -Собственный интерфейс MCM локализован на: - -- английский; -- русский; -- бразильский португальский; -- испанский; -- немецкий; -- французский; -- итальянский; -- польский; -- упрощённый китайский; -- японский; -- корейский. - -Для обычного контента Noita мод по возможности использует локализационные ключи самой игры вместо поддержки отдельного дубликата названий. - -# Заклинания - -Раздел заклинаний работает и с каталогом, и с уже существующими сущностями заклинаний игрока. - -Основная рабочая область показывает: - -- обычные слоты активного жезла; -- карточки **ALWAYS CAST**; -- инвентарь заклинаний игрока; -- поисковый каталог заклинаний. - -## Быстрая замена выбранного слота - -Короткий клик выбирает слот жезла. После этого короткий LMB по заклинанию из каталога заменяет выбранный слот. - -Это быстрый режим обычного редактирования. Для точного перемещения используется drag-and-drop. - -## Транзакционный drag-and-drop - -Существующие карточки можно перетаскивать: - -- между слотами жезла; -- из обычных слотов в **ALWAYS CAST**; -- из **ALWAYS CAST** обратно в обычные слоты; -- в конкретный слот инвентаря заклинаний; -- из инвентаря обратно на жезл; -- в игровой мир; -- в корзину, где это поддерживается. - -Для уже существующей карточки MCM по возможности перемещает реальную сущность. Поэтому изменённое состояние карты, оставшиеся использования и данные, добавленные другими модами, не теряются просто из-за смены позиции. - -Источник остаётся неизменным, пока транзакция назначения не подтверждена. Неверная или неизвестная цель отменяет операцию, а не удаляет исходную карточку. Один отпуск мыши выполняет максимум одну подтверждённую операцию. - -Карточки каталога — это шаблоны и никогда не расходуются при перетаскивании. - -## Always Cast - -У Always Cast есть отдельная полоса. Повышение, понижение и обмен учитывают эффективную ёмкость обычных слотов жезла, чтобы не получить некорректную структуру. - -## Отмена и повтор - -Для внутренних изменений жезла есть ограниченная история **UNDO / REDO**. - -Операции, которые передают реальную сущность во внешний мир или другой инвентарь, не всегда можно безопасно отменить по одному снимку жезла, поэтому такие внешние передачи не обещаются как универсально обратимые. - -# Жезлы - -Рабочая область жезла редактирует жезл, который игрок держит в руках. - -Поддерживаемые параметры: - -- ёмкость / количество слотов; -- заклинаний за выстрел; -- время перезарядки; -- задержка между выстрелами; -- разброс; -- множитель скорости снарядов; -- максимальная мана; -- скорость восстановления маны; -- восстановление отдачи; -- уровень жезла; -- shuffle; -- режим без перезарядки. - -MCM также редактирует внешний вид и связанные метаданные: - -- отображаемое имя; -- блокировки жезла и карточек; -- путь к спрайту; -- смещения спрайта; -- точку выстрела. - -Визуальный каталог внешнего вида по возможности следует данным из XML жезлов. - -## Пресеты жезлов - -Жезл можно сохранить как именованный постоянный пресет и использовать в следующих мирах и последующих запусках Noita. - -Пресет может сохранять: - -- характеристики жезла; -- значения маны; -- визуальные метаданные; -- обычные карточки заклинаний; -- Always Cast; -- позиции карточек по слотам; -- оставшиеся использования; -- замороженное состояние карточек. - -У каждого пресета две отдельные операции: - -- **APPLY** записывает сохранённый blueprint в жезл, который сейчас держит игрок; -- **GET COPY** создаёт новый жезл из того же blueprint. - -Копия помещается в свободный слот жезла в быстром инвентаре, если он есть. Если подходящего слота нет, полностью собранный жезл остаётся в мире рядом с игроком. - -Замена жезла и загрузка пресета используют commit/rollback. Если создание или размещение не удаётся завершить, MCM старается удалить неполное дерево сущностей, а не оставлять сломанный частичный жезл. - -# Предметы и жидкости - -## Предметы - -Короткий **LMB** по записи каталога создаёт один поддерживаемый предмет рядом с игроком. - -**RMB** пытается поместить предмет в подходящую область инвентаря. - -Запись каталога также можно перетащить: - -- на подходящую цель в быстром инвентаре; -- за пределы меню в точную позицию игрового мира. - -Если отпустить карточку внутри меню без корректной цели, операция отменяется. Сама карточка каталога — только шаблон и никуда не исчезает. - -MCM учитывает обычное разделение быстрого инвентаря Noita на слоты жезлов и предметов. При неудачной загрузке XML, заполнении жидкостью, передаче в инвентарь или опциональной мультиплеерной передаче новая сущность по возможности удаляется. - -Некоторые настоящие инвентарные предметы физически лежат в игровых каталогах, ориентированных на существ. MCM классифицирует известные случаи по поведению и не считает имя папки единственным источником истины о том, предмет это или существо. - -## Жидкости - -Записи жидкостей создают настоящие заполненные контейнеры Noita, а не декоративные объекты интерфейса. - -Такой контейнер можно носить, бросать, разбивать и разливать, а содержимое участвует в обычных реакциях материалов. - -# Материалы - -Раздел материалов — инструмент рисования миром на основе реального реестра материалов Noita. - -Каталог собирается из зарегистрированных движком жидкостей, песков / порошков, газов, огня, твёрдых и связанных статических или particle-effect материалов. Поэтому корректно зарегистрированные материалы других активных модов могут появляться автоматически. - -Поиск материалов и дорогая валидация распределяются на ограниченные порции работы вместо сканирования всего каталога в одном UI-кадре. - -## Отображение материалов - -Жидкости используют то же отображение заполненного контейнера, что и раздел предметов. - -Для остальных материалов MCM предпочитает авторские текстуры и tint-данные из `materials.xml`, включая унаследованные определения. Если авторской текстуры нет, fallback строится из реального цвета материала в движке, а не из случайного preview-цвета. - -## Рисование - -1. Выбери материал. -2. Выбери размер кисти. -3. Включи режим рисования. -4. Закрой инвентарь. -5. Удерживай назначенную кнопку рисования в игровом мире. - -Открытие инвентаря выключает активный режим рисования. - -Рисование не создаёт просто декоративные частицы. MCM помещает реальные клетки в мир подходящим для движка способом. Динамические материалы продолжают жить по правилам Noita: жидкости текут, порошки осыпаются, газы двигаются, огонь реагирует, а нестабильные вещества могут превращаться через реакции материалов. - -Разным классам материалов нужны разные методы размещения. Standalone-сборка может использовать прямой доступ NoitaPatcher к сетке мира и небольшой PixelScene-fallback для авторских случаев, когда Noita не позволяет напрямую создать клетку в конкретной координате текстуры. - -Очереди ограничены, чтобы большая кисть не выполняла намеренно неограниченный объём работы за один кадр. - -# Перки - -## Создание и получение перков - -**LMB** создаёт обычный pickup выбранного перка в мире. - -Действие получения умеет выдавать выбранный перк единично или пачкой. Массовые операции обрабатываются как bounded jobs, а не применяют все копии в одном UI-кадре. - -Интерфейс показывает прогресс, а ещё не выполненную работу можно отменить. Копии, уже подтверждённые до отмены, остаются на игроке. - -Каждая выданная копия всё равно проходит через обычный путь применения перка вместо прямой подделки конечного состояния. - -## Удаление перков - -Удалить перк значительно сложнее, чем выдать его. Перки могут менять globals, компоненты, сущности, характеристики игрока и долгоживущие игровые механики, а универсальной обратной операции в Noita нет. - -Поэтому MCM удаляет только состояние, для которого есть достаточно безопасная отслеживаемая обратная операция. Журнал транзакции старается убрать только состояние, принадлежащее конкретному применению перка, не сбрасывая постороннее состояние игрока. - -Если очистка выполнена частично или её нельзя доказуемо завершить, она остаётся отмеченной как незавершённая, а не молча объявляется успешной. - -Сторонний перк может выдаваться, но это не гарантирует, что его можно корректно удалить. - -# Эффекты - -Раздел эффектов применяет и снимает поддерживаемые material-status и GameEffect-сущности. - -Удаление по возможности учитывает владельца состояния. MCM не пытается без разбора уничтожать похожие скрытые эффекты, которые принадлежат перкам, игре или другой системе. - -Постоянные эффекты, созданные MCM, используют ограниченную очистку / expiry-обработку, чтобы удаление одного MCM-эффекта не сбрасывало постороннее состояние. - -# Существа - -Каталог существ сохраняет точные XML-пути и не объединяет все сущности с похожими именами. - -Поддерживаемые действия: - -- **LMB** — создать выбранную авторскую сущность рядом с игроком; -- перетянуть за пределы меню — создать её в подтверждённой позиции мирового курсора; -- **RMB** — превратить текущего игрока в поддерживаемую форму; -- специальная запись **PLAYER** — создать или восстановить player-состояние, как описано ниже. - -Если вернуть перетаскиваемую карточку над меню, создание в мире отменяется. - -Правила совместимости для опасных или необычных форм задаются точными путями. Имя файла, в котором просто встречается знакомое слово, не делает сущность автоматически эквивалентной другой форме. - -# Превращения и возврат в человека - -Игровые формы по возможности сохраняют полезное нативное движение, атаки, внешний вид и физику. Компоненты, которые напрямую конкурируют с вводом игрока, могут временно отключаться или адаптироваться, пока форма управляется игроком. - -Некоторым сложным существам нужна дополнительная совместимость. Боссы, скриптовые оболочки и тяжёлые физические сущности не обязаны вести себя абсолютно так же, как их оригинальные AI-версии, когда ими управляет игрок. - -Назначенное действие возврата — **TAB по умолчанию** — сначала использует обычный путь завершения превращения. Если этого недостаточно, standalone-сборка использует дополнительные пути восстановления через NoitaPatcher. - -В поддерживаемых случаях смертельного урона MCM пытается: - -- оставить умершую временную форму или труп в мире, когда это уместно; -- восстановить человеческую сущность игрока; -- вернуть authority и управление; -- сохранить инвентарь; -- восстановить релевантное состояние игрока. - -Это механизм восстановления, а не абсолютное бессмертие. Сторонний kill-script, несовместимое состояние движка или падение процесса могут обойти поддерживаемую передачу. - -# Вселение - -Вселение позволяет управлять существом, уже существующим в мире, вместо выбора формы из каталога. - -Клавиша по умолчанию — **G**. - -Наведи курсор на подходящее существо и используй действие вселения. MCM проверяет цель, готовит совместимый переход формы и удаляет или переводит исходную мировую сущность в неактивное состояние только после подтверждения нового управляемого состояния. - -Если переход не удался, исходное существо не должно просто исчезнуть. - -Вселение не ограничено встроенным каталогом MCM. Совместимое существо из другого мода тоже может работать, но универсальная поддержка всех сторонних сущностей не гарантируется. - -# Запись Player - -**PLAYER** — специальная запись каталога существ, а не обычная polymorph-форма. - -Действие создания создаёт отдельного player-подобного персонажа и пытается скопировать подходящие визуальные данные и максимальное здоровье игрока. - -Использование действия превращения на **PLAYER** не превращает уже человеческого игрока в дубликат. Если игрок находится в другой форме, действие используется как возврат в человека. - -# Восстановление после Game Over в одиночной игре - -Standalone-сборка для одиночной игры добавляет дополнительный путь восстановления в стандартный экран Game Over Noita. - -Когда нативная интеграция может безопасно найти необходимые структуры игры, MCM добавляет в интерфейс Game Over действие **«I didn't die»**. - -Во время игры MCM поддерживает актуальную резервную копию player-состояния. Нажатие восстановления отправляет запрос через обычный update-путь MCM, а не пытается перестроить всего игрока прямо из обработчика кнопки интерфейса. - -Поддерживаемое восстановление пытается: - -- восстановить или получить живую сущность игрока; -- снова сделать её authoritative; -- убрать Game Over-состояние движка; -- вернуть управление и пригодное состояние игрока; -- по возможности очистить Game Over audio, music и UI-состояние; -- дать короткое окно защиты после восстановления. - -Нативный helper работает fail-closed. Он сканирует запущенный поддерживаемый executable Noita в поиске известных структур вместо записи по одному навсегда зашитому адресу. Если после обновления игры нужные структуры нельзя безопасно определить, опциональное восстановление не используется вместо записи в неизвестное место. - -# Погода и время - -MCM умеет управлять поддерживаемым состоянием погоды и времени, включая пресеты и отдельные параметры, доступные в текущей реализации. - -Принудительное состояние затем можно освободить обратно под управление игры. Например, после фиксации времени суток MCM может перестать владеть параметром, и естественное течение времени Noita продолжится. - -Изменения погоды рассматриваются как управляемое состояние, а не как одноразовые консольные команды. - -# Правила мира - -Раздел **RULES** изменяет поддерживаемое глобальное поведение игры. - -Правила затрагивают, например: - -- отношения существ; -- поведение золота; -- использование заклинаний; -- fog of war; -- отдельные награды за убийства; -- выпадение лечения; -- поведение крови; -- гравитацию; -- физику; -- силу пинка; -- physics joints; -- цикл дня и ночи; -- другие поддерживаемые глобальные параметры. - -Главная цель — обратимость. - -Для поддерживаемых правил MCM сохраняет или вычисляет исходное состояние, чтобы затем вернуть настройку. Множители применяются относительно исходного значения, а не повторно умножают уже изменённый результат. - -Правила, которым нужно затронуть много сущностей или физических объектов, используют ограниченную по кадрам работу вместо попытки синхронно переписать весь мир одним кликом. - -# Телепортация - -Раздел телепортации содержит подготовленные точки по миру, включая места на основном маршруте, Holy Mountains, крупные боковые области и другие поддерживаемые ориентиры. - -Перед перемещением MCM может запросить загрузку нужной области и ищет ближайшее пригодное свободное место вместо намеренного помещения игрока прямо внутрь твёрдой стены. - -Телепортация всё равно зависит от способности мира загрузить и предоставить корректную точку. Сильно изменённые миры могут потребовать fallback-поведения. - -# Entangled Worlds - -**Entangled Worlds / Noita Proxy опционален.** MCM работает без него. - -Если EW присутствует, MCM включает дополнительное multiplayer-aware поведение. Для MCM-специфичного синхронизируемого состояния всем участникам рекомендуется использовать совместимые сборки MCM. - -## Authority и формы - -Формам игрока нужна особая обработка владения, потому что превращённый игрок не должен случайно оставлять вторую сетевую authority. - -MCM координирует с EW владение формой, retirement и возврат к человеку там, где это поддерживается. Для boss/Kolmi-подобных сущностей есть дополнительный lifecycle-код, предназначенный для предотвращения дублированных authority и старых сетевых копий. - -Обычный death-путь EW остаётся главным для сущностей, которые не распознаны как MCM-owned form state. - -## Предметы, жезлы и заклинания - -Где возможно, MCM использует штатные механизмы предметов / инвентаря EW вместо отдельной параллельной транспортной системы. - -После подтверждённых изменений жезлов и инвентаря заклинаний запрашивается соответствующее мультиплеерное обновление, если интеграция доступна. Предметы, созданные MCM в мире, могут передаваться через стандартный EW world-item путь. - -## Перки - -Обычные pickup-перки могут использовать штатную синхронизацию world items EW. Собственная обработка perk-state в MCM координирует refresh и bounded operations так, чтобы массовое действие не пыталось выполнять дорогое глобальное обновление после каждой отдельной копии. - -## Материалы - -Для рисования материалами нужен отдельный путь совместимости, потому что изменения клеток мира не являются обычными item entities. - -MCM держит paint-work ограниченным, разделяет работу по границам chunk и выполняет необходимые EW world-frame / persistence шаги перед выпуском синхронизируемой conversion-работы. Незагруженный streaming-edge chunk откладывается, а не блокирует весь текущий штрих. - -Цель — чтобы ближайшие EW-участники видели поддерживаемое нарисованное состояние мира без удалённого повторения обычного UI-действия MCM в виде filename-only PixelScene RPC. - -При этом сохраняются ограничения EW по material id: принимающая игра не может корректно создать материал, которого у неё нет или чей реестр материалов движка несовместим. - -## Погода, вселение и состояние мира - -Поддерживаемое мультиплеерное состояние MCM также включает координацию погоды, вселения и отдельных правил мира / lifecycle-состояния. Проверки authority нужны, чтобы два участника не пытались одновременно стать владельцами одного состояния. - -Поддержка EW намеренно консервативна. Если интеграция не может доказать безопасный путь синхронизации, MCM предпочитает корректное локальное поведение вместо притворной поддержки любой одиночной операции в мультиплеере. - -# Совместимость и ограничения - -Noita предоставляет многие системы через слабо связанные сущности, XML, Lua-компоненты и нативное поведение движка. Поэтому MCM не может гарантировать универсальную совместимость со всеми модифицированными сущностями или будущими обновлениями игры. - -Важные ограничения: - -- возможность создать существо не означает, что оно безопасно как player-form; -- возможность выдать перк не означает наличие надёжной обратной операции; -- внешнюю передачу заклинания или предмета не всегда можно отменить по внутреннему снимку; -- сторонние скрипты могут обходить поддерживаемые пути смерти и восстановления; -- нативное восстановление зависит от поддерживаемого поведения executable Noita и отключается fail-closed, если нужные структуры нельзя определить; -- Entangled Worlds не может синхронизировать материал, отсутствующий в реестре принимающей игры; -- сильно изменённые инвентари, сущности или правила мира могут потребовать отдельной совместимости с конкретным модом. - -MCM старается сохранять исходное состояние и откатывать неудачные изменения, но sandbox-инструмент, меняющий живое состояние игры, не способен сделать каждую комбинацию сторонних модов полностью транзакционной. - -# Сохраняемые данные - -MCM сохраняет пользовательское состояние, которое должно переживать перезапуски: поддерживаемые настройки, клавиши, раскладку меню и пресеты жезлов. - -Идентификатор мода остаётся стабильным, поэтому нормальное обновление сохраняет поддерживаемые данные. При установке новой standalone-сборки всё равно рекомендуется заменять папку мода целиком, потому что слияние старых и новых файлов может оставить устаревшие runtime-файлы. - -# Решение проблем - -## Мод не появляется - -Проверь, что структура заканчивается так: - -`mods/metamorph_creative_menu/mod.xml` - -Лишняя папка архива над `metamorph_creative_menu` мешает Noita корректно увидеть мод. - -## Не работают нативные функции или материалы - -Убедись, что разрешены **Unsafe Mods** и установлена standalone-сборка с GitHub, а не смесь GitHub- и Workshop-файлов. - -## Меню открывается, но одновременно срабатывает игровое действие - -Проверь пользовательские привязки на конфликты. MCM показывает дубликаты, но намеренно позволяет оставить их, если это нужно. - -## В существо нельзя безопасно превратиться - -Не каждая создаваемая XML-сущность является поддерживаемой player-form. Для существ со специальными требованиями используются правила по точному пути. - -## Перк нельзя удалить - -Удаление доступно только там, где у MCM есть поддерживаемая обратная операция для отслеживаемого состояния. Это намеренное ограничение: угадывание очистки может повредить несвязанное состояние игрока. - -## В мультиплеере поведение отличается у участников - -Используй совместимые сборки MCM на всех участниках и совместимую среду Noita / Entangled Worlds. MCM не может исправить несовместимый material registry или произвольные сетевые изменения других модов. - -# Сообщение об ошибках - -Полезный баг-репорт содержит: - -- что именно ты пытался сделать; -- точный раздел и действие MCM; -- воспроизводится ли проблема в одиночной игре, Entangled Worlds или в обоих режимах; -- установлена ли standalone- или Workshop-сборка; -- активны ли другие gameplay-моды; -- надёжные шаги воспроизведения; -- релевантные логи Noita / EW, если они доступны. - -Для проблем с превращением, вселением, предметом или материалом по возможности укажи точную сущность или материал. Технический идентификатор часто полезнее переведённого отображаемого имени. - -# Репозиторий и исходники разработки - -Репозиторий намеренно содержит **полное дерево разработки**, а не тот же урезанный архив, который скачивают игроки. - -`metamorph_creative_menu/` содержит runtime-код вместе с: - -- автоматическими тестами; -- QA-инструментами; -- диагностикой; -- нативными исходниками; -- build-инструментами; -- правилами очистки релиза; -- документацией для разработки. - -Эти файлы нужны для разработки и регрессионного тестирования, поэтому остаются в GitHub source. Готовый player ZIP создаётся отдельно и исключает development-only содержимое. - -Player-сборка также получает release-only очистку, включая минимальный player `README.txt`, тогда как source-tree сохраняет нормальную документацию разработки. - -# Тесты - -Автоматический набор тестов находится в `metamorph_creative_menu/tests/` и сочетает Python contract-checks с Lua mock-тестами. - -Из корня репозитория release workflow прогоняет тесты по полному импортированному source перед публикацией player build. Для Lua mock-части требуется `texlua`. - -Source hygiene также защищает production-файлы и документацию от мусора истории разработки, устаревших debug-интерфейсов и случайных process-artifacts. - -# Импорт исходного архива и выпуск сборки - -Полный development source можно импортировать из архива семейства `Metamorph-Creative-Menu-v...zip`. - -Import workflow проверяет структуру архива, требует полный набор компонентов разработки, запускает source hygiene и регрессионные тесты до коммита импортированного дерева. - -ModWorkshop / player-style архив не считается development source. - -Публичный `latest-build` затем создаётся из полного source отдельным player-builder. Он удаляет QA, тесты, диагностику, native source и прочий development-only payload, применяет release cleanup, валидирует итоговый архив и только после этого обновляет стабильный download asset. - -Такое разделение позволяет репозиторию оставаться полноценной базой разработки, а обычной загрузке для игрока — быть компактной и без development-инструментации. - -# Сторонние компоненты - -Сторонние компоненты, bundled dependencies и исходные проекты перечислены в [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). diff --git a/README.zh-CN.md b/README.zh-CN.md deleted file mode 100644 index 408b55d..0000000 --- a/README.zh-CN.md +++ /dev/null @@ -1,586 +0,0 @@ - - -[English](README.md) · [Русский](README.ru.md) · [Português (Brasil)](README.pt-BR.md) · [Español](README.es.md) · [Deutsch](README.de.md) · [Français](README.fr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [**简体中文**](README.zh-CN.md) · [日本語](README.ja.md) · [한국어](README.ko.md) - -

Metamorph: Creative Menu

- -

面向 Noita 的创造模式菜单与沙盒工具集:法术、法杖、物品、材料、天赋、效果、生物、变形、附身、传送、天气、世界规则、多人联机集成以及恢复工具。

- -

作者与维护者:zerodancing

- ---- - -# 下载 - -正常游玩请使用可直接安装的构建: - -[**⬇️ 下载最新构建**](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/download/latest-build/Metamorph-Creative-Menu.zip) - -[最新构建页面](https://github.com/zerodancing/Metamorph-Creative-Menu/releases/tag/latest-build) · [更新日志](metamorph_creative_menu/CHANGELOG.txt) - -GitHub release 会从完整开发源码树自动构建。测试、QA 工具、诊断代码、原生源码和构建工具会保留在仓库中,但不会进入玩家下载包。 - -GitHub standalone 构建包含 NoitaPatcher 和原生恢复支持,因此必须允许 **Unsafe Mods**。 - -# 安装 - -1. 通过上方链接下载 `Metamorph-Creative-Menu.zip`。 -2. 启动 Noita,在主菜单中打开 **Mods**。 -3. 点击 **Open mods folder**。 -4. 将 `metamorph_creative_menu` 文件夹解压或移动到 `mods` 目录。最终路径应直接包含 `metamorph_creative_menu/mod.xml`,不能多套一层压缩包目录。 -5. 如果已经安装旧版本,请直接替换整个 `metamorph_creative_menu` 文件夹,不要把新旧文件合并。 -6. 回到 Noita 并刷新模组列表。 -7. 允许 **Unsafe Mods**。 -8. 启用 **Metamorph: Creative Menu**,然后使用已启用模组开始游戏。 - -不要同时启用 GitHub standalone 构建和 Steam Workshop 构建。 - -# Standalone 构建与 Steam Workshop - -本 GitHub 仓库提供的是完整 standalone 构建。它包含 NoitaPatcher,以及需要不受限 mod API 访问的功能,包括低层材料操作和原生 Game Over 恢复。 - -[Steam Workshop 构建](https://steamcommunity.com/sharedfiles/filedetails/?id=3785170245)独立安装,不包含 standalone 专属功能所需的原生组件。 - -两种构建使用相同的模组身份。同时安装可能造成文件重复或冲突,因此不受支持。 - -# 模组简介 - -**Metamorph: Creative Menu (MCM)** 是 Noita 的创造模式菜单和沙盒工具集。 - -它把以下工具集中到同一套界面中: - -- 法术和法术背包; -- 法杖编辑与可重复使用的法杖预设; -- 物品和液体容器; -- 完整材料目录与材料绘制; -- 天赋以及受支持的天赋移除; -- 状态效果与 GameEffect 实体; -- 生物、变形与附身; -- 天气与时间; -- 全局世界规则; -- 传送; -- 可选的 Entangled Worlds 集成; -- 变形、形态死亡和 Game Over 后的恢复路径。 - -MCM 尽量直接操作 Noita 的真实状态,而不是用装饰性的复制品代替一切。已有法术卡会尽可能作为真实实体移动;物品投递会尊重背包结构;法杖修改使用 commit/rollback 路径;材料保持为真实参与模拟的材料;可逆世界规则会保留足够的原始状态,以便之后恢复受支持的设置。 - -Entangled Worlds 是可选的。没有它时,MCM 仍然是完整的单人模组。 - -# 操作 - -默认按键: - -| 操作 | 默认输入 | -| --- | --- | -| 打开 / 关闭创造菜单 | **F4** | -| 变形期间返回人类形态 | **TAB** | -| 附身到世界中的生物 | **G** | -| 用选中的材料绘制 | **鼠标中键** | - -创造面板也可以通过 Noita 的普通背包界面访问。 - -按键可在 MCM 的 **CONTROLS** 页面和 Noita 模组设置中修改。支持键盘按键、鼠标按键,以及精确的 **CTRL / SHIFT / ALT** 组合。 - -捕获按键时: - -- **DELETE / BACKSPACE** 清除绑定; -- **ESC** 取消; -- **R** 恢复该操作的默认绑定; -- **RESET ALL** 在确认后恢复全部默认绑定。 - -重复绑定仍然可以保留,但 MCM 会显示冲突,而不是静默覆盖另一个操作。 - -菜单导航、各页面、返回人类形态、附身、材料绘制、效果清理、释放天气控制、重置世界规则以及受支持的多人操作都可以重新绑定。 - -# Creative Menu 窗口 - -独立创造面板是可持久保存、可缩放的窗口,不是固定 debug overlay。 - -窗口可以: - -- 通过标题栏拖动; -- 从边缘和角落缩放; -- 最小化; -- 关闭; -- 恢复默认布局。 - -位置、宽度、高度和上次打开的页面会在多次启动之间保存。分辨率改变后,保存的窗口几何会重新限制到可见 GUI 范围内。 - -列表和目录使用经过测量的布局和 Noita 滚动容器。调整窗口大小会立刻改变可见内容数量,翻译后的长文本可以正常换行而不覆盖相邻控件。在狭窄布局中,控件会换到新行,而不是互相叠在一起。 - -单纯打开或悬停在独立窗口上不会永久禁用游戏控制。当点击、拖动或正在编辑的文本框可能同时触发玩家操作时,MCM 会暂时抑制相关玩家输入,并在之后恢复。 - -# 搜索与本地化 - -主要目录都支持搜索,包括法术、物品、材料、天赋和生物。 - -根据条目类型,搜索可以匹配: - -- 当前界面语言名称; -- 英文名称; -- 本地化 key; -- 技术标识符; -- XML 路径。 - -搜索不区分大小写,会规范化常见重音与分隔符,并对较长查询中的小型拼写错误具有一定容错。 - -MCM 自有界面支持: - -- 英语; -- 俄语; -- 巴西葡萄牙语; -- 西班牙语; -- 德语; -- 法语; -- 意大利语; -- 波兰语; -- 简体中文; -- 日语; -- 韩语。 - -对于普通 Noita 内容,模组会尽量复用游戏本身的本地化 key,而不是维护重复名称。 - -# 法术 - -法术页面既能操作目录,也能操作玩家已经拥有的真实法术实体。 - -主工作区包含: - -- 当前法杖的普通槽位; -- **ALWAYS CAST** 卡; -- 玩家法术背包; -- 可搜索的法术目录。 - -## 快速替换选中槽位 - -短按一个法杖槽位会选中它。随后短按目录中的法术即可替换选中的槽位。 - -这是普通编辑的快速方式。精确移动使用 drag-and-drop。 - -## 事务式 drag-and-drop - -已有法术卡可以拖动: - -- 在法杖槽位之间移动; -- 从普通槽位移入 **ALWAYS CAST**; -- 从 **ALWAYS CAST** 移回普通槽位; -- 放入法术背包的指定槽位; -- 从背包放回法杖; -- 丢到游戏世界; -- 在受支持时放入垃圾桶。 - -对于已有卡牌,MCM 会尽量移动真实实体。因此可变状态、剩余使用次数以及其他模组添加的数据不会仅因为换位置就被丢失。 - -在目标事务确认之前,来源保持不变。无效或未知的目标会取消操作,而不是删除原卡。一次鼠标释放最多提交一项操作。 - -目录卡片只是模板,拖动不会消耗它们。 - -## Always Cast - -Always Cast 有独立区域。把卡牌提升、移回或交换时,会考虑普通槽位的有效容量,以避免形成无效法杖结构。 - -## 撤销与重做 - -法杖内部修改拥有有限的 **UNDO / REDO** 历史。 - -如果操作把真实实体交给外部世界或其他背包,仅依靠法杖 snapshot 并不总能安全回收它,因此这些外部 handoff 不承诺永远可撤销。 - -# 法杖 - -法杖工作区编辑玩家当前手持的法杖。 - -支持的属性包括: - -- 容量 / 槽位; -- 每次施放的法术数; -- 充能时间; -- 施法延迟; -- 散射; -- 投射物速度倍率; -- 最大法力; -- 法力回复速度; -- 后坐恢复; -- 法杖等级; -- shuffle; -- 无需充能行为。 - -MCM 也可以编辑外观与相关元数据: - -- 显示名称; -- 法杖和卡牌锁定; -- sprite 路径; -- sprite 偏移; -- 发射位置。 - -有数据可用时,视觉外观目录会遵循法杖 XML 中定义的内容。 - -## 法杖预设 - -法杖可以保存为持久命名预设,并在之后的世界或 Noita 会话中再次使用。 - -预设可保存: - -- 法杖属性; -- 法力值; -- 视觉元数据; -- 普通法术卡; -- Always Cast 卡; -- 槽位位置; -- 剩余使用次数; -- 卡牌冻结状态。 - -每个预设有两个独立操作: - -- **APPLY** 把保存的 blueprint 应用到当前手持法杖; -- **GET COPY** 根据同一 blueprint 创建一根新法杖。 - -如果快速背包有空闲法杖槽位,新法杖会放入那里。若没有合适槽位,完整构建的法杖会留在玩家附近的世界中。 - -法杖替换和预设加载使用 commit/rollback 路径。如果构建或放置无法完成,MCM 会尝试移除不完整实体树,而不是留下损坏的半成品法杖。 - -# 物品与液体 - -## 物品 - -短按目录条目的 **LMB** 会在玩家附近创建一个受支持的物品。 - -**RMB** 会尝试把物品交给合适的背包区域。 - -目录条目也可以拖动: - -- 放到匹配的快速背包目标; -- 拖出菜单到世界中的精确位置。 - -如果在菜单内部没有有效目标时松开卡片,操作会取消。目录卡片只是模板,会继续保留。 - -MCM 尊重 Noita 快速背包中法杖槽位和物品槽位的正常区分。如果 XML 加载、液体填充、背包投递或可选多人 handoff 失败,新创建的实体会在可能时被清除。 - -一些真正的背包物品存放在游戏中偏向生物的目录下。MCM 会根据已知行为分类,而不是仅凭目录名判断它究竟是物品还是生物。 - -## 液体 - -液体条目会创建真实、已填充的 Noita 容器,而不是装饰性 UI 对象。 - -容器可以携带、丢弃、打碎并倒出内容,其内容会参与正常的材料反应。 - -# 材料 - -Materials 页面是基于 Noita 真实材料注册表的世界绘制工具。 - -目录来自 engine 注册的液体、沙 / 粉末、气体、火焰、固体以及相关静态或效果材料。其他已启用模组正确注册的材料也可以自动出现。 - -材料发现和高开销验证会拆分为受限工作,而不是在一个 UI 帧内扫描整个目录。 - -## 材料显示 - -液体使用与物品页相同的已填充容器显示方式。 - -对于非液体材料,MCM 优先使用 `materials.xml` 中定义的纹理和 tint,包括继承定义。如果没有定义纹理,则 fallback 来自 engine 中材料的真实颜色,而不是任意预览色。 - -## 绘制 - -1. 选择材料。 -2. 选择画笔大小。 -3. 开启绘制模式。 -4. 关闭背包。 -5. 在世界中按住已配置的绘制输入。 - -重新打开背包会停止当前绘制模式。 - -绘制并不是简单发射装饰粒子。MCM 会通过适合 engine 的路径把真实 cell 写入世界。动态材料继续遵循 Noita 模拟:液体流动、粉末下落、气体移动、火焰反应,不稳定物质也能通过材料反应继续变化。 - -不同材料类别需要不同放置策略。Standalone 构建可以使用 NoitaPatcher 的直接世界网格访问,并在 Noita 无法直接在特定纹理坐标构造已定义材料时使用小型 PixelScene fallback。 - -工作队列有上限,因此长时间使用大画笔不会故意在单帧内执行无限工作。 - -# 天赋 - -## 创建与获取天赋 - -**LMB** 会在世界中创建选中天赋的普通 pickup。 - -获取操作可以单次或批量授予天赋。批量操作通过有上限的 job 分帧处理,而不是在一个 UI 帧中应用所有副本。 - -界面会显示进度,尚未执行的工作可以取消。取消前已经提交的副本会继续保留。 - -每一份授予仍会走正常的天赋应用路径,而不是直接伪造最终状态。 - -## 移除天赋 - -移除天赋远比授予复杂。天赋可能修改 globals、组件、实体、玩家属性和长期机制,而 Noita 并不存在一个通用逆操作。 - -因此 MCM 只移除那些拥有足够安全、可追踪逆操作的状态。事务 journal 会尝试只删除属于该次天赋应用的状态,而不重置无关玩家状态。 - -如果清理只完成了一部分,或者无法证明已经完全清理,它会继续被视为未完成,而不是静默报告成功。 - -第三方天赋可能可以授予,但不一定能被正确移除。 - -# 效果 - -Effects 页面可以应用和移除受支持的材料状态以及 GameEffect 实体。 - -移除时会在可能的情况下考虑状态所有权。MCM 不会无差别删除看似相似但属于天赋、游戏本身或其他系统的隐藏效果。 - -由 MCM 创建的持久效果使用受限清理 / 到期处理,以避免删除一个 MCM 效果时重置无关状态。 - -# 生物 - -生物目录保留精确 XML 路径,不会把所有名字相似的实体合并为同一个条目。 - -支持的操作: - -- **LMB** — 在玩家附近创建选中的定义实体; -- 拖出菜单 — 在确认的世界光标位置创建; -- **RMB** — 把当前玩家变形成受支持形态; -- 特殊 **PLAYER** 条目 — 按下文说明创建或恢复玩家相关状态。 - -把拖动中的生物卡放回菜单上会取消世界 spawn。 - -危险或特殊形态的兼容规则基于精确路径。文件名里只是包含一个熟悉词语,并不会让该实体自动被当成另一种等价形态。 - -# 变形与返回人类形态 - -可玩形态会尽量保留有用的原生移动、攻击、外观和物理行为。与玩家输入直接冲突的组件,在玩家控制该形态期间可能会被禁用或适配。 - -部分复杂生物需要额外兼容逻辑。Boss、脚本 wrapper 和强依赖物理的实体在作为玩家形态时,不保证与其 AI 控制的原始版本完全一致。 - -已配置的返回操作——默认 **TAB**——会先尝试正常结束变形。如果不够,standalone 构建还提供 NoitaPatcher 支持的额外恢复路径。 - -在受支持的致死伤害情况下,MCM 会尝试: - -- 在合适时让死亡的临时形态或尸体留在世界中; -- 恢复人类玩家实体; -- 归还 authority 和控制权; -- 保留背包; -- 恢复相关玩家状态。 - -这是恢复逻辑,不是绝对不死。第三方 kill script、不兼容的 engine 状态或进程崩溃可能绕过受支持的 handoff。 - -# 附身 - -附身会控制已经存在于世界中的生物,而不是从目录里选择一种形态。 - -默认按键是 **G**。 - -指向合适的生物并使用附身操作。MCM 会检查目标、准备兼容的形态切换,并且只在新的玩家控制状态确认成功后,才移除或 retirement 原始世界实体。 - -如果切换失败,原始生物不应直接消失。 - -附身不限于 MCM 内置目录。其他模组创建的兼容生物也可能工作,但不保证兼容所有第三方实体。 - -# Player 条目 - -**PLAYER** 是生物目录中的特殊条目,不是普通 polymorph 目标。 - -它的 spawn 操作会创建独立的类玩家角色,并尝试复制适合的玩家外观和最大生命信息。 - -对 **PLAYER** 使用变形操作不会把已经是人类的玩家变成复制体。如果玩家当前处于其他形态,该操作会作为返回人类形态使用。 - -# 单人 Game Over 恢复 - -Standalone 单人构建为 Noita 原生 Game Over 画面提供额外恢复路径。 - -当原生集成能够安全识别所需的游戏结构时,MCM 会在 Game Over 界面加入 **“I didn't die”** 操作。 - -游戏运行期间,MCM 会持续维护玩家状态备份。触发恢复后,会通过 MCM 的正常更新路径请求恢复,而不是直接在 UI 点击 handler 中重建完整玩家。 - -受支持的恢复会尝试: - -- 恢复或取得存活玩家实体; -- 重新让它成为 authoritative; -- 清除 engine 的 Game Over 状态; -- 恢复控制和可用的玩家状态; -- 尽力清理 Game Over 音频、音乐和 UI 状态; -- 在恢复后提供短暂保护窗口。 - -原生 helper 采用 fail-closed 设计。它会扫描正在运行、受支持的 Noita executable 中的已知结构,而不是永久写死一个地址。如果游戏更新后无法安全识别预期结构,可选恢复功能就不会启用,而不是向不确定地址写入。 - -# 天气与时间 - -MCM 可以控制受支持的天气和时间状态,包括预设以及当前实现暴露的单独参数。 - -强制状态之后可以释放回游戏的正常控制。例如,强制某个时间点后,MCM 可以放弃对该设置的控制,让 Noita 自然时间再次运行。 - -天气修改被当作有状态控制,而不是一次性、不可逆的控制台命令。 - -# 世界规则 - -**RULES** 页面修改受支持的全局游戏行为。 - -规则覆盖例如: - -- 生物关系; -- 金钱行为; -- 法术使用; -- fog of war; -- 某些击杀奖励; -- 治疗掉落; -- 与血液相关的行为; -- 重力; -- 物理行为; -- 踢击力量; -- 物理连接; -- 昼夜循环; -- 其他受支持的全局参数。 - -核心目标是可逆性。 - -对于受支持的规则,MCM 会记录或推导原始状态,以便之后恢复。倍率控制相对于原始值计算,而不是不断对已经修改过的结果继续乘算。 - -需要处理大量实体或物理对象的规则会使用分帧、受限工作,而不是一次菜单点击就同步重写整个世界。 - -# 传送 - -传送页面提供预设世界目标,包括主路线、Holy Mountains、主要支线区域以及其他受支持地点。 - -移动玩家前,MCM 可以请求加载目标区域,并寻找附近可用的空闲空间,而不是故意把玩家直接放进固体地形。 - -传送仍依赖世界本身能够加载并提供有效目标。高度修改的世界可能需要 fallback 行为。 - -# Entangled Worlds - -**Entangled Worlds / Noita Proxy 是可选的。** 没有 EW 时 MCM 也能工作。 - -当 EW 存在时,MCM 会启用额外的多人感知行为。依赖 MCM 特定同步状态时,所有 peer 都应使用兼容的 MCM 构建。 - -## Authority 与形态 - -玩家形态需要特殊 ownership 处理,因为变形后的玩家不应该意外留下第二个网络 authority。 - -在受支持的情况下,MCM 会与 EW 协调形态 ownership、retirement 以及返回人类形态。Boss 和 Kolmi 类实体还有额外 lifecycle 处理,用于避免重复 authority 和陈旧的网络控制副本。 - -对于未识别为 MCM-owned 形态状态的实体,EW 的普通死亡路径仍然负责处理。 - -## 物品、法杖与法术 - -只要可行,MCM 会使用 EW 正常的 item / inventory 机制,而不是发明平行的传输系统。 - -已提交的法杖和法术背包修改会在集成可用时请求对应的多人 refresh。MCM 创建的世界物品可以交给 EW 标准 world-item 路径。 - -## 天赋 - -普通天赋 pickup 可以使用 EW 的标准 world-item 同步。MCM 自己的天赋状态处理会协调 refresh 和受限操作,避免批量行为为每个副本都触发一次昂贵的全局刷新。 - -## 材料 - -材料绘制有专门兼容路径,因为世界 cell 修改不是普通 item entity。 - -MCM 会限制绘制工作,在 chunk 边界拆分任务,并在释放同步转换工作前协调 EW 需要的 world-frame / persistence 步骤。尚未加载的 streaming 边缘 chunk 会被延后,而不是阻塞整条当前笔画。 - -目标是让附近 EW peer 能看到受支持的绘制世界状态,而不需要把普通 MCM UI 操作远程重放成只基于 filename 的 PixelScene 调用。 - -这仍然继承 EW 对 material id 的假设:接收端游戏无法正确创建自己不存在的材料,也无法处理不兼容的 engine 材料注册表。 - -## 天气、附身与世界状态 - -受支持的 MCM 多人状态还包括天气、附身以及部分世界规则 / lifecycle 行为的协调。Authority 检查可以避免两个 peer 同时试图拥有同一状态。 - -EW 支持刻意保持保守。如果集成无法证明安全同步路径,MCM 宁可保留正确的本地行为,也不会假装每个单人操作都自动适用于多人。 - -# 兼容性与限制 - -Noita 通过松散耦合的实体、XML、Lua 组件和原生 engine 行为提供许多系统。因此 MCM 无法保证与所有修改实体或未来所有游戏更新完全兼容。 - -重要限制包括: - -- 能 spawn 的生物不代表它一定是安全的玩家形态; -- 能授予的天赋不代表一定有可靠逆操作; -- 外部法术或物品 handoff 不一定能从内部 snapshot 撤销; -- 第三方脚本可能绕过受支持的死亡与恢复路径; -- 原生恢复依赖受支持的 Noita executable 行为,如果无法识别所需结构则会 fail-closed; -- Entangled Worlds 无法同步接收端注册表中不存在的材料; -- 高度修改的背包、实体或世界规则可能需要针对特定模组的兼容处理。 - -MCM 会尽量保留原始状态并回滚失败修改,但一个直接修改实时游戏状态的沙盒工具无法让所有第三方模组组合都变成完全事务式。 - -# 保存数据 - -MCM 会保存应该跨多次运行保留的用户状态,包括受支持设置、按键绑定、菜单布局和法杖预设。 - -模组身份保持稳定,因此正常升级可以继续使用受支持的保存数据。安装新的 standalone 构建时仍建议整体替换模组文件夹,因为混合旧文件和新文件可能留下过时 runtime。 - -# 故障排除 - -## 模组没有出现 - -确认目录最终为: - -`mods/metamorph_creative_menu/mod.xml` - -如果 `metamorph_creative_menu` 上面多出一层压缩包目录,Noita 无法正确识别模组。 - -## 原生功能或材料功能不工作 - -确认已经允许 **Unsafe Mods**,并且安装的是 GitHub standalone 构建,没有与 Workshop 文件混装。 - -## 菜单打开时也触发了游戏操作 - -检查自定义绑定是否冲突。MCM 会显示重复绑定,但如果你需要,也允许保留它们。 - -## 某个生物无法安全变形 - -并不是所有可 spawn 的 XML 实体都是受支持玩家形态。需要特殊处理的生物会使用精确路径兼容规则。 - -## 某个天赋无法移除 - -只有当 MCM 对已追踪状态拥有受支持逆操作时才提供移除。这是有意限制;猜测式清理可能破坏无关玩家状态。 - -## 多人环境中不同 peer 行为不一致 - -所有参与者都应使用兼容的 MCM 构建,并保持兼容的 Noita / Entangled Worlds 环境。MCM 无法修复不兼容的材料注册表或其他模组任意的网络修改。 - -# 报告问题 - -有用的问题报告应包括: - -- 你原本想做什么; -- 涉及的 MCM 精确页面和操作; -- 问题出现在单人、Entangled Worlds,还是两者都有; -- 安装的是 standalone 还是 Workshop 构建; -- 是否启用了其他 gameplay 模组; -- 可稳定复现的步骤; -- 有条件时附上相关 Noita / EW 日志。 - -对于变形、附身、物品或材料问题,请尽量给出精确实体或材料。技术标识符通常比翻译后的显示名称更有帮助。 - -# 仓库与开发源码 - -仓库有意保留**完整开发源码树**,而不是玩家下载的同一份精简压缩包。 - -`metamorph_creative_menu/` 除 runtime 代码外还包含: - -- 自动化测试; -- QA 工具; -- 诊断代码; -- 原生源码; -- 构建工具; -- release 清理规则; -- 开发文档。 - -这些内容对开发和回归测试有用,因此会保留在 GitHub source 中。面向玩家的 ZIP 会单独生成,并排除 development-only 内容。 - -玩家包还会执行仅 release 使用的清理,包括最小化的 player `README.txt`;source tree 则保留正常的开发文档。 - -# 测试 - -自动测试位于 `metamorph_creative_menu/tests/`,包含 Python contract 检查和 Lua mock 测试。 - -Release workflow 会从仓库根目录对完整导入 source 运行测试,通过后才发布 player build。Lua mock 部分需要 `texlua`。 - -Source hygiene 检查还会保护面向 production 的文件和文档,避免开发历史残留、过时 debug 接口以及意外流程产物重新进入仓库。 - -# Source 导入与发布流程 - -完整开发 source 可以从 `Metamorph-Creative-Menu-v...zip` 系列压缩包导入。 - -导入 workflow 会验证压缩包结构,要求完整开发组件,运行 source hygiene 和回归测试,然后才提交导入后的源码树。 - -ModWorkshop / player 风格压缩包不会被当作开发 source。 - -公开的 `latest-build` release 会再通过独立 player-builder 从完整 source 生成。它会删除 QA、测试、诊断、原生源码以及其他 development-only payload,应用 release cleanup 规则,验证最终压缩包,然后才更新稳定下载 asset。 - -这种分离让仓库继续保持完整开发能力,同时让普通玩家下载保持精简且不包含开发 instrumentation。 - -# 第三方组件 - -第三方组件、内置依赖以及上游项目记录在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中。 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/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5c32eb5..255b8cb 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -12,7 +12,7 @@ This file documents external projects and code used by or integrated with **Meta - `metamorph_creative_menu/NoitaPatcher/load.lua` - `metamorph_creative_menu/NoitaPatcher/noitapatcher.dll` - **Purpose in MCM:** extended Noita API functionality used for entity serialization/deserialization, player authority handoff, hard form recovery, CrossCall registration, MagicNumbers runtime changes, exact companion wand use and related fallbacks. -- **License/status note:** the NoitaPatcher upstream repository and documentation are authoritative for its licensing and redistribution terms. It is a third-party component and is not relicensed by MCM. +- **License/status note:** GitHub currently reports no detected repository license for `dextercd/NoitaPatcher`. The upstream repository and documentation are authoritative for NoitaPatcher terms. NoitaPatcher is **not** covered by any license that may later be chosen for MCM's original code. ## lbase64 @@ -44,8 +44,6 @@ This file documents external projects and code used by or integrated with **Meta ## MCM project license -Original MCM material for which **zerodancing** has the right to grant permissions is licensed under the **Metamorph Creative Menu Attribution License 1.0** in the top-level `LICENSE` file. Redistributed and modified versions may be used commercially, but must preserve the required attribution to `zerodancing` and the original GitHub project as specified by that license. +At the time this notice was written, the repository does not declare a top-level license for MCM's original code. That means no repository-wide license should be interpreted as relicensing the third-party components listed above. -The player/source package also carries `metamorph_creative_menu/LICENSE.txt` and `metamorph_creative_menu/NOTICE.txt` so the license and required attribution travel with downloaded builds. - -The MCM project license does **not** relicense third-party components. Third-party code, libraries, binaries, game resources and trademarks remain subject to their own licenses and terms described above or in their original notices. +If a project license is added later, this file should remain and the project license should explicitly exclude third-party components where their upstream terms differ. diff --git a/metamorph_creative_menu/BUILD.txt b/metamorph_creative_menu/BUILD.txt deleted file mode 100644 index 744d9ed..0000000 --- a/metamorph_creative_menu/BUILD.txt +++ /dev/null @@ -1,2 +0,0 @@ -Metamorph: Creative Menu -Development source diff --git a/metamorph_creative_menu/CHANGELOG.txt b/metamorph_creative_menu/CHANGELOG.txt deleted file mode 100644 index b3a6d3e..0000000 --- a/metamorph_creative_menu/CHANGELOG.txt +++ /dev/null @@ -1,15 +0,0 @@ -3.0.0 -===== - -- Added the resizable creative menu with persistent layout and assignable controls. -- Expanded spell and wand editing, including drag-and-drop, Always Cast support, undo/redo, - appearance editing, and persistent wand presets. -- Expanded item, material, perk, effect, creature, transformation, possession, weather, - world-rule, and teleportation tooling. -- Added bounded material painting and improved material discovery/classification. -- Improved reversible form handling, scripted attacks, held-wand forms, and return-to-human - recovery paths. -- Improved Entangled Worlds integration for forms, items, perks, materials, weather, - possession, boss lifecycle handling, and authority cleanup. -- Added adaptive single-player Game Over recovery for supported 32-bit Noita builds. -- Added the complete automated regression suite and deterministic source/release tooling. 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/NoitaPatcher/noitapatcher/nsew/world_ffi.lua b/metamorph_creative_menu/NoitaPatcher/noitapatcher/nsew/world_ffi.lua deleted file mode 100644 index 16d0a9d..0000000 --- a/metamorph_creative_menu/NoitaPatcher/noitapatcher/nsew/world_ffi.lua +++ /dev/null @@ -1,425 +0,0 @@ ----@diagnostic disable: assign-type-mismatch ----Noita world functionality exposed. ----@module 'noitapatcher.nsew.world_ffi' - ----@class WorldFFI -local world_ffi = {} - -local ffi = require("ffi") - -local np = require("noitapatcher") -local world_info = np.GetWorldInfo() - -if not world_info then - error("Couldn't get world info from NoitaPatcher.") -end - -local gg_ptr = world_info.game_global - -ffi.cdef([[ - -typedef void* __thiscall placeholder_memfn(void*); - -struct Position { - int x; - int y; -}; - -struct Colour { - uint8_t r; - uint8_t g; - uint8_t b; - uint8_t a; -}; - -struct AABB { - struct Position top_left; - struct Position bottom_right; -}; - -struct std_string { /* VC++ std::string */ - char *buffer; - char sso_buffer[12]; - size_t size; - size_t capacity; -}; - -typedef enum cell_type { - none=0, - liquid=1, - gas=2, - solid=3, - fire=4, - invalid=4294967295 -} cell_type; - -struct CellData { - struct std_string name; - struct std_string ui_name; - int material_type; - int id_2; - enum cell_type cell_type; - int platform_type; - unsigned int wang_color; - int gfx_glow; - unsigned int gfx_glow_color; - char unknown1[24]; - unsigned int default_primary_colour; - char unknown2[36]; - bool cell_holes_in_texture; - bool stainable; - bool burnable; - bool on_fire; - int fire_hp; - int autoignition_temperature; - int _100_minus_autoignition_temp; - int temperature_of_fire; - int generates_smoke; - int generates_flames; - bool requires_oxygen; - char padding1[3]; - struct std_string on_fire_convert_to_material; - int on_fire_convert_to_material_id; - struct std_string on_fire_flame_material; - int on_fire_flame_material_id; - struct std_string on_fire_smoke_material; - int on_fire_smoke_material_id; - struct ConfigExplosion *explosion_config; - int durability; - int crackability; - bool electrical_conductivity; - bool slippery; - char padding2[2]; - float stickyness; - struct std_string cold_freezes_to_material; - struct std_string warmth_melts_to_material; - int warmth_melts_to_material_id; - int cold_freezes_to_material_id; - int16_t cold_freezes_chance_rev; - int16_t warmth_melts_chance_rev; - bool cold_freezes_to_dont_do_reverse_reaction; - char padding3[3]; - int lifetime; - int hp; - float density; - bool liquid_sand; - bool liquid_slime; - bool liquid_static; - bool liquid_stains_self; - int liquid_sticks_to_ceiling; - float liquid_gravity; - int liquid_viscosity; - int liquid_stains; - unsigned int liquid_stains_custom_color; - float liquid_sprite_stain_shaken_drop_chance; - float liquid_sprite_stain_ignited_drop_chance; - int8_t liquid_sprite_stains_check_offset; - char padding4[3]; - float liquid_sprite_stains_status_threshold; - float liquid_damping; - float liquid_flow_speed; - bool liquid_sand_never_box2d; - char unknown7[3]; - int8_t gas_speed; - int8_t gas_upwards_speed; - int8_t gas_horizontal_speed; - int8_t gas_downwards_speed; - float solid_friction; - float solid_restitution; - float solid_gravity_scale; - int solid_static_type; - float solid_on_collision_splash_power; - bool solid_on_collision_explode; - bool solid_on_sleep_convert; - bool solid_on_collision_convert; - bool solid_on_break_explode; - bool solid_go_through_sand; - bool solid_collide_with_self; - char padding5[2]; - struct std_string solid_on_collision_material; - int solid_on_collision_material_id; - struct std_string solid_break_to_type; - int solid_break_to_type_id; - struct std_string convert_to_box2d_material; - int convert_to_box2d_material_id; - int vegetation_full_lifetime_growth; - struct std_string vegetation_sprite; - bool vegetation_random_flip_x_scale; - char padding6[3]; - char unknown11[12]; - float wang_noise_percent; - float wang_curvature; - int wang_noise_type; - char unknown12[12]; - bool danger_fire; - bool danger_radioactive; - bool danger_poison; - bool danger_water; - char unknown13[24]; - bool always_ignites_damagemodel; - bool ignore_self_reaction_warning; - char padding7[2]; - char unknown14[12]; - float audio_size_multiplier; - bool audio_is_soft; - char padding8[3]; - char unknown15[8]; - bool show_in_creative_mode; - bool is_just_particle_fx; - char padding9[2]; - // struct grid_CosmeticParticleConfig *ParticleEffect; -}; - -enum CellType { - CELL_TYPE_NONE = 0, - CELL_TYPE_LIQUID = 1, - CELL_TYPE_GAS = 2, - CELL_TYPE_SOLID = 3, - CELL_TYPE_FIRE = 4, -}; - -struct Cell_vtable { - void (__thiscall *destroy)(struct Cell*, char dealloc); - enum CellType (__thiscall *get_cell_type)(struct Cell*); - void* field2_0x8; - void* field3_0xc; - void* field4_0x10; - struct Colour (__thiscall *get_colour)(struct Cell*); - void* field6_0x18; - void (__thiscall *set_colour)(struct Cell*, struct Colour); - void* field8_0x20; - void* field9_0x24; - void* field10_0x28; - void* field11_0x2c; - struct CellData* (__thiscall *get_material)(void *); - void* field13_0x34; - void* field14_0x38; - void* field15_0x3c; - void* field16_0x40; - void* field17_0x44; - void* field18_0x48; - void* field19_0x4c; - struct Position * (__thiscall *get_position)(void *, struct Position *); - void* field21_0x54; - void* field22_0x58; - void* field23_0x5c; - void* field24_0x60; - void* field25_0x64; - void* field26_0x68; - void* field27_0x6c; - void* field28_0x70; - bool (__thiscall *is_burning)(struct Cell*); - void* field30_0x78; - void* field31_0x7c; - void* field32_0x80; - void (__thiscall *stop_burning)(struct Cell*); - void* field34_0x88; - void* field35_0x8c; - void* field36_0x90; - void* field37_0x94; - void* field38_0x98; - void (__thiscall *remove)(struct Cell*); - void* field40_0xa0; -}; - -// In the Noita code this would be the ICellBurnable class -struct Cell { - struct Cell_vtable* vtable; - - int hp; - char unknown1[8]; - bool is_burning; - char unknown2[3]; - uintptr_t material_ptr; -}; - -struct CLiquidCell { - struct Cell cell; - int x; - int y; - char unknown1; - char unknown2; - bool is_static; - char unknown3; - int unknown4[3]; - struct Colour colour; - unsigned not_colour; -}; - -typedef struct Cell (*cell_array)[0x40000]; - -struct ChunkMap { - int unknown[2]; - cell_array* (*cells)[0x40000]; - int unknown2[8]; -}; - -struct GridWorld_vtable { - placeholder_memfn* unknown[3]; - struct ChunkMap* (__thiscall *get_chunk_map)(struct GridWorld* this); - placeholder_memfn* unknown2[30]; -}; - -struct GridWorld { - struct GridWorld_vtable* vtable; - int unknown[318]; - int world_update_count; - struct ChunkMap chunk_map; - int unknown2[41]; - struct GridWorldThreadImpl* mThreadImpl; -}; - -struct GridWorldThreaded_vtable; - -struct GridWorldThreaded { - struct GridWorldThreaded_vtable* vtable; - int unknown[287]; - struct AABB update_region; -}; - -struct vec_pGridWorldThreaded { - struct GridWorldThreaded** begin; - struct GridWorldThreaded** end_; - struct GridWorldThreaded** capacity_end; -}; - -struct WorldUpdateParams { - struct AABB update_region; - int unknown; - struct GridWorldThreaded* grid_world_threaded; -}; - -struct vec_WorldUpdateParams { - struct WorldUpdateParams* begin; - struct WorldUpdateParams* end_; - struct WorldUpdateParams* capacity_end; -}; - -struct GridWorldThreadImpl { - int chunk_update_count; - struct vec_pGridWorldThreaded updated_grid_worlds; - - int world_update_params_count; - struct vec_WorldUpdateParams world_update_params; - - int grid_with_area_count; - struct vec_pGridWorldThreaded with_area_grid_worlds; - - int another_count; - int another_vec[3]; - - int some_kind_of_ptr; - int some_kind_of_counter; - - int last_vec[3]; -}; - -typedef struct Cell** __thiscall get_cell_f(struct ChunkMap*, int x, int y); -typedef bool __thiscall chunk_loaded_f(struct ChunkMap*, int x, int y); - -typedef void __thiscall remove_cell_f(struct GridWorld*, void* cell, int x, int y, bool); -typedef struct Cell* __thiscall construct_cell_f(struct GridWorld*, int x, int y, void* material_ptr, void* memory); - -]]) - ---local function check_celldata_field(f, o) --- local offset = ffi.offsetof("struct CellData", f) --- assert(offset == o, "Expected field " .. f .. " to be at offset " .. o) ---end --- ---check_celldata_field("wang_color", 0x40) ---check_celldata_field("generates_flames", 0xa4) ---check_celldata_field("durability", 0x104) ---check_celldata_field("cold_freezes_to_material", 0x114) ---check_celldata_field("liquid_sand", 0x160) ---check_celldata_field("liquid_sprite_stain_ignited_drop_chance", 0x17c) ---check_celldata_field("gas_horizontal_speed", 0x196) ---check_celldata_field("solid_on_sleep_convert", 0x1ad) ---check_celldata_field("solid_break_to_type", 0x1d0) ---check_celldata_field("vegetation_sprite", 0x20c) ---check_celldata_field("wang_noise_type", 0x23c) ---check_celldata_field("ignore_self_reaction_warning", 0x269) ---check_celldata_field("is_just_particle_fx", 0x289) - ----@class ChunkMap pointer type ----@class GridWorld pointer type ----@class CellData pointer type ----@class Cell pointer type - ----Access a pixel in the world. ----You can write a cell created from world_ffi.construct_cell to this pointer to add a cell into the world. ----If there's already a cell at this position, make sure to call world_ffi.remove_cell first. ----@type fun(chunk_map: ChunkMap, x: integer, y: integer): Cell -world_ffi.get_cell = ffi.cast("get_cell_f*", world_info.get_cell) - ----Remove a cell from the world. bool return has unknown meaning. ----@type fun(grid_world: GridWorld, cell: Cell, x: integer, y: integer): boolean -world_ffi.remove_cell = ffi.cast("remove_cell_f*", world_info.remove_cell) - ----Create a new cell. If memory is null pointer it will allocate its own memory. ----@type fun(grid_world: GridWorld, x: integer, y: integer, material: CellData, memory: ffi.cdata*) -world_ffi.construct_cell = ffi.cast("construct_cell_f*", world_info.construct_cell) - ----Check if a chunk is loaded. x and y are world coordinates. ----```lua ----if world_ffi.chunk_loaded(chunk_map, x, y) then ---- local cell = world_ffi.get_cell(chunk_map, x, y) ---- .. ----``` ----@type fun(chunk_map: ChunkMap, x: integer, y: integer): boolean -world_ffi.chunk_loaded = ffi.cast("chunk_loaded_f*", world_info.chunk_loaded) - -world_ffi.Position = ffi.typeof("struct Position") -world_ffi.Colour = ffi.typeof("struct Colour") -world_ffi.AABB = ffi.typeof("struct AABB") -world_ffi.CellType = ffi.typeof("enum CellType") -world_ffi.Cell = ffi.typeof("struct Cell") -world_ffi.CLiquidCell = ffi.typeof("struct CLiquidCell") -world_ffi.ChunkMap = ffi.typeof("struct ChunkMap") -world_ffi.GridWorld = ffi.typeof("struct GridWorld") -world_ffi.GridWorldThreaded = ffi.typeof("struct GridWorldThreaded") -world_ffi.WorldUpdateParams = ffi.typeof("struct WorldUpdateParams") -world_ffi.GridWorldThreadImpl = ffi.typeof("struct GridWorldThreadImpl") - ----Get the grid world. ----@return GridWorld -function world_ffi.get_grid_world() - local game_global = ffi.cast("void*", gg_ptr) - local world_data = ffi.cast("void**", ffi.cast("char*", game_global) + 0xc)[0] - local grid_world = ffi.cast("struct GridWorld**", ffi.cast("char*", world_data) + 0x44)[0] - return grid_world -end - -local celldata_size = 0x290 -local CellData_ptr = ffi.typeof("struct CellData*") - ----Turn a standard material id into a material pointer. ----@param id integer material id that is used in the standard Noita functions ----@return CellData material to internal material data (aka cell data). ----```lua ----local gold_ptr = world_ffi.get_material_ptr(CellFactory_GetType("gold")) ----``` -function world_ffi.get_material_ptr(id) - local game_global = ffi.cast("char*", gg_ptr) - local cell_factory = ffi.cast("char**", (game_global + 0x18))[0] - local begin = ffi.cast("char**", cell_factory + 0x18)[0] - local ptr = begin + celldata_size * id - return ffi.cast(CellData_ptr, ptr) --[[@as CellData]] -end - ----Turn a material pointer into a standard material id. ----@param material CellData to a material (aka cell data) ----@return integer material id that is accepted by standard Noita functions such as `CellFactory_GetUIName` and `ConvertMaterialOnAreaInstantly`. ----```lua ----local mat_id = world_ffi.get_material_id(cell.vtable.get_material(cell)) ----``` ----See: `world_ffi.get_material_ptr` -function world_ffi.get_material_id(material) - local game_global = ffi.cast("char*", gg_ptr) - local cell_factory = ffi.cast("char**", (game_global + 0x18))[0] - local begin = ffi.cast("char**", cell_factory + 0x18)[0] - local offset = ffi.cast("char*", material) - begin - return offset / celldata_size -end - -return world_ffi diff --git a/metamorph_creative_menu/README.txt b/metamorph_creative_menu/README.txt index 6775b55..0255892 100644 --- a/metamorph_creative_menu/README.txt +++ b/metamorph_creative_menu/README.txt @@ -1,10 +1 @@ -Metamorph: Creative Menu - development source package Creator: zerodancing - -This directory is the complete development tree. It intentionally contains runtime code, -tests, QA utilities, diagnostics, native source, and build tooling. - -The complete project and player-facing documentation is maintained in the repository-root -README.md and its translated README files. - -The ready-to-install player build is generated separately by the repository release workflow. diff --git a/metamorph_creative_menu/VERSION.txt b/metamorph_creative_menu/VERSION.txt deleted file mode 100644 index 4a36342..0000000 --- a/metamorph_creative_menu/VERSION.txt +++ /dev/null @@ -1 +0,0 @@ -3.0.0 diff --git a/metamorph_creative_menu/dev_mode.lua b/metamorph_creative_menu/dev_mode.lua index f129529..bf368d3 100644 --- a/metamorph_creative_menu/dev_mode.lua +++ b/metamorph_creative_menu/dev_mode.lua @@ -1,4 +1,4 @@ --- Release default: keep all in-game diagnostics and QA instrumentation unloaded. +-- Steam/release default: keep all in-game diagnostics and QA instrumentation unloaded. -- Set to 1 only while developing/debugging the mod. Offline tests in tests/ are unaffected. dev_mode = 0 return dev_mode diff --git a/metamorph_creative_menu/files/core/action_registry.lua b/metamorph_creative_menu/files/core/action_registry.lua deleted file mode 100644 index 35f83bc..0000000 --- a/metamorph_creative_menu/files/core/action_registry.lua +++ /dev/null @@ -1,60 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_ACTION_REGISTRY) == "table" then - return METAMORPH_CREATIVE_MENU_ACTION_REGISTRY -end - -local registry = {} - -local sections = { - { id="interface", key="$mcm_bind_section_interface", fallback="INTERFACE" }, - { id="sections", key="$mcm_bind_section_sections", fallback="OPEN SECTION" }, - { id="gameplay", key="$mcm_bind_section_gameplay", fallback="GAMEPLAY" }, - { id="world", key="$mcm_bind_section_world", fallback="WORLD TOOLS" }, -} - -local actions = { - { id="menu_toggle", section="interface", key="$mcm_bind_menu_toggle", fallback="Open / close creative menu", default="Key_F4" }, - { id="menu_close", section="interface", key="$mcm_bind_menu_close", fallback="Close creative menu", default="NONE", menu_only=true }, - { id="tab_previous", section="interface", key="$mcm_bind_tab_previous", fallback="Previous section", default="NONE", menu_only=true }, - { id="tab_next", section="interface", key="$mcm_bind_tab_next", fallback="Next section", default="NONE", menu_only=true }, - - { id="open_spells", section="sections", key="$mcm_tab_spells", fallback="SPELLS", default="NONE", tab="spells" }, - { id="open_items", section="sections", key="$mcm_tab_items", fallback="ITEMS", default="NONE", tab="items" }, - { id="open_materials", section="sections", key="$mcm_tab_materials", fallback="MATERIALS", default="NONE", tab="materials" }, - { id="open_perks", section="sections", key="$mcm_tab_perks", fallback="PERKS", default="NONE", tab="perks" }, - { id="open_mobs", section="sections", key="$mcm_tab_creatures", fallback="MOBS", default="NONE", tab="mobs" }, - { id="open_effects", section="sections", key="$mcm_tab_effects", fallback="EFFECTS", default="NONE", tab="effects" }, - { id="open_weather", section="sections", key="$mcm_tab_weather", fallback="WEATHER", default="NONE", tab="weather" }, - { id="open_rules", section="sections", key="$mcm_tab_rules", fallback="RULES", default="NONE", tab="rules" }, - { id="open_players", section="sections", key="$mcm_tab_players", fallback="TELEPORTATION", default="NONE", tab="players" }, - { id="open_controls", section="sections", key="$mcm_tab_controls", fallback="CONTROLS", default="NONE", tab="controls" }, - - { id="return_human", section="gameplay", key="$mcm_bind_return_human", fallback="Return to human form", default="Key_TAB" }, - { id="possession", section="gameplay", key="$mcm_setting_possession_key", fallback="Possess creature under cursor", default="Key_g", legacy_setting="metamorph_creative_menu.possession_key" }, - { id="paint_toggle", section="gameplay", key="$mcm_bind_paint_toggle", fallback="Start / stop material painting", default="NONE" }, - { id="paint_draw", section="gameplay", key="$mcm_bind_paint_draw", fallback="Draw selected material", default="Mouse:3" }, - { id="brush_smaller", section="gameplay", key="$mcm_bind_brush_smaller", fallback="Smaller material brush", default="NONE" }, - { id="brush_larger", section="gameplay", key="$mcm_bind_brush_larger", fallback="Larger material brush", default="NONE" }, - - { id="effects_clear", section="world", key="$mcm_effect_remove_all", fallback="Remove all effects", default="NONE" }, - { id="weather_release", section="world", key="$mcm_weather_release", fallback="Release weather override", default="NONE" }, - { id="rules_reset", section="world", key="$mcm_rules_reset", fallback="Reset world rules", default="NONE" }, - { id="teleport_next_player", section="world", key="$mcm_bind_teleport_next", fallback="Teleport to next network player", default="NONE" }, -} - -local by_id = {} -for index, action in ipairs(actions) do - action.index = index - action.setting_id = "metamorph_creative_menu.binding_" .. action.id - by_id[action.id] = action -end - -function registry.actions() return actions end -function registry.sections() return sections end -function registry.get(id) return by_id[tostring(id or "")] end -function registry.setting_id(id) - local action = registry.get(id) - return action and action.setting_id or nil -end - -METAMORPH_CREATIVE_MENU_ACTION_REGISTRY = registry -return registry diff --git a/metamorph_creative_menu/files/core/binding_codec.lua b/metamorph_creative_menu/files/core/binding_codec.lua deleted file mode 100644 index a29397f..0000000 --- a/metamorph_creative_menu/files/core/binding_codec.lua +++ /dev/null @@ -1,67 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_BINDING_CODEC) == "table" then - return METAMORPH_CREATIVE_MENU_BINDING_CODEC -end - -local codec = {} -local MODIFIER_ORDER = { "CTRL", "SHIFT", "ALT" } -local MODIFIER_SET = { CTRL=true, SHIFT=true, ALT=true } - -local function trim(value) - value = tostring(value or "") - return (string.gsub(string.gsub(value, "^%s+", ""), "%s+$", "")) -end - -function codec.parse(value) - value = trim(value) - if value == "" or string.upper(value) == "NONE" then - return { kind="none", canonical="NONE", modifiers={} } - end - local modifiers, base = {}, nil - for token in string.gmatch(value, "[^+]+") do - token = trim(token) - local upper = string.upper(token) - if MODIFIER_SET[upper] then - modifiers[upper] = true - elseif base ~= nil then - return nil, "multiple_base" - else - base = token - end - end - if base == nil or base == "" then return nil, "missing_base" end - local mouse = string.match(base, "^[Mm][Oo][Uu][Ss][Ee]:(%-?%d+)$") - local kind, code = "key", nil - if mouse ~= nil then - kind, code, base = "mouse", tonumber(mouse), "Mouse:" .. tostring(tonumber(mouse)) - if code == nil or code < 0 or code > 31 then return nil, "mouse_range" end - elseif not string.match(base, "^Key_[%w_]+$") then - return nil, "key_name" - end - local parts = {} - for _, modifier in ipairs(MODIFIER_ORDER) do if modifiers[modifier] then parts[#parts + 1] = modifier end end - parts[#parts + 1] = base - return { kind=kind, base=base, mouse_code=code, modifiers=modifiers, canonical=table.concat(parts, "+") } -end - -function codec.normalize(value, fallback) - if value == nil or trim(value) == "" then value = fallback end - local parsed = codec.parse(value) - if parsed ~= nil then return parsed.canonical end - parsed = codec.parse(fallback) - return parsed and parsed.canonical or "NONE" -end - -function codec.compose(base, ctrl, shift, alt) - if type(base) ~= "string" or base == "" then return "NONE" end - local parts = {} - if ctrl == true then parts[#parts + 1] = "CTRL" end - if shift == true then parts[#parts + 1] = "SHIFT" end - if alt == true then parts[#parts + 1] = "ALT" end - parts[#parts + 1] = base - return codec.normalize(table.concat(parts, "+"), "NONE") -end - -function codec.modifier_order() return MODIFIER_ORDER end - -METAMORPH_CREATIVE_MENU_BINDING_CODEC = codec -return codec diff --git a/metamorph_creative_menu/files/core/global_text.lua b/metamorph_creative_menu/files/core/global_text.lua deleted file mode 100644 index 04207fd..0000000 --- a/metamorph_creative_menu/files/core/global_text.lua +++ /dev/null @@ -1,44 +0,0 @@ -local global_text = {} - -local DEFAULT_MAX_BYTES = 512 - -local function safe_tostring(value) - if value == nil then return "" end - local ok, text = pcall(tostring, value) - return ok and type(text) == "string" and text or "" -end - -local function is_safe_ascii(byte) - if byte < 32 or byte > 126 then return false end - return byte ~= 34 -- " - and byte ~= 37 -- % - and byte ~= 38 -- & - and byte ~= 60 -- < - and byte ~= 62 -- > - and byte ~= 92 -- backslash -end - -function global_text.encode_diagnostic(value, max_bytes) - local text = safe_tostring(value) - local limit = tonumber(max_bytes) - if limit == nil then limit = DEFAULT_MAX_BYTES end - limit = math.floor(limit) - if limit <= 0 or text == "" then return "" end - - local out, used = {}, 0 - for index = 1, #text do - local byte = string.byte(text, index) - if is_safe_ascii(byte) then - if used + 1 > limit then break end - out[#out + 1] = string.char(byte) - used = used + 1 - else - if used + 3 > limit then break end - out[#out + 1] = string.format("%%%02X", byte) - used = used + 3 - end - end - return table.concat(out) -end - -return global_text diff --git a/metamorph_creative_menu/files/core/menu_visibility_policy.lua b/metamorph_creative_menu/files/core/menu_visibility_policy.lua deleted file mode 100644 index 6b1f575..0000000 --- a/metamorph_creative_menu/files/core/menu_visibility_policy.lua +++ /dev/null @@ -1,73 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_VISIBILITY_POLICY) == 'table' then - return METAMORPH_CREATIVE_MENU_VISIBILITY_POLICY -end - -local policy = {} - -policy.ALWAYS_OPEN = 'always_open' -policy.ALWAYS_CLOSED = 'always_closed' -policy.REMEMBER = 'remember' - -function policy.normalize_mode(value) - value = tostring(value or '') - if value == policy.ALWAYS_CLOSED or value == policy.REMEMBER then return value end - return policy.ALWAYS_OPEN -end - -function policy.new() - return { - native_open = false, - session_visible = false, - manual_open = false, - mode = policy.ALWAYS_OPEN, - } -end - -function policy.sync_inventory(state, native_open, mode, remembered_open) - state = type(state) == 'table' and state or policy.new() - native_open = native_open == true - mode = policy.normalize_mode(mode) - - if native_open and state.native_open ~= true then - if mode == policy.ALWAYS_OPEN then - state.session_visible = true - elseif mode == policy.REMEMBER then - state.session_visible = remembered_open == true - else - state.session_visible = false - end - elseif not native_open and state.native_open == true then - state.session_visible = false - end - - state.native_open = native_open - state.mode = mode - return nil -end - -function policy.visible(state) - if type(state) ~= 'table' then return false end - return state.manual_open == true or (state.native_open == true and state.session_visible == true) -end - -function policy.set_user_visible(state, visible) - state = type(state) == 'table' and state or policy.new() - visible = visible == true - if state.native_open == true then - -- A manual window opened while native inventory is active belongs to that - -- inventory session. Closing the native inventory therefore hides it again. - state.session_visible = visible - if not visible then state.manual_open = false end - else - state.manual_open = visible - end - if state.mode == policy.REMEMBER then return visible end - return nil -end - -function policy.toggle_user(state) - return policy.set_user_visible(state, not policy.visible(state)) -end - -METAMORPH_CREATIVE_MENU_VISIBILITY_POLICY = policy -return policy diff --git a/metamorph_creative_menu/files/core/panel_layout.lua b/metamorph_creative_menu/files/core/panel_layout.lua deleted file mode 100644 index d3f8d14..0000000 --- a/metamorph_creative_menu/files/core/panel_layout.lua +++ /dev/null @@ -1,138 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_PANEL_LAYOUT) == "table" then return METAMORPH_CREATIVE_MENU_PANEL_LAYOUT end - -local panel_layout = {} - -panel_layout.MARGIN = 4 -panel_layout.MIN_WIDTH = 190 -panel_layout.MIN_HEIGHT = 112 -panel_layout.DEFAULT_WIDTH = 196 -panel_layout.DEFAULT_MIN_HEIGHT = 170 -panel_layout.DEFAULT_MAX_HEIGHT = 282 -panel_layout.DEFAULT_Y = 28 -panel_layout.DEFAULT_Y_RATIO = 68 / 360 - -local function finite_number(value) - value = tonumber(value) - if value == nil or value ~= value or value == math.huge or value == -math.huge then return nil end - return value -end - -local function clamp(value, minimum, maximum) - if maximum < minimum then maximum = minimum end - return math.max(minimum, math.min(maximum, value)) -end - -local function normalized_outsets(outsets) - outsets = type(outsets) == "table" and outsets or {} - return { - left=math.max(0, finite_number(outsets.left) or 0), - right=math.max(0, finite_number(outsets.right) or 0), - top=math.max(0, finite_number(outsets.top) or 0), - bottom=math.max(0, finite_number(outsets.bottom) or 0), - } -end - -function panel_layout.default_width(screen_width) - screen_width = math.max(1, finite_number(screen_width) or 320) - local available = math.max(1, screen_width - panel_layout.MARGIN * 2) - -- 196x282 at Noita's 640x360 GUI space matches the user-selected reference layout. - -- Keep the default narrow and right-anchored; smaller viewports are clamped below. - return math.min(available, panel_layout.DEFAULT_WIDTH) -end - -function panel_layout.default_height(screen_height) - screen_height = math.max(1, finite_number(screen_height) or 240) - local available = math.max(1, screen_height - panel_layout.MARGIN * 2) - local responsive = math.min(panel_layout.DEFAULT_MAX_HEIGHT, math.floor(screen_height * 0.784)) - return math.min(available, math.max(math.min(panel_layout.DEFAULT_MIN_HEIGHT, available), responsive)) -end - -function panel_layout.default_y(screen_height) - screen_height = math.max(1, finite_number(screen_height) or 240) - return math.max(panel_layout.DEFAULT_Y, math.floor(screen_height * panel_layout.DEFAULT_Y_RATIO + 0.5)) -end - -function panel_layout.clamp(layout, screen_width, screen_height, measured_height, outsets) - layout = type(layout) == "table" and layout or {} - screen_width = math.max(1, finite_number(screen_width) or 320) - screen_height = math.max(1, finite_number(screen_height) or 240) - - local margin = math.min(panel_layout.MARGIN, math.floor(math.min(screen_width, screen_height) * 0.25)) - outsets = normalized_outsets(outsets) - local available_width = math.max(1, screen_width - margin * 2 - outsets.left - outsets.right) - local minimum_width = math.min(panel_layout.MIN_WIDTH, available_width) - layout.width = clamp(finite_number(layout.width) or panel_layout.default_width(screen_width), - minimum_width, available_width) - - local minimum_x = margin + outsets.left - local maximum_x = math.max(minimum_x, screen_width - layout.width - margin - outsets.right) - if finite_number(layout.x) == nil then layout.x = maximum_x end - layout.x = clamp(layout.x, minimum_x, maximum_x) - - local available_height = math.max(1, screen_height - margin * 2 - outsets.top - outsets.bottom) - local minimum_height = math.min(panel_layout.MIN_HEIGHT, available_height) - layout.height = clamp(finite_number(layout.height) or finite_number(measured_height) - or panel_layout.default_height(screen_height), minimum_height, available_height) - - local minimum_y = margin + outsets.top - local maximum_y = math.max(minimum_y, screen_height - layout.height - margin - outsets.bottom) - layout.y = clamp(finite_number(layout.y) or panel_layout.default_y(screen_height), minimum_y, maximum_y) - return layout -end - -function panel_layout.create(screen_width, screen_height, saved, outsets) - saved = type(saved) == "table" and saved or {} - return panel_layout.clamp({ - x=finite_number(saved.x), - y=finite_number(saved.y), - width=finite_number(saved.width), - height=finite_number(saved.height), - }, screen_width, screen_height, saved.measured_height, outsets) -end - -function panel_layout.move(layout, x, y, screen_width, screen_height, measured_height, outsets) - layout.x = finite_number(x) or layout.x - layout.y = finite_number(y) or layout.y - return panel_layout.clamp(layout, screen_width, screen_height, measured_height, outsets) -end - -function panel_layout.resize_edges(layout, original, edges, dx, dy, screen_width, screen_height, outsets) - original = type(original) == "table" and original or layout - edges = type(edges) == "table" and edges or {} - dx, dy = finite_number(dx) or 0, finite_number(dy) or 0 - local right = (finite_number(original.x) or layout.x) + (finite_number(original.width) or layout.width) - local bottom = (finite_number(original.y) or layout.y) + (finite_number(original.height) or layout.height) - - if edges.left then - layout.x = (finite_number(original.x) or layout.x) + dx - layout.width = right - layout.x - elseif edges.right then - layout.width = (finite_number(original.width) or layout.width) + dx - end - if edges.top then - layout.y = (finite_number(original.y) or layout.y) + dy - layout.height = bottom - layout.y - elseif edges.bottom then - layout.height = (finite_number(original.height) or layout.height) + dy - end - - local safe_outsets = normalized_outsets(outsets) - local minimum_width = math.min(panel_layout.MIN_WIDTH, - math.max(1, (finite_number(screen_width) or 320) - panel_layout.MARGIN * 2 - - safe_outsets.left - safe_outsets.right)) - local minimum_height = math.min(panel_layout.MIN_HEIGHT, - math.max(1, (finite_number(screen_height) or 240) - panel_layout.MARGIN * 2 - - safe_outsets.top - safe_outsets.bottom)) - if layout.width < minimum_width then - layout.width = minimum_width - if edges.left then layout.x = right - layout.width end - end - if layout.height < minimum_height then - layout.height = minimum_height - if edges.top then layout.y = bottom - layout.height end - end - return panel_layout.clamp(layout, screen_width, screen_height, nil, outsets) -end - -METAMORPH_CREATIVE_MENU_PANEL_LAYOUT = panel_layout -return panel_layout diff --git a/metamorph_creative_menu/files/core/search_engine.lua b/metamorph_creative_menu/files/core/search_engine.lua deleted file mode 100644 index 951bd39..0000000 --- a/metamorph_creative_menu/files/core/search_engine.lua +++ /dev/null @@ -1,283 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_SEARCH_ENGINE) == "table" then return METAMORPH_CREATIVE_MENU_SEARCH_ENGINE end - -local search_engine = {} - --- Search normalization is intentionally engine-agnostic: UI/catalog layers may feed it --- translated labels, English aliases, technical ids and paths without coupling core to --- Noita's localization APIs. -local QUERY_CACHE_LIMIT = 64 -local FIELD_CACHE_LIMIT = 4096 -local query_cache, query_cache_order = {}, {} -local field_cache, field_cache_order = {}, {} - -local function utf8_decode(value) - value = tostring(value or "") - local out, index, length = {}, 1, #value - while index <= length do - local first = string.byte(value, index) - local codepoint, width - if first == nil then break end - if first < 0x80 then - codepoint, width = first, 1 - elseif first >= 0xC2 and first < 0xE0 and index + 1 <= length then - local b2 = string.byte(value, index + 1) - if b2 ~= nil and b2 >= 0x80 and b2 < 0xC0 then - codepoint = (first - 0xC0) * 0x40 + (b2 - 0x80) - width = 2 - end - elseif first >= 0xE0 and first < 0xF0 and index + 2 <= length then - local b2, b3 = string.byte(value, index + 1), string.byte(value, index + 2) - if b2 ~= nil and b3 ~= nil and b2 >= 0x80 and b2 < 0xC0 and b3 >= 0x80 and b3 < 0xC0 then - codepoint = (first - 0xE0) * 0x1000 + (b2 - 0x80) * 0x40 + (b3 - 0x80) - width = 3 - end - elseif first >= 0xF0 and first < 0xF5 and index + 3 <= length then - local b2, b3, b4 = string.byte(value, index + 1), string.byte(value, index + 2), string.byte(value, index + 3) - if b2 ~= nil and b3 ~= nil and b4 ~= nil - and b2 >= 0x80 and b2 < 0xC0 and b3 >= 0x80 and b3 < 0xC0 and b4 >= 0x80 and b4 < 0xC0 - then - codepoint = (first - 0xF0) * 0x40000 + (b2 - 0x80) * 0x1000 + (b3 - 0x80) * 0x40 + (b4 - 0x80) - width = 4 - end - end - if codepoint == nil then - out[#out + 1] = first - index = index + 1 - else - out[#out + 1] = codepoint - index = index + width - end - end - return out -end - -local function utf8_encode(codepoint) - if codepoint < 0x80 then return string.char(codepoint) end - if codepoint < 0x800 then - return string.char(0xC0 + math.floor(codepoint / 0x40), 0x80 + codepoint % 0x40) - end - if codepoint < 0x10000 then - return string.char(0xE0 + math.floor(codepoint / 0x1000), - 0x80 + math.floor(codepoint / 0x40) % 0x40, 0x80 + codepoint % 0x40) - end - return string.char(0xF0 + math.floor(codepoint / 0x40000), - 0x80 + math.floor(codepoint / 0x1000) % 0x40, - 0x80 + math.floor(codepoint / 0x40) % 0x40, 0x80 + codepoint % 0x40) -end - -local LATIN_FOLD = { - [0x00C0]="a",[0x00C1]="a",[0x00C2]="a",[0x00C3]="a",[0x00C4]="a",[0x00C5]="a", - [0x00E0]="a",[0x00E1]="a",[0x00E2]="a",[0x00E3]="a",[0x00E4]="a",[0x00E5]="a", - [0x0100]="a",[0x0101]="a",[0x0102]="a",[0x0103]="a",[0x0104]="a",[0x0105]="a", - [0x00C6]="ae",[0x00E6]="ae", - [0x00C7]="c",[0x00E7]="c",[0x0106]="c",[0x0107]="c",[0x010C]="c",[0x010D]="c", - [0x010E]="d",[0x010F]="d",[0x0110]="d",[0x0111]="d", - [0x00C8]="e",[0x00C9]="e",[0x00CA]="e",[0x00CB]="e",[0x00E8]="e",[0x00E9]="e",[0x00EA]="e",[0x00EB]="e", - [0x0112]="e",[0x0113]="e",[0x0116]="e",[0x0117]="e",[0x0118]="e",[0x0119]="e",[0x011A]="e",[0x011B]="e", - [0x011E]="g",[0x011F]="g", - [0x00CC]="i",[0x00CD]="i",[0x00CE]="i",[0x00CF]="i",[0x00EC]="i",[0x00ED]="i",[0x00EE]="i",[0x00EF]="i", - [0x012A]="i",[0x012B]="i",[0x012E]="i",[0x012F]="i", - [0x0139]="l",[0x013A]="l",[0x013D]="l",[0x013E]="l",[0x0141]="l",[0x0142]="l", - [0x00D1]="n",[0x00F1]="n",[0x0143]="n",[0x0144]="n",[0x0147]="n",[0x0148]="n", - [0x00D2]="o",[0x00D3]="o",[0x00D4]="o",[0x00D5]="o",[0x00D6]="o",[0x00D8]="o", - [0x00F2]="o",[0x00F3]="o",[0x00F4]="o",[0x00F5]="o",[0x00F6]="o",[0x00F8]="o", - [0x014C]="o",[0x014D]="o",[0x0150]="o",[0x0151]="o",[0x0152]="oe",[0x0153]="oe", - [0x0154]="r",[0x0155]="r",[0x0158]="r",[0x0159]="r", - [0x015A]="s",[0x015B]="s",[0x0160]="s",[0x0161]="s",[0x015E]="s",[0x015F]="s",[0x00DF]="ss", - [0x0164]="t",[0x0165]="t", - [0x00D9]="u",[0x00DA]="u",[0x00DB]="u",[0x00DC]="u",[0x00F9]="u",[0x00FA]="u",[0x00FB]="u",[0x00FC]="u", - [0x016A]="u",[0x016B]="u",[0x016E]="u",[0x016F]="u",[0x0170]="u",[0x0171]="u", - [0x00DD]="y",[0x00FD]="y",[0x00FF]="y", - [0x0179]="z",[0x017A]="z",[0x017B]="z",[0x017C]="z",[0x017D]="z",[0x017E]="z", -} - -local function fold_codepoint(codepoint) - local mapped = LATIN_FOLD[codepoint] - if mapped ~= nil then return mapped end - if codepoint >= 0x41 and codepoint <= 0x5A then return string.char(codepoint + 0x20) end - -- Cyrillic uppercase + Yo. Yo is folded to e/е so common user spelling variants match. - if codepoint >= 0x0410 and codepoint <= 0x042F then codepoint = codepoint + 0x20 end - if codepoint == 0x0401 or codepoint == 0x0451 then codepoint = 0x0435 end - -- Full-width ASCII used by some CJK input methods. - if codepoint >= 0xFF01 and codepoint <= 0xFF5E then codepoint = codepoint - 0xFEE0 end - if codepoint >= 0x41 and codepoint <= 0x5A then codepoint = codepoint + 0x20 end - return utf8_encode(codepoint) -end - -function search_engine.normalize(value) - local parts = {} - for _, codepoint in ipairs(utf8_decode(value)) do parts[#parts + 1] = fold_codepoint(codepoint) end - local normalized = table.concat(parts) - -- Treat punctuation/path/id separators as word boundaries. Keep all non-ASCII UTF-8 - -- bytes intact so Chinese/Japanese/Korean text remains searchable verbatim. - normalized = string.gsub(normalized, "[%c%s_%-%.%/%\\:;,%(%)%[%]{}<>|]+", " ") - normalized = string.gsub(normalized, "[%+]+", " ") - normalized = string.gsub(normalized, "%s+", " ") - normalized = string.gsub(normalized, "^%s+", "") - normalized = string.gsub(normalized, "%s+$", "") - return normalized -end - -local function words(value) - local result = {} - for token in string.gmatch(value, "%S+") do result[#result + 1] = token end - return result -end - -local function field_cache_key(fields) - local parts = {} - for index, value in ipairs(fields) do - value = tostring(value or "") - parts[#parts + 1] = tostring(#value) .. ":" .. value - end - return table.concat(parts, "\31") -end - -local function prepare_fields(fields) - fields = type(fields) == "table" and fields or {fields} - local key = field_cache_key(fields) - local cached = field_cache[key] - if cached ~= nil then return cached end - local normalized_fields, field_words = {}, {} - for _, value in ipairs(fields) do - local normalized = search_engine.normalize(value) - if normalized ~= "" then - normalized_fields[#normalized_fields + 1] = normalized - for _, word in ipairs(words(normalized)) do field_words[#field_words + 1] = word end - end - end - local joined = table.concat(normalized_fields, " ") - local prepared = { - normalized_fields=normalized_fields, - field_words=field_words, - joined=joined, - joined_compact=string.gsub(joined, "%s+", ""), - } - field_cache[key] = prepared - field_cache_order[#field_cache_order + 1] = key - if #field_cache_order > FIELD_CACHE_LIMIT then - local oldest = table.remove(field_cache_order, 1) - field_cache[oldest] = nil - end - return prepared -end - -local function cache_query(key, compiled) - if query_cache[key] ~= nil then return end - query_cache[key] = compiled - query_cache_order[#query_cache_order + 1] = key - if #query_cache_order > QUERY_CACHE_LIMIT then - local oldest = table.remove(query_cache_order, 1) - query_cache[oldest] = nil - end -end - -function search_engine.compile_query(query) - local key = tostring(query or "") - local cached = query_cache[key] - if cached ~= nil then return cached end - local positive, negative = {}, {} - -- Operator detection happens before punctuation normalization, otherwise -foo loses - -- its exclusion meaning. - for raw in string.gmatch(key, "%S+") do - local first = string.sub(raw, 1, 1) - local excluded = (first == "-" or first == "!") and #raw > 1 - local body = excluded and string.sub(raw, 2) or raw - body = search_engine.normalize(body) - for token in string.gmatch(body, "%S+") do - (excluded and negative or positive)[#(excluded and negative or positive) + 1] = token - end - end - local compiled = { - raw=key, - normalized=search_engine.normalize(key), - positive=positive, - negative=negative, - positive_phrase=table.concat(positive, " "), - } - cache_query(key, compiled) - return compiled -end - -local function edit_distance_at_most(left, right, limit) - if left == right then return 0 end - local a, b = utf8_decode(left), utf8_decode(right) - if math.abs(#a - #b) > limit then return nil end - if #a == 0 or #b == 0 then return math.max(#a, #b) <= limit and math.max(#a, #b) or nil end - local previous = {} - for j = 0, #b do previous[j] = j end - for i = 1, #a do - local current = {[0]=i} - local row_min = current[0] - for j = 1, #b do - local cost = a[i] == b[j] and 0 or 1 - local value = math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost) - current[j] = value - if value < row_min then row_min = value end - end - if row_min > limit then return nil end - previous = current - end - return previous[#b] <= limit and previous[#b] or nil -end - -local function token_score(token, normalized_fields, joined, joined_compact, field_words) - local best = nil - for _, field in ipairs(normalized_fields) do - if field == token then best = math.max(best or 0, 220) end - if string.sub(field, 1, #token) == token then best = math.max(best or 0, 170) end - end - if string.find(joined, token, 1, true) ~= nil then best = math.max(best or 0, 130) end - local compact = string.gsub(token, " ", "") - if compact ~= "" and string.find(joined_compact, compact, 1, true) ~= nil then best = math.max(best or 0, 110) end - if best ~= nil then return best end - - -- Typo tolerance is deliberately conservative. Short tokens and CJK single-glyph - -- searches stay exact; longer human-readable names tolerate one or two edits. - local token_len = #utf8_decode(token) - local limit = token_len >= 8 and 2 or (token_len >= 4 and 1 or 0) - if limit == 0 then return nil end - for _, word in ipairs(field_words) do - local distance = edit_distance_at_most(token, word, limit) - if distance ~= nil then - local score = distance == 0 and 120 or (distance == 1 and 72 or 48) - best = math.max(best or 0, score) - end - end - return best -end - -function search_engine.score(query, fields) - local compiled = type(query) == "table" and query or search_engine.compile_query(query) - if #compiled.positive == 0 and #compiled.negative == 0 then return 0 end - local prepared = prepare_fields(fields) - local normalized_fields = prepared.normalized_fields - local field_words = prepared.field_words - local joined = prepared.joined - local joined_compact = prepared.joined_compact - for _, token in ipairs(compiled.negative) do - if token ~= "" and (string.find(joined, token, 1, true) ~= nil - or string.find(joined_compact, string.gsub(token, "%s+", ""), 1, true) ~= nil) - then - return nil - end - end - local score = 0 - for _, token in ipairs(compiled.positive) do - local matched = token_score(token, normalized_fields, joined, joined_compact, field_words) - if matched == nil then return nil end - score = score + matched - end - if compiled.positive_phrase ~= "" then - if joined == compiled.positive_phrase then score = score + 700 - elseif string.find(joined, compiled.positive_phrase, 1, true) ~= nil then score = score + 300 end - end - return score -end - -function search_engine.matches(query, fields) - return search_engine.score(query, fields) ~= nil -end - -METAMORPH_CREATIVE_MENU_SEARCH_ENGINE = search_engine -return search_engine diff --git a/metamorph_creative_menu/files/core/utf8_text.lua b/metamorph_creative_menu/files/core/utf8_text.lua deleted file mode 100644 index ba33ca6..0000000 --- a/metamorph_creative_menu/files/core/utf8_text.lua +++ /dev/null @@ -1,73 +0,0 @@ -local utf8_text = {} - -local function continuation(byte) - return byte ~= nil and byte >= 0x80 and byte <= 0xBF -end - -local function sequence_length(value, index) - local b1 = string.byte(value, index) - if b1 == nil then return nil end - if b1 <= 0x7F then return 1 end - - local b2 = string.byte(value, index + 1) - if b1 >= 0xC2 and b1 <= 0xDF then - if continuation(b2) then return 2 end - return nil - end - - local b3 = string.byte(value, index + 2) - if b1 == 0xE0 then - if b2 ~= nil and b2 >= 0xA0 and b2 <= 0xBF and continuation(b3) then return 3 end - return nil - end - if (b1 >= 0xE1 and b1 <= 0xEC) or (b1 >= 0xEE and b1 <= 0xEF) then - if continuation(b2) and continuation(b3) then return 3 end - return nil - end - if b1 == 0xED then - if b2 ~= nil and b2 >= 0x80 and b2 <= 0x9F and continuation(b3) then return 3 end - return nil - end - - local b3_4 = string.byte(value, index + 2) - local b4 = string.byte(value, index + 3) - if b1 == 0xF0 then - if b2 ~= nil and b2 >= 0x90 and b2 <= 0xBF and continuation(b3_4) and continuation(b4) then return 4 end - return nil - end - if b1 >= 0xF1 and b1 <= 0xF3 then - if continuation(b2) and continuation(b3_4) and continuation(b4) then return 4 end - return nil - end - if b1 == 0xF4 then - if b2 ~= nil and b2 >= 0x80 and b2 <= 0x8F and continuation(b3_4) and continuation(b4) then return 4 end - return nil - end - - return nil -end - -function utf8_text.truncate_bytes(value, max_bytes) - value = tostring(value or "") - local limit = tonumber(max_bytes) - if limit == nil or limit ~= limit or limit <= 0 then return "" end - if limit == math.huge then limit = #value else limit = math.floor(limit) end - - local index = 1 - local safe_end = 0 - local length = #value - while index <= length and index <= limit do - local size = sequence_length(value, index) - if size == nil then break end - local sequence_end = index + size - 1 - if sequence_end > limit then break end - safe_end = sequence_end - index = sequence_end + 1 - end - - if safe_end == length then return value end - if safe_end == 0 then return "" end - return string.sub(value, 1, safe_end) -end - -return utf8_text diff --git a/metamorph_creative_menu/files/core/wand_blueprint_codec.lua b/metamorph_creative_menu/files/core/wand_blueprint_codec.lua deleted file mode 100644 index 3b09ef7..0000000 --- a/metamorph_creative_menu/files/core/wand_blueprint_codec.lua +++ /dev/null @@ -1,110 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_WAND_BLUEPRINT_CODEC) == "table" then return METAMORPH_CREATIVE_MENU_WAND_BLUEPRINT_CODEC end - -local codec = {} -local CURRENT_HEADER = "MCM_WAND_V2" -local LEGACY_HEADER = "MCM_WAND_V1" - -function codec.escape(value) - return (tostring(value or ""):gsub("([^%w%-%._~])", function(char) - return string.format("%%%02X", string.byte(char)) - end)) -end - -function codec.unescape(value) - return (tostring(value or ""):gsub("%%(%x%x)", function(hex) return string.char(tonumber(hex, 16)) end)) -end - -local function value_encode(value) - if type(value) == "boolean" then return value and "b1" or "b0" end - if type(value) == "number" then return "n" .. string.format("%.17g", value) end - return "s" .. codec.escape(value) -end - -local function value_decode(value) - value = tostring(value or "") - local kind, body = string.sub(value, 1, 1), string.sub(value, 2) - if kind == "b" then - if body == "1" then return true end - if body == "0" then return false end - return nil - end - if kind == "n" then return tonumber(body) end - if kind == "s" then return codec.unescape(body) end - return nil -end - -local function encode_map(lines, prefix, values) - local ids = {} - for id, value in pairs(type(values) == "table" and values or {}) do - if value ~= nil then ids[#ids + 1] = tostring(id) end - end - table.sort(ids) - for _, id in ipairs(ids) do - lines[#lines + 1] = prefix .. codec.escape(id) .. "=" .. value_encode(values[id]) - end -end - -function codec.encode(blueprint) - blueprint = type(blueprint) == "table" and blueprint or {} - local lines = {CURRENT_HEADER} - if blueprint.mana ~= nil then lines[#lines + 1] = "mana=" .. value_encode(blueprint.mana) end - lines[#lines + 1] = "sprite=" .. value_encode(blueprint.sprite_file or "") - encode_map(lines, "stat:", blueprint.stats) - encode_map(lines, "meta:", blueprint.meta) - for _, spell in ipairs(type(blueprint.spells) == "table" and blueprint.spells or {}) do - lines[#lines + 1] = table.concat({ - "spell", codec.escape(spell.action_id or ""), tostring(math.floor(tonumber(spell.slot) or -1)), - spell.permanent == true and "1" or "0", - spell.uses_remaining == nil and "" or tostring(math.floor(tonumber(spell.uses_remaining) or -1)), - spell.frozen == true and "1" or "0", - tostring(math.floor(tonumber(spell.slot_y) or (spell.permanent == true and -1 or 0))), - }, "\t") - end - return table.concat(lines, "\n") -end - -local function decode_spell(line) - local fields = {} - for token in string.gmatch(line .. "\t", "([^\t]*)\t") do fields[#fields + 1] = token end - if fields[1] ~= "spell" or fields[2] == nil or fields[2] == "" then return nil end - local permanent = fields[4] == "1" - return { - action_id=codec.unescape(fields[2]), - slot=tonumber(fields[3]) or -1, - permanent=permanent, - uses_remaining=fields[5] ~= "" and tonumber(fields[5]) or nil, - frozen=fields[6] == "1", - slot_y=fields[7] ~= nil and fields[7] ~= "" and (tonumber(fields[7]) or 0) or (permanent and -1 or 0), - } -end - -function codec.decode(encoded) - encoded = tostring(encoded or "") - local first = string.match(encoded, "^([^\n]*)") - if first ~= CURRENT_HEADER and first ~= LEGACY_HEADER then return nil, "version" end - local version = first == CURRENT_HEADER and 2 or 1 - local blueprint = {version=version, stats={}, meta={}, spells={}} - for line in string.gmatch(encoded .. "\n", "([^\n]*)\n") do - if line ~= first and line ~= "" then - local key, value = string.match(line, "^([^=]+)=(.*)$") - if key == "mana" then - blueprint.mana = value_decode(value) - elseif key == "sprite" then - blueprint.sprite_file = value_decode(value) or "" - elseif key ~= nil and string.sub(key, 1, 5) == "stat:" then - blueprint.stats[codec.unescape(string.sub(key, 6))] = value_decode(value) - elseif key ~= nil and string.sub(key, 1, 5) == "meta:" then - blueprint.meta[codec.unescape(string.sub(key, 6))] = value_decode(value) - else - local spell = decode_spell(line) - if spell ~= nil then blueprint.spells[#blueprint.spells + 1] = spell end - end - end - end - return blueprint, "ok" -end - -function codec.current_version() return 2 end - -METAMORPH_CREATIVE_MENU_WAND_BLUEPRINT_CODEC = codec -return codec diff --git a/metamorph_creative_menu/files/diagnostics/runtime_scanner.lua b/metamorph_creative_menu/files/diagnostics/runtime_scanner.lua index 1c1e8c4..31fd1c4 100644 --- a/metamorph_creative_menu/files/diagnostics/runtime_scanner.lua +++ b/metamorph_creative_menu/files/diagnostics/runtime_scanner.lua @@ -13,7 +13,6 @@ local entity_summary = entity_inspection.summary local variable_value = entity_inspection.variable_string local world_sync_context = runtime_context.world_sync local one_line = logger.one_line -local perk_root_companions = dofile("mods/metamorph_creative_menu/files/features/perks/root_companions.lua") local runtime_errors = logger.runtime_errors() local QA_TRIGGER_KEY_NAME = "Key_z" @@ -39,8 +38,8 @@ local function collect_world_and_runtime(report) local ok_g, factor = pcall(rules.gravity_factor) add(report, "INFO", "world_rules.gravity_factor", "ok=" .. tostring(ok_g) .. " factor=" .. tostring(factor)) end - if type(rules.local_gravity_debug) == "function" then - local ok_lg, dbg = pcall(rules.local_gravity_debug) + if type(rules.local_gravity_state) == "function" then + local ok_lg, dbg = pcall(rules.local_gravity_state) if ok_lg and type(dbg) == "table" then local rows = {} for _, row in ipairs(dbg.rows or {}) do @@ -96,8 +95,8 @@ local function collect_world_and_runtime(report) " last=" .. one_line(GlobalsGetValue("mcm_remote_qa_last_v1", ""))) end - if type(perk_root_companions.ownership_summary) == "function" then - local ok_owned, value = pcall(perk_root_companions.ownership_summary) + if report.perk_service ~= nil and type(report.perk_service.root_companion_state) == "function" then + local ok_owned, value = pcall(report.perk_service.root_companion_state) if ok_owned then add(report, "INFO", "perk.root_companion_ownership", tostring(value or "")) end end if report.perk_service ~= nil and type(report.perk_service.count) == "function" then @@ -119,8 +118,8 @@ local function collect_world_and_runtime(report) if ok then weather_values[#weather_values + 1] = tostring(field.id) .. "=" .. tostring(value) end end add(report, "INFO", "weather.values", sample_list(weather_values, 20)) - if type(report.weather.debug_state) == "function" then - local ok_state, state = pcall(report.weather.debug_state) + if type(report.weather.state_snapshot) == "function" then + local ok_state, state = pcall(report.weather.state_snapshot) if ok_state and type(state) == "table" then add(report, "INFO", "weather.runtime", "rainfall="..tostring(state.rainfall).." rain="..tostring(state.rain).." rain_target="..tostring(state.rain_target).. diff --git a/metamorph_creative_menu/files/diagnostics/scanner.lua b/metamorph_creative_menu/files/diagnostics/scanner.lua index 886d6ec..cadbd01 100644 --- a/metamorph_creative_menu/files/diagnostics/scanner.lua +++ b/metamorph_creative_menu/files/diagnostics/scanner.lua @@ -1,14 +1,6 @@ if type(METAMORPH_CREATIVE_MENU_DIAGNOSTIC_SCANNER) == "table" then return METAMORPH_CREATIVE_MENU_DIAGNOSTIC_SCANNER end local scanner = {} - -local function mcm_text(key, fallback) - if type(GameTextGetTranslatedOrNot) == "function" then - local ok, value = pcall(GameTextGetTranslatedOrNot, key) - if ok and type(value) == "string" and value ~= "" and value ~= key then return value end - end - return fallback -end local logger = dofile("mods/metamorph_creative_menu/files/diagnostics/logger.lua") local entity_inspection = dofile("mods/metamorph_creative_menu/files/diagnostics/entity_inspection.lua") @@ -252,10 +244,10 @@ local function finish(report) local ok, err = append_file(text) if not ok then print("[Metamorph: Creative Menu] diagnostics could not write report: " .. tostring(err)) - GamePrintImportant(mcm_text("$mcm_diag_title", "Metamorph: Creative Menu diagnostics"), mcm_text("$mcm_diag_write_failed", "Could not write diagnostic report") .. ": " .. tostring(err)) + GamePrintImportant("Metamorph: Creative Menu diagnostics", "WRITE FAILED: " .. tostring(err)) else print("[Metamorph: Creative Menu] diagnostics finished: " .. summary .. " -> " .. logger.path()) - GamePrintImportant(mcm_text("$mcm_diag_title", "Metamorph: Creative Menu diagnostics"), mcm_text("$mcm_diag_done", "Done") .. ": " .. summary) + GamePrintImportant("Metamorph: Creative Menu diagnostics", "DONE: " .. summary) end end diff --git a/metamorph_creative_menu/files/diagnostics/service.lua b/metamorph_creative_menu/files/diagnostics/service.lua index d3aba9b..6ad86cf 100644 --- a/metamorph_creative_menu/files/diagnostics/service.lua +++ b/metamorph_creative_menu/files/diagnostics/service.lua @@ -5,14 +5,6 @@ local logger = dofile("mods/metamorph_creative_menu/files/diagnostics/logger.lua local runtime_recorder = dofile("mods/metamorph_creative_menu/files/diagnostics/runtime_recorder.lua") local scanner = dofile("mods/metamorph_creative_menu/files/diagnostics/scanner.lua") -local function mcm_text(key, fallback) - if type(GameTextGetTranslatedOrNot) == "function" then - local ok, value = pcall(GameTextGetTranslatedOrNot, key) - if ok and type(value) == "string" and value ~= "" and value ~= key then return value end - end - return fallback -end - local PERF_SAMPLE_TARGET = 180 local run_counter = 0 local active_report = nil @@ -37,7 +29,7 @@ METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE = logger.capture_error local function start_scan() if active_report ~= nil then - GamePrint(mcm_text("$mcm_diag_already_running", "Metamorph: Creative Menu diagnostics are already running")) + GamePrint("Metamorph: Creative Menu diagnostics already running") return false end @@ -69,7 +61,7 @@ local function start_scan() end scanner.initialize(active_report) - GamePrintImportant(mcm_text("$mcm_diag_title", "Metamorph: Creative Menu diagnostics"), mcm_text("$mcm_diag_started", "Started (Z). Non-destructive scan and frame sampling are running.")) + GamePrintImportant("Metamorph: Creative Menu diagnostics", "STARTED (Z). Non-destructive scan + frame sampling...") return true end diff --git a/metamorph_creative_menu/files/features/companion/ai.lua b/metamorph_creative_menu/files/features/companion/ai.lua index ab4b2c0..f29abf8 100644 --- a/metamorph_creative_menu/files/features/companion/ai.lua +++ b/metamorph_creative_menu/files/features/companion/ai.lua @@ -44,22 +44,9 @@ ComponentSetValue2(controls, "enabled", false) local ew_enabled = false pcall(function() ew_enabled = ModIsEnabled("quant.ew") == true end) -local function ew_locally_owned() - -- EW DES writes ew_gid_lid.value_bool=true on the peer that owns simulation. - -- Host/client role is not authority: a client-created clone can legitimately own - -- its entity and must run AI, while remote replicas must stay passive. - for _, value in ipairs(EntityGetComponentIncludingDisabled(entity, "VariableStorageComponent") or {}) do - local ok_name, name = pcall(ComponentGetValue2, value, "name") - if ok_name and name == "ew_gid_lid" then - local ok_owned, owned = pcall(ComponentGetValue2, value, "value_bool") - return ok_owned and owned == true - end - end - -- Before DES attaches its gid storage, a locally spawned entity is the only safe - -- one to simulate. Explicit EW replicas never receive local AI authority. - return not EntityHasTag(entity, "ew_replicated") -end -if ew_enabled and not ew_locally_owned() then +if ew_enabled and not GameHasFlagRun("ew_flag_this_is_host") then + -- Synced copies are visual/physical replicas. Only EW's authoritative host is + -- allowed to make AI decisions or cast a second copy of a projectile. for _, field in ipairs({ "mButtonDownLeft", "mButtonDownRight", "mButtonDownUp", "mButtonDownDown", "mButtonDownFly", "mButtonDownFire", "mButtonDownFire2", @@ -120,15 +107,8 @@ local x, y = EntityGetTransform(entity) if x == nil then return end -- Target scans are staggered by entity id to keep several companions inexpensive. --- An idle target=0 used to satisfy `not is_hostile(target)` every frame and bypass the --- staggering entirely. Clear an invalid target immediately, but only search on schedule. local frame = GameGetFrameNum() -local target_valid = is_hostile(target) -if not target_valid and target ~= 0 then - target = 0 - if target_store ~= nil then ComponentSetValue2(target_store, "value_int", 0) end -end -if frame % 10 == entity % 10 then +if not is_hostile(target) or frame % 10 == entity % 10 then target = 0 local best = 640 * 640 for _, candidate in ipairs(EntityGetInRadiusWithTag(x, y, 640, "mortal") or {}) do diff --git a/metamorph_creative_menu/files/features/companion/player_avatar.lua b/metamorph_creative_menu/files/features/companion/player_avatar.lua index 8f6c7a3..9c6a883 100644 --- a/metamorph_creative_menu/files/features/companion/player_avatar.lua +++ b/metamorph_creative_menu/files/features/companion/player_avatar.lua @@ -2,12 +2,11 @@ if type(METAMORPH_CREATIVE_MENU_PLAYER_AVATAR) == "table" then return METAMORPH_ local player_avatar = {} local ew_runtime = dofile("mods/metamorph_creative_menu/files/integrations/ew/runtime.lua") -local CLONE_PATH = "data/entities/misc/player_drone_clone.xml" +local companion_request = dofile("mods/metamorph_creative_menu/files/integrations/ew/companion_request.lua") +local CLONE_PATH = "mods/metamorph_creative_menu/files/features/companion/player_clone.xml" local WAND_FALLBACK = "data/entities/items/wand_level_01.xml" local companion_health = dofile("mods/metamorph_creative_menu/files/features/companion/health.lua") -local ew_world_entities = dofile("mods/metamorph_creative_menu/files/integrations/ew/world_entities.lua") local pending_health_guards = {} -local pending_network_activation = {} local function valid(entity) return entity ~= nil and entity ~= 0 and EntityGetIsAlive(entity) @@ -70,7 +69,7 @@ end local function copy_visuals(player, clone) local body_source = enabled_sprite(player, "character") - local body_target = enabled_sprite(clone, "character") or enabled_sprite(clone) + local body_target = enabled_sprite(clone, "character") for _, field in ipairs({"image_file", "offset_x", "offset_y", "alpha", "special_scale_x", "special_scale_y", "has_special_scale"}) do copy_scalar(body_source, body_target, field) end @@ -117,46 +116,6 @@ local function human_blueprint(player, x, y) return source, true end -local function ensure_companion_structure(clone) - if not valid(clone) then return false end - for _, tag in ipairs({"enemy", "metamorph_creative_menu_player_clone", - "metamorph_creative_menu_companion", "homing_target"}) do - if not EntityHasTag(clone, tag) then EntityAddTag(clone, tag) end - end - local controls = first_component(clone, "ControlsComponent") - if controls == nil then - controls = EntityAddComponent2(clone, "ControlsComponent", { enabled=false }) - else - pcall(ComponentSetValue2, controls, "enabled", false) - end - local inventory = first_component(clone, "Inventory2Component") - if inventory == nil then - inventory = EntityAddComponent2(clone, "Inventory2Component", { - quick_inventory_slots=8, full_inventory_slots_x=16, full_inventory_slots_y=1, - }) - end - if first_component(clone, "ItemPickUpperComponent") == nil then - EntityAddComponent2(clone, "ItemPickUpperComponent", { - is_in_npc=true, is_immune_to_kicks=true, drop_items_on_death=true, - }) - end - if named_child(clone, "inventory_quick") == 0 then - local quick = EntityCreateNew("inventory_quick") or 0 - if quick ~= 0 then EntityAddChild(clone, quick) end - end - for _, kind in ipairs({"AnimalAIComponent", "PathFindingComponent"}) do - for _, c in ipairs(EntityGetComponentIncludingDisabled(clone, kind) or {}) do - pcall(EntitySetComponentIsEnabled, clone, c, false) - end - end - if first_component(clone, "VariableStorageComponent", "mcm_companion_next_fire") == nil then - EntityAddComponent2(clone, "VariableStorageComponent", { - _tags="mcm_companion_next_fire", name="mcm_companion_next_fire", value_int=0, - }) - end - return inventory ~= nil -end - local function active_wand(player) local inventory = first_component(player, "Inventory2Component") if inventory == nil then return 0 end @@ -313,35 +272,9 @@ local function equip_wand(player, clone, x, y) return false, actions, "inventory_attach" end -local function add_local_runtime(clone) - if not valid(clone) then return false end - -- These scripts are deliberately added only after native DES has captured the initial - -- serialized entity. A stock peer therefore never needs any mods/metamorph... path. - local ai_present = false - for _, component in ipairs(EntityGetComponentIncludingDisabled(clone, "LuaComponent") or {}) do - if ComponentHasTag(component, "metamorph_creative_menu_companion_ai") then ai_present = true; break end - end - if not ai_present then - EntityAddComponent2(clone, "LuaComponent", { - _tags="metamorph_creative_menu_companion_ai,ew_remove_on_send", - script_source_file="mods/metamorph_creative_menu/files/features/companion/ai.lua", - execute_every_n_frame=1, - }) - end - EntityAddComponent2(clone, "LuaComponent", { - _tags="mcm_companion_spawn_guard,ew_remove_on_send", - script_source_file="mods/metamorph_creative_menu/files/features/companion/spawn_guard.lua", - execute_on_added=true, - execute_every_n_frame=1, - remove_after_executed=false, - }) - return true -end - local function build_clone(player, x, y) local clone = EntityLoad(CLONE_PATH, x, y) or 0 if clone == 0 then return 0, "load" end - ensure_companion_structure(clone) if type(GenomeSetHerdId) == "function" then pcall(GenomeSetHerdId, clone, "player") end local genome = first_component(clone, "GenomeDataComponent") if genome ~= nil then pcall(ComponentSetValue2, genome, "herd_id", "player") end @@ -371,17 +304,18 @@ local function build_clone(player, x, y) value_float=final_max, value_int=GameGetFrameNum(), value_bool=false, }) end - -- Main-context health repair is safe before network registration because it contains - -- no file-path dependency. MCM Lua controllers are delayed until EW has assigned a - -- DES identity, so the initial entity serialization is fully stock-peer compatible. + EntityAddComponent2(clone, "LuaComponent", { + _tags="mcm_companion_spawn_guard", + script_source_file="mods/metamorph_creative_menu/files/features/companion/spawn_guard.lua", + execute_on_added=true, + execute_every_n_frame=1, + remove_after_executed=false, + }) + -- Main-context clones also get a lifecycle-level guard. This is intentionally the + -- same companion_health.repair() implementation as the per-entity component above, + -- not a second health algorithm. It closes the real-engine case where a dynamically + -- added LuaComponent is scheduled too late to beat base_humanoid's 1/1 clamp. pending_health_guards[clone] = true - if ew_runtime.enabled() then - local prepared = ew_world_entities.track(clone) - if prepared == true then pending_network_activation[clone] = true - else add_local_runtime(clone) end - else - add_local_runtime(clone) - end if temporary and valid(source) then EntityKill(source) end return clone, "ok" end @@ -411,22 +345,13 @@ function player_avatar.update() if finished then pending_health_guards[clone] = nil end end end - for clone in pairs(pending_network_activation) do - if not valid(clone) then - pending_network_activation[clone] = nil - elseif ew_world_entities.is_tracked(clone) then - add_local_runtime(clone) - pending_network_activation[clone] = nil - end - end end function player_avatar.request_spawn(player, offset_x, offset_y) - -- Distributed Entity Sync is authoritative on whichever peer creates the entity. - -- Never require an MCM RPC handler on the host: the other player may have stock EW - -- only. The locally-created serialized clone is enough for native DES replication. - local clone = player_avatar.spawn_visual_copy(player, offset_x, offset_y) - return clone ~= 0, ew_runtime.enabled() and "local_des" or "local" + if ew_runtime.mode() ~= "client" then + return player_avatar.spawn_visual_copy(player, offset_x, offset_y) ~= 0, "local" + end + return companion_request.enqueue(offset_x, offset_y) end METAMORPH_CREATIVE_MENU_PLAYER_AVATAR = player_avatar diff --git a/metamorph_creative_menu/files/features/companion/player_clone.xml b/metamorph_creative_menu/files/features/companion/player_clone.xml index f32c498..ef52175 100644 --- a/metamorph_creative_menu/files/features/companion/player_clone.xml +++ b/metamorph_creative_menu/files/features/companion/player_clone.xml @@ -4,6 +4,7 @@ + @@ -45,7 +46,9 @@ - - + + diff --git a/metamorph_creative_menu/files/features/creatures/kolmi_combat_verify.lua b/metamorph_creative_menu/files/features/creatures/kolmi_combat_verify.lua deleted file mode 100644 index c8b9dda..0000000 --- a/metamorph_creative_menu/files/features/creatures/kolmi_combat_verify.lua +++ /dev/null @@ -1,120 +0,0 @@ --- Verify that the authored Kolmisilma combat coroutine entered the normal attack flow. --- This is a startup watchdog only; it never replaces attacks, phases, or death behavior. --- If the authored component failed to execute after activation, restart the same vanilla --- script once in a fresh component VM. -local controller = GetUpdatedEntityID() -local UPDATE = "data/entities/animals/boss_centipede/boss_centipede_update.lua" -local STATUS = "mcm25_kolmi_combat_status_v1" -local START_VAR = "mcm_kolmi_verify_start_frame_v1" -local RESTART_VAR = "mcm_kolmi_verify_restart_frame_v1" -local GRACE_FRAMES = 8 -local RESTART_GRACE_FRAMES = 12 - -local function storage_value_int(entity, name) - for _, storage in ipairs(EntityGetComponentIncludingDisabled(entity, "VariableStorageComponent") or {}) do - if ComponentGetValue2(storage, "name") == name then - return tonumber(ComponentGetValue2(storage, "value_int")) or 0, storage - end - end - return 0, nil -end - -local function set_storage_int(entity, name, value) - local _, storage = storage_value_int(entity, name) - if storage ~= nil then - pcall(ComponentSetValue2, storage, "value_int", tonumber(value) or 0) - return storage - end - if type(EntityAddComponent2) ~= "function" then return nil end - local ok, created = pcall(EntityAddComponent2, entity, "VariableStorageComponent", { - name = name, - value_int = tonumber(value) or 0, - }) - return ok and created or nil -end - -local function controller_boss() - local value = storage_value_int(controller, "mcm_kolmi_boss_id_v3") - return tonumber(value) or 0 -end - -local function initialized(boss) - for _, storage in ipairs(EntityGetComponentIncludingDisabled(boss, "VariableStorageComponent") or {}) do - if ComponentGetValue2(storage, "name") == "initialized" then - return ComponentGetValue2(storage, "value_bool") == true - end - end - return false -end - -local function update_components(boss) - local out = {} - for _, lua in ipairs(EntityGetComponentIncludingDisabled(boss, "LuaComponent") or {}) do - local ok, source = pcall(ComponentGetValue2, lua, "script_source_file") - if ok and tostring(source or "") == UPDATE then out[#out + 1] = lua end - end - return out -end - -local function publish(value) - if type(GlobalsSetValue) == "function" then pcall(GlobalsSetValue, STATUS, tostring(value)) end -end - -local function finish(value) - publish(value) - if type(EntityKill) == "function" then pcall(EntityKill, controller) end -end - -local boss = controller_boss() -if boss == 0 or EntityGetIsAlive(boss) ~= true then - finish("failed:missing_boss") - return -end - -local frame = tonumber(GameGetFrameNum()) or 0 -local start = storage_value_int(controller, START_VAR) -if start == 0 then - start = frame - set_storage_int(controller, START_VAR, start) -end - -if initialized(boss) then - finish("initialized_stock") - return -end - -if frame - start < GRACE_FRAMES then return end - -local restart_frame = storage_value_int(controller, RESTART_VAR) -if restart_frame == 0 then - -- The authored component was enabled by stock sampo_pickup.lua but never reached init_boss(). - -- Recreate only that authored VM. Do not copy or emulate any phase code. - for _, lua in ipairs(update_components(boss)) do pcall(EntityRemoveComponent, boss, lua) end - - local values = { - _tags = "disabled_at_start", - script_source_file = UPDATE, - vm_type = "ONE_PER_COMPONENT_INSTANCE", - enable_coroutines = true, - execute_on_added = true, - execute_every_n_frame = -1, - execute_times = 1, - } - local ok, fresh = pcall(EntityAddComponent2, boss, "LuaComponent", values) - if not ok or fresh == nil or fresh == 0 then - finish("restart_failed:add_component") - return - end - pcall(EntitySetComponentIsEnabled, boss, fresh, true) - set_storage_int(controller, RESTART_VAR, frame) - publish("restarted_component") - return -end - -if initialized(boss) then - finish("initialized_after_restart") - return -end -if frame - restart_frame >= RESTART_GRACE_FRAMES then - finish("restart_failed:not_initialized") -end diff --git a/metamorph_creative_menu/files/features/creatures/kolmi_creative_bootstrap.lua b/metamorph_creative_menu/files/features/creatures/kolmi_creative_bootstrap.lua deleted file mode 100644 index bc0ce4a..0000000 --- a/metamorph_creative_menu/files/features/creatures/kolmi_creative_bootstrap.lua +++ /dev/null @@ -1,99 +0,0 @@ --- Wait until the creative Kolmisilma is owned by this DES peer, then hand encounter --- activation to a separate one-shot Lua VM. Vanilla starts Kolmisilma from Sampo's --- item-pickup component, so activation from the boss update VM can leave the authored --- coroutine half-started. -local entity = GetUpdatedEntityID() -local component = GetUpdatedComponentID() -local REF_ID_VAR = "mcm_creative_kolmi_reference_id_v3" -local START_VAR = "mcm_creative_kolmi_bootstrap_frame_v3" -local CONTROLLER = "mods/metamorph_creative_menu/files/features/creatures/kolmi_encounter_start.lua" -local ew_runtime = dofile("mods/metamorph_creative_menu/files/integrations/ew/runtime.lua") - -local function get_var(name) - for _, storage in ipairs(EntityGetComponentIncludingDisabled(entity, "VariableStorageComponent") or {}) do - if ComponentGetValue2(storage, "name") == name then return storage end - end - return nil -end - -local function get_or_create_start_frame() - local storage = get_var(START_VAR) - if storage ~= nil then return tonumber(ComponentGetValue2(storage, "value_int")) or GameGetFrameNum() end - storage = EntityAddComponent2(entity, "VariableStorageComponent", { - _tags = "ew_remove_on_send", - name = START_VAR, - value_int = GameGetFrameNum(), - }) - return storage ~= nil and (tonumber(ComponentGetValue2(storage, "value_int")) or GameGetFrameNum()) or GameGetFrameNum() -end - -local function gid_owner_state() - local storage = get_var("ew_gid_lid") - if storage == nil then return false, false end - return true, ComponentGetValue2(storage, "value_bool") == true -end - -local function ew_enabled() - return type(ew_runtime) == "table" and type(ew_runtime.enabled) == "function" and ew_runtime.enabled() == true -end - -local function reference_id() - local storage = get_var(REF_ID_VAR) - return storage ~= nil and (tonumber(ComponentGetValue2(storage, "value_int")) or 0) or 0 -end - -local function add_controller_var(controller, name, value) - return EntityAddComponent2(controller, "VariableStorageComponent", { - name = name, - value_int = tonumber(value) or 0, - }) -end - -local function spawn_controller(reference) - if type(EntityCreateNew) ~= "function" or type(EntityAddComponent2) ~= "function" then return 0 end - local x, y = EntityGetTransform(entity) - local controller = EntityCreateNew("mcm_creative_kolmi_encounter_controller") or 0 - controller = tonumber(controller) or 0 - if controller == 0 then return 0 end - EntitySetTransform(controller, tonumber(x) or 0, (tonumber(y) or 0) + 80) - if type(EntityAddTag) == "function" then EntityAddTag(controller, "ew_no_enemy_sync") end - add_controller_var(controller, "mcm_kolmi_boss_id_v3", entity) - add_controller_var(controller, "mcm_kolmi_reference_id_v3", reference) - EntityAddComponent2(controller, "LuaComponent", { - script_source_file = CONTROLLER, - -- Do not execute synchronously from the boss bootstrap call stack. Vanilla Sampo - -- pickup is an external engine event; yielding to the next world frame ensures the - -- boss' enable_coroutines LuaComponent is enabled from a separate entity update. - execute_on_added = false, - execute_every_n_frame = 1, - remove_after_executed = true, - }) - return controller -end - -if not EntityGetIsAlive(entity) then return end -local started = get_or_create_start_frame() -local has_gid, is_owner = gid_owner_state() - -if ew_enabled() then - -- Never start a global final-boss encounter from a remote replica. The creative root - -- is born locally, so this normally resolves in one DES update; if authority moved, - -- the new owner (with MCM) can recreate/start its own controller later. - if not has_gid or not is_owner then return end -else - -- Singleplayer has no ew_gid_lid. Yield one frame so all authored execute_on_added - -- initialization from EntityLoad has completed before the external pickup transition. - if GameGetFrameNum() <= started then return end -end - -local reference = reference_id() -if reference == 0 or not EntityGetIsAlive(reference) then - -- The spawn path creates the authored reference first. Failing closed here is safer - -- than starting a final-boss coroutine without the anchor it requires for phases/death. - return -end - -local controller = spawn_controller(reference) -if controller == 0 then return end -if type(EntityAddTag) == "function" then EntityAddTag(entity, "mcm_creative_kolmi_activation_queued_v3") end -if component ~= nil and component ~= 0 then EntityRemoveComponent(entity, component) end diff --git a/metamorph_creative_menu/files/features/creatures/kolmi_encounter_start.lua b/metamorph_creative_menu/files/features/creatures/kolmi_encounter_start.lua deleted file mode 100644 index af9143f..0000000 --- a/metamorph_creative_menu/files/features/creatures/kolmi_encounter_start.lua +++ /dev/null @@ -1,110 +0,0 @@ --- Run the real vanilla + Entangled Worlds Sampo pickup entrypoint from an external VM. --- This is intentionally not a reimplementation of Kolmi phases. The stock script owns --- component activation, boss flags, protection removal and every subsequent combat/death --- coroutine transition. -local controller = GetUpdatedEntityID() -local SAMPO_PICKUP = "data/entities/animals/boss_centipede/sampo_pickup.lua" -local STATUS = "mcm25_kolmi_encounter_status_v1" -local VERIFY = "mods/metamorph_creative_menu/files/features/creatures/kolmi_combat_verify.lua" - -local function controller_var(name) - for _, storage in ipairs(EntityGetComponentIncludingDisabled(controller, "VariableStorageComponent") or {}) do - if ComponentGetValue2(storage, "name") == name then - return tonumber(ComponentGetValue2(storage, "value_int")) or 0 - end - end - return 0 -end - -local function alive(entity) - return entity ~= 0 and EntityGetIsAlive(entity) == true -end - -local boss = controller_var("mcm_kolmi_boss_id_v3") -local reference = controller_var("mcm_kolmi_reference_id_v3") -if not alive(boss) or not alive(reference) then - if type(GlobalsSetValue) == "function" then GlobalsSetValue(STATUS, "failed:missing_boss_or_reference") end - EntityKill(controller) - return -end - -local bx, by = EntityGetTransform(boss) -local old_get_with_tag = EntityGetWithTag - -local function nearest_player() - local players = old_get_with_tag("player_unit") or {} - local best, best_d = nil, nil - for _, player in ipairs(players) do - if EntityGetIsAlive(player) then - local px, py = EntityGetTransform(player) - local dx, dy = (tonumber(px) or 0) - (tonumber(bx) or 0), (tonumber(py) or 0) - (tonumber(by) or 0) - local d = dx * dx + dy * dy - if best_d == nil or d < best_d then best, best_d = player, d end - end - end - return best -end - -local player = nearest_player() -if player == nil then - if type(GlobalsSetValue) == "function" then GlobalsSetValue(STATUS, "failed:no_player") end - EntityKill(controller) - return -end - --- During the one synchronous vanilla item_pickup call, provide the exact encounter roots --- it would see in a real boss arena. This prevents an unrelated natural Sampo/Kolmi or a --- second world's reference point from being activated accidentally. The boss' own update --- VM is untouched and receives the engine EntityGetWithTag afterwards. -EntityGetWithTag = function(tag) - if tag == "sampo_or_boss" then return { boss } end - if tag == "reference" then return { reference } end - if tag == "player_unit" then return { player } end - return old_get_with_tag(tag) -end - -local ok, failure = pcall(function() - dofile(SAMPO_PICKUP) -- includes EW 1.6.3's ModLuaFileAppend wrapper in multiplayer - if type(item_pickup) ~= "function" then error("sampo item_pickup unavailable") end - -- The controller sits at the same +80 Y offset as the authored Sampo. The function - -- uses this entity only for pickup position; it does not require an ItemComponent. - item_pickup(controller, player, "$item_mcguffin") -end) - -EntityGetWithTag = old_get_with_tag - -if ok then - if type(EntityAddTag) == "function" then EntityAddTag(boss, "mcm_creative_kolmi_encounter_started_v3") end - if type(GlobalsSetValue) == "function" then GlobalsSetValue(STATUS, "started_via_stock_sampo_pickup") end - -- Keep the external controller alive only long enough to verify that the authored - -- enable_coroutines component really entered init_boss(). The verifier never owns - -- combat; it either observes success or restarts the same stock VM once. - if type(EntityAddComponent2) == "function" then - EntityAddComponent2(controller, "VariableStorageComponent", { - name = "mcm_kolmi_verify_start_frame_v1", - value_int = GameGetFrameNum(), - }) - EntityAddComponent2(controller, "LuaComponent", { - script_source_file = VERIFY, - execute_on_added = false, - execute_every_n_frame = 1, - remove_after_executed = false, - }) - return - end - EntityKill(controller) -else - if type(GlobalsSetValue) == "function" then GlobalsSetValue(STATUS, "failed:" .. tostring(failure)) end - if type(GamePrintImportant) == "function" then - local title = "$mcm_kolmi_encounter_failed" - local details = "$mcm_kolmi_encounter_failed_desc" - if type(GameTextGetTranslatedOrNot) == "function" then - local ok_title, translated_title = pcall(GameTextGetTranslatedOrNot, title) - if ok_title and translated_title ~= title then title = translated_title end - local ok_details, translated_details = pcall(GameTextGetTranslatedOrNot, details) - if ok_details and translated_details ~= details then details = translated_details end - end - GamePrintImportant(title, details .. ": " .. tostring(failure)) - end - EntityKill(controller) -end diff --git a/metamorph_creative_menu/files/features/creatures/menu_visibility.lua b/metamorph_creative_menu/files/features/creatures/menu_visibility.lua index 12ea94e..020c338 100644 --- a/metamorph_creative_menu/files/features/creatures/menu_visibility.lua +++ b/metamorph_creative_menu/files/features/creatures/menu_visibility.lua @@ -17,7 +17,6 @@ local HIDDEN_EXACT_PATHS = { ["data/entities/animals/drone.xml"] = true, ["data/entities/animals/_test_walk.xml"] = true, ["data/entities/animals/mimic_physics.xml"] = true, - ["data/entities/animals/ending_placeholder/boss_dragon_endcrystal.xml"] = true, } function menu_visibility.visible(path) diff --git a/metamorph_creative_menu/files/features/creatures/service.lua b/metamorph_creative_menu/files/features/creatures/service.lua index 769666d..dc3c307 100644 --- a/metamorph_creative_menu/files/features/creatures/service.lua +++ b/metamorph_creative_menu/files/features/creatures/service.lua @@ -2,10 +2,7 @@ local metadata = dofile("mods/metamorph_creative_menu/files/features/creatures/m local classification = dofile("mods/metamorph_creative_menu/files/features/creatures/classification.lua") local compatibility = dofile("mods/metamorph_creative_menu/files/features/creatures/compatibility.lua") local transform_routing = dofile("mods/metamorph_creative_menu/files/features/creatures/transform_routing.lua") -local creature_diagnostics = dofile("mods/metamorph_creative_menu/files/features/creatures/diagnostics.lua") local catalog_builder = dofile("mods/metamorph_creative_menu/files/features/creatures/catalog_builder.lua") -local ew_world_entities = dofile("mods/metamorph_creative_menu/files/integrations/ew/world_entities.lua") -local spawn_compat = dofile("mods/metamorph_creative_menu/files/features/creatures/spawn_compat.lua") local existing_creature_service = METAMORPH_CREATIVE_MENU_CREATURE_SERVICE or METAMORPH_CREATIVE_MENU_CREATURE_API if type(existing_creature_service) == "table" then return existing_creature_service end @@ -16,11 +13,6 @@ local unsafe_reason = classification.unsafe_reason local internal_helper_path = classification.internal_helper_path local catalog_creature = classification.catalog_creature -local function valid_coordinate(value) - value = tonumber(value) - return value ~= nil and value == value and value > -1000000000 and value < 1000000000 -end - function creature_service.transform_plan(path) path = tostring(path or "") if path == "" or not ModDoesFileExist(path) then return nil end @@ -63,13 +55,6 @@ function creature_service.collect() return catalog_builder.collect() end function creature_service.warmup_step(budget) return catalog_builder.warmup_step(budget) end function creature_service.catalog_version() return catalog_builder.catalog_version() end -function creature_service.collect_diagnostics() - return creature_diagnostics.collect(creature_service) -end - -function creature_service.diagnostic_info_for_path(path) - return creature_diagnostics.info(creature_service, path) -end function creature_service.collect_prewarm_candidates() -- Prewarm is intentionally broader than the lazy UI catalogue. Exact polymorph XML @@ -97,55 +82,14 @@ function creature_service.collect_all_candidates() end function creature_service.spawn_at(entity_path, x, y) - if type(entity_path) ~= "string" or entity_path == "" or not ModDoesFileExist(entity_path) - or not valid_coordinate(x) or not valid_coordinate(y) - then - return 0, "invalid" - end - local spawn_context = nil - if type(spawn_compat) == "table" and type(spawn_compat.before_spawn) == "function" then - local ok_before, context = pcall(spawn_compat.before_spawn, entity_path, tonumber(x), tonumber(y)) - if ok_before and type(context) == "table" then - spawn_context = context - if context.kind == "reuse" and tonumber(context.entity) ~= nil and tonumber(context.entity) ~= 0 then - -- A remote peer already owns the authored final-boss root at this exact - -- encounter. Never manufacture a second Kolmisilma on top of it. - return tonumber(context.entity), "existing_kolmi" - end - elseif not ok_before and type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "creature.spawn_preflight", tostring(context)) - end - end - - local entity = EntityLoad(entity_path, tonumber(x), tonumber(y)) or 0 - if entity == 0 then return 0, "load" end - -- A few authored encounter roots are not valid combatants when loaded in isolation. - -- Apply only path-specific creative-spawn setup before EW sees the entity, so native - -- DES captures the already-valid gameplay state and can preserve it across authority. - if type(spawn_compat) == "table" and type(spawn_compat.after_spawn) == "function" then - local ok_compat, compat_ok, compat_reason = pcall(spawn_compat.after_spawn, entity_path, entity, tonumber(x), tonumber(y), spawn_context) - if not ok_compat or compat_ok ~= true then - if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "creature.spawn_compat", - ok_compat and tostring(compat_reason or "compat_failed") or tostring(compat_ok)) - end - end - end - -- Let current EW native DES own the entire lifecycle. Ordinary enemies are discovered - -- automatically; unusual creature-like entities receive only EW's documented ew_synced - -- opt-in tag. MCM never allocates a second GID or installs a competing death callback. - local call_ok, tracked, track_reason = pcall(ew_world_entities.track, entity, {creative_creature=true}) - if (not call_ok or tracked ~= true) and type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "creature.ew_track", - call_ok and tostring(track_reason or "track_failed") or tostring(tracked)) - end - return entity, "spawned" + if type(entity_path) ~= "string" or entity_path == "" or not ModDoesFileExist(entity_path) then return 0 end + return EntityLoad(entity_path, x or 0, y or 0) or 0 end function creature_service.spawn_near_player(player_entity, entity_path, offset_x, offset_y) - if player_entity == nil or player_entity == 0 or not EntityGetIsAlive(player_entity) then return 0, "invalid" end + if player_entity == nil or player_entity == 0 or not EntityGetIsAlive(player_entity) then return 0 end local player_x, player_y = EntityGetTransform(player_entity) - if player_x == nil or player_y == nil then return 0, "position" end + if player_x == nil or player_y == nil then return 0 end return creature_service.spawn_at(entity_path, player_x + (offset_x or 32), player_y + (offset_y or -4)) end diff --git a/metamorph_creative_menu/files/features/creatures/spawn_compat.lua b/metamorph_creative_menu/files/features/creatures/spawn_compat.lua deleted file mode 100644 index 50c1c02..0000000 --- a/metamorph_creative_menu/files/features/creatures/spawn_compat.lua +++ /dev/null @@ -1,261 +0,0 @@ -local spawn_compat = {} - -local KOLMI = "data/entities/animals/boss_centipede/boss_centipede.xml" -local REF_PATH = "data/entities/animals/boss_centipede/reference_point.xml" -local CREATIVE_REF_VAR = "mcm_creative_kolmi_reference_v3" -local CREATIVE_REF_ID_VAR = "mcm_creative_kolmi_reference_id_v3" -local CREATIVE_MARKER = "mcm_creative_kolmi_v3" -local CREATIVE_REF_TAG = "mcm_creative_kolmi_reference_v3" -local BOOTSTRAP = "mods/metamorph_creative_menu/files/features/creatures/kolmi_creative_bootstrap.lua" -local CONTROLLER = "mods/metamorph_creative_menu/files/features/creatures/kolmi_encounter_start.lua" - --- The final-boss arena already contains an authored Kolmisilma and reference point. --- Reuse or retire that root before creating a creative replacement so the encounter never --- starts with two nearly coincident boss authorities. -local ARENA_BOSS_RADIUS_SQ = 192 * 192 -local ARENA_REFERENCE_RADIUS_SQ = 768 * 768 -local REPLACED_COUNT = "mcm30_kolmi_existing_arena_root_replaced_v1" -local REUSED_REMOTE_COUNT = "mcm30_kolmi_existing_remote_root_reused_v1" -local REUSED_REFERENCE_COUNT = "mcm30_kolmi_existing_reference_reused_v1" - -local function bump(key) - if type(GlobalsGetValue) ~= "function" or type(GlobalsSetValue) ~= "function" then return end - local ok, value = pcall(GlobalsGetValue, key, "0") - value = ok and (tonumber(value) or 0) or 0 - pcall(GlobalsSetValue, key, tostring(value + 1)) -end - -local function alive(entity) - entity = tonumber(entity) or 0 - return entity ~= 0 and type(EntityGetIsAlive) == "function" and EntityGetIsAlive(entity) == true -end - -local function has_tag(entity, tag) - if type(EntityHasTag) ~= "function" then return false end - local ok, value = pcall(EntityHasTag, entity, tag) - return ok and value == true -end - -local function position(entity) - if type(EntityGetTransform) ~= "function" then return 0, 0 end - local ok, x, y = pcall(EntityGetTransform, entity) - if not ok then return 0, 0 end - return tonumber(x) or 0, tonumber(y) or 0 -end - -local function distance_sq(entity, x, y) - local ex, ey = position(entity) - local dx, dy = ex - (tonumber(x) or 0), ey - (tonumber(y) or 0) - return dx * dx + dy * dy -end - -local function find_named_var(entity, name) - if type(EntityGetComponentIncludingDisabled) ~= "function" then return nil end - for _, component in ipairs(EntityGetComponentIncludingDisabled(entity, "VariableStorageComponent") or {}) do - local ok_name, current = pcall(ComponentGetValue2, component, "name") - if ok_name and tostring(current or "") == name then return component end - end - return nil -end - -local function gid_state(entity) - local storage = find_named_var(entity, "ew_gid_lid") - if storage == nil or type(ComponentGetValue2) ~= "function" then return nil, nil end - local ok_gid, gid = pcall(ComponentGetValue2, storage, "value_string") - local ok_owner, owner = pcall(ComponentGetValue2, storage, "value_bool") - gid = ok_gid and tostring(gid or "") or "" - if gid == "" then gid = nil end - return gid, ok_owner and owner == true -end - -local function add_var(entity, name, fields, tags) - local existing = find_named_var(entity, name) - if existing ~= nil then - for key, value in pairs(fields or {}) do pcall(ComponentSetValue2, existing, key, value) end - if type(ComponentAddTag) == "function" then - for tag in string.gmatch(tostring(tags or ""), "[^,]+") do pcall(ComponentAddTag, existing, tag) end - end - return existing - end - if type(EntityAddComponent2) ~= "function" then return nil end - local values = { name = name } - for key, value in pairs(fields or {}) do values[key] = value end - if tags ~= nil and tags ~= "" then values._tags = tags end - local ok, component = pcall(EntityAddComponent2, entity, "VariableStorageComponent", values) - return ok and component or nil -end - -local function add_synced_reference(entity, x, y) - local value = string.format("%.17g,%.17g", tonumber(x) or 0, tonumber(y) or 0) - return add_var(entity, CREATIVE_REF_VAR, { value_string = value }, "ew_synced_var") -end - -local function remember_reference_id(entity, reference) - return add_var(entity, CREATIVE_REF_ID_VAR, { value_int = tonumber(reference) or 0 }, "ew_remove_on_send") -end - -local function nearest_tagged(tag, x, y, max_distance_sq, predicate) - if type(EntityGetWithTag) ~= "function" then return 0 end - local ok, entities = pcall(EntityGetWithTag, tag) - if not ok or type(entities) ~= "table" then return 0 end - local best, best_d = 0, max_distance_sq - for _, raw in ipairs(entities) do - local entity = tonumber(raw) or 0 - if alive(entity) and (predicate == nil or predicate(entity)) then - local d = distance_sq(entity, x, y) - if d <= best_d then best, best_d = entity, d end - end - end - return best -end - -local function nearest_reference(x, y) - return nearest_tagged("reference", x, y, ARENA_REFERENCE_RADIUS_SQ) -end - -local function existing_authored_kolmi(x, y) - return nearest_tagged("boss_centipede", x, y, ARENA_BOSS_RADIUS_SQ, function(entity) - -- Never confuse a player form, a replay corpse or a Kolmi already spawned by MCM - -- with the authored arena root we are trying to deduplicate. - return not has_tag(entity, "player_unit") - and not has_tag(entity, "polymorphed_player") - and not has_tag(entity, "ew_no_enemy_sync") - and not has_tag(entity, CREATIVE_MARKER) - end) -end - -local function quiet_retire_existing(entity) - if not alive(entity) then return true end - - -- MCM's menu VM has no ewext. Hand the exact GID to the EW module, which keeps the - -- body alive until native DES has actually removed its record. A queued notification - -- is not an acknowledgement, and EntityKill alone leaves global authority behind. - local gid, owner = gid_state(entity) - if gid ~= nil and owner == false then - return false, "remote_authority" - end - if gid ~= nil then - if type(GlobalsGetValue) ~= "function" or type(GlobalsSetValue) ~= "function" - or GlobalsGetValue("mcm31_kolmi_lifecycle_ready_v1", "") ~= "1" - then return false, "des_retire_unavailable" end - local ok = pcall(GlobalsSetValue, "mcm31_kolmi_retire_request_v1_" .. tostring(entity), gid) - if not ok then return false, "des_retire_failed" end - return true, "retire_queued" - end - - if type(EntityAddTag) == "function" then pcall(EntityAddTag, entity, "mcm30_replaced_authored_kolmi_v1") end - - if type(EntityGetComponentIncludingDisabled) == "function" then - for _, lua in ipairs(EntityGetComponentIncludingDisabled(entity, "LuaComponent") or {}) do - if type(ComponentSetValue2) == "function" then - pcall(ComponentSetValue2, lua, "script_death", "") - pcall(ComponentSetValue2, lua, "script_damage_received", "") - pcall(ComponentSetValue2, lua, "script_damage_about_to_be_received", "") - end - if type(EntitySetComponentIsEnabled) == "function" then - pcall(EntitySetComponentIsEnabled, entity, lua, false) - end - end - for _, chest in ipairs(EntityGetComponentIncludingDisabled(entity, "ItemChestComponent") or {}) do - if type(EntityRemoveComponent) == "function" then pcall(EntityRemoveComponent, entity, chest) end - end - end - if type(EntityGetFirstComponentIncludingDisabled) == "function" and type(ComponentSetValue2) == "function" then - local damage = EntityGetFirstComponentIncludingDisabled(entity, "DamageModelComponent") - if damage ~= nil then - pcall(ComponentSetValue2, damage, "wait_for_kill_flag_on_death", false) - pcall(ComponentSetValue2, damage, "kill_now", true) - end - end - if type(EntityKill) == "function" then pcall(EntityKill, entity) end - return true, "retired" -end - -local function publish_reference(reference) - reference = tonumber(reference) or 0 - if reference ~= 0 and type(EntityAddTag) == "function" then - pcall(EntityAddTag, reference, CREATIVE_REF_TAG) - pcall(EntityAddTag, reference, "ew_synced") - end - return reference -end - -local function spawn_reference(x, y) - if type(EntityLoad) ~= "function" then return 0 end - local ok, reference = pcall(EntityLoad, REF_PATH, tonumber(x) or 0, tonumber(y) or 0) - reference = ok and (tonumber(reference) or 0) or 0 - return publish_reference(reference) -end - -local function add_bootstrap(entity) - if type(EntityAddComponent2) ~= "function" then return nil end - local ok, component = pcall(EntityAddComponent2, entity, "LuaComponent", { - _tags = "mcm_creative_kolmi_bootstrap,ew_remove_on_send", - script_source_file = BOOTSTRAP, - execute_every_n_frame = 1, - execute_on_added = false, - remove_after_executed = false, - }) - return ok and component or nil -end - --- Runs before EntityLoad. If the user clicks Kolmisilma while standing in the real final --- boss arena, retire the authored dormant root first and reuse its authored reference point. --- A remote-owned root cannot be safely deleted by this peer; in that case reuse it instead --- of creating a second final boss. -function spawn_compat.before_spawn(path, x, y) - if tostring(path or "") ~= KOLMI then return { kind = "ordinary" } end - local reference = nearest_reference(x, y) - local existing = existing_authored_kolmi(x, y) - if existing == 0 then - if reference ~= 0 then bump(REUSED_REFERENCE_COUNT) end - return { kind = "kolmi", reference_id = reference } - end - - local retired, reason = quiet_retire_existing(existing) - if retired then - bump(REPLACED_COUNT) - if reference ~= 0 then bump(REUSED_REFERENCE_COUNT) end - return { kind = "kolmi", reference_id = reference, replaced_entity = existing } - end - - if reason == "remote_authority" then - bump(REUSED_REMOTE_COUNT) - return { kind = "reuse", entity = existing, reference_id = reference, reason = reason } - end - -- A failed retirement must not create another root on top of the retained one. - return { kind = "reuse", entity = existing, reference_id = reference, reason = reason } -end - -local function prepare_creative_kolmi(entity, x, y, context) - if not alive(entity) then return false, "invalid" end - - local reference = type(context) == "table" and tonumber(context.reference_id) or 0 - if reference == nil then reference = 0 end - if not alive(reference) then reference = spawn_reference(x, y) - else reference = publish_reference(reference) end - if reference == 0 then return false, "reference" end - - if add_synced_reference(entity, x, y) == nil then return false, "reference_var" end - if remember_reference_id(entity, reference) == nil then return false, "reference_id_var" end - if type(EntityAddTag) == "function" then pcall(EntityAddTag, entity, CREATIVE_MARKER) end - - local bootstrap = add_bootstrap(entity) - if bootstrap == nil then return false, "bootstrap_component" end - return true, "creative_kolmi_prepared" -end - -function spawn_compat.after_spawn(path, entity, x, y, context) - if tostring(path or "") ~= KOLMI then return true, "ordinary" end - return prepare_creative_kolmi(tonumber(entity) or 0, tonumber(x) or 0, tonumber(y) or 0, context) -end - -spawn_compat.KOLMI_PATH = KOLMI -spawn_compat.REF_PATH = REF_PATH -spawn_compat.CREATIVE_REF_VAR = CREATIVE_REF_VAR -spawn_compat.CREATIVE_REF_ID_VAR = CREATIVE_REF_ID_VAR -spawn_compat.CREATIVE_MARKER = CREATIVE_MARKER -spawn_compat.CREATIVE_REF_TAG = CREATIVE_REF_TAG -spawn_compat.BOOTSTRAP = BOOTSTRAP -spawn_compat.CONTROLLER = CONTROLLER -return spawn_compat diff --git a/metamorph_creative_menu/files/features/death_recovery/post_revive_cleanup.lua b/metamorph_creative_menu/files/features/death_recovery/post_revive_cleanup.lua deleted file mode 100644 index b11900b..0000000 --- a/metamorph_creative_menu/files/features/death_recovery/post_revive_cleanup.lua +++ /dev/null @@ -1,40 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_POST_REVIVE_CLEANUP) == "table" then - return METAMORPH_CREATIVE_MENU_POST_REVIVE_CLEANUP -end - --- Post-revive cleanup owns only side effects that outlive the stock Game Over UI. --- It deliberately does not restore the player, cancel Game Over, or touch snapshots; --- those remain death_recovery/service.lua responsibilities. -local post_revive_cleanup = {} - -local native_gameover = dofile("mods/metamorph_creative_menu/files/platform/noita/native_gameover_patch.lua") - -local state = { - attempts = 0, - successes = 0, - last_reason = nil, -} - -function post_revive_cleanup.apply(_player_entity) - state.attempts = state.attempts + 1 - - local ok, reason = native_gameover.cleanup_post_revive() - state.last_reason = tostring(reason or (ok and "post_revive_cleaned" or "unknown")) - if ok then state.successes = state.successes + 1 end - - -- Cleanup is best-effort by design. Player authority and Game Over cancellation have - -- already succeeded before this function is called, so a failed secondary cleanup - -- must never re-arm death or strand the player on the Game Over screen. - return ok == true, state.last_reason -end - -function post_revive_cleanup.status() - return { - attempts = state.attempts, - successes = state.successes, - last_reason = state.last_reason, - } -end - -METAMORPH_CREATIVE_MENU_POST_REVIVE_CLEANUP = post_revive_cleanup -return post_revive_cleanup diff --git a/metamorph_creative_menu/files/features/death_recovery/service.lua b/metamorph_creative_menu/files/features/death_recovery/service.lua deleted file mode 100644 index c61444a..0000000 --- a/metamorph_creative_menu/files/features/death_recovery/service.lua +++ /dev/null @@ -1,331 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_DEATH_RECOVERY_SERVICE) == "table" then - return METAMORPH_CREATIVE_MENU_DEATH_RECOVERY_SERVICE -end - -local death_recovery = {} - -local player_locator = dofile("mods/metamorph_creative_menu/files/platform/noita/player_locator.lua") -local bridge_api = dofile("mods/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua") -local native_gameover = dofile("mods/metamorph_creative_menu/files/platform/noita/native_gameover_patch.lua") -local human_restore = dofile("mods/metamorph_creative_menu/files/features/forms/human_restore.lua") -local post_revive_cleanup = dofile("mods/metamorph_creative_menu/files/features/death_recovery/post_revive_cleanup.lua") -local ew_runtime = dofile("mods/metamorph_creative_menu/files/integrations/ew/runtime.lua") - -local INVINCIBILITY_FRAMES = 30 * 60 -local BACKUP_INTERVAL_FRAMES = 60 -local LOW_HP_INTERVAL_FRAMES = 10 - -local state = { - last_snapshot = nil, - last_snapshot_frame = -1000000, - last_x = 0, - last_y = 0, - last_player = 0, - pending_entity = 0, - last_native_error = nil, - revive_count = 0, - last_cleanup_reason = nil, - install_notice = nil, -} - -local function ew_active() - return ew_runtime.enabled() == true -end - -local function frame_number() - if type(GameGetFrameNum) ~= "function" then return 0 end - local ok, value = pcall(GameGetFrameNum) - return ok and (tonumber(value) or 0) or 0 -end - -local function alive(entity) - if entity == nil or entity == 0 or type(EntityGetIsAlive) ~= "function" then return false end - local ok, result = pcall(EntityGetIsAlive, entity) - return ok and result == true -end - -local function bridge_with(capability) - return bridge_api.get({ bootstrap_if_installed=true, capability=capability }) -end - -local function position_of(entity, fallback_x, fallback_y) - local x, y = tonumber(fallback_x) or state.last_x, tonumber(fallback_y) or state.last_y - if entity ~= nil and entity ~= 0 and type(EntityGetTransform) == "function" then - local ok, value_x, value_y = pcall(EntityGetTransform, entity) - if ok then x, y = tonumber(value_x) or x, tonumber(value_y) or y end - end - return x, y -end - -local function damage_component(entity) - if entity == nil or entity == 0 or type(EntityGetFirstComponentIncludingDisabled) ~= "function" then return 0 end - local ok, component = pcall(EntityGetFirstComponentIncludingDisabled, entity, "DamageModelComponent") - return ok and (tonumber(component) or 0) or 0 -end - -local function snapshot_interval_for(entity) - local component = damage_component(entity) - if component == 0 or type(ComponentGetValue2) ~= "function" then return BACKUP_INTERVAL_FRAMES end - local ok_hp, hp = pcall(ComponentGetValue2, component, "hp") - local ok_max, max_hp = pcall(ComponentGetValue2, component, "max_hp") - hp, max_hp = tonumber(hp), tonumber(max_hp) - if ok_hp and ok_max and hp ~= nil and max_hp ~= nil and max_hp > 0 and hp <= max_hp * 0.25 then - return LOW_HP_INTERVAL_FRAMES - end - return BACKUP_INTERVAL_FRAMES -end - -local function serialize_entity(entity) - local bridge = bridge_with("SerializeEntity") - if bridge == nil or type(bridge.SerializeEntity) ~= "function" then return nil, "serialize_unavailable" end - local ok, serialized = pcall(bridge.SerializeEntity, entity) - if not ok or type(serialized) ~= "string" or serialized == "" then - return nil, ok and "serialize_empty" or tostring(serialized) - end - return serialized -end - -local function track_live_player() - local player = 0 - local ok, value = pcall(player_locator.get_human) - if ok then player = tonumber(value) or 0 end - if player == 0 or not alive(player) then return false, "no_human" end - - state.last_player = player - state.last_x, state.last_y = position_of(player) - - local frame = frame_number() - if frame - state.last_snapshot_frame < snapshot_interval_for(player) then - return false, "tracked" - end - - local serialized, reason = serialize_entity(player) - if serialized == nil then return false, reason end - state.last_snapshot = serialized - state.last_snapshot_frame = frame - return true, "captured" -end - -local function set_full_health_and_protection(entity) - if entity == nil or entity == 0 then return false end - local damage = damage_component(entity) - if damage ~= 0 and type(ComponentGetValue2) == "function" and type(ComponentSetValue2) == "function" then - local ok_max, max_hp = pcall(ComponentGetValue2, damage, "max_hp") - max_hp = ok_max and tonumber(max_hp) or nil - if max_hp == nil or max_hp <= 0 then max_hp = 4 end - pcall(ComponentSetValue2, damage, "hp", max_hp) - pcall(ComponentSetValue2, damage, "kill_now", false) - local ok_frames, frames = pcall(ComponentGetValue2, damage, "invincibility_frames") - frames = ok_frames and tonumber(frames) or 0 - pcall(ComponentSetValue2, damage, "invincibility_frames", math.max(frames or 0, INVINCIBILITY_FRAMES)) - end - pcall(human_restore.restore_controls, entity) - pcall(human_restore.protect_player, entity, INVINCIBILITY_FRAMES) - return true -end - -local function make_authoritative(bridge, entity) - if bridge == nil or entity == nil or entity == 0 then return false, "bridge_or_entity_missing" end - if type(bridge.SetPlayerEntity) ~= "function" then return false, "set_player_unavailable" end - local ok, reason = pcall(bridge.SetPlayerEntity, entity) - if not ok then return false, tostring(reason) end - if type(bridge.RegisterPlayerEntityId) == "function" then pcall(bridge.RegisterPlayerEntityId, entity) end - if type(bridge.GetPlayerEntity) == "function" then - local read_ok, current = pcall(bridge.GetPlayerEntity) - if read_ok and tonumber(current) ~= tonumber(entity) then return false, "authority_readback_mismatch" end - end - return true -end - -local function restore_snapshot_entity(bridge) - if type(state.last_snapshot) ~= "string" or state.last_snapshot == "" then return 0, "snapshot_missing" end - if bridge == nil or type(bridge.DeserializeEntity) ~= "function" then return 0, "deserialize_unavailable" end - if type(EntityCreateNew) ~= "function" then return 0, "entity_create_unavailable" end - local entity = EntityCreateNew("mcm_death_recovery_player") or 0 - if entity == 0 then return 0, "entity_create_failed" end - local ok, err = pcall(bridge.DeserializeEntity, entity, state.last_snapshot, state.last_x, state.last_y) - if not ok then - if type(EntityKill) == "function" then pcall(EntityKill, entity) end - return 0, tostring(err) - end - if not alive(entity) then - if type(EntityKill) == "function" then pcall(EntityKill, entity) end - return 0, "deserialized_entity_dead" - end - set_full_health_and_protection(entity) - return entity, "snapshot" -end - -local function restore_fresh_entity() - if type(EntityLoad) ~= "function" then return 0, "entity_load_unavailable" end - local ok, entity = pcall(EntityLoad, "data/entities/player.xml", state.last_x, state.last_y) - entity = ok and (tonumber(entity) or 0) or 0 - if entity == 0 or not alive(entity) then return 0, "fresh_player_failed" end - set_full_health_and_protection(entity) - return entity, "fresh" -end - -local function find_existing_live_player(bridge) - if bridge ~= nil and type(bridge.GetPlayerEntity) == "function" then - local ok, entity = pcall(bridge.GetPlayerEntity) - entity = ok and (tonumber(entity) or 0) or 0 - if entity ~= 0 and alive(entity) then return entity end - end - if type(EntityGetWithTag) == "function" then - local ok, players = pcall(EntityGetWithTag, "player_unit") - if ok then - for _, entity in ipairs(players or {}) do - if alive(entity) and not (type(EntityHasTag) == "function" and EntityHasTag(entity, "ew_client")) then - return entity - end - end - end - end - return 0 -end - -local function acquire_recovery_player(bridge) - local existing = find_existing_live_player(bridge) - if existing ~= 0 then - set_full_health_and_protection(existing) - return existing, "existing" - end - - local pending = tonumber(state.pending_entity) or 0 - if pending ~= 0 and alive(pending) then - set_full_health_and_protection(pending) - return pending, "pending" - end - - local entity, source = restore_snapshot_entity(bridge) - if entity == 0 then entity, source = restore_fresh_entity() end - if entity ~= 0 then state.pending_entity = entity end - return entity, source -end - -local function handle_revive_request() - if ew_active() then return false, "disabled_in_entangled_worlds" end - if not native_gameover.has_revive_request() then return false, "idle" end - - -- This function runs from OnWorldPreUpdate, outside the Game Over button click stack. - -- The native bridge only raises a request byte; all engine-state recovery stays here. - -- First restore/attach the player; only then release the engine Game Over state. - local bridge = bridge_with("SetPlayerEntity") - if bridge == nil then return false, "patcher_unavailable" end - - local entity, source = acquire_recovery_player(bridge) - if entity == 0 then - state.last_native_error = "restore:" .. tostring(source) - return false, source - end - - local authoritative, authority_reason = make_authoritative(bridge, entity) - if not authoritative then - state.last_native_error = "authority:" .. tostring(authority_reason) - return false, authority_reason - end - - local cleared, clear_reason = native_gameover.cancel_game_over_state() - if not cleared then - state.last_native_error = "gameover:" .. tostring(clear_reason) - return false, clear_reason - end - - local cleanup_ok, cleanup_reason = post_revive_cleanup.apply(entity) - state.last_cleanup_reason = tostring(cleanup_reason or (cleanup_ok and "post_revive_cleaned" or "unknown")) - - native_gameover.clear_revive_request() - state.pending_entity = 0 - state.revive_count = state.revive_count + 1 - state.last_player = entity - state.last_x, state.last_y = position_of(entity) - state.last_snapshot_frame = -1000000 - return true, source, entity -end - -function death_recovery.install() - if ew_active() then - state.last_native_error = "disabled_in_entangled_worlds" - return false, state.last_native_error - end - local patched, reason = native_gameover.install() - if not patched then state.last_native_error = reason end - return patched, reason -end - -local function report_native_install_once(installed, reason) - local signature = installed and "installed" or tostring(reason or "unknown") - if state.install_notice == signature then return end - state.install_notice = signature - local message = installed - and "[MCM] native Game Over button patch: installed" - or "[MCM] native Game Over button patch unavailable: " .. signature - print(message) - if type(GamePrint) == "function" then pcall(GamePrint, message) end -end - -function death_recovery.update() - -- Form-death restoration is owned by files/features/forms and remains active in EW. - -- Only the post-Game-Over "I didn't die" feature is disabled here. - if ew_active() then - state.last_native_error = "disabled_in_entangled_worlds" - return false, state.last_native_error - end - if not native_gameover.is_installed() then - local patched, reason = death_recovery.install() - if not patched then - state.last_native_error = reason - -- During development this is intentionally visible: a missing native button - -- must never again look indistinguishable from a successfully installed patch. - report_native_install_once(false, reason) - else - report_native_install_once(true, reason) - end - elseif state.install_notice == nil then - report_native_install_once(true, "installed") - end - - -- Snapshot ownership is independent from native Game Over UI installation. Keeping the - -- rolling backup alive even when the button patch is temporarily unavailable means a - -- later successful install never starts with an empty recovery state. - track_live_player() - - if not native_gameover.is_installed() then - return false, state.last_native_error or "native_button_unavailable" - end - - local handled, source, entity = handle_revive_request() - if handled ~= false or source ~= "idle" then return handled, source, entity end - return false, "idle" -end - --- Manual/test entry point. It follows the exact same post-UI ordering as the button path; --- it does not synthesize a death or touch pause-state. -function death_recovery.revive() - if ew_active() then return false, "disabled_in_entangled_worlds" end - return handle_revive_request() -end - -function death_recovery.pause_update() - return false, "world_preupdate_owned" -end - -function death_recovery.status() - return { - snapshot_available = type(state.last_snapshot) == "string" and state.last_snapshot ~= "", - last_snapshot_frame = state.last_snapshot_frame, - last_player = state.last_player, - last_x = state.last_x, - last_y = state.last_y, - pending_entity = state.pending_entity, - native_error = state.last_native_error, - revive_count = state.revive_count, - last_cleanup_reason = state.last_cleanup_reason, - cleanup = post_revive_cleanup.status(), - native = native_gameover.status(), - network_disabled = ew_active(), - } -end - -METAMORPH_CREATIVE_MENU_DEATH_RECOVERY_SERVICE = death_recovery -return death_recovery diff --git a/metamorph_creative_menu/files/features/effects/catalog.lua b/metamorph_creative_menu/files/features/effects/catalog.lua index ad1cb32..b3447ca 100644 --- a/metamorph_creative_menu/files/features/effects/catalog.lua +++ b/metamorph_creative_menu/files/features/effects/catalog.lua @@ -103,15 +103,6 @@ local EFFECT_PATHS = { "data/entities/misc/effect_weakness.xml", "data/entities/misc/effect_worm_attractor.xml", "data/entities/misc/effect_worm_detractor.xml", - -- Additional user-facing vanilla effects that are not represented by status_list.lua. - -- One-shot apply wrappers, polymorph/respawn lifecycle entities and UI-only helpers - -- are intentionally excluded from the editor. - "data/entities/misc/effect_damage_plus_small.xml", - "data/entities/misc/effect_hearty.xml", - "data/entities/misc/effect_homing_shooter.xml", - "data/entities/misc/effect_twitchy.xml", - "data/entities/misc/effect_weaken.xml", - "data/entities/misc/effect_wither.xml", } local RESERVED_EFFECTS = { @@ -207,16 +198,9 @@ local function read_effect_metadata(path, visited, depth) if game_tag ~= nil then meta.effect = attr(game_tag, "effect") or meta.effect meta.custom_effect_id = attr(game_tag, "custom_effect_id") or meta.custom_effect_id - local frames = tonumber(attr(game_tag, "frames")) - if frames ~= nil then meta.authored_frames = frames end local disabled = attr(game_tag, "disable_movement") if disabled ~= nil then meta.disable_movement = disabled == "1" or disabled == "true" end end - local lifetime_tag = string.match(content, "]*>") - if lifetime_tag ~= nil then - local lifetime = tonumber(attr(lifetime_tag, "lifetime")) - if lifetime ~= nil then meta.authored_lifetime = lifetime end - end metadata_cache[path] = meta visited[path] = nil return meta @@ -236,11 +220,9 @@ local function load_status_entries() local ok = pcall(dofile_once, "data/scripts/status_effects/status_list.lua") if not ok or type(status_effects) ~= "table" then return status_entries end - -- The menu exposes one semantic status tile. When vanilla defines several concrete - -- XML implementations for the same status id (TRIP is the canonical example), keep - -- those implementations as variants of that tile instead of flooding the catalogue - -- with near-identical entries. This preserves the clean v2 presentation while still - -- making every authored strength reachable. + -- status_list.lua contains presentation variants and, depending on the game build, + -- repeated semantic status ids. Build one canonical entry per id and merge richer + -- presentation data into it instead of exposing duplicates as blank-looking cells. local by_id = {} for index, status in ipairs(status_effects) do if type(status) == "table" and type(status.id) == "string" and status.id ~= "" then @@ -248,117 +230,43 @@ local function load_status_entries() local icon = status_value(status, { "ui_icon", "icon", "icon_sprite_file" }) local name = status_value(status, { "ui_name", "name" }) local description = status_value(status, { "ui_description", "description" }) - local normalized_path = type(path) == "string" and path or "" - local meta = normalized_path ~= "" and ModDoesFileExist(normalized_path) and read_effect_metadata(normalized_path, {}, 0) or {} - local variant = { + local meta = type(path) == "string" and ModDoesFileExist(path) and read_effect_metadata(path, {}, 0) or {} + local candidate = { kind = "status", id = status.id, status_index = index, - path = normalized_path, + path = type(path) == "string" and path or "", icon = type(icon) == "string" and icon or (meta.icon or ""), - name_key = name or meta.name, - description_key = description or meta.description, display_name = translated(name or meta.name), display_description = translated(description or meta.description), material = STATUS_MATERIAL_BY_ID[status.id], game_effect = meta.effect, custom_effect_id = meta.custom_effect_id, - authored_frames = meta.authored_frames, - authored_lifetime = meta.authored_lifetime, - disable_movement = meta.disable_movement == true, - min_threshold_normalized = tonumber(status.min_threshold_normalized), } local current = by_id[status.id] if current == nil then - current = {} - for key, value in pairs(variant) do current[key] = value end - current.variants = {} - current._variant_paths = {} - by_id[status.id] = current - status_entries[#status_entries + 1] = current + by_id[status.id] = candidate + status_entries[#status_entries + 1] = candidate else - if current.icon == "" and variant.icon ~= "" then current.icon = variant.icon end - if current.display_name == "" and variant.display_name ~= "" then current.display_name = variant.display_name end - if current.display_description == "" and variant.display_description ~= "" then current.display_description = variant.display_description end - current.name_key = current.name_key or variant.name_key - current.description_key = current.description_key or variant.description_key - current.material = current.material or variant.material - current.game_effect = current.game_effect or variant.game_effect - current.custom_effect_id = current.custom_effect_id or variant.custom_effect_id - end - - -- A repeated threshold pointing at the same XML is only a presentation alias, - -- not a separate strength. Pathless material statuses also stay single-state. - if normalized_path ~= "" and current._variant_paths[normalized_path] ~= true then - current._variant_paths[normalized_path] = true - current.variants[#current.variants + 1] = variant + -- Keep the first status_index: StatusEffectDataComponent vectors are + -- indexed in vanilla list order. Only fill missing presentation/entity + -- metadata from later duplicate declarations. + if current.path == "" and candidate.path ~= "" then current.path = candidate.path end + if current.icon == "" and candidate.icon ~= "" then current.icon = candidate.icon end + if current.display_name == "" and candidate.display_name ~= "" then current.display_name = candidate.display_name end + if current.display_description == "" and candidate.display_description ~= "" then current.display_description = candidate.display_description end + current.material = current.material or candidate.material + current.game_effect = current.game_effect or candidate.game_effect + current.custom_effect_id = current.custom_effect_id or candidate.custom_effect_id end end end - - for _, entry in ipairs(status_entries) do - entry._variant_paths = nil - if type(entry.variants) == "table" and #entry.variants <= 1 then - entry.variants = nil - end - end return status_entries end -local perk_effect_ids = nil - -local function load_perk_effect_ids() - if perk_effect_ids ~= nil then return perk_effect_ids end - perk_effect_ids = {} - local ok = pcall(dofile_once, "data/scripts/perks/perk_list.lua") - if not ok or type(perk_list) ~= "table" then return perk_effect_ids end - for _, perk in ipairs(perk_list) do - if type(perk) == "table" then - for _, field in ipairs({"game_effect", "game_effect2"}) do - local effect = string.upper(tostring(perk[field] or "")) - if effect ~= "" then perk_effect_ids[effect] = true end - end - end - end - return perk_effect_ids -end - --- A tiny curated set of useful temporary vanilla GameEffects has no status-list entry. --- Keep those explicit instead of borrowing a perk's name/icon: the EFFECTS tab must not --- silently turn passive perks into timed status effects just because both use GameEffect. -local SPECIAL_PRESENTATION = { - ELECTROCUTION = { - name_key="$mcm_effect_electrocution", - description_key="$mcm_effect_electrocution_desc", - icon="data/ui_gfx/gun_actions/electrocution_field.png", - }, -} - -local function user_presentation(meta) - local effect = tostring(meta and meta.effect or "") - local custom = tostring(meta and meta.custom_effect_id or "") - return SPECIAL_PRESENTATION[(custom ~= "" and custom ~= "CUSTOM") and custom or effect] -end - -local function perk_backed(meta) - if type(meta) ~= "table" then return false end - local perks = load_perk_effect_ids() - local effect = string.upper(tostring(meta.effect or "")) - local custom = string.upper(tostring(meta.custom_effect_id or "")) - return (effect ~= "" and perks[effect] == true) - or (custom ~= "" and custom ~= "CUSTOM" and perks[custom] == true) -end - -local function translated_user_name(value) - if type(value) ~= "string" or value == "" then return "" end - local result = translated(value) - if result == "" or result == value and string.sub(value,1,1) == "$" then return "" end - return result -end - local function normalize_display(entry) if entry.display_name == nil or entry.display_name == "" or string.sub(entry.display_name, 1, 1) == "$" then - entry.display_name = "" + entry.display_name = entry.id or pretty_name(entry.path) end if entry.display_description == nil or entry.display_description == entry.display_name then entry.display_description = "" @@ -377,17 +285,22 @@ end local function semantic_identity(entry) if type(entry) ~= "table" then return "" end - -- Status ids are user-facing concepts. Never collapse two different status families - -- merely because vanilla happens to implement them with the same GameEffect id/path - -- (ALCOHOLIC vs INGESTION_DRUNK is the important case). Variants inside one status id - -- are already grouped by load_status_entries(). - if entry.kind == "status" and tostring(entry.id or "") ~= "" then - return "status:" .. string.upper(tostring(entry.id)) + -- Material statuses are distinct even when their presentation entity shares a + -- generic effect. For entity-backed effects, custom/effect ids are the semantic + -- identity; path is only a fallback. + if type(entry.material) == "string" and entry.material ~= "" then + return "status:" .. tostring(entry.id or entry.material) end local custom = tostring(entry.custom_effect_id or "") if custom ~= "" and custom ~= "CUSTOM" then return "custom:" .. string.upper(custom) end local effect = tostring(entry.game_effect or "") if effect ~= "" and effect ~= "CUSTOM" then return "effect:" .. string.upper(effect) end + -- status_list often omits effect_entity even when the status id is exactly the + -- GameEffect enum used by data/entities/misc/effect_*.xml. Canonicalize that pair + -- so BERSERK status + effect_berserk entity is one tile whose metadata is merged. + if entry.kind == "status" and tostring(entry.id or "") ~= "" then + return "effect:" .. string.upper(tostring(entry.id)) + end local path = tostring(entry.path or "") if path ~= "" then return "path:" .. path end return "id:" .. tostring(entry.id or "") @@ -407,31 +320,14 @@ local function richer(existing, candidate) if keep.display_name == "" then keep.display_name = other.display_name or "" end if keep.display_description == "" then keep.display_description = other.display_description or "" end if keep.path == "" then keep.path = other.path or "" end - keep.name_key = keep.name_key or other.name_key - keep.description_key = keep.description_key or other.description_key keep.game_effect = keep.game_effect or other.game_effect keep.custom_effect_id = keep.custom_effect_id or other.custom_effect_id - keep.authored_frames = keep.authored_frames or other.authored_frames - keep.authored_lifetime = keep.authored_lifetime or other.authored_lifetime - keep.disable_movement = keep.disable_movement == true or other.disable_movement == true - if type(keep.variants) ~= "table" and type(other.variants) == "table" then keep.variants = other.variants end return keep end function catalog_api.entries() if catalog ~= nil then return catalog end local ordered, by_identity, by_path, by_presentation, seen_path = {}, {}, {}, {}, {} - local status_effect_ids = {} - local function remember_status_effect_ids(entry) - if type(entry) ~= "table" then return end - for _, candidate in ipairs(type(entry.variants) == "table" and entry.variants or {entry}) do - local effect = string.upper(tostring(candidate.game_effect or "")) - local custom = string.upper(tostring(candidate.custom_effect_id or "")) - if effect ~= "" and effect ~= "CUSTOM" then status_effect_ids[effect] = true end - if custom ~= "" and custom ~= "CUSTOM" then status_effect_ids[custom] = true end - end - end - for _, status_entry in ipairs(load_status_entries()) do remember_status_effect_ids(status_entry) end local function include(entry) local effect_id = string.upper(tostring(entry and (entry.game_effect or entry.id) or "")) @@ -441,11 +337,9 @@ function catalog_api.entries() -- their semantic ids before any catalogue merge. if RESERVED_EFFECTS[effect_id] or RESERVED_EFFECTS[custom_id] then return end entry = normalize_display(entry) - -- The EFFECTS tab is user-facing, not an XML browser. Never turn an internal - -- engine id into a fallback English tile. A valid entry needs a localized title - -- and a real icon; technical effects can still be reached by other mod code. - if type(entry.display_name) ~= "string" or entry.display_name == "" then return end - if type(entry.icon) ~= "string" or entry.icon == "" or not ModDoesFileExist(entry.icon) then return end + -- Internal status-vector aliases and service effects have no presentation of + -- their own. They are not usable menu entries and previously produced a large + -- block of neutral placeholder tiles. if not effect_policy.visible(entry, ModDoesFileExist) then return end local identity = semantic_identity(entry) if identity == "" then return end @@ -461,21 +355,17 @@ function catalog_api.entries() -- that intentionally point at the exact same effect entity. An effect menu -- should expose that implementation once, not one localized tile plus a second -- English/blank alias for the same XML. - -- Exact presentation aliases (same XML + icon + localized title) are duplicates - -- even when vanilla exposes two status ids for them (OILED/HYDRATED). Different - -- status concepts sharing an implementation path remain separate: ALCOHOLIC, - -- INGESTION_DRUNK and BRAIN_DAMAGE all use DRUNK internally but have distinct UI. + if existing == nil and path_key ~= nil then existing = by_path[path_key] end if existing == nil and presentation ~= "" then existing = by_presentation[presentation] end - if existing == nil and entry.kind ~= "status" and path_key ~= nil then existing = by_path[path_key] end if existing == nil then by_identity[identity] = entry - if entry.kind ~= "status" and path_key ~= nil then by_path[path_key] = entry end + if path_key ~= nil then by_path[path_key] = entry end if presentation ~= "" then by_presentation[presentation] = entry end ordered[#ordered + 1] = entry else local merged = richer(existing, entry) by_identity[identity] = merged - if entry.kind ~= "status" and path_key ~= nil then by_path[path_key] = merged end + if path_key ~= nil then by_path[path_key] = merged end if presentation ~= "" then by_presentation[presentation] = merged end if merged ~= existing then for key, value in pairs(by_identity) do if value == existing then by_identity[key] = merged end end @@ -485,9 +375,6 @@ function catalog_api.entries() end end if type(entry.path) == "string" and entry.path ~= "" then seen_path[entry.path] = true end - for _, variant in ipairs(type(entry.variants) == "table" and entry.variants or {}) do - if type(variant.path) == "string" and variant.path ~= "" then seen_path[variant.path] = true end - end end for _, entry in ipairs(load_status_entries()) do include(entry) end @@ -496,35 +383,16 @@ function catalog_api.entries() local meta = read_effect_metadata(path, {}, 0) local effect_name = tostring(meta.effect or "") local custom_effect_id = tostring(meta.custom_effect_id or "") - local semantic_effect = string.upper((custom_effect_id ~= "" and custom_effect_id ~= "CUSTOM") - and custom_effect_id or effect_name) - if not RESERVED_EFFECTS[effect_name] and not RESERVED_EFFECTS[custom_effect_id] - and not perk_backed(meta) - and (semantic_effect == "" or status_effect_ids[semantic_effect] ~= true) - then - local presentation = user_presentation(meta) or {} - -- Raw one-word XML labels are developer metadata, not localized UI. Prefer - -- an XML title only when it is a real localization key; otherwise require a - -- curated presentation so the effects tab never leaks WEAKNESS/NO_HEAL - -- style English internals into non-English games. - local xml_name = type(meta.name) == "string" and string.sub(meta.name,1,1) == "$" and meta.name or nil - local xml_description = type(meta.description) == "string" and string.sub(meta.description,1,1) == "$" and meta.description or nil - local name_key = xml_name or presentation.name_key - local description_key = xml_description or presentation.description_key - local icon = (meta.icon ~= nil and meta.icon ~= "") and meta.icon or presentation.icon + if not RESERVED_EFFECTS[effect_name] and not RESERVED_EFFECTS[custom_effect_id] then include({ kind = "game_effect", id = (custom_effect_id ~= "" and custom_effect_id) or (effect_name ~= "" and effect_name) or pretty_name(path), path = path, - icon = icon or "", - name_key = name_key, - description_key = description_key, - display_name = translated_user_name(name_key), - display_description = translated(description_key), + icon = meta.icon or "", + display_name = translated(meta.name), + display_description = translated(meta.description), game_effect = meta.effect, custom_effect_id = meta.custom_effect_id, - authored_frames = meta.authored_frames, - authored_lifetime = meta.authored_lifetime, disable_movement = meta.disable_movement == true, }) end diff --git a/metamorph_creative_menu/files/features/effects/policy.lua b/metamorph_creative_menu/files/features/effects/policy.lua index e474075..d136b1b 100644 --- a/metamorph_creative_menu/files/features/effects/policy.lua +++ b/metamorph_creative_menu/files/features/effects/policy.lua @@ -4,19 +4,7 @@ function effect_policy.visible(entry, file_exists) if type(entry) ~= "table" then return false end file_exists = type(file_exists) == "function" and file_exists or function() return false end local icon = tostring(entry.icon or "") - if icon == "" or not file_exists(icon) then - -- Many perfectly valid vanilla effects omit UIIconComponent but still use the - -- conventional status-indicator sprite. Resolve that here so they are not silently - -- absent from the editor, while still refusing blank placeholder-only entries. - local id = string.lower(tostring(entry.id or entry.game_effect or entry.custom_effect_id or "")) - local candidate = id ~= "" and ("data/ui_gfx/status_indicators/" .. id .. ".png") or "" - if candidate ~= "" and file_exists(candidate) then - entry.icon = candidate - icon = candidate - else - return false - end - end + if icon == "" or not file_exists(icon) then return false end if type(entry.material) == "string" and entry.material ~= "" then return true end local path = tostring(entry.path or "") return path ~= "" and file_exists(path) diff --git a/metamorph_creative_menu/files/features/effects/service.lua b/metamorph_creative_menu/files/features/effects/service.lua index 403c44e..9501ef5 100644 --- a/metamorph_creative_menu/files/features/effects/service.lua +++ b/metamorph_creative_menu/files/features/effects/service.lua @@ -17,73 +17,6 @@ function effect_service.catalog() return effect_catalog.entries() end -function effect_service.variant_count(entry) - local variants = type(entry) == "table" and entry.variants or nil - return type(variants) == "table" and math.max(1, #variants) or 1 -end - -function effect_service.max_variant_count() - local maximum = 1 - for _, entry in ipairs(effect_service.catalog()) do - maximum = math.max(maximum, effect_service.variant_count(entry)) - end - return maximum -end - -function effect_service.resolve_variant(entry, index) - if type(entry) ~= "table" then return entry end - local variants = entry.variants - if type(variants) ~= "table" or #variants == 0 then return entry end - index = math.max(1, math.min(#variants, math.floor(tonumber(index) or 1))) - local variant = variants[index] - if type(variant) ~= "table" then return entry end - local resolved = {} - for key, value in pairs(entry) do if key ~= "variants" then resolved[key] = value end end - for key, value in pairs(variant) do resolved[key] = value end - resolved.variant_index = index - resolved.variant_count = #variants - return resolved -end - -function effect_service.family_key(entry) - if type(entry) ~= "table" then return "" end - if entry.kind == "status" then return "status:" .. tostring(entry.id or entry.path or "") end - return "effect:" .. tostring(entry.id or entry.custom_effect_id or entry.game_effect or entry.path or "") -end - -function effect_service.duration_policy(entry) - if type(entry) ~= "table" then return "editable" end - if entry.kind == "status" and type(entry.material) == "string" and entry.material ~= "" then return "native" end - if entry.disable_movement == true then return "authored" end - return "editable" -end - -function effect_service.authored_frames(entry) - if type(entry) ~= "table" then return nil end - local value = tonumber(entry.authored_frames) - if value == nil then value = tonumber(entry.authored_lifetime) end - if value == nil then return nil end - return value < 0 and -1 or math.max(1, math.floor(value)) -end - -local function requested_duration(entry, frames) - if effect_service.duration_policy(entry) == "authored" then - return effect_service.authored_frames(entry) - end - if frames == nil then return nil end - frames = tonumber(frames) - if frames == nil then return nil end - return frames < 0 and -1 or math.max(1, math.floor(frames)) -end - -function effect_service.variant_label(entry, index) - local resolved = effect_service.resolve_variant(entry, index) - if type(resolved) ~= "table" then return tostring(index or 1) end - local label = tostring(resolved.display_name or resolved.id or "") - if label == "" then label = tostring(index or 1) end - return label -end - local function status_vector_value(player, entry) local comp = EntityGetFirstComponentIncludingDisabled(player, "StatusEffectDataComponent") if comp == nil or comp == 0 then return 0 end @@ -130,123 +63,28 @@ local function add_surface_status(player, entry) return false end -local function game_effect_components(entity) - if entity == nil or entity == 0 then return {} end - if type(EntityGetComponentIncludingDisabled) == "function" then - local ok, values = pcall(EntityGetComponentIncludingDisabled, entity, "GameEffectComponent") - if ok and type(values) == "table" then return values end - end - local first = EntityGetFirstComponentIncludingDisabled(entity, "GameEffectComponent") - return first ~= nil and first ~= 0 and {first} or {} -end - -local function component_active(comp) - local frames = tonumber(ComponentGetValue2(comp, "frames")) or 0 - return frames == -1 or frames > 1 -end -local EFFECT_START_MARKER = "mcm_effect_start_frame" - -local function lifetime_component(entity) - if entity == nil or entity == 0 then return 0 end - local comp = EntityGetFirstComponentIncludingDisabled(entity, "LifetimeComponent") - return comp ~= nil and comp ~= 0 and comp or 0 -end - -local function effect_start_frame(entity) - for _, comp in ipairs(EntityGetComponentIncludingDisabled(entity, "VariableStorageComponent") or {}) do - if tostring(ComponentGetValue2(comp, "name") or "") == EFFECT_START_MARKER then - return tonumber(ComponentGetValue2(comp, "value_int")) - end - end - return nil -end - -local function ensure_effect_start_frame(entity) - local existing = effect_start_frame(entity) - if existing ~= nil then return existing end - local frame = GameGetFrameNum() - local ok = pcall(EntityAddComponent2, entity, "VariableStorageComponent", { - name=EFFECT_START_MARKER, value_int=frame, - }) - return ok and frame or nil -end - -local function lifetime_remaining(entity) - local comp = lifetime_component(entity) - if comp == 0 then return nil end - local lifetime = tonumber(ComponentGetValue2(comp, "lifetime")) - if lifetime == nil then return nil end - if lifetime < 0 then return -1 end - local started = effect_start_frame(entity) - if started == nil then return lifetime end - return math.max(0, lifetime - math.max(0, GameGetFrameNum() - started)) -end - -local function entity_effect_active(entity) - if entity == nil or entity == 0 or not EntityGetIsAlive(entity) then return false end - for _, comp in ipairs(game_effect_components(entity)) do - if component_active(comp) then return true end - end - local lifetime = lifetime_component(entity) - if lifetime ~= 0 then - local remaining = lifetime_remaining(entity) - return remaining == -1 or remaining == nil or remaining > 0 - end - return false -end - local function game_effect_name(entity) - local components = game_effect_components(entity) - local comp = components[1] + local comp = EntityGetFirstComponentIncludingDisabled(entity, "GameEffectComponent") if comp == nil or comp == 0 then return "" end local effect = tostring(ComponentGetValue2(comp, "effect") or "") if effect == "CUSTOM" then return tostring(ComponentGetValue2(comp, "custom_effect_id") or "") end return effect end -local function set_effect_duration(entity, frames, additive, reset_remaining) - local requested = tonumber(frames) - if requested == nil then return true end - requested = requested < 0 and -1 or math.max(1, math.floor(requested)) - - local components = game_effect_components(entity) - if #components > 0 then - local wrote = false - for _, comp in ipairs(components) do - local current = tonumber(ComponentGetValue2(comp, "frames")) or 0 - local value = requested - if additive == true then - if requested == -1 or current == -1 then - value = -1 - elseif current > 1 then - value = current + requested - end - end - if pcall(ComponentSetValue2, comp, "frames", value) then wrote = true end - end - return wrote - end - - -- Several real player-facing vanilla effects (Twitchy, Wither, Hearty, shot effects) - -- are LifetimeComponent entities rather than GameEffectComponent entities. Treat their - -- lifetime as the same duration surface instead of listing buttons that silently fail. - local lifetime = lifetime_component(entity) - if lifetime == 0 then return false end - local current = tonumber(ComponentGetValue2(lifetime, "lifetime")) or 0 - local value = requested - if reset_remaining == true and requested ~= -1 then - local started = ensure_effect_start_frame(entity) or GameGetFrameNum() - value = math.max(1, GameGetFrameNum() - started) + requested - elseif additive == true then - if requested == -1 or current == -1 then value = -1 else value = math.max(0,current) + requested end +local function apply_game_effect(player, entry, frames) + if type(entry.path) ~= "string" or entry.path == "" or not ModDoesFileExist(entry.path) then return false, "missing" end + local ok, effect_entity = pcall(LoadGameEffectEntityTo, player, entry.path) + if not ok or effect_entity == nil or effect_entity == 0 then return false, "load" end + EntityAddTag(effect_entity, "metamorph_creative_menu_effect") + local comp = EntityGetFirstComponentIncludingDisabled(effect_entity, "GameEffectComponent") + if comp ~= nil and comp ~= 0 then + local duration = tonumber(frames) + if duration ~= nil then pcall(ComponentSetValue2, comp, "frames", duration < 0 and -1 or math.max(1, math.floor(duration))) end end - return pcall(ComponentSetValue2, lifetime, "lifetime", value) -end -local function ensure_hud_icon(effect_entity, entry) -- Use Noita's own right-side HUD rather than a second mod-owned timer panel. A -- UIIconComponent on the same child entity as GameEffectComponent is the standard - -- presentation path; GameEffect frames remain the single source of duration. + -- presentation path; the GameEffect frames remain the single source of duration. local icon = EntityGetFirstComponentIncludingDisabled(effect_entity, "UIIconComponent") if icon == nil or icon == 0 then local ok_icon, created = pcall(EntityAddComponent2, effect_entity, "UIIconComponent", { @@ -263,60 +101,7 @@ local function ensure_hud_icon(effect_entity, entry) pcall(ComponentSetValue2, icon, "is_perk", false) if tostring(entry.icon or "") ~= "" then pcall(ComponentSetValue2, icon, "icon_sprite_file", tostring(entry.icon)) end end - return icon -end - -local function perk_owned_effect(child) - if type(EntityHasTag) ~= "function" then return false end - local ok, tagged = pcall(EntityHasTag, child, "perk_entity") - return ok and tagged == true -end - -local function active_exact_path_effect(player, path, editable_only) - path = tostring(path or "") - if path == "" then return 0 end - for _, child in ipairs(EntityGetAllChildren(player) or {}) do - if EntityGetIsAlive(child) and tostring(EntityGetFilename(child) or "") == path - and entity_effect_active(child) - and (editable_only ~= true or not perk_owned_effect(child)) - then - return child - end - end - return 0 -end - -local function apply_game_effect(player, entry, frames) - if type(entry.path) ~= "string" or entry.path == "" or not ModDoesFileExist(entry.path) then return false, "missing" end - - -- Reapplying the exact same vanilla effect variant extends the existing entity. - -- Creating another child makes Noita show five separate HUD rows for five clicks and - -- also changes semantics for custom effects. Different XML paths remain independent - -- (e.g. the four mushroom-trip strengths deliberately share custom_effect_id TRIP_00). - local duration = requested_duration(entry, frames) - local existing = active_exact_path_effect(player, entry.path, true) - if existing ~= 0 then - -- Ordinary effects stack duration on repeat. Hard control-lock effects (the - -- vanilla electrocution/freeze style) are authored as short pulses and must - -- never become a minutes-long or infinite input lock through the editor. - local additive = effect_service.duration_policy(entry) ~= "authored" - if not set_effect_duration(existing, duration, additive) then return false, "stack_failed" end - ensure_hud_icon(existing, entry) - return true, additive and "stacked" or "retriggered" - end - - local ok, effect_entity = pcall(LoadGameEffectEntityTo, player, entry.path) - if not ok or effect_entity == nil or effect_entity == 0 then return false, "load" end - EntityAddTag(effect_entity, "metamorph_creative_menu_effect") - if lifetime_component(effect_entity) ~= 0 then ensure_effect_start_frame(effect_entity) end - if not set_effect_duration(effect_entity, duration, false) then - pcall(EntityKill, effect_entity) - return false, "component_missing" - end - ensure_hud_icon(effect_entity, entry) - local name = game_effect_name(effect_entity) - if name == "" then name = tostring(entry.id or entry.path or "effect") end - return true, name + return true, game_effect_name(effect_entity) end function effect_service.add(player, entry, frames) @@ -370,30 +155,22 @@ end local function expire_effect_entity(entity) if entity == nil or entity == 0 or not EntityGetIsAlive(entity) then return false, false end - local components = game_effect_components(entity) - if #components == 0 then - if lifetime_component(entity) == 0 or not is_user_facing_effect_entity(entity) then return false, false end - -- Lifetime-backed status entities rely on execute_on_removed cleanup scripts. - -- Killing the exact child is the native way to run those end hooks immediately. - pcall(EntityKill, entity) - return true, true - end - for _, comp in ipairs(components) do - local effect = effective_component_id(comp) - if RESERVED_EFFECTS[effect] then return false, false end - end - if not is_user_facing_effect_entity(entity) then return false, false end - - -- Ask every GameEffectComponent in the selected entity to expire. Some strong - -- variants contain more than one component (the strongest trip also carries its - -- secondary sickness state), so touching only the first left partial residue. - local changed = false - for _, comp in ipairs(components) do - local before = tonumber(ComponentGetValue2(comp, "frames")) or -1 - local ok = pcall(ComponentSetValue2, comp, "frames", 1) - local after = ok and tonumber(ComponentGetValue2(comp, "frames")) or before - if ok and after ~= nil and after <= 1 and (before == -1 or before > 1) then changed = true end - end + local comp = EntityGetFirstComponentIncludingDisabled(entity, "GameEffectComponent") + if comp == nil or comp == 0 then return false, false end + local effect = effective_component_id(comp) + if RESERVED_EFFECTS[effect] or not is_user_facing_effect_entity(entity) then return false, false end + + -- First ask Noita to expire the effect normally. Some persistent GameEffect entities + -- keep their XML child alive even after frames reaches 1, however; merely observing + -- frames<=1 made the old QA report success while PROTECTION_ALL was still resident. + -- For effects created by this service we therefore keep a short, bounded retirement + -- journal: the engine gets several updates to run normal expiry callbacks, then only + -- the still-resident mod-owned entity is retired. Pre-existing/perk effects are never + -- force-killed by this fallback. + local before = tonumber(ComponentGetValue2(comp, "frames")) or -1 + local ok = pcall(ComponentSetValue2, comp, "frames", 1) + local after = ok and tonumber(ComponentGetValue2(comp, "frames")) or before + local changed = ok and after ~= nil and after <= 1 and (before == -1 or before > 1) if changed and EntityHasTag(entity, "metamorph_creative_menu_effect") then pending_expiry[entity] = GameGetFrameNum() end @@ -402,32 +179,6 @@ end function effect_service.update() local frame = GameGetFrameNum() - -- Repair legacy MCM-owned control-lock effects created by older editor builds. - -- Those builds could stretch ELECTROCUTION from its authored 40 frames to minutes/∞. - -- Clamp only MCM-owned children and only components that actually disable movement. - local owned_effects = type(EntityGetWithTag) == "function" and (EntityGetWithTag("metamorph_creative_menu_effect") or {}) or {} - for _, child in ipairs(owned_effects) do - if EntityGetIsAlive(child) then - for _, comp in ipairs(game_effect_components(child)) do - local ok_lock, locked = pcall(ComponentGetValue2, comp, "disable_movement") - if ok_lock and locked == true then - local current = tonumber(ComponentGetValue2(comp, "frames")) or 0 - local filename = tostring(EntityGetFilename(child) or "") - local authored = nil - for _, entry in ipairs(effect_service.catalog()) do - if tostring(entry.path or "") == filename then authored = effect_service.authored_frames(entry); break end - for _, variant in ipairs(type(entry.variants)=="table" and entry.variants or {}) do - if tostring(variant.path or "") == filename then authored = effect_service.authored_frames(variant); break end - end - if authored ~= nil then break end - end - if authored ~= nil and authored > 0 and (current == -1 or current > authored) then - pcall(ComponentSetValue2, comp, "frames", authored) - end - end - end - end - end for entity, started in pairs(pending_expiry) do if entity == nil or entity == 0 or not EntityGetIsAlive(entity) or not EntityHasTag(entity, "metamorph_creative_menu_effect") @@ -435,15 +186,13 @@ function effect_service.update() pending_expiry[entity] = nil else local age = frame - (tonumber(started) or frame) - local components = game_effect_components(entity) - if #components == 0 then + local comp = EntityGetFirstComponentIncludingDisabled(entity, "GameEffectComponent") + if comp == nil or comp == 0 then pending_expiry[entity] = nil else -- Escalate once after the normal frames=1 request. frames=0 gives the -- GameEffect lifecycle another deterministic expiry tick before fallback. - if age >= 2 then - for _, comp in ipairs(components) do pcall(ComponentSetValue2, comp, "frames", 0) end - end + if age >= 2 then pcall(ComponentSetValue2, comp, "frames", 0) end if age >= 4 then -- Only our own tagged child reaches this path. It has already had -- multiple normal expiry updates, so retiring it cannot delete a @@ -489,95 +238,17 @@ function effect_service.update() end matches_entry = function(child, entry) - local filename = tostring(EntityGetFilename(child) or "") - local path = tostring(entry.path or "") - -- Path-backed catalogue entries are exact variants. Never fall through to a shared - -- custom id: all four TRIP XMLs report TRIP_00, and removing one strength must not - -- remove the other three. - if path ~= "" then return filename == path end + local filename = EntityGetFilename(child) + if type(entry.path) == "string" and entry.path ~= "" and filename == entry.path then return true end local comp = EntityGetFirstComponentIncludingDisabled(child, "GameEffectComponent") if comp == nil or comp == 0 then return false end - local effect = effective_component_id(comp) + local effect = tostring(ComponentGetValue2(comp, "effect") or "") + if effect == "CUSTOM" then effect = tostring(ComponentGetValue2(comp, "custom_effect_id") or "") end if type(entry.game_effect) == "string" and entry.game_effect ~= "" and effect == entry.game_effect then return true end if type(entry.custom_effect_id) == "string" and entry.custom_effect_id ~= "" and effect == entry.custom_effect_id then return true end return false end - -function effect_service.remaining_frames(player, entry) - if not valid_player(player) or type(entry) ~= "table" then return nil end - local path = tostring(entry.path or "") - if path ~= "" then - local entity = active_exact_path_effect(player, path) - if entity ~= 0 then - local components = game_effect_components(entity) - if #components == 0 then return lifetime_remaining(entity) end - local remaining = nil - for _, comp in ipairs(components) do - local frames = tonumber(ComponentGetValue2(comp, "frames")) - if frames == -1 then return -1 end - if frames ~= nil and frames > 1 then remaining = math.max(remaining or 0, frames) end - end - return remaining - end - end - return nil -end - -function effect_service.family_remaining_frames(player, entry) - if not valid_player(player) or type(entry) ~= "table" then return nil end - local remaining = nil - for index=1,effect_service.variant_count(entry) do - local value = effect_service.remaining_frames(player, effect_service.resolve_variant(entry,index)) - if value == -1 then return -1 end - if value ~= nil then remaining = math.max(remaining or 0, value) end - end - return remaining -end - -function effect_service.set_remaining(player, entry, frames) - if not valid_player(player) or type(entry) ~= "table" then return false, "target" end - if effect_service.duration_policy(entry) ~= "editable" then return false, "fixed_duration" end - local entity = active_exact_path_effect(player, tostring(entry.path or ""), true) - if entity == 0 then return false, "inactive" end - local duration = requested_duration(entry, frames) - if duration == nil then duration = effect_service.authored_frames(entry) end - if duration == nil then return false, "duration_unknown" end - return set_effect_duration(entity, duration, false, true), "set" -end - -function effect_service.switch_variant(player, entry, variant_index) - if not valid_player(player) or type(entry) ~= "table" then return false, "target" end - local remaining = effect_service.family_remaining_frames(player, entry) - local count = effect_service.variant_count(entry) - variant_index = math.max(1, math.min(count, math.floor(tonumber(variant_index) or 1))) - if remaining == nil then return true, "selected" end - local selected = effect_service.resolve_variant(entry, variant_index) - effect_service.remove_family(player, entry) - local duration = effect_service.duration_policy(selected) == "authored" and nil or remaining - return effect_service.add(player, selected, duration) -end - -function effect_service.is_family_active(player, entry, snapshot) - if type(entry) ~= "table" then return false end - local count = effect_service.variant_count(entry) - for index=1,count do - if effect_service.is_active(player, effect_service.resolve_variant(entry,index), snapshot) then return true end - end - return false -end - --- Return the strongest currently active authored variant. This lets the editor open on the --- state the player actually has instead of always snapping back to variant 1. -function effect_service.active_variant_index(player, entry, snapshot) - if type(entry) ~= "table" then return nil end - for index=effect_service.variant_count(entry),1,-1 do - if effect_service.is_active(player, effect_service.resolve_variant(entry,index), snapshot) then - return index - end - end - return nil -end function effect_service.remove(player, entry) if not valid_player(player) or type(entry) ~= "table" then return 0 end local removed = 0 @@ -597,42 +268,6 @@ function effect_service.remove(player, entry) return removed end --- Variant families represent mutually-exclusive authored stages (TRIP, CURSE_CLOUD, --- ingestion drunkenness, ...), not independent catalogue effects. Applying a new stage --- retires active sibling stages, while reapplying the already-selected stage keeps the --- useful additive-duration behavior. -function effect_service.set_variant(player, entry, variant_index, frames) - if not valid_player(player) or type(entry) ~= "table" then return false, "target" end - local count = effect_service.variant_count(entry) - variant_index = math.max(1, math.min(count, math.floor(tonumber(variant_index) or 1))) - local selected = effect_service.resolve_variant(entry, variant_index) - if count > 1 then - local snapshot = effect_service.active_snapshot(player) - for index=1,count do - if index ~= variant_index then - local sibling = effect_service.resolve_variant(entry, index) - if effect_service.is_active(player, sibling, snapshot) then effect_service.remove(player, sibling) end - end - end - end - return effect_service.add(player, selected, frames) -end - -function effect_service.remove_family(player, entry) - if not valid_player(player) or type(entry) ~= "table" then return 0 end - local removed = 0 - local seen = {} - for index=1,effect_service.variant_count(entry) do - local variant = effect_service.resolve_variant(entry, index) - local key = tostring(variant.path or "") .. "|" .. tostring(variant.id or "") - if not seen[key] then - seen[key] = true - removed = removed + effect_service.remove(player, variant) - end - end - return removed -end - function effect_service.flush_owned(player) if not valid_player(player) then return 0 end local retired = 0 @@ -702,16 +337,16 @@ function effect_service.active_snapshot(player) end for _, child in ipairs(EntityGetAllChildren(player) or {}) do - local active = entity_effect_active(child) - if active then - for _, comp in ipairs(game_effect_components(child)) do - if component_active(comp) then - local effect = effective_component_id(comp) - if effect ~= "" then snapshot.effect_ids[effect] = true end - end + local comp = EntityGetFirstComponentIncludingDisabled(child, "GameEffectComponent") + if comp ~= nil and comp ~= 0 then + local frames = tonumber(ComponentGetValue2(comp, "frames")) or 0 + if frames == -1 or frames > 1 then + local filename = EntityGetFilename(child) + if type(filename) == "string" and filename ~= "" then snapshot.paths[filename] = true end + local effect = tostring(ComponentGetValue2(comp, "effect") or "") + if effect == "CUSTOM" then effect = tostring(ComponentGetValue2(comp, "custom_effect_id") or "") end + if effect ~= "" then snapshot.effect_ids[effect] = true end end - local filename = EntityGetFilename(child) - if type(filename) == "string" and filename ~= "" then snapshot.paths[filename] = true end end end return snapshot @@ -728,7 +363,7 @@ function effect_service.is_active(player, entry, snapshot) then return true end end local path = tostring(entry.path or "") - if path ~= "" then return snapshot.paths[path] == true end + if path ~= "" and snapshot.paths[path] then return true end local game_effect = tostring(entry.game_effect or "") if game_effect ~= "" and snapshot.effect_ids[game_effect] then return true end local custom_effect = tostring(entry.custom_effect_id or "") @@ -746,7 +381,11 @@ function effect_service.is_active(player, entry, snapshot) end end for _, child in ipairs(EntityGetAllChildren(player) or {}) do - if matches_entry(child, entry) and entity_effect_active(child) then return true end + if matches_entry(child, entry) then + local comp = EntityGetFirstComponentIncludingDisabled(child, "GameEffectComponent") + local frames = comp ~= nil and comp ~= 0 and tonumber(ComponentGetValue2(comp, "frames")) or 0 + if frames == -1 or frames > 1 then return true end + end end return false end diff --git a/metamorph_creative_menu/files/features/forms/adapters/fish.lua b/metamorph_creative_menu/files/features/forms/adapters/fish.lua index eea83d3..8797aee 100644 --- a/metamorph_creative_menu/files/features/forms/adapters/fish.lua +++ b/metamorph_creative_menu/files/features/forms/adapters/fish.lua @@ -7,23 +7,8 @@ local valid = component_ops.valid local component = component_ops.first local ensure_controls = component_ops.ensure_controls local set_component_type_enabled = component_ops.set_type_enabled -local set_typed_scalar = component_ops.set_typed_scalar - -function fish_adapter.configure(entity, profile) - -- Native polymorph can leave player respiratory values on the playerized body. - -- Fish authored with air_needed=0 must keep that property; otherwise the transformed - -- player drowns in water even though the source creature cannot. Restore only fields - -- explicitly authored by the selected form so modded fish retain their own breathing - -- model instead of receiving a hard-coded fish exemption. - local damage_model = component(entity, "DamageModelComponent") - local authored_damage = type(profile) == "table" and profile.damage or nil - if valid(damage_model) and type(authored_damage) == "table" then - for _, field in ipairs({"air_needed", "air_in_lungs_max", "air_in_lungs", "air_lack_of_damage"}) do - local authored = authored_damage[field] - if authored ~= nil then set_typed_scalar(damage_model, field, authored) end - end - end +function fish_adapter.configure(entity) set_component_type_enabled(entity, "AdvancedFishAIComponent", true) local fish_ai_component = component(entity, "AdvancedFishAIComponent") if valid(fish_ai_component) then diff --git a/metamorph_creative_menu/files/features/forms/adapters/manual_actions.lua b/metamorph_creative_menu/files/features/forms/adapters/manual_actions.lua deleted file mode 100644 index 3b7f35f..0000000 --- a/metamorph_creative_menu/files/features/forms/adapters/manual_actions.lua +++ /dev/null @@ -1,51 +0,0 @@ -local manual_actions = {} - -local component_ops = dofile("mods/metamorph_creative_menu/files/features/forms/component_ops.lua") -local mimic_melee = dofile("mods/metamorph_creative_menu/files/features/forms/adapters/manual_actions/mimic_melee.lua") -local physics_projectile = dofile("mods/metamorph_creative_menu/files/features/forms/adapters/manual_actions/physics_projectile.lua") -local blood_crystal = dofile("mods/metamorph_creative_menu/files/features/forms/adapters/manual_actions/blood_crystal.lua") - -local valid = component_ops.valid -local ensure_controls = component_ops.ensure_controls -local active_entity = 0 - -function manual_actions.reset() - active_entity = 0 - mimic_melee.reset() - physics_projectile.reset() - blood_crystal.reset() -end - -function manual_actions.configure(entity, path, profile) - manual_actions.reset() - if entity == nil or entity == 0 then return false end - active_entity = entity - local configured = false - configured = mimic_melee.configure(entity, path) or configured - configured = physics_projectile.configure(entity, path) or configured - configured = blood_crystal.configure(entity, path) or configured - return configured -end - -function manual_actions.update(entity) - if entity == nil or entity == 0 or entity ~= active_entity or not EntityGetIsAlive(entity) then return false end - local controls = ensure_controls(entity) - if not valid(controls) then return false end - local used = false - used = mimic_melee.update(entity, controls) or used - used = physics_projectile.update(entity, controls) or used - used = blood_crystal.update(entity, controls) or used - return used -end - -function manual_actions.owns_primary() - return mimic_melee.owns_primary() or physics_projectile.owns_primary() or blood_crystal.owns_primary() -end - --- Reserved as a stable coordinator surface. Manual creature adapters currently do not --- own secondary fire; dash support was intentionally removed rather than left dormant. -function manual_actions.owns_secondary() - return false -end - -return manual_actions diff --git a/metamorph_creative_menu/files/features/forms/adapters/manual_actions/blood_crystal.lua b/metamorph_creative_menu/files/features/forms/adapters/manual_actions/blood_crystal.lua deleted file mode 100644 index 7f3a946..0000000 --- a/metamorph_creative_menu/files/features/forms/adapters/manual_actions/blood_crystal.lua +++ /dev/null @@ -1,117 +0,0 @@ -local blood_crystal = {} - -local component_ops = dofile("mods/metamorph_creative_menu/files/features/forms/component_ops.lua") -local gameplay_input = dofile("mods/metamorph_creative_menu/files/platform/noita/gameplay_input.lua") - -local valid = component_ops.valid -local get_value = component_ops.get - -local SUPPORTED = { - ["data/entities/animals/bloodcrystal_physics.xml"] = true, - ["data/entities/animals/the_end/bloodcrystal_physics.xml"] = true, -} - -local COLLISION_SCRIPT = "data/scripts/animals/bloodcrystal_explosion.lua" -local PROJECTILE = "data/entities/projectiles/orb_pink.xml" -local state = nil - -local function component_enabled(component) - if not valid(component) or type(ComponentGetIsEnabled) ~= "function" then return false end - local ok, enabled = pcall(ComponentGetIsEnabled, component) - return ok and enabled == true -end - -local function restore_owned_components() - if state == nil then return end - for _, record in ipairs(state.suppressed or {}) do - if valid(record.component) and record.owner ~= nil and record.owner ~= 0 and EntityGetIsAlive(record.owner) then - pcall(EntitySetComponentIsEnabled, record.owner, record.component, record.enabled == true) - end - end - state.suppressed = {} -end - -local function suppress_vanilla_collision_attack(entity) - local suppressed = {} - for _, lua in ipairs(EntityGetComponentIncludingDisabled(entity, "LuaComponent") or {}) do - local collision = tostring(get_value(lua, "script_collision_trigger_hit", "") or "") - if collision == COLLISION_SCRIPT then - suppressed[#suppressed + 1] = { owner=entity, component=lua, enabled=component_enabled(lua) } - pcall(EntitySetComponentIsEnabled, entity, lua, false) - end - end - return suppressed -end - -local function set_projectile_velocity(projectile, vx, vy) - local velocity = EntityGetFirstComponentIncludingDisabled(projectile, "VelocityComponent") - if valid(velocity) then pcall(ComponentSetValue2, velocity, "mVelocity", vx, vy) end -end - -local function shoot(entity, path, x, y, vx, vy) - local projectile = EntityLoad(path, x, y) - if projectile == nil or projectile == 0 then return false end - local ok = pcall(GameShootProjectile, entity, x, y, x + vx, y + vy, projectile, true) - if not ok then - pcall(EntityKill, projectile) - return false - end - set_projectile_velocity(projectile, vx, vy) - return true -end - -local function radial_attack(entity) - local x, y = EntityGetTransform(entity) - if x == nil then return false end - -- Byte-for-behaviour translation of bloodcrystal_explosion.lua: 12 pink orbs, - -- 30 degrees apart, speed 100, spawned five pixels around the crystal. The spawn - -- position intentionally uses the *next* theta just like the vanilla script. - local how_many = 12 - local angle_inc = (2 * math.pi) / how_many - local theta = 0 - local speed = 100 - local dist = 5 - local fired = false - for _ = 1, how_many do - local vx = math.cos(theta) * speed - local vy = math.sin(theta) * speed - theta = theta + angle_inc - local px = x + math.cos(theta) * dist - local py = y + math.sin(theta) * dist - fired = shoot(entity, PROJECTILE, px, py, vx, vy) or fired - end - return fired -end - -function blood_crystal.reset() - restore_owned_components() - state = nil -end - -function blood_crystal.configure(entity, path) - blood_crystal.reset() - if SUPPORTED[tostring(path or "")] ~= true then return false end - state = { - entity = entity, - next_frame = 0, - cooldown = 10, -- script_wait_frames(entity, 10) in vanilla - suppressed = suppress_vanilla_collision_attack(entity), - } - gameplay_input.require_release("primary") - return true -end - -function blood_crystal.update(entity, controls) - if state == nil or entity ~= state.entity then return false end - local frame = tonumber(GameGetFrameNum()) or 0 - if gameplay_input.down(controls, "primary") and frame >= state.next_frame then - if radial_attack(entity) then state.next_frame = frame + state.cooldown end - end - return true -end - -function blood_crystal.owns_primary() - return state ~= nil -end - -return blood_crystal diff --git a/metamorph_creative_menu/files/features/forms/adapters/manual_actions/mimic_melee.lua b/metamorph_creative_menu/files/features/forms/adapters/manual_actions/mimic_melee.lua deleted file mode 100644 index 12b4f6d..0000000 --- a/metamorph_creative_menu/files/features/forms/adapters/manual_actions/mimic_melee.lua +++ /dev/null @@ -1,210 +0,0 @@ -local mimic_melee = {} - -local entity_tree = dofile("mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua") -local component_ops = dofile("mods/metamorph_creative_menu/files/features/forms/component_ops.lua") -local gameplay_input = dofile("mods/metamorph_creative_menu/files/platform/noita/gameplay_input.lua") - -local valid = component_ops.valid -local get_value = component_ops.get -local boolean_value = component_ops.boolean - -local MIMIC_PATHS = { - ["data/entities/animals/chest_mimic.xml"] = true, - ["data/entities/animals/chest_leggy.xml"] = true, - ["data/entities/animals/illusions/dark_alchemist.xml"] = true, - ["data/entities/animals/illusions/shaman_wind.xml"] = true, -} - -local state = nil -local animation_cache = {} - -local function component_enabled(component) - if not valid(component) or type(ComponentGetIsEnabled) ~= "function" then return true end - local ok, enabled = pcall(ComponentGetIsEnabled, component) - return not ok or enabled == true -end - -local function restore_animators() - if state == nil then return end - for _, record in ipairs(state.animators or {}) do - if valid(record.component) and record.owner ~= nil and record.owner ~= 0 and EntityGetIsAlive(record.owner) then - pcall(EntitySetComponentIsEnabled, record.owner, record.component, record.enabled == true) - end - end - state.animators = {} - state.animation_end_frame = -1 -end - -local function suppress_animators(entity) - if state == nil or #(state.animators or {}) > 0 then return end - entity_tree.walk(entity, function(current) - for _, animator in ipairs(EntityGetComponentIncludingDisabled(current, "SpriteAnimatorComponent") or {}) do - state.animators[#state.animators + 1] = { - owner=current, component=animator, enabled=component_enabled(animator), - } - pcall(EntitySetComponentIsEnabled, current, animator, false) - end - end) -end - -local function descriptor(entity) - local found = nil - entity_tree.walk(entity, function(current) - for _, ai in ipairs(EntityGetComponentIncludingDisabled(current, "AnimalAIComponent") or {}) do - if boolean_value(get_value(ai, "attack_melee_enabled", false)) == true then - found = ai - return false - end - end - end) - return found -end - -local function same_herd(a, b) - local ga = EntityGetFirstComponentIncludingDisabled(a, "GenomeDataComponent") - local gb = EntityGetFirstComponentIncludingDisabled(b, "GenomeDataComponent") - if not valid(ga) or not valid(gb) then return false end - return get_value(ga, "herd_id", -100000) == get_value(gb, "herd_id", -100001) -end - -local function damage_amount() - if state == nil or state.damage_max <= state.damage_min or type(Random) ~= "function" then - return state and state.damage_min or 0 - end - local scale = 10000 - local ok, value = pcall(Random, math.floor(state.damage_min * scale), math.floor(state.damage_max * scale)) - return ok and (tonumber(value) or state.damage_min * scale) / scale or state.damage_min -end - -local function bite(entity) - if state == nil then return false end - local x, y = EntityGetTransform(entity) - if x == nil then return false end - local root = EntityGetRootEntity(entity) - local best, best_d2 = 0, nil - for _, candidate in ipairs(EntityGetInRadiusWithTag(x, y, state.radius, "hittable") or {}) do - local target = EntityGetRootEntity(candidate) - if target ~= 0 and target ~= root and EntityGetIsAlive(target) and not same_herd(root, target) then - local tx, ty = EntityGetTransform(target) - if tx ~= nil then - local dx, dy = tx - x, ty - y - local d2 = dx * dx + dy * dy - if best_d2 == nil or d2 < best_d2 then best, best_d2 = target, d2 end - end - end - end - if best == 0 then return false end - return pcall(EntityInflictDamage, best, damage_amount(), "DAMAGE_MELEE", "metamorph mimic bite", "NONE", 0, 0, root, x, y, 0) -end - -local function attack_animation_timing(image_file) - image_file = tostring(image_file or "") - if image_file == "" then return nil end - if animation_cache[image_file] ~= nil then return animation_cache[image_file] or nil end - local ok, content = pcall(ModTextFileGetContent, image_file) - if not ok or type(content) ~= "string" then - animation_cache[image_file] = false - return nil - end - local block = string.match(content, ']-name%s*=%s*["\']attack["\'][^>]*>') - or string.match(content, '') - if block == nil then - animation_cache[image_file] = false - return nil - end - local frame_wait = tonumber(string.match(block, 'frame_wait%s*=%s*["\']([^"\']+)["\']')) or 0.07 - local frame_count = tonumber(string.match(block, 'frame_count%s*=%s*["\']([^"\']+)["\']')) or 7 - local timing = { - frame_ticks = math.max(1, math.floor(frame_wait * 60 + 0.5)), - total_ticks = math.max(1, math.ceil(frame_wait * 60 * frame_count)), - } - animation_cache[image_file] = timing - return timing -end - -local function play_bite_animation(entity) - local best_timing = nil - -- chest_leggy's SpriteAnimatorComponent can rewrite the root SpriteComponent back to - -- locomotion on the very next engine update. Temporarily suspend only that - -- presentation driver while the authored non-looping bite animation plays; restore - -- its exact previous enabled state afterwards. - suppress_animators(entity) - entity_tree.walk(entity, function(current) - for _, sprite in ipairs(EntityGetComponentIncludingDisabled(current, "SpriteComponent") or {}) do - local image = tostring(get_value(sprite, "image_file", "") or "") - local timing = attack_animation_timing(image) - if timing ~= nil then - -- Physics/IK forms such as chest_leggy do not always react to - -- GamePlayAnimation after their AI has been playerized. Drive the - -- authored SpriteComponent directly and ask it to return to stand when - -- the non-looping attack animation completes. - pcall(ComponentSetValue2, sprite, "rect_animation", "attack") - pcall(ComponentSetValue2, sprite, "next_rect_animation", "stand") - pcall(EntitySetComponentIsEnabled, current, sprite, true) - if type(EntityRefreshSprite) == "function" then pcall(EntityRefreshSprite, current, sprite) end - if best_timing == nil or timing.total_ticks > best_timing.total_ticks then best_timing = timing end - end - end - end) - pcall(GamePlayAnimation, entity, "attack", 45, "", 0) - return best_timing -end - -function mimic_melee.reset() - restore_animators() - state = nil -end - -function mimic_melee.configure(entity, path) - mimic_melee.reset() - if MIMIC_PATHS[tostring(path or "")] ~= true then return false end - local ai = descriptor(entity) - if not valid(ai) then return false end - local damage_min = math.max(0, tonumber(get_value(ai, "attack_melee_damage_min", 0.4)) or 0.4) - local damage_max = math.max(0, tonumber(get_value(ai, "attack_melee_damage_max", 0.6)) or 0.6) - if damage_max < damage_min then damage_min, damage_max = damage_max, damage_min end - state = { - damage_min = damage_min, - damage_max = damage_max, - radius = math.max(1, tonumber(get_value(ai, "attack_melee_max_distance", 20)) or 20), - action_frame = math.max(0, math.floor(tonumber(get_value(ai, "attack_melee_action_frame", 2)) or 2)), - cooldown = math.max(1, math.floor(tonumber(get_value(ai, "attack_melee_frames_between", 10)) or 10)), - next_frame = 0, - pending_frame = -1, - animation_end_frame = -1, - animators = {}, - } - gameplay_input.require_release("primary") - return true -end - -function mimic_melee.update(entity, controls) - if state == nil then return false end - local frame = tonumber(GameGetFrameNum()) or 0 - if state.animation_end_frame >= 0 and frame >= state.animation_end_frame then - restore_animators() - end - if state.pending_frame >= 0 and frame >= state.pending_frame then - state.pending_frame = -1 - bite(entity) - end - if gameplay_input.down(controls, "primary") and state.pending_frame < 0 and frame >= state.next_frame then - local timing = play_bite_animation(entity) - local frame_ticks = timing and timing.frame_ticks or 4 - local total_ticks = timing and timing.total_ticks or (state.action_frame * 4 + 8) - state.pending_frame = frame + state.action_frame * frame_ticks - state.animation_end_frame = frame + total_ticks - state.next_frame = frame + math.max(state.cooldown, total_ticks) - if state.action_frame <= 0 then - state.pending_frame = -1 - bite(entity) - end - end - return true -end - -function mimic_melee.owns_primary() - return state ~= nil -end - -return mimic_melee diff --git a/metamorph_creative_menu/files/features/forms/adapters/manual_actions/physics_projectile.lua b/metamorph_creative_menu/files/features/forms/adapters/manual_actions/physics_projectile.lua deleted file mode 100644 index 9d990c3..0000000 --- a/metamorph_creative_menu/files/features/forms/adapters/manual_actions/physics_projectile.lua +++ /dev/null @@ -1,99 +0,0 @@ -local physics_projectile = {} - -local component_ops = dofile("mods/metamorph_creative_menu/files/features/forms/component_ops.lua") -local gameplay_input = dofile("mods/metamorph_creative_menu/files/platform/noita/gameplay_input.lua") - -local valid = component_ops.valid -local get_value = component_ops.get -local boolean_value = component_ops.boolean - -local SUPPORTED = { - ["data/entities/animals/crystal_physics.xml"] = true, - ["data/entities/animals/skycrystal_physics.xml"] = true, -} - -local state = nil - -local function mouse_target(x, y) - if type(DEBUG_GetMouseWorld) == "function" then - local ok, mx, my = pcall(DEBUG_GetMouseWorld) - if ok and tonumber(mx) ~= nil and tonumber(my) ~= nil then return tonumber(mx), tonumber(my) end - end - return x + 100, y -end - -local function find_descriptor(entity) - for _, ai in ipairs(EntityGetComponentIncludingDisabled(entity, "AnimalAIComponent") or {}) do - local path = tostring(get_value(ai, "attack_ranged_entity_file", "") or "") - if boolean_value(get_value(ai, "attack_ranged_enabled", false)) == true and path ~= "" then return ai end - end - return nil -end - -function physics_projectile.reset() - state = nil -end - -function physics_projectile.configure(entity, path) - physics_projectile.reset() - if SUPPORTED[tostring(path or "")] ~= true then return false end - local ai = find_descriptor(entity) - if not valid(ai) then return false end - state = { - component = ai, - projectile = tostring(get_value(ai, "attack_ranged_entity_file", "") or ""), - offset_x = tonumber(get_value(ai, "attack_ranged_offset_x", 0)) or 0, - offset_y = tonumber(get_value(ai, "attack_ranged_offset_y", 0)) or 0, - action_frame = math.max(0, math.floor(tonumber(get_value(ai, "attack_ranged_action_frame", 0)) or 0)), - cooldown = math.max(1, math.floor(tonumber(get_value(ai, "attack_ranged_frames_between", 30)) or 30)), - next_frame = 0, - pending_frame = -1, - } - gameplay_input.require_release("primary") - return state.projectile ~= "" -end - -local function fire(entity) - if state == nil or not valid(state.component) then return false end - -- Live XML/component state remains authoritative so a phase script or another mod can - -- disable/rewrite the projectile while the player is already transformed. - if boolean_value(get_value(state.component, "attack_ranged_enabled", false)) ~= true then return false end - local projectile_path = tostring(get_value(state.component, "attack_ranged_entity_file", "") or "") - if projectile_path == "" then return false end - local x, y, _, sx = EntityGetTransform(entity) - if x == nil then return false end - sx = tonumber(sx) or 1 - local ox = state.offset_x - if sx < 0 then ox = -ox end - local px, py = x + ox, y + state.offset_y - local tx, ty = mouse_target(px, py) - local projectile = EntityLoad(projectile_path, px, py) - if projectile == nil or projectile == 0 then return false end - local ok = pcall(GameShootProjectile, entity, px, py, tx, ty, projectile, true) - if not ok then pcall(EntityKill, projectile) end - return ok -end - -function physics_projectile.update(entity, controls) - if state == nil then return false end - local frame = tonumber(GameGetFrameNum()) or 0 - if state.pending_frame >= 0 and frame >= state.pending_frame then - state.pending_frame = -1 - fire(entity) - end - if gameplay_input.down(controls, "primary") and state.pending_frame < 0 and frame >= state.next_frame then - state.pending_frame = frame + state.action_frame - state.next_frame = frame + state.cooldown - if state.action_frame <= 0 then - state.pending_frame = -1 - fire(entity) - end - end - return true -end - -function physics_projectile.owns_primary() - return state ~= nil -end - -return physics_projectile diff --git a/metamorph_creative_menu/files/features/forms/adapters/physics.lua b/metamorph_creative_menu/files/features/forms/adapters/physics.lua index 06edd30..e6e676b 100644 --- a/metamorph_creative_menu/files/features/forms/adapters/physics.lua +++ b/metamorph_creative_menu/files/features/forms/adapters/physics.lua @@ -107,21 +107,13 @@ end function physics_adapter.configure(entity, profile, is_ik) steering_x, steering_y = 0, 0 captured_tuning = capture_tuning(entity, profile) - -- Several authored physics/IK bosses (notably Kolmisilma) spawn with a static root and - -- expect their encounter script to call PhysicsSetStatic(false). Player forms suppress - -- that autonomous encounter script, so forces were being applied to an immovable body. - if type(PhysicsSetStatic) == "function" then pcall(PhysicsSetStatic, entity, false) end set_component_type_enabled(entity, "PhysicsAIComponent", false) local physics_tuning = captured_tuning or (profile and profile.physics_ai or {}) - local pure_flyer = type(profile) == "table" and profile.pure_flyer == true - if is_ik and not pure_flyer then configure_ik_attachment(entity) end + if is_ik then configure_ik_attachment(entity) end for _, physics_body_id in ipairs(PhysicsBodyIDGetFromEntity(entity) or {}) do - -- LimbBoss/IK is a shape/animation family, not proof that the authored creature - -- is ground-bound. Several bosses (boss_robot, boss_meat, boss_limbs, - -- boss_centipede, boss_pit) explicitly have can_fly=1 + can_walk=0. Forcing - -- gravity back to 1 made those player forms sink continuously and rendered UP - -- almost useless. Preserve zero-gravity flight for that whole profile family. - if pure_flyer or physics_tuning.levitate == true then + if is_ik then + pcall(PhysicsBodyIDSetGravityScale, physics_body_id, 1) + elseif physics_tuning.levitate == true then pcall(PhysicsBodyIDSetGravityScale, physics_body_id, 0) else pcall(PhysicsBodyIDSetGravityScale, physics_body_id, 1) @@ -141,9 +133,8 @@ function physics_adapter.update(entity, profile, is_ik) local torque_balance_coefficient = tonumber(physics_tuning.torque_balancing_coeff) or 0.2 local maximum_torque = tonumber(physics_tuning.torque_max) or 50 - local pure_flyer = type(profile) == "table" and profile.pure_flyer == true local has_support = true - if is_ik and not pure_flyer then + if is_ik then local attached_count, attachment_count = ik_attachment_count(entity) local required_attachments = attachment_count >= 2 and 2 or 1 has_support = attachment_count == 0 or attached_count >= required_attachments @@ -155,10 +146,6 @@ function physics_adapter.update(entity, profile, is_ik) local entity_x, entity_y = EntityGetTransform(entity) if entity_x ~= nil then for _, limb_component in ipairs(tree_components(entity, "LimbBossComponent")) do - -- limbboss_system.cpp state 4 = MoveTo. Merely writing mMoveToPosition while - -- leaving the authored FollowPlayer state active has no player target after the - -- AI components are suppressed, which collapses Kolmi's legs at the root. - pcall(ComponentSetValue2, limb_component, "state", 4) pcall(ComponentSetValue2, limb_component, "mMoveToPositionX", entity_x + direction_x * 96) local target_y = entity_y + direction_y * 96 if has_support or target_y >= entity_y then diff --git a/metamorph_creative_menu/files/features/forms/adapters/static_weapon_presentation.lua b/metamorph_creative_menu/files/features/forms/adapters/static_weapon_presentation.lua deleted file mode 100644 index d173beb..0000000 --- a/metamorph_creative_menu/files/features/forms/adapters/static_weapon_presentation.lua +++ /dev/null @@ -1,80 +0,0 @@ -local static_weapon_presentation = {} - -local aim_policy = dofile("mods/metamorph_creative_menu/files/features/forms/aim_policy.lua") -local component_ops = dofile("mods/metamorph_creative_menu/files/features/forms/component_ops.lua") - -local valid = component_ops.valid -local get_value = component_ops.get - -local state = nil - -local function root_facing(entity, current) - -- The body/tracks are already mirrored by Noita. Static gun layers with - -- has_special_scale=1 opt out of that inherited scale, which is why a playerized - -- tank could turn its tracks while leaving the upper gun facing the old side. - -- Prefer the actual entity transform so presentation follows exactly what the body - -- is rendering; ControlsComponent is only a fallback for unusual physics frames. - local _, _, _, sx = EntityGetTransform(entity) - sx = tonumber(sx) - if sx ~= nil and math.abs(sx) > 0.0001 then return sx < 0 and -1 or 1 end - local controls = EntityGetFirstComponentIncludingDisabled(entity, "ControlsComponent") - if valid(controls) then - local left = get_value(controls, "mButtonDownLeft", false) == true - local right = get_value(controls, "mButtonDownRight", false) == true - if left ~= right then return right and 1 or -1 end - end - return current or 1 -end - -local function restore() - if state == nil then return end - for _, record in ipairs(state.layers or {}) do - if valid(record.component) and record.owner ~= nil and record.owner ~= 0 and EntityGetIsAlive(record.owner) then - pcall(ComponentSetValue2, record.component, "has_special_scale", record.had_special_scale == true) - pcall(ComponentSetValue2, record.component, "special_scale_x", record.base_scale_x) - pcall(ComponentSetValue2, record.component, "special_scale_y", record.base_scale_y) - end - end -end - -function static_weapon_presentation.reset() - restore() - state = nil -end - -function static_weapon_presentation.configure(entity) - static_weapon_presentation.reset() - if entity == nil or entity == 0 then return false end - local layers = {} - for _, sprite in ipairs(EntityGetComponentIncludingDisabled(entity, "SpriteComponent") or {}) do - if aim_policy.sprite_is_static_weapon_layer(sprite) then - layers[#layers + 1] = { - owner = entity, - component = sprite, - had_special_scale = get_value(sprite, "has_special_scale", false) == true, - base_scale_x = tonumber(get_value(sprite, "special_scale_x", 1)) or 1, - base_scale_y = tonumber(get_value(sprite, "special_scale_y", 1)) or 1, - } - end - end - if #layers == 0 then return false end - state = { entity=entity, layers=layers, facing=root_facing(entity, 1) } - return true -end - -function static_weapon_presentation.update(entity) - if state == nil or entity ~= state.entity then return false end - state.facing = root_facing(entity, state.facing) - for _, record in ipairs(state.layers) do - if valid(record.component) and EntityGetIsAlive(record.owner) then - -- Let this non-rotating authored layer inherit the same entity-scale flip as - -- the tracks/body. This is more faithful than inventing negative - -- special_scale values (the engine treats special scale as a separate - -- presentation channel) and is trivially reversible on reset. - pcall(ComponentSetValue2, record.component, "has_special_scale", false) - end - end - return true -end - -return static_weapon_presentation diff --git a/metamorph_creative_menu/files/features/forms/aim_policy.lua b/metamorph_creative_menu/files/features/forms/aim_policy.lua deleted file mode 100644 index 25d2d93..0000000 --- a/metamorph_creative_menu/files/features/forms/aim_policy.lua +++ /dev/null @@ -1,43 +0,0 @@ -local aim_policy = {} - -local component_ops = dofile("mods/metamorph_creative_menu/files/features/forms/component_ops.lua") -local get_value = component_ops.get -local boolean_value = component_ops.boolean - -local function weapon_sprite_name(sprite) - local image = string.lower(tostring(get_value(sprite, "image_file", "") or "")) - return string.match(image, "([^/]+)%.xml$") or image -end - -local function looks_like_weapon_layer(name) - return name == "gun" or name == "shotgun" or name == "barrel" or name == "cannon" - or string.find(name, "^gun_", 1) ~= nil or string.find(name, "_gun$", 1) ~= nil - or string.find(name, "_gun_", 1, true) ~= nil - or string.find(name, "^barrel_", 1) ~= nil or string.find(name, "_barrel$", 1) ~= nil - or string.find(name, "_barrel_", 1, true) ~= nil - or string.find(name, "^cannon_", 1) ~= nil or string.find(name, "_cannon$", 1) ~= nil - or string.find(name, "_cannon_", 1, true) ~= nil -end - -function aim_policy.sprite_is_static_weapon_layer(sprite) - if sprite == nil or sprite == 0 then return false end - if not looks_like_weapon_layer(weapon_sprite_name(sprite)) then return false end - local update_rotation = get_value(sprite, "update_transform_rotation", true) - return boolean_value(update_rotation) ~= true -end - -function aim_policy.static_weapon_attack(entity, attack_component) - if attack_component == nil or attack_component == 0 then return false end - local owner = type(ComponentGetEntity) == "function" and ComponentGetEntity(attack_component) or entity - if owner == nil or owner == 0 then owner = entity end - local root = type(EntityGetRootEntity) == "function" and EntityGetRootEntity(owner) or owner - -- This policy is for authored root-level presentation layers such as vanilla tanks. - -- Child-owned turrets are genuine transform pivots and keep the normal rotation path. - if owner ~= root then return false end - for _, sprite in ipairs(EntityGetComponentIncludingDisabled(owner, "SpriteComponent") or {}) do - if aim_policy.sprite_is_static_weapon_layer(sprite) then return true end - end - return false -end - -return aim_policy diff --git a/metamorph_creative_menu/files/features/forms/attack_profile.lua b/metamorph_creative_menu/files/features/forms/attack_profile.lua deleted file mode 100644 index a4cd697..0000000 --- a/metamorph_creative_menu/files/features/forms/attack_profile.lua +++ /dev/null @@ -1,162 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_FORM_ATTACK_PROFILE) == "table" then - return METAMORPH_CREATIVE_MENU_FORM_ATTACK_PROFILE -end - -local attack_profile = {} - -local function finite(value, fallback) - value = tonumber(value) - if value == nil or value ~= value or value == math.huge or value == -math.huge then return fallback end - return value -end - -local function nonnegative(value, fallback) - return math.max(0, finite(value, fallback or 0) or 0) -end - -local function integer_nonnegative(value, fallback) - return math.floor(nonnegative(value, fallback) + 0.0000001) -end - -local function bool(value) - if type(value) == "string" then - local lower = string.lower(value) - if lower == "true" then return true end - if lower == "false" then return false end - value = tonumber(value) - end - if type(value) == "number" then return value ~= 0 end - return value == true -end - -function attack_profile.normalize(spec) - spec = type(spec) == "table" and spec or {} - local low = math.max(1, integer_nonnegative(spec.count_min, 1)) - local high = math.max(low, integer_nonnegative(spec.count_max, low)) - local min_distance = nonnegative(spec.min_distance, 0) - local max_distance = nonnegative(spec.max_distance, 10000) - if max_distance < min_distance then min_distance, max_distance = max_distance, min_distance end - return { - source_kind = tostring(spec.source_kind or ""), - component = spec.component, - path = tostring(spec.path or ""), - frames = integer_nonnegative(spec.frames, 30), - global_frames = integer_nonnegative(spec.global_frames, 0), - count_min = low, - count_max = high, - action_frame = integer_nonnegative(spec.action_frame, 0), - animation = tostring(spec.animation or "attack_ranged"), - min_distance = min_distance, - max_distance = max_distance, - use_probability = math.max(0, math.min(100, integer_nonnegative(spec.use_probability, 100))), - state_duration_frames = integer_nonnegative(spec.state_duration_frames, 45), - angular_range_deg = math.max(0, math.min(90, finite(spec.angular_range_deg, 90) or 90)), - offset_x = finite(spec.offset_x, 0) or 0, - offset_y = finite(spec.offset_y, 0) or 0, - root_offset_x = finite(spec.root_offset_x, 0) or 0, - root_offset_y = finite(spec.root_offset_y, 0) or 0, - use_message = bool(spec.use_message), - landing_required = bool(spec.landing_required), - predict = bool(spec.predict), - aim_rotation_enabled = bool(spec.aim_rotation_enabled), - aim_rotation_speed = finite(spec.aim_rotation_speed, 3) or 3, - aim_ok_angle_deg = finite(spec.aim_ok_angle_deg, 10) or 10, - use_laser_sight = bool(spec.use_laser_sight), - next_frame = integer_nonnegative(spec.next_frame, 0), - } -end - -function attack_profile.projectile_count(profile, random_fn) - profile = type(profile) == "table" and profile or {} - local low = math.max(1, integer_nonnegative(profile.count_min, 1)) - local high = math.max(low, integer_nonnegative(profile.count_max, low)) - if low == high then return low end - if type(random_fn) == "function" then - local ok, value = pcall(random_fn, low, high) - value = ok and tonumber(value) or nil - if value ~= nil then return math.max(low, math.min(high, math.floor(value))) end - end - return low -end - -function attack_profile.animation_delay_frames(profile, frame_wait, fps) - profile = type(profile) == "table" and profile or {} - local action_frame = integer_nonnegative(profile.action_frame, 0) - if action_frame <= 0 then return 0 end - frame_wait = finite(frame_wait, nil) - fps = finite(fps, 60) or 60 - if frame_wait == nil or frame_wait <= 0 or fps <= 0 then return action_frame end - return math.max(0, math.floor(action_frame * frame_wait * fps + 0.5)) -end - -local function interval_distance(profile, distance) - if distance < profile.min_distance then return profile.min_distance - distance end - if distance > profile.max_distance then return distance - profile.max_distance end - return 0 -end - -local function random_integer(random_fn, low, high) - if low >= high then return low end - if type(random_fn) ~= "function" then return low end - local ok, value = pcall(random_fn, low, high) - value = ok and tonumber(value) or low - return math.max(low, math.min(high, math.floor(value or low))) -end - -local function pick_with_authored_probability(candidates, random_fn) - if #candidates == 0 then return nil end - -- `use_probability` belongs to each attack descriptor independently. Gate every - -- otherwise-eligible profile first, then choose among those that actually passed. - -- This matters for real vanilla overlaps such as necromancer_super: a 70% close - -- attack must not create an artificial no-shot outcome while a 100% attack is also - -- valid at the same target distance. - local passed = {} - for _, candidate in ipairs(candidates) do - local probability = math.max(0, math.min(100, tonumber(candidate.use_probability) or 100)) - if probability >= 100 or (probability > 0 and random_integer(random_fn, 1, 100) <= probability) then - passed[#passed + 1] = candidate - end - end - if #passed == 0 then return nil end - if #passed == 1 then return passed[1] end - return passed[random_integer(random_fn, 1, #passed)] -end - -function attack_profile.select_primary(profiles, distance, random_fn) - distance = math.max(0, finite(distance, 0) or 0) - local usable = {} - for _, profile in ipairs(type(profiles) == "table" and profiles or {}) do - if type(profile) == "table" and tostring(profile.path or "") ~= "" - and profile.use_message ~= true and (tonumber(profile.use_probability) or 0) > 0 - then - usable[#usable + 1] = profile - end - end - if #usable == 0 then return nil end - local in_range = {} - for _, profile in ipairs(usable) do - if interval_distance(profile, distance) == 0 then in_range[#in_range + 1] = profile end - end - if #in_range > 0 then return pick_with_authored_probability(in_range, random_fn) end - - -- These distances are hard target-acquisition limits for the creature AI, but MCM - -- is driving the attack from explicit player input. Treat the authored intervals as - -- an attack-selection policy, not as a reason to discard the click. This keeps - -- close/far multi-attack creatures behaving like their originals while allowing the - -- selected projectile to be aimed at any cursor position on screen. - local nearest = {} - local nearest_distance = math.huge - for _, profile in ipairs(usable) do - local gap = interval_distance(profile, distance) - if gap < nearest_distance then - nearest_distance = gap - nearest = {profile} - elseif gap == nearest_distance then - nearest[#nearest + 1] = profile - end - end - return pick_with_authored_probability(nearest, random_fn) -end - -METAMORPH_CREATIVE_MENU_FORM_ATTACK_PROFILE = attack_profile -return attack_profile diff --git a/metamorph_creative_menu/files/features/forms/combat.lua b/metamorph_creative_menu/files/features/forms/combat.lua index 00ac781..c5c52be 100644 --- a/metamorph_creative_menu/files/features/forms/combat.lua +++ b/metamorph_creative_menu/files/features/forms/combat.lua @@ -1,12 +1,8 @@ -if type(METAMORPH_CREATIVE_MENU_FORM_COMBAT) == "table" then return METAMORPH_CREATIVE_MENU_FORM_COMBAT end - local form_combat = {} local entity_tree = dofile("mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua") local component_ops = dofile("mods/metamorph_creative_menu/files/features/forms/component_ops.lua") local tree_cache = dofile("mods/metamorph_creative_menu/files/features/forms/entity_tree_cache.lua") -local attack_profile = dofile("mods/metamorph_creative_menu/files/features/forms/attack_profile.lua") -local aim_policy = dofile("mods/metamorph_creative_menu/files/features/forms/aim_policy.lua") local valid = component_ops.valid local component = component_ops.first @@ -21,25 +17,11 @@ local tree_components = tree_cache.components local turret_aim_target = 0 local manual_barrel_pivots = {} local turret_aim_state = {} -local ranged_attack_cache = nil -local ranged_attack_cursor = 1 -local ranged_attack_global_next = 0 -local ranged_attack_state_attack = nil -local ranged_attack_state_started = 0 -local ranged_attack_state_until = 0 -local pending_ranged_attack = nil -local manual_ranged_owned = false -local manual_ranged_entity = 0 +local secondary_attack_cache = nil +local secondary_attack_cursor = 1 +local secondary_attack_global_next = 0 +local pending_secondary_attack = nil local sprite_animation_cache = {} -local ranged_animation_override = nil -local restore_ranged_animation_override = nil -local detect_ranged_attacks = nil - -local function component_is_enabled(comp) - if not valid(comp) or type(ComponentGetIsEnabled) ~= "function" then return false end - local ok, enabled = pcall(ComponentGetIsEnabled, comp) - return ok and enabled == true -end local function setup_manual_barrels(entity) manual_barrel_pivots = {} @@ -60,8 +42,7 @@ local function setup_manual_barrels(entity) or string.find(base, "^cannon_", 1) ~= nil or string.find(base, "_cannon$", 1) ~= nil or string.find(base, "_cannon_", 1, true) ~= nil local looks_turret = string.find(base, "turret", 1, true) ~= nil - local static_weapon_layer = aim_policy.sprite_is_static_weapon_layer(sprite) - if (looks_barrel or (current ~= entity and looks_turret)) and not static_weapon_layer then + if looks_barrel or (current ~= entity and looks_turret) then if current ~= entity and not seen_direct[current] then -- Child gun entities are already natural rotation pivots in many -- vanilla machines. Preserve their sprites/animations and rotate the @@ -187,69 +168,11 @@ local function ensure_native_polymorph_attack_carrier(entity) return animal end -local function ranged_ownership_capability(entity) - local direct = 0 - local contextual = 0 - walk_entity_tree(entity, function(current) - for _, animal in ipairs(EntityGetComponentIncludingDisabled(current, "AnimalAIComponent") or {}) do - local path = tostring(get_value(animal, "attack_ranged_entity_file", "") or "") - -- A non-empty direct descriptor is an authored attack capability even when - -- attack_ranged_enabled is currently false. Vanilla Lua can toggle that flag - -- later (coward/limit_projectiles) without recreating the component. Claim the - -- descriptor now, but attack_logically_enabled() still gates every actual shot. - if path ~= "" then - if boolean_value(get_value(animal, "attack_ranged_use_message", false)) == true then - contextual = contextual + 1 - else - direct = direct + 1 - end - end - end - for _, attack in ipairs(EntityGetComponentIncludingDisabled(current, "AIAttackComponent") or {}) do - local path = tostring(get_value(attack, "attack_ranged_entity_file", "") or "") - if path ~= "" then - if boolean_value(get_value(attack, "attack_ranged_use_message", false)) == true then - contextual = contextual + 1 - else - direct = direct + 1 - end - end - end - end) - -- Message-based attacks depend on an item/use-message context that the public Lua - -- API cannot faithfully replay. In mixed/direct+message creatures we deliberately - -- leave Noita's native polymorph path in charge rather than double-fire one half. - return direct > 0 and contextual == 0 -end - -local function configure_ranged_player(entity) - if manual_ranged_owned and manual_ranged_entity == entity and ranged_attack_cache ~= nil then return true end - restore_ranged_animation_override() - manual_ranged_owned = ranged_ownership_capability(entity) - ranged_attack_cache = nil - ranged_attack_cursor = 1 - ranged_attack_global_next = 0 - ranged_attack_state_attack = nil - ranged_attack_state_started = 0 - ranged_attack_state_until = 0 - pending_ranged_attack = nil - manual_ranged_entity = manual_ranged_owned and entity or 0 - if not manual_ranged_owned then return false end - - -- Keep authored attack descriptors untouched. Manual ownership is isolated by - -- disabling ControlsComponent.polymorph_hax instead of blanking projectile paths or - -- forcing AIAttackComponent enabled state. This makes external component disables and - -- an intentional empty attack_ranged_entity_file observable on the very next update. - if type(detect_ranged_attacks) == "function" then detect_ranged_attacks(entity) end - return true -end - local function configure_non_ai_player(entity) local controls = ensure_controls(entity) set_component_type_enabled_tree(entity, "PathFindingComponent", false) - local manual_ranged = configure_ranged_player(entity) - if not manual_ranged then ensure_native_polymorph_attack_carrier(entity) end + ensure_native_polymorph_attack_carrier(entity) walk_entity_tree(entity, function(current) for _, animal in ipairs(EntityGetComponentIncludingDisabled(current, "AnimalAIComponent") or {}) do @@ -258,21 +181,27 @@ local function configure_non_ai_player(entity) pcall(ComponentSetValue2, animal, "sense_creatures", false) pcall(ComponentSetValue2, animal, "attack_melee_enabled", false) pcall(ComponentSetValue2, animal, "attack_dash_enabled", false) - if not manual_ranged then - -- Compatibility fallback for contextual/message attacks: retain the old - -- native polymorph carrier exactly as before. - pcall(ComponentSetValue2, animal, "attack_ranged_enabled", ranged_path ~= "") - end - -- When MCM owns direct ranged attacks, AnimalAI must not execute them. Keep - -- AIAttackComponent itself untouched so external phase/mod enable state stays - -- observable through ComponentGetIsEnabled(). Contextual native fallback keeps - -- the old static-turret presentation loop. - pcall(EntitySetComponentIsEnabled, current, animal, (not manual_ranged) and static_turret) + -- polymorph_hax requires both the projectile path and the authored enable + -- bit. giantshooter deliberately ships with this bit off for autonomous AI, + -- which made both variants unable to fire as player forms. Autonomous AI + -- remains component-disabled, so enabling the metadata cannot create NPC fire. + pcall(ComponentSetValue2, animal, "attack_ranged_enabled", ranged_path ~= "") + -- Never blank attack_ranged_entity_file. Static turrets keep only their + -- presentation loop so the barrel/laser sight can animate toward our cursor. + pcall(EntitySetComponentIsEnabled, current, animal, static_turret) + end + for _, attack in ipairs(EntityGetComponentIncludingDisabled(current, "AIAttackComponent") or {}) do + -- AIAttackComponent is attack metadata consumed by polymorph firing. Keep it + -- enabled; autonomous target selection is already disabled at AnimalAI. + pcall(EntitySetComponentIsEnabled, current, attack, true) end end) if valid(controls) then - pcall(ComponentSetValue2, controls, "polymorph_hax", not manual_ranged) + -- Primary fire belongs to Noita's native polymorph controller. Manual replay of + -- the same attack animation/projectile can trigger authored attack callbacks a + -- second time, producing the observed double-shot/double-melee regression. + pcall(ComponentSetValue2, controls, "polymorph_hax", true) end if not valid(component(entity, "Inventory2Component")) then @@ -306,619 +235,201 @@ end local function ranged_origin(entity, comp) local owner = ComponentGetEntity(comp) if owner == nil or owner == 0 then owner = entity end - local x, y, rotation, scale_x = EntityGetTransform(owner) + local x, y, _, scale_x = EntityGetTransform(owner) if x == nil then owner = entity - x, y, rotation, scale_x = EntityGetTransform(entity) + x, y, _, scale_x = EntityGetTransform(entity) end if x == nil then return nil end - rotation = tonumber(rotation) or 0 - scale_x = tonumber(scale_x) or 1 - - -- AIAttackComponent's root offset is the physical rotation pivot. Vanilla tank - -- data makes this explicit: the gun SpriteComponent has transform_offset.y=-5 and - -- both AIAttacks use attack_ranged_root_offset_y=-5. The ranged offset is the - -- muzzle vector relative to that pivot, so aimed weapons must rotate that vector - -- with mRangedAttackCurrentAimAngle instead of adding both offsets in world space. - local root_x = tonumber(get_value(comp, "attack_ranged_root_offset_x", 0)) or 0 - local root_y = tonumber(get_value(comp, "attack_ranged_root_offset_y", 0)) or 0 - if scale_x < 0 then root_x = -root_x end - local pivot_x, pivot_y = x + root_x, y + root_y - local ox = tonumber(get_value(comp, "attack_ranged_offset_x", 0)) or 0 local oy = tonumber(get_value(comp, "attack_ranged_offset_y", 0)) or 0 - local aimed = boolean_value(get_value(comp, "attack_ranged_aim_rotation_enabled", false)) == true - local static_weapon = aimed and aim_policy.static_weapon_attack(entity, comp) - if aimed and not static_weapon then - local angle = tonumber(get_value(comp, "mRangedAttackCurrentAimAngle", rotation)) or rotation - local ca, sa = math.cos(angle), math.sin(angle) - local rx = ox * ca - oy * sa - local ry = ox * sa + oy * ca - return owner, pivot_x + rx, pivot_y + ry, pivot_x, pivot_y - end - - if scale_x < 0 then ox = -ox end - return owner, pivot_x + ox, pivot_y + oy, pivot_x, pivot_y + ox = ox + (tonumber(get_value(comp, "attack_ranged_root_offset_x", 0)) or 0) + oy = oy + (tonumber(get_value(comp, "attack_ranged_root_offset_y", 0)) or 0) + if (tonumber(scale_x) or 1) < 0 then ox = -ox end + return owner, x + ox, y + oy end -local function sprite_animation_metadata(path) +local function sprite_animation_names(path) path = tostring(path or "") if path == "" then return nil end - if sprite_animation_cache[path] ~= nil then - return sprite_animation_cache[path] ~= false and sprite_animation_cache[path] or nil - end + if sprite_animation_cache[path] ~= nil then return sprite_animation_cache[path] end + local names = {} local ok, content = pcall(ModTextFileGetContent, path) if not ok or type(content) ~= "string" then sprite_animation_cache[path] = false - return nil + return false end - local animations = {} for tag in string.gmatch(content, "") do local name = string.match(tag, "name%s*=%s*['\"]([^'\"]+)['\"]") - if name ~= nil and name ~= "" then - local loop_value = string.match(tag, "loop%s*=%s*['\"]([^'\"]+)['\"]") - animations[name] = { - frame_wait = tonumber(string.match(tag, "frame_wait%s*=%s*['\"]([^'\"]+)['\"]")), - frame_count = tonumber(string.match(tag, "frame_count%s*=%s*['\"]([^'\"]+)['\"]")), - loop = loop_value == "1" or loop_value == "true", - } - end + if name ~= nil and name ~= "" then names[name] = true end end - sprite_animation_cache[path] = animations - return animations + sprite_animation_cache[path] = names + return names end local function play_attack_animation(entity, name, priority) name = tostring(name or "") if entity == nil or entity == 0 or name == "" then return end priority = tonumber(priority) or 30 + -- Many creatures keep visible sprites on child entities. Route the authored attack + -- to every entity that actually exposes it; do not force CharacterPlatforming's + -- animation_to_play field, since that can pin a form in a missing animation and + -- suppress its native jump, landing, hurt and idle transitions. walk_entity_tree(entity, function(current) if not valid(component(current, "SpriteAnimatorComponent")) then return end local supported = false local unknown = false for _, sprite in ipairs(EntityGetComponentIncludingDisabled(current, "SpriteComponent") or {}) do local path = tostring(get_value(sprite, "image_file", "") or "") - local animations = sprite_animation_metadata(path) - if type(animations) == "table" and animations[name] ~= nil then supported = true break end - if path == "" or animations == nil then unknown = true end + local names = sprite_animation_names(path) + if type(names) == "table" and names[name] == true then supported = true break end + if path == "" or names == false then unknown = true end end if supported or unknown then pcall(GamePlayAnimation, current, name, priority, "", 0) end end) end -restore_ranged_animation_override = function() - local state = ranged_animation_override - ranged_animation_override = nil - if type(state) ~= "table" then return end - for _, record in ipairs(state.sprites or {}) do - if valid(record.component) and record.owner ~= nil and record.owner ~= 0 and EntityGetIsAlive(record.owner) then - pcall(ComponentSetValue2, record.component, "rect_animation", record.rect_animation or "") - pcall(ComponentSetValue2, record.component, "next_rect_animation", record.next_rect_animation or "") - if type(EntityRefreshSprite) == "function" then pcall(EntityRefreshSprite, record.owner, record.component) end - end - end - for _, record in ipairs(state.animators or {}) do - if valid(record.component) and record.owner ~= nil and record.owner ~= 0 and EntityGetIsAlive(record.owner) then - pcall(EntitySetComponentIsEnabled, record.owner, record.component, record.enabled == true) - end - end -end - -local function start_ranged_animation_override(entity, animation_name, frame, authored_state_frames) - animation_name = tostring(animation_name or "") - if entity == nil or entity == 0 or animation_name == "" then return false end - restore_ranged_animation_override() - - local state = { entity=entity, sprites={}, animators={}, end_frame=frame + math.max(1, tonumber(authored_state_frames) or 1) } - local animator_seen = {} - local longest = 0 - local has_loop = false - - walk_entity_tree(entity, function(current) - local supported_sprites = {} - for _, sprite in ipairs(EntityGetComponentIncludingDisabled(current, "SpriteComponent") or {}) do - local image = tostring(get_value(sprite, "image_file", "") or "") - local animations = sprite_animation_metadata(image) - local metadata = type(animations) == "table" and animations[animation_name] or nil - if type(metadata) == "table" then - supported_sprites[#supported_sprites + 1] = { component=sprite, metadata=metadata } - local wait = tonumber(metadata.frame_wait) or 0.07 - local count = math.max(1, tonumber(metadata.frame_count) or 1) - longest = math.max(longest, math.ceil(wait * 60 * count)) - if metadata.loop == true then has_loop = true end - end - end - if #supported_sprites == 0 then return end - - -- SpriteAnimatorComponent is the system that races our manual attack animation - -- with walk/fly/landing. Suspend only that visual state switcher for the short - -- authored attack window. SpriteComponent itself continues advancing rect frames. - for _, animator in ipairs(EntityGetComponentIncludingDisabled(current, "SpriteAnimatorComponent") or {}) do - if not animator_seen[animator] then - animator_seen[animator] = true - local enabled = true - if type(ComponentGetIsEnabled) == "function" then - local ok_enabled, value = pcall(ComponentGetIsEnabled, animator) - if ok_enabled then enabled = value == true end - end - state.animators[#state.animators + 1] = {owner=current,component=animator,enabled=enabled} - pcall(EntitySetComponentIsEnabled, current, animator, false) - end - end - for _, data in ipairs(supported_sprites) do - local sprite = data.component - state.sprites[#state.sprites + 1] = { - owner=current, component=sprite, - rect_animation=tostring(get_value(sprite, "rect_animation", "") or ""), - next_rect_animation=tostring(get_value(sprite, "next_rect_animation", "") or ""), - } - pcall(ComponentSetValue2, sprite, "rect_animation", animation_name) - -- Do not force `stand` as a follow-up. The saved/native locomotion state is - -- restored when the short override ends, so an airborne shot can land safely. - pcall(ComponentSetValue2, sprite, "next_rect_animation", "") - pcall(EntitySetComponentIsEnabled, current, sprite, true) - if type(EntityRefreshSprite) == "function" then pcall(EntityRefreshSprite, current, sprite) end - end - end) - - if #state.sprites == 0 then - -- Unknown sprite setup: retain the old public API fallback, but never use this - -- path for normal authored creature sprites where we can release ownership cleanly. - play_attack_animation(entity, animation_name, 30) - return false - end - - local authored = math.max(1, math.floor(tonumber(authored_state_frames) or longest or 1)) - if has_loop then - state.end_frame = frame + authored - else - state.end_frame = frame + math.max(1, longest > 0 and math.min(authored, longest) or authored) +-- LMB remains Noita's native polymorph primary attack. Every distinct authored +-- AIAttack beyond that primary becomes an RMB attack. When several exist, repeated RMB +-- shots rotate through the ready attacks while preserving each component's cooldown, +-- projectile count, muzzle offset, action frame and animation. +local function detect_secondary_attacks(entity) + local primary = "" + for _, animal in ipairs(tree_components(entity, "AnimalAIComponent")) do + local path = tostring(get_value(animal, "attack_ranged_entity_file", "") or "") + if path ~= "" then primary = path; break end end - ranged_animation_override = state - return true -end + if primary == "" then return {} end -local function animation_frame_wait(entity, animation_name) - animation_name = tostring(animation_name or "") - if animation_name == "" then return nil end - local found = nil - walk_entity_tree(entity, function(current) - for _, sprite in ipairs(EntityGetComponentIncludingDisabled(current, "SpriteComponent") or {}) do - local path = tostring(get_value(sprite, "image_file", "") or "") - local animations = sprite_animation_metadata(path) - local metadata = type(animations) == "table" and animations[animation_name] or nil - if type(metadata) == "table" and tonumber(metadata.frame_wait) ~= nil then - found = tonumber(metadata.frame_wait) - return false + local unique = {} + local extras = {} + for _, attack in ipairs(tree_components(entity, "AIAttackComponent")) do + local path = tostring(get_value(attack, "attack_ranged_entity_file", "") or "") + if path ~= "" and not unique[path] + and boolean_value(get_value(attack, "attack_ranged_use_message", false)) ~= true + and ModDoesFileExist(path) + then + unique[path] = true + if path ~= primary then + extras[#extras + 1] = { + component = attack, + path = path, + frames = math.max(1, tonumber(get_value(attack, "frames_between", 30)) or 30), + global_frames = math.max(1, tonumber(get_value(attack, "frames_between_global", 1)) or 1), + count_min = math.max(1, tonumber(get_value(attack, "attack_ranged_entity_count_min", 1)) or 1), + count_max = math.max(1, tonumber(get_value(attack, "attack_ranged_entity_count_max", 1)) or 1), + action_frame = math.max(0, tonumber(get_value(attack, "attack_ranged_action_frame", 0)) or 0), + animation = tostring(get_value(attack, "animation_name", "attack_ranged") or "attack_ranged"), + next_frame = 0, + } end end - end) - return found -end - -local function normalize_attack_component(comp, source_kind) - if not valid(comp) then return nil end - local is_multi = source_kind == "ai_attack" - local prefix = is_multi and "" or "attack_ranged_" - local path = tostring(get_value(comp, "attack_ranged_entity_file", "") or "") - if path == "" then return nil end - local use_message = boolean_value(get_value(comp, "attack_ranged_use_message", false)) == true - if use_message then return nil end - return attack_profile.normalize({ - source_kind = source_kind, - component = comp, - path = path, - frames = get_value(comp, is_multi and "frames_between" or "attack_ranged_frames_between", 30), - global_frames = is_multi and get_value(comp, "frames_between_global", 0) or 0, - count_min = get_value(comp, "attack_ranged_entity_count_min", 1), - count_max = get_value(comp, "attack_ranged_entity_count_max", 1), - action_frame = get_value(comp, "attack_ranged_action_frame", 0), - animation = is_multi and get_value(comp, "animation_name", "attack_ranged") or "attack_ranged", - min_distance = get_value(comp, is_multi and "min_distance" or "attack_ranged_min_distance", 0), - max_distance = get_value(comp, is_multi and "max_distance" or "attack_ranged_max_distance", 10000), - use_probability = is_multi and get_value(comp, "use_probability", 100) or 100, - state_duration_frames = get_value(comp, is_multi and "state_duration_frames" or "attack_ranged_state_duration_frames", 45), - angular_range_deg = is_multi and get_value(comp, "angular_range_deg", 90) or 90, - offset_x = get_value(comp, "attack_ranged_offset_x", 0), - offset_y = get_value(comp, "attack_ranged_offset_y", 0), - root_offset_x = is_multi and get_value(comp, "attack_ranged_root_offset_x", 0) or 0, - root_offset_y = is_multi and get_value(comp, "attack_ranged_root_offset_y", 0) or 0, - use_message = false, - landing_required = get_value(comp, "attack_landing_ranged_enabled", false), - predict = get_value(comp, "attack_ranged_predict", false), - aim_rotation_enabled = get_value(comp, "attack_ranged_aim_rotation_enabled", false), - aim_rotation_speed = get_value(comp, "attack_ranged_aim_rotation_speed", 3), - aim_ok_angle_deg = get_value(comp, "attack_ranged_aim_rotation_shooting_ok_angle_deg", 10), - use_laser_sight = get_value(comp, "attack_ranged_use_laser_sight", false), - next_frame = 0, - }) -end - -detect_ranged_attacks = function(entity) - if ranged_attack_cache ~= nil then return ranged_attack_cache end - local attacks = {} - -- AIAttackComponent is a real multi-attack descriptor. Never deduplicate by - -- projectile path: vanilla can author two attacks with the same projectile but - -- different range/count/cooldown/origin semantics. - for _, comp in ipairs(tree_components(entity, "AIAttackComponent")) do - local attack = normalize_attack_component(comp, "ai_attack") - if attack ~= nil then attacks[#attacks + 1] = attack end - end - for _, comp in ipairs(tree_components(entity, "AnimalAIComponent")) do - local attack = normalize_attack_component(comp, "animal") - if attack ~= nil then attacks[#attacks + 1] = attack end end - ranged_attack_cache = attacks - return attacks + return extras end local function projectile_count(attack) - local random_fn = type(Random) == "function" and Random or nil - return attack_profile.projectile_count(attack, random_fn) -end - -local function ranged_target(attack, sx, sy) - if type(attack) == "table" and attack.aim_rotation_enabled == true and valid(attack.component) then - local owner = ComponentGetEntity(attack.component) - local root = owner ~= nil and owner ~= 0 and EntityGetRootEntity(owner) or 0 - if not aim_policy.static_weapon_attack(root, attack.component) then - local angle = tonumber(get_value(attack.component, "mRangedAttackCurrentAimAngle", nil)) - if angle ~= nil then return sx + math.cos(angle) * 1000, sy + math.sin(angle) * 1000 end - end + local low = math.max(1, math.floor(tonumber(attack.count_min) or 1)) + local high = math.max(low, math.floor(tonumber(attack.count_max) or low)) + if high == low then return low end + if type(Random) == "function" then + local ok, value = pcall(Random, low, high) + if ok and tonumber(value) ~= nil then return math.max(low, math.min(high, tonumber(value))) end end - return mouse_target(sx, sy) -end - -local function refresh_attack_from_component(attack, suppress_native_path) - if type(attack) ~= "table" or not valid(attack.component) then return end - local comp = attack.component - local is_multi = attack.source_kind == "ai_attack" - local changed_path = tostring(get_value(comp, "attack_ranged_entity_file", "") or "") - -- The live component is authoritative, including an intentional empty string. MCM no - -- longer hides its own carrier blank in this field, so a phase script or another mod - -- can disable/rewrite the attack without stale shadow projectile state surviving. - attack.path = changed_path - attack.frames = math.max(0, tonumber(get_value(comp, is_multi and "frames_between" or "attack_ranged_frames_between", attack.frames)) or attack.frames or 0) - attack.global_frames = is_multi and math.max(0, tonumber(get_value(comp, "frames_between_global", attack.global_frames)) or attack.global_frames or 0) or 0 - attack.count_min = math.max(1, math.floor(tonumber(get_value(comp, "attack_ranged_entity_count_min", attack.count_min)) or attack.count_min or 1)) - attack.count_max = math.max(attack.count_min, math.floor(tonumber(get_value(comp, "attack_ranged_entity_count_max", attack.count_max)) or attack.count_max or attack.count_min)) - attack.action_frame = math.max(0, math.floor(tonumber(get_value(comp, "attack_ranged_action_frame", attack.action_frame)) or attack.action_frame or 0)) - local min_distance = math.max(0, tonumber(get_value(comp, is_multi and "min_distance" or "attack_ranged_min_distance", attack.min_distance)) or attack.min_distance or 0) - local max_distance = math.max(0, tonumber(get_value(comp, is_multi and "max_distance" or "attack_ranged_max_distance", attack.max_distance)) or attack.max_distance or 10000) - if max_distance < min_distance then min_distance, max_distance = max_distance, min_distance end - attack.min_distance, attack.max_distance = min_distance, max_distance - attack.offset_x = tonumber(get_value(comp, "attack_ranged_offset_x", attack.offset_x)) or attack.offset_x or 0 - attack.offset_y = tonumber(get_value(comp, "attack_ranged_offset_y", attack.offset_y)) or attack.offset_y or 0 - attack.use_message = boolean_value(get_value(comp, "attack_ranged_use_message", attack.use_message)) == true - attack.landing_required = boolean_value(get_value(comp, "attack_landing_ranged_enabled", attack.landing_required)) == true - attack.predict = boolean_value(get_value(comp, "attack_ranged_predict", attack.predict)) == true - attack.aim_rotation_enabled = boolean_value(get_value(comp, "attack_ranged_aim_rotation_enabled", attack.aim_rotation_enabled)) == true - attack.aim_rotation_speed = tonumber(get_value(comp, "attack_ranged_aim_rotation_speed", attack.aim_rotation_speed)) or attack.aim_rotation_speed or 3 - attack.aim_ok_angle_deg = tonumber(get_value(comp, "attack_ranged_aim_rotation_shooting_ok_angle_deg", attack.aim_ok_angle_deg)) or attack.aim_ok_angle_deg or 10 - attack.use_laser_sight = boolean_value(get_value(comp, "attack_ranged_use_laser_sight", attack.use_laser_sight)) == true - if is_multi then - attack.animation = tostring(get_value(comp, "animation_name", attack.animation) or attack.animation or "attack_ranged") - attack.root_offset_x = tonumber(get_value(comp, "attack_ranged_root_offset_x", attack.root_offset_x)) or attack.root_offset_x or 0 - attack.root_offset_y = tonumber(get_value(comp, "attack_ranged_root_offset_y", attack.root_offset_y)) or attack.root_offset_y or 0 - attack.state_duration_frames = math.max(0, math.floor(tonumber(get_value(comp, "state_duration_frames", attack.state_duration_frames)) or attack.state_duration_frames or 45)) - attack.angular_range_deg = math.max(0, math.min(90, tonumber(get_value(comp, "angular_range_deg", attack.angular_range_deg)) or attack.angular_range_deg or 90)) - attack.use_probability = math.max(0, math.min(100, math.floor(tonumber(get_value(comp, "use_probability", attack.use_probability)) or attack.use_probability or 100))) - else - attack.state_duration_frames = math.max(0, math.floor(tonumber(get_value(comp, "attack_ranged_state_duration_frames", attack.state_duration_frames)) or attack.state_duration_frames or 45)) - end -end - -local function refresh_attack_after_shot(attack) - refresh_attack_from_component(attack) -end - -local function sync_dynamic_attack_metadata(entity) - if not manual_ranged_owned then return end - local attacks = detect_ranged_attacks(entity) - local known = {} - for _, attack in ipairs(attacks) do - if type(attack) == "table" and valid(attack.component) then known[attack.component] = attack end - end - - -- Entity LuaComponents (and external mods) may add a new direct attack descriptor - -- after the form has already been configured. Discover those components every frame - -- instead of treating the initial snapshot as the complete lifetime attack set. - -- Contextual Message_UseItem descriptors remain native because they cannot be - -- faithfully replayed by this layer. - local function claim_new(comp, source_kind) - if known[comp] ~= nil or not valid(comp) then return end - local use_message = boolean_value(get_value(comp, "attack_ranged_use_message", false)) == true - local path = tostring(get_value(comp, "attack_ranged_entity_file", "") or "") - if use_message or path == "" then return end - local attack = normalize_attack_component(comp, source_kind) - if attack == nil then return end - attacks[#attacks + 1] = attack - known[comp] = attack - end - - for _, comp in ipairs(tree_components(entity, "AIAttackComponent")) do claim_new(comp, "ai_attack") end - for _, comp in ipairs(tree_components(entity, "AnimalAIComponent")) do claim_new(comp, "animal") end - - for _, attack in ipairs(attacks) do refresh_attack_from_component(attack) end -end - -local function attack_logically_enabled(attack) - if type(attack) ~= "table" or not valid(attack.component) or tostring(attack.path or "") == "" then return false end - if attack.source_kind == "animal" then - return boolean_value(get_value(attack.component, "attack_ranged_enabled", true)) == true - end - if attack.source_kind == "ai_attack" then - return component_is_enabled(attack.component) - end - return true + return low end local function fire_projectile_attack(entity, attack) - if type(attack) ~= "table" or not valid(attack.component) or tostring(attack.path or "") == "" then return false end + if type(attack) ~= "table" or not valid(attack.component) then return false end local owner, sx, sy = ranged_origin(entity, attack.component) if owner == nil then return false end - local tx, ty = ranged_target(attack, sx, sy) + local tx, ty = mouse_target(sx, sy) local fired = false - local volley_path = attack.path for _ = 1, projectile_count(attack) do - -- Load the exact authored entity. The projectile's own ProjectileComponent then - -- owns speed/lob/direction_random_rad/muzzle flash/callbacks just as in vanilla. - -- Keep one immutable path for this whole volley: Message_Shot may author the - -- *next* attack, but it must not change later projectiles in the current volley. - local projectile = EntityLoad(volley_path, sx, sy) + local projectile = EntityLoad(attack.path, sx, sy) if projectile ~= nil and projectile ~= 0 then local shot_ok = pcall(GameShootProjectile, entity, sx, sy, tx, ty, projectile, true) if shot_ok then fired = true else + -- EntityLoad succeeded but the engine rejected the shoot call. Do not + -- leave a live projectile at the muzzle or start a false cooldown. pcall(EntityKill, projectile) end end end - if fired then refresh_attack_after_shot(attack) end return fired end -local function component_next_frame(attack) - if type(attack) ~= "table" or not valid(attack.component) then return 0 end - if attack.source_kind == "ai_attack" then - return tonumber(get_value(attack.component, "mNextFrameUsable", 0)) or 0 - end - if attack.source_kind == "animal" then - return tonumber(get_value(attack.component, "mRangedAttackNextFrame", 0)) or 0 - end - return 0 -end - -local function set_component_next_frame(attack, value) - if type(attack) ~= "table" or not valid(attack.component) then return end - if attack.source_kind == "ai_attack" then - pcall(ComponentSetValue2, attack.component, "mNextFrameUsable", tonumber(value) or 0) - elseif attack.source_kind == "animal" then - pcall(ComponentSetValue2, attack.component, "mRangedAttackNextFrame", tonumber(value) or 0) - end -end - -local function attack_is_aimed(entity, attack) - if type(attack) ~= "table" or attack.aim_rotation_enabled ~= true then return true end - if aim_policy.static_weapon_attack(entity, attack.component) then return true end - local owner, _, _, pivot_x, pivot_y = ranged_origin(entity, attack.component) - if owner == nil then return false end - local current = tonumber(get_value(attack.component, "mRangedAttackCurrentAimAngle", nil)) - if current == nil then return true end - local tx, ty = mouse_target(pivot_x, pivot_y) - local desired = target_angle(pivot_x, pivot_y, tx, ty) - local tolerance = math.rad(math.max(0, tonumber(attack.aim_ok_angle_deg) or 10)) - return math.abs(shortest_angle_delta(current, desired)) <= tolerance -end - -local function landing_ready(entity, attack) - if type(attack) ~= "table" or attack.landing_required ~= true then return true end - -- This authored flag is a hard attack precondition. CharacterDataComponent is the - -- engine's own collision-ground state and avoids inventing raycast thresholds. - for _, comp in ipairs(tree_components(entity, "CharacterDataComponent")) do - if boolean_value(get_value(comp, "is_on_ground", false)) == true then return true end - end - return false -end +local function update_secondary_attacks(entity) + local controls = ensure_controls(entity) + if not valid(controls) then return false end + local frame = tonumber(GameGetFrameNum()) or 0 -local function ready_attacks(entity, frame) - local ready = {} - for _, attack in ipairs(detect_ranged_attacks(entity)) do - if valid(attack.component) - and attack_logically_enabled(attack) - and frame >= math.max(tonumber(attack.next_frame) or 0, component_next_frame(attack)) - and landing_ready(entity, attack) - and attack_is_aimed(entity, attack) - then - ready[#ready + 1] = attack + if pending_secondary_attack ~= nil and frame >= pending_secondary_attack.fire_at then + local pending = pending_secondary_attack + pending_secondary_attack = nil + if not fire_projectile_attack(entity, pending.attack) then + -- The attack was reserved before its authored action frame. A native shoot + -- failure must not consume the creature's cooldown: restore the exact state + -- observed before reservation so the player may retry immediately. + pending.attack.next_frame = pending.previous_next_frame + secondary_attack_global_next = pending.previous_global_next + pcall(ComponentSetValue2, pending.attack.component, "mNextFrameUsable", pending.previous_component_next) end end - return ready -end - -local function cursor_distance(entity) - local x, y = EntityGetTransform(entity) - if x == nil then return 0 end - local tx, ty = mouse_target(x, y) - local dx, dy = tx - x, ty - y - return math.sqrt(dx * dx + dy * dy) -end - -local function attack_ready_now(entity, attack, frame) - if type(attack) ~= "table" or not valid(attack.component) or not attack_logically_enabled(attack) then return false end - if frame < math.max(tonumber(attack.next_frame) or 0, component_next_frame(attack)) then return false end - if not landing_ready(entity, attack) or not attack_is_aimed(entity, attack) then return false end - -- Player authority must not inherit the AI's target-acquisition radius. Range still - -- participates in primary attack selection (attack_profile.select_primary), but once - -- a profile is selected its authored state/cooldown remains usable wherever the - -- cursor moves. The projectile itself is still fired toward the real cursor target. - return true -end -local function select_primary_attack(entity, frame) - -- AIAttackComponent.state_duration_frames is an authored state lock: once the AI - -- selects an attack, other attack states are not allowed until this window expires. - -- Keep the same profile selected while it is locked; if its own local cooldown has - -- not elapsed, holding fire simply waits instead of randomly switching weapons. - if ranged_attack_state_attack ~= nil and frame < ranged_attack_state_until then - if attack_ready_now(entity, ranged_attack_state_attack, frame) then return ranged_attack_state_attack end - return nil + if get_value(controls, "mButtonDownFire2", false) ~= true + or pending_secondary_attack ~= nil + or frame < secondary_attack_global_next + then + return false end - ranged_attack_state_attack = nil - ranged_attack_state_started = 0 - ranged_attack_state_until = 0 - local ready = ready_attacks(entity, frame) - if #ready == 0 then return nil end - local random_fn = type(Random) == "function" and Random or nil - return attack_profile.select_primary(ready, cursor_distance(entity), random_fn) -end + if secondary_attack_cache == nil then secondary_attack_cache = detect_secondary_attacks(entity) end + local attacks = secondary_attack_cache + if type(attacks) ~= "table" or #attacks == 0 then return false end -local function select_secondary_attack(entity, frame) - local attacks = detect_ranged_attacks(entity) - if #attacks == 0 then return nil end - -- A creature may author its entire ranged behavior in one AIAttackComponent while - -- keeping AnimalAI.attack_ranged_enabled=0. That sole real descriptor must remain - -- usable from the secondary fire surface; requiring two profiles resurrected the - -- old false assumption that a dormant AnimalAI projectile path was a real attack. - if #attacks == 1 then - local attack = attacks[1] - return attack_ready_now(entity, attack, frame) and attack or nil - end + local selected = nil + local selected_index = nil for offset = 0, #attacks - 1 do - local index = ((ranged_attack_cursor - 1 + offset) % #attacks) + 1 + local index = ((secondary_attack_cursor - 1 + offset) % #attacks) + 1 local attack = attacks[index] - if attack_ready_now(entity, attack, frame) then - ranged_attack_cursor = (index % #attacks) + 1 - return attack - end - end - return nil -end - -local function restore_attack_reservation(pending) - if type(pending) ~= "table" or type(pending.attack) ~= "table" then return end - pending.attack.next_frame = pending.previous_next_frame - ranged_attack_global_next = pending.previous_global_next - set_component_next_frame(pending.attack, pending.previous_component_next) -end - -local function flush_pending_attack(entity) - if pending_ranged_attack == nil then return false end - local pending = pending_ranged_attack - pending_ranged_attack = nil - if fire_projectile_attack(entity, pending.attack) then - -- Message_Shot may rewrite the next projectile/cooldown (Boss Wizard is a - -- vanilla example). The reservation starts at attack entry, but must use the - -- post-callback authored intervals for the *next* attack. - local started = tonumber(pending.started_at) or 0 - pending.attack.next_frame = started + math.max(0, tonumber(pending.attack.frames) or 0) - ranged_attack_global_next = started + math.max(0, tonumber(pending.attack.global_frames) or 0) - set_component_next_frame(pending.attack, pending.attack.next_frame) - if ranged_attack_state_attack == pending.attack then - -- Message_Shot may also change state_duration_frames. Rebase the end of the - -- authored attack state on the frame where that state was first entered, - -- never on every projectile fired from within it; otherwise rapid-fire - -- attacks would extend their state indefinitely. - ranged_attack_state_until = ranged_attack_state_started + math.max(0, tonumber(pending.attack.state_duration_frames) or 45) + if valid(attack.component) then + local native_next = tonumber(get_value(attack.component, "mNextFrameUsable", 0)) or 0 + if frame >= math.max(native_next, tonumber(attack.next_frame) or 0) then + selected, selected_index = attack, index + break + end end - return true - end - restore_attack_reservation(pending) - return false -end - -local function begin_ranged_attack(entity, attack, frame) - if type(attack) ~= "table" then return false end - if ranged_attack_state_attack ~= attack or frame >= ranged_attack_state_until then - ranged_attack_state_attack = attack - ranged_attack_state_started = frame - ranged_attack_state_until = frame + math.max(0, tonumber(attack.state_duration_frames) or 45) - end - if attack.animation ~= "" then - start_ranged_animation_override(entity, attack.animation, frame, attack.state_duration_frames) end - local previous_next_frame = tonumber(attack.next_frame) or 0 - local previous_component_next = component_next_frame(attack) - local previous_global_next = ranged_attack_global_next - attack.next_frame = frame + math.max(0, tonumber(attack.frames) or 0) - ranged_attack_global_next = frame + math.max(0, tonumber(attack.global_frames) or 0) - set_component_next_frame(attack, attack.next_frame) - local frame_wait = animation_frame_wait(entity, attack.animation) - local delay = attack_profile.animation_delay_frames(attack, frame_wait, 60) - pending_ranged_attack = { - attack = attack, - fire_at = frame + delay, + if selected == nil then return false end + + if selected.animation ~= "" then play_attack_animation(entity, selected.animation, 40) end + local previous_next_frame = tonumber(selected.next_frame) or 0 + local previous_component_next = tonumber(get_value(selected.component, "mNextFrameUsable", 0)) or 0 + local previous_global_next = secondary_attack_global_next + selected.next_frame = frame + selected.frames + secondary_attack_global_next = frame + selected.global_frames + pcall(ComponentSetValue2, selected.component, "mNextFrameUsable", selected.next_frame) + pending_secondary_attack = { + attack = selected, + fire_at = frame + selected.action_frame, previous_next_frame = previous_next_frame, previous_component_next = previous_component_next, previous_global_next = previous_global_next, - started_at = frame, } - if delay == 0 then return flush_pending_attack(entity) end - return true -end - -local function update_ranged_attacks(entity, allow_secondary, allow_primary) - if allow_secondary == nil then allow_secondary = true end - if allow_primary == nil then allow_primary = true end - if not manual_ranged_owned then - -- A form can acquire its first direct ranged descriptor after transformation - -- (Lua init, delayed component creation, or another mod). Retry ownership here - -- rather than permanently falling back to polymorph_hax based on frame-zero state. - -- Contextual Message_UseItem-only forms still fail capability detection and stay native. - if not configure_ranged_player(entity) then return false end - end - -- Vanilla LuaComponents and external mods can mutate an existing AnimalAI/AIAttack - -- descriptor while the form is alive. Refresh from the live component before input; - -- polymorph_hax is disabled while MCM owns the direct ranged path, so descriptors can - -- remain untouched and continue serving as the compatibility source of truth. - sync_dynamic_attack_metadata(entity) - local controls = ensure_controls(entity) - if not valid(controls) then return false end - -- ensure_controls() enables the generic polymorph helper by default. Manual ranged - -- ownership replaces only its attack path, so keep that native helper off here. - pcall(ComponentSetValue2, controls, "polymorph_hax", false) - local frame = tonumber(GameGetFrameNum()) or 0 - local completed = false - - if ranged_animation_override ~= nil and frame >= (tonumber(ranged_animation_override.end_frame) or frame) then - restore_ranged_animation_override() - end - - -- Release only MCM's logical attack reservation. Do not inject a synthetic `stand` - -- animation here: landing/walking/flying are owned by Noita's locomotion controller, - -- and a forced stand after an airborne shot was racing that controller every landing. - -- Priority-0 attack playback above lets the native state machine replace even looped - -- attack animations naturally as soon as locomotion changes. - if ranged_attack_state_attack ~= nil and frame >= ranged_attack_state_until then - ranged_attack_state_attack = nil - ranged_attack_state_started = 0 - ranged_attack_state_until = 0 - end - - if pending_ranged_attack ~= nil then - if frame < pending_ranged_attack.fire_at then return false end - completed = flush_pending_attack(entity) - if pending_ranged_attack ~= nil then return completed end + secondary_attack_cursor = (selected_index % #attacks) + 1 + if selected.action_frame == 0 then + local pending = pending_secondary_attack + pending_secondary_attack = nil + if not fire_projectile_attack(entity, selected) then + selected.next_frame = pending.previous_next_frame + secondary_attack_global_next = pending.previous_global_next + pcall(ComponentSetValue2, selected.component, "mNextFrameUsable", pending.previous_component_next) + return false + end end - if frame < ranged_attack_global_next then return completed end - - local fire_primary = allow_primary and get_value(controls, "mButtonDownFire", false) == true - local fire_secondary = allow_secondary and get_value(controls, "mButtonDownFire2", false) == true - if not fire_primary and not fire_secondary then return completed end - - local selected = nil - if fire_primary then selected = select_primary_attack(entity, frame) end - if selected == nil and fire_secondary then selected = select_secondary_attack(entity, frame) end - if selected == nil then return completed end - return begin_ranged_attack(entity, selected, frame) or completed -end - -local function update_secondary_attacks(entity) - -- Stable compatibility hook for older adapters/tests that invoked the secondary - -- driver directly. Runtime configuration normally acquires ownership first. - if not manual_ranged_owned then configure_ranged_player(entity) end - return update_ranged_attacks(entity) + return true end @@ -934,21 +445,20 @@ local function step_manual_aim(comp, owner, sx, sy) aim_state = { base=current } turret_aim_state[comp] = aim_state end - local authored_half_arc = tonumber(get_value(comp, "creature_detection_angular_range_deg", 90)) or 90 - -- Noita documents this value as the half-angle around the entity's X axis: - -- 90 means the complete 180 degree forward sector (90 left + 90 right). - -- Do not divide it a second time when translating target detection to player aim. - authored_half_arc = math.max(0, math.min(90, authored_half_arc)) + local authored_arc = tonumber(get_value(comp, "creature_detection_angular_range_deg", 170)) or 170 + local full_arc = math.max(20, math.min(170, authored_arc > 0 and authored_arc or 170)) local relative = shortest_angle_delta(aim_state.base, desired) - local half_arc = math.rad(authored_half_arc) + local half_arc = math.rad(full_arc * 0.5) relative = math.max(-half_arc, math.min(half_arc, relative)) desired = aim_state.base + relative end - -- Preserve the engine-authored value verbatim. All vanilla ranged rotation speeds - -- are authored as small per-frame angular steps (0.01/0.05/0.1 in current data). - -- Converting values heuristically would reintroduce the very attack guessing this - -- driver is meant to remove. - local step = math.max(0.0001, math.abs(tonumber(get_value(comp, "attack_ranged_aim_rotation_speed", 3)) or 3)) + local raw_speed = math.abs(tonumber(get_value(comp, "attack_ranged_aim_rotation_speed", 3)) or 3) + -- The field is not consistently authored in one unit across vanilla entities: + -- small values behave like radians/frame while larger values are effectively + -- degree-like tuning. Convert both to radians and apply a responsive player floor. + -- A player-controlled turret should track intent, not spend seconds catching up. + local native_step = raw_speed <= 1 and raw_speed or math.rad(raw_speed) + local step = math.max(math.rad(12), native_step) local delta = shortest_angle_delta(current, desired) if math.abs(delta) > step then delta = delta < 0 and -step or step end local next_angle = current + delta @@ -980,12 +490,8 @@ end local function update_manual_aim(entity) for _, attack in ipairs(tree_components(entity, "AIAttackComponent")) do - if component_is_enabled(attack) and tostring(get_value(attack, "attack_ranged_entity_file", "") or "") ~= "" - and not aim_policy.static_weapon_attack(entity, attack) - then - local owner, _, _, pivot_x, pivot_y = ranged_origin(entity, attack) - if owner ~= nil then step_manual_aim(attack, owner, pivot_x, pivot_y) end - end + local owner, sx, sy = ranged_origin(entity, attack) + if owner ~= nil then step_manual_aim(attack, owner, sx, sy) end end for _, animal in ipairs(tree_components(entity, "AnimalAIComponent")) do @@ -994,7 +500,9 @@ local function update_manual_aim(entity) if owner == nil or owner == 0 then owner = entity end local x, y = EntityGetTransform(owner) if x ~= nil then - local aim_angle = step_manual_aim(animal, owner, x, y) + local ox = tonumber(get_value(animal, "attack_ranged_offset_x", 0)) or 0 + local oy = tonumber(get_value(animal, "attack_ranged_offset_y", 0)) or 0 + local aim_angle = step_manual_aim(animal, owner, x + ox, y + oy) if get_value(animal, "is_static_turret", false) == true then local target = ensure_turret_target() @@ -1002,9 +510,9 @@ local function update_manual_aim(entity) local a = tonumber(aim_angle) local tx, ty if a ~= nil then - tx, ty = x + math.cos(a) * 800, y + math.sin(a) * 800 + tx, ty = x + ox + math.cos(a) * 800, y + oy + math.sin(a) * 800 else - tx, ty = mouse_target(x, y) + tx, ty = mouse_target(x + ox, y + oy) end EntitySetTransform(target, tx, ty) pcall(ComponentSetValue2, animal, "mGreatestPrey", target) @@ -1017,22 +525,17 @@ local function update_manual_aim(entity) end end -local function update_manual_lasers(entity, allow_secondary, allow_primary) - if allow_secondary == nil then allow_secondary = true end - if allow_primary == nil then allow_primary = true end +local function update_manual_lasers(entity) local lasers = tree_components(entity, "LaserEmitterComponent") if #lasers == 0 then return end local controls = ensure_controls(entity) if not valid(controls) then return end - local fire_primary = allow_primary and get_value(controls, "mButtonDownFire", false) == true - local fire_secondary = allow_secondary and get_value(controls, "mButtonDownFire2", false) == true + local has_ranged = #tree_components(entity, "AIAttackComponent") > 0 local fire - if manual_ranged_owned then - fire = fire_primary or fire_secondary - elseif #tree_components(entity, "AIAttackComponent") > 0 then - fire = fire_secondary + if has_ranged then + fire = get_value(controls, "mButtonDownFire2", false) == true else - fire = fire_primary + fire = get_value(controls, "mButtonDownFire", false) == true end local frame = GameGetFrameNum() for _, laser in ipairs(lasers) do @@ -1051,19 +554,13 @@ end function form_combat.reset() - restore_ranged_animation_override() if turret_aim_target ~= 0 and EntityGetIsAlive(turret_aim_target) then EntityKill(turret_aim_target) end turret_aim_target = 0 turret_aim_state = {} - ranged_attack_cache = nil - ranged_attack_cursor = 1 - ranged_attack_global_next = 0 - ranged_attack_state_attack = nil - ranged_attack_state_started = 0 - ranged_attack_state_until = 0 - pending_ranged_attack = nil - manual_ranged_owned = false - manual_ranged_entity = 0 + secondary_attack_cache = nil + secondary_attack_cursor = 1 + secondary_attack_global_next = 0 + pending_secondary_attack = nil for _, barrel_record in ipairs(manual_barrel_pivots) do local pivot_entity = type(barrel_record) == "table" and barrel_record.entity or barrel_record local uses_native_child = type(barrel_record) == "table" and barrel_record.direct == true @@ -1075,15 +572,11 @@ function form_combat.reset() end form_combat.configure_non_ai_player = configure_non_ai_player -form_combat.configure_ranged_player = configure_ranged_player -form_combat.manual_ranged_owned = function() return manual_ranged_owned end form_combat.setup_manual_barrels = setup_manual_barrels form_combat.play_attack_animation = play_attack_animation -form_combat.update_ranged_attacks = update_ranged_attacks form_combat.update_secondary_attacks = update_secondary_attacks form_combat.update_manual_aim = update_manual_aim form_combat.update_manual_lasers = update_manual_lasers form_combat.tree_has_laser = function(entity) return #tree_components(entity, "LaserEmitterComponent") > 0 end -METAMORPH_CREATIVE_MENU_FORM_COMBAT = form_combat return form_combat diff --git a/metamorph_creative_menu/files/features/forms/corpse_service.lua b/metamorph_creative_menu/files/features/forms/corpse_service.lua index 9d81bee..1502887 100644 --- a/metamorph_creative_menu/files/features/forms/corpse_service.lua +++ b/metamorph_creative_menu/files/features/forms/corpse_service.lua @@ -1,9 +1,5 @@ local corpse_service = {} -local entity_tree = dofile("mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua") -local walk_entity_tree = entity_tree.walk -local ew_runtime = dofile("mods/metamorph_creative_menu/files/integrations/ew/runtime.lua") - local NETWORK_FORM_TAG = "metamorph_creative_menu_network_form" local POLYMORPH_EFFECTS = { "POLYMORPH", "POLYMORPH_RANDOM", "POLYMORPH_UNSTABLE", "POLYMORPH_CESSATION", @@ -12,15 +8,6 @@ local POLYMORPH_EFFECTS = { local pending_corpse_watch = {} local pending_corpse_finalize = {} -local function ew_active() - return ew_runtime.enabled() == true -end - -local function diagnostic_event(kind, details) - if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_EVENT) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_EVENT, tostring(kind or "FORM"), tostring(details or "")) - end -end local CORPSE_PLAYER_TAGS = { -- A dead player-form must become an ordinary world entity. In particular remove @@ -36,27 +23,6 @@ local CORPSE_FREEZE_COMPONENTS = { "BossDragonComponent", "WormPlayerComponent", } -local function component_string(component, field) - local ok, value = pcall(ComponentGetValue2, component, field) - return ok and tostring(value or "") or "" -end - -local function lua_is_pure_death_hook(component) - if component_string(component, "script_death") == "" then return false end - -- Preserve a component whose only purpose is native death handling. Everything that - -- can execute while the body is held for EW/DES adoption must stay frozen. - local active_fields = { - "script_source_file", "script_damage_received", "script_damage_about_to_be_received", - "script_shot", "script_kick", "script_collision_trigger_hit", "script_physics_body_modified", - "script_pressure_plate_change", "script_material_area_checker_success", - "script_electricity_receiver_electrified", "script_electricity_receiver_switched", - } - for _, field in ipairs(active_fields) do - if component_string(component, field) ~= "" then return false end - end - return true -end - local function corpse_health(entity) local damage = EntityGetFirstComponentIncludingDisabled(entity, "DamageModelComponent") if damage == nil or damage == 0 then return nil, nil end @@ -95,29 +61,16 @@ local function disarm_polymorph_restore(entity) end local function freeze_pending_corpse(entity) - local frame = tonumber(GameGetFrameNum()) or 0 - walk_entity_tree(entity, function(current) - for _, component_type in ipairs(CORPSE_FREEZE_COMPONENTS) do - for _, component in ipairs(EntityGetComponentIncludingDisabled(current, component_type) or {}) do - pcall(EntitySetComponentIsEnabled, current, component, false) - end + for _, component_type in ipairs(CORPSE_FREEZE_COMPONENTS) do + for _, component in ipairs(EntityGetComponentIncludingDisabled(entity, component_type) or {}) do + pcall(EntitySetComponentIsEnabled, entity, component, false) end - for _, lua in ipairs(EntityGetComponentIncludingDisabled(current, "LuaComponent") or {}) do - if not lua_is_pure_death_hook(lua) then - pcall(EntitySetComponentIsEnabled, current, lua, false) - end - end - for _, laser in ipairs(EntityGetComponentIncludingDisabled(current, "LaserEmitterComponent") or {}) do - pcall(ComponentSetValue2, laser, "is_emitting", false) - pcall(ComponentSetValue2, laser, "emit_until_frame", frame) - pcall(EntitySetComponentIsEnabled, current, laser, false) - end - local character = EntityGetFirstComponentIncludingDisabled(current, "CharacterDataComponent") - if character ~= nil and character ~= 0 then pcall(ComponentSetValue2, character, "mVelocity", 0, 0) end - for _, velocity in ipairs(EntityGetComponentIncludingDisabled(current, "VelocityComponent") or {}) do - pcall(ComponentSetValue2, velocity, "mVelocity", 0, 0) - end - end) + end + local character = EntityGetFirstComponentIncludingDisabled(entity, "CharacterDataComponent") + if character ~= nil and character ~= 0 then pcall(ComponentSetValue2, character, "mVelocity", 0, 0) end + for _, velocity in ipairs(EntityGetComponentIncludingDisabled(entity, "VelocityComponent") or {}) do + pcall(ComponentSetValue2, velocity, "mVelocity", 0, 0) + end end local function ordinary_corpse_fallback_allowed(source) @@ -154,43 +107,12 @@ local function spawn_native_corpse_fallback(record, x, y) return corpse end -local function retire_network_player_form(entity, source_path, reason) - -- In EW the old polymorphed body is a player-replica identity, not a normal enemy. - -- Re-tagging it as ew_synced created a *second* DES identity during death, which could - -- reappear as a low-HP boss. After the human entity is committed, retire this old - -- identity quietly instead. Never run its boss/worm death scripts here. - disarm_polymorph_restore(entity) - for _, tag in ipairs(CORPSE_PLAYER_TAGS) do pcall(EntityRemoveTag, entity, tag) end - pcall(EntityRemoveTag, entity, "ew_synced") - pcall(EntityRemoveTag, entity, "ew_des") - pcall(EntitySetTransform, entity, 10000000, 10000000) - walk_entity_tree(entity, function(current) - for _, component in ipairs(EntityGetAllComponents(current) or {}) do - local ok_type, component_type = pcall(ComponentGetTypeName, component) - if ok_type and component_type == "LuaComponent" then - pcall(EntityRemoveComponent, current, component) - else - if ok_type and component_type == "DamageModelComponent" then - pcall(ComponentSetValue2, component, "wait_for_kill_flag_on_death", false) - pcall(ComponentSetValue2, component, "kill_now", false) - end - pcall(EntitySetComponentIsEnabled, current, component, false) - end - end - end) - if EntityGetIsAlive(entity) then pcall(EntityKill, entity) end - diagnostic_event("FORM NETWORK BODY RETIRED", string.format("entity=%s source=%s reason=%s", - tostring(entity), tostring(source_path or ""), tostring(reason or "death"))) - return true -end - -- Called only after the human player entity has been committed. Hold the old creature -- for exactly one world frame as an inert ew_synced entity so EW/DES can adopt it, then -- release Noita's native death/ragdoll path with DamageModel.kill_now. The modding API -- wait_for_kill_flag_on_death requires this explicit kill flag to finish native death. local function detach(entity, source_path, reason, responsible) if entity == nil or entity == 0 or not EntityGetIsAlive(entity) then return false end - if ew_active() then return retire_network_player_form(entity, source_path, reason) end for _, tag in ipairs(CORPSE_PLAYER_TAGS) do pcall(EntityRemoveTag, entity, tag) end pcall(EntityAddTag, entity, "metamorph_creative_menu_form_corpse") pcall(EntityAddTag, entity, "metamorph_creative_menu_form_corpse_pending") @@ -221,10 +143,6 @@ local function detach(entity, source_path, reason, responsible) born=frame, next_index=1, checks={1,3,10,60}, } local gid = corpse_des_gid(entity) - diagnostic_event("FORM CORPSE PENDING", string.format("entity=%s source=%s pos=%.1f,%.1f hp=%s/%s ew_synced=%s des=%s gid=%s release=%s", - tostring(entity), tostring(source_path or ""), tonumber(x) or 0, tonumber(y) or 0, - tostring(hp), tostring(max_hp), tostring(EntityHasTag(entity, "ew_synced")), - tostring(EntityHasTag(entity, "ew_des")), tostring(gid or ""), tostring(frame + 1))) return true end @@ -239,8 +157,6 @@ function corpse_service.update() local entity = tonumber(record.entity) or 0 local alive = entity ~= 0 and EntityGetIsAlive(entity) if not alive then - diagnostic_event("FORM CORPSE FINALIZED", string.format("entity=%s source=%s age=%s native_death=true", - tostring(entity), tostring(record.source or ""), tostring(frame - (tonumber(record.born) or frame)))) table.remove(pending_corpse_finalize, index) elseif frame >= (tonumber(record.release_frame) or frame) and record.released ~= true then local damage = EntityGetFirstComponentIncludingDisabled(entity, "DamageModelComponent") @@ -250,9 +166,6 @@ function corpse_service.update() record.released = true record.release_frame = frame pcall(EntityRemoveTag, entity, "metamorph_creative_menu_form_corpse_pending") - diagnostic_event("FORM CORPSE RELEASE", string.format("entity=%s source=%s hp=%s/%s des=%s gid=%s", - tostring(entity), tostring(record.source or ""), tostring(corpse_health(entity)), - select(2, corpse_health(entity)), tostring(EntityHasTag(entity, "ew_des")), tostring(corpse_des_gid(entity) or ""))) elseif record.released == true and frame - (tonumber(record.release_frame) or frame) >= 3 and record.forced ~= true then -- A few unusual entities cache the wait flag during the death callback. If -- kill_now did not complete the death within three frames, clear the wait @@ -266,7 +179,6 @@ function corpse_service.update() end freeze_pending_corpse(entity) record.forced = true - diagnostic_event("FORM CORPSE FORCE_RELEASE", string.format("entity=%s source=%s", tostring(entity), tostring(record.source or ""))) elseif record.released == true and frame - (tonumber(record.release_frame) or frame) >= 20 then -- Last-resort protection: never leave a living detached player-form entity -- immortal detached form in the world. For ordinary animals, replace only @@ -276,8 +188,6 @@ function corpse_service.update() -- consequential; a stuck body is removed instead. local x, y = EntityGetTransform(entity) local fallback = spawn_native_corpse_fallback(record, x, y) - diagnostic_event("FORM CORPSE FALLBACK", string.format("old=%s fallback=%s source=%s", - tostring(entity), tostring(fallback), tostring(record.source or ""))) if EntityGetIsAlive(entity) then pcall(EntityKill, entity) end if fallback ~= 0 then pending_corpse_watch[#pending_corpse_watch + 1] = { @@ -300,10 +210,6 @@ function corpse_service.update() local x, y, hp, max_hp = 0, 0, nil, nil if alive then x, y = EntityGetTransform(entity); hp, max_hp = corpse_health(entity) end local gid = alive and corpse_des_gid(entity) or nil - diagnostic_event("FORM CORPSE WATCH", string.format("age=%s entity=%s alive=%s source=%s pos=%.1f,%.1f hp=%s/%s sync_request=%s des=%s gid=%s", - tostring(check_at), tostring(entity), tostring(alive), tostring(record.source or ""), tonumber(x) or 0, tonumber(y) or 0, - tostring(hp), tostring(max_hp), tostring(alive and EntityHasTag(entity, "ew_synced") or false), - tostring(alive and EntityHasTag(entity, "ew_des") or false), tostring(gid or ""))) record.next_index = (record.next_index or 1) + 1 if not alive or record.next_index > #checks then table.remove(pending_corpse_watch, index) end end diff --git a/metamorph_creative_menu/files/features/forms/exact_effects.lua b/metamorph_creative_menu/files/features/forms/exact_effects.lua index a383f41..33ecfbb 100644 --- a/metamorph_creative_menu/files/features/forms/exact_effects.lua +++ b/metamorph_creative_menu/files/features/forms/exact_effects.lua @@ -18,9 +18,9 @@ local function effect_xml(target) end local RUNTIME_ENTITY_CLONE_MODE = { - -- `sheep.xml` is treated specially by Noita's polymorph path and can resolve to - -- alternate sheep forms. A byte-identical copy preserves ordinary sheep data while - -- removing the special filename from the polymorph target. + -- `sheep.xml` is treated specially by Noita's polymorph path and can resolve + -- to sheep_bat/sheep_fly. A byte-identical copy keeps + -- the ordinary sheep data while removing the magic filename from the target. ["data/entities/animals/sheep.xml"] = "exact", -- These NPCs run wand_ghost.lua on their first update. That script owns NPC wand @@ -153,6 +153,8 @@ function exact_effects.runtime_target(entity_path) return exact_runtime_entity_target(entity_path) end + + function exact_effects.default_duration_frames() return LONG_DURATION end diff --git a/metamorph_creative_menu/files/features/forms/external_polymorph.lua b/metamorph_creative_menu/files/features/forms/external_polymorph.lua deleted file mode 100644 index 60aa178..0000000 --- a/metamorph_creative_menu/files/features/forms/external_polymorph.lua +++ /dev/null @@ -1,62 +0,0 @@ -local external_polymorph = {} - -local human_restore = dofile("mods/metamorph_creative_menu/files/features/forms/human_restore.lua") - -local RANDOM_TARGETS = { - ["[RANDOM]"] = true, - ["[RANDOM_SUPER]"] = true, -} - -local function alive(entity) - return entity ~= nil and entity ~= 0 and EntityGetIsAlive(entity) -end - -local function component_target(components) - for _, component in ipairs(components or {}) do - local ok, target = pcall(ComponentGetValue2, component, "polymorph_target") - target = ok and tostring(target or "") or "" - if target ~= "" and not RANDOM_TARGETS[target] then return target end - end - return "" -end - -local function entity_target(entity, components) - if type(EntityGetFilename) == "function" then - local ok, path = pcall(EntityGetFilename, entity) - path = ok and tostring(path or "") or "" - -- For random/unstable polymorph the GameEffect keeps a generic target marker; - -- the live body filename is the only exact creature identity and therefore the - -- correct input for MCM's attack/profile replay. - if path ~= "" then return path end - end - return component_target(components) -end - --- Describe only a real local polymorph body. Ownership/local-player selection remains --- the caller's job (form_manager uses player_locator); this module deliberately knows --- nothing about EW, menu state, input, or the form session lifecycle. -function external_polymorph.describe(entity) - if not alive(entity) or not EntityHasTag(entity, "polymorphed_player") then return nil end - - local components = human_restore.polymorph_effect_components(entity) - if #components == 0 then return nil end - - local target = entity_target(entity, components) - if target == "" then return nil end - - local backup = human_restore.serialized_backup_from_effects(components) - return { - entity = entity, - target = target, - requested_target = target, - components = components, - original_backup = backup, - allow_death_handoff = type(backup) == "string" and backup ~= "", - } -end - -function external_polymorph.is_active(entity) - return external_polymorph.describe(entity) ~= nil -end - -return external_polymorph diff --git a/metamorph_creative_menu/files/features/forms/family.lua b/metamorph_creative_menu/files/features/forms/family.lua index 4c48803..30da2ef 100644 --- a/metamorph_creative_menu/files/features/forms/family.lua +++ b/metamorph_creative_menu/files/features/forms/family.lua @@ -59,4 +59,9 @@ function form_family.detect(entity) return "unknown" end +function form_family.is_native_tank_path(entity_path) + local entity_id = string.match(string.lower(tostring(entity_path or "")), "([^/]+)%.xml$") or "" + return entity_id == "tank" or string.match(entity_id, "^tank_") ~= nil +end + return form_family diff --git a/metamorph_creative_menu/files/features/forms/held_wand_forms/controller.lua b/metamorph_creative_menu/files/features/forms/held_wand_forms/controller.lua deleted file mode 100644 index c98d0df..0000000 --- a/metamorph_creative_menu/files/features/forms/held_wand_forms/controller.lua +++ /dev/null @@ -1,53 +0,0 @@ -local held_wand_forms = {} - -local component_ops = dofile("mods/metamorph_creative_menu/files/features/forms/component_ops.lua") -local wand_ghost = dofile("mods/metamorph_creative_menu/files/features/forms/held_wand_forms/wand_ghost.lua") - -local valid = component_ops.valid -local ensure_controls = component_ops.ensure_controls - -local PATHS = { - ["data/entities/animals/wand_ghost.xml"] = true, - ["data/entities/animals/wand_ghost_charmed.xml"] = true, - ["data/entities/animals/wand_ghost_with_sampo.xml"] = true, -} - -local active = nil -local active_entity = 0 - -function held_wand_forms.reset() - if active ~= nil and type(active.reset) == "function" then active.reset() end - active = nil - active_entity = 0 -end - -function held_wand_forms.configure(entity, path) - held_wand_forms.reset() - if PATHS[tostring(path or "")] ~= true then return false end - if wand_ghost.configure(entity) ~= true then return false end - active = wand_ghost - active_entity = entity - return true -end - -function held_wand_forms.update(entity) - if active == nil or entity ~= active_entity then return false end - local controls = ensure_controls(entity) - if not valid(controls) then return false end - -- ensure_controls() intentionally enables polymorph_hax for ordinary creature forms. - -- Held-wand ghosts own primary fire themselves, so immediately take that surface back - -- before the native ghost AI is pulsed. Otherwise the fallback orb_pink descriptor can - -- leak through on the same click. - pcall(ComponentSetValue2, controls, "polymorph_hax", false) - return active.update(entity, controls) == true -end - -function held_wand_forms.active() - return active ~= nil -end - -function held_wand_forms.owns_primary() - return active ~= nil and type(active.owns_primary) == "function" and active.owns_primary() == true -end - -return held_wand_forms diff --git a/metamorph_creative_menu/files/features/forms/held_wand_forms/wand_ghost.lua b/metamorph_creative_menu/files/features/forms/held_wand_forms/wand_ghost.lua deleted file mode 100644 index fa3a55f..0000000 --- a/metamorph_creative_menu/files/features/forms/held_wand_forms/wand_ghost.lua +++ /dev/null @@ -1,194 +0,0 @@ -local wand_ghost = {} - -local component_ops = dofile("mods/metamorph_creative_menu/files/features/forms/component_ops.lua") -local gameplay_input = dofile("mods/metamorph_creative_menu/files/platform/noita/gameplay_input.lua") -local patcher_bridge = dofile("mods/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua") - -local valid = component_ops.valid -local get_value = component_ops.get - --- Wand ghosts are an inventory/item creature, not a projectile descriptor creature. --- A player-polymorphed root cannot safely reproduce that ownership through AnimalAI: --- forcing the ranged state fires base_wand_ghost.xml's orb_pink fallback instead of the --- held procedural wand. Keep the polymorph root solely as the player body and attach an --- unsynchronised *real vanilla wand_ghost.xml* proxy. Its untouched one-shot --- wand_ghost.lua generates/picks up the actual wand, the engine renders that held item, --- and NoitaPatcher UseItem casts that exact wand just like player/companion wand use. -local HELPER_ENTITY = "data/entities/animals/wand_ghost.xml" -local state = nil -local generation_serial = 0 - -local function component(entity, kind) - if entity == nil or entity == 0 then return nil end - return EntityGetFirstComponentIncludingDisabled(entity, kind) -end - -local function set_enabled(entity, comp, enabled) - if valid(comp) then pcall(EntitySetComponentIsEnabled, entity, comp, enabled == true) end -end - -local function destroy(entity) - if entity ~= nil and entity ~= 0 and EntityGetIsAlive(entity) then pcall(EntityKill, entity) end -end - -local function sanitize_proxy(helper) - if type(EntityAddTag) == "function" then - pcall(EntityAddTag, helper, "metamorph_creative_menu_runtime") - pcall(EntityAddTag, helper, "ew_no_enemy_sync") - pcall(EntityAddTag, helper, "metamorph_creative_menu_wand_proxy") - end - if type(EntityRemoveTag) == "function" then - pcall(EntityRemoveTag, helper, "hittable") - pcall(EntityRemoveTag, helper, "homing_target") - pcall(EntityRemoveTag, helper, "enemy") - end - - -- The proxy is presentation/casting infrastructure only. It must never become an - -- extra combat body, autonomous mover or death/drop source. - for _, kind in ipairs({"DamageModelComponent", "HitboxComponent", "PhysicsAIComponent", "AnimalAIComponent"}) do - for _, comp in ipairs(EntityGetComponentIncludingDisabled(helper, kind) or {}) do - set_enabled(helper, comp, false) - end - end - - local pickup = component(helper, "ItemPickUpperComponent") - if valid(pickup) then - pcall(ComponentSetValue2, pickup, "drop_items_on_death", false) - pcall(ComponentSetValue2, pickup, "is_immune_to_kicks", true) - pcall(ComponentSetValue2, pickup, "is_in_npc", true) - end -end - -local function mouse_world(x, y) - if type(DEBUG_GetMouseWorld) == "function" then - local ok, mx, my = pcall(DEBUG_GetMouseWorld) - if ok and tonumber(mx) ~= nil and tonumber(my) ~= nil then return tonumber(mx), tonumber(my) end - end - return x + 100, y -end - -local function current_wand(helper) - if type(GameGetAllInventoryItems) ~= "function" then return 0 end - local ok, items = pcall(GameGetAllInventoryItems, helper) - if not ok or type(items) ~= "table" then return 0 end - for _, item in ipairs(items) do - if item ~= nil and item ~= 0 and EntityGetIsAlive(item) then - if type(EntityHasTag) ~= "function" or EntityHasTag(item, "wand") then return item end - end - end - return 0 -end - -local function spawn_helper(entity) - local x, y = EntityGetTransform(entity) - if x == nil then return 0 end - generation_serial = generation_serial + 1 - local frame = type(GameGetFrameNum) == "function" and (tonumber(GameGetFrameNum()) or 0) or 0 - -- The procedural wand generator seeds from spawn coordinates. Keep the invisible - -- proxy at a unique seed location until wand_ghost.lua has created the item, then - -- snap it onto the player. This is why repeated transforms no longer resolve to the - -- same deck merely because the player stood on the same pixel. - local sx = x + 257 + generation_serial * 31 - local sy = y + 131 + (frame % 71) * 13 + generation_serial * 7 - local helper = EntityLoad(HELPER_ENTITY, sx, sy) or 0 - if helper == 0 then return 0 end - sanitize_proxy(helper) - return helper, sx, sy -end - -local function ability_ready(wand, frame) - local ability = component(wand, "AbilityComponent") - if not valid(ability) then return true end - local reload_left = tonumber(get_value(ability, "mReloadFramesLeft", 0)) or 0 - local next_usable = tonumber(get_value(ability, "mNextFrameUsable", 0)) or 0 - return reload_left <= 0 and next_usable <= frame -end - -local function use_real_wand(entity) - if state == nil or state.wand == 0 or not EntityGetIsAlive(state.wand) then return false end - local bridge = patcher_bridge.get({capability="UseItem", bootstrap_if_installed=true}) - if type(bridge) ~= "table" or type(bridge.UseItem) ~= "function" then return false end - - local frame = tonumber(GameGetFrameNum()) or 0 - if not ability_ready(state.wand, frame) then return false end - - local x, y = EntityGetTransform(entity) - if x == nil then return false end - local tx, ty = mouse_world(x, y) - local wx, wy = EntityGetTransform(state.wand) - wx, wy = tonumber(wx) or x, tonumber(wy) or y - - -- Patcher item use expects a player-like shooter tag during gun-script execution; - -- EW and MCM's companion system use the same narrow tag scope. The proxy remains - -- unsynchronised and non-damageable outside this call. - if type(EntityAddTag) == "function" then pcall(EntityAddTag, state.helper, "player_unit") end - local ok = pcall(bridge.UseItem, state.helper, state.wand, true, true, true, wx, wy, tx, ty) - if type(EntityRemoveTag) == "function" then pcall(EntityRemoveTag, state.helper, "player_unit") end - return ok == true -end - -function wand_ghost.reset() - if state ~= nil then - if state.helper ~= 0 and EntityGetIsAlive(state.helper) and type(GameGetAllInventoryItems) == "function" then - local ok, items = pcall(GameGetAllInventoryItems, state.helper) - if ok and type(items) == "table" then - for _, item in ipairs(items) do destroy(item) end - end - end - destroy(state.helper) - end - state = nil -end - -function wand_ghost.configure(entity) - wand_ghost.reset() - local helper, sx, sy = spawn_helper(entity) - if helper == nil or helper == 0 then return false end - state = { - entity=entity, - helper=helper, - wand=0, - seed_x=sx, - seed_y=sy, - ready=false, - } - gameplay_input.require_release("primary") - return true -end - -function wand_ghost.update(entity, controls) - if state == nil or entity ~= state.entity or not EntityGetIsAlive(entity) then return false end - if state.helper == 0 or not EntityGetIsAlive(state.helper) then return false end - - if not state.ready then - local wand = current_wand(state.helper) - if wand ~= 0 then - state.wand = wand - state.ready = true - else - -- Do not move the proxy before its vanilla one-shot init has generated the - -- procedural wand: these coordinates are intentionally the random seed. - return true - end - end - - local x, y, r, sx, sy = EntityGetTransform(entity) - if x ~= nil then pcall(EntitySetTransform, state.helper, x, y, r or 0, sx or 1, sy or 1) end - - if gameplay_input.down(controls, "primary") then use_real_wand(entity) end - return true -end - -function wand_ghost.owns_primary() - return state ~= nil -end - -function wand_ghost.wand_entity() - return state and state.wand or 0 -end - -function wand_ghost.helper_entity() - return state and state.helper or 0 -end - -return wand_ghost diff --git a/metamorph_creative_menu/files/features/forms/human_restore.lua b/metamorph_creative_menu/files/features/forms/human_restore.lua index e991057..b69efbf 100644 --- a/metamorph_creative_menu/files/features/forms/human_restore.lua +++ b/metamorph_creative_menu/files/features/forms/human_restore.lua @@ -9,32 +9,6 @@ local function valid_component(component) return component ~= nil and component ~= 0 end --- A transform can be requested by clicking the creative menu while its input guard --- temporarily owns ControlsComponent.enabled. Native polymorph serializes that exact --- player state, so the restored human can otherwise inherit disabled controls forever. --- Repair both the public field and the component's engine-enabled state, and clear the --- form-only polymorph flag once authority belongs to a human again. -function human_restore.restore_controls(player_entity) - if player_entity == nil or player_entity == 0 or not EntityGetIsAlive(player_entity) then return false end - - local components = EntityGetComponentIncludingDisabled(player_entity, "ControlsComponent") or {} - if #components == 0 then - local first = EntityGetFirstComponentIncludingDisabled(player_entity, "ControlsComponent") - if valid_component(first) then components = { first } end - end - - local restored = false - for _, component in ipairs(components) do - if valid_component(component) then - local field_enabled = pcall(ComponentSetValue2, component, "enabled", true) - pcall(ComponentSetValue2, component, "polymorph_hax", false) - local component_enabled = pcall(EntitySetComponentIsEnabled, player_entity, component, true) - restored = restored or field_enabled or component_enabled - end - end - return restored -end - function human_restore.protect_player(player_entity, invincibility_frames) if player_entity == nil or player_entity == 0 then return end local damage_component = EntityGetFirstComponentIncludingDisabled(player_entity, "DamageModelComponent") diff --git a/metamorph_creative_menu/files/features/forms/manager.lua b/metamorph_creative_menu/files/features/forms/manager.lua index 0f4ac83..4ccf299 100644 --- a/metamorph_creative_menu/files/features/forms/manager.lua +++ b/metamorph_creative_menu/files/features/forms/manager.lua @@ -5,13 +5,8 @@ local NETWORK_FORM_TAG = "metamorph_creative_menu_network_form" local NETWORK_SOURCE_NAME = "metamorph_creative_menu_network_source" local function mark_network_form_source(entity, target) - if entity == nil or entity == 0 or not EntityGetIsAlive(entity) or type(target) ~= "string" or target == "" then return false end - local tag_ok = pcall(EntityAddTag, entity, NETWORK_FORM_TAG) - if not tag_ok then return false end - -- EW native DES already excludes polymorphed_player, but add its explicit exclusion - -- tag at the earliest MCM-owned point too. This closes load-order races where a very - -- large boss form is created between two mods' OnWorldPreUpdate callbacks. - pcall(EntityAddTag, entity, "ew_no_enemy_sync") + if entity == nil or entity == 0 or not EntityGetIsAlive(entity) or type(target) ~= "string" or target == "" then return end + pcall(EntityAddTag, entity, NETWORK_FORM_TAG) local storage = EntityGetFirstComponentIncludingDisabled(entity, "VariableStorageComponent", NETWORK_FORM_TAG) if storage == nil or storage == 0 then local ok, created = pcall(EntityAddComponent2, entity, "VariableStorageComponent", { @@ -19,10 +14,10 @@ local function mark_network_form_source(entity, target) }) if ok then storage = created end end - if storage == nil or storage == 0 then return false end - local name_ok = pcall(ComponentSetValue2, storage, "name", NETWORK_SOURCE_NAME) - local value_ok = pcall(ComponentSetValue2, storage, "value_string", target) - return name_ok and value_ok + if storage ~= nil and storage ~= 0 then + pcall(ComponentSetValue2, storage, "name", NETWORK_SOURCE_NAME) + pcall(ComponentSetValue2, storage, "value_string", target) + end end @@ -36,8 +31,6 @@ local player_authority = dofile("mods/metamorph_creative_menu/files/features/for local transform_flash = dofile("mods/metamorph_creative_menu/files/features/forms/transform_flash.lua") local corpse_service = dofile("mods/metamorph_creative_menu/files/features/forms/corpse_service.lua") local human_restore = dofile("mods/metamorph_creative_menu/files/features/forms/human_restore.lua") -local external_polymorph = dofile("mods/metamorph_creative_menu/files/features/forms/external_polymorph.lua") -local transition_queue = dofile("mods/metamorph_creative_menu/files/features/forms/transition_queue.lua") local form_death_channel = dofile("mods/metamorph_creative_menu/files/integrations/ew/form_death_channel.lua") local session = nil @@ -45,8 +38,6 @@ local pending_return_frame = nil local runtime_hooks_installed = false local detach_dead_form_as_corpse = corpse_service.detach local session_counter = 0 -local orphan_probe_complete = false -local orphan_probe_entity = 0 -- Form runtime failures used to be swallowed by pcall every frame. Keep -- the protective boundary (a malformed creature must not take down the whole menu), @@ -55,11 +46,6 @@ local orphan_probe_entity = 0 local runtime_error_log = {} local RUNTIME_ERROR_REPEAT_FRAMES = 600 -local function diagnostic_event(kind, details) - if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_EVENT) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_EVENT, tostring(kind or "FORM"), tostring(details or "")) - end -end local function real_time_ms() if type(GameGetRealWorldTimeSinceStarted) ~= "function" then return nil end @@ -81,9 +67,6 @@ local function protected_runtime_call(label, fn, ...) local last = runtime_error_log[signature] if last == nil or frame - last >= RUNTIME_ERROR_REPEAT_FRAMES then runtime_error_log[signature] = frame - if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "form." .. tostring(label), message) - end print("[Metamorph: Creative Menu] " .. tostring(label) .. " failed: " .. message) end return false @@ -94,14 +77,6 @@ local function valid(component) end local protect_restored_player = human_restore.protect_player -local restore_human_controls = type(human_restore.restore_controls) == "function" - and human_restore.restore_controls or function() return false end - -local function activate_restored_player(player_entity, invincibility_frames) - local controls_restored = restore_human_controls(player_entity) - protect_restored_player(player_entity, invincibility_frames) - return controls_restored -end function form_manager.prepare_exact_effect_paths(entries) return exact_effects.prepare(entries) end function form_manager.prepare_exact_effect_paths_from_catalog() return exact_effects.prepare_from_catalog() end @@ -117,81 +92,6 @@ local function next_session_id() return session_counter end -local function network_form_source(entity) - for _, storage in ipairs(EntityGetComponentIncludingDisabled(entity, "VariableStorageComponent") or {}) do - local ok_name, name = pcall(ComponentGetValue2, storage, "name") - if ok_name and name == NETWORK_SOURCE_NAME then - local ok_value, value = pcall(ComponentGetValue2, storage, "value_string") - if ok_value and type(value) == "string" and value ~= "" then return value end - end - end - return nil -end - -local function polymorph_component_target(components) - for _, component in ipairs(components or {}) do - local ok, target = pcall(ComponentGetValue2, component, "polymorph_target") - if ok and type(target) == "string" and target ~= "" then return target end - end - return nil -end - --- Lua session state is not part of Noita's polymorph serialization. Rehydrate an MCM --- form after save/load, and also adopt a local polymorph created by vanilla, a spell, --- a stain or another mod. Native GameEffectComponent.mSerializedData remains the single --- source of truth for the original human, so adoption does not depend on knowing what --- caused the transformation. -local function recover_or_adopt_form_session() - if session ~= nil then return true end - local current = current_player() - if current == 0 then return false end - - local owned_marker = EntityHasTag(current, "polymorphed_player") - and EntityHasTag(current, NETWORK_FORM_TAG) - local description = external_polymorph.describe(current) - if description == nil then - orphan_probe_entity = current - orphan_probe_complete = true - return false - end - - local components = description.components or {} - local target = owned_marker - and (network_form_source(current) or polymorph_component_target(components) or description.target) - or description.target - if type(target) ~= "string" or target == "" then target = description.target end - local x, y = EntityGetTransform(current) - session = { - id = next_session_id(), - kind = "polymorph", - phase = "active", - form_entity = current, - network_marked_entity = owned_marker and current or 0, - network_marked_target = owned_marker and target or nil, - target = target, - requested_target = target, - compatibility_mode = owned_marker and "save_recovery" or "external_polymorph", - role = "creature", - form_strategy = owned_marker and "native_polymorph" or "external_native_polymorph", - profile = profile_api.get(target), - original_backup = description.original_backup, - allow_death_handoff = description.allow_death_handoff == true, - effect_entity = 0, - started = GameGetFrameNum(), - last_x = tonumber(x) or 0, - last_y = tonumber(y) or 0, - recovered_from_save = owned_marker, - adopted_external = not owned_marker, - } - orphan_probe_complete = true - orphan_probe_entity = current - form_manager.ensure_runtime_hooks({ bootstrap_if_installed = true }) - diagnostic_event(owned_marker and "FORM RECOVERED" or "FORM ADOPTED", - string.format("entity=%s target=%s backup=%s", tostring(current), tostring(target), - tostring(session.allow_death_handoff))) - return true -end - local function human_entity_ready(entity) if entity == nil or entity == 0 or not EntityGetIsAlive(entity) then return false end if session ~= nil then return false end @@ -225,7 +125,7 @@ local function fallback_restore(current, components) if switch_state ~= "unknown" and EntityGetIsAlive(restored) then EntityKill(restored) end return false end - activate_restored_player(restored, 12) + protect_restored_player(restored, 12) EntityKill(current) session = nil pending_return_frame = nil @@ -263,7 +163,7 @@ local function restore_polymorph_backup(old_form, rescued_from_death) owned.phase = "active" return false end - activate_restored_player(restored, 12) + protect_restored_player(restored, rescued_from_death and 12 or 12) local cleanup = old_form ~= nil and old_form ~= 0 and old_form ~= restored and EntityGetIsAlive(old_form) local corpse_source = tostring(owned.requested_target or owned.target or "") @@ -287,9 +187,6 @@ function form_manager.handle_form_death(old_form, reason, responsible, damage, p local source_path = session ~= nil and tostring(session.requested_target or session.target or "") or "" local x0, y0 = 0, 0 if old_form ~= 0 and EntityGetIsAlive(old_form) then x0, y0 = EntityGetTransform(old_form) end - diagnostic_event("FORM DEATH", string.format("entity=%s source=%s reason=%s responsible=%s damage=%s projectile=%s pos=%.1f,%.1f", - tostring(old_form), source_path, tostring(reason or "death"), tostring(responsible or 0), tostring(damage), tostring(projectile or 0), - tonumber(x0) or 0, tonumber(y0) or 0)) if session == nil then return false end if session.kind ~= "polymorph" @@ -329,19 +226,12 @@ function form_manager.handle_form_death(old_form, reason, responsible, damage, p return false end - activate_restored_player(restored, 12) - -- reset() restores any Lua/AIAttack state MCM temporarily owned. Do that before the - -- old body is detached, then let corpse_service perform the final tree-wide freeze. - -- Reversing this order can resurrect boss scripts or a laser on the one EW/DES handoff - -- frame where the corpse is intentionally kept alive. + protect_restored_player(restored, 12) + local corpse_detached = detach_dead_form_as_corpse(old_form, source_path, reason, responsible) + local death_finished_ms = real_time_ms() session = nil pending_return_frame = nil protected_runtime_call("form_runtime.reset", form_runtime.reset) - local corpse_detached = detach_dead_form_as_corpse(old_form, source_path, reason, responsible) - local death_finished_ms = real_time_ms() - diagnostic_event("FORM HANDOFF", string.format("old=%s restored=%s corpse_detached=%s elapsed_ms=%s", - tostring(old_form), tostring(restored), tostring(corpse_detached), - death_started_ms ~= nil and death_finished_ms ~= nil and string.format("%.2f", death_finished_ms-death_started_ms) or "nil")) return true end @@ -356,32 +246,21 @@ function form_manager.ensure_runtime_hooks(options) return runtime_hooks_installed end -local function ensure_transform_effect(entity_path) - if type(entity_path) ~= "string" or entity_path == "" or not ModDoesFileExist(entity_path) then - return nil, "target" - end - local effect_file = exact_effects.effect_path(entity_path) - if type(effect_file) ~= "string" or effect_file == "" then - exact_effects.invalidate_failed_target(entity_path) - if exact_effects.prepare({entity_path}) > 0 then effect_file = exact_effects.effect_path(entity_path) end - end - if type(effect_file) ~= "string" or effect_file == "" then return nil, "effect_not_cached" end - return effect_file -end - -local function start_transform_creature(player, entity_path, frames, options, prepared_effect_file) +function form_manager.transform_creature(player, entity_path, frames, _legacy_force_unverified, options) if session ~= nil then return false, "form_busy" end if player == nil or player == 0 or not EntityGetIsAlive(player) then return false, "player" end if EntityHasTag(player, "polymorphed_player") then return false, "not_human" end - - local effect_file = prepared_effect_file + if type(entity_path) ~= "string" or entity_path == "" or not ModDoesFileExist(entity_path) then + return false, "target" + end + local effect_file = exact_effects.effect_path(entity_path) if type(effect_file) ~= "string" or effect_file == "" then - local reason - effect_file, reason = ensure_transform_effect(entity_path) - if effect_file == nil then return false, reason end + exact_effects.invalidate_failed_target(entity_path) -- retry late VFS/mod resources without exposing cache internals + if exact_effects.prepare({entity_path}) > 0 then effect_file = exact_effects.effect_path(entity_path) end end + if type(effect_file) ~= "string" or effect_file == "" then return false, "effect_not_cached" end options = type(options) == "table" and options or {} local requested_target = type(options.requested_target) == "string" and options.requested_target ~= "" and options.requested_target or entity_path @@ -399,16 +278,24 @@ local function start_transform_creature(player, entity_path, frames, options, pr end local start_x, start_y = EntityGetTransform(player) + -- Suppress only the presentation flash caused by swapping from human HP to a + -- low-HP creature. The creature's real hp/max_hp are never changed. Applying + -- this before native polymorph is important: post-transform suppression is one + -- frame too late for the transition flash. suppress_transform_flash(18) + -- The exact source is attached to the resulting polymorphed entity by + -- mark_network_form_source(); do not maintain a second write-only Globals copy. local ok, effect_entity = pcall(LoadGameEffectEntityTo, player, effect_file) if not ok or effect_entity == nil or effect_entity == 0 then return false, "effect" end + -- Some builds swap the polymorph body synchronously enough for the first render + -- to see its low HP before our next OnWorldPreUpdate. Prime the new damage UI now + -- when possible; form runtime repeats this once when the form becomes active. local immediate = current_player() - local immediate_network_marked = 0 if immediate ~= 0 and immediate ~= player and EntityHasTag(immediate, "polymorphed_player") then - if mark_network_form_source(immediate, entity_path) then immediate_network_marked = immediate end + mark_network_form_source(immediate, entity_path) local damage = EntityGetFirstComponentIncludingDisabled(immediate, "DamageModelComponent") if valid(damage) then local max_hp = tonumber(ComponentGetValue2(damage, "max_hp")) or 0 @@ -429,8 +316,6 @@ local function start_transform_creature(player, entity_path, frames, options, pr kind = "polymorph", phase = "transforming", form_entity = 0, - network_marked_entity = immediate_network_marked, - network_marked_target = immediate_network_marked ~= 0 and entity_path or nil, target = entity_path, requested_target = requested_target, compatibility_mode = tostring(options.compatibility_mode or "direct"), @@ -438,80 +323,49 @@ local function start_transform_creature(player, entity_path, frames, options, pr form_strategy = tostring(options.form_strategy or "native_polymorph"), profile = profile_api.get(options.profile_target or requested_target), original_backup = original_backup, + -- Death handoff is available only when a serialized human backup can be + -- restored synchronously before the dying form reaches the Game Over path. allow_death_handoff = original_backup ~= nil, effect_entity = effect_entity, started = GameGetFrameNum(), last_x = start_x, last_y = start_y, } - orphan_probe_complete = true protected_runtime_call("form_runtime.reset", form_runtime.reset) pending_return_frame = nil return true end -local function expire_current_polymorph(current) - local components = human_restore.polymorph_effect_components(current) - if #components == 0 then return false, "not_polymorphed" end - for _, component in ipairs(components) do - pcall(ComponentSetValue2, component, "frames", 1) - end - pending_return_frame = GameGetFrameNum() - return true, "expire" -end - -function form_manager.transform_creature(player, entity_path, frames, _legacy_force_unverified, options) - if player == nil or player == 0 or not EntityGetIsAlive(player) then return false, "player" end - - local effect_file, validation_reason = ensure_transform_effect(entity_path) - if effect_file == nil then return false, validation_reason end - - local is_form = session ~= nil or EntityHasTag(player, "polymorphed_player") - if is_form then - if session == nil then recover_or_adopt_form_session() end - transition_queue.schedule(entity_path, frames, options, GameGetFrameNum()) - local ok, reason = expire_current_polymorph(player) - if not ok then - transition_queue.clear() - return false, reason - end - diagnostic_event("FORM SWITCH QUEUED", string.format("from=%s to=%s frame=%s", - tostring(session ~= nil and (session.requested_target or session.target) or "external"), - tostring(entity_path), tostring(GameGetFrameNum()))) - return true, "queued_after_human" - end - - -- A direct selection while already human is newer intent than any stale queued - -- mob->human->mob request. Never let an older target survive behind this transform. - transition_queue.clear() - return start_transform_creature(player, entity_path, frames, options, effect_file) -end - function form_manager.return_to_human() - transition_queue.clear() local current = current_player() - if current == 0 then return false, "player" end + if current == 0 then + return false, "player" + end - local ok, reason = expire_current_polymorph(current) - if not ok then + local components = human_restore.polymorph_effect_components(current) + if #components == 0 then session = nil pending_return_frame = nil + return false, "not_polymorphed" + end + + for _, component in ipairs(components) do + pcall(ComponentSetValue2, component, "frames", 1) end - return ok, reason + pending_return_frame = GameGetFrameNum() + return true, "expire" end -function form_manager.handle_tab_return(input_blocked, binding_pressed) - -- Focus changes such as Alt+Tab must not leak stale input into form return. The GUI - -- input guard also quarantines stale clicks on focus restoration. +function form_manager.handle_tab_return(input_blocked) + -- Alt+Tab must never be interpreted as the form-return hotkey. The GUI input + -- guard also quarantines stale clicks on focus restoration. if input_blocked == true then return false end - if binding_pressed == nil then - -- Compatibility for older callers and isolated integrations. The lifecycle - -- root passes the assignable action explicitly. - local key = keycodes.resolve("Key_TAB", "KEY_TAB") - if key == nil then return false end - local ok, pressed = pcall(InputIsKeyJustDown, key) - if not ok or pressed ~= true then return false end - elseif binding_pressed ~= true then + local key = keycodes.resolve("Key_TAB", "KEY_TAB") + if key == nil then + return false + end + local ok, pressed = pcall(InputIsKeyJustDown, key) + if not ok or pressed ~= true then return false end @@ -523,40 +377,10 @@ function form_manager.handle_tab_return(input_blocked, binding_pressed) if #components == 0 then return false end - transition_queue.clear() for _, component in ipairs(components) do pcall(ComponentSetValue2, component, "frames", 1) end pending_return_frame = GameGetFrameNum() - if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_USER_ACTION) == "function" then pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_USER_ACTION, "form.return_binding", "result=true") end - return true -end - -local function advance_queued_transform(frame) - if not transition_queue.has_pending() or session ~= nil then return false end - local current = current_player() - if not human_entity_ready(current) then return false end - - local pending = transition_queue.peek() - if pending == nil then return false end - if pending.human_frame == nil then - transition_queue.mark_human(frame) - diagnostic_event("FORM SWITCH HUMAN FRAME", string.format("entity=%s target=%s frame=%s", - tostring(current), tostring(pending.entity_path), tostring(frame))) - return false - end - if not transition_queue.ready(frame) then return false end - - local request = transition_queue.take(frame) - if request == nil then return false end - local ok, reason = start_transform_creature(current, request.entity_path, request.frames, request.options) - if not ok then - diagnostic_event("FORM SWITCH FAILED", string.format("target=%s reason=%s frame=%s", - tostring(request.entity_path), tostring(reason), tostring(frame))) - return false - end - diagnostic_event("FORM SWITCH START", string.format("target=%s human_frame=%s frame=%s", - tostring(request.entity_path), tostring(request.human_frame), tostring(frame))) return true end @@ -564,26 +388,12 @@ function form_manager.update() local frame = GameGetFrameNum() restore_transform_flash_suppression(false) - if transition_queue.expire(frame) then - diagnostic_event("FORM SWITCH EXPIRED", string.format("frame=%s", tostring(frame))) - end corpse_service.update() - if advance_queued_transform(frame) then return true end - - if session == nil then - local probe_player = current_player() - -- EW can replace its initial player with the save entity a few frames after - -- startup. Re-probe on that authority change even if the initial human was - -- already classified as idle. - if probe_player ~= 0 and probe_player ~= orphan_probe_entity then orphan_probe_complete = false end - if not orphan_probe_complete then recover_or_adopt_form_session() end - end - -- Human idle is the overwhelmingly common state. There is no polymorph effect to -- inspect and no death hook is needed until an explicit transform starts; keeping -- this path O(1) avoids four GameGetGameEffect probes and player scans every frame. - if session == nil and pending_return_frame == nil and not transition_queue.has_pending() then return false end + if session == nil and pending_return_frame == nil then return false end form_manager.ensure_runtime_hooks() local current = current_player() @@ -619,43 +429,22 @@ function form_manager.update() -- state into the human player. Likewise, while the effect is still pending -- on the original player we must not pre-apply the future form profile. if is_actual_form then - -- Network source metadata is immutable for a given form entity. Rewriting the - -- same tag/VariableStorage every frame only adds engine calls to the hot path. - if session.network_marked_entity ~= current or session.network_marked_target ~= session.target then - if mark_network_form_source(current, session.target) then - session.network_marked_entity = current - session.network_marked_target = session.target - end - end + mark_network_form_source(current, session.target) session.form_entity = current if session.phase == "transforming" then session.phase = "active" end local form_x, form_y = EntityGetTransform(current) if form_x ~= nil then session.last_x, session.last_y = form_x, form_y end if session.phase == "active" then protected_runtime_call("form_runtime.update", form_runtime.update, current, session) end elseif #components == 0 then - local controls_restored = restore_human_controls(current) - diagnostic_event("FORM RETURN", string.format("entity=%s controls_restored=%s path=native", - tostring(current), tostring(controls_restored))) session = nil pending_return_frame = nil protected_runtime_call("form_runtime.reset", form_runtime.reset) - if transition_queue.has_pending() and human_entity_ready(current) then - transition_queue.mark_human(frame) - end return false end end if #components == 0 then - if pending_return_frame ~= nil then - local controls_restored = restore_human_controls(current) - diagnostic_event("FORM RETURN", string.format("entity=%s controls_restored=%s path=unowned", - tostring(current), tostring(controls_restored))) - end pending_return_frame = nil - if transition_queue.has_pending() and human_entity_ready(current) then - transition_queue.mark_human(frame) - end return false end @@ -688,20 +477,13 @@ function form_manager.is_human_ready(entity) end function form_manager.has_active_form() - if session ~= nil then return true end - local current = current_player() - if current == 0 or not EntityHasTag(current, "polymorphed_player") then return false end - return external_polymorph.is_active(current) + return session ~= nil end function form_manager.session_phase() return session ~= nil and tostring(session.phase or "") or "human" end -function form_manager.has_pending_transform() - return transition_queue.has_pending() -end - function form_manager.session_target() return session ~= nil and (session.requested_target or session.target) or nil end diff --git a/metamorph_creative_menu/files/features/forms/projectile_forms/boss_pit_wand.lua b/metamorph_creative_menu/files/features/forms/projectile_forms/boss_pit_wand.lua deleted file mode 100644 index f9dfb56..0000000 --- a/metamorph_creative_menu/files/features/forms/projectile_forms/boss_pit_wand.lua +++ /dev/null @@ -1,234 +0,0 @@ -local boss_pit_wand = {} - -local component_ops = dofile("mods/metamorph_creative_menu/files/features/forms/component_ops.lua") -local gameplay_input = dofile("mods/metamorph_creative_menu/files/platform/noita/gameplay_input.lua") - -local valid = component_ops.valid -local get_value = component_ops.get -local ensure_controls = component_ops.ensure_controls - -local WAND_LUA = "data/entities/animals/boss_pit/wand.lua" -local WAND_ROTATE_LUA = "data/entities/animals/boss_pit/wand_rotate.lua" -local ATTACKS = { - "data/entities/projectiles/deck/rocket.xml", - "data/entities/projectiles/deck/rocket_tier_2.xml", - "data/entities/projectiles/deck/rocket_tier_3.xml", - "data/entities/projectiles/deck/grenade.xml", - "data/entities/projectiles/deck/grenade_tier_2.xml", - "data/entities/projectiles/deck/grenade_tier_3.xml", - "data/entities/projectiles/deck/rubber_ball.xml", -} - -local active_entity = 0 -local visual_entity = 0 -local projectile_path = "" -local projectile_mult = 1.0 -local next_fire_frame = 0 -local last_attack_index = 0 -local last_visual_index = 0 - -local function random_int(low, high) - if low >= high then return low end - if type(Random) == "function" then - local ok, value = pcall(Random, low, high) - if ok and tonumber(value) ~= nil then - return math.max(low, math.min(high, math.floor(tonumber(value)))) - end - end - return low -end - -local function seed(entity) - if type(SetRandomSeed) ~= "function" then return end - local x, y = EntityGetTransform(entity) - local frame = type(GameGetFrameNum) == "function" and tonumber(GameGetFrameNum()) or 0 - pcall(SetRandomSeed, tonumber(x) or 0, (tonumber(y) or 0) * math.max(1, frame)) -end - -local function next_nonrepeating(low, high, previous) - local value = random_int(low, high) - if high > low and value == previous then - value = value + 1 - if value > high then value = low end - end - return value -end - -local function mouse_target(x, y) - if type(DEBUG_GetMouseWorld) == "function" then - local ok, mx, my = pcall(DEBUG_GetMouseWorld) - if ok and tonumber(mx) ~= nil and tonumber(my) ~= nil then return tonumber(mx), tonumber(my) end - end - return x + 100, y -end - -local function velocity_component(entity) - local velocity = EntityGetFirstComponentIncludingDisabled(entity, "VelocityComponent") - return valid(velocity) and velocity or nil -end - -local function set_velocity(entity, vx, vy) - local velocity = velocity_component(entity) - if not valid(velocity) then return false end - return pcall(ComponentSetValue2, velocity, "mVelocity", tonumber(vx) or 0, tonumber(vy) or 0) -end - -local function destroy_visual() - if visual_entity ~= 0 and EntityGetIsAlive(visual_entity) then pcall(EntityKill, visual_entity) end - visual_entity = 0 -end - -local function make_visual(entity, index) - destroy_visual() - local visual = EntityCreateNew("metamorph_creative_menu_boss_pit_wand_visual") or 0 - if visual == 0 then return 0 end - EntityAddTag(visual, "metamorph_creative_menu_runtime") - EntityAddTag(visual, "ew_no_enemy_sync") - local ok, sprite = pcall(EntityAddComponent2, visual, "SpriteComponent", { - image_file = string.format("data/entities/animals/boss_pit/wand_0%d.png", index), - offset_x = 16, - offset_y = 16, - alpha = 1, - z_index = 0, - }) - if not ok or not valid(sprite) then - EntityKill(visual) - return 0 - end - if type(EntityRefreshSprite) == "function" then pcall(EntityRefreshSprite, visual, sprite) end - local x, y, rotation, sx, sy = EntityGetTransform(entity) - if x ~= nil then EntitySetTransform(visual, x, y, rotation or 0, sx or 1, sy or 1) end - visual_entity = visual - return visual -end - -local function suppress_projectile_lifecycle(entity) - for _, projectile in ipairs(EntityGetComponentIncludingDisabled(entity, "ProjectileComponent") or {}) do - pcall(ComponentSetValue2, projectile, "on_death_explode", false) - pcall(ComponentSetValue2, projectile, "on_lifetime_out_explode", false) - pcall(EntitySetComponentIsEnabled, entity, projectile, false) - end - for _, lua in ipairs(EntityGetComponentIncludingDisabled(entity, "LuaComponent") or {}) do - local source = tostring(get_value(lua, "script_source_file", "") or "") - if source == WAND_LUA or source == WAND_ROTATE_LUA then - pcall(EntitySetComponentIsEnabled, entity, lua, false) - end - end -end - -local function configure_root_sprite(entity, index) - -- Keep a concrete authored sprite on the root for EW/vanilla serialization and peers - -- without MCM. Some local polymorph paths still suppress projectile rendering, so a - -- separate presentation entity is created as the local visual fallback below. - for _, sprite in ipairs(EntityGetComponentIncludingDisabled(entity, "SpriteComponent") or {}) do - pcall(ComponentSetValue2, sprite, "image_file", string.format("data/entities/animals/boss_pit/wand_0%d.png", index)) - pcall(ComponentSetValue2, sprite, "alpha", 1) - pcall(EntitySetComponentIsEnabled, entity, sprite, true) - if type(EntityRefreshSprite) == "function" then pcall(EntityRefreshSprite, entity, sprite) end - end -end - -local function write_memory(entity, value) - for _, storage in ipairs(EntityGetComponentIncludingDisabled(entity, "VariableStorageComponent") or {}) do - if tostring(get_value(storage, "name", "") or "") == "memory" then - pcall(ComponentSetValue2, storage, "value_string", value) - return true - end - end - return false -end - -local function fire(entity, x, y, tx, ty) - if projectile_path == "" then return false end - local dx, dy = tx - x, ty - y - local length = math.sqrt(dx * dx + dy * dy) - if length < 0.001 then dx, dy, length = 1, 0, 1 end - dx, dy = dx / length, dy / length - local speed = 500 - if valid(EntityGetFirstComponentIncludingDisabled(entity, "HomingComponent")) then speed = 300 end - - local projectile = EntityLoad(projectile_path, x, y) - if projectile == nil or projectile == 0 then return false end - local ok = pcall(GameShootProjectile, entity, x, y, tx, ty, projectile, true) - if not ok then - pcall(EntityKill, projectile) - return false - end - local velocity = EntityGetFirstComponentIncludingDisabled(projectile, "VelocityComponent") - if valid(velocity) then - pcall(ComponentSetValue2, velocity, "mVelocity", dx * speed * projectile_mult, dy * speed * projectile_mult) - end - local projectile_component = EntityGetFirstComponentIncludingDisabled(projectile, "ProjectileComponent") - if valid(projectile_component) then - local damage = tonumber(get_value(projectile_component, "damage", 0)) or 0 - pcall(ComponentSetValue2, projectile_component, "damage", damage + 0.2) - end - return true -end - -function boss_pit_wand.reset() - destroy_visual() - active_entity = 0 - projectile_path = "" - projectile_mult = 1.0 - next_fire_frame = 0 - -- last_attack_index/last_visual_index intentionally survive a form reset so repeated - -- transformations do not appear to be the same deterministic wand every time. -end - -function boss_pit_wand.configure(entity) - boss_pit_wand.reset() - if entity == nil or entity == 0 then return false end - active_entity = entity - local controls = ensure_controls(entity) - if valid(controls) then pcall(ComponentSetValue2, controls, "polymorph_hax", false) end - suppress_projectile_lifecycle(entity) - - seed(entity) - local attack_index = next_nonrepeating(1, #ATTACKS, last_attack_index) - last_attack_index = attack_index - projectile_path = ATTACKS[attack_index] - projectile_mult = string.find(projectile_path, "rocket", 1, true) ~= nil and 0.5 or 1.2 - write_memory(entity, projectile_path) - - local visual_index = next_nonrepeating(1, 9, last_visual_index) - last_visual_index = visual_index - configure_root_sprite(entity, visual_index) - make_visual(entity, visual_index) - gameplay_input.require_release("primary") - return true -end - -function boss_pit_wand.update(entity) - if entity == nil or entity == 0 or entity ~= active_entity or not EntityGetIsAlive(entity) then return false end - local controls = ensure_controls(entity) - if not valid(controls) then return false end - local x, y, _, sx, sy = EntityGetTransform(entity) - if x == nil then return false end - - local mx = (get_value(controls, "mButtonDownRight", false) == true and 1 or 0) - - (get_value(controls, "mButtonDownLeft", false) == true and 1 or 0) - local my = (get_value(controls, "mButtonDownDown", false) == true and 1 or 0) - - ((get_value(controls, "mButtonDownUp", false) == true or get_value(controls, "mButtonDownFly", false) == true) and 1 or 0) - local move_length = math.sqrt(mx * mx + my * my) - if move_length > 0 then mx, my = mx / move_length, my / move_length end - set_velocity(entity, mx * 85, my * 85) - - local tx, ty = mouse_target(x, y) - local angle = math.atan2 and math.atan2(ty - y, tx - x) or math.atan((ty - y) / math.max(0.001, tx - x)) - if visual_entity ~= 0 and EntityGetIsAlive(visual_entity) then - EntitySetTransform(visual_entity, x, y, angle, sx or 1, sy or 1) - end - - local frame = tonumber(GameGetFrameNum()) or 0 - if gameplay_input.down(controls, "primary") and frame >= next_fire_frame then - if fire(entity, x, y, tx, ty) then next_fire_frame = frame + 40 end - end - return true -end - -function boss_pit_wand.visual_entity() - return visual_entity -end - -return boss_pit_wand diff --git a/metamorph_creative_menu/files/features/forms/projectile_forms/controller.lua b/metamorph_creative_menu/files/features/forms/projectile_forms/controller.lua deleted file mode 100644 index bc5e27e..0000000 --- a/metamorph_creative_menu/files/features/forms/projectile_forms/controller.lua +++ /dev/null @@ -1,39 +0,0 @@ -local projectile_forms = {} - -local boss_pit_wand = dofile("mods/metamorph_creative_menu/files/features/forms/projectile_forms/boss_pit_wand.lua") - -local WAND_PATH = "data/entities/animals/boss_pit/wand.xml" -local active = nil -local active_entity = 0 - -function projectile_forms.reset() - if active ~= nil and type(active.reset) == "function" then active.reset() end - active = nil - active_entity = 0 -end - -function projectile_forms.configure(entity, path, profile) - projectile_forms.reset() - if tostring(path or "") == WAND_PATH then - active = boss_pit_wand - else - return false - end - if active.configure(entity, profile) ~= true then - active = nil - return false - end - active_entity = entity - return true -end - -function projectile_forms.update(entity) - if active == nil or entity ~= active_entity then return false end - return active.update(entity) == true -end - -function projectile_forms.active() - return active ~= nil -end - -return projectile_forms diff --git a/metamorph_creative_menu/files/features/forms/runtime.lua b/metamorph_creative_menu/files/features/forms/runtime.lua index 152eb2c..cc5c74c 100644 --- a/metamorph_creative_menu/files/features/forms/runtime.lua +++ b/metamorph_creative_menu/files/features/forms/runtime.lua @@ -13,24 +13,19 @@ local worm_adapter = dofile("mods/metamorph_creative_menu/files/features/forms/a local physics_adapter = dofile("mods/metamorph_creative_menu/files/features/forms/adapters/physics.lua") local form_combat = dofile("mods/metamorph_creative_menu/files/features/forms/combat.lua") local boss_dragon_adapter = dofile("mods/metamorph_creative_menu/files/features/forms/adapters/boss_dragon.lua") -local scripted_attacks = dofile("mods/metamorph_creative_menu/files/features/forms/scripted_attacks.lua") -local manual_actions = dofile("mods/metamorph_creative_menu/files/features/forms/adapters/manual_actions.lua") -local projectile_forms = dofile("mods/metamorph_creative_menu/files/features/forms/projectile_forms/controller.lua") -local static_weapon_presentation = dofile("mods/metamorph_creative_menu/files/features/forms/adapters/static_weapon_presentation.lua") -local held_wand_forms = dofile("mods/metamorph_creative_menu/files/features/forms/held_wand_forms/controller.lua") local applied_entity = 0 local applied_started = -1 local active_family = "" local root_lifecycle_is_temporary = false +local native_tank_mode = false local ensure_controls = component_ops.ensure_controls local add_death_guard = component_ops.add_death_guard local setup_manual_barrels = form_combat.setup_manual_barrels local configure_non_ai_player = form_combat.configure_non_ai_player -local configure_ranged_player = form_combat.configure_ranged_player -local update_ranged_attacks = form_combat.update_ranged_attacks +local update_secondary_attacks = form_combat.update_secondary_attacks local update_manual_aim = form_combat.update_manual_aim local update_manual_lasers = form_combat.update_manual_lasers @@ -45,6 +40,7 @@ local claim_root_lifecycle = form_family.claim_root_lifecycle local runtime_family = form_family.detect +local is_tank_path = form_family.is_native_tank_path local stabilize_special_ghost_lifecycle = ghost_adapter.stabilize_lifecycle local configure_ghost_player = ghost_adapter.configure @@ -52,13 +48,13 @@ local update_ghost_player = ghost_adapter.update local function configure_character_player(entity) configure_non_ai_player(entity) - setup_manual_barrels(entity) + if not native_tank_mode then setup_manual_barrels(entity) end end -local function configure_fish_player(entity, profile) +local function configure_fish_player(entity) configure_non_ai_player(entity) setup_manual_barrels(entity) - fish_adapter.configure(entity, profile) + fish_adapter.configure(entity) end local update_fish_player = fish_adapter.update @@ -89,13 +85,9 @@ function form_runtime.reset() worm_adapter.reset() physics_adapter.reset() form_combat.reset() - scripted_attacks.reset() - manual_actions.reset() - projectile_forms.reset() - static_weapon_presentation.reset() - held_wand_forms.reset() boss_dragon_adapter.reset() root_lifecycle_is_temporary = false + native_tank_mode = false end function form_runtime.apply(entity, session) @@ -109,7 +101,26 @@ function form_runtime.apply(entity, session) local profile_path = session.requested_target or session.target local profile = session.profile or profile_api.get(profile_path) + native_tank_mode = is_tank_path(profile_path) root_lifecycle_is_temporary = claim_root_lifecycle(entity) + + -- Tanks were the most over-adapted forms in earlier builds: custom barrel pivots, + -- manual aiming, manual secondary fire and movement/profile rewrites all competed + -- with Noita's own polymorph controller. For tanks, deliberately do *less*: keep + -- the native polymorph body and controls intact, adding only lifecycle/vision/UI + -- support needed by every playable form. This is a passthrough, not a tank emulator. + if native_tank_mode then + ensure_controls(entity) + ensure_form_vision(entity) + sync_damage_ui(entity) + prime_transform_damage_ui(entity) + if session.allow_death_handoff == true then add_death_guard(entity) end + active_family = "tank_native" + applied_entity = entity + applied_started = tonumber(session.started) or GameGetFrameNum() + return true + end + if session.compatibility_mode == "canonical_damage_overlay" or session.compatibility_mode == "canonical_crash_fallback" then apply_damage_overlay(entity, profile) end @@ -123,22 +134,9 @@ function form_runtime.apply(entity, session) add_death_guard(entity) end - -- Projectile-shaped forms (currently Boss Pit wands) are not creatures with an AI - -- body at all. Give them a dedicated controller instead of forcing projectile - -- lifecycle/rendering through character/physics adapters. - local projectile_owned = projectile_forms.configure(entity, profile_path, profile) - if not projectile_owned then - -- Snapshot manual melee/physics-projectile capabilities before family - -- adapters disable native AnimalAI. Normal ranged attacks stay in form_combat. - manual_actions.configure(entity, profile_path, profile) - end - - active_family = projectile_owned and "projectile_form" or runtime_family(entity) + active_family = runtime_family(entity) if active_family == "ghost_native" then configure_ghost_player(entity) - if not held_wand_forms.configure(entity, profile_path) then - configure_ranged_player(entity) - end elseif active_family == "boss_dragon" then configure_boss_dragon(entity) elseif active_family == "ik_physics" then @@ -146,22 +144,13 @@ function form_runtime.apply(entity, session) elseif active_family == "physics" then configure_physics_player(entity, profile, false) elseif active_family == "worm_native" then - configure_ranged_player(entity) configure_worm_player(entity) elseif active_family == "fish" then - configure_fish_player(entity, profile) + configure_fish_player(entity) elseif active_family == "character" then configure_character_player(entity) end - if not projectile_owned then static_weapon_presentation.configure(entity) end - - -- Some vanilla creatures implement part or all of their firing in Lua rather than - -- AnimalAI/AIAttack. Snapshot/disable only those known attack scripts and replay - -- their authored projectile patterns under player fire input. Component-authored - -- attacks remain active through form_combat, so mixed creatures keep both layers. - if not projectile_owned then scripted_attacks.configure(entity, profile_path) end - applied_entity = entity applied_started = tonumber(session.started) or GameGetFrameNum() return true @@ -196,46 +185,37 @@ function form_runtime.update(entity, session) if applied_started >= 0 and now - applied_started <= 8 then prime_transform_damage_ui(entity) end + if active_family == "tank_native" then + update_secondary_attacks(entity) + return + end -- apply() performs one defensive lifecycle scan for every form. Repeating that -- whole-tree Ghost/Lua scan every frame is only necessary for native ghost forms. if active_family == "ghost_native" then stabilize_special_ghost_lifecycle(entity) update_ghost_player(entity) - held_wand_forms.update(entity) elseif active_family == "boss_dragon" then update_boss_dragon(entity) elseif active_family == "ik_physics" then update_physics_player(entity, profile, true) + update_manual_aim(entity) elseif active_family == "physics" then update_physics_player(entity, profile, false) + update_manual_aim(entity) elseif active_family == "worm_native" then update_worm_player(entity) elseif active_family == "fish" then update_fish_player(entity) - elseif active_family == "projectile_form" then - projectile_forms.update(entity) - return + update_manual_aim(entity) + elseif active_family == "character" then + if not native_tank_mode then update_manual_aim(entity) end end - - static_weapon_presentation.update(entity) - - -- Player-only actions (melee/physics-projectile control) are evaluated after the - -- family movement adapter. An owned input surface is then withheld from both ranged replay - -- layers, preventing one RMB/LMB press from triggering two attacks. - if not held_wand_forms.active() then manual_actions.update(entity) end - local allow_primary = not manual_actions.owns_primary() and not held_wand_forms.owns_primary() - local allow_secondary = not manual_actions.owns_secondary() - - -- BossDragonComponent has its own authored projectile interface and dedicated - -- adapter. Wand ghosts are another explicit ownership boundary: their real carried - -- procedural wand is fired by a short native AnimalAI attack state, so the generic - -- descriptor replay must not reacquire the fallback orb_pink attack underneath it. - if active_family ~= "boss_dragon" and not held_wand_forms.active() then - if form_combat.manual_ranged_owned() then update_manual_aim(entity) end - if not scripted_attacks.manages_lasers() then update_manual_lasers(entity, allow_secondary, allow_primary) end - update_ranged_attacks(entity, allow_secondary, allow_primary) + if active_family ~= "worm_native" and active_family ~= "boss_dragon" then + if not native_tank_mode then update_manual_lasers(entity) end + if not native_tank_mode and not form_combat.tree_has_laser(entity) then + update_secondary_attacks(entity) + end end - scripted_attacks.update(entity, allow_secondary, allow_primary) end function form_runtime.family() diff --git a/metamorph_creative_menu/files/features/forms/scripted_attack_math.lua b/metamorph_creative_menu/files/features/forms/scripted_attack_math.lua deleted file mode 100644 index 020c103..0000000 --- a/metamorph_creative_menu/files/features/forms/scripted_attack_math.lua +++ /dev/null @@ -1,72 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_SCRIPTED_ATTACK_MATH) == "table" then - return METAMORPH_CREATIVE_MENU_SCRIPTED_ATTACK_MATH -end - -local mathx = {} - -local function finite(v, fallback) - v = tonumber(v) - if v == nil or v ~= v or v == math.huge or v == -math.huge then return fallback end - return v -end - -function mathx.radial(count, speed, start_angle, invert_y) - count = math.max(1, math.floor(finite(count, 1) or 1)) - speed = finite(speed, 0) or 0 - start_angle = finite(start_angle, 0) or 0 - local out = {} - local step = (math.pi * 2) / count - for i = 0, count - 1 do - local a = start_angle + step * i - out[#out + 1] = { - x = math.cos(a) * speed, - y = math.sin(a) * speed * (invert_y == true and -1 or 1), - } - end - return out -end - -function mathx.radial_degrees(count, speed, start_degrees) - count = math.max(1, math.floor(finite(count, 1) or 1)) - speed = finite(speed, 0) or 0 - local angle = finite(start_degrees, 0) or 0 - local step = math.floor(360 / count) - local out = {} - for _ = 1, count do - local rad = math.rad(angle) - out[#out + 1] = {x=math.cos(rad) * speed, y=math.sin(rad) * speed} - angle = angle + step - end - return out -end - -function mathx.firepillar(amount) - amount = math.max(1, math.floor(finite(amount, 1) or 1)) - local space = math.floor(180 / amount) - local angle = space * 0.5 - local out = {} - for _ = 1, amount do - local rad = math.rad(angle) - out[#out + 1] = {x=math.cos(rad) * 150, y=math.sin(rad) * 150 - 200} - angle = angle + space - end - return out -end - -function mathx.aimed(sx, sy, tx, ty, speed) - sx, sy = finite(sx, 0) or 0, finite(sy, 0) or 0 - tx, ty = finite(tx, sx) or sx, finite(ty, sy - 1) or (sy - 1) - speed = finite(speed, 0) or 0 - local dx, dy = tx - sx, ty - sy - local len = math.sqrt(dx * dx + dy * dy) - if len <= 0.000001 then return 0, -speed end - return dx / len * speed, dy / len * speed -end - -function mathx.rotate(x, y, angle) - local c, s = math.cos(angle), math.sin(angle) - return x * c - y * s, x * s + y * c -end - -METAMORPH_CREATIVE_MENU_SCRIPTED_ATTACK_MATH = mathx -return mathx diff --git a/metamorph_creative_menu/files/features/forms/scripted_attacks.lua b/metamorph_creative_menu/files/features/forms/scripted_attacks.lua deleted file mode 100644 index eba1867..0000000 --- a/metamorph_creative_menu/files/features/forms/scripted_attacks.lua +++ /dev/null @@ -1,1293 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_FORM_SCRIPTED_ATTACKS) == "table" then - return METAMORPH_CREATIVE_MENU_FORM_SCRIPTED_ATTACKS -end - -local scripted = {} -local entity_tree = dofile("mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua") -local component_ops = dofile("mods/metamorph_creative_menu/files/features/forms/component_ops.lua") -local mathx = dofile("mods/metamorph_creative_menu/files/features/forms/scripted_attack_math.lua") - -local valid = component_ops.valid -local get_value = component_ops.get -local ensure_controls = component_ops.ensure_controls -local walk_entity_tree = entity_tree.walk - -local SCRIPT = { - MONK = "data/scripts/animals/monk_hand_shoot.lua", - FUNGUS_GIGA = "data/scripts/animals/fungus_giga_pollen.lua", - BOSS_DRAGON = "data/scripts/projectiles/orb_green_dragon.lua", - BOSS_LIMBS = "data/entities/animals/boss_limbs/boss_limbs_update.lua", - BOSS_MEAT_SHOT = "data/entities/animals/boss_meat/shot.lua", - BOSS_MEAT_EYE = "data/entities/animals/boss_meat/eye.lua", - FISH_GIGA = "data/entities/animals/boss_fish/eye.lua", - BOSS_ROBOT = "data/entities/animals/boss_robot/state.lua", - BOSS_PIT = "data/entities/animals/boss_pit/boss_pit_logic.lua", - MAGGOT_TINY = "data/entities/animals/maggot_tiny/shot.lua", - BOSS_WIZARD = "data/entities/animals/boss_wizard/bloodtentacle.lua", - BOSS_CENTIPEDE = "data/entities/animals/boss_centipede/boss_centipede_update.lua", - BOSS_CENTIPEDE_BEFORE_FIGHT = "data/entities/animals/boss_centipede/boss_centipede_before_fight.lua", - BOSS_GHOST_LASERS = "data/entities/animals/boss_ghost/lasers.lua", -} - -local MANAGED = {} -for key, path in pairs(SCRIPT) do - if key ~= "BOSS_GHOST_LASERS" and key ~= "BOSS_CENTIPEDE_BEFORE_FIGHT" then MANAGED[path] = true end -end - -local SUPPRESS_ONLY = { - [SCRIPT.BOSS_CENTIPEDE_BEFORE_FIGHT] = true, -} - --- Arena-gated coroutine controllers are authored disabled until vanilla encounter --- setup starts them. A transformed form has no arena trigger, so these controllers --- are the only scripts we intentionally make available from an initially-disabled --- component. Ordinary disabled attack scripts (for example boss_wizard's tagged --- blood-tentacle phase) stay unavailable until their owning vanilla state enables them. -local REPLAY_WHEN_INITIALLY_DISABLED = { - [SCRIPT.BOSS_CENTIPEDE] = true, -} - -local active_entity = 0 -local active_path = "" -local records = {} -local by_script = {} -local state = {} -local target_entity = 0 -local manages_lasers = false - -local function frame_num() - return tonumber(GameGetFrameNum()) or 0 -end - -local function mouse_target(x, y) - local ok, mx, my = pcall(DEBUG_GetMouseWorld) - if ok and tonumber(mx) ~= nil and tonumber(my) ~= nil then return tonumber(mx), tonumber(my) end - return x, y - 100 -end - -local function component_enabled(_, comp) - if type(ComponentGetIsEnabled) ~= "function" then return false end - local ok, enabled = pcall(ComponentGetIsEnabled, comp) - return ok and enabled == true -end - -local function find_variable(entity, name) - local result = nil - walk_entity_tree(entity, function(current) - for _, comp in ipairs(EntityGetComponentIncludingDisabled(current, "VariableStorageComponent") or {}) do - if tostring(get_value(comp, "name", "") or "") == name then - result = comp - return false - end - end - end) - return result -end - -local function get_herd(entity) - local comp = EntityGetFirstComponentIncludingDisabled(entity, "GenomeDataComponent") - return valid(comp) and (tonumber(get_value(comp, "herd_id", -1)) or -1) or -1 -end - -local function set_velocity(entity, vx, vy) - for _, comp in ipairs(EntityGetComponentIncludingDisabled(entity, "VelocityComponent") or {}) do - pcall(ComponentSetValue2, comp, "mVelocity", vx, vy) - end -end - --- Deliberately mirrors data/scripts/lib/utilities.lua::shoot_projectile. Scripted --- vanilla attacks author explicit velocities, so GameShootProjectile alone is not --- sufficient: utilities.lua also stamps shooter metadata and the VelocityComponent. -local function shoot_velocity(who, path, x, y, vx, vy, send_message) - path = tostring(path or "") - if path == "" then return 0 end - local projectile = EntityLoad(path, x, y) - if projectile == nil or projectile == 0 then return 0 end - local send = send_message ~= false - local ok = pcall(GameShootProjectile, who, x, y, x + vx, y + vy, projectile, send) - if not ok then - pcall(EntityKill, projectile) - return 0 - end - local herd = get_herd(who) - for _, comp in ipairs(EntityGetComponentIncludingDisabled(projectile, "ProjectileComponent") or {}) do - pcall(ComponentSetValue2, comp, "mWhoShot", who) - pcall(ComponentSetValue2, comp, "mShooterHerdId", herd) - end - set_velocity(projectile, vx, vy) - return projectile -end - -local function ensure_target() - if target_entity ~= 0 and EntityGetIsAlive(target_entity) then return target_entity end - target_entity = EntityCreateNew("metamorph_creative_menu_scripted_attack_target") or 0 - if target_entity ~= 0 then - EntityAddTag(target_entity, "metamorph_creative_menu_runtime") - EntityAddTag(target_entity, "metamorph_creative_menu_attack_target") - end - return target_entity -end - -local function update_target(entity) - local x, y = EntityGetTransform(entity) - if x == nil then return 0 end - local tx, ty = mouse_target(x, y) - local target = ensure_target() - if target ~= 0 then pcall(EntitySetTransform, target, tx, ty) end - return target -end - -local function due(key, interval, frame) - local next_frame = tonumber(state[key]) or 0 - if frame < next_frame then return false end - state[key] = frame + math.max(1, tonumber(interval) or 1) - return true -end - -local function owners(script_path) - return by_script[script_path] or {} -end - -local function first_owner(script_path) - local list = owners(script_path) - return type(list[1]) == "table" and list[1].owner or nil -end - -local function authored_next_frame(record, fallback_interval, now) - if type(record) ~= "table" or not valid(record.component) then return now end - local interval = tonumber(get_value(record.component, "execute_every_n_frame", fallback_interval)) or fallback_interval - if interval == nil or interval < 1 then return now end - local last = tonumber(get_value(record.component, "mLastExecutionFrame", -1)) or -1 - if last >= 0 then return math.max(now, last + interval) end - return now + interval -end - -local function root_position(entity) - local x, y = EntityGetTransform(entity) - return x, y -end - -local function random_float(low, high) - if type(Random) == "function" then - local ok, r = pcall(Random) - if ok and tonumber(r) ~= nil then return low + tonumber(r) * (high - low) end - end - return low -end - -local function fire_monk(entity, frame, held) - for index, record in ipairs(owners(SCRIPT.MONK)) do - local key = "monk_" .. tostring(index) - local next_frame = tonumber(state[key]) or 0 - if frame >= next_frame then - local owner = record.owner - local sprite = EntityGetFirstComponentIncludingDisabled(owner, "SpriteComponent") - local x, y = EntityGetTransform(owner) - if type(EntityGetFirstHitboxCenter) == "function" then - local ok, hx, hy = pcall(EntityGetFirstHitboxCenter, owner) - if ok and hx ~= nil then x, y = hx, hy end - end - local can_shoot = held == true and x ~= nil - local tx, ty = x, y - if can_shoot then - tx, ty = mouse_target(x, y) - -- Vanilla target search caps this attack at 200px. Under player - -- authority the cursor is explicit intent rather than an AI-acquired - -- enemy, so range must not reject the shot. Preserve the authored LOS - -- requirement because it is part of the attack itself, not acquisition. - if type(RaytraceSurfacesAndLiquiform) == "function" then - local ok, blocked = pcall(RaytraceSurfacesAndLiquiform, x, y, tx, ty) - if ok and blocked == true then can_shoot = false end - end - end - if not can_shoot then - if valid(sprite) and tostring(get_value(sprite, "rect_animation", "") or "") ~= "open" then - pcall(ComponentSetValue2, sprite, "rect_animation", "open") - end - state[key] = frame + 60 - else - local hand_open = valid(sprite) and tostring(get_value(sprite, "rect_animation", "") or "") == "open" - if hand_open then - pcall(ComponentSetValue2, sprite, "rect_animation", "close") - state[key] = frame + 60 - else - local vx, vy = mathx.aimed(x, y, tx, ty, 1) - x, y = x + vx * 8, y + vy * 8 - if type(SetRandomSeed) == "function" then pcall(SetRandomSeed, x + frame, y) end - local scatter = random_float(-0.2, 0.2) - vx, vy = mathx.rotate(vx, vy, scatter) - shoot_velocity(entity, "data/entities/projectiles/orb_green_accelerating.xml", x, y, vx * 20, vy * 20) - local delay = 55 - if type(Random) == "function" then - local ok, value = pcall(Random, 10) - if ok and tonumber(value) ~= nil then delay = 55 + tonumber(value) end - end - state[key] = frame + math.max(55, math.floor(delay)) - end - end - end - end -end - -local function fire_fungus_giga(entity, frame, held) - if not held or not due("fungus_giga", 10, frame) then return end - local x, y = root_position(entity) - if x == nil then return end - -- fungus_giga_pollen.lua normally requires an AI target inside 64px. Player-held - -- fire is already the target-acquisition decision, so keep the authored cadence and - -- projectile spread without applying that AI-only radius to the cursor. - if type(SetRandomSeed) == "function" then pcall(SetRandomSeed, x + entity, y + frame) end - local vx, vy = -300, -300 - if type(Random) == "function" then - local okx, rx = pcall(Random, -300, 300) - local oky, ry = pcall(Random, -300, 10) - if okx and tonumber(rx) ~= nil then vx = tonumber(rx) end - if oky and tonumber(ry) ~= nil then vy = tonumber(ry) end - end - shoot_velocity(entity, "data/entities/projectiles/pollen.xml", x, y, vx, vy) -end - -local function fire_boss_dragon(entity, frame, held) - if not held or not due("boss_dragon", 80, frame) then return end - local owner = first_owner(SCRIPT.BOSS_DRAGON) or entity - local x, y = EntityGetTransform(owner) - if x == nil then return end - for _, v in ipairs(mathx.radial(10, 100, 0, false)) do - local px, py = x + v.x * 0.1, y + v.y * 0.1 - if type(GameEntityPlaySound) == "function" then pcall(GameEntityPlaySound, owner, "duplicate") end - shoot_velocity(owner, "data/entities/projectiles/orb_green_boss_dragon.xml", px, py, v.x, v.y) - end - if type(GamePlaySound) == "function" then - pcall(GamePlaySound, "data/audio/Desktop/projectiles.bank", "projectiles/orb_dragon/create", x, y) - end -end - -local function boss_meat_eye_step(entity) - local status_comp = find_variable(entity, "status") - if not valid(status_comp) then return end - local status = tonumber(get_value(status_comp, "value_int", 0)) or 0 - local x, y = root_position(entity) - if status == 1 then - if type(GamePlayAnimation) == "function" then pcall(GamePlayAnimation, entity, "open", 0, "opened", 0) end - -- Vanilla eye.lua disables the `vacuum_NOT` group here, which is also the - -- group containing shot.lua. Preserve that firing window even though the Lua - -- component itself is held disabled by this replay layer. - state.boss_meat_acid_enabled = false - if type(EntitySetComponentsWithTagEnabled) == "function" then - pcall(EntitySetComponentsWithTagEnabled, entity, "vacuum", true) - pcall(EntitySetComponentsWithTagEnabled, entity, "vacuum_NOT", false) - end - local hit = EntityGetFirstComponentIncludingDisabled(entity, "HitboxComponent") - if valid(hit) then pcall(ComponentSetValue2, hit, "damage_multiplier", 1.0) end - elseif status == 3 then - if type(GamePlayAnimation) == "function" then pcall(GamePlayAnimation, entity, "close", 0, "stand", 0) end - state.boss_meat_acid_enabled = true - if type(EntitySetComponentsWithTagEnabled) == "function" then - pcall(EntitySetComponentsWithTagEnabled, entity, "vacuum", false) - -- In vanilla boss_meat.xml the only `vacuum_NOT` component is shot.lua. - -- That script is owned and replayed manually by this module, so enabling - -- the tag here would run the original acid attack in parallel. Keep the - -- native script disabled and let boss_meat_acid_enabled represent the - -- same eye-closed firing window for the manual replay. - end - local hit = EntityGetFirstComponentIncludingDisabled(entity, "HitboxComponent") - if valid(hit) then pcall(ComponentSetValue2, hit, "damage_multiplier", 0) end - if x ~= nil then shoot_velocity(entity, "data/entities/animals/boss_meat/orb_big.xml", x, y, 0, 0) end - end - pcall(ComponentSetValue2, status_comp, "value_int", (status + 1) % 4) -end - -local function fire_boss_meat(entity, frame, held) - if not held then return end - local x, y = root_position(entity) - if x == nil then return end - if state.boss_meat_acid_enabled ~= false and due("boss_meat_acid", 20, frame) then - if type(SetRandomSeed) == "function" then pcall(SetRandomSeed, x + frame, y) end - local rnd = 0 - if type(Random) == "function" then - local ok, value = pcall(Random, 0, 99) - if ok and tonumber(value) ~= nil then rnd = tonumber(value) * 0.01 end - end - local angle = math.pi * 2 * rnd - shoot_velocity(entity, "data/entities/animals/boss_meat/acidshot_slow.xml", x, y, - math.cos(angle) * 90, -math.sin(angle) * 90) - end - if due("boss_meat_eye", 80, frame) then boss_meat_eye_step(entity) end -end - -local function fire_fish_giga(entity, frame, held) - local owner = first_owner(SCRIPT.FISH_GIGA) - if owner == nil then return end - local key = "fish_timer" - local timer = tonumber(state[key]) or 0 - local sprite = EntityGetFirstComponentIncludingDisabled(owner, "SpriteComponent") - local root = EntityGetRootEntity(owner) - if root == nil or root == 0 then root = entity end - local hitbox = EntityGetFirstComponentIncludingDisabled(root, "HitboxComponent") - local function set_eye_hitbox(value) - if valid(hitbox) then pcall(EntitySetComponentIsEnabled, owner, hitbox, value == true) end - end - local current = valid(sprite) and tostring(get_value(sprite, "rect_animation", "") or "") or "" - if current == "" then - current = "closed" - if valid(sprite) then pcall(ComponentSetValue2, sprite, "rect_animation", current) end - set_eye_hitbox(false) - end - timer = timer + 1 - local player_driving_attack = held == true - if player_driving_attack then - local x = EntityGetTransform(owner) - if x == nil then player_driving_attack = false end - end - -- Vanilla opens this eye only while a player is inside a 160px acquisition radius. - -- In a controlled form the local fire button replaces that acquisition condition. - if not player_driving_attack then - if current == "opened" and valid(sprite) then - pcall(ComponentSetValue2, sprite, "rect_animation", "close") - pcall(ComponentSetValue2, sprite, "next_rect_animation", "closed") - timer = 0 - set_eye_hitbox(false) - elseif current == "close" and timer > 36 and valid(sprite) then - pcall(ComponentSetValue2, sprite, "rect_animation", "closed") - pcall(ComponentSetValue2, sprite, "next_rect_animation", "closed") - timer = 0 - end - state[key] = timer - return - end - if current == "closed" and valid(sprite) then - pcall(ComponentSetValue2, sprite, "rect_animation", "open") - pcall(ComponentSetValue2, sprite, "next_rect_animation", "opened") - timer = 0 - elseif current == "open" and timer > 36 and valid(sprite) then - pcall(ComponentSetValue2, sprite, "rect_animation", "opened") - pcall(ComponentSetValue2, sprite, "next_rect_animation", "opened") - timer = 0 - set_eye_hitbox(true) - elseif current == "opened" and timer > 360 then - timer = 0 - local x, y = EntityGetTransform(owner) - if x ~= nil then - if type(SetRandomSeed) == "function" then pcall(SetRandomSeed, x + y, frame) end - local offset = 0.01 * math.pi - if type(Random) == "function" then - local ok, r = pcall(Random, 1, 100) - if ok and tonumber(r) ~= nil then offset = tonumber(r) * 0.01 * math.pi end - end - for _, v in ipairs(mathx.radial(8, 80, offset, true)) do - shoot_velocity(entity, "data/entities/animals/boss_fish/orb_big.xml", x, y, v.x, v.y) - end - end - end - state[key] = timer -end - -local function fire_maggot(entity, frame, held) - if not held or not due("maggot", 20, frame) then return end - local var = find_variable(entity, "shooter_part") - if not valid(var) then return end - local current = tonumber(get_value(var, "value_int", 1)) or 1 - if current > 0 and current <= 11 then - local children = EntityGetAllChildren(entity) or {} - local n = 0 - for _, child in ipairs(children) do - if valid(EntityGetFirstComponentIncludingDisabled(child, "GenomeDataComponent")) then - n = n + 1 - if n == current then - local x, y = EntityGetTransform(child) - if x ~= nil then shoot_velocity(entity, "data/entities/animals/maggot_tiny/orb.xml", x, y, 0, 0) end - break - end - end - end - end - current = current + 1 - if current > 33 then current = 1 end - pcall(ComponentSetValue2, var, "value_int", current) -end - -local function fire_boss_wizard(entity, frame, held) - if not held or not due("boss_wizard", 16, frame) then return end - local x, y = root_position(entity) - if x == nil then return end - for i = 1, 2 do - if type(SetRandomSeed) == "function" then pcall(SetRandomSeed, x * frame, y + i) end - local r = 0 - if type(Random) == "function" then - local ok, value = pcall(Random, 0, 100) - if ok and tonumber(value) ~= nil then r = tonumber(value) end - end - local arc = r * 0.01 * math.pi * 2 - shoot_velocity(entity, "data/entities/animals/boss_wizard/bloodtentacle.xml", x, y - 32, - math.cos(arc) * 150, -math.sin(arc) * 150) - end -end - -local function set_laser_object(comp, key, value) - if type(ComponentObjectSetValue2) == "function" then pcall(ComponentObjectSetValue2, comp, "laser", key, value) end -end - -local function boss_robot_step(entity, frame) - local state_comp = find_variable(entity, "state") - if not valid(state_comp) then return end - local value = (tonumber(get_value(state_comp, "value_int", 0)) or 0) + 1 - local x, y = root_position(entity) - if x == nil then return end - if type(SetRandomSeed) == "function" then pcall(SetRandomSeed, x + frame, y + entity) end - local lasers = EntityGetComponentIncludingDisabled(entity, "LaserEmitterComponent") or {} - local eatercomp = find_variable(entity, "spell_eater") - if value == 2 then - if valid(eatercomp) then pcall(ComponentSetValue2, eatercomp, "value_int", 0) end - -- The vanilla state script gates this rocket phase on a nearby target. The - -- controlled form already has explicit player fire intent, so do not suppress - -- the phase merely because the cursor is beyond the AI's 300px search radius. - for _ = 1, 10 do - local angle_roll, length = 0, 100 - if type(Random) == "function" then - local oka, a = pcall(Random, 0, 100); if oka and tonumber(a) ~= nil then angle_roll = tonumber(a) end - local okl, l = pcall(Random, 100, 250); if okl and tonumber(l) ~= nil then length = tonumber(l) end - end - local a = math.pi * (angle_roll * 0.01) - shoot_velocity(entity, "data/entities/animals/boss_robot/rocket_roll.xml", x, y, - math.cos(a) * length, -math.sin(a) * length) - end - elseif value == 4 then - if valid(eatercomp) then pcall(ComponentSetValue2, eatercomp, "value_int", 1) end - elseif value == 6 then - if valid(eatercomp) then pcall(ComponentSetValue2, eatercomp, "value_int", 0) end - local tx, ty = mouse_target(x, y) - local a = math.atan2(ty - y, tx - x) - for _, laser in ipairs(lasers) do - pcall(ComponentSetValue2, laser, "laser_angle_add_rad", a) - set_laser_object(laser, "beam_radius", 1.5) - set_laser_object(laser, "damage_to_entities", 0) - set_laser_object(laser, "damage_to_cells", 10) - set_laser_object(laser, "max_cell_durability_to_destroy", 2) - set_laser_object(laser, "audio_enabled", false) - pcall(ComponentSetValue2, laser, "is_emitting", true) - end - elseif value == 8 then - for index, laser in ipairs(lasers) do - set_laser_object(laser, "beam_radius", 10.5) - set_laser_object(laser, "damage_to_entities", 0.6) - set_laser_object(laser, "damage_to_cells", 700000) - set_laser_object(laser, "max_cell_durability_to_destroy", 14) - if index == 1 then set_laser_object(laser, "audio_enabled", true) end - end - elseif value == 10 then - for _, laser in ipairs(lasers) do - pcall(ComponentSetValue2, laser, "is_emitting", false) - set_laser_object(laser, "beam_radius", 1.5) - set_laser_object(laser, "damage_to_entities", 0) - set_laser_object(laser, "damage_to_cells", 10) - set_laser_object(laser, "max_cell_durability_to_destroy", 2) - set_laser_object(laser, "audio_enabled", false) - end - if valid(eatercomp) then pcall(ComponentSetValue2, eatercomp, "value_int", 1) end - elseif value >= 13 then - if type(EntityGetWithTag) == "function" then - local ok, healers = pcall(EntityGetWithTag, "healer") - if ok and type(healers) == "table" and #healers < 3 then - pcall(EntityLoad, "data/entities/animals/robobase/healerdrone_physics.xml", x, y) - end - end - value = 0 - end - pcall(ComponentSetValue2, state_comp, "value_int", value) -end - -local function boss_robot_laser_cycle_active(entity) - local state_comp = find_variable(entity, "state") - if not valid(state_comp) then return false end - local value = tonumber(get_value(state_comp, "value_int", 0)) or 0 - return value >= 6 and value < 10 -end - -local function boss_robot_stop_lasers(entity) - for _, laser in ipairs(EntityGetComponentIncludingDisabled(entity, "LaserEmitterComponent") or {}) do - pcall(ComponentSetValue2, laser, "is_emitting", false) - set_laser_object(laser, "beam_radius", 1.5) - set_laser_object(laser, "damage_to_entities", 0) - set_laser_object(laser, "damage_to_cells", 10) - set_laser_object(laser, "max_cell_durability_to_destroy", 2) - set_laser_object(laser, "audio_enabled", false) - end -end - -local function fire_boss_robot(entity, frame, held) - -- Player input starts/continues the authored cycle, but once state 6 has enabled - -- the laser we must advance through state 10 even after fire is released. Otherwise - -- the manually-owned LaserEmitterComponent can remain damaging forever. - if (held or boss_robot_laser_cycle_active(entity)) and due("boss_robot", 40, frame) then - boss_robot_step(entity, frame) - end -end - -local function add_homing_to_target(projectile) - if projectile == nil or projectile == 0 then return end - update_target(active_entity) - pcall(EntityAddComponent2, projectile, "HomingComponent", { - homing_targeting_coeff = 30.0, - homing_velocity_multiplier = 0.16, - target_tag = "metamorph_creative_menu_attack_target", - }) -end - -local function boss_pit_step(entity, frame) - local state_comp = find_variable(entity, "state") - local memory_comp = find_variable(entity, "memory") - if not valid(state_comp) then return end - local current = ((tonumber(get_value(state_comp, "value_int", 0)) or 0) + 1) % 10 - local x, y = root_position(entity) - if x == nil then return end - if type(SetRandomSeed) == "function" then pcall(SetRandomSeed, x, y * frame) end - if current == 1 then - local memory = valid(memory_comp) and tostring(get_value(memory_comp, "value_string", "") or "") or "" - if memory == "" then - memory = "data/entities/projectiles/enlightened_laser_darkbeam.xml" - if valid(memory_comp) then pcall(ComponentSetValue2, memory_comp, "value_string", memory) end - end - local angle_roll, pick = 1, 1 - if type(Random) == "function" then - local oka, a = pcall(Random, 1, 200); if oka and tonumber(a) ~= nil then angle_roll = tonumber(a) end - local okp, r = pcall(Random, 1, 7); if okp and tonumber(r) ~= nil then pick = tonumber(r) end - end - local a = angle_roll * math.pi - local spells = {"rocket", "rocket_tier_2", "rocket_tier_3", "grenade", "grenade_tier_2", "grenade_tier_3", "rubber_ball"} - local path = "data/entities/projectiles/deck/" .. spells[math.max(1, math.min(#spells, math.floor(pick)))] .. ".xml" - local wand = shoot_velocity(entity, "data/entities/animals/boss_pit/wand.xml", x, y, math.cos(a) * 100, -math.cos(a) * 100) - if wand ~= 0 then - for _, comp in ipairs(EntityGetComponentIncludingDisabled(wand, "VariableStorageComponent") or {}) do - pcall(ComponentSetValue2, comp, "value_string", path) - end - add_homing_to_target(wand) - pcall(EntityAddComponent2, wand, "VariableStorageComponent", { - name="mult", value_float=string.find(path, "rocket", 1, true) ~= nil and 0.5 or 1.2, - }) - end - elseif current == 7 then - local stuck_comp = find_variable(entity, "pathfinding_frames_stuck") - local stuck = valid(stuck_comp) and (tonumber(get_value(stuck_comp, "value_int", 0)) or 0) or 0 - if stuck > 160 then - shoot_velocity(entity, "data/entities/projectiles/remove_ground.xml", x, y, 0, 0) - else - local opts = {"orb_poly", "orb_neutral", "orb_tele", "orb_dark"} - local pick, off = 1, 1 - if type(Random) == "function" then - local okp, p = pcall(Random, 1, #opts); if okp and tonumber(p) ~= nil then pick = tonumber(p) end - local oko, o = pcall(Random, 1, 10); if oko and tonumber(o) ~= nil then off = tonumber(o) end - end - local path = "data/entities/projectiles/" .. opts[math.max(1, math.min(#opts, math.floor(pick)))] .. ".xml" - local offset = math.pi * (off * 0.1) - for _, v in ipairs(mathx.radial(8, 300, offset, true)) do shoot_velocity(entity, path, x, y, v.x, v.y) end - end - end - pcall(ComponentSetValue2, state_comp, "value_int", current) -end - -local function boss_pit_maintenance(entity, frame) - if not due("boss_pit_maintenance", 40, frame) then return end - local hitbox = EntityGetFirstComponentIncludingDisabled(entity, "HitboxComponent") - if valid(hitbox) then - local multiplier = tonumber(get_value(hitbox, "damage_multiplier", 1)) or 1 - if multiplier < 1 then - pcall(ComponentSetValue2, hitbox, "damage_multiplier", math.min(1, multiplier + 0.35)) - end - end - if type(EntitySetComponentsWithTagEnabled) == "function" then - pcall(EntitySetComponentsWithTagEnabled, entity, "invincible", false) - end -end - -local function fire_boss_pit(entity, frame, held) - -- boss_pit_logic.lua contains two independent responsibilities. Its defensive - -- recovery is passive and must keep ticking even under player control; only the - -- projectile state machine is gated by primary fire. - boss_pit_maintenance(entity, frame) - if held and due("boss_pit", 40, frame) then boss_pit_step(entity, frame) end -end - -local function boss_limbs_set_hitboxes(entity, weak) - if type(EntitySetComponentsWithTagEnabled) == "function" then - pcall(EntitySetComponentsWithTagEnabled, entity, "hitbox_weak_spot", weak == true) - pcall(EntitySetComponentsWithTagEnabled, entity, "hitbox_default", weak ~= true) - end -end - -local function boss_limbs_set_details(entity, hidden) - local sprites = EntityGetComponentIncludingDisabled(entity, "SpriteComponent") or {} - local animation = hidden and "invisible" or "stand" - -- Vanilla boss_limbs_update.lua treats the first SpriteComponent as the main - -- body and every later sprite as a cosmetic detail. - for index = 2, #sprites do - pcall(ComponentSetValue2, sprites[index], "rect_animation", animation) - end -end - -local function boss_limbs_update_lifecycle(entity, frame) - local weak_due = tonumber(state.boss_limbs_weak_due) - if weak_due ~= nil and frame >= weak_due then - boss_limbs_set_hitboxes(entity, true) - state.boss_limbs_weak_due = nil - end - - local close_due = tonumber(state.boss_limbs_close_due) - if close_due ~= nil and frame >= close_due then - if type(GamePlayAnimation) == "function" then pcall(GamePlayAnimation, entity, "close", 0, "stand", 0) end - state.boss_limbs_close_due = nil - end - - local default_due = tonumber(state.boss_limbs_default_hitbox_due) - if default_due ~= nil and frame >= default_due then - boss_limbs_set_hitboxes(entity, false) - state.boss_limbs_default_hitbox_due = nil - end - - local details_due = tonumber(state.boss_limbs_details_due) - if details_due ~= nil and frame >= details_due then - boss_limbs_set_details(entity, false) - state.boss_limbs_details_due = nil - end -end - -local function boss_limbs_begin_expose(entity, frame) - if type(GamePlayAnimation) == "function" then pcall(GamePlayAnimation, entity, "open", 0, "opened", 0) end - boss_limbs_set_details(entity, true) - state.boss_limbs_weak_due = frame + 10 -end - -local function boss_limbs_schedule_close(frame, close_delay) - local close_due = frame + close_delay - state.boss_limbs_close_due = close_due - state.boss_limbs_default_hitbox_due = close_due + 10 - state.boss_limbs_details_due = close_due + 40 -end - -local function fire_boss_limbs(entity, frame, primary, secondary) - boss_limbs_update_lifecycle(entity, frame) - local mode = state.boss_limbs_mode - local sequence = state.boss_limbs_sequence - if type(sequence) ~= "table" then - if frame < (tonumber(state.boss_limbs_next) or 0) then return end - if primary then - state.boss_limbs_mode = "circle" - state.boss_limbs_sequence = {step=1, due=frame + 45} - boss_limbs_begin_expose(entity, frame) - elseif secondary then - state.boss_limbs_mode = "homing" - state.boss_limbs_sequence = {step=1, due=frame + 45} - boss_limbs_begin_expose(entity, frame) - end - return - end - if frame < (tonumber(sequence.due) or frame) then return end - local x, y = root_position(entity) - if x == nil then return end - if mode == "circle" then - for _, v in ipairs(mathx.radial_degrees(8, 230, 0)) do - shoot_velocity(entity, "data/entities/animals/boss_limbs/orb_boss_limbs.xml", x, y, v.x, v.y) - end - if type(GamePlayAnimation) == "function" then pcall(GamePlayAnimation, entity, "attack_ranged", 0, "opened", 0) end - sequence.step = sequence.step + 1 - if sequence.step <= 3 then - sequence.due = frame + 65 - else - -- phase1 after the third ring keeps the weak spot exposed for 240f, then - -- closes it (40f) and waits 10f before another phase can begin. - boss_limbs_schedule_close(frame, 240) - state.boss_limbs_next = frame + 290 - state.boss_limbs_sequence=nil; state.boss_limbs_mode=nil - end - else - shoot_velocity(entity, "data/entities/animals/boss_limbs/orb_pink_big.xml", x, y, 0, -30) - if type(GamePlayAnimation) == "function" then pcall(GamePlayAnimation, entity, "attack_ranged", 0, "opened", 0) end - sequence.step = sequence.step + 1 - if sequence.step <= 2 then - sequence.due = frame + 55 - else - -- phase4: after the second homing shot vanilla waits 120f, performs the - -- 40f close sequence, then waits 10f before returning to phase0. - boss_limbs_schedule_close(frame, 120) - state.boss_limbs_next = frame + 170 - state.boss_limbs_sequence=nil; state.boss_limbs_mode=nil - end - end -end - -local function centipede_orbcount(entity) - local comp = find_variable(entity, "orbcount") - if valid(comp) then return math.max(0, tonumber(get_value(comp, "value_int", 0)) or 0) end - if type(GameGetOrbCountThisRun) == "function" then - local ok, value = pcall(GameGetOrbCountThisRun) - if ok and tonumber(value) ~= nil then return math.max(0, tonumber(value)) end - end - return 0 -end - -local function centipede_cursor_distance(entity) - local x, y = root_position(entity); if x == nil then return math.huge end - local tx, ty = mouse_target(x, y) - local dx, dy = tx - x, ty - y - return math.sqrt(dx * dx + dy * dy) -end - -local function centipede_eye(entity, opened) - opened = opened == true - if state.centipede_eye_open == opened then return end - state.centipede_eye_open = opened - if type(GamePlayAnimation) == "function" then - if opened then pcall(GamePlayAnimation, entity, "open", 0, "opened", 0) - else pcall(GamePlayAnimation, entity, "close", 0, "stand", 0) end - end - if type(GameEntityPlaySound) == "function" then - pcall(GameEntityPlaySound, entity, opened and "open_mouth" or "close_mouth") - end -end - -local function centipede_shield_entity(entity) - if type(EntityGetName) ~= "function" then return 0 end - for _, child in ipairs(EntityGetAllChildren(entity) or {}) do - local ok, name = pcall(EntityGetName, child) - if ok and tostring(name or "") == "shield_entity" then return child end - end - return 0 -end - - -local function centipede_initialize_form(entity) - local initialized = find_variable(entity, "initialized") - local orbcount_comp = find_variable(entity, "orbcount") - -- The vanilla boss contract contains both variables. If a modded derivative removes - -- either one, do not invent initialization state for it; keep its authored values. - if not valid(initialized) or not valid(orbcount_comp) then return false end - - local already_initialized = get_value(initialized, "value_bool", false) == true - local orbcount = tonumber(get_value(orbcount_comp, "value_int", 0)) or 0 - if not already_initialized then - local newgame_n = 0 - if type(SessionNumbersGetValue) == "function" then - local ok, value = pcall(SessionNumbersGetValue, "NEW_GAME_PLUS_COUNT") - if ok then newgame_n = tonumber(value) or 0 end - end - local run_orbs = 0 - if type(GameGetOrbCountThisRun) == "function" then - local ok, value = pcall(GameGetOrbCountThisRun) - if ok then run_orbs = tonumber(value) or 0 end - end - orbcount = math.max(0, math.floor(run_orbs + newgame_n)) - pcall(ComponentSetValue2, initialized, "value_bool", true) - pcall(ComponentSetValue2, orbcount_comp, "value_int", orbcount) - - local damage = EntityGetFirstComponentIncludingDisabled(entity, "DamageModelComponent") - if valid(damage) then - local boss_hp = 46.0 + (2.0 ^ (orbcount + 1.3)) + (orbcount * 15.5) - pcall(ComponentSetValue2, damage, "max_hp", boss_hp) - pcall(ComponentSetValue2, damage, "hp", boss_hp) - if type(ComponentObjectSetValue2) == "function" then - if orbcount >= 3 then - pcall(ComponentObjectSetValue2, damage, "damage_multipliers", "melee", 1.5) - pcall(ComponentObjectSetValue2, damage, "damage_multipliers", "drill", 0.25) - pcall(ComponentObjectSetValue2, damage, "damage_multipliers", "projectile", 0.2) - pcall(ComponentObjectSetValue2, damage, "damage_multipliers", "fire", 0) - pcall(ComponentObjectSetValue2, damage, "damage_multipliers", "ice", 0) - pcall(ComponentObjectSetValue2, damage, "damage_multipliers", "electricity", 0) - end - if orbcount >= 5 then - pcall(ComponentObjectSetValue2, damage, "damage_multipliers", "melee", 1.0) - pcall(ComponentObjectSetValue2, damage, "damage_multipliers", "drill", 0.01) - end - if orbcount >= 9 then - pcall(ComponentObjectSetValue2, damage, "damage_multipliers", "slice", 0.5) - pcall(ComponentObjectSetValue2, damage, "damage_multipliers", "physics_hit", 0.5) - pcall(ComponentObjectSetValue2, damage, "damage_multipliers", "radioactive", 0.5) - pcall(ComponentObjectSetValue2, damage, "damage_multipliers", "poison", 0.5) - end - if orbcount >= 11 then - pcall(ComponentObjectSetValue2, damage, "damage_multipliers", "melee", 0.5) - end - end - end - - if type(EntitySetDamageFromMaterial) == "function" then - if orbcount >= 4 then pcall(EntitySetDamageFromMaterial, entity, "acid", 0.01) end - if orbcount >= 7 then pcall(EntitySetDamageFromMaterial, entity, "acid", 0.0) end - if orbcount == 12 then pcall(EntitySetDamageFromMaterial, entity, "magic_liquid_polymorph", 0.35) end - if orbcount == 13 then pcall(EntitySetDamageFromMaterial, entity, "magic_liquid_random_polymorph", 0.35) end - if orbcount == 14 then pcall(EntitySetDamageFromMaterial, entity, "magic_liquid_teleportation", 0.35) end - if orbcount == 15 then pcall(EntitySetDamageFromMaterial, entity, "magic_liquid_movement_faster", 0.35) end - if orbcount == 16 then pcall(EntitySetDamageFromMaterial, entity, "material_confusion", 0.35) end - end - if orbcount > 30 and type(EntityAddTag) == "function" then pcall(EntityAddTag, entity, "touchmagic_immunity") end - - if centipede_shield_entity(entity) == 0 and type(EntityLoad) == "function" and type(EntityAddChild) == "function" then - local x, y = root_position(entity) - if x ~= nil then - local shield_path = orbcount == 0 - and "data/entities/animals/boss_centipede/boss_centipede_shield_weak.xml" - or "data/entities/animals/boss_centipede/boss_centipede_shield_strong.xml" - local ok_load, shield = pcall(EntityLoad, shield_path, x, y) - if ok_load and shield ~= nil and shield ~= 0 then pcall(EntityAddChild, entity, shield) end - end - end - end - - -- Vanilla init_boss() does this on every load, not only first initialization. - if type(EntitySetComponentsWithTagEnabled) == "function" then - for _, child in ipairs(EntityGetAllChildren(entity) or {}) do - pcall(EntitySetComponentsWithTagEnabled, child, "disabled_at_start", true) - end - end - state.centipede_orbcount = orbcount - return true -end - -local function centipede_shield(entity, enabled) - enabled = enabled == true - if state.centipede_shield_enabled == enabled then return end - state.centipede_shield_enabled = enabled - local shield = centipede_shield_entity(entity) - if shield == 0 then return end - if type(EntitySetComponentsWithTagEnabled) == "function" then - pcall(EntitySetComponentsWithTagEnabled, shield, "shield", enabled) - end - local x, y = root_position(entity) - if x ~= nil then - pcall(EntityLoad, - enabled - and "data/entities/particles/muzzle_flashes/muzzle_flash_circular_large_pink_reverse.xml" - or "data/entities/particles/muzzle_flashes/muzzle_flash_circular_large_pink.xml", - x, y) - end - if type(GameEntityPlaySound) == "function" then - pcall(GameEntityPlaySound, shield, enabled and "activate" or "deactivate") - end -end - -local function centipede_muzzle(entity, path) - local x, y = root_position(entity) - if x == nil then return end - pcall(EntityLoad, path, x, y) - if type(GamePlaySound) == "function" then - pcall(GamePlaySound, "data/audio/Desktop/projectiles.bank", "projectiles/magic/create", x, y) - end -end - -local function centipede_event(q, due_frame, kind) - q.events = q.events or {} - q.events[#q.events + 1] = {due=due_frame, kind=kind} -end - -local function centipede_process_events(entity, frame, q) - local remaining = {} - for _, event in ipairs(type(q.events) == "table" and q.events or {}) do - if frame >= (tonumber(event.due) or frame) then - if event.kind == "shield_on" then centipede_shield(entity, true) - elseif event.kind == "shield_off" then centipede_shield(entity, false) - elseif event.kind == "eye_open" then centipede_eye(entity, true) - elseif event.kind == "eye_close" then centipede_eye(entity, false) - elseif event.kind == "circle_start_sound" and type(GameEntityPlaySound) == "function" then - pcall(GameEntityPlaySound, entity, "phase_circleshot_start") - elseif event.kind == "fire_sound" and type(GameEntityPlaySound) == "function" then - pcall(GameEntityPlaySound, entity, "shoot_fire") - elseif event.kind == "homing_sound" and type(GameEntityPlaySound) == "function" then - pcall(GameEntityPlaySound, entity, "shoot_homingshot") - end - else - remaining[#remaining + 1] = event - end - end - q.events = remaining -end - -local function centipede_start_sequence(entity, frame, kind) - local orbcount = centipede_orbcount(entity) - local q = {kind=kind, orbcount=orbcount, events={}} - if kind == "circle" then - -- phase_circleshot: move/wait50 -> shield_on -> wait10 -> open_eye/wait55. - q.repeat_left = 1 - q.shot_left = 0 - q.due = frame + 115 - centipede_event(q, frame + 50, "shield_on") - centipede_event(q, frame + 60, "eye_open") - centipede_event(q, frame + 115, "circle_start_sound") - elseif kind == "fire" then - q.repeat_left = 2 - q.due = frame + 115 - centipede_event(q, frame + 50, "shield_on") - centipede_event(q, frame + 60, "eye_open") - centipede_event(q, frame + 115, "fire_sound") - elseif kind == "homing" then - q.left = 4 + math.floor(orbcount * 0.5) - q.due = frame + 55 - centipede_eye(entity, true) - elseif kind == "poly" then - q.due = frame + 85 -- open_eye 55 + explicit 30f wait - centipede_eye(entity, true) - elseif kind == "melee" then - q.due = frame + 55 - centipede_eye(entity, true) - elseif kind == "clear" then - q.due = frame + 25 - elseif kind == "aggro" then - q.left = 12 + math.floor(orbcount / 3) - q.stage = "rings" - q.due = frame + 60 - if type(GamePlayAnimation) == "function" then pcall(GamePlayAnimation, entity, "aggro", 0, "aggro", 0) end - else - return false - end - state.centipede_sequence = q - return true -end - -local function centipede_finish(entity, frame, cooldown, close_eye) - if close_eye == true then centipede_eye(entity, false) end - state.centipede_sequence = nil - state.centipede_next = frame + math.max(1, tonumber(cooldown) or 1) -end - -local function centipede_circle_ring(entity, frame, q) - local x, y = root_position(entity); if x == nil then return false end - if (tonumber(q.shot_left) or 0) <= 0 then - local r = 0 - if type(ProceduralRandomf) == "function" then - local ok, value = pcall(ProceduralRandomf, frame, frame) - if ok and tonumber(value) ~= nil then r = tonumber(value) end - end - q.shot_left = 10 + math.floor((tonumber(q.orbcount) or 0) / 3) - q.branches = 6 + (tonumber(q.orbcount) or 0) - (tonumber(q.repeat_left) or 0) - q.angle_step = 25 * (r * 2 - 1) - q.angle = r * 180 + q.angle_step - end - for _, v in ipairs(mathx.radial_degrees(q.branches, 80, q.angle)) do - shoot_velocity(entity, "data/entities/animals/boss_centipede/orb_circleshot.xml", x, y, v.x, v.y) - end - centipede_muzzle(entity, "data/entities/particles/muzzle_flashes/muzzle_flash_circular_pink.xml") - q.angle = q.angle + q.angle_step - q.shot_left = q.shot_left - 1 - if q.shot_left > 0 then - q.due = frame + 12 - elseif (tonumber(q.repeat_left) or 0) > 0 then - -- Final loop wait12, then next_phase() turns the shield off after the 60f - -- tail; repeated phase turns it back on after its own 50f move wait. Eye - -- remains open because close_eye() is suppressed while repeats are queued. - q.repeat_left = q.repeat_left - 1 - q.shot_left = 0 - centipede_event(q, frame + 12, "shield_off") - centipede_event(q, frame + 122, "shield_on") - centipede_event(q, frame + 132, "circle_start_sound") - q.due = frame + 132 - else - -- Last ring waits12 before close_eye(), whose animation lasts50; only then - -- does next_phase() disable the shield, followed by the 60f phase tail. - centipede_event(q, frame + 12, "eye_close") - centipede_event(q, frame + 62, "shield_off") - q.finish_at = frame + 122 - q.due = q.finish_at - q.stage = "finish" - end - return true -end - -local function centipede_aggro_ring(entity, frame, q) - local x, y = root_position(entity); if x == nil then return false end - local branches = 4 + (tonumber(q.orbcount) or 0) - for i = 1, branches do - local r = 0 - if type(ProceduralRandomf) == "function" then - local ok, value = pcall(ProceduralRandomf, frame + i, frame) - if ok and tonumber(value) ~= nil then r = tonumber(value) end - end - local a = math.rad(r * 360) - shoot_velocity(entity, "data/entities/animals/boss_centipede/orb_circleshot.xml", x, y, - math.cos(a) * 120, math.sin(a) * 120) - end - if type(GameCreateParticle) == "function" then - pcall(GameCreateParticle, "slime_green", x, y, 50, 0, -20, true, false) - end - centipede_muzzle(entity, "data/entities/particles/muzzle_flashes/muzzle_flash_circular_pink.xml") - q.left = q.left - 1 - if q.left > 0 then - q.due = frame + 12 - else - -- Final ring's wait12 + explicit wait20 precede explosion_attack(). - q.stage = "explosion" - q.due = frame + 32 - end - return true -end - -local function update_centipede_sequence(entity, frame) - local q = state.centipede_sequence - if type(q) ~= "table" then return false end - centipede_process_events(entity, frame, q) - if frame < (tonumber(q.due) or frame) then return false end - if q.stage == "finish" then - state.centipede_sequence = nil - state.centipede_next = frame - return true - end - local x, y = root_position(entity); if x == nil then state.centipede_sequence=nil; return false end - if q.kind == "circle" then - return centipede_circle_ring(entity, frame, q) - elseif q.kind == "fire" then - local amount = 10 + (tonumber(q.orbcount) or 0) - (tonumber(q.repeat_left) or 0) * 2 - for _, v in ipairs(mathx.firepillar(amount)) do - shoot_velocity(entity, "data/entities/animals/boss_centipede/firepillar.xml", x, y, v.x, v.y) - end - centipede_muzzle(entity, "data/entities/particles/muzzle_flashes/muzzle_flash_circular.xml") - if (tonumber(q.repeat_left) or 0) > 0 then - q.repeat_left = q.repeat_left - 1 - -- close_eye() is suppressed. After the 40f tail next_phase() disables - -- shield; repeated phase re-enables it after its 50f movement wait. - centipede_event(q, frame + 40, "shield_off") - centipede_event(q, frame + 90, "shield_on") - centipede_event(q, frame + 100, "fire_sound") - q.due = frame + 100 - else - -- Final phase closes the eye immediately, waits50+40, then next_phase() - -- turns off the shield. - centipede_eye(entity, false) - centipede_event(q, frame + 90, "shield_off") - q.finish_at = frame + 90 - q.due = q.finish_at - q.stage = "finish" - end - return true - elseif q.kind == "homing" then - local vy = -200 - if type(ProceduralRandomf) == "function" then - local ok, value = pcall(ProceduralRandomf, x, y + frame, -200, 50) - if ok and tonumber(value) ~= nil then vy = tonumber(value) end - end - shoot_velocity(entity, "data/entities/animals/boss_centipede/orb_homing.xml", x, y, 0, vy) - q.left = q.left - 1 - if type(GameEntityPlaySound) == "function" then pcall(GameEntityPlaySound, entity, "shoot_homingshot") end - if q.left > 0 then q.due = frame + 20 - else - centipede_event(q, frame + 20, "eye_close") - q.due = frame + 70 - q.stage = "finish" - end - return true - elseif q.kind == "poly" then - shoot_velocity(entity, "data/entities/animals/boss_centipede/orb_polymorph.xml", x, y - 10, 0, -50) - shoot_velocity(entity, "data/entities/animals/boss_centipede/orb_polymorph.xml", x - 5, y, -30, 20) - shoot_velocity(entity, "data/entities/animals/boss_centipede/orb_polymorph.xml", x - 5, y, -30, 20) - if type(GameEntityPlaySound) == "function" then pcall(GameEntityPlaySound, entity, "shoot_homingshot") end - centipede_event(q, frame + 20, "eye_close") - q.due = frame + 70 - q.stage = "finish" - return true - elseif q.kind == "melee" then - shoot_velocity(entity, "data/entities/animals/boss_centipede/melee.xml", x, y, 0, 0) - if type(GameEntityPlaySound) == "function" then pcall(GameEntityPlaySound, entity, "phase_rush_start") end - centipede_event(q, frame + 50, "eye_close") - q.due = frame + 100 - q.stage = "finish" - return true - elseif q.kind == "clear" then - shoot_velocity(entity, "data/entities/animals/boss_centipede/clear_materials.xml", x, y, 0, 0) - centipede_finish(entity, frame, 1, false) - return true - elseif q.kind == "aggro" then - if q.stage == "rings" then return centipede_aggro_ring(entity, frame, q) end - shoot_velocity(entity, "data/entities/animals/boss_centipede/melee.xml", x, y, 0, 0) - centipede_finish(entity, frame, 60, false) - return true - end - state.centipede_sequence = nil - return false -end - -local function centipede_choose_projectile_phase(entity, frame) - local orbcount = centipede_orbcount(entity) - if find_variable(entity, "aggro") ~= nil then return "aggro" end - local distance = centipede_cursor_distance(entity) - if distance < 65 then return "melee" end - -- 700px is the boss AI's target-acquisition ceiling. Player cursor distance should - -- still select a projectile phase beyond it rather than turning primary fire off. - local subphase = tonumber(state.centipede_subphase) or 0 - if subphase >= 6 then - state.centipede_subphase = 0 - return "clear" - end - local choices = {"circle", "fire"} - if orbcount >= 2 then choices[#choices+1] = "homing" end - if orbcount >= 11 then choices[#choices+1] = "poly" end - local pick = 1 - local x, y = root_position(entity) - local tx, ty = mouse_target(x or 0, y or 0) - -- Vanilla next_phase seeds with boss position + target position + subphase. The - -- cursor is the controlled form's target, so preserve that exact dependency. - if type(SetRandomSeed) == "function" then pcall(SetRandomSeed, (x or 0) + tx + subphase, (y or 0) + ty) end - if type(Random) == "function" then - local ok, value = pcall(Random, 1, #choices) - if ok and tonumber(value) ~= nil then pick = tonumber(value) end - end - state.centipede_subphase = subphase + 1 - return choices[math.max(1, math.min(#choices, math.floor(pick)))] -end - -local function fire_boss_centipede(entity, frame, primary, secondary) - if update_centipede_sequence(entity, frame) then return end - if state.centipede_sequence ~= nil or frame < (tonumber(state.centipede_next) or 0) then return end - if not primary and not secondary then return end - - local kind = nil - if secondary then - local orbcount = centipede_orbcount(entity) - local options = {"circle", "fire"} - if orbcount >= 2 then options[#options+1] = "homing" end - if orbcount >= 11 then options[#options+1] = "poly" end - local index = (tonumber(state.centipede_secondary) or 0) + 1 - if index > #options then index = 1 end - state.centipede_secondary = index - kind = options[index] - else - kind = centipede_choose_projectile_phase(entity, frame) - end - if kind ~= nil then centipede_start_sequence(entity, frame, kind) end -end - -local function restore_components() - for _, record in ipairs(records) do - if valid(record.component) and record.owner ~= nil and record.owner ~= 0 and EntityGetIsAlive(record.owner) then - pcall(EntitySetComponentIsEnabled, record.owner, record.component, record.enabled == true) - end - end -end - -function scripted.reset() - if active_entity ~= 0 and EntityGetIsAlive(active_entity) and #owners(SCRIPT.BOSS_ROBOT) > 0 then - boss_robot_stop_lasers(active_entity) - end - if active_entity ~= 0 and EntityGetIsAlive(active_entity) and #owners(SCRIPT.BOSS_LIMBS) > 0 then - boss_limbs_set_hitboxes(active_entity, false) - boss_limbs_set_details(active_entity, false) - end - if active_entity ~= 0 and EntityGetIsAlive(active_entity) and #owners(SCRIPT.BOSS_CENTIPEDE) > 0 then - if state.centipede_eye_open == true then centipede_eye(active_entity, false) end - if state.centipede_shield_enabled == true then centipede_shield(active_entity, false) end - end - restore_components() - if target_entity ~= 0 and EntityGetIsAlive(target_entity) then pcall(EntityKill, target_entity) end - target_entity = 0 - active_entity = 0 - active_path = "" - records = {} - by_script = {} - state = {} - manages_lasers = false -end - -function scripted.configure(entity, target_path) - if active_entity ~= 0 and active_entity ~= entity then scripted.reset() end - active_entity = entity or 0 - active_path = tostring(target_path or "") - records = {} - by_script = {} - state = {} - manages_lasers = false - if entity == nil or entity == 0 then return false end - walk_entity_tree(entity, function(current) - for _, comp in ipairs(EntityGetComponentIncludingDisabled(current, "LuaComponent") or {}) do - local source = tostring(get_value(comp, "script_source_file", "") or "") - if source == SCRIPT.BOSS_GHOST_LASERS and component_enabled(current, comp) then - -- This script is already a complete self-contained vanilla laser state - -- machine: it rotates the cross and derives emission/range/damage from - -- laser_status without targeting player_unit. Keep it native and merely - -- prevent the generic cursor-laser adapter from overwriting it. - manages_lasers = true - elseif SUPPRESS_ONLY[source] == true then - local enabled = component_enabled(current, comp) - records[#records + 1] = {component=comp, owner=current, source=source, enabled=enabled, suppress_only=true} - pcall(EntitySetComponentIsEnabled, current, comp, false) - elseif MANAGED[source] then - local enabled = component_enabled(current, comp) - local record = {component=comp, owner=current, source=source, enabled=enabled} - records[#records + 1] = record - if enabled or REPLAY_WHEN_INITIALLY_DISABLED[source] == true then - by_script[source] = by_script[source] or {} - by_script[source][#by_script[source] + 1] = record - if source == SCRIPT.BOSS_ROBOT then manages_lasers = true end - end - pcall(EntitySetComponentIsEnabled, current, comp, false) - end - end - end) - - if #owners(SCRIPT.BOSS_CENTIPEDE) > 0 then - centipede_initialize_form(entity) - end - - local now = frame_num() - for index, record in ipairs(owners(SCRIPT.MONK)) do - state["monk_" .. tostring(index)] = authored_next_frame(record, 60, now) - end - local singleton_schedules = { - {SCRIPT.FUNGUS_GIGA, "fungus_giga", 10}, - {SCRIPT.BOSS_DRAGON, "boss_dragon", 80}, - {SCRIPT.BOSS_MEAT_SHOT, "boss_meat_acid", 20}, - {SCRIPT.BOSS_MEAT_EYE, "boss_meat_eye", 80}, - {SCRIPT.MAGGOT_TINY, "maggot", 20}, - {SCRIPT.BOSS_WIZARD, "boss_wizard", 16}, - {SCRIPT.BOSS_ROBOT, "boss_robot", 40}, - {SCRIPT.BOSS_PIT, "boss_pit", 40}, - } - for _, spec in ipairs(singleton_schedules) do - local list = owners(spec[1]) - if type(list[1]) == "table" then state[spec[2]] = authored_next_frame(list[1], spec[3], now) end - end - local pit = owners(SCRIPT.BOSS_PIT) - if type(pit[1]) == "table" then - state.boss_pit_maintenance = authored_next_frame(pit[1], 40, now) - end - local fish_timer = find_variable(entity, "phase_timer") - if valid(fish_timer) then state.fish_timer = tonumber(get_value(fish_timer, "value_int", 0)) or 0 end - state.boss_meat_acid_enabled = #owners(SCRIPT.BOSS_MEAT_SHOT) > 0 - return #records > 0 -end - -function scripted.active() - return active_entity ~= 0 and #records > 0 -end - -function scripted.manages_lasers() - return manages_lasers == true -end - -function scripted.update(entity, allow_secondary, allow_primary) - if allow_secondary == nil then allow_secondary = true end - if allow_primary == nil then allow_primary = true end - if entity == nil or entity == 0 or entity ~= active_entity or #records == 0 then return false end - local controls = ensure_controls(entity) - if not valid(controls) then return false end - local primary = allow_primary and get_value(controls, "mButtonDownFire", false) == true - local secondary = allow_secondary and get_value(controls, "mButtonDownFire2", false) == true - local frame = frame_num() - update_target(entity) - - if #owners(SCRIPT.MONK) > 0 then fire_monk(entity, frame, primary) end - if #owners(SCRIPT.FUNGUS_GIGA) > 0 then fire_fungus_giga(entity, frame, primary) end - if #owners(SCRIPT.BOSS_DRAGON) > 0 then fire_boss_dragon(entity, frame, primary) end - if #owners(SCRIPT.BOSS_MEAT_SHOT) > 0 or #owners(SCRIPT.BOSS_MEAT_EYE) > 0 then fire_boss_meat(entity, frame, primary) end - if #owners(SCRIPT.FISH_GIGA) > 0 then fire_fish_giga(entity, frame, primary) end - if #owners(SCRIPT.MAGGOT_TINY) > 0 then fire_maggot(entity, frame, primary) end - if #owners(SCRIPT.BOSS_WIZARD) > 0 then fire_boss_wizard(entity, frame, primary) end - if #owners(SCRIPT.BOSS_ROBOT) > 0 then fire_boss_robot(entity, frame, primary) end - if #owners(SCRIPT.BOSS_PIT) > 0 then fire_boss_pit(entity, frame, primary) end - if #owners(SCRIPT.BOSS_LIMBS) > 0 then fire_boss_limbs(entity, frame, primary, secondary) end - if #owners(SCRIPT.BOSS_CENTIPEDE) > 0 then fire_boss_centipede(entity, frame, primary, secondary) end - return true -end - -function scripted.sources() - local out = {} - for _, record in ipairs(records) do out[#out+1] = record.source end - table.sort(out) - return out -end - -METAMORPH_CREATIVE_MENU_FORM_SCRIPTED_ATTACKS = scripted -return scripted diff --git a/metamorph_creative_menu/files/features/forms/transition_queue.lua b/metamorph_creative_menu/files/features/forms/transition_queue.lua deleted file mode 100644 index 7ba6f6e..0000000 --- a/metamorph_creative_menu/files/features/forms/transition_queue.lua +++ /dev/null @@ -1,79 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_FORM_TRANSITION_QUEUE) == "table" then - return METAMORPH_CREATIVE_MENU_FORM_TRANSITION_QUEUE -end - -local transition_queue = {} -local pending = nil -local MAX_AGE_FRAMES = 30 - -local function copy_options(options) - local result = {} - if type(options) == "table" then - for key, value in pairs(options) do result[key] = value end - end - return result -end - -function transition_queue.schedule(entity_path, frames, options, requested_frame) - pending = { - entity_path = entity_path, - frames = frames, - options = copy_options(options), - requested_frame = tonumber(requested_frame) or 0, - human_frame = nil, - } - return true -end - -local function expired(frame) - if pending == nil then return false end - local now = tonumber(frame) - if now == nil then return false end - local requested = tonumber(pending.requested_frame) or now - return now - requested > MAX_AGE_FRAMES -end - -function transition_queue.expire(frame) - if not expired(frame) then return false end - pending = nil - return true -end - -function transition_queue.has_pending() - return pending ~= nil -end - -function transition_queue.mark_human(frame) - if pending == nil then return false end - if pending.human_frame == nil then pending.human_frame = tonumber(frame) or 0 end - return true -end - -function transition_queue.ready(frame) - if transition_queue.expire(frame) then return false end - return pending ~= nil - and pending.human_frame ~= nil - and (tonumber(frame) or 0) > pending.human_frame -end - -function transition_queue.take(frame) - if not transition_queue.ready(frame) then return nil end - local request = pending - pending = nil - return request -end - -function transition_queue.clear() - local had_pending = pending ~= nil - pending = nil - return had_pending -end - -function transition_queue.peek() - return pending -end - -transition_queue.MAX_AGE_FRAMES = MAX_AGE_FRAMES - -METAMORPH_CREATIVE_MENU_FORM_TRANSITION_QUEUE = transition_queue -return transition_queue diff --git a/metamorph_creative_menu/files/features/items/catalog.lua b/metamorph_creative_menu/files/features/items/catalog.lua index 4e2ffb9..7acc3c0 100644 --- a/metamorph_creative_menu/files/features/items/catalog.lua +++ b/metamorph_creative_menu/files/features/items/catalog.lua @@ -1,22 +1,22 @@ -- Generated from this Noita installation's data.wak. -- Contains spawnable player items and collectible item entities; internal base/test XML is excluded. return { - { path = "data/entities/items/pickup/bloodmoney_10.xml", name = "$item_bloodmoney_10", description = "Lorem ipsum", icon = "data/ui_gfx/items/goldnugget.png", category = "BONUSES", advanced = false }, + { path = "data/entities/items/pickup/bloodmoney_10.xml", name = "$item_bloodmoney_10", description = "Lorem ipsum", icon = "data/ui_gfx/items/book.png", category = "BONUSES", advanced = false }, { path = "data/entities/items/pickup/bloodmoney_1000.xml", name = "$item_bloodmoney_1000", description = "Lorem ipsum", icon = "data/ui_gfx/items/goldnugget.png", category = "BONUSES", advanced = false }, - { path = "data/entities/items/pickup/bloodmoney_10000.xml", name = "$item_bloodmoney_10000", description = "Lorem ipsum", icon = "data/items_gfx/easter/golden_idol.png", category = "BONUSES", advanced = false }, + { path = "data/entities/items/pickup/bloodmoney_10000.xml", name = "$item_bloodmoney_10000", description = "Lorem ipsum", icon = "data/ui_gfx/items/goldnugget.png", category = "BONUSES", advanced = false }, { path = "data/entities/items/pickup/bloodmoney_200.xml", name = "$item_bloodmoney_200", description = "Lorem ipsum", icon = "data/ui_gfx/items/goldnugget.png", category = "BONUSES", advanced = false }, - { path = "data/entities/items/pickup/bloodmoney_200000.xml", name = "$item_bloodmoney_200000", description = "Lorem ipsum", icon = "data/items_gfx/easter/golden_idol_big.png", category = "BONUSES", advanced = false }, + { path = "data/entities/items/pickup/bloodmoney_200000.xml", name = "$item_bloodmoney_200000", description = "Lorem ipsum", icon = "data/ui_gfx/items/goldnugget.png", category = "BONUSES", advanced = false }, { path = "data/entities/items/pickup/bloodmoney_50.xml", name = "$item_bloodmoney_50", description = "Lorem ipsum", icon = "data/ui_gfx/items/goldnugget.png", category = "BONUSES", advanced = false }, - { path = "data/entities/items/pickup/bloodmoney_x.xml", name = "$reward_gold_statue", description = "Lorem ipsum", icon = "data/entities/animals/boss_centipede/rewards/gold_reward_sprite.png", category = "BONUSES", advanced = false }, + { path = "data/entities/items/pickup/bloodmoney_x.xml", name = "$reward_gold_statue", description = "Lorem ipsum", icon = "data/ui_gfx/items/goldnugget.png", category = "BONUSES", advanced = false }, { path = "data/entities/items/pickup/chest_random_super.xml", name = "$item_chest_treasure", description = "", icon = "data/ui_gfx/inventory/inventory_box.png", category = "BONUSES", advanced = false }, { path = "data/entities/items/pickup/chest_random.xml", name = "$item_chest_treasure", description = "", icon = "data/ui_gfx/inventory/inventory_box.png", category = "BONUSES", advanced = false }, - { path = "data/entities/items/pickup/goldnugget_10.xml", name = "$item_goldnugget_10", description = "Lorem ipsum", icon = "data/ui_gfx/items/goldnugget.png", category = "BONUSES", advanced = false }, + { path = "data/entities/items/pickup/goldnugget_10.xml", name = "$item_goldnugget_10", description = "Lorem ipsum", icon = "data/ui_gfx/items/book.png", category = "BONUSES", advanced = false }, { path = "data/entities/items/pickup/goldnugget_1000.xml", name = "$item_goldnugget_1000", description = "Lorem ipsum", icon = "data/ui_gfx/items/goldnugget.png", category = "BONUSES", advanced = false }, - { path = "data/entities/items/pickup/goldnugget_10000.xml", name = "$item_goldnugget_10000", description = "Lorem ipsum", icon = "data/items_gfx/easter/golden_idol.png", category = "BONUSES", advanced = false }, + { path = "data/entities/items/pickup/goldnugget_10000.xml", name = "$item_goldnugget_10000", description = "Lorem ipsum", icon = "data/ui_gfx/items/goldnugget.png", category = "BONUSES", advanced = false }, { path = "data/entities/items/pickup/goldnugget_200.xml", name = "$item_goldnugget_200", description = "Lorem ipsum", icon = "data/ui_gfx/items/goldnugget.png", category = "BONUSES", advanced = false }, - { path = "data/entities/items/pickup/goldnugget_200000.xml", name = "$item_goldnugget_200000", description = "Lorem ipsum", icon = "data/items_gfx/easter/golden_idol_big.png", category = "BONUSES", advanced = false }, + { path = "data/entities/items/pickup/goldnugget_200000.xml", name = "$item_goldnugget_200000", description = "Lorem ipsum", icon = "data/ui_gfx/items/goldnugget.png", category = "BONUSES", advanced = false }, { path = "data/entities/items/pickup/goldnugget_50.xml", name = "$item_goldnugget_50", description = "Lorem ipsum", icon = "data/ui_gfx/items/goldnugget.png", category = "BONUSES", advanced = false }, - { path = "data/entities/items/pickup/goldnugget_x.xml", name = "$reward_gold_statue", description = "Lorem ipsum", icon = "data/entities/animals/boss_centipede/rewards/gold_reward_sprite.png", category = "BONUSES", advanced = false }, + { path = "data/entities/items/pickup/goldnugget_x.xml", name = "$reward_gold_statue", description = "Lorem ipsum", icon = "data/ui_gfx/items/goldnugget.png", category = "BONUSES", advanced = false }, { path = "data/entities/items/pickup/goldnugget.xml", name = "$item_goldnugget_10", description = "Lorem ipsum", icon = "data/ui_gfx/items/goldnugget.png", category = "BONUSES", advanced = false }, { path = "data/entities/items/pickup/heart_better.xml", name = "$item_heart_better", description = "", icon = "data/items_gfx/heart_extrahp.xml", category = "BONUSES", advanced = false }, { path = "data/entities/items/pickup/heart_evil.xml", name = "$item_heart", description = "", icon = "data/items_gfx/heart_extrahp_evil.xml", category = "BONUSES", advanced = false }, diff --git a/metamorph_creative_menu/files/features/items/liquid_preview.lua b/metamorph_creative_menu/files/features/items/liquid_preview.lua new file mode 100644 index 0000000..476242d --- /dev/null +++ b/metamorph_creative_menu/files/features/items/liquid_preview.lua @@ -0,0 +1,46 @@ +if type(METAMORPH_CREATIVE_MENU_LIQUID_PREVIEW) == "table" then return METAMORPH_CREATIVE_MENU_LIQUID_PREVIEW end + +local liquid_preview = {} + +local PROBE_ENTITY_PATH = "data/entities/items/pickup/potion_empty.xml" +local PROBE_VERTICAL_OFFSET = -100000 +local MATERIAL_AMOUNT = 1000 + +local function valid_entity(entity_id) + return entity_id ~= nil and entity_id ~= 0 and EntityGetIsAlive(entity_id) +end + +local function decode_potion_color(packed_color) + packed_color = tonumber(packed_color) + if packed_color == nil then return nil end + -- GameGetPotionColorUint is encoded as 0xBBGGRR: low byte is red. + local red = packed_color % 256 + local green = math.floor(packed_color / 256) % 256 + local blue = math.floor(packed_color / 65536) % 256 + return { red / 255, green / 255, blue / 255, 0.96 } +end + +function liquid_preview.sample_color(player_entity_id, material_id) + if not valid_entity(player_entity_id) or type(material_id) ~= "string" or material_id == "" then return nil end + + local player_x, player_y = EntityGetTransform(player_entity_id) + if player_x == nil then return nil end + + local probe_entity_id = EntityLoad(PROBE_ENTITY_PATH, player_x, (player_y or 0) + PROBE_VERTICAL_OFFSET) or 0 + if probe_entity_id == 0 then return nil end + + local color = nil + local operation_succeeded = pcall(function() + RemoveMaterialInventoryMaterial(probe_entity_id) + AddMaterialInventoryMaterial(probe_entity_id, material_id, MATERIAL_AMOUNT) + local packed_color = GameGetPotionColorUint(probe_entity_id) + color = decode_potion_color(packed_color) + end) + + if EntityGetIsAlive(probe_entity_id) then EntityKill(probe_entity_id) end + if not operation_succeeded then return nil end + return color +end + +METAMORPH_CREATIVE_MENU_LIQUID_PREVIEW = liquid_preview +return liquid_preview diff --git a/metamorph_creative_menu/files/features/items/service.lua b/metamorph_creative_menu/files/features/items/service.lua index 8d23105..4aed54c 100644 --- a/metamorph_creative_menu/files/features/items/service.lua +++ b/metamorph_creative_menu/files/features/items/service.lua @@ -9,35 +9,18 @@ local function valid_component(component) return component ~= nil and component ~= 0 end -local function valid_coordinate(value) - value = tonumber(value) - return value ~= nil and value == value and value > -1000000000 and value < 1000000000 +function item_service.world_sync_state(entity) + return ew_world_items.world_sync_state(entity) end -local function kill_tree(entity) - if entity == nil or entity == 0 or not EntityGetIsAlive(entity) then return end - if type(EntityGetAllChildren) == "function" then - local ok, children = pcall(EntityGetAllChildren, entity) - if ok and type(children) == "table" then - for _, child in ipairs(children) do kill_tree(child) end - end - end - if EntityGetIsAlive(entity) then pcall(EntityKill, entity) end +function item_service.world_item_outbox_state() + return ew_world_items.outbox_state() end -local function commit_world_entity(entity) - if entity == nil or entity == 0 or not EntityGetIsAlive(entity) then return false, "invalid" end - local enabled = pcall(inventory_slots.enable_world, entity) - if not enabled or not EntityGetIsAlive(entity) then - kill_tree(entity) - return false, "world_enable" - end - local call_ok, synced, sync_reason = pcall(ew_world_items.notify_world_item, entity) - if not call_ok or synced ~= true then - kill_tree(entity) - return false, call_ok and (sync_reason or "world_sync") or "world_sync" - end - return true, sync_reason or "singleplayer" +-- Public only for items that must be constructed/customized before they become a world +-- entity (for example a liquid-filled flask). Normal catalogue spawns should use spawn_near. +function item_service.notify_world_item(entity) + return ew_world_items.notify_world_item(entity) end local function drop_near(player, entity) @@ -68,15 +51,15 @@ end function item_service.spawn_near(player, path, offset_x, offset_y) local entity, reason = load_near(player, path, offset_x, offset_y) if entity == 0 then return entity, reason end - -- World state is enabled before the single optional EW handoff; failure rolls back the - -- newly created entity tree instead of leaving a local-only partial item. - local committed, sync_reason = commit_world_entity(entity) - if not committed then return 0, sync_reason end - return entity, "spawned_" .. tostring(sync_reason or "queued") + -- LMB world-spawns never pass through vanilla throw callbacks. Without explicitly + -- registering them, EW has no gid/authority handoff and wands appear late or only on + -- the spawning peer. ew_thrown itself defers serialization by one frame, so velocity + -- and physics initialization are still allowed to settle normally. + local synced, sync_reason = ew_world_items.notify_world_item(entity) + return entity, synced and ("spawned_" .. tostring(sync_reason or "queued")) or "spawned_unsynced" end -local function give_existing_entity(player, entity, play_sound, options) - options = type(options) == "table" and options or {} +function item_service.give_existing_entity(player, entity, play_sound) if player == nil or player == 0 or not EntityGetIsAlive(player) or entity == nil or entity == 0 or not EntityGetIsAlive(entity) then @@ -98,19 +81,11 @@ local function give_existing_entity(player, entity, play_sound, options) local picked, pickup_reason = inventory_slots.pickup(player, entity, play_sound == true) if not picked then - if options.rollback_on_failure == true then - kill_tree(entity) - return false, pickup_reason or "pickup_failed", 0, false - end local spawned_nearby = EntityGetIsAlive(entity) == true if spawned_nearby then drop_near(player, entity) end return false, pickup_reason or "pickup_failed", entity, spawned_nearby end if not EntityGetIsAlive(entity) or not inventory_slots.is_inside_player_inventory(player, entity) then - if options.rollback_on_failure == true then - kill_tree(entity) - return false, "pickup_not_committed", 0, false - end local spawned_nearby = EntityGetIsAlive(entity) == true if spawned_nearby then drop_near(player, entity) end return false, "pickup_not_committed", entity, spawned_nearby @@ -123,69 +98,20 @@ local function give_existing_entity(player, entity, play_sound, options) return true, "picked", entity, false end -local function create_filled_flask(material_id, x, y) - if type(material_id) ~= "string" or material_id == "" or not valid_coordinate(x) or not valid_coordinate(y) then - return 0, "invalid" - end - local entity = EntityLoad("data/entities/items/pickup/potion_empty.xml", tonumber(x), tonumber(y)) or 0 - if entity == 0 then return 0, "load" end - pcall(RemoveMaterialInventoryMaterial, entity) - local material_added = pcall(AddMaterialInventoryMaterial, entity, material_id, 1000) - if not material_added then kill_tree(entity); return 0, "material" end - return entity, "filled" -end - --- Exact world placement used only after a catalog drag has completed. The entity is not --- created before release; world enablement and the single EW handoff are part of the same --- transaction, so a failed handoff cannot leave a half-committed item behind. -function item_service.spawn_at(path, x, y) - if type(path) ~= "string" or path == "" or not valid_coordinate(x) or not valid_coordinate(y) - or not ModDoesFileExist(path) - then - return 0, "invalid" - end - local entity = EntityLoad(path, tonumber(x), tonumber(y)) or 0 - if entity == 0 then return 0, "load" end - local committed, reason = commit_world_entity(entity) - if not committed then return 0, reason end - return entity, "spawned_" .. tostring(reason) -end - -function item_service.spawn_filled_flask_at(material_id, x, y) - local entity, reason = create_filled_flask(material_id, x, y) - if entity == 0 then return false, reason, 0, false end - local committed, sync_reason = commit_world_entity(entity) - if not committed then return false, sync_reason, 0, false end - return true, "spawned_" .. tostring(sync_reason), entity, true -end - -function item_service.give_strict(player, path, play_sound) - local entity, reason = load_near(player, path) - if entity == 0 then return false, reason, 0, false end - return give_existing_entity(player, entity, play_sound, {rollback_on_failure=true}) -end - -function item_service.give_filled_flask_strict(player, material_id) - if player == nil or player == 0 or not EntityGetIsAlive(player) then return false, "invalid", 0, false end - local player_x, player_y = EntityGetTransform(player) - if player_x == nil then return false, "position", 0, false end - local entity, reason = create_filled_flask(material_id, player_x + 12, player_y - 8) - if entity == 0 then return false, reason, 0, false end - return give_existing_entity(player, entity, true, {rollback_on_failure=true}) -end - function item_service.spawn_filled_flask(player, material_id, pick_up) if player == nil or player == 0 or not EntityGetIsAlive(player) or type(material_id) ~= "string" or material_id == "" then return false, "invalid", 0, false end local player_x, player_y = EntityGetTransform(player) if player_x == nil then return false, "position", 0, false end - local entity, reason = create_filled_flask(material_id, player_x + 12, player_y - 8) - if entity == 0 then return false, reason, 0, false end - if pick_up then return give_existing_entity(player, entity, true) end - local committed, sync_reason = commit_world_entity(entity) - if not committed then return false, sync_reason, 0, false end - return true, "spawned_synced", entity, true + local entity = EntityLoad("data/entities/items/pickup/potion_empty.xml", player_x + 12, player_y - 8) or 0 + if entity == 0 then return false, "load", 0, false end + pcall(RemoveMaterialInventoryMaterial, entity) + local material_added = pcall(AddMaterialInventoryMaterial, entity, material_id, 1000) + if not material_added then EntityKill(entity); return false, "material", 0, false end + if pick_up then return item_service.give_existing_entity(player, entity, true) end + local synced = item_service.notify_world_item(entity) + return true, synced and "spawned_synced" or "spawned", entity, true end function item_service.give(player, path, play_sound) @@ -194,7 +120,7 @@ function item_service.give(player, path, play_sound) -- committed inventory snapshot below is the single authority path for this case. local entity, reason = load_near(player, path) if entity == 0 then return false, reason, 0, false end - return give_existing_entity(player, entity, play_sound) + return item_service.give_existing_entity(player, entity, play_sound) end METAMORPH_CREATIVE_MENU_ITEM_SERVICE = item_service diff --git a/metamorph_creative_menu/files/features/items/ui_catalog.lua b/metamorph_creative_menu/files/features/items/ui_catalog.lua index 1432515..9a58825 100644 --- a/metamorph_creative_menu/files/features/items/ui_catalog.lua +++ b/metamorph_creative_menu/files/features/items/ui_catalog.lua @@ -1,7 +1,6 @@ local item_catalog = {} local ITEM_CATALOG_PATH = "mods/metamorph_creative_menu/files/features/items/catalog.lua" -local VANILLA_OUTLIERS_PATH = "mods/metamorph_creative_menu/files/features/items/vanilla_outliers.lua" local ITEM_REGISTRY_PATH = "mods/metamorph_creative_menu/files/item_registry.lua" local POTION_ICON = "data/ui_gfx/items/potion.png" @@ -77,7 +76,6 @@ local function add_entry(target, seen_paths, item, translate, translate_with_fal entry.display_name = string.match(entry.path, "([^/]+)%.xml$") or entry.path end entry.display_description = translate(entry.description) - entry.kind = entry.kind or "item" target[#target + 1] = entry end @@ -106,46 +104,6 @@ local function add_external_entries(target, seen_paths, translate, translate_wit end end -function item_catalog.prewarm_icons(asset_service) - if type(asset_service) ~= "table" or type(asset_service.prewarm) ~= "function" then return 0, 0 end - local identity = function(value) return tostring(value or "") end - local identity_fallback = function(_, fallback) return tostring(fallback or "") end - local entries, seen_paths = {}, {} - - local catalog_loaded, base_entries = pcall(dofile, ITEM_CATALOG_PATH) - if catalog_loaded and type(base_entries) == "table" then - for _, item in ipairs(base_entries) do add_entry(entries, seen_paths, item, identity, identity_fallback) end - end - local outliers_loaded, outliers = pcall(dofile, VANILLA_OUTLIERS_PATH) - if outliers_loaded and type(outliers) == "table" then - for _, item in ipairs(outliers) do add_entry(entries, seen_paths, item, identity, identity_fallback) end - end - -- External manifests are intentionally excluded from init-time prewarming. Their - -- files may not exist yet, and executing another mod's registry during our lifecycle - -- phase can freeze a transient fallback in the presentation cache. Runtime collect() - -- still discovers every active mod and resolves its icons lazily after initialization. - - local icon_paths, seen_icons = {}, {} - local entity_paths = {} - for _, item in ipairs(entries) do - local icon = tostring(item.icon or "") - if icon ~= "" and not seen_icons[icon] then - seen_icons[icon] = true - icon_paths[#icon_paths + 1] = icon - elseif icon == "" then - entity_paths[#entity_paths + 1] = item.path - end - end - - local prepared, failed = asset_service.prewarm(icon_paths) - if type(asset_service.prewarm_entity) == "function" then - for _, entity_path in ipairs(entity_paths) do - if asset_service.prewarm_entity(entity_path, "item") then prepared = prepared + 1 else failed = failed + 1 end - end - end - return prepared, failed -end - function item_catalog.collect(translate, translate_with_fallback) if cached_catalog ~= nil then return cached_catalog end translate = type(translate) == "function" and translate or tostring @@ -156,12 +114,6 @@ function item_catalog.collect(translate, translate_with_fallback) local entries = {} local seen_paths = {} for _, item in ipairs(base_entries) do add_entry(entries, seen_paths, item, translate, translate_with_fallback) end - local outliers_loaded, outliers = pcall(dofile, VANILLA_OUTLIERS_PATH) - if outliers_loaded and type(outliers) == "table" then - for _, item in ipairs(outliers) do - add_entry(entries, seen_paths, item, translate, translate_with_fallback) - end - end add_external_entries(entries, seen_paths, translate, translate_with_fallback) table.sort(entries, function(left, right) local left_name = string.lower(left.display_name or left.path) @@ -185,29 +137,17 @@ function item_catalog.entries_for(filter_index, translate, translate_with_fallba for _, item in ipairs(entries) do if filter[3] == nil or item.category == filter[3] then matches[#matches + 1] = item end end - -- ALL is a real union of the item and liquid catalogues. Liquids used to live only in - -- their dedicated filter, which made ALL incomplete and also meant a global item search - -- could never find a filled flask by material name. - if filter_index == 1 then - for _, liquid in ipairs(item_catalog.liquids(translate)) do matches[#matches + 1] = liquid end - table.sort(matches, function(left, right) - local left_name = string.lower(tostring(left.display_name or left.id or left.path or "")) - local right_name = string.lower(tostring(right.display_name or right.id or right.path or "")) - if left_name ~= right_name then return left_name < right_name end - return tostring(left.id or left.path or "") < tostring(right.id or right.path or "") - end) - end cached_filter_entries[filter_index] = matches return matches end -local function material_presentation(material_name, translate) +local function material_display_name(material_name, translate) local id_loaded, material_id = pcall(CellFactory_GetType, material_name) - if not id_loaded or material_id == nil or material_id < 0 then return material_name, "" end + if not id_loaded or material_id == nil or material_id < 0 then return material_name end local ui_name_loaded, ui_name_key = pcall(CellFactory_GetUIName, material_id) - if not ui_name_loaded or type(ui_name_key) ~= "string" or ui_name_key == "" then return material_name, "" end + if not ui_name_loaded or type(ui_name_key) ~= "string" or ui_name_key == "" then return material_name end local translated_name = translate(ui_name_key) - return (translated_name == "" or translated_name == ui_name_key) and material_name or translated_name, ui_name_key + return (translated_name == "" or translated_name == ui_name_key) and material_name or translated_name end function item_catalog.liquids(translate) @@ -223,13 +163,10 @@ function item_catalog.liquids(translate) for _, liquid_name in ipairs(liquid_names) do if type(liquid_name) == "string" and liquid_name ~= "" and liquid_name ~= "unknown" and not seen_names[liquid_name] then seen_names[liquid_name] = true - local display_name, ui_name_key = material_presentation(liquid_name, translate) entries[#entries + 1] = { id = liquid_name, - display_name = display_name, - ui_name_key = ui_name_key, + display_name = material_display_name(liquid_name, translate), icon = POTION_ICON, - kind = "liquid", } end end diff --git a/metamorph_creative_menu/files/features/items/vanilla_outliers.lua b/metamorph_creative_menu/files/features/items/vanilla_outliers.lua deleted file mode 100644 index de7c165..0000000 --- a/metamorph_creative_menu/files/features/items/vanilla_outliers.lua +++ /dev/null @@ -1,37 +0,0 @@ --- Vanilla stores these genuine ItemComponent entities under the animals tree, so a --- directory-based item catalogue misses them. Keep the explicit role correction --- separate from the generated catalogue: game updates can regenerate catalog.lua --- without silently dropping these authored collectibles again. -return { - { path="data/entities/animals/boss_alchemist/key.xml", name="$item_key", - description="$itemdesc_key_0", icon="data/ui_gfx/items/key.png", category="QUEST" }, - { path="data/entities/animals/boss_centipede/sampo.xml", name="$item_mcguffin", - description="$itemdesc_mcguffin", icon="data/ui_gfx/items/sampo.png", category="QUEST" }, - - { path="data/entities/animals/boss_centipede/rewards/gold_reward.xml", - name="$reward_gold_statue", category="BONUSES" }, - { path="data/entities/animals/boss_centipede/rewards/reward_almostpacifist.xml", - name="$reward_almostpacifist", category="BONUSES" }, - { path="data/entities/animals/boss_centipede/rewards/reward_clock.xml", - name="$reward_clock", category="BONUSES" }, - { path="data/entities/animals/boss_centipede/rewards/reward_crown.xml", - name="$reward_crown", category="BONUSES" }, - { path="data/entities/animals/boss_centipede/rewards/reward_dollar.xml", - name="$reward_dollar", category="BONUSES" }, - { path="data/entities/animals/boss_centipede/rewards/reward_kicksonly.xml", - name="$reward_kicksonly", category="BONUSES" }, - { path="data/entities/animals/boss_centipede/rewards/reward_minit.xml", - name="$reward_minit", category="BONUSES" }, - { path="data/entities/animals/boss_centipede/rewards/reward_nohit.xml", - name="$reward_nohit", category="BONUSES" }, - { path="data/entities/animals/boss_centipede/rewards/reward_nolla.xml", - name="$reward_nolla", category="BONUSES" }, - { path="data/entities/animals/boss_centipede/rewards/reward_notinkeringofwands.xml", - name="$reward_notinkeringofwands", category="BONUSES" }, - { path="data/entities/animals/boss_centipede/rewards/reward_nowands.xml", - name="$reward_nowands", category="BONUSES" }, - { path="data/entities/animals/boss_centipede/rewards/reward_peace.xml", - name="$reward_peace", category="BONUSES" }, - { path="data/entities/animals/boss_centipede/rewards/reward_sun.xml", - name="$reward_sun", category="BONUSES" }, -} diff --git a/metamorph_creative_menu/files/features/materials/brushes/brush_r0.png b/metamorph_creative_menu/files/features/materials/brushes/brush_r0.png deleted file mode 100644 index a78c4f0..0000000 Binary files a/metamorph_creative_menu/files/features/materials/brushes/brush_r0.png and /dev/null differ diff --git a/metamorph_creative_menu/files/features/materials/brushes/brush_r1.png b/metamorph_creative_menu/files/features/materials/brushes/brush_r1.png deleted file mode 100644 index 4f395be..0000000 Binary files a/metamorph_creative_menu/files/features/materials/brushes/brush_r1.png and /dev/null differ diff --git a/metamorph_creative_menu/files/features/materials/brushes/brush_r2.png b/metamorph_creative_menu/files/features/materials/brushes/brush_r2.png deleted file mode 100644 index 7a61ed6..0000000 Binary files a/metamorph_creative_menu/files/features/materials/brushes/brush_r2.png and /dev/null differ diff --git a/metamorph_creative_menu/files/features/materials/brushes/brush_r4.png b/metamorph_creative_menu/files/features/materials/brushes/brush_r4.png deleted file mode 100644 index fb90f5f..0000000 Binary files a/metamorph_creative_menu/files/features/materials/brushes/brush_r4.png and /dev/null differ diff --git a/metamorph_creative_menu/files/features/materials/brushes/brush_r6.png b/metamorph_creative_menu/files/features/materials/brushes/brush_r6.png deleted file mode 100644 index 6a2e857..0000000 Binary files a/metamorph_creative_menu/files/features/materials/brushes/brush_r6.png and /dev/null differ diff --git a/metamorph_creative_menu/files/features/materials/catalog.lua b/metamorph_creative_menu/files/features/materials/catalog.lua deleted file mode 100644 index 9de0711..0000000 --- a/metamorph_creative_menu/files/features/materials/catalog.lua +++ /dev/null @@ -1,326 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_MATERIAL_CATALOG) == "table" then return METAMORPH_CREATIVE_MENU_MATERIAL_CATALOG end - -local material_catalog = {} - --- Noita's CellFactory cell classes are implementation details, not useful UI categories. --- In particular, authored/static terrain is commonly backed by CellData with --- cell_type="liquid", while Box2D materials are reported as solids. The base game's own --- materials.xml documents semantic tags for the intended families ([liquid], [static], --- [sand_*], [gas], [fire], [box2d], [hax]). Prefer those tags and use the enumeration API --- only as a compatibility fallback for third-party materials that omit semantic tags. -local ENUMERATORS = { - { id="LIQUIDS", api="CellFactory_GetAllLiquids" }, - { id="SANDS", api="CellFactory_GetAllSands" }, - { id="GASES", api="CellFactory_GetAllGases" }, - { id="FIRES", api="CellFactory_GetAllFires" }, - { id="SOLIDS", api="CellFactory_GetAllSolids" }, - { id="SPECIAL", api="vanilla_definitions" }, -} - -local ALL_ENUMERATOR_IDS = { 1, 2, 3, 4, 5, 6 } -local CATEGORY_DEFINITIONS = { - { id="ALL", key="$mcm_material_filter_all", fallback="ALL", sources=ALL_ENUMERATOR_IDS, - icon="data/ui_gfx/items/material_pouch.png" }, - { id="LIQUIDS", key="$mcm_material_filter_liquids", fallback="LIQUIDS", sources=ALL_ENUMERATOR_IDS, - icon="data/ui_gfx/items/potion.png" }, - { id="SANDS", key="$mcm_material_filter_sands", fallback="POWDERS", sources=ALL_ENUMERATOR_IDS, - icon="data/ui_gfx/items/material_pouch.png" }, - { id="STATIC", key="$mcm_material_filter_static", fallback="STATIC TERRAIN", sources=ALL_ENUMERATOR_IDS, - icon="data/ui_gfx/items/material_pouch.png" }, - { id="GASES", key="$mcm_material_filter_gases", fallback="GASES", sources=ALL_ENUMERATOR_IDS, - icon="data/ui_gfx/items/material_pouch.png" }, - { id="FIRES", key="$mcm_material_filter_fires", fallback="FIRES", sources=ALL_ENUMERATOR_IDS, - icon="data/ui_gfx/items/thunderstone.png" }, - { id="SOLIDS", key="$mcm_material_filter_solids", fallback="PHYSICS SOLIDS", sources=ALL_ENUMERATOR_IDS, - icon="data/ui_gfx/items/material_pouch.png" }, - { id="SPECIAL", key="$mcm_material_filter_special", fallback="SPECIAL", sources=ALL_ENUMERATOR_IDS, - icon="data/ui_gfx/items/material_pouch.png" }, -} - -local definitions_by_id = {} -for _, definition in ipairs(CATEGORY_DEFINITIONS) do definitions_by_id[definition.id] = definition end - -local states = nil -local all_entries = nil -local by_id = nil -local source_cache = {} - -local function reset_state() - states, all_entries, by_id = {}, {}, {} - for index = 1, #CATEGORY_DEFINITIONS do - local definition = CATEGORY_DEFINITIONS[index] - states[definition.id] = { - source_cursor=1, raw=nil, cursor=1, entries={}, seen={}, done=false, - } - end - source_cache = {} -end -reset_state() - -local function translate_material(material_name, material_type, translate) - local ok_name, ui_key = pcall(CellFactory_GetUIName, material_type) - if not ok_name or type(ui_key) ~= "string" or ui_key == "" then return tostring(material_name), nil end - local translated = type(translate) == "function" and translate(ui_key) or ui_key - if type(translated) ~= "string" or translated == "" or translated == ui_key then return tostring(material_name), ui_key end - return translated, ui_key -end - -local function collect_vanilla_definitions() - local source = {values={}, static={}, particle={}, families={}, from_definitions=true} - if type(ModTextFileGetContent) ~= "function" then return source end - local ok, content = pcall(ModTextFileGetContent, "data/materials.xml") - if not ok or type(content) ~= "string" then return source end - content = content:gsub("", "") - local seen = {} - for block in content:gmatch("]+)>") do - local attrs = {} - for key, value in block:gmatch('([%w_]+)%s*=%s*"([^"]*)"') do attrs[key] = value end - local name = attrs.name - if name and not seen[name] then - source.values[#source.values + 1] = name - seen[name] = true - end - if name then - source.static[name] = attrs.liquid_static == "1" or nil - source.families[name] = ({liquid=attrs.liquid_sand == "1" and "SANDS" or "LIQUIDS", - gas="GASES", fire="FIRES", solid="SOLIDS"})[attrs.cell_type] - end - end - return source -end - -local function collect_from_api(api_name) - if source_cache[api_name] ~= nil then return source_cache[api_name] end - if api_name == "vanilla_definitions" then - local source = collect_vanilla_definitions() - source_cache[api_name] = source - return source - end - local callback = _G[api_name] - local source = { values={}, static={}, particle={} } - if type(callback) ~= "function" then return source end - local ok, values = pcall(callback, true, true) - if not ok or type(values) ~= "table" then ok, values = pcall(callback) end - source.values = ok and type(values) == "table" and values or {} - -- The API explicitly separates statics and particle-FX materials. Use those - -- differences for materials without semantic tags, including third-party terrain. - local normal_ok, normal = pcall(callback, true, false) - local dynamic_ok, dynamic = pcall(callback, false, false) - if normal_ok and type(normal) == "table" then - local normal_set, dynamic_set = {}, {} - for _, name in ipairs(normal) do normal_set[name] = true end - if dynamic_ok and type(dynamic) == "table" then - for _, name in ipairs(dynamic) do dynamic_set[name] = true end - for _, name in ipairs(normal) do - if not dynamic_set[name] then source.static[name] = true end - end - end - for _, name in ipairs(source.values) do - if not normal_set[name] then source.particle[name] = true end - end - end - source_cache[api_name] = source - return source -end - -local function tag_set(material_type) - local result = {} - if type(CellFactory_GetTags) == "function" then - local ok, tags = pcall(CellFactory_GetTags, material_type) - if ok and type(tags) == "table" then - for _, tag in ipairs(tags) do - -- Vanilla returns literal [static]/[liquid]/[hax] tokens. Accept - -- unwrapped tokens too for compatibility with API wrappers. - local name = tostring(tag):match("^%s*(.-)%s*$") - name = name:match("^%[(.-)%]$") or name - result[name] = true - end - end - end - return result -end - -local function has_sand_tag(tags) - return tags.sand_ground == true or tags.sand_metal == true or tags.sand_other == true -end - -local function semantic_category(material_type, fallback_category, source, material_name) - local tags = tag_set(material_type) - -- Explicit special/hax tags win before fallback. The other order mirrors the - -- semantic grouping documented at the top of data/materials.xml. - if tags.hax == true or source.particle[material_name] then return "SPECIAL", tags end - if tags.static == true or source.static[material_name] then return "STATIC", tags end - if tags.liquid == true then return "LIQUIDS", tags end - if has_sand_tag(tags) then return "SANDS", tags end - if tags.gas == true then return "GASES", tags end - if tags.fire == true then return "FIRES", tags end - if tags.box2d == true then return "SOLIDS", tags end - -- Mod materials are not required to use vanilla semantic tags. Keep them usable by - -- falling back to the CellFactory family that actually reported the material. - if fallback_category ~= nil and fallback_category ~= "ALL" then return fallback_category, tags end - return "SPECIAL", tags -end - -local function ensure_source(definition, state) - while not state.done and state.raw == nil do - local source_slot = definition.sources and definition.sources[state.source_cursor] or nil - if source_slot == nil then - state.done = true - break - end - local enumerator = ENUMERATORS[source_slot] - state.source_cursor = state.source_cursor + 1 - if enumerator ~= nil then - state.source = collect_from_api(enumerator.api) - state.raw = state.source.values - state.raw_source = enumerator.id - state.cursor = 1 - if #state.raw == 0 then state.raw = nil end - end - end -end - -local function entry_for(material_name, material_type, primary_category, tags, translate, icon) - local entry = by_id[material_name] - if entry == nil then - local display_name, name_key = translate_material(material_name, material_type, translate) - entry = { - id=material_name, - material_type=material_type, - name_key=name_key, - display_name=display_name, - category=primary_category, - categories={}, - material_tags=tags, - icon=icon, - } - entry.categories[primary_category] = true - by_id[material_name] = entry - all_entries[#all_entries + 1] = entry - else - entry.categories[primary_category] = true - if entry.category == nil or entry.category == "SPECIAL" then entry.category = primary_category end - end - return entry -end - -local function process_category(definition, translate, budget) - local state = states[definition.id] - if state == nil or state.done then return 0, true end - local used = 0 - budget = math.max(1, math.floor(tonumber(budget) or 1)) - - while not state.done and used < budget do - ensure_source(definition, state) - if state.done then break end - - local raw_name = state.raw[state.cursor] - state.cursor = state.cursor + 1 - used = used + 1 - if raw_name ~= nil then - local material_name = tostring(raw_name or "") - if material_name ~= "" and not state.seen[material_name] then - state.seen[material_name] = true - local ok_type, material_type = pcall(CellFactory_GetType, material_name) - material_type = ok_type and tonumber(material_type) or nil - -- Unknown material names can resolve to air (0). XML is only a - -- discovery fallback: never offer an unloaded definition as paintable. - local loaded = material_type ~= nil and material_type >= 0 - if loaded and state.source.from_definitions then - loaded = material_type ~= 0 or material_name == "air" - if loaded and type(CellFactory_GetName) == "function" then - local ok_name, resolved = pcall(CellFactory_GetName, material_type) - loaded = ok_name and resolved == material_name - end - end - if loaded then - material_type = math.floor(material_type) - local primary_category, tags = semantic_category(material_type, state.source.families and state.source.families[material_name] or state.raw_source, state.source, material_name) - if definition.id == "ALL" or primary_category == definition.id then - local entry = entry_for(material_name, material_type, primary_category, tags, translate, definition.icon) - state.entries[#state.entries + 1] = entry - end - end - end - end - - if state.cursor > #(state.raw or {}) then - state.raw = nil - state.raw_source = nil - state.cursor = 1 - end - end - ensure_source(definition, state) - return used, state.done -end - -local function sort_entries(values) - table.sort(values, function(a, b) - local an = string.lower(tostring(a.display_name or a.id)) - local bn = string.lower(tostring(b.display_name or b.id)) - if an == bn then return tostring(a.id) < tostring(b.id) end - return an < bn - end) -end - -function material_catalog.step(category_id, translate, budget) - category_id = tostring(category_id or "ALL") - budget = math.max(1, math.floor(tonumber(budget) or 32)) - if category_id ~= "ALL" then - local definition = definitions_by_id[category_id] - if definition == nil then return true, 0 end - local state = states[category_id] - local was_done = state.done - local used, done = process_category(definition, translate, budget) - if done and not was_done then sort_entries(state.entries) end - return done, used - end - - local definition = definitions_by_id.ALL - local state = states.ALL - local was_done = state.done - local used, done = process_category(definition, translate, budget) - if done and not was_done then - sort_entries(state.entries) - sort_entries(all_entries) - end - return done, used -end - -function material_catalog.is_ready(category_id) - category_id = tostring(category_id or "ALL") - return states[category_id] ~= nil and states[category_id].done == true -end - --- Synchronous compatibility API for tests/tools. UI code must use step() so merely --- opening MATERIALS never performs a full all-material scan in one frame. -function material_catalog.collect(translate) - local guard = 0 - while not material_catalog.is_ready("ALL") and guard < 10000 do - material_catalog.step("ALL", translate, 512) - guard = guard + 1 - end - return states.ALL.entries -end - -function material_catalog.categories() - local result = {} - for index, category in ipairs(CATEGORY_DEFINITIONS) do result[index] = category end - return result -end - -function material_catalog.entries_for(category_id) - category_id = tostring(category_id or "ALL") - return states[category_id] and states[category_id].entries or {} -end - -function material_catalog.get(material_id) - return by_id[tostring(material_id or "")] -end - -function material_catalog.reset_cache() - reset_state() -end - -METAMORPH_CREATIVE_MENU_MATERIAL_CATALOG = material_catalog -return material_catalog diff --git a/metamorph_creative_menu/files/features/materials/painter.lua b/metamorph_creative_menu/files/features/materials/painter.lua deleted file mode 100644 index 8797be4..0000000 --- a/metamorph_creative_menu/files/features/materials/painter.lua +++ /dev/null @@ -1,302 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_MATERIAL_PAINTER) == "table" then return METAMORPH_CREATIVE_MENU_MATERIAL_PAINTER end - -local painter = {} - -local input_guard = dofile("mods/metamorph_creative_menu/files/platform/noita/input_guard.lua") -local action_bindings = dofile("mods/metamorph_creative_menu/files/platform/noita/action_bindings.lua") -local grid_backend = dofile("mods/metamorph_creative_menu/files/platform/noita/material_grid.lua") -local ew_material_sync = dofile("mods/metamorph_creative_menu/files/integrations/ew/material_paint_sync.lua") - -local BRUSHES = { - { radius=0, file="mods/metamorph_creative_menu/files/features/materials/brushes/brush_r0.png" }, - { radius=1, file="mods/metamorph_creative_menu/files/features/materials/brushes/brush_r1.png" }, - { radius=2, file="mods/metamorph_creative_menu/files/features/materials/brushes/brush_r2.png" }, - { radius=4, file="mods/metamorph_creative_menu/files/features/materials/brushes/brush_r4.png" }, - { radius=6, file="mods/metamorph_creative_menu/files/features/materials/brushes/brush_r6.png" }, -} -local MATERIAL_MASK_COLOR = "ff5ac75a" -local MAX_CELLS_PER_FRAME = 1400 --- Eight stamps cover normal player/camera movement even with the one-pixel brush while --- bounding proxy messages and relay components during a fast cursor jump. -local MAX_STAMPS_PER_FRAME = 8 -local REPEAT_SYNC_INTERVAL = 6 -local MIDDLE_MOUSE_FALLBACK = 3 - -local selected_material = "water" -local selected_force_scene = false -local brush_index = 3 -local armed = false -local world_mode_seen = false -local last_x, last_y = nil, nil -local last_published_x, last_published_y = nil, nil -local last_published_frame = -100000 -local brush_offsets = {} -local paint_button = nil -local last_runtime_error_frame = -100000 - -local function translated(key, fallback) - if type(GameTextGetTranslatedOrNot) == "function" then - local ok, value = pcall(GameTextGetTranslatedOrNot, key) - if ok and type(value) == "string" and value ~= "" and value ~= key then return value end - end - return fallback -end - -local function frame_number() - if type(GameGetFrameNum) ~= "function" then return 0 end - local ok, value = pcall(GameGetFrameNum) - return ok and (tonumber(value) or 0) or 0 -end - -local function resolve_paint_button() - if paint_button ~= nil then return paint_button end - if type(Mouse_middle) == "number" then paint_button = Mouse_middle; return paint_button end - if type(MOUSE_MIDDLE) == "number" then paint_button = MOUSE_MIDDLE; return paint_button end - if type(ModTextFileGetContent) == "function" then - local ok, text = pcall(ModTextFileGetContent, "data/scripts/debug/keycodes.lua") - if ok and type(text) == "string" then - local value = text:match("Mouse_middle%s*=%s*(%d+)") or text:match("MOUSE_MIDDLE%s*=%s*(%d+)") - if value ~= nil then paint_button = tonumber(value) end - end - end - paint_button = paint_button or MIDDLE_MOUSE_FALLBACK - return paint_button -end - -local function mouse_down() - return action_bindings.is_down("paint_draw") -end - -local function mouse_world() - if type(DEBUG_GetMouseWorld) ~= "function" then return nil, nil end - local ok, x, y = pcall(DEBUG_GetMouseWorld) - if not ok then return nil, nil end - x, y = tonumber(x), tonumber(y) - if x == nil or y == nil then return nil, nil end - return math.floor(x + 0.5), math.floor(y + 0.5) -end - -local function inventory_open() - if type(GameIsInventoryOpen) ~= "function" then return false end - local ok, value = pcall(GameIsInventoryOpen) - return ok and value == true -end - -local function valid_player(player_entity_id) - player_entity_id = tonumber(player_entity_id) or 0 - if player_entity_id == 0 then return false end - if type(EntityGetIsAlive) ~= "function" then return true end - local ok, alive = pcall(EntityGetIsAlive, player_entity_id) - return ok and alive == true -end - -local function current_brush() return BRUSHES[brush_index] or BRUSHES[1] end -local function current_radius() return current_brush().radius end - -local function offsets_for_radius(radius) - local cached = brush_offsets[radius] - if cached ~= nil then return cached end - local values = {} - for dy = -radius, radius do - for dx = -radius, radius do - if dx * dx + dy * dy <= radius * radius then values[#values + 1] = { dx, dy } end - end - end - if #values == 0 then values[1] = { 0, 0 } end - brush_offsets[radius] = values - return values -end - -local function reset_stroke() - last_x, last_y = nil, nil - last_published_x, last_published_y = nil, nil - last_published_frame = -100000 -end - -local function runtime_failure(reason) - local frame = frame_number() - if frame - last_runtime_error_frame >= 300 then - last_runtime_error_frame = frame - if type(GamePrint) == "function" then - pcall(GamePrint, translated("$mcm_material_stopped", "Material brush stopped") - .. ": " .. tostring(reason or "backend")) - end - end - painter.set_enabled(false) -end - -local function stamp(context, x, y, budget) - local brush = current_brush() - local offsets = offsets_for_radius(brush.radius) - if context.mode == "solid_scene" then - if budget < #offsets then return false, 0, nil, false end - local ok, reason = grid_backend.paint_solid_scene_prepared(context, brush.file, - x - brush.radius, y - brush.radius, MATERIAL_MASK_COLOR) - if ok then return true, #offsets, nil, true end - if reason == "unloaded" then return false, #offsets, nil, false end - return false, #offsets, reason, true - end - - local painted, used = false, 0 - local needs_scene_fallback = false - local attempted = false - for _, offset in ipairs(offsets) do - if used >= budget then break end - local ok, reason = grid_backend.paint_cell_prepared(context, x + offset[1], y + offset[2]) - used = used + 1 - if ok then - painted = true - attempted = true - elseif reason == "construct" then - -- Some valid material definitions cannot be constructed at particular - -- texture coordinates. PixelScene placement is Noita's general authored - -- fallback and works for dynamic materials too. - needs_scene_fallback = true - attempted = true - elseif reason ~= "unloaded" and reason ~= "construct" then - return painted, used, reason, true - end - end - if needs_scene_fallback and budget >= #offsets - and type(grid_backend.paint_scene_prepared) == "function" - then - local fallback_ok, fallback_reason = grid_backend.paint_scene_prepared(context, - brush.file, x - brush.radius, y - brush.radius, MATERIAL_MASK_COLOR) - if fallback_ok then return true, math.max(used, #offsets), nil, true end - if fallback_reason ~= "unloaded" then return painted, used, fallback_reason, true end - end - return painted, used, nil, attempted -end - - -function painter.set_material(material_name, options) - material_name = tostring(material_name or "") - if grid_backend.material_id(material_name) == nil then return false, "invalid_material" end - options = type(options) == "table" and options or {} - selected_material = material_name - -- The catalog's CellFactory_GetAllSolids membership is the strongest semantic hint - -- available from the public API. Keep native CellData inspection as a fallback so - -- callers that select a material by id directly still work. - selected_force_scene = options.solid == true - reset_stroke() - return true -end - -function painter.get_material() - if grid_backend.material_id(selected_material) ~= nil then return selected_material end - return nil -end - -function painter.material_color(material_name) - if type(grid_backend.material_color) ~= "function" then return nil end - return grid_backend.material_color(material_name) -end - -function painter.brush_radius() return current_radius() end -function painter.brush_diameter() return current_radius() * 2 + 1 end - -function painter.set_brush_index(index) - index = math.floor(tonumber(index) or brush_index) - brush_index = math.max(1, math.min(#BRUSHES, index)) - reset_stroke() - return painter.brush_diameter() -end - -function painter.adjust_brush(delta) return painter.set_brush_index(brush_index + (tonumber(delta) or 0)) end -function painter.paint_button() - local value = action_bindings.get("paint_draw") - local mouse = type(value) == "string" and string.match(value, "Mouse:(%-?%d+)$") or nil - return tonumber(mouse) or resolve_paint_button() -end - -function painter.set_enabled(enabled) - enabled = enabled == true - if enabled then - if painter.get_material() == nil then return false, "invalid_material" end - local ok, reason = grid_backend.status() - if not ok then return false, reason or "backend" end - end - armed = enabled - world_mode_seen = false - reset_stroke() - return armed, armed and "enabled" or "disabled" -end - -function painter.is_enabled() return armed == true end -function painter.reset_stroke() reset_stroke() end - --- Called from OnWorldPreUpdate. The default middle-mouse paint binding avoids competing --- with Noita's native primary/secondary fire. Liquids/powders/gases/fires use direct --- cells; textured solids use a tiny PixelScene mask because construct_cell may --- legitimately return nil for solid texture holes at a world coordinate. -function painter.update(player_entity_id) - if not armed then reset_stroke(); return false, "inactive" end - if not valid_player(player_entity_id) then painter.set_enabled(false); return false, "player" end - if inventory_open() then - reset_stroke() - if world_mode_seen then painter.set_enabled(false); return false, "menu_reopened" end - return false, "armed_in_menu" - end - if type(input_guard.actions_allowed) == "function" and input_guard.actions_allowed() ~= true then - reset_stroke(); return false, "input_blocked" - end - - world_mode_seen = true - if not mouse_down() then reset_stroke(); return false, "idle" end - - local material_name = painter.get_material() - if material_name == nil then runtime_failure("invalid material"); return false, "material" end - local x, y = mouse_world() - if x == nil or y == nil then reset_stroke(); return false, "mouse" end - local paint_context, context_reason = grid_backend.begin_paint(material_name, { force_scene=selected_force_scene }) - if paint_context == nil then runtime_failure(context_reason or "grid backend"); return false, context_reason or "backend" end - - local frame = frame_number() - local radius = current_radius() - local spacing = math.max(1, math.floor(radius * 0.75 + 1)) - local from_x, from_y = last_x or x, last_y or y - local dx, dy = x - from_x, y - from_y - local distance = math.sqrt(dx * dx + dy * dy) - local needed_steps = math.max(1, math.ceil(distance / spacing)) - local steps = math.min(MAX_STAMPS_PER_FRAME, needed_steps) - - local budget, any = MAX_CELLS_PER_FRAME, false - local published_points = {} - local progress_x, progress_y = from_x, from_y - for step = 1, steps do - if budget <= 0 then break end - -- If a cursor jump exceeds the bounded per-frame budget, continue from the last - -- processed point next frame instead of skipping the unsent middle of the line. - local t = step / needed_steps - local sx = math.floor(from_x + dx * t + 0.5) - local sy = math.floor(from_y + dy * t + 0.5) - local painted, used, fatal, attempted = stamp(paint_context, sx, sy, budget) - budget = budget - used - any = painted or any - progress_x, progress_y = sx, sy - -- Publish the requested stamp even when a valid material could not be - -- constructed in this exact texture cell. The stock-EW relay has its own - -- Pixel/particle creation path and must receive the user's intent as well. - if attempted and (sx ~= last_published_x or sy ~= last_published_y - or frame - last_published_frame >= REPEAT_SYNC_INTERVAL) - then - published_points[#published_points + 1] = { sx, sy } - last_published_x, last_published_y = sx, sy - last_published_frame = frame - end - if fatal ~= nil then runtime_failure(fatal); return any, fatal end - end - if #published_points > 0 and type(ew_material_sync.publish_batch) == "function" then - -- Publish the backend's actual terrain mode, not just the catalog hint. CellData - -- can classify authored/static terrain as scene-only even when a caller selected - -- the material by id and did not pass the public SOLIDS hint. - ew_material_sync.publish_batch(material_name, paint_context.mode == "solid_scene", - brush_index, published_points) - end - if steps >= needed_steps then last_x, last_y = x, y - else last_x, last_y = progress_x, progress_y end - return any, any and "painted" or "world" -end - -METAMORPH_CREATIVE_MENU_MATERIAL_PAINTER = painter -return painter diff --git a/metamorph_creative_menu/files/features/perks/nested_pickups.lua b/metamorph_creative_menu/files/features/perks/nested_pickups.lua index 748e33e..6f9a63b 100644 --- a/metamorph_creative_menu/files/features/perks/nested_pickups.lua +++ b/metamorph_creative_menu/files/features/perks/nested_pickups.lua @@ -35,20 +35,6 @@ function nested_pickups.open_gamble_scope(player_entity_id, parent_transaction_i return true end -function nested_pickups.scope_open(player_entity_id, parent_transaction_id) - local scope = scopes_by_player[tonumber(player_entity_id) or 0] - if type(scope) ~= "table" then return false end - if tonumber(parent_transaction_id) ~= nil and tonumber(scope.parent_transaction_id) ~= tonumber(parent_transaction_id) then - return false - end - local now = frame_now() - if now > (tonumber(scope.expires_frame) or now) or (tonumber(scope.remaining) or 0) <= 0 then - scopes_by_player[tonumber(player_entity_id) or 0] = nil - return false - end - return true -end - function nested_pickups.claim_parent(player_entity_id) player_entity_id = tonumber(player_entity_id) or 0 local scope = scopes_by_player[player_entity_id] diff --git a/metamorph_creative_menu/files/features/perks/non_perk_states.lua b/metamorph_creative_menu/files/features/perks/non_perk_states.lua deleted file mode 100644 index 8665ef6..0000000 --- a/metamorph_creative_menu/files/features/perks/non_perk_states.lua +++ /dev/null @@ -1,203 +0,0 @@ --- Removable player states that belong next to perks in the UI but are not perks. --- Keep them out of perk_list/perk transactions: these are vanilla essence/curse mechanics --- with their own counters, run flags and child entities. -local states = {} - -local ESSENCES = { - { id="laser", name_key="$item_essence_laser", count_key="ESSENCE_LASER_PICKUP_COUNT" }, - { id="fire", name_key="$item_essence_fire", count_key="ESSENCE_FIRE_PICKUP_COUNT" }, - { id="water", name_key="$item_essence_water", count_key="ESSENCE_WATER_PICKUP_COUNT" }, - { id="air", name_key="$item_essence_air", count_key="ESSENCE_AIR_PICKUP_COUNT" }, - { id="alcohol", name_key="$item_essence_alcohol", count_key="ESSENCE_ALCOHOL_PICKUP_COUNT" }, -} -local BY_ID = {} -for _, entry in ipairs(ESSENCES) do BY_ID[entry.id] = entry end - -local function valid(entity) - if entity == nil or entity == 0 or type(EntityGetIsAlive) ~= "function" then return false end - local ok, alive = pcall(EntityGetIsAlive, entity) - return ok and alive == true -end - -local function children(player) - if not valid(player) or type(EntityGetAllChildren) ~= "function" then return {} end - local ok, result = pcall(EntityGetAllChildren, player) - return ok and type(result) == "table" and result or {} -end - -local function has_tag(entity, tag) - if type(EntityHasTag) ~= "function" then return false end - local ok, value = pcall(EntityHasTag, entity, tag) - return ok and value == true -end - -local function filename(entity) - if type(EntityGetFilename) ~= "function" then return "" end - local ok, value = pcall(EntityGetFilename, entity) - return ok and tostring(value or "") or "" -end - -local function component_value(component, field) - if component == nil or component == 0 then return nil end - if type(ComponentGetValue2) == "function" then - local ok, value = pcall(ComponentGetValue2, component, field) - if ok then return value end - end - if type(ComponentGetValue) == "function" then - local ok, value = pcall(ComponentGetValue, component, field) - if ok then return value end - end - return nil -end - -local function icon_name(entity) - if type(EntityGetFirstComponentIncludingDisabled) ~= "function" then return nil end - local ok, component = pcall(EntityGetFirstComponentIncludingDisabled, entity, "UIIconComponent") - if not ok then return nil end - return component_value(component, "name") -end - -local function essence_child_kind(child, id) - if not has_tag(child, "essence_effect") then return nil end - if filename(child) == "data/entities/misc/essences/" .. id .. ".xml" then return "effect" end - if tostring(icon_name(child) or "") == "$item_essence_" .. id then return "icon" end - return nil -end - -local function essence_child_count(player, id) - local count = 0 - for _, child in ipairs(children(player)) do - if essence_child_kind(child, id) == "effect" then count = count + 1 end - end - return count -end - -local function run_flag(flag) - if type(GameHasFlagRun) ~= "function" then return false end - local ok, value = pcall(GameHasFlagRun, flag) - return ok and value == true -end - -local function global_count(key) - if type(GlobalsGetValue) ~= "function" then return 0 end - local ok, value = pcall(GlobalsGetValue, key, "0") - return ok and math.max(0, math.floor(tonumber(value) or 0)) or 0 -end - -local function retire_child(child) - if not valid(child) then return true end - if type(EntityRemoveFromParent) == "function" then pcall(EntityRemoveFromParent, child) end - if type(EntityKill) ~= "function" then return false end - return pcall(EntityKill, child) -end - -local function reverse_fire_multiplier(player, copies) - copies = math.max(0, math.floor(tonumber(copies) or 0)) - if copies == 0 or type(EntityGetComponent) ~= "function" then return true end - local factor = 1.3 ^ copies - local ok_components, components = pcall(EntityGetComponent, player, "DamageModelComponent") - if not ok_components or type(components) ~= "table" then return true end - for _, component in ipairs(components) do - for _, field in ipairs({"projectile", "explosion"}) do - local ok_read, raw = pcall(ComponentObjectGetValue, component, "damage_multipliers", field) - local value = ok_read and tonumber(raw) or nil - if value ~= nil and type(ComponentObjectSetValue) == "function" then - local target = value / factor - local ok_write = pcall(ComponentObjectSetValue, component, "damage_multipliers", field, tostring(target)) - if not ok_write then return false end - end - end - end - return true -end - -local function remove_essence(player, entry, remove_all) - if not valid(player) then return false, "player" end - local count = global_count(entry.count_key) - local effects, icons = {}, {} - for _, child in ipairs(children(player)) do - local kind = essence_child_kind(child, entry.id) - if kind == "effect" then effects[#effects + 1] = child - elseif kind == "icon" then icons[#icons + 1] = child end - end - local copies = math.max(count, #effects, #icons) - if copies == 0 and run_flag("essence_" .. entry.id) then copies = 1 end - if copies == 0 then return false, "not_active" end - local removed = remove_all and copies or 1 - -- Vanilla fire pickup scales both fields once per copy. Use its counter for the - -- inverse; entity/flag-only recovery cannot prove an unrecorded multiplier exists. - local fire_copies = remove_all and count or math.min(count, 1) - if entry.id == "fire" and not reverse_fire_multiplier(player, fire_copies) then - return false, "fire_multiplier" - end - for _, group in ipairs({effects, icons}) do - local limit = remove_all and #group or math.min(#group, 1) - for index = 1, limit do - if not retire_child(group[index]) then return false, "child_cleanup" end - end - end - local remaining = math.max(0, copies - removed) - if type(GlobalsSetValue) == "function" then pcall(GlobalsSetValue, entry.count_key, tostring(remaining)) end - if remaining == 0 and type(GameRemoveFlagRun) == "function" then - pcall(GameRemoveFlagRun, "essence_" .. entry.id) - end - -- Persistent discovery flags belong to progression, not the active effect. - return true, "essence_removed" -end - -local function greed_active(player) - if run_flag("greed_curse") and not run_flag("greed_curse_gone") then return true end - for _, child in ipairs(children(player)) do if has_tag(child, "greed_curse") then return true end end - return false -end - -local function remove_greed(player) - if not valid(player) then return false, "player" end - for _, child in ipairs(children(player)) do - if has_tag(child, "greed_curse") and not retire_child(child) then return false, "child_cleanup" end - end - if type(GameRemoveFlagRun) == "function" then pcall(GameRemoveFlagRun, "greed_curse") end - -- This is the same runtime terminal marker used by the vanilla Greed Crystal. It - -- prevents biome/chest scripts from reactivating curse behaviour after the child dies. - if type(GameAddFlagRun) == "function" then pcall(GameAddFlagRun, "greed_curse_gone") end - return true, "greed_removed" -end - -function states.list(player) - local result = {} - for _, entry in ipairs(ESSENCES) do - local count = global_count(entry.count_key) - local child_count = essence_child_count(player, entry.id) - if count > 0 or child_count > 0 or run_flag("essence_" .. entry.id) then - result[#result + 1] = { - id="essence_" .. entry.id, kind="essence", essence_id=entry.id, - name_key=entry.name_key, count=math.max(count, child_count, 1), - description_key="$itemdesc_essence_" .. entry.id, - icon="data/ui_gfx/essence_icons/" .. entry.id .. ".png", - } - end - end - if greed_active(player) then - result[#result + 1] = { id="greed_curse", kind="curse", name_key="$item_essence_greed", count=1, - description_key="$itemdesc_essence_greed", icon="data/items_gfx/greed_curse.png" } - end - return result -end - -function states.remove(player, state_id) - local id = tostring(state_id or "") - local essence_id = string.match(id, "^essence_(.+)$") - if essence_id ~= nil and BY_ID[essence_id] ~= nil then return remove_essence(player, BY_ID[essence_id], true) end - if id == "greed_curse" then return remove_greed(player) end - return false, "unknown_state" -end - -function states.remove_one(player, state_id) - local id = tostring(state_id or "") - local essence_id = string.match(id, "^essence_(.+)$") - if essence_id ~= nil and BY_ID[essence_id] ~= nil then return remove_essence(player, BY_ID[essence_id], false) end - if id == "greed_curse" then return remove_greed(player) end - return false, "unknown_state" -end - -return states diff --git a/metamorph_creative_menu/files/features/perks/service.lua b/metamorph_creative_menu/files/features/perks/service.lua index 677cc51..60fcb2e 100644 --- a/metamorph_creative_menu/files/features/perks/service.lua +++ b/metamorph_creative_menu/files/features/perks/service.lua @@ -6,8 +6,6 @@ local transactions = dofile("mods/metamorph_creative_menu/files/features/perks/t local root_companions = dofile("mods/metamorph_creative_menu/files/features/perks/root_companions.lua") local nested_pickups = dofile("mods/metamorph_creative_menu/files/features/perks/nested_pickups.lua") local locomotion_guard = dofile("mods/metamorph_creative_menu/files/features/perks/locomotion_guard.lua") -local ew_world_items = dofile("mods/metamorph_creative_menu/files/integrations/ew/world_items.lua") -local ew_perk_visibility = dofile("mods/metamorph_creative_menu/files/integrations/ew/perk_visibility.lua") local function valid(entity) return entity ~= nil and entity ~= 0 and EntityGetIsAlive(entity) @@ -15,17 +13,6 @@ end local last_human_player_entity_id = nil local failed_pickup_rollbacks = {} -local FAILED_ROLLBACK_RETRY_INITIAL_FRAMES = 30 -local FAILED_ROLLBACK_RETRY_MAX_FRAMES = 300 -local FAILED_ROLLBACK_RETRY_BUDGET = 2 -local BATCH_OPERATION_BUDGET = 4 -local BATCH_REMOVE_LIMIT = 1000 -local ew_runtime = nil - -local function inventory_sync() - if ew_runtime == nil then ew_runtime = dofile("mods/metamorph_creative_menu/files/integrations/ew/runtime.lua") end - return ew_runtime.force_inventory_sync() -end local function is_polymorphed_player(entity_id) if not valid(entity_id) or type(EntityHasTag) ~= "function" then return false end @@ -123,8 +110,7 @@ local function revert_nested_children(player, parent_transaction_id) return true, "nested_children_reverted:" .. tostring(#children) end -function perk_service.remove_one(player, perk, options) - options = type(options) == "table" and options or {} +function perk_service.remove_one(player, perk) if not valid(player) or type(perk) ~= "table" or type(perk.id) ~= "string" then return false, "target" end local current = perk_service.count(perk.id) if current <= 0 then return false, "none" end @@ -153,8 +139,9 @@ function perk_service.remove_one(player, perk, options) else strategy = inverse_reason or "tracked_inverse" end - -- Structural tracking does not snapshot arbitrary Globals/run flags. Run only - -- inverses that own such state, so one stack cannot consume another stack's effect. + -- Structural tracking deliberately does not snapshot arbitrary Globals/run + -- flags. Only inverses which own such state run after the exact delta. Calling + -- every inverse here used to consume a second RESPAWN effect from the next stack. if type(inverses.post_tracked_cleanup) == "function" then pcall(inverses.post_tracked_cleanup, player, perk.id, current) end @@ -199,20 +186,14 @@ function perk_service.remove_one(player, perk, options) if type(locomotion_guard.repair_if_idle) == "function" then pcall(locomotion_guard.repair_if_idle, player, type(transactions.active_count)=="function" and transactions.active_count() or 0) end - -- Only tracked MCM-created copies are hidden from EW's global-perk advertisement. - -- Ordinary vanilla pickups retain EW's native semantics, and untracked copies are - -- deliberately not hidden because we could not later prove when that exact layer left. - if options.defer_sync ~= true then pcall(ew_perk_visibility.refresh, perk.id, player, transactions) end return true, strategy, new_count end function perk_service.remove_all(player, perk) - -- Synchronous compatibility path used by EW reconciliation and QA. The menu uses the - -- bounded job below, so one UI command never runs this loop in a GUI frame. local perk_id = type(perk) == "table" and perk.id or "" local removed = 0 while perk_service.count(perk_id) > 0 do - if removed >= BATCH_REMOVE_LIMIT then return removed, "limit_reached" end + if removed >= 1000 then return removed, "limit_reached" end local ok, reason = perk_service.remove_one(player, perk) if not ok then return removed, reason end removed = removed + 1 @@ -220,198 +201,23 @@ function perk_service.remove_all(player, perk) return removed, "ok" end -local active_job = nil -local terminal_job_notice = nil -local next_job_id = 0 - -local function copy_job(job) - if type(job) ~= "table" then return nil end - return { - id=job.id, kind=job.kind, perk_id=job.perk_id, total=job.total, - completed=job.completed, state=job.state, reason=job.reason, - waiting_async=job.waiting_transaction_id ~= nil, - } -end - -local function flush_job_sync(job) - if type(job) ~= "table" or job.sync_dirty ~= true then return end - if valid(job.player) then - pcall(ew_perk_visibility.refresh, job.perk_id, job.player, transactions) - if job.kind == "take" then pcall(inventory_sync) end - end - job.sync_dirty = false -end - -local function finish_job(state, reason) - local job = active_job - if type(job) ~= "table" then return false end - flush_job_sync(job) - job.state = tostring(state or "done") - job.reason = tostring(reason or job.state) - if job.state ~= "done" then terminal_job_notice = copy_job(job) end - active_job = nil - return true -end - -local function begin_job(kind, player, perk, total) - if active_job ~= nil then return false, "busy" end - if not valid(player) or is_polymorphed_player(player) then return false, "target" end - if type(perk) ~= "table" or type(perk.id) ~= "string" or perk.id == "" then return false, "invalid" end - total = math.max(1, math.floor(tonumber(total) or 1)) - next_job_id = next_job_id + 1 - active_job = { - id=next_job_id, kind=kind, player=player, perk=perk, perk_id=perk.id, - total=total, completed=0, state="running", sync_dirty=false, - waiting_transaction_id=nil, - } - return true, "queued", active_job.id -end - -function perk_service.start_take_job(player, perk, amount) - amount = math.floor(tonumber(amount) or 0) - if amount ~= 1 and amount ~= 10 and amount ~= 100 then return false, "amount" end - return begin_job("take", player, perk, amount) -end - -function perk_service.start_remove_all_job(player, perk) - if not valid(player) or type(perk) ~= "table" then return false, "target" end - local allowed, reason = perk_service.can_remove(perk, player) - if not allowed then return false, reason end - local count = perk_service.count(perk.id) - if count <= 0 then return false, "none" end - return begin_job("remove_all", player, perk, math.min(BATCH_REMOVE_LIMIT, count)) -end - -function perk_service.job_status() - return copy_job(active_job) -end - -function perk_service.consume_job_notice() - local notice = terminal_job_notice - terminal_job_notice = nil - return notice -end - -function perk_service.cancel_job() - if active_job == nil then return false, "idle" end - finish_job("cancelled", "cancelled") - return true, "cancelled" -end - -local function process_active_job(player) - local job = active_job - if type(job) ~= "table" then return end - if not valid(player) or player ~= job.player or not valid(job.player) or is_polymorphed_player(player) then - finish_job("stopped", "player_changed") - return - end - - if job.waiting_transaction_id ~= nil then - local pending = type(nested_pickups.scope_open) == "function" - and nested_pickups.scope_open(job.player, job.waiting_transaction_id) == true - if pending then return end - job.waiting_transaction_id = nil - end - - local budget = BATCH_OPERATION_BUDGET - local operations = 0 - while budget > 0 and active_job == job do - if job.kind == "take" then - if job.completed >= job.total then - finish_job("done", "complete") - break - end - local ok, reason, tracked, track_reason, transaction_id = perk_service.apply(job.player, job.perk, { - ignore_debounce=true, defer_sync=true, batch=true, - }) - if not ok then - finish_job("error", tostring(reason)) - break - end - -- apply() may complete the vanilla pickup even when its structural delta is - -- not safely reversible. Count and synchronize that real copy before - -- stopping the batch; reporting 0/N here would be false and would leave EW - -- inventory/perk state one successful operation behind. - job.completed = job.completed + 1 - job.sync_dirty = true - operations = operations + 1 - budget = budget - 1 - if tracked ~= true or tonumber(transaction_id) == nil then - finish_job("error", "transaction:" .. tostring(track_reason or "untracked")) - break - end - if job.perk_id == "GAMBLE" and type(nested_pickups.scope_open) == "function" - and nested_pickups.scope_open(job.player, transaction_id) == true - then - job.waiting_transaction_id = transaction_id - break - end - elseif job.kind == "remove_all" then - if perk_service.count(job.perk_id) <= 0 then - finish_job("done", "complete") - break - end - if job.completed >= BATCH_REMOVE_LIMIT then - finish_job("error", "limit_reached") - break - end - local ok, reason = perk_service.remove_one(job.player, job.perk, {defer_sync=true, batch=true}) - if not ok then - finish_job("error", tostring(reason)) - break - end - job.completed = job.completed + 1 - job.total = math.max(job.total, job.completed + perk_service.count(job.perk_id)) - job.sync_dirty = true - operations = operations + 1 - budget = budget - 1 - else - finish_job("error", "unknown_job") - break - end - end - if active_job == job and operations > 0 then flush_job_sync(job) end - if active_job == job and job.kind == "take" and job.completed >= job.total and job.waiting_transaction_id == nil then - finish_job("done", "complete") - elseif active_job == job and job.kind == "remove_all" and perk_service.count(job.perk_id) <= 0 then - finish_job("done", "complete") - end -end - function perk_service.update(player) if type(transactions.update) == "function" then pcall(transactions.update) end - local frame = tonumber(GameGetFrameNum()) or 0 - local rollback_budget = FAILED_ROLLBACK_RETRY_BUDGET for index = #failed_pickup_rollbacks, 1, -1 do - if rollback_budget <= 0 then break end local pending = failed_pickup_rollbacks[index] local owner = type(pending) == "table" and pending.player or 0 - if valid(owner) and frame >= (tonumber(pending.next_retry_frame) or frame) then - rollback_budget = rollback_budget - 1 + if valid(owner) then local ok = select(1, transactions.revert_transaction(pending.perk_id, owner, pending.transaction_id)) == true - if ok then - table.remove(failed_pickup_rollbacks, index) - else - local delay = math.max(FAILED_ROLLBACK_RETRY_INITIAL_FRAMES, tonumber(pending.retry_delay) or FAILED_ROLLBACK_RETRY_INITIAL_FRAMES) - delay = math.min(FAILED_ROLLBACK_RETRY_MAX_FRAMES, delay * 2) - pending.retry_delay = delay - pending.next_retry_frame = frame + delay - end + if ok then table.remove(failed_pickup_rollbacks, index) end end end if type(nested_pickups.update) == "function" then pcall(nested_pickups.update) end - if not valid(player) then - if active_job ~= nil then finish_job("stopped", "player_changed") end - return - end + if not valid(player) then return end -- Perks belong to the human player state. During native polymorph the original human -- is serialized away; running its delayed ownership/presentation maintenance against -- the temporary creature would discard watches and associate cleanup with the wrong -- entity. Pause until the human returns, then rebind first. - if is_polymorphed_player(player) then - if active_job ~= nil then finish_job("stopped", "player_changed") end - return - end + if is_polymorphed_player(player) then return end local has_active_transactions = type(transactions.active_count) == "function" and transactions.active_count() > 0 if last_human_player_entity_id ~= nil and last_human_player_entity_id ~= player and has_active_transactions then @@ -429,8 +235,8 @@ function perk_service.update(player) last_human_player_entity_id = player end -- A just-deserialized player can expose its child/component tree one frame later. - -- Keep the existing owner on failure so update() retries instead of stranding - -- the transaction on stale ids. + -- Keep the old owner on failure so update() retries instead of permanently + -- stranding the transaction on stale ids. if not rebound then return end else last_human_player_entity_id = player @@ -442,7 +248,6 @@ function perk_service.update(player) pcall(inverses.maintenance_cleanup, owner, perk_id) end end) - process_active_job(player) end function perk_service.begin_pickup(player, perk) @@ -486,6 +291,12 @@ end local last_direct_apply_frame = {} +local ew_runtime = nil + +local function inventory_sync() + if ew_runtime == nil then ew_runtime = dofile("mods/metamorph_creative_menu/files/integrations/ew/runtime.lua") end + ew_runtime.force_inventory_sync() +end local function cleanup_pickup_entity(entity_id) if not valid(entity_id) then return end @@ -502,47 +313,18 @@ local function cleanup_pickup_entity(entity_id) pcall(EntityKill, entity_id) end -local function spawn_world_perk(perk, x, y) - if type(perk) ~= "table" or type(perk.id) ~= "string" or tonumber(x) == nil or tonumber(y) == nil then - return false, "invalid", 0 - end - if type(perk_spawn) ~= "function" then return false, "unavailable", 0 end - local pickup_entity_id = perk_spawn(tonumber(x), tonumber(y), perk.id) or 0 - if pickup_entity_id == 0 then return false, "spawn_failed", 0 end - - local notified, notify_reason = ew_world_items.notify_world_item(pickup_entity_id) - if notified ~= true then - if EntityGetIsAlive(pickup_entity_id) then pcall(EntityKill, pickup_entity_id) end - return false, "world_sync_failed:" .. tostring(notify_reason), 0 - end - -- A stock EW peer can consume a perk without ever executing MCM code. Register the - -- authoritative pickup so our EW extra-module can correlate the peer's stock - -- ew_current_perks increment and retire this owner-side DES entity. - local registered, register_reason = true, "legacy_world_items" - if type(ew_world_items.register_creative_perk) == "function" then - registered, register_reason = ew_world_items.register_creative_perk(pickup_entity_id, perk.id) - end - if registered ~= true then - if EntityGetIsAlive(pickup_entity_id) then pcall(EntityKill, pickup_entity_id) end - return false, "network_consume_watch_failed:" .. tostring(register_reason), 0 - end - return true, "spawned", pickup_entity_id -end - -function perk_service.spawn_at(perk, x, y) - return spawn_world_perk(perk, x, y) -end - function perk_service.spawn(player_entity_id, perk) if not valid(player_entity_id) or type(perk) ~= "table" or type(perk.id) ~= "string" then - return false, "invalid", 0 + return false, "invalid" end local player_x, player_y = EntityGetTransform(player_entity_id) - if player_x == nil or player_y == nil then return false, "unavailable", 0 end - return spawn_world_perk(perk, player_x + 12, player_y - 8) + if player_x == nil or type(perk_spawn) ~= "function" then return false, "unavailable" end + local pickup_entity_id = perk_spawn(player_x + 12, player_y - 8, perk.id) or 0 + return pickup_entity_id ~= 0, pickup_entity_id ~= 0 and "spawned" or "spawn_failed", pickup_entity_id end --- Canonical immediate pickup path shared by UI, QA and compatibility callers. +-- Canonical immediate pickup path. UI RMB, QA and compatibility callers all enter here +-- so test and player behavior cannot silently diverge again. function perk_service.apply(player_entity_id, perk, options) options = type(options) == "table" and options or {} if not valid(player_entity_id) or type(perk) ~= "table" or type(perk.id) ~= "string" then @@ -550,7 +332,7 @@ function perk_service.apply(player_entity_id, perk, options) end local current_frame = tonumber(GameGetFrameNum()) or 0 local previous_frame = tonumber(last_direct_apply_frame[perk.id]) or -100000 - if options.ignore_debounce ~= true and current_frame == previous_frame then return false, "debounced" end + if options.ignore_debounce ~= true and current_frame - previous_frame < 15 then return false, "debounced" end -- stackable controls vanilla perk-pool reappearance, not whether perk_pickup itself -- can execute again. Creative mode intentionally allows another real pickup of any -- perk; each copy receives its own rollback transaction. @@ -565,7 +347,6 @@ function perk_service.apply(player_entity_id, perk, options) if player_x == nil or player_y == nil then return false, "player_transform" end local count_before = perk_service.count(perk.id) local token = perk_service.begin_pickup(player_entity_id, perk) - if type(token) == "table" then token.source = "mcm_creative" end -- EW's persistent-flag hook can live in another Lua VM. Persistent discovery is -- optional here; vanilla still owns the actual local perk application. @@ -583,8 +364,9 @@ function perk_service.apply(player_entity_id, perk, options) local pickup_entity_id = 0 METAMORPH_CREATIVE_MENU_PERK_CAPTURE_ACTIVE = true local pickup_succeeded, pickup_error = xpcall(function() - -- Spawn a real vanilla perk entity because several perk paths depend on pickup - -- entity state and cannot be reproduced correctly with entity id 0. + -- Spawn a real vanilla perk entity before calling perk_pickup. Passing entity 0 + -- skipped entity-dependent behavior in several perks and made QA test a different + -- mechanic than RMB in the menu. -- Build the same perk item a player would touch, but mark it not to destroy -- neighboring perks because Creative Menu is not a Holy Mountain selection. pickup_entity_id = perk_spawn(player_x, player_y, perk.id, true) or 0 @@ -618,10 +400,8 @@ function perk_service.apply(player_entity_id, perk, options) if tracked then rollback_ok, rollback_reason = transactions.revert_transaction(perk.id, player_entity_id, token.transaction_id) if not rollback_ok then - local retry_delay = FAILED_ROLLBACK_RETRY_INITIAL_FRAMES failed_pickup_rollbacks[#failed_pickup_rollbacks + 1] = { perk_id=perk.id, player=player_entity_id, transaction_id=token.transaction_id, - retry_delay=retry_delay, next_retry_frame=(tonumber(GameGetFrameNum()) or 0) + retry_delay, } end else @@ -641,13 +421,29 @@ function perk_service.apply(player_entity_id, perk, options) local tracked, track_reason = true, "no_token" if token ~= nil then tracked, track_reason = perk_service.commit_pickup(token) end - if options.defer_sync ~= true then - if tracked == true then pcall(ew_perk_visibility.refresh, perk.id, player_entity_id, transactions) end - inventory_sync() - end - return true, "applied", tracked == true, track_reason, type(token) == "table" and token.transaction_id or nil + inventory_sync() + return true, "applied", tracked == true, track_reason +end + +function perk_service.root_companion_state() + return root_companions.ownership_summary() end +function perk_service.ownership_state() + local global_owners, flag_owners = 0, 0 + if type(transactions.active_global_owner_counts) == "function" then + global_owners, flag_owners = transactions.active_global_owner_counts() + end + return { + transactions = type(transactions.active_count) == "function" and transactions.active_count() or 0, + mutations = type(transactions.active_mutation_count) == "function" and transactions.active_mutation_count() or 0, + global_owners = tonumber(global_owners) or 0, + run_flag_owners = tonumber(flag_owners) or 0, + cleanup = type(transactions.cleanup_state) == "function" and transactions.cleanup_state() or {pending=0,failed=0}, + nested = type(nested_pickups.state_snapshot) == "function" and nested_pickups.state_snapshot() or {scopes=0,children=0}, + locomotion_baselines = type(locomotion_guard.baseline_count)=="function" and locomotion_guard.baseline_count() or 0, + } +end METAMORPH_CREATIVE_MENU_PERK_SERVICE = perk_service return perk_service diff --git a/metamorph_creative_menu/files/features/perks/transactions.lua b/metamorph_creative_menu/files/features/perks/transactions.lua index 5e35d8a..d9ba30b 100644 --- a/metamorph_creative_menu/files/features/perks/transactions.lua +++ b/metamorph_creative_menu/files/features/perks/transactions.lua @@ -106,7 +106,7 @@ function perk_transactions.commit(token) local before_charge = tonumber(token.special_before.mana_charge_speed) local after_charge = tonumber(special_after.mana_charge_speed) local delta = { - kind="extra_mana", perk_id=key, player=token.player, source=token.source, wand=token.special_before.wand, + kind="extra_mana", perk_id=key, player=token.player, wand=token.special_before.wand, ability=token.special_before.ability, before=token.special_before, after={ deck_capacity=special_after.deck_capacity, mana_max=special_after.mana_max, mana_charge_speed=special_after.mana_charge_speed }, actions=action_states, @@ -124,7 +124,7 @@ function perk_transactions.commit(token) if key == "NO_MORE_SHUFFLE" and type(token.no_shuffle_before) == "table" then local changes, shuffle_reason = no_more_shuffle_delta(token.no_shuffle_before) if changes == nil then return false, shuffle_reason end - local delta = { kind="no_more_shuffle", perk_id=key, player=token.player, source=token.source, changes=changes } + local delta = { kind="no_more_shuffle", perk_id=key, player=token.player, changes=changes } global_journal.attach_delta(delta, token) mutation_journal.attach_delta(delta, token) delta.player_locators = player_rebind.capture(token.player) @@ -138,7 +138,6 @@ function perk_transactions.commit(token) delta.player = token.player delta.perk_id = key - delta.source = token.source global_journal.attach_delta(delta, token) mutation_journal.attach_delta(delta, token) delta.player_locators = player_rebind.capture(token.player) @@ -165,21 +164,6 @@ function perk_transactions.has(perk_id, player) return true end -function perk_transactions.source_count(perk_id, source, player) - local stack = history[tostring(perk_id or "")] - if type(stack) ~= "table" then return 0 end - local wanted = tostring(source or "") - local count = 0 - for _, delta in ipairs(stack) do - if type(delta) == "table" and tostring(delta.source or "") == wanted - and (player == nil or player == 0 or delta.player == player) - then - count = count + 1 - end - end - return count -end - function perk_transactions.revert(perk_id, player) local key = tostring(perk_id or "") diff --git a/metamorph_creative_menu/files/features/perks/transactions/pending_cleanup.lua b/metamorph_creative_menu/files/features/perks/transactions/pending_cleanup.lua index 2aa4462..51feff5 100644 --- a/metamorph_creative_menu/files/features/perks/transactions/pending_cleanup.lua +++ b/metamorph_creative_menu/files/features/perks/transactions/pending_cleanup.lua @@ -9,9 +9,6 @@ local pending = {} local failed = {} local fallback_tick = 0 local VERIFY_FRAMES = 30 -local FAILED_RETRY_INITIAL_FRAMES = 30 -local FAILED_RETRY_MAX_FRAMES = 300 -local FAILED_RETRY_BUDGET = 4 local function now_frame() if type(GameGetFrameNum) == "function" then @@ -119,34 +116,18 @@ function pending_cleanup.update() pending[key] = nil record.failed = true record.failed_frame = frame - record.retry_delay = FAILED_RETRY_INITIAL_FRAMES - record.next_retry_frame = frame + record.retry_delay failed[key] = record end end end -- A timed-out object can still disappear later because another engine subsystem - -- finally completed the request. Do not hammer a permanently-stuck residue every - -- frame, though: failed cleanup is exceptional and must have bounded background cost. + -- finally completed the request. Keep retrying, and clear the diagnostic failure once clean. local failed_keys = {} for key in pairs(failed) do failed_keys[#failed_keys + 1] = key end - table.sort(failed_keys) - local retry_budget = FAILED_RETRY_BUDGET for _, key in ipairs(failed_keys) do - if retry_budget <= 0 then break end local record = failed[key] - if record ~= nil and frame >= (tonumber(record.next_retry_frame) or frame) then - retry_budget = retry_budget - 1 - if select(1, attempt(record)) == true then - failed[key] = nil - else - local delay = math.max(FAILED_RETRY_INITIAL_FRAMES, tonumber(record.retry_delay) or FAILED_RETRY_INITIAL_FRAMES) - delay = math.min(FAILED_RETRY_MAX_FRAMES, delay * 2) - record.retry_delay = delay - record.next_retry_frame = frame + delay - end - end + if record ~= nil and select(1, attempt(record)) == true then failed[key] = nil end end end diff --git a/metamorph_creative_menu/files/features/player_tools/service.lua b/metamorph_creative_menu/files/features/player_tools/service.lua deleted file mode 100644 index fa825d8..0000000 --- a/metamorph_creative_menu/files/features/player_tools/service.lua +++ /dev/null @@ -1,232 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_PLAYER_TOOLS) == "table" then return METAMORPH_CREATIVE_MENU_PLAYER_TOOLS end - -local service = {} - -local BRING_SEQ = "mcm_teleport_bring_outbox_seq_v1" -local BRING_PEER = "mcm_teleport_bring_outbox_peer_v1" -local BRING_TARGET_X = "mcm_teleport_bring_outbox_target_x_v1" -local BRING_TARGET_Y = "mcm_teleport_bring_outbox_target_y_v1" -local BRING_DEST_X = "mcm_teleport_bring_outbox_dest_x_v1" -local BRING_DEST_Y = "mcm_teleport_bring_outbox_dest_y_v1" -local STREAM_TIMEOUT_FRAMES = 300 -local BODY_RADIUS = 9 - --- Coordinates are taken from EW's own debug teleports where available. Main-path --- biome points use their central corridors and are resolved again by --- FindFreePositionForBody after the destination chunk finishes streaming. -local locations = { - { id="start", key="$mcm_location_start", fallback="Starting area", x=0, y=-100 }, - { id="mines", key="$mcm_location_mines", fallback="Mines", x=0, y=650 }, - { id="hm_mines", key="$mcm_location_hm_mines", fallback="Holy Mountain after Mines", x=-200, y=1390 }, - { id="coal_pits", key="$mcm_location_coal_pits", fallback="Coal Pits", x=0, y=2150 }, - { id="fungal", key="$mcm_location_fungal", fallback="Fungal Caverns", x=-2012, y=1960 }, - { id="hm_coal", key="$mcm_location_hm_coal", fallback="Holy Mountain after Coal Pits", x=-300, y=2900 }, - { id="snowy", key="$mcm_location_snowy", fallback="Snowy Depths", x=0, y=4100 }, - { id="hm_snowy", key="$mcm_location_hm_snowy", fallback="Holy Mountain after Snowy Depths", x=-300, y=5000 }, - { id="hiisi", key="$mcm_location_hiisi", fallback="Hiisi Base", x=0, y=5850 }, - { id="hm_hiisi", key="$mcm_location_hm_hiisi", fallback="Holy Mountain after Hiisi Base", x=-300, y=6500 }, - { id="jungle", key="$mcm_location_jungle", fallback="Underground Jungle", x=0, y=7750 }, - { id="hm_jungle", key="$mcm_location_hm_jungle", fallback="Holy Mountain after Jungle", x=-300, y=8550 }, - { id="vault", key="$mcm_location_vault", fallback="The Vault", x=0, y=8580 }, - { id="hm_vault", key="$mcm_location_hm_vault", fallback="Holy Mountain after Vault", x=-300, y=10600 }, - { id="temple", key="$mcm_location_temple", fallback="Temple of the Art", x=0, y=11500 }, - { id="lab_portal", key="$mcm_location_lab_portal", fallback="Laboratory portal", x=350, y=12853 }, - { id="kolmi", key="$mcm_location_kolmi", fallback="Kolmisilma room", x=3400, y=13040 }, - { id="work", key="$mcm_location_work", fallback="The Work", x=6300, y=15155 }, - { id="tree", key="$mcm_location_tree", fallback="Giant Tree", x=-1902, y=-1405 }, - { id="orb_zero", key="$mcm_location_orb_zero", fallback="Mountain orb room", x=766, y=-1075 }, - { id="meat", key="$mcm_location_meat", fallback="Meat Realm", x=7328, y=9263 }, - { id="kivi", key="$mcm_location_kivi", fallback="Kivi chamber", x=7427, y=-4960 }, - { id="null_altar", key="$mcm_location_null_altar", fallback="Nullifying Altar", x=14000, y=7500 }, - { id="essence_laser", key="$mcm_location_essence_laser", fallback="Essence of Light", x=16000, y=-1800 }, - { id="essence_eater", key="$mcm_location_essence_eater", fallback="Essence Eater", x=12621, y=-141 }, -} - -local pending = nil - -local function frame_number() - if type(GameGetFrameNum) ~= "function" then return 0 end - local ok, value = pcall(GameGetFrameNum) - return ok and (tonumber(value) or 0) or 0 -end - -local function valid_entity(entity) - if entity == nil or entity == 0 then return false end - if type(EntityGetIsAlive) ~= "function" then return true end - local ok, alive = pcall(EntityGetIsAlive, entity) - return ok and alive == true -end - -local function local_player(player) - if valid_entity(player) then return player end - if type(EntityGetWithTag) ~= "function" then return 0 end - local ok, list = pcall(EntityGetWithTag, "player_unit") - if ok and type(list) == "table" then - for _, entity in ipairs(list) do if valid_entity(entity) then return entity end end - end - ok, list = pcall(EntityGetWithTag, "polymorphed_player") - if ok and type(list) == "table" then - for _, entity in ipairs(list) do if valid_entity(entity) then return entity end end - end - return 0 -end - -local function world_loaded(x, y) - if type(DoesWorldExistAt) ~= "function" then return true end - local ok, loaded = pcall(DoesWorldExistAt, x - 24, y - 32, x + 24, y + 20) - return ok and loaded == true -end - -local function free_position(x, y) - if type(FindFreePositionForBody) == "function" then - local ok, px, py = pcall(FindFreePositionForBody, x, y, 0, 0, BODY_RADIUS) - px, py = tonumber(px), tonumber(py) - if ok and px ~= nil and py ~= nil and world_loaded(px, py) then return px, py end - end - local offsets = { - {18,0},{-18,0},{0,-20},{24,-16},{-24,-16},{36,0},{-36,0}, - {0,-40},{48,-16},{-48,-16},{0,24},{64,0},{-64,0}, - } - for _, offset in ipairs(offsets) do - local px, py = x + offset[1], y + offset[2] - if world_loaded(px, py) then - local blocked = false - if type(RaytraceSurfaces) == "function" then - local ok_a, hit_a = pcall(RaytraceSurfaces, px - 5, py - 10, px + 5, py + 5) - local ok_b, hit_b = pcall(RaytraceSurfaces, px + 5, py - 10, px - 5, py + 5) - blocked = (ok_a and hit_a == true) or (ok_b and hit_b == true) - end - if not blocked then return px, py end - end - end - return nil, nil -end - -local function clear_velocity(player) - if type(EntityGetFirstComponentIncludingDisabled) ~= "function" then return end - local ok, component = pcall(EntityGetFirstComponentIncludingDisabled, player, "CharacterDataComponent") - if ok and component ~= nil and component ~= 0 and type(ComponentSetValue2) == "function" then - pcall(ComponentSetValue2, component, "mVelocity", 0, 0) - end -end - -local function perform(player, x, y) - player = local_player(player) - if player == 0 or not world_loaded(x, y) then return false, "unloaded" end - local px, py = free_position(x, y) - if px == nil then return false, "blocked" end - local ox, oy = service.position(player) - clear_velocity(player) - local ok = pcall(EntitySetTransform, player, px, py) - if not ok then return false, "transform" end - if type(EntityLoad) == "function" then - pcall(EntityLoad, "data/entities/particles/teleportation_source.xml", ox, oy) - pcall(EntityLoad, "data/entities/particles/teleportation_target.xml", px, py) - end - return true, "teleported" -end - -function service.visible_players() - local result, seen = {}, {} - if type(EntityGetWithTag) ~= "function" then return result end - -- Demand-driven only: opening another tab stops all player enumeration. - for _, tag in ipairs({"ew_client", "ew_peer", "player_unit", "polymorphed_player"}) do - local ok, list = pcall(EntityGetWithTag, tag) - if ok and type(list) == "table" then - for _, entity in ipairs(list) do - if valid_entity(entity) and not seen[entity] then - seen[entity] = true - result[#result + 1] = entity - end - end - end - end - return result -end - -function service.position(entity) - if not valid_entity(entity) or type(EntityGetTransform) ~= "function" then return 0, 0 end - local ok, x, y = pcall(EntityGetTransform, entity) - if not ok then return 0, 0 end - return tonumber(x) or 0, tonumber(y) or 0 -end - -function service.teleport_position(player, x, y) - player = local_player(player) - x, y = tonumber(x), tonumber(y) - if player == 0 or x == nil or y == nil then return false, "target" end - -- A newer command owns the teleport intent even when its destination is already - -- streamed. Otherwise the old pending request can fire later and pull us back. - pending = nil - if world_loaded(x, y) then return perform(player, x, y) end - pending = { player=player, x=x, y=y, started=frame_number() } - if type(GameSetCameraPos) == "function" then pcall(GameSetCameraPos, x, y) end - return true, "streaming" -end - -function service.teleport_to(entity, player) - if not valid_entity(entity) then return false, "target" end - local x, y = service.position(entity) - return service.teleport_position(player, x + 18, y) -end - -function service.locations() - local result = {} - for index, location in ipairs(locations) do result[index] = location end - return result -end - -function service.teleport_location(location_id, player) - for _, location in ipairs(locations) do - if location.id == tostring(location_id or "") then - return service.teleport_position(player, location.x, location.y) - end - end - return false, "location" -end - -local function set_outbox(base, sequence, value) - GlobalsSetValue(base, tostring(value)) - GlobalsSetValue(base .. "_" .. tostring(sequence), tostring(value)) -end - -function service.bring_to_me(entity, player) - player = local_player(player) - if player == 0 or not valid_entity(entity) or type(GlobalsGetValue) ~= "function" - or type(GlobalsSetValue) ~= "function" - then - return false, "network" - end - local peer_id = type(EntityGetName) == "function" and tostring(EntityGetName(entity) or "") or "" - local tx, ty = service.position(entity) - local x, y = service.position(player) - local sequence = (tonumber(GlobalsGetValue(BRING_SEQ, "0")) or 0) + 1 - set_outbox(BRING_PEER, sequence, peer_id) - set_outbox(BRING_TARGET_X, sequence, tx) - set_outbox(BRING_TARGET_Y, sequence, ty) - set_outbox(BRING_DEST_X, sequence, x + 18) - set_outbox(BRING_DEST_Y, sequence, y) - GlobalsSetValue(BRING_SEQ, tostring(sequence)) - return true, "queued" -end - -function service.update() - if pending == nil then return false, "idle" end - if not valid_entity(pending.player) then pending = nil; return false, "player" end - if frame_number() - pending.started > STREAM_TIMEOUT_FRAMES then - pending = nil - return false, "timeout" - end - if not world_loaded(pending.x, pending.y) then - if type(GameSetCameraPos) == "function" then pcall(GameSetCameraPos, pending.x, pending.y) end - return false, "streaming" - end - local request = pending - pending = nil - return perform(request.player, request.x, request.y) -end - -function service.has_pending_teleport() return pending ~= nil end - -METAMORPH_CREATIVE_MENU_PLAYER_TOOLS = service -return service diff --git a/metamorph_creative_menu/files/features/possession/keybinds.lua b/metamorph_creative_menu/files/features/possession/keybinds.lua index 833d72e..2a7fcf2 100644 --- a/metamorph_creative_menu/files/features/possession/keybinds.lua +++ b/metamorph_creative_menu/files/features/possession/keybinds.lua @@ -3,18 +3,36 @@ if type(METAMORPH_CREATIVE_MENU_KEYBINDS) == "table" then return METAMORPH_CREAT local possession_keybinds = {} local input_guard = dofile("mods/metamorph_creative_menu/files/platform/noita/input_guard.lua") local keycodes = dofile("mods/metamorph_creative_menu/files/platform/noita/keycodes.lua") -local binding_codec = dofile("mods/metamorph_creative_menu/files/core/binding_codec.lua") -local action_bindings = dofile("mods/metamorph_creative_menu/files/platform/noita/action_bindings.lua") local player_locator = dofile("mods/metamorph_creative_menu/files/platform/noita/player_locator.lua") local possession = dofile("mods/metamorph_creative_menu/files/features/possession/service.lua") +local SETTING_ID = "metamorph_creative_menu.possession_key" local DEFAULT_BINDING = "Key_g" +local cached_setting = nil +local cached_binding = nil +local next_refresh_frame = -1 local function decode_binding(value) - local parsed = binding_codec.parse(type(value) == "string" and value or DEFAULT_BINDING) - if parsed == nil or parsed.kind == "none" then return {kind="none",code=nil,name="NONE"} end - if parsed.kind == "mouse" then return {kind="mouse",code=parsed.mouse_code,name=parsed.canonical} end - return {kind="key",code=keycodes.resolve(parsed.base),name=parsed.canonical} + value = type(value) == "string" and value or DEFAULT_BINDING + local mouse = string.match(value, "^Mouse:(%-?%d+)$") + if mouse ~= nil then + return { kind="mouse", code=tonumber(mouse), name=value } + end + local code, resolved_name = keycodes.resolve_binding(value, DEFAULT_BINDING) + value = resolved_name or DEFAULT_BINDING + code = tonumber(code) or 10 + return { kind="key", code=code, name=value } +end + +local function configured_binding() + local frame = tonumber(GameGetFrameNum()) or 0 + if cached_binding ~= nil and frame < next_refresh_frame then return cached_binding end + next_refresh_frame = frame + 15 + + local ok, value = pcall(ModSettingGet, SETTING_ID) + local binding = decode_binding(ok and value or DEFAULT_BINDING) + cached_setting, cached_binding = binding.name, binding + return binding end local function inventory_open() @@ -22,13 +40,22 @@ local function inventory_open() return ok and value == true end -function possession_keybinds.update(menu_open) +local function binding_just_down(binding) + if type(binding) ~= "table" or binding.code == nil then return false end + if binding.kind == "mouse" then + local ok, pressed = pcall(InputIsMouseButtonJustDown, binding.code) + return ok and pressed == true + end + local ok, pressed = pcall(InputIsKeyJustDown, binding.code) + return ok and pressed == true +end + +function possession_keybinds.update() possession.update() - action_bindings.update() - if not input_guard.actions_allowed() or inventory_open() or menu_open == true then return false end - if not action_bindings.consume("possession") then return false end + local binding = configured_binding() + if not input_guard.actions_allowed() or inventory_open() then return false end + if not binding_just_down(binding) then return false end local success, reason = possession.possess_under_cursor(player_locator.get()) - if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_USER_ACTION) == "function" then pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_USER_ACTION, "possession", "binding="..tostring(action_bindings.get("possession")).." result="..tostring(success).." reason="..tostring(reason)) end if not success then if reason == "no_target" then GamePrint(GameTextGetTranslatedOrNot("$mcm_possess_no_target")) else GamePrint(GameTextGetTranslatedOrNot("$mcm_possess_failed") .. ": " .. tostring(reason or "")) end @@ -37,16 +64,17 @@ function possession_keybinds.update(menu_open) end function possession_keybinds.possess_key() - local binding = decode_binding(action_bindings.get("possession")) + local binding = configured_binding() return binding.kind == "key" and binding.code or nil end function possession_keybinds.possess_key_name() - return action_bindings.get("possession") + configured_binding() + return cached_setting or DEFAULT_BINDING end function possession_keybinds.possess_binding() - return decode_binding(action_bindings.get("possession")) + return configured_binding() end METAMORPH_CREATIVE_MENU_KEYBINDS = possession_keybinds diff --git a/metamorph_creative_menu/files/features/possession/retirement.lua b/metamorph_creative_menu/files/features/possession/retirement.lua index 4b326f9..3ef94f4 100644 --- a/metamorph_creative_menu/files/features/possession/retirement.lua +++ b/metamorph_creative_menu/files/features/possession/retirement.lua @@ -7,11 +7,6 @@ end function retirement.retire_without_death_side_effects(entity) if not valid_entity(entity) then return false end - -- Targeting can return the DamageModel-bearing child of a multipart boss. Replace - -- the whole local creature tree, while EW retirement separately addresses whichever - -- entity owns the DES identity. Otherwise limbs/controllers can survive possession. - local root = entity_tree.root(entity) - if valid_entity(root) then entity = root end -- Possession is replacement, not murder. Normal EntityKill may run arbitrary -- death/explosion/drop scripts on the copied mob. Disable the whole tree first so diff --git a/metamorph_creative_menu/files/features/possession/service.lua b/metamorph_creative_menu/files/features/possession/service.lua index df722d6..76b4ec7 100644 --- a/metamorph_creative_menu/files/features/possession/service.lua +++ b/metamorph_creative_menu/files/features/possession/service.lua @@ -2,6 +2,7 @@ if type(METAMORPH_CREATIVE_MENU_POSSESSION) == "table" then return METAMORPH_CRE local possession_service = {} local form_manager = dofile("mods/metamorph_creative_menu/files/features/forms/manager.lua") +local ew_runtime = dofile("mods/metamorph_creative_menu/files/integrations/ew/runtime.lua") local targeting = dofile("mods/metamorph_creative_menu/files/features/possession/targeting.lua") local retirement = dofile("mods/metamorph_creative_menu/files/features/possession/retirement.lua") local ew_retirement = dofile("mods/metamorph_creative_menu/files/integrations/ew/possession_retire.lua") @@ -123,13 +124,12 @@ end function possession_service.possess_under_cursor(player) if pending ~= nil then return false, "pending" end if not valid(player) then return false, "player" end - local target = possession_service.target_under_cursor(player, 48) - if not valid(target) then - -- Large natural mobs can have their entity origin well outside the visible body; - -- network interpolation can add another small offset. The bounded second pass is - -- therefore useful for both host and client without turning the possession action - -- (G by default) into a map-wide pick. - target = possession_service.target_under_cursor(player, 96) + local target = possession_service.target_under_cursor(player, 32) + if not valid(target) and ew_runtime.mode() == "client" then + -- The client's visual remote pose can be ahead of the local authoritative entity + -- by a few dozen pixels. Keep the exact cursor behavior first, then use a bounded + -- fallback instead of treating a synchronization offset as "no target". + target = possession_service.target_under_cursor(player, 64) end if not valid(target) then return false, "no_target" end return begin_possession_target(player, target) @@ -143,25 +143,6 @@ function possession_service.update() target_position(pending) -- keep last known position while the original mob is alive local player = form_manager.current_player() - if pending.phase == "retiring" then - local local_target = tonumber(pending.retire_local_target) or 0 - if not valid(local_target) then - pending = nil - return - end - local acknowledged = type(ew_retirement.acknowledged) == "function" - and ew_retirement.acknowledged(pending.retire_sequence) == true - if acknowledged then - retirement.retire_without_death_side_effects(local_target) - pending = nil - return - end - -- The EW bridge normally acknowledges on the same network frame. Keep the - -- original alive rather than destroying its GID early if an unusual load order - -- delays the bridge; the outer 240-frame timeout remains the hard safety bound. - return - end - if pending.phase == "returning" then if form_manager.is_human_ready(player) then if not place_human_at_target(player, pending) then clear_failed("target_position"); return end @@ -190,32 +171,10 @@ function possession_service.update() and form_manager.session_actual_target() or form_manager.session_target() if valid(player) and EntityHasTag(player, "polymorphed_player") and actual_target_path == pending.actual_path then if valid(pending.target) and pending.target ~= player then - local retire_target = pending.target - if type(ew_retirement.network_entity) == "function" then - local resolved = ew_retirement.network_entity(pending.target) - if resolved ~= nil and resolved ~= 0 then retire_target = resolved end - end - local network_kind = type(ew_retirement.network_kind) == "function" - and select(1, ew_retirement.network_kind(retire_target)) or "local" - if network_kind == "des" and type(ew_retirement.queue_remote) == "function" then - local queued, _, _, request_sequence = ew_retirement.queue_remote( - retire_target, pending.path, pending.last_x, pending.last_y, player) - if queued == true and request_sequence ~= nil then - -- Do not destroy the original yet. EW's DES retirement needs the - -- live entity/GID; the next update removes the local multipart tree - -- only after the EW-context bridge acknowledges the request. - pending.phase = "retiring" - pending.retire_sequence = request_sequence - pending.retire_local_target = pending.target - return - end - end - -- Host-authoritative enemies are removed locally and disappear from stock - -- enemy_sync on its next snapshot. A pure ew_replicated client proxy has - -- no DES authority to retire here, so do not send it through the wrong - -- ew_death_notify channel. - if network_kind ~= "enemy_replica" then + if ew_retirement.is_owned_locally(pending.target) then retirement.retire_without_death_side_effects(pending.target) + else + ew_retirement.queue_remote(pending.path, pending.last_x, pending.last_y) end end pending = nil diff --git a/metamorph_creative_menu/files/features/possession/targeting.lua b/metamorph_creative_menu/files/features/possession/targeting.lua index ea70816..dc2bfc3 100644 --- a/metamorph_creative_menu/files/features/possession/targeting.lua +++ b/metamorph_creative_menu/files/features/possession/targeting.lua @@ -1,209 +1,58 @@ local targeting = {} local entity_tree = dofile("mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua") --- Load the catalogue/classification service once. Possession targeting can inspect dozens --- of live entities in one click; re-entering the module loader for every candidate made --- object/prop misses unnecessarily expensive even though the service itself is a singleton. -local creature_service = nil -do - local ok, service = pcall(dofile, "mods/metamorph_creative_menu/files/features/creatures/service.lua") - if ok and type(service) == "table" then creature_service = service end -end - local CREATURE_MOTOR_COMPONENTS = { "AnimalAIComponent", "WormComponent", "WormAIComponent", "PhysicsAIComponent", "CharacterDataComponent", "BossDragonComponent", "CrawlerAnimalComponent", "AdvancedFishAIComponent", "FishAIComponent", "GhostComponent", "IKLimbWalkerComponent", "IKLimbsAnimatorComponent", "LimbBossComponent", } -local EW_REPLICA_CREATURE_TAGS = { - "enemy", "helpless_animal", "worm", "boss", "boss_centipede", - "plague_rat", "perk_fungus_tiny", "seed_c", "seed_d", "seed_e", "seed_f", -} - -local CREATURE_HINT_TAGS = { - "enemy", "helpless_animal", "worm", "boss", "boss_centipede", - "plague_rat", "perk_fungus_tiny", "seed_c", "seed_d", "seed_e", "seed_f", -} - --- These engine directories are dominated by pickups, scenery, particles and projectiles. --- They are NOT a global deny-list: a live entity with a real creature motor/tag still goes --- through full validation. The prefix only lets an ordinary rock/heart/prop miss stop before --- a recursive entity-tree walk or XML/Base-chain classification. -local OBVIOUS_NON_CREATURE_PREFIXES = { - "data/entities/items/", - "data/entities/props/", - "data/entities/buildings/", - "data/entities/projectiles/", - "data/entities/particles/", -} - local function valid_entity(entity) return entity ~= nil and entity ~= 0 and EntityGetIsAlive(entity) end -local function has_any_tag(entity, tags) - for _, tag in ipairs(tags) do - if EntityHasTag(entity, tag) then return true end - end - return false -end - -local function direct_motor(entity) - for _, component_type in ipairs(CREATURE_MOTOR_COMPONENTS) do - if EntityGetFirstComponentIncludingDisabled(entity, component_type) ~= nil then return true end - end - return false -end - -local function obvious_non_creature_path(path) - path = tostring(path or "") - for _, prefix in ipairs(OBVIOUS_NON_CREATURE_PREFIXES) do - if string.sub(path, 1, #prefix) == prefix then return true end - end - return false -end - -local function hard_reject(entity, player_entity) - if not valid_entity(entity) or entity == player_entity then return true end - if EntityHasTag(entity, "player_unit") or EntityHasTag(entity, "polymorphed_player") then return true end - if EntityHasTag(entity, "projectile") or EntityHasTag(entity, "item_pickup") or EntityHasTag(entity, "perk_entity") then - return true - end - return false -end - --- One traversal collects all runtime structure needed by the possession policy. The old --- implementation walked the same tree once for DamageModel and then again for every motor --- component type (up to ~14 full traversals per candidate). -local function tree_signature(root_entity, memo) - memo = type(memo) == "table" and memo or nil - local signatures = memo and memo.signatures or nil - if signatures ~= nil and signatures[root_entity] ~= nil then return signatures[root_entity] end - - local signature = { damage=false, motor=false, hint=false, animal_path=false, ew_replica=false } +local function tree_has_component(root_entity, component_type) + local found = false entity_tree.walk(root_entity, function(current_entity) - if not valid_entity(current_entity) then return end - if EntityGetFirstComponentIncludingDisabled(current_entity, "DamageModelComponent") ~= nil then - signature.damage = true + if valid_entity(current_entity) + and EntityGetFirstComponentIncludingDisabled(current_entity, component_type) ~= nil then + found = true + return false end - if direct_motor(current_entity) then signature.motor = true end - if has_any_tag(current_entity, CREATURE_HINT_TAGS) then signature.hint = true end - if EntityHasTag(current_entity, "ew_replicated") then signature.ew_replica = true end - local path = tostring(EntityGetFilename(current_entity) or "") - if string.sub(path, 1, 22) == "data/entities/animals/" then signature.animal_path = true end - if signature.damage and signature.motor and signature.hint then return false end end) - - if memo ~= nil then - memo.signatures = memo.signatures or {} - memo.signatures[root_entity] = signature - end - return signature -end - -local function service_allows_path(entity_filename) - if type(creature_service) ~= "table" then return true end - if type(creature_service.unsafe_reason) == "function" then - local checked, reason = pcall(creature_service.unsafe_reason, entity_filename) - if checked and reason ~= nil then return false end - end - if type(creature_service.is_internal_helper_path) == "function" then - local checked, is_helper = pcall(creature_service.is_internal_helper_path, entity_filename) - if checked and is_helper == true then return false end - end - return true + return found end -function targeting.is_creature(entity, player_entity, memo) - if hard_reject(entity, player_entity) then return false end +function targeting.is_creature(entity, player_entity) + if not valid_entity(entity) or entity == player_entity then return false end + if EntityHasTag(entity, "player_unit") or EntityHasTag(entity, "polymorphed_player") then return false end + if EntityHasTag(entity, "projectile") or EntityHasTag(entity, "item_pickup") or EntityHasTag(entity, "perk_entity") then return false end local entity_filename = tostring(EntityGetFilename(entity) or "") if entity_filename == "" then return false end - local entity_is_replica = EntityHasTag(entity, "ew_replicated") - local entity_has_hint = has_any_tag(entity, CREATURE_HINT_TAGS) - local entity_has_motor = direct_motor(entity) - - -- Fast negative path for the exact class that caused the live hitch: a plain pickup, - -- physics prop, scenery/building or projectile root with no creature runtime signal. - -- Crucially this executes before creature_service.is_internal_helper_path(), which may - -- recursively read/parse XML chains on the first encounter with a path. - if obvious_non_creature_path(entity_filename) - and not entity_is_replica and not entity_has_hint and not entity_has_motor then - return false - end - - local root = entity_tree.root(entity) - if not valid_entity(root) then root = entity end - local signature = tree_signature(root, memo) - if not signature.damage then return false end - - -- Entangled Worlds deliberately removes AnimalAI/PhysicsAI/FishAI from client-side - -- replicas. A replicated enemy is still a valid target when its path/tag identifies a - -- creature and its live tree retains a damage model. - if entity_is_replica or signature.ew_replica then - if string.sub(entity_filename, 1, 22) == "data/entities/animals/" then - return service_allows_path(entity_filename) - end - for _, tag in ipairs(EW_REPLICA_CREATURE_TAGS) do - if EntityHasTag(entity, tag) or (root ~= entity and EntityHasTag(root, tag)) then - return service_allows_path(entity_filename) - end + -- Runtime tags are insufficient: mines/projectile helpers can have DamageModel plus + -- movement-like children. Reuse the MOBS catalog's XML classification first. + local loaded, creature_service = pcall(dofile, "mods/metamorph_creative_menu/files/features/creatures/service.lua") + if loaded and type(creature_service) == "table" then + if type(creature_service.is_internal_helper_path) == "function" then + local checked, is_helper = pcall(creature_service.is_internal_helper_path, entity_filename) + if checked and is_helper == true then return false end end - -- A replicated physics prop with DamageModel but no creature identity remains a miss. - if not signature.animal_path and not signature.hint then return false end - end - - if not signature.motor then return false end - return service_allows_path(entity_filename) -end - -local function creature_from_entity(raw_entity, player_entity, memo) - local current, seen, structural_fallback = raw_entity, {}, 0 - for _ = 1, 64 do - if not valid_entity(current) or seen[current] then break end - seen[current] = true - if targeting.is_creature(current, player_entity, memo) then - if EntityGetFirstComponentIncludingDisabled(current, "DamageModelComponent") ~= nil then - return current - end - if structural_fallback == 0 then structural_fallback = current end + -- Apply the same exact-path compatibility policy to world possession as the MOBS + -- menu. This blocks confirmed crash targets without introducing filename bans; + -- structurally valid uncatalogued mod creatures can still pass the service check. + if type(creature_service.is_transformable_creature_path) == "function" then + local checked, is_root_creature = pcall(creature_service.is_transformable_creature_path, entity_filename) + if checked and is_root_creature ~= true then return false end end - if type(EntityGetParent) ~= "function" then break end - local parent_ok, parent = pcall(EntityGetParent, current) - if not parent_ok or parent == nil or parent == 0 then break end - current = parent end - -- EW and some creature XMLs add a non-creature synchronization/controller root. - -- Only do the descendant fallback after the cheap parent path did not find a body. - -- Runtime tree signatures are memoized for this click, so wrappers do not multiply - -- recursive component work for every nearby child. - local root = entity_tree.root(raw_entity) - local found = 0 - local allow_descendant_fallback = valid_entity(root) and not hard_reject(root, player_entity) - if allow_descendant_fallback then - local root_path = tostring(EntityGetFilename(root) or "") - if obvious_non_creature_path(root_path) - and not EntityHasTag(root, "ew_replicated") - and not has_any_tag(root, CREATURE_HINT_TAGS) - and not direct_motor(root) then - allow_descendant_fallback = false - end - end - if allow_descendant_fallback then - entity_tree.walk(root, function(candidate) - if targeting.is_creature(candidate, player_entity, memo) then - if EntityGetFirstComponentIncludingDisabled(candidate, "DamageModelComponent") ~= nil then - found = candidate - return false - end - if structural_fallback == 0 then structural_fallback = candidate end - end - end) + if not tree_has_component(entity, "DamageModelComponent") then return false end + for _, component_type in ipairs(CREATURE_MOTOR_COMPONENTS) do + if tree_has_component(entity, component_type) then return true end end - return found ~= 0 and found or structural_fallback + return false end function targeting.center(entity) @@ -223,20 +72,19 @@ function targeting.target_under_cursor(player_entity, radius) if not query_succeeded or type(nearby_entities) ~= "table" then return 0 end local best_entity, best_distance_squared = 0, math.huge - local visited_creatures = {} - local memo = { signatures={} } + local visited_roots = {} for _, raw_entity in ipairs(nearby_entities) do - -- Most click misses now terminate in is_creature's direct tag/path/component - -- prefilter. No XML catalogue scan is performed for ordinary rocks/pickups/props. - local creature = creature_from_entity(raw_entity, player_entity, memo) - if valid_entity(creature) and not visited_creatures[creature] then - visited_creatures[creature] = true - local center_x, center_y = targeting.center(creature) - if center_x ~= nil then - local delta_x, delta_y = center_x - mouse_x, center_y - mouse_y - local distance_squared = delta_x * delta_x + delta_y * delta_y - if distance_squared < best_distance_squared then - best_entity, best_distance_squared = creature, distance_squared + local root_entity = entity_tree.root(raw_entity) + if valid_entity(root_entity) and not visited_roots[root_entity] then + visited_roots[root_entity] = true + if targeting.is_creature(root_entity, player_entity) then + local center_x, center_y = targeting.center(root_entity) + if center_x ~= nil then + local delta_x, delta_y = center_x - mouse_x, center_y - mouse_y + local distance_squared = delta_x * delta_x + delta_y * delta_y + if distance_squared < best_distance_squared then + best_entity, best_distance_squared = root_entity, distance_squared + end end end end @@ -245,7 +93,8 @@ function targeting.target_under_cursor(player_entity, radius) end function targeting.transform_plan(entity_filename) - if type(creature_service) == "table" then + local loaded, creature_service = pcall(dofile, "mods/metamorph_creative_menu/files/features/creatures/service.lua") + if loaded and type(creature_service) == "table" then if type(creature_service.transform_plan) == "function" then local resolved, plan = pcall(creature_service.transform_plan, entity_filename) if resolved and type(plan) == "table" and type(plan.target_path) == "string" and plan.target_path ~= "" then diff --git a/metamorph_creative_menu/files/features/spells/catalog.lua b/metamorph_creative_menu/files/features/spells/catalog.lua index ed42c7f..a715a00 100644 --- a/metamorph_creative_menu/files/features/spells/catalog.lua +++ b/metamorph_creative_menu/files/features/spells/catalog.lua @@ -11,16 +11,11 @@ local BACKGROUND_BY_TYPE = nil local function load_vanilla_actions() if loaded then return unique_actions ~= nil end + loaded = true local enums_loaded = pcall(dofile_once, "data/scripts/gun/gun_enums.lua") local actions_loaded = pcall(dofile_once, "data/scripts/gun/gun_actions.lua") - if not enums_loaded or not actions_loaded or type(actions) ~= "table" then - -- Loading can legitimately fail during an early/partial initialization frame. - -- Do not poison the catalogue for the rest of the Lua session; a later draw may retry. - loaded = false - return false - end - loaded = true + if not enums_loaded or not actions_loaded or type(actions) ~= "table" then return false end unique_actions = {} actions_by_id = {} diff --git a/metamorph_creative_menu/files/features/spells/factory.lua b/metamorph_creative_menu/files/features/spells/factory.lua deleted file mode 100644 index 1e26672..0000000 --- a/metamorph_creative_menu/files/features/spells/factory.lua +++ /dev/null @@ -1,36 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_SPELL_FACTORY) == "table" then return METAMORPH_CREATIVE_MENU_SPELL_FACTORY end - -local spell_factory = {} -local spell_catalog = dofile("mods/metamorph_creative_menu/files/features/spells/catalog.lua") - -local function unlock_flag(action_id) - if not spell_catalog.load() then return nil end - local actions = spell_catalog.by_id() - local action = type(actions) == "table" and actions[action_id] or nil - local flag = type(action) == "table" and action.spawn_requires_flag or nil - return type(flag) == "string" and flag ~= "" and flag or nil -end - -function spell_factory.create(action_id) - if type(action_id) ~= "string" or action_id == "" then return 0, "invalid_action" end - local flag = unlock_flag(action_id) - local had_flag = nil - if flag ~= nil and type(GameHasFlagPersistent) == "function" then - local ok, value = pcall(GameHasFlagPersistent, flag) - if ok then had_flag = value == true end - end - - local entity_id = CreateItemActionEntity(action_id) or 0 - if entity_id == 0 then return 0, "create_failed" end - - if flag ~= nil and had_flag == false and type(GameHasFlagPersistent) == "function" - and type(RemoveFlagPersistent) == "function" - then - local ok, now_has = pcall(GameHasFlagPersistent, flag) - if ok and now_has == true then pcall(RemoveFlagPersistent, flag) end - end - return entity_id, "ok" -end - -METAMORPH_CREATIVE_MENU_SPELL_FACTORY = spell_factory -return spell_factory diff --git a/metamorph_creative_menu/files/features/spells/inventory_service.lua b/metamorph_creative_menu/files/features/spells/inventory_service.lua deleted file mode 100644 index 54bedc3..0000000 --- a/metamorph_creative_menu/files/features/spells/inventory_service.lua +++ /dev/null @@ -1,184 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_SPELL_INVENTORY_SERVICE) == "table" then return METAMORPH_CREATIVE_MENU_SPELL_INVENTORY_SERVICE end - -local inventory_service = {} -local inventory_slots = dofile("mods/metamorph_creative_menu/files/platform/noita/inventory_slots.lua") -local spell_factory = dofile("mods/metamorph_creative_menu/files/features/spells/factory.lua") -local spell_service = dofile("mods/metamorph_creative_menu/files/features/spells/service.lua") -local ew_runtime = dofile("mods/metamorph_creative_menu/files/integrations/ew/runtime.lua") -local ew_world_items = dofile("mods/metamorph_creative_menu/files/integrations/ew/world_items.lua") - -local function valid(component) return component ~= nil and component ~= 0 end -local function alive(entity) return entity ~= nil and entity ~= 0 and EntityGetIsAlive(entity) == true end - -local function coordinates(layout, index) - index = math.floor(tonumber(index) or -1) - if type(layout) ~= "table" or index < 0 or index >= layout.width * layout.height then return nil end - return index % layout.width, math.floor(index / layout.width) -end - -local function action_record(record, width) - if type(record) ~= "table" or record.is_action ~= true then return nil end - local action = EntityGetFirstComponentIncludingDisabled(record.entity, "ItemActionComponent") - if not valid(action) then return nil end - local ok, action_id = pcall(ComponentGetValue2, action, "action_id") - if not ok or type(action_id) ~= "string" or action_id == "" then return nil end - return { - entity=record.entity, - item_component=record.item_component, - action_component=action, - action_id=action_id, - x=record.x, y=record.y, - index=record.y * width + record.x, - } -end - -function inventory_service.contents(player) - local layout, reason = inventory_slots.snapshot(player, "inventory_full") - if layout == nil then return nil, reason end - local by_index, entries = {}, {} - for _, record in ipairs(layout.entries) do - local entry = action_record(record, layout.width) - if entry ~= nil then - by_index[entry.index] = entry - entries[#entries + 1] = entry - end - end - return { - inventory=layout.inventory, - width=layout.width, - height=layout.height, - capacity=layout.width * layout.height, - by_index=by_index, - entries=entries, - }, "ok" -end - -local function configure_card(entity, action_id, x, y) - local item = EntityGetFirstComponentIncludingDisabled(entity, "ItemComponent") - local action = EntityGetFirstComponentIncludingDisabled(entity, "ItemActionComponent") - if not valid(item) or not valid(action) then return nil, "components_missing" end - local action_ok, actual_action = pcall(ComponentGetValue2, action, "action_id") - if not action_ok or tostring(actual_action or "") ~= tostring(action_id or "") then return nil, "action_mismatch" end - local writes = { - {"inventory_slot",x,y}, - {"permanently_attached",false}, - {"has_been_picked_by_player",true}, - } - for _, write in ipairs(writes) do - local ok - if write[3] ~= nil then ok = pcall(ComponentSetValue2, item, write[1], write[2], write[3]) - else ok = pcall(ComponentSetValue2, item, write[1], write[2]) end - if not ok then return nil, "configure_failed" end - end - return {entity=entity,item_component=item,action_component=action,action_id=action_id,x=x,y=y}, "ok" -end - -function inventory_service.create_at(player, action_id, index) - local layout, reason = inventory_service.contents(player) - if layout == nil then return false, reason end - local x, y = coordinates(layout, index) - if x == nil then return false, "slot_out_of_range" end - if layout.by_index[index] ~= nil then return false, "occupied" end - - local entity, create_reason = spell_factory.create(action_id) - if entity == nil or entity == 0 then return false, create_reason or "create_failed" end - local record, configure_reason = configure_card(entity, action_id, x, y) - if record == nil then pcall(EntityKill, entity); return false, configure_reason end - local placed, place_reason, expected = inventory_slots.place_exact(player, entity, "inventory_full", x, y) - if not placed then pcall(EntityKill, entity); return false, place_reason end - ew_runtime.force_inventory_sync() - spell_service.expect_exact_slots(player, "inventory_create", expected or { - {entity=entity, parent=layout.inventory, x=x, y=y}, - }) - return true, "created", entity -end - -function inventory_service.move(player, source_entry, target_index) - if type(source_entry) ~= "table" or not alive(source_entry.entity) then return false, "missing_action" end - local layout, reason = inventory_service.contents(player) - if layout == nil then return false, reason end - local x, y = coordinates(layout, target_index) - if x == nil then return false, "slot_out_of_range" end - if source_entry.index == target_index then return true, "unchanged" end - local target = layout.by_index[target_index] - local ok, move_reason, expected - if target ~= nil then - ok, move_reason, expected = inventory_slots.swap_exact(player, source_entry.entity, target.entity) - else - ok, move_reason, expected = inventory_slots.place_exact(player, source_entry.entity, "inventory_full", x, y) - end - if not ok then return false, move_reason end - ew_runtime.force_inventory_sync() - expected = expected or {{entity=source_entry.entity, parent=layout.inventory, x=x, y=y}} - if target ~= nil and #expected == 1 then - expected[#expected + 1] = { - entity=target.entity, parent=layout.inventory, - x=source_entry.x, y=source_entry.y, - } - end - spell_service.expect_exact_slots(player, "inventory_move", expected) - return true, target ~= nil and "swapped" or "moved" -end - -function inventory_service.drop_to_world(player, entry, target_x, target_y) - if type(entry) ~= "table" or not alive(entry.entity) then return false, "missing_action" end - local parent = EntityGetParent(entry.entity) or 0 - if parent == 0 then return false, "not_inventory" end - local plan, plan_reason = spell_service.prepare_world_launch(player, target_x, target_y) - if plan == nil then return false, plan_reason end - local detached = pcall(EntityRemoveFromParent, entry.entity) - if not detached or (type(EntityGetParent) == "function" and EntityGetParent(entry.entity) ~= 0) then - return false, "detach_failed" - end - local launched, launch_reason = spell_service.apply_world_launch(entry.entity, plan, false) - if not launched then - local restored = inventory_slots.place_exact(player, entry.entity, "inventory_full", entry.x, entry.y) - if not restored then - pcall(EntityAddChild, parent, entry.entity) - pcall(ComponentSetValue2, entry.item_component, "inventory_slot", entry.x, entry.y) - pcall(EntitySetComponentsWithTagEnabled, entry.entity, "enabled_in_world", false) - pcall(EntitySetComponentsWithTagEnabled, entry.entity, "enabled_in_inventory", true) - end - ew_runtime.force_inventory_sync() - return false, launch_reason - end - pcall(ew_world_items.notify_world_item, entry.entity) - ew_runtime.force_inventory_sync() - return true, "thrown" -end - -function inventory_service.remove(player, entry, drop_into_world, defer_world_notify) - if type(entry) ~= "table" or not alive(entry.entity) then return false, "missing_action" end - local parent = EntityGetParent(entry.entity) or 0 - if parent == 0 then return false, "not_inventory" end - local detached = pcall(EntityRemoveFromParent, entry.entity) - if not detached or (type(EntityGetParent) == "function" and EntityGetParent(entry.entity) ~= 0) then - return false, "detach_failed" - end - if drop_into_world then - local x, y = EntityGetTransform(player) - if x == nil then - pcall(EntityAddChild, parent, entry.entity) - pcall(ComponentSetValue2, entry.item_component, "inventory_slot", entry.x, entry.y) - return false, "position_failed" - end - pcall(ComponentSetValue2, entry.item_component, "inventory_slot", -1, -1) - EntitySetComponentsWithTagEnabled(entry.entity, "enabled_in_hand", false) - EntitySetComponentsWithTagEnabled(entry.entity, "enabled_in_inventory", false) - EntitySetComponentsWithTagEnabled(entry.entity, "enabled_in_world", true) - EntitySetTransform(entry.entity, x + 12, y - 8) - if defer_world_notify ~= true then ew_world_items.notify_world_item(entry.entity) end - else - local killed = pcall(EntityKill, entry.entity) - if not killed then - pcall(EntityAddChild, parent, entry.entity) - pcall(ComponentSetValue2, entry.item_component, "inventory_slot", entry.x, entry.y) - return false, "kill_failed" - end - end - ew_runtime.force_inventory_sync() - return true, drop_into_world and "dropped" or "deleted" -end - -METAMORPH_CREATIVE_MENU_SPELL_INVENTORY_SERVICE = inventory_service -return inventory_service diff --git a/metamorph_creative_menu/files/features/spells/permanent_service.lua b/metamorph_creative_menu/files/features/spells/permanent_service.lua deleted file mode 100644 index 675ade3..0000000 --- a/metamorph_creative_menu/files/features/spells/permanent_service.lua +++ /dev/null @@ -1,390 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_PERMANENT_SPELL_SERVICE) == "table" then - return METAMORPH_CREATIVE_MENU_PERMANENT_SPELL_SERVICE -end - -local permanent_service = {} -local wand_api = dofile("mods/metamorph_creative_menu/files/platform/noita/wand.lua") -local inventory_slots = dofile("mods/metamorph_creative_menu/files/platform/noita/inventory_slots.lua") -local inventory_policy = dofile("mods/metamorph_creative_menu/files/core/inventory_policy.lua") -local spell_factory = dofile("mods/metamorph_creative_menu/files/features/spells/factory.lua") -local spell_service = dofile("mods/metamorph_creative_menu/files/features/spells/service.lua") -local ew_world_items = dofile("mods/metamorph_creative_menu/files/integrations/ew/world_items.lua") - -local function valid(component) return component ~= nil and component ~= 0 end -local function alive(entity) return entity ~= nil and entity ~= 0 and EntityGetIsAlive(entity) == true end - -local function set_slot(item, x, y) - if not valid(item) then return false end - local wrote = pcall(ComponentSetValue2, item, "inventory_slot", x, y) - local read, actual_x, actual_y = pcall(ComponentGetValue2, item, "inventory_slot") - return wrote and read and tonumber(actual_x) == tonumber(x) and (tonumber(actual_y) or 0) == (tonumber(y) or 0) -end - -local function set_bool(item, field, value) - if not valid(item) then return false end - local wrote = pcall(ComponentSetValue2, item, field, value == true) - local read, actual = pcall(ComponentGetValue2, item, field) - return wrote and read and actual == (value == true) -end - -local function raw_capacity(wand) - local ability = wand_api.ability(wand) - if ability == 0 then return nil, nil, "not_wand" end - local value, ok = wand_api.get_object(ability, "gun_config", "deck_capacity") - value = ok and tonumber(value) or nil - if value == nil or value ~= value or value == math.huge or value == -math.huge then - return nil, nil, "capacity_read_failed" - end - return ability, value, "ok" -end - -local function write_capacity(ability, value) - value = tonumber(value) - if value == nil or value < 1 then return false end - return wand_api.set_object(ability, "gun_config", "deck_capacity", value) -end - -local function attach_wand(wand, entity) - local parent = EntityGetParent(entity) or 0 - if parent ~= 0 and parent ~= wand then pcall(EntityRemoveFromParent, entity) end - if EntityGetParent(entity) ~= wand then - local ok = pcall(EntityAddChild, wand, entity) - if not ok or EntityGetParent(entity) ~= wand then return false end - end - EntitySetComponentsWithTagEnabled(entity, "enabled_in_hand", false) - EntitySetComponentsWithTagEnabled(entity, "enabled_in_inventory", false) - EntitySetComponentsWithTagEnabled(entity, "enabled_in_world", false) - return true -end - -local function detach(entity) - if not alive(entity) then return false end - local ok = pcall(EntityRemoveFromParent, entity) - if not ok then return false end - return type(EntityGetParent) ~= "function" or tonumber(EntityGetParent(entity) or 0) == 0 -end - -local function item_action(entity) - if not alive(entity) then return nil, nil, "missing_action" end - local item = EntityGetFirstComponentIncludingDisabled(entity, "ItemComponent") - local action = EntityGetFirstComponentIncludingDisabled(entity, "ItemActionComponent") - if not valid(item) or not valid(action) then return nil, nil, "components_missing" end - return item, action, "ok" -end - -local function restore_permanent(wand, entry) - if type(entry) ~= "table" or not alive(entry.entity) then return end - local parent = EntityGetParent(entry.entity) or 0 - if parent ~= 0 and parent ~= wand then pcall(EntityRemoveFromParent, entry.entity) end - attach_wand(wand, entry.entity) - set_slot(entry.item_component, entry.actual_slot or -1, entry.actual_slot_y or -1) - set_bool(entry.item_component, "permanently_attached", true) - pcall(ComponentSetValue2, entry.item_component, "has_been_picked_by_player", true) -end - -local function restore_regular(wand, entry) - if type(entry) ~= "table" or not alive(entry.entity) then return end - local parent = EntityGetParent(entry.entity) or 0 - if parent ~= 0 and parent ~= wand then pcall(EntityRemoveFromParent, entry.entity) end - attach_wand(wand, entry.entity) - set_bool(entry.item_component, "permanently_attached", false) - set_slot(entry.item_component, entry.actual_slot or entry.slot or 0, entry.actual_slot_y or 0) -end - -local function restore_inventory(player, entry, x, y) - if type(entry) ~= "table" or not alive(entry.entity) then return end - local parent = EntityGetParent(entry.entity) or 0 - if parent ~= 0 then pcall(EntityRemoveFromParent, entry.entity) end - set_bool(entry.item_component, "permanently_attached", false) - inventory_slots.place_exact(player, entry.entity, "inventory_full", x, y) -end - -local function finish(player, wand) - spell_service.refresh(player, wand) -end - -function permanent_service.add(player, wand, action_id) - if type(action_id) ~= "string" or action_id == "" then return false, "invalid_action" end - local ability, raw, reason = raw_capacity(wand) - if ability == nil then return false, reason end - local entity, create_reason = spell_factory.create(action_id) - if entity == nil or entity == 0 then return false, create_reason or "create_failed" end - local item, action, component_reason = item_action(entity) - if item == nil then pcall(EntityKill, entity); return false, component_reason end - local read_action, actual_action = pcall(ComponentGetValue2, action, "action_id") - if not read_action or tostring(actual_action or "") ~= action_id then - pcall(EntityKill, entity); return false, "action_mismatch" - end - if not set_slot(item, -1, -1) or not set_bool(item, "permanently_attached", true) then - pcall(EntityKill, entity); return false, "configure_failed" - end - pcall(ComponentSetValue2, item, "has_been_picked_by_player", true) - if not attach_wand(wand, entity) then pcall(EntityKill, entity); return false, "attach_failed" end - if not write_capacity(ability, raw + 1) then - pcall(EntityRemoveFromParent, entity); pcall(EntityKill, entity) - return false, "capacity_write_failed" - end - finish(player, wand) - return true, "permanent_added", entity -end - -function permanent_service.replace(player, wand, entry, action_id) - if type(entry) ~= "table" or not alive(entry.entity) then return false, "missing_action" end - if type(action_id) ~= "string" or action_id == "" then return false, "invalid_action" end - if tostring(entry.action_id or "") == action_id then return true, "unchanged" end - - local entity, create_reason = spell_factory.create(action_id) - if entity == nil or entity == 0 then return false, create_reason or "create_failed" end - local item, action, component_reason = item_action(entity) - if item == nil then pcall(EntityKill, entity); return false, component_reason end - local read_action, actual_action = pcall(ComponentGetValue2, action, "action_id") - if not read_action or tostring(actual_action or "") ~= action_id then - pcall(EntityKill, entity); return false, "action_mismatch" - end - if not set_slot(item, -1, -1) or not set_bool(item, "permanently_attached", true) then - pcall(EntityKill, entity); return false, "configure_failed" - end - pcall(ComponentSetValue2, item, "has_been_picked_by_player", true) - if not attach_wand(wand, entity) then pcall(EntityKill, entity); return false, "attach_failed" end - - if not detach(entry.entity) then - pcall(EntityRemoveFromParent, entity); pcall(EntityKill, entity) - return false, "old_detach_failed" - end - local killed = pcall(EntityKill, entry.entity) - if not killed then - restore_permanent(wand, entry) - pcall(EntityRemoveFromParent, entity); pcall(EntityKill, entity) - return false, "old_kill_failed" - end - finish(player, wand) - return true, "permanent_replaced", entity -end - -function permanent_service.promote(player, wand, entry) - if type(entry) ~= "table" or not alive(entry.entity) then return false, "missing_action" end - -- Be idempotent against a stale UI entry being submitted twice in one release frame. - -- A second capacity increment for an already-permanent card creates a phantom ordinary - -- wand slot even though there is still only one actual Always Cast entity. - local ok_permanent, already_permanent = pcall(ComponentGetValue2, entry.item_component, "permanently_attached") - if ok_permanent and already_permanent == true then return false, "already_permanent" end - local ability, raw, reason = raw_capacity(wand) - if ability == nil then return false, reason end - local old_x, old_y = entry.actual_slot or entry.slot or 0, entry.actual_slot_y or 0 - if not set_slot(entry.item_component, -1, -1) then return false, "slot_write_failed" end - if not set_bool(entry.item_component, "permanently_attached", true) then - set_slot(entry.item_component, old_x, old_y) - return false, "permanent_write_failed" - end - if not write_capacity(ability, raw + 1) then - set_bool(entry.item_component, "permanently_attached", false) - set_slot(entry.item_component, old_x, old_y) - return false, "capacity_write_failed" - end - finish(player, wand) - return true, "promoted" -end - -function permanent_service.demote(player, wand, entry, target_slot) - if type(entry) ~= "table" or not alive(entry.entity) then return false, "missing_action" end - target_slot = math.max(0, math.floor(tonumber(target_slot) or 0)) - local slots, highest, permanent_count = spell_service.contents(wand) - local cap = spell_service.capacity(wand, highest, permanent_count) - if target_slot >= cap then return false, "slot_out_of_range" end - if slots[target_slot] ~= nil then return false, "occupied" end - local ability, raw, reason = raw_capacity(wand) - if ability == nil then return false, reason end - if raw - 1 < 1 then return false, "capacity_underflow" end - local old_x, old_y = entry.actual_slot or -1, entry.actual_slot_y or -1 - if not set_bool(entry.item_component, "permanently_attached", false) then return false, "permanent_write_failed" end - if not set_slot(entry.item_component, target_slot, 0) then - set_bool(entry.item_component, "permanently_attached", true) - return false, "slot_write_failed" - end - if not write_capacity(ability, raw - 1) then - set_slot(entry.item_component, old_x, old_y) - set_bool(entry.item_component, "permanently_attached", true) - return false, "capacity_write_failed" - end - finish(player, wand) - return true, "demoted", target_slot -end - --- Exchanging a normal and an Always Cast card keeps the permanent count unchanged, --- so raw deck capacity must remain unchanged as well. -function permanent_service.swap_with_slot(player, wand, permanent_entry, regular_entry) - if type(permanent_entry) ~= "table" or not alive(permanent_entry.entity) then return false, "permanent_missing" end - if type(regular_entry) ~= "table" or not alive(regular_entry.entity) then return false, "regular_missing" end - local target_slot = math.max(0, math.floor(tonumber(regular_entry.slot) or 0)) - local p_x, p_y = permanent_entry.actual_slot or -1, permanent_entry.actual_slot_y or -1 - local r_x, r_y = regular_entry.actual_slot or target_slot, regular_entry.actual_slot_y or 0 - - if not set_slot(regular_entry.item_component, -1, -1) then return false, "regular_slot_failed" end - if not set_bool(regular_entry.item_component, "permanently_attached", true) then - set_slot(regular_entry.item_component, r_x, r_y); return false, "regular_promote_failed" - end - if not set_bool(permanent_entry.item_component, "permanently_attached", false) then - set_bool(regular_entry.item_component, "permanently_attached", false); set_slot(regular_entry.item_component, r_x, r_y) - return false, "permanent_demote_failed" - end - if not set_slot(permanent_entry.item_component, target_slot, 0) then - set_bool(permanent_entry.item_component, "permanently_attached", true); set_slot(permanent_entry.item_component, p_x, p_y) - set_bool(regular_entry.item_component, "permanently_attached", false); set_slot(regular_entry.item_component, r_x, r_y) - return false, "permanent_slot_failed" - end - finish(player, wand) - return true, "swapped_permanent" -end - -function permanent_service.adopt_inventory(player, wand, source_entry) - if type(source_entry) ~= "table" or not alive(source_entry.entity) then return false, "source_missing" end - if not inventory_slots.is_inside_player_inventory(player, source_entry.entity) then return false, "source_not_inventory" end - local source_x, source_y = math.floor(tonumber(source_entry.x) or -1), math.floor(tonumber(source_entry.y) or -1) - if source_x < 0 or source_y < 0 then return false, "source_slot_missing" end - local ability, raw, reason = raw_capacity(wand) - if ability == nil then return false, reason end - - if not detach(source_entry.entity) then return false, "source_detach_failed" end - if not set_slot(source_entry.item_component, -1, -1) or not set_bool(source_entry.item_component, "permanently_attached", true) - or not attach_wand(wand, source_entry.entity) - then - restore_inventory(player, source_entry, source_x, source_y) - return false, "source_attach_failed" - end - if not write_capacity(ability, raw + 1) then - restore_inventory(player, source_entry, source_x, source_y) - return false, "capacity_write_failed" - end - finish(player, wand) - return true, "inventory_promoted" -end - -function permanent_service.export_to_inventory_slot(player, wand, entry, x, y) - if type(entry) ~= "table" or not alive(entry.entity) then return false, "missing_action" end - x, y = math.floor(tonumber(x) or -1), math.floor(tonumber(y) or -1) - if x < 0 or y < 0 then return false, "slot_out_of_range" end - local layout, layout_reason = inventory_slots.snapshot(player, "inventory_full") - if layout == nil then return false, layout_reason end - local occupied = layout.by_slot[inventory_policy.slot_key(x, y)] - if occupied ~= nil and occupied.entity ~= entry.entity then return false, "occupied", occupied end - local ability, raw, reason = raw_capacity(wand) - if ability == nil then return false, reason end - if raw - 1 < 1 then return false, "capacity_underflow" end - - if not detach(entry.entity) then return false, "detach_failed" end - set_bool(entry.item_component, "permanently_attached", false) - -- Capacity is the only fallible wand mutation that must happen around this transfer. - -- Commit it before exact placement because the full-build exact path may deliberately - -- rehydrate the spell into a new entity id; after a successful placement there must be - -- no caller-side failure that tries to resurrect the now-retired old id. - if not write_capacity(ability, raw - 1) then - restore_permanent(wand, entry) - return false, "capacity_write_failed" - end - local placed, place_reason, inventory_expected = inventory_slots.place_exact(player, entry.entity, "inventory_full", x, y) - if not placed then - write_capacity(ability, raw) - if alive(entry.entity) then restore_permanent(wand, entry) end - return false, place_reason - end - finish(player, wand) - spell_service.expect_exact_slots(player, "permanent_to_inventory", inventory_expected or { - {entity=entry.entity, parent=layout.inventory, x=x, y=y}, - }) - return true, "inventory_slot" -end - -function permanent_service.swap_with_inventory(player, wand, permanent_entry, inventory_entry) - if type(permanent_entry) ~= "table" or not alive(permanent_entry.entity) then return false, "permanent_missing" end - if type(inventory_entry) ~= "table" or not alive(inventory_entry.entity) then return false, "inventory_missing" end - local x, y = math.floor(tonumber(inventory_entry.x) or -1), math.floor(tonumber(inventory_entry.y) or -1) - if x < 0 or y < 0 then return false, "inventory_slot_missing" end - - if not detach(inventory_entry.entity) then return false, "inventory_detach_failed" end - if not set_slot(inventory_entry.item_component, -1, -1) or not set_bool(inventory_entry.item_component, "permanently_attached", true) - or not attach_wand(wand, inventory_entry.entity) - then - restore_inventory(player, inventory_entry, x, y) - return false, "inventory_promote_failed" - end - if not detach(permanent_entry.entity) then - restore_inventory(player, inventory_entry, x, y) - return false, "permanent_detach_failed" - end - set_bool(permanent_entry.item_component, "permanently_attached", false) - local placed, place_reason, inventory_expected = inventory_slots.place_exact(player, permanent_entry.entity, "inventory_full", x, y) - if not placed then - restore_permanent(wand, permanent_entry) - restore_inventory(player, inventory_entry, x, y) - return false, place_reason or "inventory_place_failed" - end - finish(player, wand) - spell_service.expect_exact_slots(player, "permanent_inventory_swap", inventory_expected or { - {entity=permanent_entry.entity, parent=EntityGetParent(permanent_entry.entity), x=x, y=y}, - }) - return true, "swapped_inventory_permanent" -end - -function permanent_service.move_to_inventory(player, wand, entry) - if type(entry) ~= "table" or not alive(entry.entity) then return false, "missing_action" end - local plan, plan_reason = inventory_slots.preflight(player, entry.entity) - if plan == nil then return false, plan_reason or "inventory_full" end - return permanent_service.export_to_inventory_slot(player, wand, entry, plan.x, plan.y) -end - -function permanent_service.drop_to_world(player, wand, entry, target_x, target_y) - if type(entry) ~= "table" or not alive(entry.entity) then return false, "missing_action" end - local ability, raw, reason = raw_capacity(wand) - if ability == nil then return false, reason end - if raw - 1 < 1 then return false, "capacity_underflow" end - local plan, plan_reason = spell_service.prepare_world_launch(player, target_x, target_y) - if plan == nil then return false, plan_reason end - if not detach(entry.entity) then return false, "detach_failed" end - if not write_capacity(ability, raw - 1) then restore_permanent(wand, entry); return false, "capacity_write_failed" end - local launched, launch_reason = spell_service.apply_world_launch(entry.entity, plan, false) - if not launched then - write_capacity(ability, raw) - restore_permanent(wand, entry) - finish(player, wand) - return false, launch_reason - end - pcall(ew_world_items.notify_world_item, entry.entity) - finish(player, wand) - return true, "thrown" -end - -function permanent_service.remove(player, wand, entry, drop_into_world, defer_world_notify) - if type(entry) ~= "table" or not alive(entry.entity) then return false, "missing_action" end - local ability, raw, reason = raw_capacity(wand) - if ability == nil then return false, reason end - if raw - 1 < 1 then return false, "capacity_underflow" end - local px, py - if drop_into_world then - px, py = EntityGetTransform(player) - if px == nil then return false, "position_failed" end - end - if not detach(entry.entity) then return false, "detach_failed" end - if not write_capacity(ability, raw - 1) then restore_permanent(wand, entry); return false, "capacity_write_failed" end - - if drop_into_world then - set_bool(entry.item_component, "permanently_attached", false) - set_slot(entry.item_component, -1, -1) - EntitySetComponentsWithTagEnabled(entry.entity, "enabled_in_hand", false) - EntitySetComponentsWithTagEnabled(entry.entity, "enabled_in_inventory", false) - EntitySetComponentsWithTagEnabled(entry.entity, "enabled_in_world", true) - EntitySetTransform(entry.entity, px + 12, py - 8) - if defer_world_notify ~= true then ew_world_items.notify_world_item(entry.entity) end - else - local killed = pcall(EntityKill, entry.entity) - if not killed then - write_capacity(ability, raw) - restore_permanent(wand, entry) - return false, "kill_failed" - end - end - finish(player, wand) - return true, drop_into_world and "dropped" or "deleted" -end - -METAMORPH_CREATIVE_MENU_PERMANENT_SPELL_SERVICE = permanent_service -return permanent_service diff --git a/metamorph_creative_menu/files/features/spells/service.lua b/metamorph_creative_menu/files/features/spells/service.lua index 0d46d1c..f8b5591 100644 --- a/metamorph_creative_menu/files/features/spells/service.lua +++ b/metamorph_creative_menu/files/features/spells/service.lua @@ -1,30 +1,26 @@ -if type(METAMORPH_CREATIVE_MENU_SPELL_SERVICE) == "table" then - return METAMORPH_CREATIVE_MENU_SPELL_SERVICE -end - local spell_service = {} local ew_runtime = dofile("mods/metamorph_creative_menu/files/integrations/ew/runtime.lua") -local ew_world_items = dofile("mods/metamorph_creative_menu/files/integrations/ew/world_items.lua") -local wand_api = dofile("mods/metamorph_creative_menu/files/platform/noita/wand.lua") -local inventory_slots = dofile("mods/metamorph_creative_menu/files/platform/noita/inventory_slots.lua") -local spell_factory = dofile("mods/metamorph_creative_menu/files/features/spells/factory.lua") -local slot_settler = dofile("mods/metamorph_creative_menu/files/features/spells/slot_settler.lua") + +local MAX_VISIBLE_CAPACITY = 128 local function valid_component(component_id) return component_id ~= nil and component_id ~= 0 end -local function alive(entity_id) - return entity_id ~= nil and entity_id ~= 0 and EntityGetIsAlive(entity_id) == true +function spell_service.held_wand(player_entity_id) + local inventory_component = EntityGetFirstComponentIncludingDisabled(player_entity_id, "Inventory2Component") + if not valid_component(inventory_component) then return 0 end + local active_item_entity_id = ComponentGetValue2(inventory_component, "mActiveItem") or 0 + if active_item_entity_id == 0 or not EntityGetIsAlive(active_item_entity_id) then return 0 end + local ability_component = EntityGetFirstComponentIncludingDisabled(active_item_entity_id, "AbilityComponent") + if not valid_component(ability_component) or ComponentGetValue2(ability_component, "use_gun_script") ~= true then return 0 end + return active_item_entity_id end -function spell_service.held_wand(player_entity_id) return wand_api.held(player_entity_id) end - --- Modified/generated wands can contain several actions reporting slot 0. Build a stable --- logical view first; mutations normalize only when they can roll the change back. +-- Modified/generated wands can contain several actions reporting slot 0. Normalize them +-- into stable logical slots before any destructive operation so no action is lost. function spell_service.contents(wand_entity_id) local entries = {} - local permanent_entries = {} local occupied_slots = {} local permanent_action_count = 0 for _, child_entity_id in ipairs(EntityGetAllChildren(wand_entity_id) or {}) do @@ -38,48 +34,23 @@ function spell_service.contents(wand_entity_id) slot_y = tonumber(slot_y) or 0 if permanently_attached then permanent_action_count = permanent_action_count + 1 - if type(action_id) == "string" and action_id ~= "" then - permanent_entries[#permanent_entries + 1] = { - entity = child_entity_id, - item_component = item_component, - action_component = action_component, - action_id = action_id, - actual_slot = slot_x, - actual_slot_y = slot_y, - permanent = true, - } - end elseif type(action_id) == "string" and slot_x ~= nil and slot_x >= 0 and slot_y == 0 then - entries[#entries + 1] = { + local entry = { entity = child_entity_id, item_component = item_component, - action_component = action_component, action_id = action_id, actual_slot = slot_x, - actual_slot_y = slot_y, slot = nil, - permanent = false, } + if slot_x > 0 and not occupied_slots[slot_x] then + entry.slot = slot_x + occupied_slots[slot_x] = true + end + entries[#entries + 1] = entry end end end - -- EntityGetAllChildren ordering is not an API contract. Sort before resolving duplicate - -- coordinates so a malformed/modded wand gets the same logical slot assignment each draw. - table.sort(entries, function(left, right) - if left.actual_slot ~= right.actual_slot then return left.actual_slot < right.actual_slot end - return tonumber(left.entity) < tonumber(right.entity) - end) - - -- Reserve non-zero coordinates first. Several generated wands report every action as 0:0; - -- treating zero as ambiguous lets exactly one of those actions occupy slot zero afterwards. - for _, entry in ipairs(entries) do - if entry.actual_slot > 0 and not occupied_slots[entry.actual_slot] then - entry.slot = entry.actual_slot - occupied_slots[entry.actual_slot] = true - end - end - local next_free_slot = 0 for _, entry in ipairs(entries) do if entry.slot == nil then @@ -89,53 +60,21 @@ function spell_service.contents(wand_entity_id) end end - table.sort(permanent_entries, function(left, right) - if tostring(left.action_id) ~= tostring(right.action_id) then - return tostring(left.action_id) < tostring(right.action_id) - end - return tonumber(left.entity) < tonumber(right.entity) - end) - local entries_by_slot = {} local highest_slot = -1 for _, entry in ipairs(entries) do entries_by_slot[entry.slot] = entry highest_slot = math.max(highest_slot, entry.slot) end - return entries_by_slot, highest_slot, permanent_action_count, entries, permanent_entries -end - -local function set_slot(item_component, x, y) - if not valid_component(item_component) then return false end - local wrote = pcall(ComponentSetValue2, item_component, "inventory_slot", x, y or 0) - if not wrote then return false end - local read, actual_x, actual_y = pcall(ComponentGetValue2, item_component, "inventory_slot") - return read and tonumber(actual_x) == tonumber(x) and (tonumber(actual_y) or 0) == (tonumber(y) or 0) -end - -local function rollback_slot_changes(changes) - for index = #(changes or {}), 1, -1 do - local change = changes[index] - pcall(ComponentSetValue2, change.component, "inventory_slot", change.x, change.y) - end + return entries_by_slot, highest_slot, permanent_action_count, entries end -local function normalize_slots(entries, excluded_entity) - local changes = {} +local function normalize_slots(entries) for _, entry in ipairs(entries or {}) do - if entry.entity ~= excluded_entity and entry.actual_slot ~= entry.slot then - changes[#changes + 1] = { - component=entry.item_component, - x=entry.actual_slot, - y=entry.actual_slot_y or 0, - } - if not set_slot(entry.item_component, entry.slot, 0) then - rollback_slot_changes(changes) - return false, {}, "slot_normalization_failed" - end + if entry.actual_slot ~= entry.slot then + pcall(ComponentSetValue2, entry.item_component, "inventory_slot", entry.slot, 0) end end - return true, changes, "ok" end function spell_service.capacity(wand_entity_id, highest_slot, permanent_action_count) @@ -146,10 +85,32 @@ function spell_service.capacity(wand_entity_id, highest_slot, permanent_action_c if read_succeeded then deck_capacity = tonumber(value) or 0 end end deck_capacity = deck_capacity - (permanent_action_count or 0) - if deck_capacity ~= deck_capacity or deck_capacity == math.huge or deck_capacity == -math.huge then deck_capacity = 0 end - -- The horizontal strip virtualizes rendering, so large modded capacities no longer need - -- the old 128-slot UI cap. highest_slot still keeps malformed-but-populated wands editable. - return math.max(math.floor(deck_capacity), (highest_slot or -1) + 1, 1) + return math.min(math.max(math.floor(deck_capacity), (highest_slot or -1) + 1, 1), MAX_VISIBLE_CAPACITY) +end + +local function capture_mana_state(wand_entity_id) + if wand_entity_id == nil or wand_entity_id == 0 or not EntityGetIsAlive(wand_entity_id) then return nil end + local ability_component = EntityGetFirstComponentIncludingDisabled(wand_entity_id, "AbilityComponent") + if not valid_component(ability_component) then return nil end + local mana_state = { ability = ability_component } + for _, field_name in ipairs({ "mana", "mana_max", "mana_charge_speed" }) do + local read_succeeded, value = pcall(ComponentGetValue2, ability_component, field_name) + if read_succeeded and value ~= nil then mana_state[field_name] = value end + end + return mana_state +end + +local function restore_mana_state(mana_state) + if type(mana_state) ~= "table" or not valid_component(mana_state.ability) then return end + for _, field_name in ipairs({ "mana", "mana_max", "mana_charge_speed" }) do + local original_value = mana_state[field_name] + if original_value ~= nil then + local read_succeeded, current_value = pcall(ComponentGetValue2, mana_state.ability, field_name) + if read_succeeded and current_value ~= original_value then + pcall(ComponentSetValue2, mana_state.ability, field_name, original_value) + end + end + end end local function refresh_wand_and_inventory(player_entity_id, wand_entity_id) @@ -159,150 +120,61 @@ local function refresh_wand_and_inventory(player_entity_id, wand_entity_id) -- vanilla inventory code repairs it. pcall(ComponentSetValue2, inventory_component, "mForceRefresh", true) end - -- GameRegenItemActionsInContainer/Player are gameplay APIs for regenerating action - -- cards (not UI refresh calls). Invoking them after a drag lets Noita recreate or - -- renormalize the cards we just positioned, which is why an exact drop could land in - -- an adjacent slot. Child/ItemComponent mutations are already live; mForceRefresh is - -- the only vanilla inventory refresh required here. + local mana_state = capture_mana_state(wand_entity_id) + if wand_entity_id ~= nil and wand_entity_id ~= 0 then pcall(GameRegenItemActionsInContainer, wand_entity_id) end + pcall(GameRegenItemActionsInPlayer, player_entity_id) + restore_mana_state(mana_state) ew_runtime.force_inventory_sync() end -function spell_service.refresh(player_entity_id, wand_entity_id) - refresh_wand_and_inventory(player_entity_id, wand_entity_id) -end - -function spell_service.expect_exact_slots(player_entity_id, label, specs) - return slot_settler.schedule(player_entity_id, label, specs, ew_runtime.force_inventory_sync) -end - -function spell_service.settle_slots() - return slot_settler.update() -end - -local function configure_action_entity(entity_id, action_id, slot_index) - if not alive(entity_id) then return nil, nil, "create_failed" end - local item_component = EntityGetFirstComponentIncludingDisabled(entity_id, "ItemComponent") - local action_component = EntityGetFirstComponentIncludingDisabled(entity_id, "ItemActionComponent") +function spell_service.replace(player_entity_id, wand_entity_id, slot_index, action_id, existing_entry, entries) + local created_entity_id = CreateItemActionEntity(action_id) + if created_entity_id == nil or created_entity_id == 0 then return false end + local item_component = EntityGetFirstComponentIncludingDisabled(created_entity_id, "ItemComponent") + local action_component = EntityGetFirstComponentIncludingDisabled(created_entity_id, "ItemActionComponent") if not valid_component(item_component) or not valid_component(action_component) then - pcall(EntityKill, entity_id) - return nil, nil, "components_missing" - end - if slot_index ~= nil and not set_slot(item_component, slot_index, 0) then - pcall(EntityKill, entity_id) - return nil, nil, "slot_write_failed" + EntityKill(created_entity_id) + return false end + pcall(ComponentSetValue2, item_component, "inventory_slot", slot_index, 0) pcall(ComponentSetValue2, item_component, "has_been_picked_by_player", true) pcall(ComponentSetValue2, item_component, "permanently_attached", false) - local ok_action, actual_action = pcall(ComponentGetValue2, action_component, "action_id") - if not ok_action or tostring(actual_action or "") ~= tostring(action_id or "") then - pcall(EntityKill, entity_id) - return nil, nil, "action_mismatch" - end - return item_component, action_component, "ok" -end + normalize_slots(entries) -local function detach_verified(entity_id) - if not alive(entity_id) then return false end - local detached = pcall(EntityRemoveFromParent, entity_id) - if not detached then return false end - if type(EntityGetParent) ~= "function" then return true end - local read, parent = pcall(EntityGetParent, entity_id) - return read and tonumber(parent) == 0 -end - -local function attach_verified(parent_id, entity_id) - local attached = pcall(EntityAddChild, parent_id, entity_id) - if not attached then return false end - if type(EntityGetParent) ~= "function" then return true end - local read, parent = pcall(EntityGetParent, entity_id) - return read and tonumber(parent) == tonumber(parent_id) -end - -function spell_service.replace(player_entity_id, wand_entity_id, slot_index, action_id, existing_entry, entries) - slot_index = math.max(0, math.floor(tonumber(slot_index) or 0)) - if type(action_id) ~= "string" or action_id == "" then return false, "invalid_action" end - if existing_entry ~= nil and tostring(existing_entry.action_id or "") == action_id then - return true, "unchanged" - end - - local created_entity_id = select(1, spell_factory.create(action_id)) - if created_entity_id == nil or created_entity_id == 0 then return false, "create_failed" end - local item_component, action_component, configure_reason = configure_action_entity(created_entity_id, action_id, slot_index) - if item_component == nil then return false, configure_reason end - - -- Prepare and verify the replacement before mutating any existing card. This is the - -- reversible side of the transaction. - if not attach_verified(wand_entity_id, created_entity_id) then - pcall(EntityRemoveFromParent, created_entity_id) - pcall(EntityKill, created_entity_id) - return false, "attach_failed" + local attached = pcall(EntityAddChild, wand_entity_id, created_entity_id) + local parent_ok = false + if attached and type(EntityGetParent) == "function" then + local ok_parent, parent = pcall(EntityGetParent, created_entity_id) + parent_ok = ok_parent and tonumber(parent) == tonumber(wand_entity_id) end local ok_slot, actual_slot_x, actual_slot_y = pcall(ComponentGetValue2, item_component, "inventory_slot") local ok_action, actual_action = pcall(ComponentGetValue2, action_component, "action_id") - if not ok_slot or tonumber(actual_slot_x) ~= slot_index or (tonumber(actual_slot_y) or 0) ~= 0 - or not ok_action or tostring(actual_action or "") ~= action_id - then - pcall(EntityRemoveFromParent, created_entity_id) + local verified = attached and parent_ok and ok_slot + and tonumber(actual_slot_x) == tonumber(slot_index) and (tonumber(actual_slot_y) or 0) == 0 + and ok_action and tostring(actual_action or "") == tostring(action_id or "") + if not verified then + if type(EntityRemoveFromParent) == "function" then pcall(EntityRemoveFromParent, created_entity_id) end pcall(EntityKill, created_entity_id) - return false, "verification_failed" - end - - local normalized, slot_changes, normalize_reason = normalize_slots(entries, existing_entry and existing_entry.entity or nil) - if not normalized then - pcall(EntityRemoveFromParent, created_entity_id) - pcall(EntityKill, created_entity_id) - return false, normalize_reason - end - - if existing_entry ~= nil and alive(existing_entry.entity) then - if not detach_verified(existing_entry.entity) then - rollback_slot_changes(slot_changes) - pcall(EntityRemoveFromParent, created_entity_id) - pcall(EntityKill, created_entity_id) - return false, "old_detach_failed" - end - local killed = pcall(EntityKill, existing_entry.entity) - if not killed then - -- Best-effort rollback: restore the detached original before removing the candidate. - pcall(EntityAddChild, wand_entity_id, existing_entry.entity) - set_slot(existing_entry.item_component, existing_entry.actual_slot, existing_entry.actual_slot_y or 0) - rollback_slot_changes(slot_changes) - pcall(EntityRemoveFromParent, created_entity_id) - pcall(EntityKill, created_entity_id) - return false, "old_kill_failed" - end + return false end EntitySetComponentsWithTagEnabled(created_entity_id, "enabled_in_world", false) + if existing_entry ~= nil and existing_entry.entity ~= nil and EntityGetIsAlive(existing_entry.entity) then + -- Commit point: the replacement is already a verified child of the wand. Only + -- now retire the old action so a failed attach can never destroy the user's spell. + pcall(EntityRemoveFromParent, existing_entry.entity) + pcall(EntityKill, existing_entry.entity) + end refresh_wand_and_inventory(player_entity_id, wand_entity_id) - spell_service.expect_exact_slots(player_entity_id, "replace", { - {entity=created_entity_id, parent=wand_entity_id, x=slot_index, y=0}, - }) - return true, "replaced" + return true end -function spell_service.remove(player_entity_id, wand_entity_id, existing_entry, entries, drop_into_world, defer_world_notify) - if existing_entry == nil or not alive(existing_entry.entity) then return false, "missing_action" end - - -- Detach first, while every other card is untouched. If any later preparatory step fails, - -- the original card can be reattached with its exact old coordinate. - if not detach_verified(existing_entry.entity) then return false, "detach_failed" end - - local normalized, slot_changes, normalize_reason = normalize_slots(entries, existing_entry.entity) - if not normalized then - pcall(EntityAddChild, wand_entity_id, existing_entry.entity) - set_slot(existing_entry.item_component, existing_entry.actual_slot, existing_entry.actual_slot_y or 0) - return false, normalize_reason - end - +function spell_service.remove(player_entity_id, wand_entity_id, existing_entry, entries, drop_into_world) + if existing_entry == nil or existing_entry.entity == nil or not EntityGetIsAlive(existing_entry.entity) then return false end + normalize_slots(entries) + EntityRemoveFromParent(existing_entry.entity) if drop_into_world then local player_x, player_y = EntityGetTransform(player_entity_id) - if player_x == nil then - rollback_slot_changes(slot_changes) - pcall(EntityAddChild, wand_entity_id, existing_entry.entity) - set_slot(existing_entry.item_component, existing_entry.actual_slot, existing_entry.actual_slot_y or 0) - return false, "position_failed" - end local item_component = existing_entry.item_component if valid_component(item_component) then pcall(ComponentSetValue2, item_component, "inventory_slot", -1, -1) @@ -311,361 +183,12 @@ function spell_service.remove(player_entity_id, wand_entity_id, existing_entry, EntitySetComponentsWithTagEnabled(existing_entry.entity, "enabled_in_hand", false) EntitySetComponentsWithTagEnabled(existing_entry.entity, "enabled_in_inventory", false) EntitySetComponentsWithTagEnabled(existing_entry.entity, "enabled_in_world", true) - EntitySetTransform(existing_entry.entity, player_x + 12, player_y - 8) - if defer_world_notify ~= true then ew_world_items.notify_world_item(existing_entry.entity) end + if player_x ~= nil then EntitySetTransform(existing_entry.entity, player_x + 12, player_y - 8) end else - local killed = pcall(EntityKill, existing_entry.entity) - if not killed then - rollback_slot_changes(slot_changes) - pcall(EntityAddChild, wand_entity_id, existing_entry.entity) - set_slot(existing_entry.item_component, existing_entry.actual_slot, existing_entry.actual_slot_y or 0) - return false, "kill_failed" - end - end - refresh_wand_and_inventory(player_entity_id, wand_entity_id) - return true, drop_into_world and "dropped" or "deleted" -end - -local function enable_world(entity_id) - EntitySetComponentsWithTagEnabled(entity_id, "enabled_in_hand", false) - EntitySetComponentsWithTagEnabled(entity_id, "enabled_in_inventory", false) - EntitySetComponentsWithTagEnabled(entity_id, "enabled_in_world", true) -end - -local function enable_world_verified(entity_id) - local hand = pcall(EntitySetComponentsWithTagEnabled, entity_id, "enabled_in_hand", false) - local inventory = pcall(EntitySetComponentsWithTagEnabled, entity_id, "enabled_in_inventory", false) - local world = pcall(EntitySetComponentsWithTagEnabled, entity_id, "enabled_in_world", true) - return hand and inventory and world -end - -local function player_velocity(player_entity_id) - local character = EntityGetFirstComponentIncludingDisabled(player_entity_id, "CharacterDataComponent") - if not valid_component(character) then return 0, 0 end - local ok, vx, vy = pcall(ComponentGetValue2, character, "mVelocity") - if not ok then return 0, 0 end - return tonumber(vx) or 0, tonumber(vy) or 0 -end - -local function set_throw_velocity(entity_id, vx, vy) - local components = {} - if type(EntityGetComponentIncludingDisabled) == "function" then - local ok, value = pcall(EntityGetComponentIncludingDisabled, entity_id, "VelocityComponent") - if ok and type(value) == "table" then components = value end - end - if #components == 0 and type(EntityAddComponent2) == "function" then - local ok, component = pcall(EntityAddComponent2, entity_id, "VelocityComponent", {_tags="enabled_in_world"}) - if ok and valid_component(component) then components = {component} end - end - local wrote = false - for _, component in ipairs(components) do - if pcall(ComponentSetValue2, component, "mVelocity", vx, vy) then wrote = true end - end - return wrote -end - -local function prepare_world_launch(player_entity_id, target_x, target_y) - local player_x, player_y = EntityGetTransform(player_entity_id) - if tonumber(player_x) == nil or tonumber(player_y) == nil then return nil, "position_failed" end - target_x, target_y = tonumber(target_x), tonumber(target_y) - if target_x == nil or target_y == nil then target_x, target_y = player_x + 100, player_y - 8 end - local dx, dy = target_x - player_x, target_y - player_y - local length = math.sqrt(dx * dx + dy * dy) - if length < 0.001 then dx, dy, length = 1, -0.08, 1.003 end - local nx, ny = dx / length, dy / length - local inherited_x, inherited_y = player_velocity(player_entity_id) - local speed = 145 - return { - x=player_x + nx * 13, - y=player_y - 6 + ny * 8, - vx=nx * speed + inherited_x * 0.35, - vy=ny * speed + inherited_y * 0.35, - }, "ok" -end - -function spell_service.prepare_world_launch(player_entity_id, target_x, target_y) - return prepare_world_launch(player_entity_id, target_x, target_y) -end - -function spell_service.apply_world_launch(entity_id, plan, notify_world) - if not alive(entity_id) then return false, "missing_action" end - if type(plan) ~= "table" or tonumber(plan.x) == nil or tonumber(plan.y) == nil then - return false, "invalid_launch_plan" - end - local item_component = EntityGetFirstComponentIncludingDisabled(entity_id, "ItemComponent") - if valid_component(item_component) then - pcall(ComponentSetValue2, item_component, "inventory_slot", -1, -1) - pcall(ComponentSetValue2, item_component, "permanently_attached", false) - end - if not enable_world_verified(entity_id) then return false, "world_enable_failed" end - local moved = pcall(EntitySetTransform, entity_id, plan.x, plan.y) - if not moved then return false, "world_position_failed" end - set_throw_velocity(entity_id, tonumber(plan.vx) or 0, tonumber(plan.vy) or 0) - if notify_world == true then pcall(ew_world_items.notify_world_item, entity_id) end - return true, "thrown" -end - -function spell_service.launch_world_entity(player_entity_id, entity_id, target_x, target_y, notify_world) - if not alive(entity_id) then return false, "missing_action" end - local plan, reason = prepare_world_launch(player_entity_id, target_x, target_y) - if plan == nil then return false, reason end - return spell_service.apply_world_launch(entity_id, plan, notify_world) -end - -function spell_service.throw_catalog(player_entity_id, action_id, target_x, target_y) - if type(action_id) ~= "string" or action_id == "" then return false, "invalid_action", 0 end - local entity_id = select(1, spell_factory.create(action_id)) - if entity_id == nil or entity_id == 0 then return false, "create_failed", 0 end - local item_component = select(1, configure_action_entity(entity_id, action_id, nil)) - if item_component == nil then return false, "components_missing", 0 end - pcall(ComponentSetValue2, item_component, "has_been_picked_by_player", false) - pcall(ComponentSetValue2, item_component, "inventory_slot", -1, -1) - local ok, reason = spell_service.launch_world_entity(player_entity_id, entity_id, target_x, target_y, true) - if not ok then pcall(EntityKill, entity_id); return false, reason, 0 end - return true, "thrown", entity_id -end - -function spell_service.give(player_entity_id, action_id) - if type(action_id) ~= "string" or action_id == "" then return false, "invalid_action", 0, false end - local entity_id = select(1, spell_factory.create(action_id)) - if entity_id == nil or entity_id == 0 then return false, "create_failed", 0, false end - local item_component = select(1, configure_action_entity(entity_id, action_id, nil)) - if item_component == nil then return false, "components_missing", 0, false end - pcall(ComponentSetValue2, item_component, "inventory_slot", -1, -1) - local picked, reason = inventory_slots.pickup(player_entity_id, entity_id, false) - if picked then - pcall(ComponentSetValue2, item_component, "has_been_picked_by_player", true) - ew_runtime.force_inventory_sync() - return true, "picked", entity_id, false - end - if alive(entity_id) then - local x, y = EntityGetTransform(player_entity_id) - if x ~= nil then EntitySetTransform(entity_id, x + 12, y - 8) end - enable_world(entity_id) - ew_world_items.notify_world_item(entity_id) - return false, reason or "inventory_full", entity_id, true - end - return false, reason or "pickup_failed", 0, false -end - -function spell_service.move(player_entity_id, wand_entity_id, existing_entry, target_slot, target_entry, entries) - if existing_entry == nil or not alive(existing_entry.entity) then return false, "missing_action" end - target_slot = math.max(0, math.floor(tonumber(target_slot) or 0)) - if existing_entry.slot == target_slot then return true, "unchanged" end - - -- Normalize all pre-existing logical slots under one rollback journal, then change only - -- the source/target coordinates. Moving onto an occupied slot swaps the two cards. - local normalized, changes, reason = normalize_slots(entries) - if not normalized then return false, reason end - local function fail(message) - -- Restore the two directly edited cards as well as any normalization journal. - -- Without this, failure after the temporary write could strand the source at a - -- negative coordinate and make it disappear from both MCM and vanilla UI. - if target_entry ~= nil and target_entry.entity ~= existing_entry.entity then - pcall(ComponentSetValue2, target_entry.item_component, "inventory_slot", -1, -1) - pcall(ComponentSetValue2, existing_entry.item_component, "inventory_slot", - existing_entry.actual_slot, existing_entry.actual_slot_y or 0) - pcall(ComponentSetValue2, target_entry.item_component, "inventory_slot", - target_entry.actual_slot, target_entry.actual_slot_y or 0) - else - pcall(ComponentSetValue2, existing_entry.item_component, "inventory_slot", - existing_entry.actual_slot, existing_entry.actual_slot_y or 0) - end - rollback_slot_changes(changes) - return false, message - end - if target_entry ~= nil and target_entry.entity ~= existing_entry.entity then - -- Never expose two live cards at the same coordinate, even transiently. Some - -- engine builds resolve that collision immediately and move one card to an - -- adjacent slot before the second write arrives. - if not set_slot(existing_entry.item_component, -1, -1) then - return fail("source_temp_slot_failed") - end - if not set_slot(target_entry.item_component, existing_entry.slot, 0) then - return fail("target_slot_failed") - end - end - if not set_slot(existing_entry.item_component, target_slot, 0) then return fail("source_slot_failed") end - refresh_wand_and_inventory(player_entity_id, wand_entity_id) - local expected = {{entity=existing_entry.entity, parent=wand_entity_id, x=target_slot, y=0}} - if target_entry ~= nil and target_entry.entity ~= existing_entry.entity then - expected[#expected + 1] = { - entity=target_entry.entity, parent=wand_entity_id, x=existing_entry.slot, y=0, - } - end - spell_service.expect_exact_slots(player_entity_id, "wand_move", expected) - return true, target_entry ~= nil and "swapped" or "moved" -end - -local function restore_wand_entry(wand_entity_id, entry) - if entry == nil or not alive(entry.entity) then return end - local parent = type(EntityGetParent) == "function" and EntityGetParent(entry.entity) or 0 - if parent ~= nil and parent ~= 0 and parent ~= wand_entity_id then pcall(EntityRemoveFromParent, entry.entity) end - if parent ~= wand_entity_id then pcall(EntityAddChild, wand_entity_id, entry.entity) end - set_slot(entry.item_component, entry.actual_slot or entry.slot or 0, entry.actual_slot_y or 0) - pcall(ComponentSetValue2, entry.item_component, "permanently_attached", false) - EntitySetComponentsWithTagEnabled(entry.entity, "enabled_in_world", false) - EntitySetComponentsWithTagEnabled(entry.entity, "enabled_in_inventory", false) -end - --- Directional drag-to-world is one source transaction: validate the world target first, --- then detach/normalize, and restore the exact wand source if world activation fails. -function spell_service.drop_to_world(player_entity_id, wand_entity_id, existing_entry, entries, target_x, target_y) - if existing_entry == nil or not alive(existing_entry.entity) then return false, "missing_action" end - local plan, plan_reason = prepare_world_launch(player_entity_id, target_x, target_y) - if plan == nil then return false, plan_reason end - if not detach_verified(existing_entry.entity) then return false, "detach_failed" end - local normalized, slot_changes, normalize_reason = normalize_slots(entries, existing_entry.entity) - if not normalized then - restore_wand_entry(wand_entity_id, existing_entry) - return false, normalize_reason - end - local launched, launch_reason = spell_service.apply_world_launch(existing_entry.entity, plan, false) - if not launched then - rollback_slot_changes(slot_changes) - restore_wand_entry(wand_entity_id, existing_entry) - refresh_wand_and_inventory(player_entity_id, wand_entity_id) - return false, launch_reason - end - pcall(ew_world_items.notify_world_item, existing_entry.entity) - refresh_wand_and_inventory(player_entity_id, wand_entity_id) - return true, "thrown" -end - --- Move a real inventory card into a wand slot. If the wand slot is occupied, the two --- entities are exchanged instead of cloning either card, preserving modded/custom state. -function spell_service.adopt_inventory(player_entity_id, wand_entity_id, source_entry, target_slot, target_entry, entries) - if type(source_entry) ~= "table" or not alive(source_entry.entity) then return false, "source_missing" end - if not inventory_slots.is_inside_player_inventory(player_entity_id, source_entry.entity) then return false, "source_not_inventory" end - target_slot = math.max(0, math.floor(tonumber(target_slot) or 0)) - local source_parent = EntityGetParent(source_entry.entity) or 0 - if source_parent == 0 or EntityGetName(source_parent) ~= "inventory_full" then return false, "source_not_spell_inventory" end - local source_x = math.floor(tonumber(source_entry.x) or -1) - local source_y = math.floor(tonumber(source_entry.y) or 0) - if source_x < 0 then return false, "source_slot_missing" end - - local normalized, slot_changes, normalize_reason = normalize_slots(entries, target_entry and target_entry.entity or nil) - if not normalized then return false, normalize_reason end - - if not detach_verified(source_entry.entity) then - rollback_slot_changes(slot_changes) - return false, "source_detach_failed" - end - pcall(ComponentSetValue2, source_entry.item_component, "permanently_attached", false) - pcall(ComponentSetValue2, source_entry.item_component, "has_been_picked_by_player", true) - if target_entry ~= nil and alive(target_entry.entity) then - -- Vacate the destination before attaching the incoming card. Attaching first - -- creates a short-lived duplicate wand coordinate which Noita may resolve to the - -- previous/next slot on the same update. - if not detach_verified(target_entry.entity) then - inventory_slots.place_exact(player_entity_id, source_entry.entity, "inventory_full", source_x, source_y) - rollback_slot_changes(slot_changes) - return false, "target_detach_failed" - end - pcall(ComponentSetValue2, target_entry.item_component, "permanently_attached", false) - pcall(ComponentSetValue2, target_entry.item_component, "has_been_picked_by_player", true) - end - - if not set_slot(source_entry.item_component, target_slot, 0) or not attach_verified(wand_entity_id, source_entry.entity) then - pcall(EntityRemoveFromParent, source_entry.entity) - inventory_slots.place_exact(player_entity_id, source_entry.entity, "inventory_full", source_x, source_y) - if target_entry ~= nil and alive(target_entry.entity) then restore_wand_entry(wand_entity_id, target_entry) end - rollback_slot_changes(slot_changes) - return false, "source_attach_failed" - end - EntitySetComponentsWithTagEnabled(source_entry.entity, "enabled_in_world", false) - EntitySetComponentsWithTagEnabled(source_entry.entity, "enabled_in_inventory", false) - - if target_entry ~= nil and alive(target_entry.entity) then - local placed, place_reason, inventory_expected = inventory_slots.place_exact(player_entity_id, target_entry.entity, "inventory_full", source_x, source_y) - if not placed then - pcall(EntityRemoveFromParent, source_entry.entity) - inventory_slots.place_exact(player_entity_id, source_entry.entity, "inventory_full", source_x, source_y) - restore_wand_entry(wand_entity_id, target_entry) - rollback_slot_changes(slot_changes) - return false, place_reason or "target_inventory_failed" - end - end - - refresh_wand_and_inventory(player_entity_id, wand_entity_id) - local expected = {{entity=source_entry.entity, parent=wand_entity_id, x=target_slot, y=0}} - if target_entry ~= nil then - if type(inventory_expected) == "table" and #inventory_expected > 0 then - for _, spec in ipairs(inventory_expected) do expected[#expected + 1] = spec end - elseif alive(target_entry.entity) then - expected[#expected + 1] = { - entity=target_entry.entity, parent=source_parent, x=source_x, y=source_y, - } - end - end - spell_service.expect_exact_slots(player_entity_id, "inventory_to_wand", expected) - return true, target_entry ~= nil and "swapped_inventory" or "moved_from_inventory" -end - -function spell_service.export_to_inventory_slot(player_entity_id, wand_entity_id, source_entry, entries, x, y) - if source_entry == nil or not alive(source_entry.entity) then return false, "source_missing" end - x, y = math.floor(tonumber(x) or -1), math.floor(tonumber(y) or -1) - if x < 0 or y < 0 then return false, "slot_out_of_range" end - local layout, layout_reason = inventory_slots.snapshot(player_entity_id, "inventory_full") - if layout == nil then return false, layout_reason end - local occupied = nil - for _, record in ipairs(layout.entries) do - if record.x == x and record.y == y and record.is_action == true then occupied = record; break end - end - if occupied ~= nil then return false, "occupied", occupied end - - local normalized, slot_changes, normalize_reason = normalize_slots(entries, source_entry.entity) - if not normalized then return false, normalize_reason end - if not detach_verified(source_entry.entity) then - rollback_slot_changes(slot_changes) - return false, "detach_failed" - end - pcall(ComponentSetValue2, source_entry.item_component, "permanently_attached", false) - pcall(ComponentSetValue2, source_entry.item_component, "has_been_picked_by_player", true) - local placed, place_reason, inventory_expected = inventory_slots.place_exact(player_entity_id, source_entry.entity, "inventory_full", x, y) - if not placed then - restore_wand_entry(wand_entity_id, source_entry) - rollback_slot_changes(slot_changes) - return false, place_reason or "inventory_failed" - end - refresh_wand_and_inventory(player_entity_id, wand_entity_id) - spell_service.expect_exact_slots(player_entity_id, "wand_to_inventory", inventory_expected or { - {entity=source_entry.entity, parent=layout.inventory, x=x, y=y}, - }) - return true, "inventory_slot" -end - -function spell_service.move_to_inventory(player_entity_id, wand_entity_id, existing_entry, entries) - if existing_entry == nil or not alive(existing_entry.entity) then return false, "missing_action" end - local plan, preflight_reason = inventory_slots.preflight(player_entity_id, existing_entry.entity) - if plan == nil then return false, preflight_reason or "inventory_full" end - if not detach_verified(existing_entry.entity) then return false, "detach_failed" end - - local normalized, slot_changes, normalize_reason = normalize_slots(entries, existing_entry.entity) - if not normalized then - pcall(EntityAddChild, wand_entity_id, existing_entry.entity) - set_slot(existing_entry.item_component, existing_entry.actual_slot, existing_entry.actual_slot_y or 0) - return false, normalize_reason - end - - pcall(ComponentSetValue2, existing_entry.item_component, "inventory_slot", -1, -1) - enable_world(existing_entry.entity) - local picked, reason = inventory_slots.pickup(player_entity_id, existing_entry.entity, false) - if not picked then - if alive(existing_entry.entity) then - local parent = type(EntityGetParent) == "function" and EntityGetParent(existing_entry.entity) or 0 - if parent ~= nil and parent ~= 0 then pcall(EntityRemoveFromParent, existing_entry.entity) end - pcall(EntityAddChild, wand_entity_id, existing_entry.entity) - set_slot(existing_entry.item_component, existing_entry.actual_slot, existing_entry.actual_slot_y or 0) - EntitySetComponentsWithTagEnabled(existing_entry.entity, "enabled_in_world", false) - EntitySetComponentsWithTagEnabled(existing_entry.entity, "enabled_in_inventory", false) - end - rollback_slot_changes(slot_changes) - return false, reason or "pickup_failed" + EntityKill(existing_entry.entity) end refresh_wand_and_inventory(player_entity_id, wand_entity_id) - return true, "inventory" + return true end -METAMORPH_CREATIVE_MENU_SPELL_SERVICE = spell_service return spell_service diff --git a/metamorph_creative_menu/files/features/spells/slot_settler.lua b/metamorph_creative_menu/files/features/spells/slot_settler.lua deleted file mode 100644 index c1d80ad..0000000 --- a/metamorph_creative_menu/files/features/spells/slot_settler.lua +++ /dev/null @@ -1,223 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_SPELL_SLOT_SETTLER) == "table" then - return METAMORPH_CREATIVE_MENU_SPELL_SLOT_SETTLER -end - -local slot_settler = {} -local pending = {} - -local function valid(component_id) - return component_id ~= nil and component_id ~= 0 -end - -local function current_frame() - if type(GameGetFrameNum) ~= "function" then return 0 end - local ok, value = pcall(GameGetFrameNum) - return ok and math.floor(tonumber(value) or 0) or 0 -end - -local function slot(entity_id) - if entity_id == nil or entity_id == 0 or EntityGetIsAlive(entity_id) ~= true then return nil end - local item = EntityGetFirstComponentIncludingDisabled(entity_id, "ItemComponent") - if not valid(item) then return nil end - local ok, x, y = pcall(ComponentGetValue2, item, "inventory_slot") - if not ok then return nil end - return item, tonumber(x), tonumber(y) or 0 -end - -local function report(label, reason) - if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, - "spells.slot_settle", tostring(label or "drag") .. ":" .. tostring(reason or "failed")) - end -end - -local function inspect(commit) - for _, expected in ipairs(commit.specs) do - local item, x, y = slot(expected.entity) - if item == nil then return false, "entity_missing" end - local parent = type(EntityGetParent) == "function" and EntityGetParent(expected.entity) or expected.parent - if tonumber(parent) ~= tonumber(expected.parent) then return false, "parent_changed" end - if x ~= expected.x or y ~= expected.y then return false, "slot_changed" end - end - return true, "exact" -end - -local function has_foreign_collision(commit) - local tracked = {} - for _, expected in ipairs(commit.specs) do tracked[expected.entity] = true end - local scanned = {} - for _, expected in ipairs(commit.specs) do - local parent = expected.parent - if not scanned[parent] then - scanned[parent] = true - for _, child in ipairs(EntityGetAllChildren(parent) or {}) do - if not tracked[child] then - local _, x, y = slot(child) - if x ~= nil then - for _, target in ipairs(commit.specs) do - if target.parent == parent and target.x == x and target.y == y then - return true - end - end - end - end - end - end - end - return false -end - -local function force_refresh(player) - local inventory = EntityGetFirstComponentIncludingDisabled(player, "Inventory2Component") - if valid(inventory) then pcall(ComponentSetValue2, inventory, "mForceRefresh", true) end -end - -local function reapply(commit) - -- A parent change is external state and must never be "fixed" by MCM. Slot drift inside - -- the parent that MCM just committed is ours to repair, provided an unrelated card has - -- not moved into the requested destination in the meantime. - for _, expected in ipairs(commit.specs) do - if type(EntityGetParent) == "function" - and tonumber(EntityGetParent(expected.entity)) ~= tonumber(expected.parent) - then - return false, "parent_changed" - end - end - if has_foreign_collision(commit) then return false, "foreign_occupant" end - - local full_assignments = {} - local direct_specs = {} - for _, expected in ipairs(commit.specs) do - local parent_name = type(EntityGetName) == "function" and EntityGetName(expected.parent) or "" - local root = type(EntityGetRootEntity) == "function" and EntityGetRootEntity(expected.parent) or commit.player - if parent_name == "inventory_full" and tonumber(root) == tonumber(commit.player) then - full_assignments[#full_assignments+1] = {entity=expected.entity,x=expected.x,y=expected.y} - else - direct_specs[#direct_specs+1] = expected - end - end - - -- Never repair a delayed full-inventory packing pass with EntityAddChild/uid rewrites. - -- That was the old failure mode: the ECS fields looked correct while Noita's private - -- InventoryComponent.items order still disagreed. Re-run the engine-authoritative exact - -- pickup transaction instead, so delayed repairs use the same semantics as the original - -- drag from a wand or another inventory cell. - if #full_assignments > 0 then - local api = METAMORPH_CREATIVE_MENU_INVENTORY_SLOTS - if type(api) ~= "table" or type(api.rebuild_full_exact) ~= "function" then - return false, "exact_inventory_api_missing" - end - local ok, reason, rebuilt_specs, replacements = api.rebuild_full_exact(commit.player, full_assignments) - if not ok then return false, reason or "inventory_repair_failed" end - -- Full-build exact repair may intentionally rehydrate an already-registered card - -- into a fresh entity id. Carry those replacement ids forward in the pending - -- expectation; otherwise the next inspect would incorrectly report entity_missing - -- even though the logical spell was repaired into the requested native cell. - if type(replacements) == "table" then - for _, expected in ipairs(commit.specs) do - local replacement = replacements[expected.entity] - if replacement ~= nil and replacement ~= 0 then expected.entity = replacement end - end - elseif type(rebuilt_specs) == "table" then - for _, rebuilt in ipairs(rebuilt_specs) do - local old = tonumber(rebuilt.replaces) or 0 - if old ~= 0 then - for _, expected in ipairs(commit.specs) do - if expected.entity == old then expected.entity = rebuilt.entity; break end - end - end - end - end - end - - -- Wand children do not use InventoryComponent.items; their slot is authored directly by - -- ItemComponent.inventory_slot. Repair only that field and leave entity registration and - -- runtime state untouched. - for _, expected in ipairs(direct_specs) do - local item = select(1, slot(expected.entity)) - if item == nil then return false, "entity_missing" end - local wrote = pcall(ComponentSetValue2, item, "inventory_slot", expected.x, expected.y) - if not wrote then return false, "final_write_failed" end - end - - local exact, reason = inspect(commit) - if not exact then return false, reason or "readback_failed" end - force_refresh(commit.player) - if type(commit.on_repair) == "function" then pcall(commit.on_repair) end - return true, "repaired" -end - -function slot_settler.schedule(player_entity_id, label, specs, on_repair) - local normalized, entities = {}, {} - for _, spec in ipairs(type(specs) == "table" and specs or {}) do - local entity = math.floor(tonumber(spec.entity) or 0) - local parent = math.floor(tonumber(spec.parent) or 0) - local x, y = tonumber(spec.x), tonumber(spec.y) - if entity ~= 0 and parent ~= 0 and x ~= nil and y ~= nil and not entities[entity] then - entities[entity] = true - normalized[#normalized + 1] = { - entity=entity, parent=parent, - x=math.floor(x), y=math.floor(y), - } - end - end - if #normalized == 0 then return false end - - -- A newer drag involving the same card supersedes the older expectation. Full-build - -- exact inventory moves may also rehydrate a card into a new entity id, so an older - -- expectation that points at a deliberately retired entity is stale as soon as the - -- next successful transaction is scheduled and must not become a false diagnostic. - for index = #pending, 1, -1 do - local stale = false - for _, old in ipairs(pending[index].specs) do - if entities[old.entity] or (type(EntityGetIsAlive) == "function" and EntityGetIsAlive(old.entity) ~= true) then - stale = true - break - end - end - if stale then table.remove(pending, index) end - end - pending[#pending + 1] = { - player=player_entity_id, label=tostring(label or "drag"), specs=normalized, - frame=current_frame(), attempts=0, on_repair=on_repair, - } - return true -end - -function slot_settler.update() - local frame = current_frame() - local repaired, failed = 0, 0 - for index = #pending, 1, -1 do - local commit = pending[index] - if frame > commit.frame then - local exact, reason = inspect(commit) - if exact then - table.remove(pending, index) - elseif commit.attempts < 3 and reason == "slot_changed" then - local ok, repair_reason = reapply(commit) - commit.attempts = commit.attempts + 1 - if ok then - repaired = repaired + 1 - -- Keep the expectation alive for two more update boundaries. Native - -- InventoryGui can perform a delayed packing pass after mForceRefresh. - commit.frame = frame - else - report(commit.label, repair_reason) - table.remove(pending, index) - failed = failed + 1 - end - else - report(commit.label, reason) - table.remove(pending, index) - failed = failed + 1 - end - end - end - return repaired, failed -end - -function slot_settler.pending_count() return #pending end -function slot_settler.reset() pending = {} end - -METAMORPH_CREATIVE_MENU_SPELL_SLOT_SETTLER = slot_settler -return slot_settler diff --git a/metamorph_creative_menu/files/features/wands/appearance.lua b/metamorph_creative_menu/files/features/wands/appearance.lua deleted file mode 100644 index 3daf061..0000000 --- a/metamorph_creative_menu/files/features/wands/appearance.lua +++ /dev/null @@ -1,357 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_WAND_APPEARANCE) == "table" then return METAMORPH_CREATIVE_MENU_WAND_APPEARANCE end - -local appearance = {} -local utf8_text = dofile("mods/metamorph_creative_menu/files/core/utf8_text.lua") -local wand_api = dofile("mods/metamorph_creative_menu/files/platform/noita/wand.lua") -local wand_sync = dofile("mods/metamorph_creative_menu/files/features/wands/sync.lua") - -local function valid(component) return component ~= nil and component ~= 0 end -local function safe_name(value) return utf8_text.truncate_bytes(tostring(value or ""), 160) end -local function finite(value) - value = tonumber(value) - return value ~= nil and value == value and value > -math.huge and value < math.huge and value or nil -end - -local function first(entity, component_type, tag) - if tag == nil then - local ok, component = pcall(EntityGetFirstComponentIncludingDisabled, entity, component_type) - return ok and (component or 0) or 0 - end - local ok, component = pcall(EntityGetFirstComponentIncludingDisabled, entity, component_type, tag) - return ok and (component or 0) or 0 -end - --- Vanilla and modded wands are not perfectly consistent about component tags. Prefer the --- canonical tag, but only fall back when the component type is unambiguous. In particular, --- never guess between multiple SpriteComponents and accidentally edit a glow/overlay sprite. -local function resolve_component(entity, component_type, tag) - local component = first(entity, component_type, tag) - if valid(component) then return component, "primary" end - if type(EntityGetComponentIncludingDisabled) ~= "function" then return 0, "missing" end - local ok, list = pcall(EntityGetComponentIncludingDisabled, entity, component_type) - if not ok or type(list) ~= "table" then return 0, "missing" end - local candidate, count = 0, 0 - for _, current in ipairs(list) do - if valid(current) then candidate, count = current, count + 1 end - end - if count == 1 then return candidate, "unique_fallback" end - return 0, count == 0 and "missing" or "ambiguous" -end - -local function read(component, field) - if not valid(component) then return nil end - local ok, value = pcall(ComponentGetValue2, component, field) - if not ok then return nil end - return value -end - -local function write(component, field, ...) - if not valid(component) then return false end - return pcall(ComponentSetValue2, component, field, ...) -end - -local function write_verified(component, field, value) - if not write(component, field, value) then return false end - local actual = read(component, field) - if type(value) == "number" then return tonumber(actual) == tonumber(value) end - if type(value) == "boolean" then return actual == value end - return tostring(actual or "") == tostring(value or "") -end - -local function write_vector_verified(component, field, x, y) - if not write(component, field, x, y) then return false end - local ok, actual_x, actual_y = pcall(ComponentGetValue2, component, field) - return ok and tonumber(actual_x) == tonumber(x) and tonumber(actual_y) == tonumber(y) -end - -function appearance.snapshot(wand) - local ability = wand_api.ability(wand) - if ability == 0 then return nil, "not_wand" end - local item, item_resolution = resolve_component(wand, "ItemComponent") - local sprite, sprite_resolution = resolve_component(wand, "SpriteComponent", "item") - local hotspot, hotspot_resolution = resolve_component(wand, "HotspotComponent", "shoot_pos") - local result = { - wand=wand, ability=ability, item=item, sprite=sprite, hotspot=hotspot, - item_resolution=item_resolution, sprite_resolution=sprite_resolution, hotspot_resolution=hotspot_resolution, - sprite_file=tostring(select(1, wand_api.get_scalar(ability, "sprite_file")) or ""), - } - if valid(item) then - result.name = tostring(read(item, "item_name") or "") - result.show_name_in_ui = read(item, "always_use_item_name_in_ui") == true - result.wand_frozen = read(item, "is_frozen") == true - else - result.name, result.show_name_in_ui, result.wand_frozen = "", false, false - end - if valid(sprite) then - result.image_file = tostring(read(sprite, "image_file") or "") - result.offset_x = finite(read(sprite, "offset_x")) - result.offset_y = finite(read(sprite, "offset_y")) - end - if valid(hotspot) then - local ok, x, y = pcall(ComponentGetValue2, hotspot, "offset") - if ok then result.tip_x, result.tip_y = finite(x), finite(y) end - end - return result, "ok" -end - --- Rollback always resolves fresh component IDs. A synchronization/refresh may replace the --- component object even though the wand entity remains the same. -local function restore(snapshot) - if type(snapshot) ~= "table" then return end - local wand = snapshot.wand - local ability = wand_api.ability(wand) - if ability ~= 0 then pcall(wand_api.set_scalar, ability, "sprite_file", snapshot.sprite_file or "") end - local item = select(1, resolve_component(wand, "ItemComponent")) - if valid(item) then - pcall(ComponentSetValue2, item, "item_name", snapshot.name or "") - pcall(ComponentSetValue2, item, "always_use_item_name_in_ui", snapshot.show_name_in_ui == true) - pcall(ComponentSetValue2, item, "is_frozen", snapshot.wand_frozen == true) - end - local sprite = select(1, resolve_component(wand, "SpriteComponent", "item")) - if valid(sprite) then - if snapshot.image_file ~= nil then pcall(ComponentSetValue2, sprite, "image_file", snapshot.image_file) end - if snapshot.offset_x ~= nil then pcall(ComponentSetValue2, sprite, "offset_x", snapshot.offset_x) end - if snapshot.offset_y ~= nil then pcall(ComponentSetValue2, sprite, "offset_y", snapshot.offset_y) end - if type(EntityRefreshSprite) == "function" then pcall(EntityRefreshSprite, wand, sprite) end - end - local hotspot = select(1, resolve_component(wand, "HotspotComponent", "shoot_pos")) - if valid(hotspot) and snapshot.tip_x ~= nil and snapshot.tip_y ~= nil then - pcall(ComponentSetValue2, hotspot, "offset", snapshot.tip_x, snapshot.tip_y) - end -end - -local function sync_then_snapshot(player, wand, skip_sync) - if skip_sync ~= true then wand_sync.inventory(player) end - return appearance.snapshot(wand) -end - -local function rollback_after_failure(player, before, skip_sync) - restore(before) - if skip_sync ~= true then wand_sync.inventory(player) end -end - -function appearance.set_name(player, wand, name, show_name_in_ui) - local before, reason = appearance.snapshot(wand) - if before == nil then return false, reason end - if not valid(before.item) then return false, "item_missing" end - name = safe_name(name) - if name == "" then show_name_in_ui = false end - local target_show = show_name_in_ui == true - if not write_verified(before.item, "item_name", name) - or not write_verified(before.item, "always_use_item_name_in_ui", target_show) - then - restore(before) - return false, "name_write_failed" - end - local after = select(1, sync_then_snapshot(player, wand, false)) - if after == nil or not valid(after.item) - or tostring(after.name or "") ~= name or after.show_name_in_ui ~= target_show - then - rollback_after_failure(player, before, false) - return false, "name_verify_failed" - end - return true, "ok" -end - -function appearance.set_wand_frozen(player, wand, frozen) - local before, reason = appearance.snapshot(wand) - if before == nil then return false, reason end - if not valid(before.item) then return false, "item_missing" end - local target = frozen == true - if not write_verified(before.item, "is_frozen", target) then - restore(before) - return false, "freeze_write_failed" - end - local after = select(1, sync_then_snapshot(player, wand, false)) - if after == nil or not valid(after.item) or after.wand_frozen ~= target then - rollback_after_failure(player, before, false) - return false, "freeze_verify_failed" - end - return true, "ok" -end - -local function spell_items(wand) - local result = {} - for _, entity in ipairs(EntityGetAllChildren(wand) or {}) do - local action = select(1, resolve_component(entity, "ItemActionComponent")) - local item = select(1, resolve_component(entity, "ItemComponent")) - if valid(action) and valid(item) then result[#result + 1] = {entity=entity,item=item} end - end - return result -end - -function appearance.set_spells_frozen(player, wand, frozen) - if wand_api.ability(wand) == 0 then return false, "not_wand" end - local target = frozen == true - local before = {} - for _, record in ipairs(spell_items(wand)) do - local old = read(record.item, "is_frozen") == true - before[#before + 1] = {entity=record.entity,old=old} - if old ~= target and not write_verified(record.item, "is_frozen", target) then - for _, previous in ipairs(before) do - local current_item = select(1, resolve_component(previous.entity, "ItemComponent")) - if valid(current_item) then pcall(ComponentSetValue2, current_item, "is_frozen", previous.old) end - end - return false, "spell_freeze_write_failed" - end - end - wand_sync.inventory(player) - local all_frozen, mixed, total = appearance.spell_freeze_state(wand) - local verified = total == 0 or (mixed == false and all_frozen == target) - if not verified then - for _, previous in ipairs(before) do - local current_item = select(1, resolve_component(previous.entity, "ItemComponent")) - if valid(current_item) then pcall(ComponentSetValue2, current_item, "is_frozen", previous.old) end - end - wand_sync.inventory(player) - return false, "spell_freeze_verify_failed" - end - return true, "ok" -end - -function appearance.spell_freeze_state(wand) - local total, frozen = 0, 0 - for _, record in ipairs(spell_items(wand)) do - total = total + 1 - if read(record.item, "is_frozen") == true then frozen = frozen + 1 end - end - if total == 0 or frozen == 0 then return false, false, total end - if frozen == total then return true, false, total end - return false, true, total -end - -local function visual_matches(snapshot, values) - if type(snapshot) ~= "table" then return false end - if values.sprite_file ~= nil and tostring(snapshot.sprite_file or "") ~= tostring(values.sprite_file) then return false end - if values.image_file ~= nil and tostring(snapshot.image_file or "") ~= tostring(values.image_file) then return false end - if values.offset_x ~= nil and tonumber(snapshot.offset_x) ~= tonumber(values.offset_x) then return false end - if values.offset_y ~= nil and tonumber(snapshot.offset_y) ~= tonumber(values.offset_y) then return false end - if values.tip_x ~= nil and tonumber(snapshot.tip_x) ~= tonumber(values.tip_x) then return false end - if values.tip_y ~= nil and tonumber(snapshot.tip_y) ~= tonumber(values.tip_y) then return false end - return true -end - -function appearance.set_visual(player, wand, values, options) - values = type(values) == "table" and values or {} - options = type(options) == "table" and options or {} - local before, reason = appearance.snapshot(wand) - if before == nil then return false, reason end - - local requested = {} - local path = values.sprite_file - local image_path = values.image_file - if path ~= nil then - path = tostring(path) - if path == "" then return false, "empty_path" end - if type(ModDoesFileExist) == "function" and not ModDoesFileExist(path) then return false, "missing_asset" end - if not wand_api.set_scalar(before.ability, "sprite_file", path) then restore(before); return false, "ability_sprite_failed" end - requested.sprite_file = path - if image_path == nil then image_path = path end - end - if image_path ~= nil then - image_path = tostring(image_path) - if image_path == "" then restore(before); return false, "empty_image_path" end - if type(ModDoesFileExist) == "function" and not ModDoesFileExist(image_path) then restore(before); return false, "missing_image_asset" end - if valid(before.sprite) then - if not write_verified(before.sprite, "image_file", image_path) then restore(before); return false, "sprite_image_failed" end - requested.image_file = image_path - else - restore(before); return false, "sprite_component_missing" - end - end - - local offset_x = values.offset_x ~= nil and finite(values.offset_x) or nil - local offset_y = values.offset_y ~= nil and finite(values.offset_y) or nil - local tip_x = values.tip_x ~= nil and finite(values.tip_x) or nil - local tip_y = values.tip_y ~= nil and finite(values.tip_y) or nil - if (values.offset_x ~= nil and offset_x == nil) or (values.offset_y ~= nil and offset_y == nil) - or (values.tip_x ~= nil and tip_x == nil) or (values.tip_y ~= nil and tip_y == nil) - then restore(before); return false, "invalid_geometry" end - - if valid(before.sprite) then - if offset_x ~= nil and not write_verified(before.sprite, "offset_x", offset_x) then restore(before); return false, "offset_x_failed" end - if offset_y ~= nil and not write_verified(before.sprite, "offset_y", offset_y) then restore(before); return false, "offset_y_failed" end - if offset_x ~= nil then requested.offset_x = offset_x end - if offset_y ~= nil then requested.offset_y = offset_y end - if type(EntityRefreshSprite) == "function" then pcall(EntityRefreshSprite, wand, before.sprite) end - elseif offset_x ~= nil or offset_y ~= nil then - restore(before); return false, "sprite_component_missing" - end - - if tip_x ~= nil or tip_y ~= nil then - if not valid(before.hotspot) then restore(before); return false, "shoot_hotspot_missing" end - local final_x = tip_x ~= nil and tip_x or before.tip_x - local final_y = tip_y ~= nil and tip_y or before.tip_y - if final_x == nil or final_y == nil or not write_vector_verified(before.hotspot, "offset", final_x, final_y) then - restore(before); return false, "tip_write_failed" - end - if tip_x ~= nil then requested.tip_x = tip_x end - if tip_y ~= nil then requested.tip_y = tip_y end - end - - local after = select(1, sync_then_snapshot(player, wand, options.skip_sync == true)) - if not visual_matches(after, requested) then - rollback_after_failure(player, before, options.skip_sync == true) - return false, "visual_verify_failed" - end - if after ~= nil and valid(after.sprite) and type(EntityRefreshSprite) == "function" then - pcall(EntityRefreshSprite, wand, after.sprite) - end - return true, "ok" -end - -function appearance.apply(player, wand, desired, options) - desired = type(desired) == "table" and desired or {} - options = type(options) == "table" and options or {} - local before, reason = appearance.snapshot(wand) - if before == nil then return false, reason end - local meta = type(desired.meta) == "table" and desired.meta or {} - - if desired.sprite_file ~= nil or meta.image_file ~= nil or meta.sprite_offset_x ~= nil or meta.sprite_offset_y ~= nil - or meta.tip_x ~= nil or meta.tip_y ~= nil - then - local ok, err = appearance.set_visual(player, wand, { - sprite_file=desired.sprite_file, image_file=meta.image_file, - offset_x=meta.sprite_offset_x, offset_y=meta.sprite_offset_y, - tip_x=meta.tip_x, tip_y=meta.tip_y, - }, {skip_sync=true}) - if not ok then restore(before); return false, err end - end - - local current = select(1, appearance.snapshot(wand)) or before - if meta.name ~= nil or meta.show_name_in_ui ~= nil then - if not valid(current.item) then restore(before); return false, "item_missing" end - local name = meta.name ~= nil and safe_name(meta.name) or current.name - local show = current.show_name_in_ui - if meta.show_name_in_ui ~= nil then show = meta.show_name_in_ui == true end - if name == "" then show = false end - if not write_verified(current.item, "item_name", name) - or not write_verified(current.item, "always_use_item_name_in_ui", show) - then restore(before); return false, "name_write_failed" end - end - current = select(1, appearance.snapshot(wand)) or current - if meta.wand_frozen ~= nil then - if not valid(current.item) or not write_verified(current.item, "is_frozen", meta.wand_frozen == true) then - restore(before); return false, "freeze_write_failed" - end - end - - local after = select(1, sync_then_snapshot(player, wand, options.skip_sync == true)) - if after == nil then rollback_after_failure(player, before, options.skip_sync == true); return false, "appearance_verify_failed" end - if meta.name ~= nil then - local expected_name = safe_name(meta.name) - if tostring(after.name or "") ~= expected_name then - rollback_after_failure(player, before, options.skip_sync == true); return false, "name_verify_failed" - end - end - if meta.show_name_in_ui ~= nil and after.show_name_in_ui ~= (meta.show_name_in_ui == true and tostring(after.name or "") ~= "") then - rollback_after_failure(player, before, options.skip_sync == true); return false, "name_verify_failed" - end - if meta.wand_frozen ~= nil and after.wand_frozen ~= (meta.wand_frozen == true) then - rollback_after_failure(player, before, options.skip_sync == true); return false, "freeze_verify_failed" - end - return true, "ok" -end - -METAMORPH_CREATIVE_MENU_WAND_APPEARANCE = appearance -return appearance diff --git a/metamorph_creative_menu/files/features/wands/blueprints.lua b/metamorph_creative_menu/files/features/wands/blueprints.lua deleted file mode 100644 index 43e6f6a..0000000 --- a/metamorph_creative_menu/files/features/wands/blueprints.lua +++ /dev/null @@ -1,251 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_WAND_BLUEPRINTS) == "table" then return METAMORPH_CREATIVE_MENU_WAND_BLUEPRINTS end - -local blueprints = {} -local wand_service = dofile("mods/metamorph_creative_menu/files/features/wands/service.lua") -local appearance = dofile("mods/metamorph_creative_menu/files/features/wands/appearance.lua") -local spell_factory = dofile("mods/metamorph_creative_menu/files/features/spells/factory.lua") - -local function valid(component) return component ~= nil and component ~= 0 end -local function alive(entity) return entity ~= nil and entity ~= 0 and EntityGetIsAlive(entity) == true end - -local function action_children(wand) - local result = {} - for _, entity in ipairs(EntityGetAllChildren(wand) or {}) do - local item = EntityGetFirstComponentIncludingDisabled(entity, "ItemComponent") - local action = EntityGetFirstComponentIncludingDisabled(entity, "ItemActionComponent") - if valid(item) and valid(action) then result[#result + 1] = {entity=entity,item=item,action=action} end - end - return result -end - -local function read_optional(component, field) - local ok, value = pcall(ComponentGetValue2, component, field) - return ok and value or nil -end - -local function permanent_count(blueprint) - local count = 0 - for _, spec in ipairs(type(blueprint) == "table" and blueprint.spells or {}) do - if spec.permanent == true then count = count + 1 end - end - return count -end - -function blueprints.capture(wand) - local snapshot, reason = wand_service.snapshot(wand) - if snapshot == nil then return nil, reason end - local visual = select(1, appearance.snapshot(wand)) - local blueprint = { - version=2, - stats={}, - mana=snapshot.mana, - sprite_file=visual and visual.sprite_file or snapshot.sprite_file, - meta={}, - spells={}, - } - for id, value in pairs(snapshot.stats or {}) do blueprint.stats[id] = value end - if visual ~= nil then - blueprint.meta.name = visual.name - blueprint.meta.show_name_in_ui = visual.show_name_in_ui == true - blueprint.meta.wand_frozen = visual.wand_frozen == true - blueprint.meta.image_file = visual.image_file - blueprint.meta.sprite_offset_x = visual.offset_x - blueprint.meta.sprite_offset_y = visual.offset_y - blueprint.meta.tip_x = visual.tip_x - blueprint.meta.tip_y = visual.tip_y - end - for _, record in ipairs(action_children(wand)) do - local action_id = read_optional(record.action, "action_id") - if type(action_id) == "string" and action_id ~= "" then - local ok_slot, x, y = pcall(ComponentGetValue2, record.item, "inventory_slot") - if not ok_slot then x, y = -1, 0 end - local is_permanent = read_optional(record.item, "permanently_attached") == true - blueprint.spells[#blueprint.spells + 1] = { - action_id=action_id, - slot=math.floor(tonumber(x) or -1), - slot_y=math.floor(tonumber(y) or (is_permanent and -1 or 0)), - permanent=is_permanent, - uses_remaining=tonumber(read_optional(record.item, "uses_remaining")), - frozen=read_optional(record.item, "is_frozen") == true, - } - end - end - table.sort(blueprint.spells, function(a,b) - if a.permanent ~= b.permanent then return a.permanent == true end - if a.slot_y ~= b.slot_y then return a.slot_y < b.slot_y end - if a.slot ~= b.slot then return a.slot < b.slot end - return a.action_id < b.action_id - end) - return blueprint, "ok" -end - -local function configure_new_spell(spec) - local entity, reason = spell_factory.create(spec.action_id) - if entity == 0 then return nil, reason end - local item = EntityGetFirstComponentIncludingDisabled(entity, "ItemComponent") - local action = EntityGetFirstComponentIncludingDisabled(entity, "ItemActionComponent") - if not valid(item) or not valid(action) then pcall(EntityKill, entity); return nil, "components_missing" end - local slot_x = spec.permanent == true and -1 or math.max(0, math.floor(tonumber(spec.slot) or 0)) - local slot_y = spec.permanent == true and -1 or math.max(0, math.floor(tonumber(spec.slot_y) or 0)) - local writes = { - {"inventory_slot",slot_x,slot_y}, - {"permanently_attached",spec.permanent == true}, - {"has_been_picked_by_player",true}, - } - for _, write in ipairs(writes) do - local ok - if write[3] ~= nil then ok = pcall(ComponentSetValue2, item, write[1], write[2], write[3]) - else ok = pcall(ComponentSetValue2, item, write[1], write[2]) end - if not ok then pcall(EntityKill, entity); return nil, "configure_failed" end - end - if spec.uses_remaining ~= nil and not pcall(ComponentSetValue2, item, "uses_remaining", spec.uses_remaining) then - pcall(EntityKill, entity); return nil, "uses_write_failed" - end - if spec.frozen ~= nil and not pcall(ComponentSetValue2, item, "is_frozen", spec.frozen == true) then - pcall(EntityKill, entity); return nil, "freeze_write_failed" - end - return {entity=entity,item=item,action=action,spec=spec}, "ok" -end - -local function cleanup_new(records) - for _, record in ipairs(records or {}) do - if alive(record.entity) then - pcall(EntityRemoveFromParent, record.entity) - pcall(EntityKill, record.entity) - end - end -end - -local function attach_verified(wand, record) - local ok = pcall(EntityAddChild, wand, record.entity) - if not ok then return false end - if type(EntityGetParent) == "function" then - local read, parent = pcall(EntityGetParent, record.entity) - if not read or tonumber(parent) ~= tonumber(wand) then return false end - end - local read, action_id = pcall(ComponentGetValue2, record.action, "action_id") - return read and tostring(action_id or "") == tostring(record.spec.action_id or "") -end - -local function restore_old(wand, detached) - for _, record in ipairs(detached or {}) do - if alive(record.entity) then pcall(EntityAddChild, wand, record.entity) end - end -end - -local function restore_card_runtime(records) - for _, record in ipairs(records or {}) do - local spec = record.spec - if alive(record.entity) then - if spec.uses_remaining ~= nil then pcall(ComponentSetValue2, record.item, "uses_remaining", spec.uses_remaining) end - if spec.frozen ~= nil then pcall(ComponentSetValue2, record.item, "is_frozen", spec.frozen == true) end - end - end -end - -local function apply_non_spell_state(player, wand, blueprint, skip_sync) - local ok, reason = wand_service.apply_configuration(player, wand, { - stats=blueprint.stats or {}, mana=blueprint.mana, - }, {permanent_count=permanent_count(blueprint),skip_sync=true}) - if not ok then return false, reason end - local visual_ok, visual_reason = appearance.apply(player, wand, blueprint, {skip_sync=true}) - if not visual_ok then return false, visual_reason end - if skip_sync ~= true then wand_service.refresh(player) end - return true, "ok" -end - -local function rollback(player, wand, before, old_records, prepared) - cleanup_new(prepared) - restore_old(wand, old_records) - pcall(apply_non_spell_state, player, wand, before, true) - if type(GameRegenItemActionsInContainer) == "function" then pcall(GameRegenItemActionsInContainer, wand) end - if type(GameRegenItemActionsInPlayer) == "function" then pcall(GameRegenItemActionsInPlayer, player) end - if before.mana ~= nil then - pcall(wand_service.apply_configuration, player, wand, {stats={},mana=before.mana}, - {permanent_count=permanent_count(before),skip_sync=true}) - end - wand_service.refresh(player) -end - -function blueprints.apply(player, wand, blueprint) - if type(blueprint) ~= "table" or type(blueprint.spells) ~= "table" or type(blueprint.stats) ~= "table" then - return false, "invalid_blueprint" - end - blueprint.meta = type(blueprint.meta) == "table" and blueprint.meta or {} - local before, reason = blueprints.capture(wand) - if before == nil then return false, reason end - local old = action_children(wand) - local highest_requested = -1 - for _, spec in ipairs(blueprint.spells) do - if type(spec.action_id) ~= "string" or spec.action_id == "" then return false, "invalid_spell" end - if spec.permanent ~= true then - local slot = math.floor(tonumber(spec.slot) or -1) - if slot < 0 then return false, "invalid_slot" end - highest_requested = math.max(highest_requested, slot) - end - end - local max_slots = type(wand_service.max_slots) == "function" and wand_service.max_slots() or 64 - if highest_requested >= max_slots then return false, "invalid_capacity" end - if tonumber(blueprint.stats.slots) ~= nil then - local requested_capacity = tonumber(blueprint.stats.slots) - if requested_capacity < highest_requested + 1 or requested_capacity > max_slots then - return false, "invalid_capacity" - end - end - - local prepared = {} - for _, spec in ipairs(blueprint.spells) do - local record, prepare_reason = configure_new_spell(spec) - if record == nil then cleanup_new(prepared); return false, prepare_reason end - prepared[#prepared + 1] = record - end - for _, record in ipairs(prepared) do - if not attach_verified(wand, record) then cleanup_new(prepared); return false, "attach_failed" end - if type(EntitySetComponentsWithTagEnabled) == "function" then - pcall(EntitySetComponentsWithTagEnabled, record.entity, "enabled_in_world", false) - end - end - - local detached = {} - for _, record in ipairs(old) do - local ok = pcall(EntityRemoveFromParent, record.entity) - local parent_ok = ok - if ok and type(EntityGetParent) == "function" then - local read, parent = pcall(EntityGetParent, record.entity) - parent_ok = read and tonumber(parent) == 0 - end - if not parent_ok then - cleanup_new(prepared) - restore_old(wand, detached) - return false, "old_detach_failed" - end - detached[#detached + 1] = record - end - - local config_ok, config_reason = apply_non_spell_state(player, wand, blueprint, true) - if not config_ok then - rollback(player, wand, before, detached, prepared) - return false, config_reason - end - - -- Regeneration makes Noita rebuild the action view. Mutable card state is restored - -- afterwards so loading a blueprint cannot silently refill limited-use cards. - if type(GameRegenItemActionsInContainer) == "function" then pcall(GameRegenItemActionsInContainer, wand) end - if type(GameRegenItemActionsInPlayer) == "function" then pcall(GameRegenItemActionsInPlayer, player) end - restore_card_runtime(prepared) - if blueprint.mana ~= nil then - local mana_ok, mana_reason = wand_service.apply_configuration(player, wand, {stats={},mana=blueprint.mana}, - {permanent_count=permanent_count(blueprint),skip_sync=true}) - if not mana_ok then - rollback(player, wand, before, detached, prepared) - return false, mana_reason - end - end - - for _, record in ipairs(detached) do if alive(record.entity) then pcall(EntityKill, record.entity) end end - wand_service.refresh(player) - return true, "loaded" -end - -METAMORPH_CREATIVE_MENU_WAND_BLUEPRINTS = blueprints -return blueprints diff --git a/metamorph_creative_menu/files/features/wands/history.lua b/metamorph_creative_menu/files/features/wands/history.lua deleted file mode 100644 index f726e12..0000000 --- a/metamorph_creative_menu/files/features/wands/history.lua +++ /dev/null @@ -1,199 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_WAND_HISTORY) == "table" then return METAMORPH_CREATIVE_MENU_WAND_HISTORY end - -local history = {} -local blueprints = dofile("mods/metamorph_creative_menu/files/features/wands/blueprints.lua") -local codec = dofile("mods/metamorph_creative_menu/files/core/wand_blueprint_codec.lua") - -local states = {} -local state_order = {} -local HISTORY_LIMIT = 30 -local WAND_STATE_LIMIT = 16 - -local function touch_key(key) - for index = #state_order, 1, -1 do - if state_order[index] == key then table.remove(state_order, index); break end - end - state_order[#state_order + 1] = key - while #state_order > WAND_STATE_LIMIT do - local oldest = table.remove(state_order, 1) - states[oldest] = nil - end -end - -local function state_for(wand) - local key = tonumber(wand) or 0 - local state = states[key] - if state == nil then - state = {undo={}, redo={}} - states[key] = state - end - touch_key(key) - return state -end - -local function frame_now() - if type(GameGetFrameNum) == "function" then - local ok, value = pcall(GameGetFrameNum) - if ok then return tonumber(value) or 0 end - end - return 0 -end - -local function push_bounded(list, value) - list[#list + 1] = value - while #list > HISTORY_LIMIT do table.remove(list, 1) end -end - -local function stable_blueprint(blueprint) - if type(blueprint) ~= "table" then return nil end - local value = { - version=blueprint.version, stats=blueprint.stats or {}, - sprite_file=blueprint.sprite_file or "", meta=blueprint.meta or {}, spells={}, - } - -- Mana and limited-use counters can change through normal gameplay between an edit - -- and Undo. They are not evidence that cards were externally transferred. - for _, spell in ipairs(type(blueprint.spells) == "table" and blueprint.spells or {}) do - value.spells[#value.spells + 1] = { - action_id=spell.action_id, slot=spell.slot, slot_y=spell.slot_y, - permanent=spell.permanent == true, frozen=spell.frozen == true, - } - end - return codec.encode(value) -end - -local function card_identity(wand) - if type(EntityGetAllChildren) ~= "function" or type(EntityGetFirstComponentIncludingDisabled) ~= "function" then return nil end - local ids = {} - local ok, children = pcall(EntityGetAllChildren, wand) - if not ok or type(children) ~= "table" then return nil end - for _, child in ipairs(children) do - local ok_item, item = pcall(EntityGetFirstComponentIncludingDisabled, child, "ItemComponent") - local ok_action, action = pcall(EntityGetFirstComponentIncludingDisabled, child, "ItemActionComponent") - if ok_item and ok_action and item ~= nil and item ~= 0 and action ~= nil and action ~= 0 then - ids[#ids + 1] = tostring(child) - end - end - table.sort(ids) - return table.concat(ids, "|") -end - -local function make_guard(wand, blueprint) - return {stable=stable_blueprint(blueprint), cards=card_identity(wand)} -end - -local function guard_equal(current, expected) - if type(current) ~= "table" or type(expected) ~= "table" then return false end - if current.stable ~= expected.stable then return false end - -- In-engine identity catches external transfer/replacement even when the spell id and - -- slot happen to be identical. Mocks/platforms without entity APIs fall back to the - -- stable structural blueprint comparison above. - if current.cards ~= nil and expected.cards ~= nil and current.cards ~= expected.cards then return false end - return true -end - -local function invalidate_stale(wand) - -- Once the wand changed outside this command history, neither side of the old - -- undo/redo chain is a safe inverse anymore (cards may now belong elsewhere). - history.clear(wand) - return false, "stale_history" -end - --- History is intentionally scoped to mutations whose complete state lives inside the wand. --- Moving a card to world/player inventory needs a command-specific inverse; snapshot undo --- alone would recreate the card in the wand without reclaiming the external entity. -function history.perform(player, wand, label, callback, options) - if type(callback) ~= "function" then return false, "missing_callback" end - local before, reason = blueprints.capture(wand) - if before == nil then return false, reason end - - local call = {pcall(callback)} - local call_ok = table.remove(call, 1) - if not call_ok then return false, "operation_error" end - if call[1] == false then return unpack(call) end - - local after = select(1, blueprints.capture(wand)) - if after == nil then return unpack(call) end - if codec.encode(before) == codec.encode(after) then return unpack(call) end - local after_guard = make_guard(wand, after) - - options = type(options) == "table" and options or {} - local state = state_for(wand) - local now = frame_now() - local coalesce_key = options.coalesce_key - local last = state.undo[#state.undo] - if coalesce_key ~= nil and last ~= nil and last.coalesce_key == coalesce_key - and now - (last.frame or 0) <= math.max(1, tonumber(options.coalesce_frames) or 30) - then - last.after, last.frame, last.label = after, now, tostring(label or last.label or "edit") - last.undo_guard = after_guard - else - push_bounded(state.undo, { - before=before, after=after, label=tostring(label or "edit"), - coalesce_key=coalesce_key, frame=now, undo_guard=after_guard, - }) - end - state.redo = {} - return unpack(call) -end - -function history.can_undo(wand) return #state_for(wand).undo > 0 end -function history.can_redo(wand) return #state_for(wand).redo > 0 end - -function history.undo_label(wand) - local record = state_for(wand).undo[#state_for(wand).undo] - return record and record.label or nil -end - -function history.redo_label(wand) - local record = state_for(wand).redo[#state_for(wand).redo] - return record and record.label or nil -end - -function history.undo(player, wand) - local state = state_for(wand) - local record = state.undo[#state.undo] - if record == nil then return false, "empty" end - local current = select(1, blueprints.capture(wand)) - if current == nil or not guard_equal(make_guard(wand, current), record.undo_guard or make_guard(wand, record.after)) then - return invalidate_stale(wand) - end - local ok, reason = blueprints.apply(player, wand, record.before) - if not ok then return false, reason end - local restored = select(1, blueprints.capture(wand)) - if restored ~= nil then record.redo_guard = make_guard(wand, restored) end - table.remove(state.undo) - push_bounded(state.redo, record) - return true, record.label -end - -function history.redo(player, wand) - local state = state_for(wand) - local record = state.redo[#state.redo] - if record == nil then return false, "empty" end - local current = select(1, blueprints.capture(wand)) - if current == nil or not guard_equal(make_guard(wand, current), record.redo_guard or make_guard(wand, record.before)) then - return invalidate_stale(wand) - end - local ok, reason = blueprints.apply(player, wand, record.after) - if not ok then return false, reason end - local restored = select(1, blueprints.capture(wand)) - if restored ~= nil then record.undo_guard = make_guard(wand, restored) end - table.remove(state.redo) - push_bounded(state.undo, record) - return true, record.label -end - -function history.clear(wand) - if wand == nil then - states, state_order = {}, {} - return - end - local key = tonumber(wand) or 0 - states[key] = nil - for index = #state_order, 1, -1 do - if state_order[index] == key then table.remove(state_order, index) end - end -end - -METAMORPH_CREATIVE_MENU_WAND_HISTORY = history -return history diff --git a/metamorph_creative_menu/files/features/wands/presets.lua b/metamorph_creative_menu/files/features/wands/presets.lua deleted file mode 100644 index adf1eb1..0000000 --- a/metamorph_creative_menu/files/features/wands/presets.lua +++ /dev/null @@ -1,161 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_WAND_PRESETS) == "table" then return METAMORPH_CREATIVE_MENU_WAND_PRESETS end - -local presets = {} -local utf8_text = dofile("mods/metamorph_creative_menu/files/core/utf8_text.lua") -local codec = dofile("mods/metamorph_creative_menu/files/core/wand_blueprint_codec.lua") -local blueprints = dofile("mods/metamorph_creative_menu/files/features/wands/blueprints.lua") -local inventory_slots = dofile("mods/metamorph_creative_menu/files/platform/noita/inventory_slots.lua") -local wand_sync = dofile("mods/metamorph_creative_menu/files/features/wands/sync.lua") -local ew_world_items = dofile("mods/metamorph_creative_menu/files/integrations/ew/world_items.lua") - -local SETTING_KEY = "metamorph_creative_menu.wand_presets_v1" -local HEADER = "MCM_PRESETS_V1\n" -local MAX_PRESETS = 64 -local BASE_WAND = "data/entities/items/starting_wand.xml" -local cache = nil - -local function deep_copy(value) - if type(value) ~= "table" then return value end - local copy = {} - for key, nested in pairs(value) do copy[key] = deep_copy(nested) end - return copy -end - -local function clean_name(value) - value = tostring(value or "") - value = string.gsub(value, "^%s+", "") - value = string.gsub(value, "%s+$", "") - value = string.gsub(value, "[%c]", " ") - value = utf8_text.truncate_bytes(value, 80) - return value -end - -local function alive(entity) - return entity ~= nil and entity ~= 0 and type(EntityGetIsAlive) == "function" and EntityGetIsAlive(entity) == true -end - -local function destroy_tree(entity) - if not alive(entity) then return end - for _, child in ipairs(EntityGetAllChildren(entity) or {}) do destroy_tree(child) end - if type(EntityRemoveFromParent) == "function" then pcall(EntityRemoveFromParent, entity) end - pcall(EntityKill, entity) -end - -local function load_cache() - if cache ~= nil then return cache end - cache = {} - local raw = "" - if type(ModSettingGet) == "function" then - local ok, value = pcall(ModSettingGet, SETTING_KEY) - if ok and type(value) == "string" then raw = value end - end - if string.sub(raw, 1, #HEADER) ~= HEADER then return cache end - for line in string.gmatch(string.sub(raw, #HEADER + 1) .. "\n", "([^\n]*)\n") do - local encoded_name, encoded_blueprint = string.match(line, "^([^\t]*)\t(.*)$") - if encoded_name ~= nil and encoded_blueprint ~= nil then - local name = codec.unescape(encoded_name) - local blueprint = select(1, codec.decode(codec.unescape(encoded_blueprint))) - if name ~= "" and blueprint ~= nil then cache[#cache + 1] = {name=name,blueprint=blueprint} end - end - if #cache >= MAX_PRESETS then break end - end - return cache -end - -local function persist(list) - local lines = {"MCM_PRESETS_V1"} - for _, preset in ipairs(list) do - lines[#lines + 1] = codec.escape(preset.name) .. "\t" .. codec.escape(codec.encode(preset.blueprint)) - end - if type(ModSettingSet) ~= "function" then return false, "settings_unavailable" end - local ok = pcall(ModSettingSet, SETTING_KEY, table.concat(lines, "\n")) - return ok, ok and "ok" or "settings_write_failed" -end - -function presets.all() - return deep_copy(load_cache()) -end - -function presets.save(name, wand) - name = clean_name(name) - if name == "" then return false, "name_required" end - local blueprint, reason = blueprints.capture(wand) - if blueprint == nil then return false, reason end - local list = deep_copy(load_cache()) - local target = nil - for index, preset in ipairs(list) do if string.lower(preset.name) == string.lower(name) then target = index; break end end - if target == nil then - if #list >= MAX_PRESETS then return false, "preset_limit" end - list[#list + 1] = {name=name,blueprint=blueprint} - else - list[target] = {name=name,blueprint=blueprint} - end - local ok, persist_reason = persist(list) - if ok then cache = list end - return ok, persist_reason -end - -function presets.load(index, player, wand) - local preset = load_cache()[tonumber(index)] - if preset == nil then return false, "missing_preset" end - return blueprints.apply(player, wand, preset.blueprint) -end - -function presets.give(index, player) - local preset = load_cache()[tonumber(index)] - if preset == nil then return false, "missing_preset", 0 end - if player == nil or player == 0 or not alive(player) then return false, "invalid_player", 0 end - local px, py = EntityGetTransform(player) - if tonumber(px) == nil or tonumber(py) == nil then return false, "position", 0 end - - local loaded_ok, entity = pcall(EntityLoad, BASE_WAND, px + 12, py - 8) - entity = loaded_ok and (tonumber(entity) or 0) or 0 - if entity == 0 or not alive(entity) then return false, "wand_load_failed", 0 end - - local applied, apply_reason = blueprints.apply(player, entity, preset.blueprint) - if not applied then - destroy_tree(entity) - return false, apply_reason or "blueprint_failed", 0 - end - - local plan, plan_reason = inventory_slots.preflight(player, entity) - if plan ~= nil then - local placed, place_reason = inventory_slots.place_exact(player, entity, plan.name, plan.x, plan.y) - if not placed then - destroy_tree(entity) - wand_sync.inventory(player) - return false, place_reason or "inventory_failed", 0 - end - wand_sync.inventory(player) - return true, "given_inventory", entity - end - - if plan_reason ~= "full" then - destroy_tree(entity) - return false, plan_reason or "inventory_failed", 0 - end - - pcall(EntityRemoveFromParent, entity) - pcall(EntitySetTransform, entity, px + 12, py - 8) - inventory_slots.enable_world(entity) - local notify_ok, notified = pcall(ew_world_items.notify_world_item, entity) - if not notify_ok or notified == false or not alive(entity) then - destroy_tree(entity) - return false, "world_sync_failed", 0 - end - return true, "given_world", entity -end - -function presets.delete(index) - index = math.floor(tonumber(index) or 0) - local list = deep_copy(load_cache()) - if list[index] == nil then return false, "missing_preset" end - table.remove(list, index) - local ok, persist_reason = persist(list) - if ok then cache = list end - return ok, persist_reason -end - - -METAMORPH_CREATIVE_MENU_WAND_PRESETS = presets -return presets diff --git a/metamorph_creative_menu/files/features/wands/service.lua b/metamorph_creative_menu/files/features/wands/service.lua deleted file mode 100644 index 6082f84..0000000 --- a/metamorph_creative_menu/files/features/wands/service.lua +++ /dev/null @@ -1,244 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_WAND_SERVICE) == "table" then return METAMORPH_CREATIVE_MENU_WAND_SERVICE end - -local wand_service = {} -local wand_api = dofile("mods/metamorph_creative_menu/files/platform/noita/wand.lua") -local wand_sync = dofile("mods/metamorph_creative_menu/files/features/wands/sync.lua") -local wand_appearance = dofile("mods/metamorph_creative_menu/files/features/wands/appearance.lua") - --- Keep creative editing inside a conservative engine-safe envelope. Vanilla can very --- rarely generate capacities above this, but this editor must never be able to create --- the hundreds/thousands of slots that can destabilize inventory/action regeneration. -local MAX_EDITABLE_SLOTS = 64 - -local DEFINITIONS = { - slots={kind="effective_capacity", min=1, max=MAX_EDITABLE_SLOTS, step=1, integer=true}, - actions_per_round={kind="object", object="gun_config", field="actions_per_round", min=1, max=1024, step=1, integer=true}, - reload_time={kind="object", object="gun_config", field="reload_time", min=-100000, max=100000, step=1, integer=true}, - fire_rate_wait={kind="object", object="gunaction_config", field="fire_rate_wait", min=-100000, max=100000, step=1, integer=true}, - spread_degrees={kind="object", object="gunaction_config", field="spread_degrees", min=-3600, max=3600, step=1}, - speed_multiplier={kind="object", object="gunaction_config", field="speed_multiplier", min=-1000, max=1000, step=0.05}, - mana_max={kind="scalar", field="mana_max", min=0, max=1000000000, step=50}, - mana_charge_speed={kind="scalar", field="mana_charge_speed", min=-1000000000, max=1000000000, step=50}, - item_recoil_recovery_speed={kind="scalar", field="item_recoil_recovery_speed", min=-1000000, max=1000000, step=1}, - gun_level={kind="scalar", field="gun_level", min=0, max=1000, step=1, integer=true}, -} - -local BOOLEAN_DEFINITIONS = { - shuffle={kind="object", object="gun_config", field="shuffle_deck_when_empty"}, - never_reload={kind="scalar", field="never_reload"}, -} - -local function finite_number(value) - value = tonumber(value) - return value ~= nil and value == value and value > -math.huge and value < math.huge and value or nil -end - -local function clamp_definition(definition, value) - value = finite_number(value) - if value == nil then return nil end - value = math.max(definition.min, math.min(definition.max, value)) - if definition.integer then - -- floor(x - 0.5) is biased for negative values (-1 becomes -2). Round - -- symmetrically so already-integral negative wand stats stay unchanged. - value = value >= 0 and math.floor(value + 0.5) or math.ceil(value - 0.5) - end - return value -end - -local function action_shape(wand) - local permanent, highest = 0, -1 - for _, child in ipairs(EntityGetAllChildren(wand) or {}) do - local item = EntityGetFirstComponentIncludingDisabled(child, "ItemComponent") - local action = EntityGetFirstComponentIncludingDisabled(child, "ItemActionComponent") - if item ~= nil and item ~= 0 and action ~= nil and action ~= 0 then - local ok_perm, is_perm = pcall(ComponentGetValue2, item, "permanently_attached") - if ok_perm and is_perm == true then - permanent = permanent + 1 - else - local ok_slot, x, y = pcall(ComponentGetValue2, item, "inventory_slot") - if ok_slot and (tonumber(y) or 0) == 0 then highest = math.max(highest, tonumber(x) or -1) end - end - end - end - return permanent, highest -end - -local function read_definition(ability, definition) - if definition.kind == "scalar" then - local value, ok = wand_api.get_scalar(ability, definition.field) - return ok and finite_number(value) or nil - end - local value, ok = wand_api.get_object(ability, definition.object, definition.field) - return ok and finite_number(value) or nil -end - -function wand_service.snapshot(wand) - local ability = wand_api.ability(wand) - if ability == 0 then return nil, "not_wand" end - local snapshot = { wand=wand, ability=ability, stats={} } - local permanent, highest = action_shape(wand) - snapshot.permanent_actions, snapshot.highest_slot = permanent, highest - for id, definition in pairs(DEFINITIONS) do - if id ~= "slots" then snapshot.stats[id] = read_definition(ability, definition) end - end - local raw_capacity = select(1, wand_api.get_object(ability, "gun_config", "deck_capacity")) - snapshot.raw_capacity = finite_number(raw_capacity) - snapshot.stats.slots = math.max(1, math.floor((snapshot.raw_capacity or 1) - permanent)) - local mana = select(1, wand_api.get_scalar(ability, "mana")) - snapshot.mana = finite_number(mana) - for id, definition in pairs(BOOLEAN_DEFINITIONS) do - local value, ok - if definition.kind == "scalar" then value, ok = wand_api.get_scalar(ability, definition.field) - else value, ok = wand_api.get_object(ability, definition.object, definition.field) end - snapshot.stats[id] = ok and value == true or false - end - local sprite = select(1, wand_api.get_scalar(ability, "sprite_file")) - snapshot.sprite_file = type(sprite) == "string" and sprite or "" - return snapshot, "ok" -end - -local function sync(player) wand_sync.inventory(player) end - -function wand_service.definition(id) - return DEFINITIONS[id] or BOOLEAN_DEFINITIONS[id] -end - -function wand_service.max_slots() - return MAX_EDITABLE_SLOTS -end - -function wand_service.set_stat(player, wand, id, value) - local snapshot, reason = wand_service.snapshot(wand) - if snapshot == nil then return false, reason end - local definition = DEFINITIONS[id] - if definition == nil then return false, "unknown_stat" end - value = clamp_definition(definition, value) - if value == nil then return false, "invalid_value" end - - local ok - if definition.kind == "effective_capacity" then - local minimum = math.max(1, snapshot.highest_slot + 1) - if value < minimum then return false, "occupied_slots" end - local raw = value + snapshot.permanent_actions - ok = wand_api.set_object(snapshot.ability, "gun_config", "deck_capacity", raw) - elseif definition.kind == "scalar" then - ok = wand_api.set_scalar(snapshot.ability, definition.field, value) - else - ok = wand_api.set_object(snapshot.ability, definition.object, definition.field, value) - end - if not ok then return false, "write_failed" end - - if id == "mana_max" and snapshot.mana ~= nil and snapshot.mana > value then - wand_api.set_scalar(snapshot.ability, "mana", value) - end - sync(player) - return true, "ok" -end - -function wand_service.set_boolean(player, wand, id, value) - local snapshot, reason = wand_service.snapshot(wand) - if snapshot == nil then return false, reason end - local definition = BOOLEAN_DEFINITIONS[id] - if definition == nil then return false, "unknown_boolean" end - value = value == true - local ok - if definition.kind == "scalar" then ok = wand_api.set_scalar(snapshot.ability, definition.field, value) - else ok = wand_api.set_object(snapshot.ability, definition.object, definition.field, value) end - if not ok then return false, "write_failed" end - sync(player) - return true, "ok" -end - -function wand_service.set_sprite(player, wand, path) - return wand_appearance.set_visual(player, wand, {sprite_file=path}) -end - - -local function write_definition(ability, definition, value, raw_capacity) - if definition.kind == "effective_capacity" then - return wand_api.set_object(ability, "gun_config", "deck_capacity", raw_capacity) - elseif definition.kind == "scalar" then - return wand_api.set_scalar(ability, definition.field, value) - end - return wand_api.set_object(ability, definition.object, definition.field, value) -end - -local function restore_snapshot(snapshot) - if type(snapshot) ~= "table" or snapshot.ability == nil then return end - for id, definition in pairs(DEFINITIONS) do - if id == "slots" then - if snapshot.raw_capacity ~= nil then pcall(wand_api.set_object, snapshot.ability, "gun_config", "deck_capacity", snapshot.raw_capacity) end - elseif snapshot.stats[id] ~= nil then - if definition.kind == "scalar" then pcall(wand_api.set_scalar, snapshot.ability, definition.field, snapshot.stats[id]) - else pcall(wand_api.set_object, snapshot.ability, definition.object, definition.field, snapshot.stats[id]) end - end - end - for id, definition in pairs(BOOLEAN_DEFINITIONS) do - if snapshot.stats[id] ~= nil then - if definition.kind == "scalar" then pcall(wand_api.set_scalar, snapshot.ability, definition.field, snapshot.stats[id]) - else pcall(wand_api.set_object, snapshot.ability, definition.object, definition.field, snapshot.stats[id]) end - end - end - if snapshot.sprite_file ~= nil and snapshot.sprite_file ~= "" then pcall(wand_api.set_scalar, snapshot.ability, "sprite_file", snapshot.sprite_file) end - if snapshot.mana ~= nil then pcall(wand_api.set_scalar, snapshot.ability, "mana", snapshot.mana) end -end - --- Apply a complete wand configuration with one synchronization point. This is used by --- persistent blueprints so ten scalar writes never become ten independent network/UI --- commits. Any failed verified write restores the exact pre-edit configuration. -function wand_service.apply_configuration(player, wand, desired, options) - desired = type(desired) == "table" and desired or {} - options = type(options) == "table" and options or {} - local before, reason = wand_service.snapshot(wand) - if before == nil then return false, reason end - local stats = type(desired.stats) == "table" and desired.stats or {} - local permanent_count = math.max(0, math.floor(tonumber(options.permanent_count) or before.permanent_actions or 0)) - - for id, definition in pairs(DEFINITIONS) do - if stats[id] ~= nil then - local value = clamp_definition(definition, stats[id]) - if value == nil then restore_snapshot(before); return false, "invalid_" .. id end - local raw_capacity = id == "slots" and value + permanent_count or nil - if not write_definition(before.ability, definition, value, raw_capacity) then - restore_snapshot(before); return false, "write_" .. id - end - end - end - for id, definition in pairs(BOOLEAN_DEFINITIONS) do - if stats[id] ~= nil then - local value = stats[id] == true - local ok - if definition.kind == "scalar" then ok = wand_api.set_scalar(before.ability, definition.field, value) - else ok = wand_api.set_object(before.ability, definition.object, definition.field, value) end - if not ok then restore_snapshot(before); return false, "write_" .. id end - end - end - - local sprite = desired.sprite_file - if type(sprite) == "string" and sprite ~= "" then - local exists = type(ModDoesFileExist) ~= "function" or ModDoesFileExist(sprite) - if exists and not wand_api.set_scalar(before.ability, "sprite_file", sprite) then - restore_snapshot(before); return false, "write_sprite" - end - end - local mana = finite_number(desired.mana) - if mana ~= nil then - local mana_max = finite_number(stats.mana_max) or finite_number(before.stats.mana_max) or mana - mana = math.max(0, math.min(mana, mana_max)) - if not wand_api.set_scalar(before.ability, "mana", mana) then restore_snapshot(before); return false, "write_mana" end - end - - if options.skip_sync ~= true then sync(player) end - return true, "ok" -end - -function wand_service.refresh(player) - sync(player) -end - -function wand_service.definitions() - return DEFINITIONS, BOOLEAN_DEFINITIONS -end - -METAMORPH_CREATIVE_MENU_WAND_SERVICE = wand_service -return wand_service diff --git a/metamorph_creative_menu/files/features/wands/skins.lua b/metamorph_creative_menu/files/features/wands/skins.lua deleted file mode 100644 index 8b95068..0000000 --- a/metamorph_creative_menu/files/features/wands/skins.lua +++ /dev/null @@ -1,102 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_WAND_SKINS) == "table" then return METAMORPH_CREATIVE_MENU_WAND_SKINS end - -local skins = {} -local item_catalog = dofile("mods/metamorph_creative_menu/files/features/items/catalog.lua") -local cache = nil - -local function attr(tag, name) - local escaped = string.gsub(name, "([^%w])", "%%%1") - return string.match(tag, escaped .. "%s*=%s*\"([^\"]*)\"") - or string.match(tag, escaped .. "%s*=%s*'([^']*)'") -end - -local function number_attr(tag, name) - local value = attr(tag, name) - return value ~= nil and tonumber(value) or nil -end - -local function tagged(tag, wanted) - local tags = tostring(attr(tag, "_tags") or attr(tag, "tags") or "") - for value in string.gmatch(tags, "[^,%s]+") do if value == wanted then return true end end - return false -end - -local function merge(into, values) - for key, value in pairs(values or {}) do if value ~= nil then into[key] = value end end - return into -end - -local function read_visual(path, visited, depth) - if type(path) ~= "string" or path == "" or depth > 10 or visited[path] then return nil end - if type(ModTextFileGetContent) ~= "function" then return nil end - visited[path] = true - local ok, content = pcall(ModTextFileGetContent, path) - if not ok or type(content) ~= "string" or content == "" then visited[path] = nil; return nil end - - local result = {} - for base in string.gmatch(content, "]*>") do - local base_path = attr(base, "file") - if base_path ~= nil and base_path ~= "" then merge(result, read_visual(base_path, visited, depth + 1)) end - end - - for ability in string.gmatch(content, "]*>") do - local sprite = attr(ability, "sprite_file") - if sprite ~= nil and sprite ~= "" then result.sprite_file = sprite end - end - for sprite in string.gmatch(content, "]*>") do - if tagged(sprite, "item") then - local image = attr(sprite, "image_file") - if image ~= nil and image ~= "" then result.image_file = image end - local x, y = number_attr(sprite, "offset_x"), number_attr(sprite, "offset_y") - if x ~= nil then result.offset_x = x end - if y ~= nil then result.offset_y = y end - end - end - for hotspot in string.gmatch(content, "]*>") do - if tagged(hotspot, "shoot_pos") then - local x = number_attr(hotspot, "offset.x") - local y = number_attr(hotspot, "offset.y") - if x ~= nil then result.tip_x = x end - if y ~= nil then result.tip_y = y end - end - end - visited[path] = nil - return next(result) ~= nil and result or nil -end - -function skins.entries() - if cache ~= nil then return cache end - cache = {} - local seen = {} - for _, entry in ipairs(type(item_catalog) == "table" and item_catalog or {}) do - if entry.category == "WANDS" and type(entry.path) == "string" then - local visual = read_visual(entry.path, {}, 0) - local sprite = visual and (visual.sprite_file or visual.image_file) or nil - if type(sprite) == "string" and sprite ~= "" then - local image = visual.image_file or sprite - local signature = table.concat({sprite, image, tostring(visual.offset_x or ""), tostring(visual.offset_y or ""), - tostring(visual.tip_x or ""), tostring(visual.tip_y or "")}, "\31") - if not seen[signature] then - seen[signature] = true - cache[#cache + 1] = { - sprite_file=sprite, image_file=image, source_path=entry.path, - name=entry.name, icon=entry.icon, - offset_x=visual.offset_x, offset_y=visual.offset_y, - tip_x=visual.tip_x, tip_y=visual.tip_y, - } - end - end - end - end - table.sort(cache, function(a,b) - local aa=tostring(a.name or a.source_path); local bb=tostring(b.name or b.source_path) - if aa==bb then return a.sprite_file= frame and (tonumber(state.rainfall) or 0) <= 0 then + write_value(component, "rain", 0) + write_value(component, "rain_target", 0) end + runtime_effects.emit_rain(state) + runtime_effects.update_lightning(state, component, write_value) + if not state.remote then weather_sync.publish(state, world_component, false) end end -function weather_service.debug_state() +function weather_service.state_snapshot() local component = world_component() local result = { active = state.active == true, diff --git a/metamorph_creative_menu/files/features/world_rules/definitions.lua b/metamorph_creative_menu/files/features/world_rules/definitions.lua index ee8fda3..2c08419 100644 --- a/metamorph_creative_menu/files/features/world_rules/definitions.lua +++ b/metamorph_creative_menu/files/features/world_rules/definitions.lua @@ -35,7 +35,8 @@ local rules = { { id="joint_strength", label="$mcm_rule_joint_strength", description="$mcm_rule_joint_strength_desc", kind="magic_multiplier", magic_keys={"PHYSICS_JOINT_MAX_FORCE_MULTIPLIER"}, choices={ native_choice, {value=0.25,label="0.25x"}, {value=0.5,label="0.5x"}, {value=1,label="1x"}, {value=2,label="2x"}, {value=4,label="4x"} } }, - { id="day_speed", label="$mcm_rule_day_speed", description="$mcm_rule_day_speed_desc", kind="time_dt_multiplier", + { id="day_speed", label="$mcm_rule_day_speed", description="$mcm_rule_day_speed_desc", kind="magic_multiplier", + magic_keys={"DESIGN_DAY_CYCLE_SPEED"}, choices={ native_choice, {value=0,label="0x"}, {value=0.25,label="0.25x"}, {value=0.5,label="0.5x"}, {value=1,label="1x"}, {value=2,label="2x"}, {value=4,label="4x"}, {value=10,label="10x"} } }, } diff --git a/metamorph_creative_menu/files/features/world_rules/magic_numbers.lua b/metamorph_creative_menu/files/features/world_rules/magic_numbers.lua index f0598da..15faf4b 100644 --- a/metamorph_creative_menu/files/features/world_rules/magic_numbers.lua +++ b/metamorph_creative_menu/files/features/world_rules/magic_numbers.lua @@ -5,10 +5,6 @@ local patcher_bridge = dofile("mods/metamorph_creative_menu/files/platform/noita local rule_math = dofile("mods/metamorph_creative_menu/files/core/rule_math.lua") local recovery = dofile("mods/metamorph_creative_menu/files/features/world_rules/recovery.lua") local magic_state = {} --- polish08 and earlier implemented Day Speed by owning this Magic Number. Keep it in --- recovery discovery only so an upgrade cannot leave a stale DESIGN multiplier behind --- and then multiply time_dt a second time with the new safe backend. -local LEGACY_RECOVERY_KEYS = { "DESIGN_DAY_CYCLE_SPEED" } local function same_value(a, b) if type(a) == "number" or type(b) == "number" then return rule_math.same(a, b) end @@ -185,12 +181,6 @@ local function recovery_magic_keys(rules) end end end - for _, key in ipairs(LEGACY_RECOVERY_KEYS) do - if not seen[key] then - seen[key] = true - result[#result + 1] = key - end - end return result end diff --git a/metamorph_creative_menu/files/features/world_rules/physics.lua b/metamorph_creative_menu/files/features/world_rules/physics.lua index b45bc3d..56117fd 100644 --- a/metamorph_creative_menu/files/features/world_rules/physics.lua +++ b/metamorph_creative_menu/files/features/world_rules/physics.lua @@ -7,110 +7,30 @@ local recovery = dofile("mods/metamorph_creative_menu/files/features/world_rules local PHYSICS_SCAN_HALF_WIDTH = 1024 local PHYSICS_SCAN_HALF_HEIGHT = 768 --- Use a wider query shell when deciding whether a previously modified body is still --- safe to touch. If Box2D no longer returns it there, relinquish the record instead of --- calling native getters on a potentially expired body ID. -local PHYSICS_RESTORE_HALF_WIDTH = PHYSICS_SCAN_HALF_WIDTH + 192 -local PHYSICS_RESTORE_HALF_HEIGHT = PHYSICS_SCAN_HALF_HEIGHT + 192 local CHARACTER_SCAN_RADIUS = 1024 local physics_state = { bodies = {}, - -- Bodies that left even the safe restore query may still be alive and return later. - -- Keep only pure Lua baseline data here; never query a dormant BodyID until Noita - -- returns that ID from a fresh spatial query again. - released_bodies = {}, - released_order = {}, characters = {}, local_gravity_native = {}, player_recovery_cache = {}, } -local RELEASED_BODY_LIMIT = 1024 local function values_equal(a, b) if type(a) == "number" or type(b) == "number" then return rule_math.same(a, b) end return a == b end -local function forget_released_body(id) - physics_state.released_bodies[id] = nil - for index = #physics_state.released_order, 1, -1 do - if physics_state.released_order[index] == id then - table.remove(physics_state.released_order, index) - break - end - end -end - -local function remember_released_body(id, record, frame) - if record == nil or (record.last_gravity == nil and record.last_linear == nil) then return end - forget_released_body(id) - physics_state.released_bodies[id] = { - gravity=record.gravity, linear=record.linear, angular=record.angular, - last_gravity=record.last_gravity, last_linear=record.last_linear, - last_angular=record.last_angular, released_frame=frame, - } - local order = physics_state.released_order - order[#order + 1] = id - while #order > RELEASED_BODY_LIMIT do - local oldest = table.remove(order, 1) - physics_state.released_bodies[oldest] = nil - end -end - -local function body_record(id, frame, gravity_factor, damping_factor) +local function body_record(id, frame) local record = physics_state.bodies[id] if record ~= nil then record.last_seen = frame return record end - -- The ID came from this frame's query, so native getters are safe again. local ok_g, gravity = pcall(PhysicsBodyIDGetGravityScale, id) local ok_d, linear, angular = pcall(PhysicsBodyIDGetDamping, id) if not ok_g or gravity == nil or not ok_d or linear == nil then return nil end - angular = angular or linear - - local released = physics_state.released_bodies[id] - local baseline_g, baseline_l, baseline_a = gravity, linear, angular - local retained_last_g, retained_last_l, retained_last_a = nil, nil, nil - if released ~= nil then - -- Reuse the old baseline only if the body still carries exactly our last write. - -- If another owner changed it while away (or Noita reused the ID), current values - -- become the new native baseline instead of corrupting the new body. - if released.last_gravity ~= nil and values_equal(gravity, released.last_gravity) then - baseline_g = released.gravity - if gravity_factor == nil then - local wrote = pcall(PhysicsBodyIDSetGravityScale, id, baseline_g) - local read_ok, after = pcall(PhysicsBodyIDGetGravityScale, id) - if wrote and read_ok and values_equal(after, baseline_g) then - gravity = after - else - retained_last_g = gravity - end - end - end - if released.last_linear ~= nil and values_equal(linear, released.last_linear) - and values_equal(angular, released.last_angular or released.last_linear) - then - baseline_l, baseline_a = released.linear, released.angular - if damping_factor == nil then - local wrote = pcall(PhysicsBodyIDSetDamping, id, baseline_l, baseline_a) - local read_ok, after_l, after_a = pcall(PhysicsBodyIDGetDamping, id) - after_a = after_a or after_l - if wrote and read_ok and values_equal(after_l, baseline_l) and values_equal(after_a, baseline_a) then - linear, angular = after_l, after_a - else - retained_last_l, retained_last_a = linear, angular - end - end - end - forget_released_body(id) - end - - record = { - gravity=baseline_g, linear=baseline_l, angular=baseline_a, last_seen=frame, - last_gravity=retained_last_g, last_linear=retained_last_l, last_angular=retained_last_a, - } + record = { gravity=gravity, linear=linear, angular=angular or linear, last_seen=frame } physics_state.bodies[id] = record return record end @@ -292,35 +212,16 @@ local function restore_character_record(key, record) return true, "ok" end -local function scan_physics_bodies(player, half_width, half_height) +local function scan_physics_bodies(player) if type(PhysicsBodyIDQueryBodies) ~= "function" then return {} end player = player or player_locator.get() if player == nil or player == 0 or not EntityGetIsAlive(player) then return {} end local x, y = EntityGetTransform(player) if x == nil then return {} end - half_width = tonumber(half_width) or PHYSICS_SCAN_HALF_WIDTH - half_height = tonumber(half_height) or PHYSICS_SCAN_HALF_HEIGHT - local ok, bodies = pcall(PhysicsBodyIDQueryBodies, x - half_width, y - half_height, x + half_width, y + half_height, false, false) + local ok, bodies = pcall(PhysicsBodyIDQueryBodies, x - PHYSICS_SCAN_HALF_WIDTH, y - PHYSICS_SCAN_HALF_HEIGHT, x + PHYSICS_SCAN_HALF_WIDTH, y + PHYSICS_SCAN_HALF_HEIGHT, false, false) return ok and type(bodies) == "table" and bodies or {} end -local function body_set(bodies) - local result = {} - for _, body_id in ipairs(bodies or {}) do result[body_id] = true end - return result -end - -local function confirmed_restore_bodies(player) - return body_set(scan_physics_bodies(player, PHYSICS_RESTORE_HALF_WIDTH, PHYSICS_RESTORE_HALF_HEIGHT)) -end - -local function relinquish_body(record) - if record == nil then return end - record.last_gravity = nil - record.last_linear = nil - record.last_angular = nil -end - local function local_gravity_key(comp, field) return tostring(comp) .. ":" .. tostring(field) @@ -547,11 +448,10 @@ end function physics_adapter.scan(player, gravity_factor, damping_factor, frame) if player == nil or player == 0 or not EntityGetIsAlive(player) then return end local scanned_bodies = scan_physics_bodies(player) - local confirmed_bodies = confirmed_restore_bodies(player) local live_bodies = {} for _, body_id in ipairs(scanned_bodies) do live_bodies[body_id] = true - local record = body_record(body_id, frame, gravity_factor, damping_factor) + local record = body_record(body_id, frame) if record ~= nil then if gravity_factor ~= nil then set_body_gravity(body_id, record, gravity_factor) end if damping_factor ~= nil then set_body_damping(body_id, record, damping_factor) end @@ -562,17 +462,8 @@ function physics_adapter.scan(player, gravity_factor, damping_factor, frame) for _, body_id in ipairs(body_ids) do local record = physics_state.bodies[body_id] if record ~= nil and record.last_seen ~= frame and not live_bodies[body_id] then - if confirmed_bodies[body_id] then - local restored, reason = restore_body(body_id, record) - if restored or reason == "gone" then physics_state.bodies[body_id] = nil end - else - -- Calling any PhysicsBodyID getter on an expired ID emits a native Lua - -- error even behind pcall. Preserve only our Lua baseline/last-write - -- values so a live body can safely recover them if it is queried again. - remember_released_body(body_id, record, frame) - relinquish_body(record) - physics_state.bodies[body_id] = nil - end + local restored, reason = restore_body(body_id, record) + if restored or reason == "gone" then physics_state.bodies[body_id] = nil end end end if gravity_factor ~= nil then scan_character_gravity(gravity_factor, frame, player) end @@ -586,18 +477,12 @@ end function physics_adapter.restore_rule(kind) local all_restored = true - local confirmed_bodies = confirmed_restore_bodies(player_locator.get()) local body_ids = {} for body_id in pairs(physics_state.bodies) do body_ids[#body_ids + 1] = body_id end for _, body_id in ipairs(body_ids) do local record = physics_state.bodies[body_id] local restored, reason - if not confirmed_bodies[body_id] then - remember_released_body(body_id, record, type(GameGetFrameNum) == "function" and GameGetFrameNum() or 0) - relinquish_body(record) - physics_state.bodies[body_id] = nil - restored, reason = true, "gone" - elseif kind == "physics_gravity" then + if kind == "physics_gravity" then restored, reason = restore_body_gravity(body_id, record) else restored, reason = restore_body_damping(body_id, record) @@ -619,19 +504,11 @@ end function physics_adapter.reset_all() local all_restored = true - local confirmed_bodies = confirmed_restore_bodies(player_locator.get()) local body_ids = {} for body_id in pairs(physics_state.bodies) do body_ids[#body_ids + 1] = body_id end for _, body_id in ipairs(body_ids) do local record = physics_state.bodies[body_id] - local restored, reason - if confirmed_bodies[body_id] then - restored, reason = restore_body(body_id, record) - else - remember_released_body(body_id, record, type(GameGetFrameNum) == "function" and GameGetFrameNum() or 0) - relinquish_body(record) - restored, reason = true, "gone" - end + local restored, reason = restore_body(body_id, record) if restored or reason == "gone" then physics_state.bodies[body_id] = nil else all_restored = false end end local character_keys = {} @@ -651,7 +528,7 @@ function physics_adapter.has_persisted_local_recovery() return recovery.has("player_gravity") end -function physics_adapter.debug_local_gravity(player, factor) +function physics_adapter.local_gravity_state(player, factor) local rows = {} if player == nil or player == 0 or not EntityGetIsAlive(player) then return {player=0, factor=factor, rows=rows} end for _, pair in ipairs({{"CharacterDataComponent","gravity"},{"CharacterPlatformingComponent","pixel_gravity"}}) do diff --git a/metamorph_creative_menu/files/features/world_rules/recovery.lua b/metamorph_creative_menu/files/features/world_rules/recovery.lua index 28b874b..4472aaa 100644 --- a/metamorph_creative_menu/files/features/world_rules/recovery.lua +++ b/metamorph_creative_menu/files/features/world_rules/recovery.lua @@ -81,11 +81,9 @@ function recovery.read(scope, id) if phase ~= "captured" and phase ~= "owned" and phase ~= "partial" then phase = last_ok and "owned" or "captured" end - local last_value = nil - if last_ok then last_value = last end return { original = original, - last = last_value, + last = last_ok and last or nil, phase = phase, meta = GlobalsGetValue(value_key(scope, id, "meta"), ""), } diff --git a/metamorph_creative_menu/files/features/world_rules/service.lua b/metamorph_creative_menu/files/features/world_rules/service.lua index 02f5e34..d6bd8ab 100644 --- a/metamorph_creative_menu/files/features/world_rules/service.lua +++ b/metamorph_creative_menu/files/features/world_rules/service.lua @@ -11,7 +11,6 @@ local physics_adapter = dofile("mods/metamorph_creative_menu/files/features/worl local world_state_adapter = dofile("mods/metamorph_creative_menu/files/features/world_rules/world_state.lua") local stain_adapter = dofile("mods/metamorph_creative_menu/files/features/world_rules/stains.lua") local magic_number_adapter = dofile("mods/metamorph_creative_menu/files/features/world_rules/magic_numbers.lua") -local time_dt_owner = dofile("mods/metamorph_creative_menu/files/features/world_rules/time_dt.lua") -- Live physics rules only need to cover the active neighbourhood. The old 4608x3072 -- query plus a 2304-radius entity scan every 8 frames scaled badly in long EW runs. @@ -44,7 +43,6 @@ local function startup_recovery_pending() return (type(world_state_adapter.has_persisted_recovery) == "function" and world_state_adapter.has_persisted_recovery(RULES)) or (type(magic_number_adapter.has_persisted_recovery) == "function" and magic_number_adapter.has_persisted_recovery(RULES)) or (type(physics_adapter.has_persisted_local_recovery) == "function" and physics_adapter.has_persisted_local_recovery()) - or (type(time_dt_owner.has_persisted_recovery) == "function" and time_dt_owner.has_persisted_recovery()) end local function recover_stale_saved_overrides() @@ -59,8 +57,7 @@ local function recover_stale_saved_overrides() local magic_ok = type(magic_number_adapter.recover_persisted) ~= "function" or magic_number_adapter.recover_persisted(RULES) local local_ok = type(physics_adapter.recover_persisted_local) ~= "function" or physics_adapter.recover_persisted_local(player_locator.get()) - local time_ok = type(time_dt_owner.recover_persisted) ~= "function" or time_dt_owner.recover_persisted() - if not (world_ok and magic_ok and local_ok and time_ok) then return false end + if not (world_ok and magic_ok and local_ok) then return false end if startup_recovery_pending() then return false end state.startup_recovery_done = true state.selected = {} @@ -74,9 +71,6 @@ local function recover_stale_saved_overrides() restore_loaded_gold_lifetimes_if_disabled() -- Startup recovery removes stale state from a previous Lua/save session. It is not -- a user edit and must not race a live EW host snapshot during peer reconnect. - if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "world_rules.startup_recovery", "restored_stale_saved_overrides=true") - end return true end @@ -108,8 +102,6 @@ local function reassert_nonphysics_rules() if choice ~= nil and choice.native ~= true then if rule.kind == "magic_multiplier" then magic_number_adapter.apply(rule.magic_keys, tonumber(choice.value) or 1) - elseif rule.kind == "time_dt_multiplier" then - time_dt_owner.apply_day_multiplier(tonumber(choice.value) or 1) elseif rule.kind == "infinite_spells" or rule.kind == "field" then world_state_adapter.apply(rule, choice) end @@ -182,7 +174,6 @@ function world_rule_service.supported(rule) end if rule.kind == "stain_drop" then return stain_adapter.supported() end if rule.kind == "magic_multiplier" then return magic_number_adapter.supported() end - if rule.kind == "time_dt_multiplier" then return time_dt_owner.supported() end return world_state_adapter.supported(rule) end @@ -225,14 +216,6 @@ local function apply_rule_index(rule, index) return applied, applied and "ok" or "magic_write_readback" end - if rule.kind == "time_dt_multiplier" then - local applied, reason - if choice.native == true then applied, reason = time_dt_owner.release_day_multiplier() - else applied, reason = time_dt_owner.apply_day_multiplier(tonumber(choice.value) or 1) end - if applied then state.selected[rule.id] = choice.native == true and nil or index end - return applied, reason or (applied and "ok" or "time_dt_write_readback") - end - local applied, reason = world_state_adapter.apply(rule, choice) if applied then state.selected[rule.id] = choice.native == true and nil or index @@ -271,9 +254,6 @@ local function apply_network_snapshot(choices) end end state.last_physics_scan = -100000 - if #failures > 0 and type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "world_rules.snapshot_apply", table.concat(failures, ",")) - end end update_network_sync = function(frame) @@ -287,8 +267,8 @@ end function world_rule_service.gravity_factor() return physics_factor("physics_gravity") end -function world_rule_service.local_gravity_debug() - return physics_adapter.debug_local_gravity(player_locator.get(), world_rule_service.gravity_factor()) +function world_rule_service.local_gravity_state() + return physics_adapter.local_gravity_state(player_locator.get(), world_rule_service.gravity_factor()) end function world_rule_service.step(rule, direction) @@ -324,8 +304,6 @@ function world_rule_service.reset() if not physics_adapter.reset_all() then all_restored = false end if not stain_adapter.restore_all() then all_restored = false end if not magic_number_adapter.reset_all() then all_restored = false end - local time_restored = time_dt_owner.release_day_multiplier() - if time_restored ~= true then all_restored = false end for _, rule in ipairs(RULES) do local restored = false @@ -335,8 +313,6 @@ function world_rule_service.reset() restored = not stain_adapter.has_overrides() elseif rule.kind == "magic_multiplier" then restored = not magic_number_adapter.owns(rule.magic_keys) - elseif rule.kind == "time_dt_multiplier" then - restored = not time_dt_owner.has_day_override() elseif rule.kind == "physics_gravity" then restored = type(physics_adapter.has_gravity_overrides) ~= "function" or not physics_adapter.has_gravity_overrides() elseif rule.kind == "physics_damping" then @@ -363,7 +339,6 @@ function world_rule_service.post_update() local player = player_locator.get() physics_adapter.reassert_local(player, factor, GameGetFrameNum()) end - if time_dt_owner.has_day_override() then time_dt_owner.reassert() end end function world_rule_service.recovery_status() @@ -380,7 +355,6 @@ function world_rule_service.has_overrides() or physics_adapter.has_overrides() or stain_adapter.has_overrides() or magic_number_adapter.has_overrides() - or time_dt_owner.has_day_override() end METAMORPH_CREATIVE_MENU_WORLD_RULE_SERVICE = world_rule_service diff --git a/metamorph_creative_menu/files/features/world_rules/time_dt.lua b/metamorph_creative_menu/files/features/world_rules/time_dt.lua deleted file mode 100644 index bfa5c43..0000000 --- a/metamorph_creative_menu/files/features/world_rules/time_dt.lua +++ /dev/null @@ -1,276 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_TIME_DT_OWNER) == "table" then return METAMORPH_CREATIVE_MENU_TIME_DT_OWNER end - --- One owner for WorldStateComponent.time_dt. --- Day Speed uses time_dt as a safe multiplier backend; Weather temporarily freezes the --- same field while a fixed time-of-day is active. Keeping both features behind this --- adapter prevents either one from restoring a stale value over the other. -local owner = {} -local rule_math = dofile("mods/metamorph_creative_menu/files/core/rule_math.lua") -local recovery = dofile("mods/metamorph_creative_menu/files/features/world_rules/recovery.lua") - -local RECOVERY_SCOPE = "world_time" -local RECOVERY_ID = "time_dt" - -local state = { - baseline = nil, - last_written = nil, - day_multiplier = nil, - weather_frozen = false, -} - -local function same(a, b) - return rule_math.same(tonumber(a), tonumber(b)) -end - -local function world_component() - if type(GameGetWorldStateEntity) ~= "function" or type(EntityGetFirstComponentIncludingDisabled) ~= "function" then return nil end - local world = GameGetWorldStateEntity() - if world == nil or world == 0 then return nil end - local comp = EntityGetFirstComponentIncludingDisabled(world, "WorldStateComponent") - return comp ~= nil and comp ~= 0 and comp or nil -end - -local function read(comp) - comp = comp or world_component() - if comp == nil or type(ComponentGetValue2) ~= "function" then return false, nil end - local ok, value = pcall(ComponentGetValue2, comp, "time_dt") - value = ok and tonumber(value) or nil - return value ~= nil, value -end - -local function write_verified(comp, target) - if comp == nil or type(ComponentSetValue2) ~= "function" then return false, nil end - local ok = pcall(ComponentSetValue2, comp, "time_dt", target) - local read_ok, after = read(comp) - return ok and read_ok and same(after, target), after -end - -local function hydrate_recovery() - if state.baseline ~= nil then return true end - local record = recovery.read(RECOVERY_SCOPE, RECOVERY_ID) - if type(record) ~= "table" then return false end - local baseline = tonumber(record.original) - if baseline == nil then return false end - state.baseline = baseline - state.last_written = tonumber(record.last) - return true -end - -local function capture(comp) - if state.baseline ~= nil or hydrate_recovery() then return true end - local ok, current = read(comp) - if not ok then return false end - if not recovery.capture(RECOVERY_SCOPE, RECOVERY_ID, current, "WorldStateComponent.time_dt") then return false end - state.baseline = current - state.last_written = nil - return true -end - -local function clear_capture() - recovery.clear(RECOVERY_SCOPE, RECOVERY_ID) - state.baseline = nil - state.last_written = nil -end - -local function mark_owned(value) - state.last_written = value - recovery.update_last(RECOVERY_SCOPE, RECOVERY_ID, value) -end - -local function target_for(day_multiplier, weather_frozen) - if state.baseline == nil then return nil end - if weather_frozen == true then return 0 end - if day_multiplier ~= nil then return state.baseline * day_multiplier end - return nil -end - -local function write_target(comp, target) - if not capture(comp) then return false, "capture" end - local ok, before = read(comp) - if not ok then return false, "read" end - if same(before, target) then return true, "ok" end - local wrote, after = write_verified(comp, target) - if wrote then - mark_owned(after) - return true, "ok" - end - -- A setter can fail after mutating its backing value. Keep that write owned so a - -- later RESET/restart can still perform a verified CAS recovery. - if after ~= nil and not same(after, before) then - state.last_written = after - recovery.mark_partial(RECOVERY_SCOPE, RECOVERY_ID, after) - end - return false, "write_readback" -end - -local function release_if_inactive(comp) - if state.day_multiplier ~= nil or state.weather_frozen then return true, "active" end - if state.baseline == nil and not hydrate_recovery() then return true, "ok" end - if state.last_written == nil then - clear_capture() - return true, "ok" - end - local ok, current = read(comp) - if not ok then return false, "read" end - -- Compare-and-swap: another mod/game wrote after MCM. Its newer value wins. - if not same(current, state.last_written) then - clear_capture() - return true, "external_preserved" - end - local wrote, after = write_verified(comp or world_component(), state.baseline) - if wrote then - clear_capture() - return true, "ok" - end - state.last_written = after or current - recovery.mark_partial(RECOVERY_SCOPE, RECOVERY_ID, state.last_written) - return false, "restore_readback" -end - -function owner.supported() - local comp = world_component() - if comp == nil then return false end - local ok = read(comp) - return ok and type(ComponentSetValue2) == "function" -end - -function owner.apply_day_multiplier(multiplier) - multiplier = tonumber(multiplier) - if multiplier == nil or multiplier ~= multiplier or math.abs(multiplier) > 100000 then return false, "factor" end - local comp = world_component() - if comp == nil then return false, "world" end - if not capture(comp) then return false, "capture" end - local target = target_for(multiplier, state.weather_frozen) - local ok, reason = write_target(comp, target) - if not ok then return false, reason end - state.day_multiplier = multiplier - return true, "ok" -end - -function owner.release_day_multiplier() - local previous = state.day_multiplier - if previous == nil then return true, "ok" end - state.day_multiplier = nil - local ok, reason = release_if_inactive(world_component()) - if not ok then state.day_multiplier = previous end - return ok, reason -end - -function owner.set_weather_frozen(enabled) - enabled = enabled == true - if state.weather_frozen == enabled then - return owner.reassert() - end - local comp = world_component() - if comp == nil then return false, "world" end - if enabled then - if not capture(comp) then return false, "capture" end - local ok, reason = write_target(comp, 0) - if not ok then return false, reason end - state.weather_frozen = true - return true, "ok" - end - - state.weather_frozen = false - if state.day_multiplier ~= nil then - local target = target_for(state.day_multiplier, false) - local ok, reason = write_target(comp, target) - if not ok then state.weather_frozen = true end - return ok, reason - end - local ok, reason = release_if_inactive(comp) - if not ok then state.weather_frozen = true end - return ok, reason -end - -function owner.reassert() - if state.day_multiplier == nil and not state.weather_frozen then return true, "ok" end - local comp = world_component() - if comp == nil then return false, "world" end - if not capture(comp) then return false, "capture" end - local target = target_for(state.day_multiplier, state.weather_frozen) - return write_target(comp, target) -end - -function owner.day_multiplier() return state.day_multiplier end -function owner.has_day_override() return state.day_multiplier ~= nil end -function owner.is_weather_frozen() return state.weather_frozen == true end - --- Weather protocol v1 historically transports time_dt itself. New builds send the --- native baseline rather than baseline*DaySpeed so an older peer, which still applies --- Day Speed through DESIGN_DAY_CYCLE_SPEED, cannot multiply the same factor twice. -function owner.legacy_sync_value(comp) - if state.baseline == nil then hydrate_recovery() end - if state.baseline ~= nil then return state.baseline end - local ok, current = read(comp) - return ok and current or nil -end - --- Preserve the old weather-v1 convergence behaviour only while this owner has no active --- Day Speed/freeze. If MCM currently owns time_dt, its local composed target is authoritative. -function owner.accept_legacy_sync_value(value, comp) - value = tonumber(value) - if value == nil then return false end - if state.day_multiplier ~= nil or state.weather_frozen then return true end - comp = comp or world_component() - if comp == nil then return false end - local ok, current = read(comp) - if not ok then return false end - if same(current, value) then return true end - local wrote = write_verified(comp, value) - return wrote == true -end - -function owner.has_persisted_recovery() - return recovery.read(RECOVERY_SCOPE, RECOVERY_ID) ~= nil -end - -function owner.recover_persisted() - local record = recovery.read(RECOVERY_SCOPE, RECOVERY_ID) - if type(record) ~= "table" then return true end - local comp = world_component() - if comp == nil then return false end - local original = tonumber(record.original) - local last = tonumber(record.last) - if original == nil then - recovery.clear(RECOVERY_SCOPE, RECOVERY_ID) - return true - end - if last == nil then - recovery.clear(RECOVERY_SCOPE, RECOVERY_ID) - state.baseline, state.last_written = nil, nil - return true - end - local read_ok, current = read(comp) - if not read_ok then return false end - -- Same CAS rule on startup: never resurrect an old MCM baseline over a newer write. - if not same(current, last) then - recovery.clear(RECOVERY_SCOPE, RECOVERY_ID) - state.baseline, state.last_written = nil, nil - return true - end - local wrote, after = write_verified(comp, original) - if not wrote then - recovery.mark_partial(RECOVERY_SCOPE, RECOVERY_ID, after or current) - state.baseline = original - state.last_written = after or current - return false - end - recovery.clear(RECOVERY_SCOPE, RECOVERY_ID) - state.baseline, state.last_written = nil, nil - state.day_multiplier = nil - state.weather_frozen = false - return true -end - -function owner.debug_state() - return { - baseline=state.baseline, - last_written=state.last_written, - day_multiplier=state.day_multiplier, - weather_frozen=state.weather_frozen, - } -end - -METAMORPH_CREATIVE_MENU_TIME_DT_OWNER = owner -return owner diff --git a/metamorph_creative_menu/files/integrations/ew/bootstrap.lua b/metamorph_creative_menu/files/integrations/ew/bootstrap.lua index 422fba8..885415b 100644 --- a/metamorph_creative_menu/files/integrations/ew/bootstrap.lua +++ b/metamorph_creative_menu/files/integrations/ew/bootstrap.lua @@ -3,119 +3,52 @@ local ew_bootstrap = {} local ew_api = dofile_once("mods/quant.ew/files/api/ew_api.lua") local protocol = dofile("mods/metamorph_creative_menu/files/integrations/ew/bridge/protocol.lua") --- RPC indices are protocol state. The v4 order is asserted by tests and MUST NOT be --- changed without a namespace bump. Registration below preserves all eleven slots. +-- RPC indices are protocol state. v3 order is asserted by tests and MUST NOT be changed +-- without a namespace bump. Registration order below reproduces v10/v11 slots exactly. local rpc = ew_api.new_rpc_namespace(protocol.NAMESPACE) local common = dofile("mods/metamorph_creative_menu/files/integrations/ew/bridge/common.lua") -local form_death_intercept = dofile("mods/metamorph_creative_menu/files/integrations/ew/form_death_intercept.lua") -local form_death_intercept_ok, form_death_intercept_reason = form_death_intercept.install() -if not form_death_intercept_ok then - common.report_error("form_death_intercept.install", form_death_intercept_reason) -end -local boss_global_demote = dofile("mods/metamorph_creative_menu/files/integrations/ew/boss_global_demote.lua") -local boss_global_demote_ok, boss_global_demote_reason = boss_global_demote.install() -if not boss_global_demote_ok then - common.report_error("boss_global_demote.install", boss_global_demote_reason) -end -local kolmi_lifecycle = dofile("mods/metamorph_creative_menu/files/integrations/ew/kolmi_lifecycle.lua") -local kolmi_lifecycle_ok, kolmi_lifecycle_reason = kolmi_lifecycle.install() -if not kolmi_lifecycle_ok then - common.report_error("kolmi_lifecycle.install", kolmi_lifecycle_reason) -end -local boss_lifecycle = dofile("mods/metamorph_creative_menu/files/integrations/ew/boss_lifecycle.lua") -local boss_lifecycle_ok, boss_lifecycle_reason = boss_lifecycle.install() -if not boss_lifecycle_ok then - common.report_error("boss_lifecycle.install", boss_lifecycle_reason) -end local perk_runtime_guard = dofile("mods/metamorph_creative_menu/files/integrations/ew/perk_runtime_guard.lua") local perk_guard_ok, perk_guard_reason = perk_runtime_guard.install() if not perk_guard_ok then - GlobalsSetValue("mcm_peer_perk_runtime_guard_v1", common.encode_diagnostic("failed:" .. tostring(perk_guard_reason))) + GlobalsSetValue("mcm_peer_perk_runtime_guard_v1", "failed:" .. tostring(perk_guard_reason)) end local world_rules = dofile("mods/metamorph_creative_menu/files/integrations/ew/bridge/world_rules.lua") -local dev_mode = tonumber(dofile("mods/metamorph_creative_menu/dev_mode.lua")) == 1 -local qa = dofile(dev_mode - and "mods/metamorph_creative_menu/files/integrations/ew/bridge/qa.lua" - or "mods/metamorph_creative_menu/files/integrations/ew/bridge/qa_reserved.lua") local companion = dofile("mods/metamorph_creative_menu/files/integrations/ew/bridge/companion.lua") local forms = dofile("mods/metamorph_creative_menu/files/integrations/ew/bridge/forms.lua") local perks = dofile("mods/metamorph_creative_menu/files/integrations/ew/bridge/perks.lua") local weather = dofile("mods/metamorph_creative_menu/files/integrations/ew/bridge/weather.lua") local possession = dofile("mods/metamorph_creative_menu/files/integrations/ew/bridge/possession.lua") local items = dofile("mods/metamorph_creative_menu/files/integrations/ew/bridge/items.lua") -local creative_perks = dofile("mods/metamorph_creative_menu/files/integrations/ew/bridge/creative_perks.lua") -local materials = dofile("mods/metamorph_creative_menu/files/integrations/ew/bridge/materials.lua") -local teleport = dofile("mods/metamorph_creative_menu/files/integrations/ew/bridge/teleport.lua") -if type(forms.set_profiling_enabled) == "function" then forms.set_profiling_enabled(dev_mode) end -if type(materials.set_metrics_enabled) == "function" then materials.set_metrics_enabled(dev_mode) end --- v4 RPC slots: 1 apply_rules, 2 request_rules, 3 QA telemetry, 4 companion, --- 5 form pose, 6 reserved perk, 7 apply weather, 8 request weather, --- 9 possession retirement, 10 reserved old light-form protocol, --- 11 material paint batch. +-- v3 RPC slots are positional. Slot 3 is reserved to preserve wire compatibility. world_rules.register(rpc, common) -qa.register(rpc, common) +rpc.opts_reliable() +rpc.opts_everywhere() +function rpc.reserved_protocol_slot_3(...) end companion.register(rpc, common) forms.register_pose(rpc, common) perks.register(rpc, common) weather.register(rpc, common) possession.register(rpc, common) forms.register_reserved(rpc, common) -materials.register(rpc, common) --- Bring-player requests use a separate namespace so the established v4 slot order --- remains byte-for-byte compatible with earlier MCM peers. -teleport.register(ew_api, common) items.init(common) -creative_perks.init(common) local function publish_identity() GlobalsSetValue("mcm_world_rules_rpc_ready_v1", "1") GlobalsSetValue("mcm_world_rules_rpc_my_id_v1", common.clean(ctx.my_id)) GlobalsSetValue("mcm_world_rules_rpc_host_id_v1", common.clean(ctx.host_id)) - if dev_mode then - local sent, received = forms.metrics() - GlobalsSetValue("mcm_form_pose_sent_v1", tostring(sent)) - GlobalsSetValue("mcm_form_pose_received_v1", tostring(received)) - GlobalsSetValue("mcm_form_sync_mode_v1", "native_full_entity_pose_v1") - GlobalsSetValue("mcm_light_form_serialize_v1", "disabled_native") - GlobalsSetValue("mcm_light_form_deserialize_v1", "disabled_native") - end -end - -local function protected_update(scope, callback, ...) - if type(callback) ~= "function" then - pcall(common.report_error, scope, "callback_unavailable") - return false - end - local ok, result = pcall(callback, ...) - if not ok then - pcall(common.report_error, scope, result) - return false - end - return true, result end function ew_bootstrap.on_world_update() - -- Retry only until EW publishes its health capability. This is normally installed - -- synchronously at bootstrap, but the retry keeps unusual extra-module order safe. - if not form_death_intercept_ok then - form_death_intercept_ok, form_death_intercept_reason = form_death_intercept.install() - end - local frame_ok, frame = pcall(GameGetFrameNum) - frame = frame_ok and (tonumber(frame) or 0) or 0 - if frame <= 1 or frame % 120 == 0 then protected_update("identity.update", publish_identity) end - protected_update("world_rules.update", world_rules.update) - protected_update("items.update", items.update) - protected_update("creative_perks.update", creative_perks.update) - protected_update("materials.update", materials.update) - protected_update("teleport.update", teleport.update) - protected_update("companion.update", companion.update) - -- Slot 6 remains registered by perks.register(); the legacy mailbox has no producer, - -- so polling it every EW frame would only read/write acknowledge Globals forever. - protected_update("weather.update", weather.update) - protected_update("possession.update", possession.update) - if dev_mode then protected_update("qa.update", qa.update, frame) end - protected_update("forms.update", forms.update, frame) + local frame = GameGetFrameNum() + if frame <= 1 or frame % 120 == 0 then publish_identity() end + world_rules.update() + items.update() + companion.update() + perks.update() + weather.update() + possession.update() + forms.update(frame) end return ew_bootstrap diff --git a/metamorph_creative_menu/files/integrations/ew/boss_death_notify.lua b/metamorph_creative_menu/files/integrations/ew/boss_death_notify.lua deleted file mode 100644 index f48ce76..0000000 --- a/metamorph_creative_menu/files/integrations/ew/boss_death_notify.lua +++ /dev/null @@ -1,26 +0,0 @@ --- Append to EW's stock death LuaComponent. This runs in the entity VM, possibly --- during a native DES call: only write a mailbox, never re-enter ewext from here. -local policy = dofile_once("mods/metamorph_creative_menu/files/integrations/ew/boss_lifecycle_policy.lua") -local original_death = death -local function remember_death(responsible) - local entity = GetUpdatedEntityID() - if not policy.is_boss(entity) then return end - local gid, owner = policy.identity(entity) - if not gid or not owner then return end - local damage = EntityGetFirstComponentIncludingDisabled(entity, "DamageModelComponent") - local wait = damage and ComponentGetValue2(damage, "wait_for_kill_flag_on_death") == true - local x, y = EntityGetTransform(entity) - local key = policy.EVENT_GID .. tostring(entity) - if GlobalsGetValue(key, "") == gid then return end - GlobalsSetValue(key, gid) - -- Printable ASCII only: this mailbox can be serialized into a Noita save. - local event = string.format("%d,%s,%d,%.17g,%.17g,%d;", - entity, gid, wait and 1 or 0, x, y, math.max(0, tonumber(responsible) or 0)) - GlobalsSetValue(policy.EVENTS, GlobalsGetValue(policy.EVENTS, "") .. event) -end - -function death(damage_type_bit_field, damage_message, entity_thats_responsible, drop_items) - -- An observer failure must not suppress the original callback or a boss mod's loot. - pcall(remember_death, entity_thats_responsible) - return original_death(damage_type_bit_field, damage_message, entity_thats_responsible, drop_items) -end diff --git a/metamorph_creative_menu/files/integrations/ew/boss_global_demote.lua b/metamorph_creative_menu/files/integrations/ew/boss_global_demote.lua deleted file mode 100644 index 417e51d..0000000 --- a/metamorph_creative_menu/files/integrations/ew/boss_global_demote.lua +++ /dev/null @@ -1,460 +0,0 @@ --- Prevent EW from retaining a dead global boss authority record after the entity dies. --- Delayed-kill bosses keep EW's stock lifecycle unless they are an MCM-created Kolmisilma. --- Component IDs are preserved while the entity is hidden from the initial classification, --- and a temporary CameraBoundComponent keeps the authored filename available for replicas. -local demote = {} - -local STATUS = "mcm21_boss_global_demote_v1" -local HIDDEN = "mcm21_boss_global_hidden_v1" -local RESTORED = "mcm21_boss_global_restored_v1" -local FAILED = "mcm21_boss_global_failed_v1" -local GAP_HIDDEN = "mcm21_boss_global_gap_hidden_v1" -local LOAD_HIDDEN = "mcm21_boss_global_load_hidden_v1" -local DESERIALIZE_HIDDEN = "mcm21_boss_global_deserialize_hidden_v1" -local WAIT_BYPASS = "mcm21_boss_wait_kill_bypass_v1" -local CREATIVE_KOLMI_DEMOTED = "mcm27_creative_kolmi_non_global_v1" -local CREATIVE_KOLMI_TAG = "mcm_creative_kolmi_v3" -local FILENAME_FORCED = "mcm21_boss_filename_spawninfo_v1" -local MAX_HIDDEN_FRAMES = 30 - -local pending = {} -local installed = false -local original_new_entity = nil -local original_world_update = nil -local original_entity_load = nil -local original_ewext_deserialize = nil -local last_seen_entity_id = 0 - -local function set_global(key, value) - if type(GlobalsSetValue) == "function" then - pcall(GlobalsSetValue, key, tostring(value or "")) - end -end - -local function bump(key) - if type(GlobalsGetValue) ~= "function" or type(GlobalsSetValue) ~= "function" then return end - local ok, value = pcall(GlobalsGetValue, key, "0") - value = ok and (tonumber(value) or 0) or 0 - pcall(GlobalsSetValue, key, tostring(value + 1)) -end - -local function frame_num() - if type(GameGetFrameNum) ~= "function" then return 0 end - local ok, value = pcall(GameGetFrameNum) - return ok and (tonumber(value) or 0) or 0 -end - -local function max_entity_id() - if type(EntitiesGetMaxID) ~= "function" then return last_seen_entity_id end - local ok, value = pcall(EntitiesGetMaxID) - return ok and math.max(0, tonumber(value) or 0) or last_seen_entity_id -end - -local function note_entity_id(entity) - entity = tonumber(entity) or 0 - if entity > last_seen_entity_id then last_seen_entity_id = entity end -end - -local function alive(entity) - entity = tonumber(entity) or 0 - if entity == 0 or type(EntityGetIsAlive) ~= "function" then return false end - local ok, value = pcall(EntityGetIsAlive, entity) - return ok and value == true -end - -local function has_tag(entity, tag) - if type(EntityHasTag) ~= "function" then return false end - local ok, value = pcall(EntityHasTag, entity, tag) - return ok and value == true -end - -local function component(entity, component_type) - if type(EntityGetFirstComponentIncludingDisabled) ~= "function" then return nil end - local ok, value = pcall(EntityGetFirstComponentIncludingDisabled, entity, component_type) - if not ok or value == nil or value == 0 then return nil end - return value -end - -local function active_component(entity, component_type) - if type(EntityGetFirstComponent) ~= "function" then - -- Conservative fallback: without enabled-only lookup we cannot safely prove that - -- BossDragon/StreamingKeepAlive are hidden from EW. - return component(entity, component_type) - end - local ok, value = pcall(EntityGetFirstComponent, entity, component_type) - if not ok or value == nil or value == 0 then return nil end - return value -end - -local function component_list(entity, component_type) - if type(EntityGetComponentIncludingDisabled) ~= "function" then return {} end - local ok, value = pcall(EntityGetComponentIncludingDisabled, entity, component_type) - return ok and type(value) == "table" and value or {} -end - -local function has_gid_storage(entity) - for _, storage in ipairs(component_list(entity, "VariableStorageComponent")) do - local ok_name, name = pcall(ComponentGetValue2, storage, "name") - if ok_name and tostring(name or "") == "ew_gid_lid" then return true end - end - return false -end - -local function wait_for_kill_handshake(entity) - local damage = component(entity, "DamageModelComponent") - if damage == nil or type(ComponentGetValue2) ~= "function" then return false end - local ok, value = pcall(ComponentGetValue2, damage, "wait_for_kill_flag_on_death") - return ok and value == true -end - -local function is_local_untracked_boss(entity) - if not alive(entity) then return false end - -- Player forms are handled by EW polymorph, not enemy DES. Remote replicas and - -- SpawnOnce death-replay entities must keep their stock lifecycle. - if has_tag(entity, "player_unit") or has_tag(entity, "polymorphed_player") - or has_tag(entity, "ew_des") or has_tag(entity, "ew_no_enemy_sync") - then - return false - end - if has_gid_storage(entity) then return false end - - local bossish = component(entity, "BossHealthBarComponent") ~= nil - or component(entity, "BossDragonComponent") ~= nil - if not bossish then return false end - - -- Delayed-kill bosses normally keep EW's stock global lifecycle. MCM-created - -- Kolmisilma is the exception because it must avoid the same retained-authority - -- path as other global bosses while preserving the authored Sampo encounter flow. - -- Natural Kolmisilma and boss limbs remain untouched. - if wait_for_kill_handshake(entity) then - if has_tag(entity, "boss_centipede") and has_tag(entity, CREATIVE_KOLMI_TAG) then - bump(CREATIVE_KOLMI_DEMOTED) - else - bump(WAIT_BYPASS) - return false - end - end - return true -end - -local function split_tags(value) - local out = {} - value = tostring(value or "") - for tag in string.gmatch(value, "[^,]+") do - tag = string.gsub(tag, "^%s+", "") - tag = string.gsub(tag, "%s+$", "") - if tag ~= "" then out[#out + 1] = tag end - end - return out -end - -local function snapshot_component(entity, component_id, component_type, mode) - local members = {} - if mode == "recreate" and type(ComponentGetMembers) == "function" and type(ComponentGetValue) == "function" then - local ok_members, names = pcall(ComponentGetMembers, component_id) - if ok_members and type(names) == "table" then - for name in pairs(names) do - local ok_value, value = pcall(ComponentGetValue, component_id, name) - if ok_value and value ~= nil then members[tostring(name)] = tostring(value) end - end - end - end - local enabled = true - if type(ComponentGetIsEnabled) == "function" then - local ok_enabled, value = pcall(ComponentGetIsEnabled, component_id) - if ok_enabled then enabled = value == true end - end - local tags = "" - if mode == "recreate" and type(ComponentGetTags) == "function" then - local ok_tags, value = pcall(ComponentGetTags, component_id) - if ok_tags then tags = tostring(value or "") end - end - return { - entity = entity, - id = component_id, - type = component_type, - mode = mode, - members = members, - enabled = enabled, - tags = tags, - } -end - -local function recreate_component(snapshot) - local entity = tonumber(snapshot.entity) or 0 - if not alive(entity) then return true end - local ok_add, new_component = false, nil - if type(EntityAddComponent2) == "function" then - ok_add, new_component = pcall(EntityAddComponent2, entity, snapshot.type, {}) - end - if (not ok_add or new_component == nil or new_component == 0) and type(EntityAddComponent) == "function" then - ok_add, new_component = pcall(EntityAddComponent, entity, snapshot.type, {}) - end - if not ok_add or new_component == nil or new_component == 0 then - return false, "add_failed:" .. tostring(snapshot.type) - end - if type(ComponentSetValue) == "function" then - for name, value in pairs(snapshot.members or {}) do pcall(ComponentSetValue, new_component, name, value) end - end - if type(ComponentAddTag) == "function" then - for _, tag in ipairs(split_tags(snapshot.tags)) do pcall(ComponentAddTag, new_component, tag) end - end - if type(EntitySetComponentIsEnabled) == "function" then - pcall(EntitySetComponentIsEnabled, entity, new_component, snapshot.enabled == true) - end - return true, new_component -end - -local function remove_component_if_present(entity, component_id) - if component_id == nil or type(EntityRemoveComponent) ~= "function" then return end - pcall(EntityRemoveComponent, entity, component_id) -end - -local function restore_record(record) - if record == nil or record.restored then return true end - record.restored = true - if not alive(record.entity) then return true end - - -- Native track_entity normally consumes every CameraBoundComponent itself. If a - -- timeout/error path restored before tracking, remove our temporary disabled marker. - if record.temp_camera_bound ~= nil then - remove_component_if_present(record.entity, record.temp_camera_bound) - end - - for _, snapshot in ipairs(record.components or {}) do - if snapshot.mode == "disable" then - if type(EntitySetComponentIsEnabled) == "function" then - local ok = pcall(EntitySetComponentIsEnabled, record.entity, snapshot.id, snapshot.enabled == true) - if not ok then - bump(FAILED) - set_global(STATUS, "restore_failed:enable:" .. tostring(snapshot.type)) - return false, "enable_failed:" .. tostring(snapshot.type) - end - end - else - local ok, reason = recreate_component(snapshot) - if not ok then - bump(FAILED) - set_global(STATUS, "restore_failed:" .. tostring(reason)) - return false, reason - end - end - end - bump(RESTORED) - return true -end - -local function force_filename_spawn_info(entity) - -- EW switches to EntitySpawnInfo::Filename whenever track_entity removes any - -- CameraBoundComponent. Use a disabled temporary marker so it has no gameplay - -- effect before native tracking consumes it. - if #component_list(entity, "CameraBoundComponent") > 0 then return nil, true end - if type(EntityGetFilename) == "function" then - local ok, filename = pcall(EntityGetFilename, entity) - if not ok or tostring(filename or "") == "" then return nil, false end - end - local ok_add, marker = false, nil - if type(EntityAddComponent2) == "function" then - ok_add, marker = pcall(EntityAddComponent2, entity, "CameraBoundComponent", {}) - end - if (not ok_add or marker == nil or marker == 0) and type(EntityAddComponent) == "function" then - ok_add, marker = pcall(EntityAddComponent, entity, "CameraBoundComponent", {}) - end - if not ok_add or marker == nil or marker == 0 then return nil, false end - if type(EntitySetComponentIsEnabled) == "function" then - pcall(EntitySetComponentIsEnabled, entity, marker, false) - end - bump(FILENAME_FORCED) - return marker, true -end - -local function hide_boss(entity) - entity = tonumber(entity) or 0 - if entity == 0 or pending[entity] ~= nil or not is_local_untracked_boss(entity) then return false end - - local snapshots = {} - -- Preserve component IDs for the two types EW only queries while enabled. - for _, component_type in ipairs({ "StreamingKeepAliveComponent", "BossDragonComponent" }) do - for _, component_id in ipairs(component_list(entity, component_type)) do - local snapshot = snapshot_component(entity, component_id, component_type, "disable") - snapshots[#snapshots + 1] = snapshot - if type(EntitySetComponentIsEnabled) == "function" then - pcall(EntitySetComponentIsEnabled, entity, component_id, false) - end - end - end - -- BossHealthBar is queried including disabled, so it must be absent during the - -- native classification. Recreate it only after ew_gid_lid has been attached. - for _, component_id in ipairs(component_list(entity, "BossHealthBarComponent")) do - local snapshot = snapshot_component(entity, component_id, "BossHealthBarComponent", "recreate") - snapshots[#snapshots + 1] = snapshot - remove_component_if_present(entity, component_id) - end - - local temp_camera_bound, filename_safe = force_filename_spawn_info(entity) - - if component(entity, "BossHealthBarComponent") ~= nil - or active_component(entity, "StreamingKeepAliveComponent") ~= nil - or active_component(entity, "BossDragonComponent") ~= nil - then - local temp = { - entity = entity, - components = snapshots, - temp_camera_bound = temp_camera_bound, - restored = false, - } - restore_record(temp) - bump(FAILED) - set_global(STATUS, "hide_failed") - return false - end - - pending[entity] = { - entity = entity, - components = snapshots, - temp_camera_bound = temp_camera_bound, - filename_safe = filename_safe, - frame = frame_num(), - restored = false, - } - bump(HIDDEN) - set_global(STATUS, filename_safe and "hidden_filename_tracking" or "hidden_for_native_tracking") - return true -end - -local function has_gid(entity) - if not alive(entity) then return false end - return has_gid_storage(entity) -end - -local function prepare_new_entities(arr) - if type(arr) ~= "table" then return 0 end - local count = 0 - for _, raw_entity in ipairs(arr) do - local entity = tonumber(raw_entity) or 0 - note_entity_id(entity) - if hide_boss(entity) then count = count + 1 end - end - return count -end - --- Catch only entities created after EW's top-level Lua discovery pass. This closes the --- one-frame native fallback gap without EntityGetWithTag world scans. -local function prepare_native_gap_entities_before_update() - local newest = max_entity_id() - if newest <= last_seen_entity_id then return 0 end - local first = last_seen_entity_id + 1 - local count = 0 - for entity = first, newest do - if alive(entity) and hide_boss(entity) then count = count + 1 end - end - last_seen_entity_id = newest - if count > 0 then - bump(GAP_HIDDEN) - set_global(STATUS, "native_gap_boss_hidden") - end - return count -end - -local function observe_loaded_entity(entity, counter_key, status) - entity = tonumber(entity) or 0 - if entity == 0 then return false end - note_entity_id(entity) - if hide_boss(entity) then - bump(counter_key) - set_global(STATUS, status) - return true - end - return false -end - -local function restore_after_native_update(force_all) - local now = frame_num() - for entity, record in pairs(pending) do - local tracked = has_gid(entity) or has_tag(entity, "ew_des") - local expired = now - (tonumber(record.frame) or now) >= MAX_HIDDEN_FRAMES - if force_all or tracked or expired or not alive(entity) then - if alive(entity) then restore_record(record) end - pending[entity] = nil - if tracked then set_global(STATUS, "tracked_non_global_restored") - elseif expired then set_global(STATUS, "timeout_restored") end - end - end -end - -local function wrap_native_spawn_entrypoints() - if type(EntityLoad) == "function" then - original_entity_load = EntityLoad - EntityLoad = function(...) - local entity = original_entity_load(...) - observe_loaded_entity(entity, LOAD_HIDDEN, "entityload_boss_hidden") - return entity - end - end - if type(EwextDeserialize) == "function" then - original_ewext_deserialize = EwextDeserialize - EwextDeserialize = function(...) - local values = { original_ewext_deserialize(...) } - observe_loaded_entity(values[1], DESERIALIZE_HIDDEN, "deserialize_boss_hidden") - local unpack_fn = unpack or table.unpack - return unpack_fn(values) - end - end -end - -function demote.install() - if installed then return true, "already_installed" end - if type(ewext) ~= "table" - or type(ewext.module_on_new_entity) ~= "function" - or type(ewext.module_on_world_update) ~= "function" - then - installed = true - set_global(STATUS, "install_failed:ewext_api_unavailable") - return false, "ewext_api_unavailable" - end - - original_new_entity = ewext.module_on_new_entity - original_world_update = ewext.module_on_world_update - last_seen_entity_id = max_entity_id() - wrap_native_spawn_entrypoints() - - ewext.module_on_new_entity = function(arr, len) - prepare_new_entities(arr) - local ok, result = pcall(original_new_entity, arr, len) - if not ok then - restore_after_native_update(true) - error(result) - end - return result - end - - ewext.module_on_world_update = function(...) - prepare_native_gap_entities_before_update() - local ok, result = pcall(original_world_update, ...) - if not ok then - restore_after_native_update(true) - error(result) - end - restore_after_native_update(false) - return result - end - - installed = true - set_global(STATUS, "installed") - return true, "installed" -end - -if rawget(_G, "METAMORPH_CREATIVE_MENU_TESTING") == true then - demote._test = { - prepare_new_entities = prepare_new_entities, - prepare_native_gap_entities_before_update = prepare_native_gap_entities_before_update, - restore_after_native_update = restore_after_native_update, - pending_count = function() - local n = 0 - for _ in pairs(pending) do n = n + 1 end - return n - end, - last_seen_entity_id = function() return last_seen_entity_id end, - } -end -return demote diff --git a/metamorph_creative_menu/files/integrations/ew/boss_lifecycle.lua b/metamorph_creative_menu/files/integrations/ew/boss_lifecycle.lua deleted file mode 100644 index e9b1c01..0000000 --- a/metamorph_creative_menu/files/integrations/ew/boss_lifecycle.lua +++ /dev/null @@ -1,239 +0,0 @@ --- Final death must reach EW 1.6.3's pending_death_notify BEFORE the native update can --- release a dead global root. Never infer a custom boss's final death from HP alone. -local lifecycle = {} -local policy = dofile("mods/metamorph_creative_menu/files/integrations/ew/boss_lifecycle_policy.lua") -local candidates, observed, pending, retired = {}, {}, {}, {} -local installed, native_notify = false, nil -local last_id, next_sweep = 0, 0 - -local function alive(entity) return EntityGetIsAlive(entity) == true end -local function resolve(gid) - local ok, entity = pcall(ewext.find_by_gid, gid) - return ok, ok and (tonumber(entity) or 0) or 0 -end -local function discover(entity) - if policy.is_boss(entity) then candidates[entity] = true end -end -local function discover_gap() - if type(EntitiesGetMaxID) ~= "function" then return end - local newest = EntitiesGetMaxID() - for entity = last_id + 1, newest do discover(entity) end - last_id = newest -end -local function discover_existing() - local frame = GameGetFrameNum() - if frame < next_sweep then return end - next_sweep = frame + 60 - local seen = {} - -- Recovery for saves, late component additions, and installations into a running - -- world. The per-frame path handles only new IDs and known boss candidates. - for _, tag in ipairs({"ew_des", "boss", "miniboss", "mcm_boss"}) do - for _, entity in ipairs(EntityGetWithTag(tag) or {}) do - if not seen[entity] then discover(entity); seen[entity] = true end - end - end -end - -local function observe() - for entity in pairs(candidates) do - if not policy.eligible(entity) then - candidates[entity] = nil - else - local gid, owner = policy.identity(entity) - if gid and not owner then - candidates[entity] = nil -- the new owner creates a different local root - elseif gid then - local ok, tracked = resolve(gid) - if ok and tracked == entity then - local record = observed[entity] - if not record or record.gid ~= gid then - record = {entity = entity, gid = gid} - observed[entity] = record - end - record.x, record.y = EntityGetTransform(entity) - local damage = EntityGetFirstComponentIncludingDisabled(entity, "DamageModelComponent") - record.wait = damage ~= nil and ComponentGetValue2(damage, "wait_for_kill_flag_on_death") == true - record.kill = damage ~= nil and ComponentGetValue2(damage, "kill_now") == true - record.hp = damage and tonumber(ComponentGetValue2(damage, "hp")) or nil - elseif ok then - candidates[entity] = nil - end - end - end - end -end - -local function drain_events() - local text = GlobalsGetValue(policy.EVENTS, "") - if text == "" then return end - GlobalsSetValue(policy.EVENTS, "") - for line in text:gmatch("[^;]+") do - local entity, gid, wait, x, y, responsible = line:match("^(%d+),(%d+),([01]),([^,]+),([^,]+),(%d+)$") - entity, x, y = tonumber(entity), tonumber(x), tonumber(y) - if entity and entity > 0 and gid and x and y then - local record = observed[entity] - if not record or record.gid ~= gid then - record = {entity = entity, gid = gid, x = x, y = y, wait = wait == "1"} - observed[entity] = record - end - record.notified = true - record.responsible = tonumber(responsible) or 0 - end - end -end - -local function quiet_kill(entity, expected_gid) - if not policy.eligible(entity) then return end - local gid, owner = policy.identity(entity) - if gid ~= expected_gid or not owner then return end - -- Only an acknowledged reconstruction of an already dead GID, never an original - -- boss or a new phase/new summon. Suppress callbacks throughout its child tree too. - local function silence(current) - for _, child in ipairs(EntityGetAllChildren(current) or {}) do silence(child) end - for _, lua in ipairs(EntityGetComponentIncludingDisabled(current, "LuaComponent") or {}) do - ComponentSetValue2(lua, "script_death", "") - ComponentSetValue2(lua, "script_damage_received", "") - ComponentSetValue2(lua, "script_damage_about_to_be_received", "") - EntitySetComponentIsEnabled(current, lua, false) - end - for _, chest in ipairs(EntityGetComponentIncludingDisabled(current, "ItemChestComponent") or {}) do - EntityRemoveComponent(current, chest) - end - end - silence(entity) - local damage = EntityGetFirstComponentIncludingDisabled(entity, "DamageModelComponent") - if damage then - ComponentSetValue2(damage, "wait_for_kill_flag_on_death", false) - ComponentSetValue2(damage, "kill_now", true) - end - EntityKill(entity) -end - -local function submit(record, reason) - if pending[record.gid] then return end - local ok, tracked = resolve(record.gid) - if not ok or tracked ~= record.entity then return end - local sent, failure = pcall(native_notify, record.entity, record.wait, - record.x, record.y, "", record.responsible or 0) - if not sent then - if record.error ~= tostring(failure) then - record.error = tostring(failure) - end - return - end - record.reason = reason - pending[record.gid] = record -end - -local function before_update() - discover_existing() - discover_gap() - drain_events() - observe() - for entity, record in pairs(observed) do - local is_alive = alive(entity) - local valid = not is_alive or policy.eligible(entity) - if is_alive then - local gid, owner = policy.identity(entity) - valid = valid and owner and gid == record.gid - end - if not valid then - observed[entity] = nil - else - -- Delayed-kill mods may signal a phase defeat and then restore HP. Do not - -- carry that notification into a later healthy transfer/despawn. - if is_alive and record.wait and not record.kill and record.hp and record.hp > 0 then - record.notified = nil - GlobalsSetValue(policy.EVENT_GID .. tostring(entity), "") - end - local terminal = not is_alive or record.kill or (record.notified and not record.wait) - -- A real death event remains evidence if native DES already released the - -- old body. It can then reject a delayed GotAuthority for this exact GID. - if terminal and record.notified then retired[record.gid] = retired[record.gid] or entity end - local ok, tracked = resolve(record.gid) - if ok and tracked ~= entity then - observed[entity] = nil - candidates[entity] = nil - elseif ok then - if is_alive and retired[record.gid] and retired[record.gid] ~= entity then - submit(record, "reconstruction") - elseif terminal then - submit(record, "death") - end - end - end - end -end - -local function after_update() - for gid, record in pairs(pending) do - local ok, entity = resolve(gid) - if ok and entity == 0 then - retired[gid] = retired[gid] or record.entity - pending[gid] = nil - observed[record.entity], candidates[record.entity] = nil, nil - GlobalsSetValue(policy.EVENT_GID .. tostring(record.entity), "") - if record.reason == "reconstruction" then quiet_kill(record.entity, gid) end - elseif ok and entity ~= record.entity then - retired[gid] = retired[gid] or record.entity - pending[gid] = nil - end - end - discover_gap() - observe() -end - -function lifecycle.install() - if installed then return true end - if type(ewext) ~= "table" or type(ewext.des_death_notify) ~= "function" - or type(ewext.find_by_gid) ~= "function" or type(ewext.module_on_world_update) ~= "function" - then return false, "ewext_lifecycle_api_unavailable" end - native_notify = ewext.des_death_notify - local native_update = ewext.module_on_world_update - local native_new = ewext.module_on_new_entity - last_id = type(EntitiesGetMaxID) == "function" and EntitiesGetMaxID() or 0 - if type(native_new) == "function" then - ewext.module_on_new_entity = function(arr, len) - for _, entity in ipairs(arr or {}) do - discover(entity) -- discover before the demotion layer hides the health bar - last_id = math.max(last_id, entity) - end - return native_new(arr, len) - end - end - ewext.des_death_notify = function(entity, wait_on_kill, x, y, file, responsible) - if alive(entity) then - local _, owner = policy.identity(entity) - if not policy.eligible(entity) or owner == false then - return native_notify(entity, wait_on_kill, x, y, file, responsible) - end - end - local record = observed[entity] - if not record then - -- The append preserves identity even if the body died before our first - -- observation. Only that validated, owner-only mailbox may opt it in. - local gid = GlobalsGetValue(policy.EVENT_GID .. tostring(entity), "") - if gid:match("^%d+$") then - record = {entity = entity, gid = gid, wait = wait_on_kill, x = x, y = y} - observed[entity] = record - end - end - if record then - record.notified, record.responsible = true, responsible - -- Native DES accepts no reentrant calls. Save the stock callback's killer - -- and process it before the next native update, not from this call stack. - return - end - return native_notify(entity, wait_on_kill, x, y, file, responsible) - end - ewext.module_on_world_update = function(...) - before_update() - local result = native_update(...) - after_update() - return result - end - installed = true - return true -end - -return lifecycle diff --git a/metamorph_creative_menu/files/integrations/ew/boss_lifecycle_policy.lua b/metamorph_creative_menu/files/integrations/ew/boss_lifecycle_policy.lua deleted file mode 100644 index 28994b4..0000000 --- a/metamorph_creative_menu/files/integrations/ew/boss_lifecycle_policy.lua +++ /dev/null @@ -1,64 +0,0 @@ --- Runtime traits, not a list of vanilla filenames: inherited/custom mod XML works too. --- Kolmisilma keeps its dedicated encounter lifecycle. -local policy = {} -local KOLMI = "data/entities/animals/boss_centipede/boss_centipede.xml" -policy.EVENTS = "mcm32_boss_death_events_v1" -policy.EVENT_GID = "mcm32_boss_death_gid_v1_" - -function policy.eligible(entity) - if not entity or entity == 0 or EntityGetIsAlive(entity) ~= true then return false end - for _, tag in ipairs({"player_unit", "polymorphed_player", "ew_peer", "ew_notplayer", - "ew_no_enemy_sync"}) do - if EntityHasTag(entity, tag) then return false end - end - if EntityHasTag(entity, "boss_centipede") and EntityGetFilename(entity) == KOLMI then return false end - return EntityGetRootEntity(entity) == entity -end - -local function boss_component(entity) - -- LimbBossComponent alone also occurs on ordinary lukki and leggy chests. - for _, kind in ipairs({"BossHealthBarComponent", "BossDragonComponent"}) do - if EntityGetFirstComponentIncludingDisabled(entity, kind) then return true end - end - return false -end - -local function child_boss_component(entity) - for _, child in ipairs(EntityGetAllChildren(entity) or {}) do - if not EntityHasTag(child, "player_unit") and not EntityHasTag(child, "polymorphed_player") then - if boss_component(child) or child_boss_component(child) then return true end - end - end - return false -end - -function policy.is_boss(entity) - if not policy.eligible(entity) then return false end - for _, tag in ipairs({"boss", "miniboss", "mcm_boss"}) do - if EntityHasTag(entity, tag) then return true end - end - if boss_component(entity) then return true end - local damage = EntityGetFirstComponentIncludingDisabled(entity, "DamageModelComponent") - -- These also cover custom bosses with their own HUD. Keepalive+damage roots use - -- the same global DES retention path even when no standard boss tag was provided. - if damage ~= nil and (ComponentGetValue2(damage, "wait_for_kill_flag_on_death") == true - or EntityGetFirstComponentIncludingDisabled(entity, "StreamingKeepAliveComponent") ~= nil) - then return true end - -- Some mods put the HUD/controller on a child of the actual DES root. Do not - -- inspect every projectile tree: only actors or explicit EW synchronization roots. - return (damage ~= nil or EntityHasTag(entity, "enemy") or EntityHasTag(entity, "ew_synced")) - and child_boss_component(entity) -end - -function policy.identity(entity) - for _, var in ipairs(EntityGetComponentIncludingDisabled(entity, "VariableStorageComponent") or {}) do - if ComponentGetValue2(var, "name") == "ew_gid_lid" then - local gid = ComponentGetValue2(var, "value_string") - if type(gid) == "string" and gid:match("^%d+$") then - return gid, ComponentGetValue2(var, "value_bool") == true - end - end - end -end - -return policy diff --git a/metamorph_creative_menu/files/integrations/ew/bridge/common.lua b/metamorph_creative_menu/files/integrations/ew/bridge/common.lua index 3af255f..55d86ea 100644 --- a/metamorph_creative_menu/files/integrations/ew/bridge/common.lua +++ b/metamorph_creative_menu/files/integrations/ew/bridge/common.lua @@ -1,4 +1,3 @@ -local global_text = dofile("mods/metamorph_creative_menu/files/core/global_text.lua") local bridge_common = {} local ERROR_SEQ = "mcm_world_rules_rpc_error_seq_v1" @@ -13,11 +12,9 @@ end function bridge_common.report_error(code, detail) error_sequence = error_sequence + 1 GlobalsSetValue(ERROR_SEQ, tostring(error_sequence)) - GlobalsSetValue(ERROR_VALUE, global_text.encode_diagnostic(tostring(code or "") .. ":" .. tostring(detail or ""))) + GlobalsSetValue(ERROR_VALUE, bridge_common.clean(code) .. ":" .. bridge_common.clean(detail)) end -bridge_common.encode_diagnostic = global_text.encode_diagnostic - function bridge_common.finite_number(value) return type(value) == "number" and value == value and math.abs(value) < 100000000 end diff --git a/metamorph_creative_menu/files/integrations/ew/bridge/creative_perks.lua b/metamorph_creative_menu/files/integrations/ew/bridge/creative_perks.lua deleted file mode 100644 index 9ee7df0..0000000 --- a/metamorph_creative_menu/files/integrations/ew/bridge/creative_perks.lua +++ /dev/null @@ -1,181 +0,0 @@ -local bridge = {} - -local SEQ_KEY = "mcm_creative_perk_spawn_seq_v1" -local ENTITY_KEY = "mcm_creative_perk_spawn_entity_v1:" -local ID_KEY = "mcm_creative_perk_spawn_id_v1:" -local X_KEY = "mcm_creative_perk_spawn_x_v1:" -local Y_KEY = "mcm_creative_perk_spawn_y_v1:" -local FRAME_KEY = "mcm_creative_perk_spawn_frame_v1:" -local ACK_KEY = "mcm_creative_perk_spawn_ack_v1" -local STATUS_KEY = "mcm_creative_perk_consume_status_v1" -local HISTORY_FRAMES = 180 -local MAX_PICKUP_DISTANCE = 128 - -local last_sequence = tonumber(GlobalsGetValue(ACK_KEY, "0")) or 0 -local entries = {} -local peer_counts = {} -local position_history = {} -local common - -local function valid(entity) - return entity ~= nil and tonumber(entity) ~= 0 - and type(EntityGetIsAlive) == "function" and EntityGetIsAlive(tonumber(entity)) == true -end - -local function copy_counts(value) - local result = {} - if type(value) == "table" then - for id, count in pairs(value) do result[tostring(id)] = math.max(0, tonumber(count) or 0) end - end - return result -end - -local function remote_counts(data) - if data == nil or not valid(data.entity) or type(util) ~= "table" or type(util.get_ent_variable) ~= "function" then - return nil - end - local ok, value = pcall(util.get_ent_variable, data.entity, "ew_current_perks") - return ok and type(value) == "table" and value or nil -end - -local function consume_registrations() - local sequence = tonumber(GlobalsGetValue(SEQ_KEY, "0")) or 0 - if sequence <= last_sequence then return end - local processed, budget = last_sequence, 32 - while processed < sequence and budget > 0 do - local n = processed + 1 - local suffix = tostring(n) - local entity = tonumber(GlobalsGetValue(ENTITY_KEY .. suffix, "0")) or 0 - local perk_id = GlobalsGetValue(ID_KEY .. suffix, "") - local x = tonumber(GlobalsGetValue(X_KEY .. suffix, "0")) or 0 - local y = tonumber(GlobalsGetValue(Y_KEY .. suffix, "0")) or 0 - local frame = tonumber(GlobalsGetValue(FRAME_KEY .. suffix, "0")) or 0 - if entity ~= 0 and perk_id ~= "" then - entries[#entries + 1] = {entity=entity, perk_id=perk_id, x=x, y=y, frame=frame, sequence=n} - end - GlobalsSetValue(ENTITY_KEY .. suffix, "") - GlobalsSetValue(ID_KEY .. suffix, "") - GlobalsSetValue(X_KEY .. suffix, "") - GlobalsSetValue(Y_KEY .. suffix, "") - GlobalsSetValue(FRAME_KEY .. suffix, "") - processed, budget = n, budget - 1 - end - last_sequence = processed - GlobalsSetValue(ACK_KEY, tostring(processed)) -end - -local function update_position_history(frame) - for peer_id, data in pairs(ctx.players or {}) do - if peer_id ~= ctx.my_id and data ~= nil and valid(data.entity) then - local x, y = EntityGetTransform(data.entity) - if tonumber(x) ~= nil and tonumber(y) ~= nil then - local history = position_history[peer_id] or {} - history[#history + 1] = {frame=frame, x=x, y=y} - local cutoff = frame - HISTORY_FRAMES - local first = 1 - while first <= #history and history[first].frame < cutoff do first = first + 1 end - if first > 1 then - local compact = {} - for i=first,#history do compact[#compact+1]=history[i] end - history = compact - end - position_history[peer_id] = history - end - end - end -end - -local function entry_distance_sq(entry, peer_id, frame) - if valid(entry.entity) then - local x, y = EntityGetTransform(entry.entity) - if tonumber(x) ~= nil and tonumber(y) ~= nil then entry.x, entry.y = x, y end - end - local best = math.huge - for _, point in ipairs(position_history[peer_id] or {}) do - if point.frame >= math.max(entry.frame - 4, frame - HISTORY_FRAMES) then - local dx, dy = point.x - entry.x, point.y - entry.y - local d = dx*dx + dy*dy - if d < best then best = d end - end - end - return best -end - -local function consume_nearby(peer_id, perk_id, amount, frame) - local consumed = 0 - for _=1,amount do - local best_index, best_distance = nil, MAX_PICKUP_DISTANCE * MAX_PICKUP_DISTANCE - for index, entry in ipairs(entries) do - if entry.perk_id == perk_id and valid(entry.entity) then - local distance = entry_distance_sq(entry, peer_id, frame) - if distance <= best_distance then best_index, best_distance = index, distance end - end - end - if best_index == nil then break end - local entry = entries[best_index] - -- This runs in the MCM owner's EW context, where this pickup is authoritative. - -- Submit the DES death directly before EntityKill so deletion is network-authoritative - -- even though vanilla perk pickup on the stock peer only destroyed its local replica. - local x, y = EntityGetTransform(entry.entity) - local path = EntityGetFilename(entry.entity) or "data/entities/items/pickup/perk.xml" - local notify_ok, notify_failure = false, "des_death_notify_unavailable" - if type(ewext) == "table" and type(ewext.des_death_notify) == "function" then - notify_ok, notify_failure = pcall(ewext.des_death_notify, entry.entity, false, - tonumber(x) or entry.x, tonumber(y) or entry.y, tostring(path), 0) - end - local ok, failure = pcall(EntityKill, entry.entity) - if notify_ok and ok then - consumed = consumed + 1 - GlobalsSetValue(STATUS_KEY, - "consumed:" .. tostring(perk_id) .. ":peer=" .. tostring(peer_id) .. ":seq=" .. tostring(entry.sequence)) - table.remove(entries, best_index) - else - if common ~= nil and type(common.report_error) == "function" then - common.report_error("creative_perk_consume", - "notify=" .. tostring(notify_failure) .. ";kill=" .. tostring(failure)) - end - break - end - end - return consumed -end - -local function cleanup_entries() - local alive_entries = {} - for _, entry in ipairs(entries) do - if valid(entry.entity) then alive_entries[#alive_entries + 1] = entry end - end - entries = alive_entries -end - -function bridge.init(shared_common) - common = shared_common -end - -function bridge.update() - consume_registrations() - local frame = type(GameGetFrameNum)=="function" and (tonumber(GameGetFrameNum()) or 0) or 0 - update_position_history(frame) - - for peer_id, data in pairs(ctx.players or {}) do - if peer_id ~= ctx.my_id then - local current = remote_counts(data) - if current ~= nil then - local previous = peer_counts[peer_id] or {} - for perk_id, count in pairs(current) do - local old = tonumber(previous[perk_id]) or 0 - local new = tonumber(count) or 0 - if new > old then consume_nearby(peer_id, tostring(perk_id), math.floor(new-old), frame) end - end - peer_counts[peer_id] = copy_counts(current) - end - end - end - cleanup_entries() -end - -function bridge.debug_state() - return #entries, last_sequence -end - -return bridge diff --git a/metamorph_creative_menu/files/integrations/ew/bridge/forms.lua b/metamorph_creative_menu/files/integrations/ew/bridge/forms.lua index 2e251eb..411eb7f 100644 --- a/metamorph_creative_menu/files/integrations/ew/bridge/forms.lua +++ b/metamorph_creative_menu/files/integrations/ew/bridge/forms.lua @@ -3,94 +3,8 @@ local rpc, common local last_remote_pose_frame = {} local last_pose_send_frame = -100000 local last_remote_root_frame = {} -local last_remote_entity = {} local remote_articulated_prepared = {} -local form_pose_sent, form_pose_received = 0, 0 -local profiling_enabled = false -local MCM_BOSS_DEATH_PRESSURE_KEY = "mcm_ew_boss_death_pressure_until_v1" -local NETWORK_SOURCE_NAME = "metamorph_creative_menu_network_source" - -local function profile_now_ms() - if not profiling_enabled then return nil end - if type(GameGetRealWorldTimeSinceStarted) ~= "function" then return nil end - local ok, value = pcall(GameGetRealWorldTimeSinceStarted) - value = ok and tonumber(value) or nil - return value ~= nil and value * 1000 or nil -end - -local function form_source(entity) - if entity == nil or entity == 0 then return "" end - for _, component in ipairs(EntityGetComponentIncludingDisabled(entity, "VariableStorageComponent") or {}) do - local ok_name, name = pcall(ComponentGetValue2, component, "name") - if ok_name and name == NETWORK_SOURCE_NAME then - local ok_value, value = pcall(ComponentGetValue2, component, "value_string") - if ok_value and type(value) == "string" then return value end - end - end - return "" -end - -local function publish_remote_prepare_metrics(entity, kind, started_ms, visited, disabled) - if not profiling_enabled or type(GlobalsSetValue) ~= "function" then return end - local finished_ms = profile_now_ms() - local elapsed = started_ms ~= nil and finished_ms ~= nil and math.max(0, finished_ms - started_ms) or -1 - pcall(GlobalsSetValue, "mcm_form_remote_prepare_ms_v1", string.format("%.3f", elapsed)) - pcall(GlobalsSetValue, "mcm_form_remote_prepare_entities_v1", tostring(math.max(0, tonumber(visited) or 0))) - pcall(GlobalsSetValue, "mcm_form_remote_prepare_components_v1", tostring(math.max(0, tonumber(disabled) or 0))) - pcall(GlobalsSetValue, "mcm_form_remote_prepare_kind_v1", tostring(tonumber(kind) or 0)) - pcall(GlobalsSetValue, "mcm_form_remote_prepare_source_v1", form_source(entity)) - if type(GameGetFrameNum) == "function" then - local ok, frame = pcall(GameGetFrameNum) - if ok then pcall(GlobalsSetValue, "mcm_form_remote_prepare_frame_v1", tostring(tonumber(frame) or -1)) end - end - if type(print) == "function" then - print("[MCM EW profile] remote_prepare ms=" .. string.format("%.3f", elapsed) - .. " entities=" .. tostring(math.max(0, tonumber(visited) or 0)) - .. " components=" .. tostring(math.max(0, tonumber(disabled) or 0)) - .. " kind=" .. tostring(tonumber(kind) or 0) .. " source=" .. tostring(form_source(entity))) - end -end - - - -local function boss_death_pressure_active() - if type(GlobalsGetValue) ~= "function" or type(GameGetFrameNum) ~= "function" then return false end - local until_frame = tonumber(GlobalsGetValue(MCM_BOSS_DEATH_PRESSURE_KEY, "-1")) or -1 - return until_frame >= (tonumber(GameGetFrameNum()) or 0) -end - -local function des_gid(entity) - for _, component in ipairs(EntityGetComponentIncludingDisabled(entity, "VariableStorageComponent") or {}) do - local ok_name, name = pcall(ComponentGetValue2, component, "name") - if ok_name and tostring(name or "") == "ew_gid_lid" then - local ok_gid, gid = pcall(ComponentGetValue2, component, "value_string") - if ok_gid and tostring(gid or "") ~= "" then return tostring(gid) end - end - end - return nil -end - -local function enforce_local_form_des_exclusion(entity) - if entity == nil or entity == 0 or not EntityGetIsAlive(entity) then return end - if not EntityHasTag(entity, "polymorphed_player") - or not EntityHasTag(entity, "metamorph_creative_menu_network_form") - then return end - -- EW 1.6.3 already excludes polymorphed_player in native DES. Keep the explicit tag - -- as a belt-and-suspenders guard, but do not call ewext.notrack(): notrack only affects - -- first discovery and is not an authority-retirement primitive. - pcall(EntityAddTag, entity, "ew_no_enemy_sync") - local gid = des_gid(entity) - if gid ~= nil and type(GlobalsSetValue) == "function" then - -- This should never happen on a healthy player-form. Keep it observable so a - -- live test can prove/disprove the dual-identity hypothesis without log spam. - pcall(GlobalsSetValue, "mcm_form_dual_des_gid_v1", gid) - pcall(GlobalsSetValue, "mcm_form_dual_des_entity_v1", tostring(entity)) - if type(GameGetFrameNum) == "function" then - pcall(GlobalsSetValue, "mcm_form_dual_des_frame_v1", tostring(GameGetFrameNum())) - end - end -end local function finite_number(value) return type(value) == "number" and value == value and math.abs(value) < 100000000 @@ -162,17 +76,13 @@ local function remote_tree(root, fn) end local function disable_remote_ai(entity) - local visited, disabled = 0, 0 remote_tree(entity, function(current) - visited = visited + 1 for _, component_type in ipairs(REMOTE_GAMEPLAY_COMPONENTS) do for _, comp in ipairs(EntityGetComponentIncludingDisabled(current, component_type) or {}) do - local ok = pcall(EntitySetComponentIsEnabled, current, comp, false) - if ok then disabled = disabled + 1 end + pcall(EntitySetComponentIsEnabled, current, comp, false) end end end) - return visited, disabled end local function prepare_remote_articulated(entity, kind) @@ -182,8 +92,7 @@ local function prepare_remote_articulated(entity, kind) if driver ~= nil and driver ~= 0 then return end remote_articulated_prepared[entity] = nil -- entity id may have been reused end - local started_ms = profile_now_ms() - local visited, disabled = disable_remote_ai(entity) + disable_remote_ai(entity) local worm = EntityGetFirstComponentIncludingDisabled(entity, "WormComponent") local driver = EntityGetFirstComponentIncludingDisabled(entity, "WormPlayerComponent") if kind == 5 then @@ -215,7 +124,6 @@ local function prepare_remote_articulated(entity, kind) if worm ~= nil and worm ~= 0 then pcall(EntitySetComponentIsEnabled, entity, worm, true) end if driver ~= nil and driver ~= 0 then pcall(EntitySetComponentIsEnabled, entity, driver, true) end remote_articulated_prepared[entity] = kind - publish_remote_prepare_metrics(entity, kind, started_ms, visited, disabled) end local function apply_motion_state(entity, kind, x, y, speed) @@ -251,28 +159,14 @@ function forms_bridge.register_pose(shared_rpc, shared_common) local data = ctx.rpc_player_data local frame = tonumber(source_frame) or -1 if sender == nil or data == nil or sender == ctx.my_id or frame < 0 then return end + if frame <= (last_remote_pose_frame[sender] or -1) then return end if data.currently_polymorphed ~= true then return end local entity = tonumber(data.entity) or 0 if entity == 0 or not EntityGetIsAlive(entity) or not EntityHasTag(entity, "ew_client") then return end - - -- Source frame numbers are monotonic only for one concrete remote player entity. - -- A peer that reconnects/restarts (or whose EW player entity is replaced) may begin - -- again at a lower GameGetFrameNum(). Do not let the previous generation's anti- - -- reorder watermark suppress the new form for minutes. Clearing the old prepared - -- entry also bounds articulated lifecycle state across repeated transformations. - local previous_entity = last_remote_entity[sender] - if previous_entity ~= entity then - if previous_entity ~= nil then remote_articulated_prepared[previous_entity] = nil end - last_remote_entity[sender] = entity - last_remote_pose_frame[sender] = nil - last_remote_root_frame[sender] = nil - end - if frame <= (last_remote_pose_frame[sender] or -1) then return end x, y = tonumber(x), tonumber(y) rotation, scale_x, scale_y = tonumber(rotation) or 0, tonumber(scale_x) or 1, tonumber(scale_y) or 1 if not finite_number(x) or not finite_number(y) or not finite_number(rotation) then return end last_remote_pose_frame[sender] = frame - form_pose_received = form_pose_received + 1 local kind = tonumber(motion_kind) or 0 local articulated = kind == 3 or kind == 5 @@ -342,11 +236,7 @@ local function send_form_pose(frame) if character_data ~= nil and character_data ~= 0 and platforming ~= nil and platforming ~= 0 then return end local motion_kind, motion_x, motion_y, motion_speed = motion_state(entity) - -- Articulated boss/worm forms simulate motion locally on the receiving peer. 10 Hz - -- direction snapshots are enough for correction and halve MCM RPC pressure compared - -- with the old 20 Hz stream. Non-articulated fallback forms keep 20 Hz. - local interval = (motion_kind == 3 or motion_kind == 5) and 6 or 3 - if boss_death_pressure_active() then return end + local interval = 3 -- latest-pose stream ~20 Hz; articulated root correction is budgeted below if frame - last_pose_send_frame < interval then return end local x, y, rotation, scale_x, scale_y = EntityGetTransform(entity) if not finite_number(x) or not finite_number(y) then return end @@ -361,17 +251,9 @@ local function send_form_pose(frame) if ok and type(value) == "table" then phys_info = value end end end - form_pose_sent = form_pose_sent + 1 rpc.sync_form_pose(frame, x, y, rotation or 0, scale_x or 1, scale_y or 1, phys_info, motion_kind, motion_x, motion_y, motion_speed) end -function forms_bridge.update(frame) - local data = ctx.my_player - local entity = data ~= nil and tonumber(data.entity) or 0 - if entity ~= 0 then enforce_local_form_des_exclusion(entity) end - send_form_pose(frame) -end -function forms_bridge.metrics() return form_pose_sent, form_pose_received end -function forms_bridge.set_profiling_enabled(enabled) profiling_enabled = enabled == true end +function forms_bridge.update(frame) send_form_pose(frame) end return forms_bridge diff --git a/metamorph_creative_menu/files/integrations/ew/bridge/items.lua b/metamorph_creative_menu/files/integrations/ew/bridge/items.lua index 04be33a..e84c73b 100644 --- a/metamorph_creative_menu/files/integrations/ew/bridge/items.lua +++ b/metamorph_creative_menu/files/integrations/ew/bridge/items.lua @@ -17,23 +17,17 @@ function items_bridge.update() local suffix = ":" .. tostring(next_seq) local entity = tonumber(GlobalsGetValue(OUTBOX_ENTITY .. suffix, "0")) or 0 local result = "invalid" - local submitted = entity == 0 or not EntityGetIsAlive(entity) if entity ~= 0 and EntityGetIsAlive(entity) then if type(CrossCall) == "function" then local ok, err = pcall(CrossCall, "ew_thrown", entity) - if ok then - result, submitted = "submitted", true - else - result = "error:" .. common.clean(err) - common.report_error("world_item", "seq=" .. tostring(next_seq) .. ";" .. common.clean(err)) - end + result = ok and "submitted" or ("error:" .. common.clean(err)) + if not ok then common.report_error("world_item", "seq=" .. tostring(next_seq) .. ";" .. common.clean(err)) end else result = "crosscall_unavailable" common.report_error("world_item", "seq=" .. tostring(next_seq) .. ";CrossCall unavailable") end end GlobalsSetValue(OUTBOX_RESULT, tostring(next_seq) .. ":" .. result) - if not submitted then break end GlobalsSetValue(OUTBOX_ENTITY .. suffix, "") processed, budget = next_seq, budget - 1 end diff --git a/metamorph_creative_menu/files/integrations/ew/bridge/materials.lua b/metamorph_creative_menu/files/integrations/ew/bridge/materials.lua deleted file mode 100644 index f189590..0000000 --- a/metamorph_creative_menu/files/integrations/ew/bridge/materials.lua +++ /dev/null @@ -1,646 +0,0 @@ -local materials_bridge = {} - --- Material paint deliberately travels over transports already understood by an --- unmodified EW peer. Every operation first seeds each touched 128x128 area through --- EW's own aligned world-frame encoder. Every brush stamp then uses a short-lived --- vanilla MagicConvertMaterialComponent entity, uploaded through EW's normal entity --- sync. Seeding is required even for --- solid paint: EW encodes solids as air, but the frame gives the proxy an authoritative --- chunk to which the following relay can apply. Without that ordering, paint --- in a previously unknown chunk is ignored by the proxy and is soon restored by its --- current authority. The converter is still essential because EW's world decoder --- deliberately preserves solid cells. --- Nothing received by the other game points back into MCM's files. -local ok_ffi, ffi = pcall(require, "ffi") -local transport = dofile("mods/metamorph_creative_menu/files/integrations/ew/material_paint_sync.lua") - -local OUTBOX_RESULT = "mcm_material_paint_outbox_result_v3" -local KEY_WORLD_FRAME = 0 -local KEY_WORLD_END = 1 -local CHUNK_SIZE = 128 -local MAX_ENCODED_BYTES = 90000 -local MAX_MATERIAL_ID = 32767 -- EW EncodedArea.PixelRun.material is signed int16. -local MAX_BATCHES_PER_UPDATE = 8 -local MAX_CHUNKS_PER_UPDATE = 1 -local CHUNK_SEND_INTERVAL = 1 -local CHUNK_RESEND_COOLDOWN = 6 -local CHUNK_QUEUE_CAPACITY = 64 -local RELAY_QUEUE_CAPACITY = 16 -local MAX_RELAY_POINTS = 24 --- One relay per frame keeps the current chunk authority painted before its next native --- world snapshot can restore a newly drawn area. Queue bounds still cap burst cost. -local RELAY_SEND_INTERVAL = 1 -local RELAY_LIFETIME = 8 -local BRUSH_RADII = { [1] = 0, [2] = 1, [3] = 2, [4] = 4, [5] = 6 } - -local last_sequence = transport.ack_sequence() -local rpc, common -local world = nil -local encoded_area = nil - -local function new_queue(capacity) - return { data = {}, head = 1, tail = 0, size = 0, capacity = capacity } -end - -local function queue_push(queue, value) - if queue.size >= queue.capacity then return false end - queue.tail = (queue.tail % queue.capacity) + 1 - queue.data[queue.tail] = value - queue.size = queue.size + 1 - return true -end - -local function queue_peek(queue) - if queue.size == 0 then return nil end - return queue.data[queue.head] -end - -local function queue_last(queue) - if queue.size == 0 then return nil end - return queue.data[queue.tail] -end - -local function queue_pop(queue) - if queue.size == 0 then return nil end - local value = queue.data[queue.head] - queue.data[queue.head] = nil - queue.head = (queue.head % queue.capacity) + 1 - queue.size = queue.size - 1 - return value -end - -local function queue_rotate(queue) - local value = queue_pop(queue) - if value ~= nil then queue_push(queue, value) end - return value -end - -local chunk_queue = new_queue(CHUNK_QUEUE_CAPACITY) -local chunk_pending = {} -local chunk_last_sent = {} -local world_frame_open = false -local world_frame_world_number = nil -local active_world_number = nil -local relay_queue = new_queue(RELAY_QUEUE_CAPACITY) - -local metrics = { - chunks = 0, - bytes = 0, - relays = 0, - overwritten_batches = 0, -} -local metrics_enabled = false - -local function clean_error(err) - if common ~= nil and type(common.clean) == "function" then return common.clean(err) end - return tostring(err) -end - -local function report_error(scope, err) - local diagnostic = tostring(scope) .. ":" .. tostring(err) - if common ~= nil and type(common.encode_diagnostic) == "function" then - diagnostic = common.encode_diagnostic(diagnostic) - else - diagnostic = clean_error(diagnostic) - end - GlobalsSetValue("mcm_material_sync_last_error_v1", diagnostic) - if common ~= nil and type(common.report_error) == "function" then - common.report_error(scope, err) - end -end - -local function frame_number() - if type(GameGetFrameNum) ~= "function" then return 0 end - local ok, value = pcall(GameGetFrameNum) - return ok and (tonumber(value) or 0) or 0 -end - -local function radius_for_brush(brush_index) - return BRUSH_RADII[math.floor(tonumber(brush_index) or 1)] or 2 -end - -local function resolve_material_id(material_name) - if type(CellFactory_GetType) ~= "function" then return nil end - local ok, value = pcall(CellFactory_GetType, material_name) - value = tonumber(value) - if not ok or value == nil or value < 0 or value > MAX_MATERIAL_ID then return nil end - return math.floor(value) -end - -local function ensure_world_modules() - if world ~= nil and encoded_area ~= nil then return true end - if not ok_ffi then return false, "ffi_unavailable" end - local ok, loaded = pcall(dofile_once, "mods/quant.ew/files/system/world_sync/world.lua") - if not ok then return false, loaded end - if type(loaded) ~= "table" or type(loaded.encode_area) ~= "function" - or (type(loaded.EncodedArea) ~= "cdata" and type(loaded.EncodedArea) ~= "function") - or type(loaded.encoded_size) ~= "function" - then - return false, "world_api" - end - local area_ok, area = pcall(loaded.EncodedArea) - if not area_ok or area == nil then return false, area or "encoded_area" end - world, encoded_area = loaded, area - return true -end - -local function chunk_key(cx, cy) - return tostring(cx) .. ":" .. tostring(cy) -end - -local function current_world_number() - if type(ctx) == "table" and type(ctx.proxy_opt) == "table" then - return math.floor(tonumber(ctx.proxy_opt.world_num) or 0) - end - return 0 -end - -local function complete_chunk(frame) - local completed = queue_pop(chunk_queue) - if completed ~= nil then - chunk_pending[completed.key] = nil - chunk_last_sent[completed.key] = tonumber(frame) or frame_number() - end -end - -local function point_chunk_keys(x, y, radius) - local keys = {} - local min_cx = math.floor((x - radius) / CHUNK_SIZE) - local max_cx = math.floor((x + radius) / CHUNK_SIZE) - local min_cy = math.floor((y - radius) / CHUNK_SIZE) - local max_cy = math.floor((y + radius) / CHUNK_SIZE) - for cy = min_cy, max_cy do - for cx = min_cx, max_cx do - keys[#keys + 1] = chunk_key(cx, cy) - end - end - return keys -end - -local function primary_chunk_key(point) - return chunk_key(math.floor(point[1] / CHUNK_SIZE), math.floor(point[2] / CHUNK_SIZE)) -end - -local function collect_chunks(points, radius) - local result, seen = {}, {} - for i = 1, #points do - local x, y = points[i][1], points[i][2] - local min_cx = math.floor((x - radius) / CHUNK_SIZE) - local max_cx = math.floor((x + radius) / CHUNK_SIZE) - local min_cy = math.floor((y - radius) / CHUNK_SIZE) - local max_cy = math.floor((y + radius) / CHUNK_SIZE) - for cy = min_cy, max_cy do - for cx = min_cx, max_cx do - local key = chunk_key(cx, cy) - if not seen[key] and not chunk_pending[key] then - seen[key] = true - result[#result + 1] = { - cx = cx, cy = cy, key = key, - ready_frame = (chunk_last_sent[key] or -100000) + CHUNK_RESEND_COOLDOWN, - } - end - end - end - end - return result -end - -local function commit_chunks(chunks) - for i = 1, #chunks do - queue_push(chunk_queue, chunks[i]) - chunk_pending[chunks[i].key] = true - end -end - -local function append_dependency_keys(job, keys) - job.dependency_seen = job.dependency_seen or {} - job.dependencies = job.dependencies or {} - for i = 1, #keys do - local key = keys[i] - if not job.dependency_seen[key] then - job.dependency_seen[key] = true - job.dependencies[#job.dependencies + 1] = key - end - end -end - -local function make_relay_groups(points, material_name, material_id, radius) - local groups = {} - local current = nil - for i = 1, #points do - local point = points[i] - local anchor_key = primary_chunk_key(point) - if current == nil or current.anchor_key ~= anchor_key - or #current.points >= MAX_RELAY_POINTS - then - current = { - material_name = material_name, - material_id = material_id, - radius = radius, - anchor_key = anchor_key, - points = {}, - dependencies = {}, - dependency_seen = {}, - } - groups[#groups + 1] = current - end - current.points[#current.points + 1] = { point[1], point[2] } - append_dependency_keys(current, point_chunk_keys(point[1], point[2], radius)) - end - return groups -end - -local function plan_relays(points, material_name, material_id, radius) - local groups = make_relay_groups(points, material_name, material_id, radius) - local tail = queue_last(relay_queue) - local merge_first = #groups > 0 and tail ~= nil - and tail.material_id == material_id and tail.material_name == material_name and tail.radius == radius - and tail.anchor_key == groups[1].anchor_key - and #tail.points + #groups[1].points <= MAX_RELAY_POINTS - local new_jobs = #groups - (merge_first and 1 or 0) - if relay_queue.size + new_jobs > relay_queue.capacity then return nil end - return { groups = groups, merge_first = merge_first, tail = tail } -end - -local function commit_native_operation(relay_plan) - local first = 1 - if relay_plan.merge_first then - local group = relay_plan.groups[1] - local target = relay_plan.tail - for i = 1, #group.points do - target.points[#target.points + 1] = group.points[i] - end - append_dependency_keys(target, group.dependencies) - first = 2 - end - for i = first, #relay_plan.groups do - queue_push(relay_queue, relay_plan.groups[i]) - end -end - -local function accept_batch(payload) - local decoded = transport.decode_batch(payload) - if decoded == nil then return true, "invalid" end - local material_id = resolve_material_id(decoded.material_name) - if material_id == nil then return true, "invalid_material" end - local radius = radius_for_brush(decoded.brush_index) - -- Solid frames contain air where Noita has solid cells, but they still establish - -- the native EW chunk ordering before the synchronized converter/emitter arrives. - local chunks = collect_chunks(decoded.points, radius) - if chunk_queue.size + #chunks > chunk_queue.capacity then - return false, "backpressure" - end - local relay_plan = plan_relays(decoded.points, decoded.material_name, material_id, radius) - if relay_plan == nil then return false, "backpressure" end - - -- Commit only after every destination has capacity. If backpressure stops mailbox - -- acknowledgement, retrying the same batch cannot duplicate half of its operation. - commit_chunks(chunks) - commit_native_operation(relay_plan) - return true, decoded.solid and "queued_solid" or "queued_dynamic" -end - -local function drain_outbox() - local next_sequence, newest = transport.first_available_sequence(last_sequence) - if next_sequence > last_sequence + 1 then - if metrics_enabled then - metrics.overwritten_batches = metrics.overwritten_batches + (next_sequence - last_sequence - 1) - end - last_sequence = next_sequence - 1 - transport.mark_ack(last_sequence) - end - - local budget = MAX_BATCHES_PER_UPDATE - while last_sequence < newest and budget > 0 do - local sequence = last_sequence + 1 - local payload = transport.read_outbox(sequence) - local accepted, result - if type(payload) == "string" and payload ~= "" then - local ok, accepted_or_error, reason = pcall(accept_batch, payload) - if not ok then - report_error("material_paint_accept", accepted_or_error) - break - end - accepted, result = accepted_or_error, reason - else - accepted, result = true, "missing" - end - - if not accepted then break end - transport.clear_outbox(sequence) - last_sequence = sequence - transport.mark_ack(sequence) - GlobalsSetValue(OUTBOX_RESULT, tostring(sequence) .. ":" .. tostring(result or "accepted")) - budget = budget - 1 - end -end - -local function world_exists(x1, y1, x2, y2) - if type(DoesWorldExistAt) ~= "function" then return true end - local ok, exists = pcall(DoesWorldExistAt, x1, y1, x2, y2) - if not ok then return false, exists end - return exists == true, exists == true and nil or "unloaded" -end - -local function encode_and_send_chunk(chunk) - local modules_ok, modules_reason = ensure_world_modules() - if not modules_ok then return false, modules_reason end - local x, y = chunk.cx * CHUNK_SIZE, chunk.cy * CHUNK_SIZE - local exists, exists_reason = world_exists(x, y, x + CHUNK_SIZE, y + CHUNK_SIZE) - -- Never mark an unloaded dependency as complete. Releasing the following relay - -- without a native frame recreates the exact one-chunk-only rollback bug. - if not exists then return false, exists_reason end - - -- EW's native encoder has hard alignment and size preconditions in native code. - -- Keeping this exactly 128x128 and aligned avoids the unsafe arbitrary rectangles - -- used by the old bridge. - local encode_ok, area = pcall(world.encode_area, x, y, x + CHUNK_SIZE, y + CHUNK_SIZE, encoded_area) - if not encode_ok then return false, area end - if area == nil then return false, "encode_nil" end - local size_ok, size = pcall(world.encoded_size, area) - size = tonumber(size) - if not size_ok or size == nil or size <= 0 or size > MAX_ENCODED_BYTES then - return false, "encoded_size" - end - local string_ok, payload = pcall(ffi.string, area, size) - if not string_ok or type(payload) ~= "string" then return false, payload or "encode_string" end - local send_ok, send_error = pcall(net.proxy_bin_send, KEY_WORLD_FRAME, payload) - if not send_ok then return false, send_error end - if metrics_enabled then - metrics.chunks = metrics.chunks + 1 - metrics.bytes = metrics.bytes + #payload - end - return true, "sent" -end - -local function send_world_end(world_number) - world_number = math.floor(tonumber(world_number) or current_world_number()) - return pcall(net.proxy_bin_send, KEY_WORLD_END, string.char(0) .. tostring(world_number)) -end - -local function reset_for_world_change() - local world_number = current_world_number() - if active_world_number == nil then - active_world_number = world_number - return - end - if active_world_number == world_number then return end - - -- Paint from the previous world must never be released into the new world. The - -- proxy owns a separate chunk store per world number, so its seed cache is scoped - -- to that number as well. - if world_frame_open then - local closed, close_error = send_world_end(world_frame_world_number) - if not closed then report_error("material_paint_old_world_end", close_error) end - end - chunk_queue = new_queue(CHUNK_QUEUE_CAPACITY) - chunk_pending = {} - chunk_last_sent = {} - relay_queue = new_queue(RELAY_QUEUE_CAPACITY) - world_frame_open = false - world_frame_world_number = nil - active_world_number = world_number -end - -local function drain_chunks(frame) - -- Once key 0 has been accepted, no later world frame may be started until its - -- matching key 1 succeeds. Retrying only the terminator avoids both losing the - -- accepted chunk and concatenating unrelated native frames after a transient error. - if world_frame_open then - local end_ok, end_error = send_world_end(world_frame_world_number) - if not end_ok then - report_error("material_paint_world_end", end_error) - return - end - complete_chunk(frame) - world_frame_open = false - world_frame_world_number = nil - return - end - if frame % CHUNK_SEND_INTERVAL ~= 0 then return end - local budget = MAX_CHUNKS_PER_UPDATE - local remaining_to_scan = chunk_queue.size - while chunk_queue.size > 0 and budget > 0 and remaining_to_scan > 0 do - local chunk = queue_peek(chunk_queue) - local ok, reason - if frame < (tonumber(chunk.ready_frame) or 0) then - ok, reason = false, "cooldown" - else - ok, reason = encode_and_send_chunk(chunk) - end - if not ok then - -- A chunk touching the streaming edge may become available a few frames - -- later. Rotate only that dependency so it cannot freeze all loaded chunks - -- encountered while the player keeps moving and drawing. - if reason == "unloaded" or reason == "cooldown" then - queue_rotate(chunk_queue) - remaining_to_scan = remaining_to_scan - 1 - else - report_error("material_paint_chunk", reason) - break - end - else - if reason == "sent" then - world_frame_open = true - world_frame_world_number = current_world_number() - local end_ok, end_error = send_world_end(world_frame_world_number) - if not end_ok then - report_error("material_paint_world_end", end_error) - return - end - world_frame_open = false - world_frame_world_number = nil - end - complete_chunk(frame) - budget = budget - 1 - remaining_to_scan = remaining_to_scan - 1 - end - end -end - -local function dependencies_ready(job) - for i = 1, #(job.dependencies or {}) do - if chunk_pending[job.dependencies[i]] then return false end - end - return true -end - -local function kill_entity(entity_id) - if entity_id ~= nil and tonumber(entity_id) ~= 0 and type(EntityKill) == "function" then - pcall(EntityKill, entity_id) - end -end - -local function add_component(entity_id, component_name, values) - if type(EntityAddComponent2) ~= "function" then return false, "component_api" end - local ok, component = pcall(EntityAddComponent2, entity_id, component_name, values) - if not ok or component == nil or component == 0 then return false, component or "component" end - return true -end - -local function create_relay(job) - if type(EntityCreateNew) ~= "function" or type(EntitySetTransform) ~= "function" - or type(EntityAddChild) ~= "function" - then - return nil, "entity_api" - end - if type(job.points) ~= "table" or #job.points == 0 then return nil, "relay_points" end - - local create_ok, root = pcall(EntityCreateNew, "mcm_material_relay") - if not create_ok or root == nil or root == 0 then return nil, root or "relay_root" end - local base_x, base_y = job.points[1][1], job.points[1][2] - local transform_ok, transform_error = pcall(EntitySetTransform, root, base_x, base_y) - if not transform_ok then kill_entity(root); return nil, transform_error end - if type(EntityAddTag) == "function" then pcall(EntityAddTag, root, "mcm_material_relay") end - - local lifetime_ok, lifetime_error = add_component(root, "LifetimeComponent", { - _tags = "enabled_in_world", - lifetime = RELAY_LIFETIME, - }) - if not lifetime_ok then kill_entity(root); return nil, lifetime_error end - - for i = 1, #job.points do - local point = job.points[i] - local child_ok, child = pcall(EntityCreateNew, "mcm_material_converter") - if not child_ok or child == nil or child == 0 then - kill_entity(root) - return nil, child or "relay_child" - end - -- Position in world space before parenting, matching Noita's own EntityLoad(x,y) - -- then EntityAddChild() pattern. Reparenting derives the correct relative offset - -- and the serialized tree remains anchored at the painted chunk. - local child_transform_ok, child_transform_error = pcall(EntitySetTransform, - child, point[1], point[2]) - if not child_transform_ok then kill_entity(child); kill_entity(root); return nil, child_transform_error end - local parent_ok, parent_error = pcall(EntityAddChild, root, child) - if not parent_ok then kill_entity(child); kill_entity(root); return nil, parent_error end - local convert_ok, convert_error = add_component(child, "MagicConvertMaterialComponent", { - _tags = "enabled_in_world", - radius = math.floor(job.radius), - -- A brush stamp is a filled disc. Setting min_radius to radius turns the - -- converter into an annulus and leaves the solid interior untouched on a - -- stock peer (EW's normal world decoder intentionally preserves solids). - min_radius = 0, - is_circle = true, - steps_per_frame = 256, - from_any_material = true, - to_material = math.floor(job.material_id), - loop = true, - kill_when_finished = false, - convert_entities = false, - -- Re-running the short-lived relay must not keep rebuilding cells that - -- already have the requested material while they begin simulating. - convert_same_material = false, - }) - if not convert_ok then kill_entity(root); return nil, convert_error end - -- MagicConvert changes existing cells but cannot reliably fill empty air for - -- every material class. A one-frame real-particle emitter supplies seed cells - -- on stock EW peers; the converter immediately expands them to the exact disc. - local particle_count = math.max(1, - math.floor(math.pi * (math.max(0, tonumber(job.radius) or 0) + 0.5) ^ 2)) - add_component(child, "ParticleEmitterComponent", { - _tags = "enabled_in_world", - emitted_material_name = tostring(job.material_name or ""), - create_real_particles = true, - emit_real_particles = false, - emit_cosmetic_particles = false, - emitter_lifetime_frames = 1, - emission_interval_min_frames = 1, - emission_interval_max_frames = 1, - count_min = particle_count, - count_max = particle_count, - x_pos_offset_min = -math.floor(job.radius), - x_pos_offset_max = math.floor(job.radius), - y_pos_offset_min = -math.floor(job.radius), - y_pos_offset_max = math.floor(job.radius), - x_vel_min = 0, - x_vel_max = 0, - y_vel_min = 0, - y_vel_max = 0, - lifetime_min = 1, - lifetime_max = 1, - render_on_grid = true, - set_magic_creation = true, - is_emitting = true, - }) - local child_lifetime_ok, child_lifetime_error = add_component(child, "LifetimeComponent", { - _tags = "enabled_in_world", - lifetime = RELAY_LIFETIME, - }) - if not child_lifetime_ok then kill_entity(root); return nil, child_lifetime_error end - end - return root -end - -local function upload_relay(root) - -- EW exposes this primitive to its own ew_thrown bridge. Calling it directly - -- uploads the freshly built component tree before its converters have a chance to - -- run. CrossCall is retained as a compatibility fallback for other EW revisions. - if type(ewext) == "table" and type(ewext.des_item_thrown) == "function" then - return pcall(ewext.des_item_thrown, root) - end - if type(CrossCall) == "function" then return pcall(CrossCall, "ew_thrown", root) end - return false, "entity_transport" -end - -local function drain_relays(frame) - if frame % RELAY_SEND_INTERVAL ~= 0 or relay_queue.size == 0 then return end - local remaining_to_scan = relay_queue.size - while remaining_to_scan > 0 do - local job = queue_peek(relay_queue) - if not dependencies_ready(job) then - queue_rotate(relay_queue) - remaining_to_scan = remaining_to_scan - 1 - else - local root, create_error = create_relay(job) - if root == nil then - report_error("material_paint_relay_create", create_error) - return - end - local uploaded, upload_error = upload_relay(root) - if not uploaded then - kill_entity(root) - report_error("material_paint_relay_upload", upload_error) - return - end - queue_pop(relay_queue) - if metrics_enabled then metrics.relays = metrics.relays + 1 end - return - end - end -end - -local function publish_metrics(frame) - if not metrics_enabled or frame % 60 ~= 0 then return end - GlobalsSetValue("mcm_material_sync_sent_chunks_v1", tostring(metrics.chunks)) - GlobalsSetValue("mcm_material_sync_sent_bytes_v1", tostring(metrics.bytes)) - GlobalsSetValue("mcm_material_sync_relay_entities_v1", tostring(metrics.relays)) - GlobalsSetValue("mcm_material_sync_overwritten_batches_v1", tostring(metrics.overwritten_batches)) - GlobalsSetValue("mcm_material_sync_backlog_v1", table.concat({ - tostring(chunk_queue.size), tostring(relay_queue.size), - }, ":")) -end - -function materials_bridge.set_metrics_enabled(enabled) metrics_enabled = enabled == true end - -function materials_bridge.register(shared_rpc, shared_common) - rpc, common = shared_rpc, shared_common - -- Slot 11 remains registered for wire compatibility with MCM v4 peers. Material - -- state itself never relies on this handler, so a stock peer can safely lack it. - rpc.opts_reliable() - rpc.opts_everywhere() - function rpc.sync_material_paint(_batch) return end -end - -function materials_bridge.update() - local frame = frame_number() - reset_for_world_change() - -- Accept/coalesce the newest brush points before encoding, so a continuous stroke - -- is captured in the next native frame instead of one update late. - drain_outbox() - drain_chunks(frame) - drain_relays(frame) - publish_metrics(frame) -end - -return materials_bridge diff --git a/metamorph_creative_menu/files/integrations/ew/bridge/possession.lua b/metamorph_creative_menu/files/integrations/ew/bridge/possession.lua index 8acbca1..a05e1e6 100644 --- a/metamorph_creative_menu/files/integrations/ew/bridge/possession.lua +++ b/metamorph_creative_menu/files/integrations/ew/bridge/possession.lua @@ -1,121 +1,98 @@ local possession_bridge = {} local OUTBOX_SEQ = "mcm_possession_retire_outbox_seq_v1" -local OUTBOX_ENTITY = "mcm_possession_retire_outbox_entity_v1" local OUTBOX_PATH = "mcm_possession_retire_outbox_path_v1" local OUTBOX_X = "mcm_possession_retire_outbox_x_v1" local OUTBOX_Y = "mcm_possession_retire_outbox_y_v1" -local OUTBOX_WAIT = "mcm_possession_retire_outbox_wait_v1" -local OUTBOX_RESPONSIBLE = "mcm_possession_retire_outbox_responsible_v1" local OUTBOX_ACK = "mcm_possession_retire_outbox_ack_v1" -local MAX_DRAIN_PER_FRAME = 16 -local MAX_SEQUENCE_GAP = 4096 - -local function parse_sequence(value) - local sequence = tonumber(value) - if type(sequence) ~= "number" or sequence ~= sequence - or sequence == math.huge or sequence == -math.huge - or sequence < 0 or math.floor(sequence) ~= sequence - then return nil end - return sequence -end - -local last_sequence = parse_sequence(GlobalsGetValue(OUTBOX_ACK, "0")) or 0 +local last_sequence = tonumber(GlobalsGetValue(OUTBOX_ACK, "0")) or 0 local rpc, common -local last_invalid_sequence = nil -local function path_allowed(path) - return type(path) == "string" and #path > 0 and #path <= 512 - and string.find(path, "..", 1, true) == nil - and (string.sub(path, 1, 14) == "data/entities/" or string.sub(path, 1, 5) == "mods/") +local function safe_retire_owned_entity(entity) + if entity == nil or entity == 0 or not EntityGetIsAlive(entity) then return false end + local ok_util, util_min = pcall(dofile, "mods/quant.ew/files/resource/util_min.lua") + if not ok_util or type(util_min) ~= "table" or type(util_min.do_i_own) ~= "function" then return false end + local ok_owner, owned = pcall(util_min.do_i_own, entity) + if not ok_owner or owned ~= true then return false end + local tree, queue, seen, index = {}, {entity}, {}, 1 + while index <= #queue do + local current = queue[index]; index = index + 1 + if current ~= nil and current ~= 0 and EntityGetIsAlive(current) and not seen[current] then + seen[current] = true; tree[#tree + 1] = current + for _, child in ipairs(EntityGetAllChildren(current) or {}) do queue[#queue + 1] = child end + end + end + pcall(EntitySetTransform, entity, 10000000, 10000000) + for _, current in ipairs(tree) do + for _, component in ipairs(EntityGetAllComponents(current) or {}) do + local ok_name, name = pcall(ComponentGetTypeName, component) + if ok_name and name == "LuaComponent" then pcall(EntityRemoveComponent, current, component) + else pcall(EntitySetComponentIsEnabled, current, component, false) end + end + end + if EntityGetIsAlive(entity) then pcall(EntityKill, entity) end + return true end function possession_bridge.register(shared_rpc, shared_common) rpc, common = shared_rpc, shared_common - -- Slot 9 stays registered for v4 wire compatibility. Retirement itself deliberately - -- uses EW's own ew_death_notify CrossCall so no MCM is required on another peer. rpc.opts_reliable() rpc.opts_everywhere() - function rpc.retire_possession_target(_path, _x, _y) return end -end - -local PAYLOAD_KEYS = { OUTBOX_ENTITY, OUTBOX_PATH, OUTBOX_X, OUTBOX_Y, OUTBOX_WAIT, OUTBOX_RESPONSIBLE } - -local function read_value(base, current, published, fallback) - local value = GlobalsGetValue(base .. "_" .. tostring(current), "") - if value == "" and current == published then value = GlobalsGetValue(base, fallback or "") end - return value -end - -local function clear_payload(current, published) - for _, base in ipairs(PAYLOAD_KEYS) do - GlobalsSetValue(base .. "_" .. tostring(current), "") - -- The unsuffixed keys are a compatibility alias for the latest published event. - -- Never clear them while an earlier event in the same batch is being drained. - if current == published then GlobalsSetValue(base, "") end + function rpc.retire_possession_target(path, x, y) + if type(path) ~= "string" or #path == 0 or #path > 512 or string.sub(path, 1, 22) ~= "data/entities/animals/" then return end + x, y = tonumber(x), tonumber(y) + if not common.finite_number(x) or not common.finite_number(y) then return end + local sender_data = ctx.rpc_player_data + local sender_entity = sender_data ~= nil and sender_data.entity or 0 + if sender_entity == nil or sender_entity == 0 or not EntityGetIsAlive(sender_entity) then return end + local sx, sy = EntityGetTransform(sender_entity) + if not common.finite_number(sx) or not common.finite_number(sy) then return end + local dx, dy = sx - x, sy - y + if dx * dx + dy * dy > 512 * 512 then return end + local best, best_d2 = 0, 96 * 96 + for _, raw in ipairs(EntityGetInRadius(x, y, 96) or {}) do + local root, guard = raw, 0 + while root ~= nil and root ~= 0 and guard < 32 do + local parent = EntityGetParent(root) + if parent == nil or parent == 0 then break end + root, guard = parent, guard + 1 + end + if root ~= nil and root ~= 0 and root ~= sender_entity and EntityGetIsAlive(root) + and tostring(EntityGetFilename(root) or "") == path then + local rx, ry = EntityGetTransform(root) + if common.finite_number(rx) and common.finite_number(ry) then + local ex, ey = rx - x, ry - y + local d2 = ex * ex + ey * ey + if d2 <= best_d2 then best, best_d2 = root, d2 end + end + end + end + if best ~= 0 then safe_retire_owned_entity(best) end end end function possession_bridge.update() - local raw_sequence = GlobalsGetValue(OUTBOX_SEQ, "0") - local sequence = parse_sequence(raw_sequence) - if sequence == nil then - raw_sequence = tostring(raw_sequence) - if raw_sequence ~= last_invalid_sequence then - last_invalid_sequence = raw_sequence - common.report_error("possession_retire_sequence", "invalid=" .. raw_sequence) - end - return - end - last_invalid_sequence = nil + local sequence = tonumber(GlobalsGetValue(OUTBOX_SEQ, "0")) or 0 if sequence <= last_sequence then return end - if sequence - last_sequence > MAX_SEQUENCE_GAP then - common.report_error("possession_retire_gap", "last=" .. tostring(last_sequence) .. ";published=" .. tostring(sequence)) - last_sequence = sequence - GlobalsSetValue(OUTBOX_ACK, tostring(last_sequence)) - return - end - local processed = last_sequence - local drain_until = math.min(sequence, last_sequence + MAX_DRAIN_PER_FRAME) - for current = last_sequence + 1, drain_until do - local entity = tonumber(read_value(OUTBOX_ENTITY, current, sequence, "0")) or 0 - local path = read_value(OUTBOX_PATH, current, sequence, "") - local x = tonumber(read_value(OUTBOX_X, current, sequence, "")) - local y = tonumber(read_value(OUTBOX_Y, current, sequence, "")) - local wait_on_kill = read_value(OUTBOX_WAIT, current, sequence, "0") == "1" - local responsible = tonumber(read_value(OUTBOX_RESPONSIBLE, current, sequence, "0")) or 0 - if entity == 0 or not path_allowed(path) or not common.finite_number(x) or not common.finite_number(y) then - common.report_error("possession_retire_submit", "seq=" .. tostring(current)) - processed = current - clear_payload(current, sequence) + for current = last_sequence + 1, sequence do + local suffix = "_" .. tostring(current) + local path = GlobalsGetValue(OUTBOX_PATH .. suffix, "") + local x = tonumber(GlobalsGetValue(OUTBOX_X .. suffix, "")) + local y = tonumber(GlobalsGetValue(OUTBOX_Y .. suffix, "")) + if current == sequence then + if path == "" then path = GlobalsGetValue(OUTBOX_PATH, "") end + if x == nil then x = tonumber(GlobalsGetValue(OUTBOX_X, "")) end + if y == nil then y = tonumber(GlobalsGetValue(OUTBOX_Y, "")) end + end + if path == "" or not common.finite_number(x) or not common.finite_number(y) then + common.report_error("possession_retire_submit", "seq=" .. tostring(current)); processed = current else - -- Possession is not running from an engine death callback, so the safest path - -- is to resolve DES immediately while the entity and ew_gid_lid are guaranteed - -- to still exist. Fallback to EW's stock CrossCall queue on older builds; the - -- ACK is still observed by main MCM only on the following game update. - local ok, failure - if type(ewext) == "table" and type(ewext.des_death_notify) == "function" - and (type(EntityGetIsAlive) ~= "function" or EntityGetIsAlive(entity)) - then - ok, failure = pcall(ewext.des_death_notify, entity, wait_on_kill, x, y, path, responsible) - elseif type(CrossCall) == "function" then - ok, failure = pcall(CrossCall, "ew_death_notify", - entity, wait_on_kill, x, y, path, responsible) - else - ok, failure = false, "DES death API unavailable" - end - if not ok then - common.report_error("possession_retire_submit", tostring(failure)) - break - end + local ok, failure = pcall(rpc.retire_possession_target, path, x, y) + if not ok then common.report_error("possession_retire_send", failure); break end processed = current - clear_payload(current, sequence) end end - if processed > last_sequence then - last_sequence = processed - GlobalsSetValue(OUTBOX_ACK, tostring(processed)) - end + last_sequence = processed + GlobalsSetValue(OUTBOX_ACK, tostring(processed)) end - return possession_bridge diff --git a/metamorph_creative_menu/files/integrations/ew/bridge/protocol.lua b/metamorph_creative_menu/files/integrations/ew/bridge/protocol.lua index 29a02fb..94fbe70 100644 --- a/metamorph_creative_menu/files/integrations/ew/bridge/protocol.lua +++ b/metamorph_creative_menu/files/integrations/ew/bridge/protocol.lua @@ -2,7 +2,7 @@ -- RPC namespaces are positional in Entangled Worlds: slot order is wire protocol state. -- Change NAMESPACE whenever RPC_ORDER or any registered RPC signature changes. return { - NAMESPACE = "metamorph_creative_menu:world_rules:v4:", + NAMESPACE = "metamorph_creative_menu:world_rules:v3:", RPC_ORDER = { "apply_world_rules", "request_world_rules", @@ -14,6 +14,5 @@ return { "request_weather_state", "retire_possession_target", "announce_light_form_protocol", - "sync_material_paint", }, } diff --git a/metamorph_creative_menu/files/integrations/ew/bridge/teleport.lua b/metamorph_creative_menu/files/integrations/ew/bridge/teleport.lua deleted file mode 100644 index fa639c3..0000000 --- a/metamorph_creative_menu/files/integrations/ew/bridge/teleport.lua +++ /dev/null @@ -1,184 +0,0 @@ -local teleport_bridge = {} - -local NAMESPACE = "metamorph_creative_menu:teleport:v1:" -local OUTBOX_SEQ = "mcm_teleport_bring_outbox_seq_v1" -local OUTBOX_PEER = "mcm_teleport_bring_outbox_peer_v1" -local OUTBOX_TARGET_X = "mcm_teleport_bring_outbox_target_x_v1" -local OUTBOX_TARGET_Y = "mcm_teleport_bring_outbox_target_y_v1" -local OUTBOX_DEST_X = "mcm_teleport_bring_outbox_dest_x_v1" -local OUTBOX_DEST_Y = "mcm_teleport_bring_outbox_dest_y_v1" -local OUTBOX_ACK = "mcm_teleport_bring_outbox_ack_v1" -local MAX_STREAM_FRAMES = 300 -local MAX_DRAIN_PER_FRAME = 16 -local MAX_SEQUENCE_GAP = 4096 - -local rpc, common -local last_invalid_sequence = nil - -local function parse_sequence(value) - local sequence = tonumber(value) - if type(sequence) ~= "number" or sequence ~= sequence - or sequence == math.huge or sequence == -math.huge - or sequence < 0 or math.floor(sequence) ~= sequence - then - return nil - end - return sequence -end - -local last_sequence = parse_sequence(GlobalsGetValue(OUTBOX_ACK, "0")) or 0 -local pending = nil - -local function frame_number() - local ok, frame = pcall(GameGetFrameNum) - return ok and (tonumber(frame) or 0) or 0 -end - -local function world_loaded(x, y) - if type(DoesWorldExistAt) ~= "function" then return true end - local ok, loaded = pcall(DoesWorldExistAt, x - 24, y - 32, x + 24, y + 20) - return ok and loaded == true -end - -local function safe_position(x, y) - if type(FindFreePositionForBody) == "function" then - local ok, px, py = pcall(FindFreePositionForBody, x, y, 0, 0, 9) - if ok and common.finite_number(px) and common.finite_number(py) and world_loaded(px, py) then - return px, py - end - end - return nil, nil -end - -local function apply_pending() - if pending == nil then return end - local player = type(ctx) == "table" and type(ctx.my_player) == "table" and ctx.my_player.entity or 0 - if player == nil or player == 0 or not EntityGetIsAlive(player) then pending = nil; return end - if frame_number() - pending.started > MAX_STREAM_FRAMES then pending = nil; return end - if not world_loaded(pending.x, pending.y) then - if type(GameSetCameraPos) == "function" then pcall(GameSetCameraPos, pending.x, pending.y) end - return - end - local x, y = safe_position(pending.x, pending.y) - if x == nil then pending = nil; return end - local component = EntityGetFirstComponentIncludingDisabled(player, "CharacterDataComponent") - if component ~= nil and component ~= 0 then pcall(ComponentSetValue2, component, "mVelocity", 0, 0) end - pcall(EntitySetTransform, player, x, y) - pending = nil -end - -local function peer_by_name(peer_id) - peer_id = tostring(peer_id or "") - for candidate in pairs(ctx.players or {}) do - if tostring(candidate) == peer_id then return candidate end - end - return nil -end - -local function closest_peer(x, y) - local best, best_distance = nil, 96 * 96 - for candidate, data in pairs(ctx.players or {}) do - local entity = type(data) == "table" and data.entity or 0 - if entity ~= nil and entity ~= 0 and EntityGetIsAlive(entity) then - local px, py = EntityGetTransform(entity) - if common.finite_number(px) and common.finite_number(py) then - local dx, dy = px - x, py - y - local distance = dx * dx + dy * dy - if distance <= best_distance then best, best_distance = candidate, distance end - end - end - end - return best -end - -local function read_value(base, sequence, fallback) - local value = GlobalsGetValue(base .. "_" .. tostring(sequence), "") - if value == "" and sequence == tonumber(GlobalsGetValue(OUTBOX_SEQ, "0")) then - value = GlobalsGetValue(base, fallback or "") - end - return value -end - -local function clear_processed(current) - GlobalsSetValue(OUTBOX_PEER .. "_" .. tostring(current), "") - GlobalsSetValue(OUTBOX_TARGET_X .. "_" .. tostring(current), "") - GlobalsSetValue(OUTBOX_TARGET_Y .. "_" .. tostring(current), "") - GlobalsSetValue(OUTBOX_DEST_X .. "_" .. tostring(current), "") - GlobalsSetValue(OUTBOX_DEST_Y .. "_" .. tostring(current), "") -end - -local function drain_outbox() - local raw_sequence = GlobalsGetValue(OUTBOX_SEQ, "0") - local sequence = parse_sequence(raw_sequence) - if sequence == nil then - raw_sequence = tostring(raw_sequence) - if raw_sequence ~= last_invalid_sequence then - last_invalid_sequence = raw_sequence - common.report_error("teleport_bring_sequence", "invalid=" .. raw_sequence) - end - return - end - last_invalid_sequence = nil - if sequence <= last_sequence then return end - if sequence - last_sequence > MAX_SEQUENCE_GAP then - common.report_error("teleport_bring_gap", - "last=" .. tostring(last_sequence) .. ";published=" .. tostring(sequence)) - last_sequence = sequence - GlobalsSetValue(OUTBOX_ACK, tostring(last_sequence)) - return - end - - local drain_until = math.min(sequence, last_sequence + MAX_DRAIN_PER_FRAME) - while last_sequence < drain_until do - local current = last_sequence + 1 - local peer_id = read_value(OUTBOX_PEER, current, "") - local target_x = tonumber(read_value(OUTBOX_TARGET_X, current, "")) - local target_y = tonumber(read_value(OUTBOX_TARGET_Y, current, "")) - local dest_x = tonumber(read_value(OUTBOX_DEST_X, current, "")) - local dest_y = tonumber(read_value(OUTBOX_DEST_Y, current, "")) - if not common.finite_number(target_x) or not common.finite_number(target_y) - or not common.finite_number(dest_x) or not common.finite_number(dest_y) - then - common.report_error("teleport_bring_submit", "seq=" .. tostring(current)) - else - local target = peer_by_name(peer_id) or closest_peer(target_x, target_y) - if target ~= nil then - local ok, failure = pcall(rpc.request_bring, tostring(target), dest_x, dest_y) - if not ok then common.report_error("teleport_bring_send", failure); break end - end - end - clear_processed(current) - last_sequence = current - GlobalsSetValue(OUTBOX_ACK, tostring(last_sequence)) - end -end - -function teleport_bridge.register(ew_api, shared_common) - common = shared_common - rpc = ew_api.new_rpc_namespace(NAMESPACE) - rpc.opts_reliable() - rpc.opts_everywhere() - function rpc.request_bring(target_peer_id, x, y) - x, y = tonumber(x), tonumber(y) - if not common.finite_number(x) or not common.finite_number(y) then return end - -- A sender may only request a point beside its own authoritative player. - local sender = ctx.rpc_player_data and ctx.rpc_player_data.entity or 0 - if sender == nil or sender == 0 or not EntityGetIsAlive(sender) then return end - local sx, sy = EntityGetTransform(sender) - if not common.finite_number(sx) or not common.finite_number(sy) - or (sx - x) * (sx - x) + (sy - y) * (sy - y) > 96 * 96 - then - return - end - if tostring(ctx.my_id) ~= tostring(target_peer_id or "") then return end - pending = { x=x, y=y, started=frame_number() } - if type(GameSetCameraPos) == "function" then pcall(GameSetCameraPos, x, y) end - end -end - -function teleport_bridge.update() - drain_outbox() - apply_pending() -end - -return teleport_bridge diff --git a/metamorph_creative_menu/files/integrations/ew/companion_request.lua b/metamorph_creative_menu/files/integrations/ew/companion_request.lua new file mode 100644 index 0000000..5243132 --- /dev/null +++ b/metamorph_creative_menu/files/integrations/ew/companion_request.lua @@ -0,0 +1,35 @@ +if type(METAMORPH_CREATIVE_MENU_EW_COMPANION_REQUEST) == "table" then + return METAMORPH_CREATIVE_MENU_EW_COMPANION_REQUEST +end + +local companion_request = {} + +local REQUEST_SEQUENCE_KEY = "mcm_companion_request_seq_v1" +local REQUEST_OFFSET_X_KEY = "mcm_companion_request_x_v1" +local REQUEST_OFFSET_Y_KEY = "mcm_companion_request_y_v1" +local DEFAULT_OFFSET_X = 32 +local DEFAULT_OFFSET_Y = -4 + +local local_request_sequence = 0 + +local function current_frame() + if type(GameGetFrameNum) ~= "function" then return 0 end + local ok, frame_number = pcall(GameGetFrameNum) + return ok and tonumber(frame_number) or 0 +end + +function companion_request.enqueue(offset_x, offset_y) + if type(GlobalsSetValue) ~= "function" then return false, "globals_unavailable" end + + local_request_sequence = local_request_sequence + 1 + GlobalsSetValue(REQUEST_OFFSET_X_KEY, tostring(tonumber(offset_x) or DEFAULT_OFFSET_X)) + GlobalsSetValue(REQUEST_OFFSET_Y_KEY, tostring(tonumber(offset_y) or DEFAULT_OFFSET_Y)) + GlobalsSetValue( + REQUEST_SEQUENCE_KEY, + tostring(current_frame()) .. ":" .. tostring(local_request_sequence) + ) + return true, "queued" +end + +METAMORPH_CREATIVE_MENU_EW_COMPANION_REQUEST = companion_request +return companion_request diff --git a/metamorph_creative_menu/files/integrations/ew/form_death_channel.lua b/metamorph_creative_menu/files/integrations/ew/form_death_channel.lua index 1a2652e..c401188 100644 --- a/metamorph_creative_menu/files/integrations/ew/form_death_channel.lua +++ b/metamorph_creative_menu/files/integrations/ew/form_death_channel.lua @@ -4,7 +4,6 @@ end local form_death_channel = {} local CROSSCALL_NAME = "metamorph_creative_menu_form_died" -local ACK_KEY = "mcm_form_death_intercept_ack_v1" function form_death_channel.emit(form_entity_id, reason, responsible_entity_id, damage_amount, projectile_entity_id) if type(CrossCall) ~= "function" then return false, "crosscall_unavailable" end @@ -20,39 +19,14 @@ function form_death_channel.emit(form_entity_id, reason, responsible_entity_id, return call_succeeded, call_succeeded and "sent" or "crosscall_failed" end --- CrossCall itself has no portable return-value contract between Lua contexts. Publish --- a one-value acknowledgement only after the main MCM form transaction has actually --- committed the human replacement. EW's optional compatibility patch uses this to --- distinguish "MCM rescued this creative form death" from an ordinary polymorph death. function form_death_channel.register(noita_patcher_bridge, handler) if type(noita_patcher_bridge) ~= "table" or type(noita_patcher_bridge.CrossCallAdd) ~= "function" then return false end if type(handler) ~= "function" then return false end - local function wrapped(entity, reason, responsible, damage, projectile) - local ok, handled = pcall(handler, entity, reason, responsible, damage, projectile) - if ok and handled == true and type(GlobalsSetValue) == "function" then - local frame = 0 - if type(GameGetFrameNum) == "function" then - local ok_frame, value = pcall(GameGetFrameNum) - if ok_frame then frame = tonumber(value) or 0 end - end - pcall(GlobalsSetValue, ACK_KEY, tostring(tonumber(entity) or 0) .. ":" .. tostring(frame)) - end - return ok and handled == true - end - -- Register in NoitaPatcher's native cross-VM channel. EW's main VM replaces the - -- global CrossCall with a private same-VM table, so the EW compatibility patch - -- deliberately invokes np.CrossCall to reach this handler. - local registered = pcall(noita_patcher_bridge.CrossCallAdd, CROSSCALL_NAME, wrapped) - if type(GlobalsSetValue) == "function" then - pcall(GlobalsSetValue, "mcm_form_death_channel_registration_v1", - registered and "native_cross_vm" or "failed") - end + local registered = pcall(noita_patcher_bridge.CrossCallAdd, CROSSCALL_NAME, handler) return registered == true end -function form_death_channel.ack_key() return ACK_KEY end - METAMORPH_CREATIVE_MENU_EW_FORM_DEATH_CHANNEL = form_death_channel return form_death_channel diff --git a/metamorph_creative_menu/files/integrations/ew/form_death_intercept.lua b/metamorph_creative_menu/files/integrations/ew/form_death_intercept.lua deleted file mode 100644 index 9194bea..0000000 --- a/metamorph_creative_menu/files/integrations/ew/form_death_intercept.lua +++ /dev/null @@ -1,235 +0,0 @@ --- Runs inside Entangled Worlds' own Lua context as part of the official extra-module --- bootstrap. Unlike a source rewrite performed during OnModPreInit, this wrapper cannot --- lose a mod-order race: EW has already created ctx.cap.health, but no world update has --- run yet. -local form_death_intercept = {} - -local ACK_KEY = "mcm_form_death_intercept_ack_v1" -local STATUS_KEY = "mcm_form_death_runtime_intercept_v2" -local COUNT_KEY = "mcm_form_death_runtime_rescues_v2" -local MARKER = "mcm_form_death_runtime_intercept_v2" -local NETWORK_FORM_TAG = "metamorph_creative_menu_network_form" -local POLYMORPH_EFFECT = { - POLYMORPH=true, - POLYMORPH_RANDOM=true, - POLYMORPH_UNSTABLE=true, - POLYMORPH_CESSATION=true, -} -local installed = false - -local function frame_number() - if type(GameGetFrameNum) ~= "function" then return 0 end - local ok, value = pcall(GameGetFrameNum) - return ok and (tonumber(value) or 0) or 0 -end - -local function publish_status(value) - if type(GlobalsSetValue) == "function" then pcall(GlobalsSetValue, STATUS_KEY, tostring(value)) end -end - -local function ack_matches(entity) - if type(GlobalsGetValue) ~= "function" then return false end - local ok, ack = pcall(GlobalsGetValue, ACK_KEY, "") - if not ok then return false end - local ack_entity, ack_frame = tostring(ack or ""):match("^(%-?%d+):(%-?%d+)$") - if tonumber(ack_entity) ~= tonumber(entity) then return false end - return math.abs(frame_number() - (tonumber(ack_frame) or -100000)) <= 2 -end - -local function active_player_is_restored_human(old_entity) - if type(np) ~= "table" or type(np.GetPlayerEntity) ~= "function" then return false end - local ok, current = pcall(np.GetPlayerEntity) - current = ok and tonumber(current) or 0 - if current == 0 or current == tonumber(old_entity) then return false end - if type(EntityGetIsAlive) == "function" then - local alive_ok, alive = pcall(EntityGetIsAlive, current) - if not alive_ok or alive ~= true then return false end - end - if type(EntityHasTag) == "function" then - local player_ok, is_player = pcall(EntityHasTag, current, "player_unit") - local poly_ok, is_poly = pcall(EntityHasTag, current, "polymorphed_player") - if not player_ok or is_player ~= true or (poly_ok and is_poly == true) then return false end - end - return true -end - -local function tagged_network_form(entity) - if entity == nil or tonumber(entity) == 0 or type(EntityHasTag) ~= "function" then return false end - local ok, tagged = pcall(EntityHasTag, entity, NETWORK_FORM_TAG) - return ok and tagged == true -end - -local function mark_rescued(entity, method) - local count = 0 - if type(GlobalsGetValue) == "function" then - local ok, value = pcall(GlobalsGetValue, COUNT_KEY, "0") - if ok then count = tonumber(value) or 0 end - end - if type(GlobalsSetValue) == "function" then - pcall(GlobalsSetValue, COUNT_KEY, tostring(count + 1)) - end - local prefix = method == "native" and "rescued_native:" or "rescued:" - publish_status(prefix .. tostring(tonumber(entity) or 0) .. ":" .. tostring(frame_number())) -end - -local function human_candidate(entity) - entity = tonumber(entity) or 0 - if entity == 0 then return false end - if type(EntityGetIsAlive) ~= "function" or type(EntityHasTag) ~= "function" then return false end - local alive_ok, alive = pcall(EntityGetIsAlive, entity) - local player_ok, is_player = pcall(EntityHasTag, entity, "player_unit") - local poly_ok, is_poly = pcall(EntityHasTag, entity, "polymorphed_player") - return alive_ok and alive == true and player_ok and is_player == true - and (not poly_ok or is_poly ~= true) -end - -local function serialized_human_backup(entity) - if type(EntityGetAllChildren) ~= "function" - or type(EntityGetFirstComponentIncludingDisabled) ~= "function" - or type(ComponentGetValue2) ~= "function" - then - return nil - end - local children_ok, children = pcall(EntityGetAllChildren, entity) - if not children_ok then return nil end - for _, child in ipairs(children or {}) do - local component_ok, component = pcall(EntityGetFirstComponentIncludingDisabled, child, "GameEffectComponent") - if component_ok and component ~= nil and component ~= 0 then - local effect_ok, effect = pcall(ComponentGetValue2, component, "effect") - if effect_ok and POLYMORPH_EFFECT[tostring(effect or "")] then - local data_ok, encoded = pcall(ComponentGetValue2, component, "mSerializedData") - if data_ok and type(encoded) == "string" and encoded ~= "" then return encoded end - end - end - end - return nil -end - -local function discard_uncommitted_candidate(entity, old_entity) - entity = tonumber(entity) or 0 - if entity == 0 or entity == tonumber(old_entity) or type(EntityKill) ~= "function" - or type(EntityGetIsAlive) ~= "function" - then - return - end - -- Never delete an entity which SetPlayerEntity may already have committed despite - -- an incomplete verification API. An uncommitted malformed deserialize is safe to - -- clean up and must not leak into the world. - if type(np) == "table" and type(np.GetPlayerEntity) == "function" then - local current_ok, current = pcall(np.GetPlayerEntity) - if current_ok and tonumber(current) == entity then return end - end - local alive_ok, alive = pcall(EntityGetIsAlive, entity) - if alive_ok and alive == true then pcall(EntityKill, entity) end -end - -local function protect_restored_human(entity) - if type(EntityGetFirstComponentIncludingDisabled) ~= "function" then return end - local ok, damage = pcall(EntityGetFirstComponentIncludingDisabled, entity, "DamageModelComponent") - if not ok or damage == nil or damage == 0 or type(ComponentSetValue2) ~= "function" then return end - local frames = 0 - if type(ComponentGetValue2) == "function" then - local frames_ok, value = pcall(ComponentGetValue2, damage, "invincibility_frames") - if frames_ok then frames = tonumber(value) or 0 end - end - pcall(ComponentSetValue2, damage, "invincibility_frames", math.max(frames, 12)) - pcall(ComponentSetValue2, damage, "kill_now", false) -end - --- Last-resort path wholly inside EW's VM. It mirrors EW local_health.end_poly_effect: --- the native polymorph effect already contains a base64 NoitaPatcher serialization of --- the original player. Restoring it here avoids relying on CrossCall registrations --- being shared by two separately bundled NoitaPatcher instances. -local function try_native_restore(entity) - if not tagged_network_form(entity) then return false end - if type(np) ~= "table" or type(np.SetPlayerEntity) ~= "function" - or type(np.GetPlayerEntity) ~= "function" - or type(util) ~= "table" or type(util.deserialize_entity) ~= "function" - or type(dofile_once) ~= "function" - then - return false - end - local encoded = serialized_human_backup(entity) - if encoded == nil then return false end - local codec_ok, codec = pcall(dofile_once, "mods/quant.ew/files/resource/base64.lua") - if not codec_ok or type(codec) ~= "table" or type(codec.decode) ~= "function" then return false end - local decode_ok, serialized = pcall(codec.decode, encoded) - if not decode_ok or type(serialized) ~= "string" or serialized == "" then return false end - - local x, y = 0, 0 - if type(EntityGetTransform) == "function" then - local transform_ok, value_x, value_y = pcall(EntityGetTransform, entity) - if transform_ok then x, y = tonumber(value_x) or 0, tonumber(value_y) or 0 end - end - local restore_ok, restored = pcall(util.deserialize_entity, serialized, x, y) - restored = restore_ok and (tonumber(restored) or 0) or 0 - if not human_candidate(restored) then - discard_uncommitted_candidate(restored, entity) - return false - end - local switch_ok = pcall(np.SetPlayerEntity, restored) - if not switch_ok or not active_player_is_restored_human(entity) then - discard_uncommitted_candidate(restored, entity) - return false - end - - protect_restored_human(restored) - -- This is intentionally the same cleanup used by EW's own polymorph restoration. - -- Only kill the old form after the new player pointer has been verified. - if type(EntityKill) == "function" then pcall(EntityKill, entity) end - mark_rescued(entity, "native") - return true -end - -local function try_rescue(entity) - -- The form-side death callback may already have committed the human replacement and - -- removed the form tag. Accept only a recent entity-scoped ack plus verified live - -- human player pointer, so a stale value can never swallow an ordinary EW death. - if ack_matches(entity) and active_player_is_restored_human(entity) then - mark_rescued(entity, "crosscall") - return true - end - if not tagged_network_form(entity) then return false end - if type(np) == "table" and type(np.CrossCall) == "function" then - if type(GlobalsSetValue) == "function" then pcall(GlobalsSetValue, ACK_KEY, "") end - local ok, err = pcall(np.CrossCall, "metamorph_creative_menu_form_died", - tonumber(entity) or 0, "ew_health_poly_death", 0, nil, 0) - if not ok then publish_status("crosscall_failed:" .. tostring(err)) end - if ok and ack_matches(entity) and active_player_is_restored_human(entity) then - mark_rescued(entity, "crosscall") - return true - end - end - return try_native_restore(entity) -end - -function form_death_intercept.install() - if installed then return true, "already_installed" end - if type(ctx) ~= "table" or type(ctx.cap) ~= "table" or type(ctx.cap.health) ~= "table" then - publish_status("health_capability_unavailable") - return false, "health_capability" - end - local health = ctx.cap.health - if health[MARKER] == true then installed = true; return true, "already_installed" end - local original = health.on_poly_death - if type(original) ~= "function" then - publish_status("handler_unavailable") - return false, "handler" - end - - health.on_poly_death = function(...) - local entity = type(ctx.my_player) == "table" and tonumber(ctx.my_player.entity) or 0 - local ok, handled_or_error = pcall(try_rescue, entity) - if ok and handled_or_error == true then return end - if not ok then publish_status("intercept_error:" .. tostring(handled_or_error)) end - -- Fail open: anything not positively acknowledged as an MCM human restore keeps - -- the exact stock EW notplayer/game-over behavior. - return original(...) - end - health[MARKER] = true - installed = true - publish_status("installed") - return true, "installed" -end - -return form_death_intercept diff --git a/metamorph_creative_menu/files/integrations/ew/kolmi_lifecycle.lua b/metamorph_creative_menu/files/integrations/ew/kolmi_lifecycle.lua deleted file mode 100644 index 591cc23..0000000 --- a/metamorph_creative_menu/files/integrations/ew/kolmi_lifecycle.lua +++ /dev/null @@ -1,200 +0,0 @@ --- EW 1.6.3 drains pending_death_notify BEFORE updating/transferring local entities. --- Submit in this VM, before module_on_world_update, including deaths whose body has --- already disappeared. A successful des_death_notify call only enqueues work: confirm --- removal with find_by_gid after the native update before disposing of replacement roots. -local lifecycle = {} -local BOSS = "data/entities/animals/boss_centipede/boss_centipede.xml" -local READY = "mcm31_kolmi_lifecycle_ready_v1" -local REQUEST = "mcm31_kolmi_retire_request_v1_" -local observed, pending, retired = {}, {}, {} -local installed = false -local native_notify - -local function alive(entity) - return EntityGetIsAlive(entity) == true -end - -local function boss_root(entity) - return alive(entity) and EntityHasTag(entity, "boss_centipede") - -- A mod may inherit this tag but implement different phases/death semantics. - -- The proven early-HP policy belongs to the vanilla encounter, not its tag. - and EntityGetFilename(entity) == BOSS - and not EntityHasTag(entity, "player_unit") - and not EntityHasTag(entity, "polymorphed_player") - and not EntityHasTag(entity, "ew_no_enemy_sync") - and EntityGetRootEntity(entity) == entity -end - -local function identity(entity) - for _, var in ipairs(EntityGetComponentIncludingDisabled(entity, "VariableStorageComponent") or {}) do - if ComponentGetValue2(var, "name") == "ew_gid_lid" then - -- Native GIDs are u64. Never round-trip them through a Lua number. - local gid = ComponentGetValue2(var, "value_string") - if type(gid) == "string" and gid ~= "" then - return gid, ComponentGetValue2(var, "value_bool") == true - end - end - end -end - -local function uninitialized(entity) - for _, var in ipairs(EntityGetComponentIncludingDisabled(entity, "VariableStorageComponent") or {}) do - if ComponentGetValue2(var, "name") == "initialized" then - return ComponentGetValue2(var, "value_bool") == false - end - end - return false -end - -local function resolve(gid) - local ok, entity = pcall(ewext.find_by_gid, gid) - return ok, ok and (tonumber(entity) or 0) or 0 -end - -local function quiet_kill(entity) - if not alive(entity) then return end - -- This is an arena replacement or a reconstruction of an already retired GID, - -- never the original dying boss. Do not run a second encounter/death/loot sequence. - for _, lua in ipairs(EntityGetComponentIncludingDisabled(entity, "LuaComponent") or {}) do - ComponentSetValue2(lua, "script_death", "") - ComponentSetValue2(lua, "script_damage_received", "") - ComponentSetValue2(lua, "script_damage_about_to_be_received", "") - EntitySetComponentIsEnabled(entity, lua, false) - end - for _, chest in ipairs(EntityGetComponentIncludingDisabled(entity, "ItemChestComponent") or {}) do - EntityRemoveComponent(entity, chest) - end - local damage = EntityGetFirstComponentIncludingDisabled(entity, "DamageModelComponent") - if damage then - ComponentSetValue2(damage, "wait_for_kill_flag_on_death", false) - ComponentSetValue2(damage, "kill_now", true) - end - EntityKill(entity) -end - -local function submit(record, reason, responsible) - if pending[record.gid] then return end - local ok, entity = resolve(record.gid) - if not ok or entity ~= record.entity then return end - -- The stock filename would ask distant peers to load a fresh pre-fight Kolmi - -- merely to replay her death. KillEntity still reaches existing stock EW replicas. - local sent, failure = pcall(native_notify, record.entity, record.wait, - record.x, record.y, "", responsible or 0) - if not sent then - if record.error ~= tostring(failure) then - record.error = tostring(failure) - end - return - end - record.reason = reason - pending[record.gid] = record -end - -local function observe() - for _, entity in ipairs(EntityGetWithTag("boss_centipede") or {}) do - if boss_root(entity) then - local gid, owner = identity(entity) - if gid and owner then - local ok, tracked = resolve(gid) - if ok and tracked == entity then - local record = observed[entity] - local first_observation = not record or record.gid ~= gid - if first_observation then - record = { entity = entity, gid = gid } - observed[entity] = record - end - record.x, record.y = EntityGetTransform(entity) - local damage = EntityGetFirstComponentIncludingDisabled(entity, "DamageModelComponent") - record.wait = damage ~= nil - and ComponentGetValue2(damage, "wait_for_kill_flag_on_death") == true - record.hp = damage and tonumber(ComponentGetValue2(damage, "hp")) or nil - record.kill = damage and ComponentGetValue2(damage, "kill_now") == true - record.uninitialized = uninitialized(entity) - end - end - end - end -end - -local function before_update() - observe() - for entity, record in pairs(observed) do - local ok, tracked = resolve(record.gid) - if ok and tracked ~= entity then - observed[entity] = nil -- transferred/despawned: never kill another owner's body - elseif ok then - if alive(entity) then - local gid, owner = identity(entity) - if not boss_root(entity) or gid ~= record.gid or not owner then - observed[entity] = nil - elseif retired[gid] and retired[gid] ~= entity then - submit(record, "reconstruction") - elseif GlobalsGetValue(REQUEST .. tostring(entity), "") == gid then - record.wait = false - submit(record, "arena_replacement") - elseif record.kill or (record.hp and record.hp <= 0) then - -- Commit the lethal state before DES can transfer authority and - -- reload boss_centipede.xml, whose init_boss resets HP. The original - -- body remains alive for the authored wait-for-kill animation/portal. - -- A Filename reconstruction can carry HP=0 but initialized=false: - -- it has no running death coroutine and would wait for Sampo forever, - -- or reset HP in init_boss on activation. Dispose of that inert body - -- after acknowledgement instead of leaving an untracked zero-HP boss. - submit(record, record.uninitialized and "inert_death" or "death") - end - else - -- Stock death callbacks can reach EW's post-update queue one frame late. - -- The previous owner snapshot lets us notify BEFORE !alive -> ReleaseAuthority. - submit(record, "death") - end - end - end -end - -local function after_update() - for gid, record in pairs(pending) do - local ok, entity = resolve(gid) - if ok and entity == 0 then - retired[gid] = retired[gid] or record.entity - pending[gid] = nil - observed[record.entity] = nil - GlobalsSetValue(REQUEST .. tostring(record.entity), "") - if record.reason ~= "death" then quiet_kill(record.entity) end - elseif ok and entity ~= record.entity then - -- A queued authority message may recreate the same GID. Observe it below - -- and retire that exact local incarnation on the next native update. - retired[gid] = retired[gid] or record.entity - pending[gid] = nil - end - end - -- Also cover roots first tracked/reconstructed *inside* the native update, before - -- a Lua script or a kill can remove them between this and the next frame. - observe() -end - -function lifecycle.install() - if installed then return true end - if type(ewext) ~= "table" or type(ewext.des_death_notify) ~= "function" - or type(ewext.find_by_gid) ~= "function" or type(ewext.module_on_world_update) ~= "function" - then return false, "ewext_lifecycle_api_unavailable" end - native_notify = ewext.des_death_notify - local native_update = ewext.module_on_world_update - ewext.des_death_notify = function(entity, wait_on_kill, x, y, file, responsible) - -- Keep the one stock callback on the same terminal path, even if it arrives - -- after our lethal-state notification. Native DES ignores an untracked entity. - local record = observed[entity] - if record or (file == BOSS and boss_root(entity)) then file = "" end - return native_notify(entity, wait_on_kill, x, y, file, responsible) - end - ewext.module_on_world_update = function(...) - before_update() - local result = native_update(...) - after_update() - return result - end - installed = true - GlobalsSetValue(READY, "1") - return true -end - -return lifecycle diff --git a/metamorph_creative_menu/files/integrations/ew/material_paint_sync.lua b/metamorph_creative_menu/files/integrations/ew/material_paint_sync.lua deleted file mode 100644 index 7d4eb2d..0000000 --- a/metamorph_creative_menu/files/integrations/ew/material_paint_sync.lua +++ /dev/null @@ -1,171 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_EW_MATERIAL_PAINT_SYNC) == "table" then - return METAMORPH_CREATIVE_MENU_EW_MATERIAL_PAINT_SYNC -end - -local sync = {} - --- This mailbox crosses from MCM's normal Lua VM into EW's extra-module VM. A fixed --- ring is intentional: a disconnected/failed EW bridge must not create one permanent --- Global key per painted frame and eventually bloat the run or save. -local OUTBOX_SEQ = "mcm_material_paint_outbox_seq_v3" -local OUTBOX_ACK = "mcm_material_paint_outbox_ack_v3" -local OUTBOX_SLOT = "mcm_material_paint_outbox_slot_v3" -local OUTBOX_DROPPED = "mcm_material_paint_outbox_dropped_v3" -local OUTBOX_CAPACITY = 32 -local MAX_POINTS = 24 -local MAX_MATERIAL_NAME = 96 -local MAX_COORDINATE = 100000000 --- WorldState does not exist while mod source files are being loaded. Reading a Global --- here emits a native Lua error; merge the persisted sequence only on first publish. -local cached_sequence = 0 - -local function ew_enabled() - if type(ModIsEnabled) ~= "function" then return false end - local ok, enabled = pcall(ModIsEnabled, "quant.ew") - return ok and enabled == true -end - -local function finite_coordinate(value) - value = tonumber(value) - return value ~= nil and value == value and math.abs(value) <= MAX_COORDINATE -end - -local function valid_material_name(value) - return type(value) == "string" and value ~= "" and #value <= MAX_MATERIAL_NAME - and string.match(value, "^[%w_%.%-]+$") ~= nil -end - -local function sanitize_points(points) - if type(points) ~= "table" or #points == 0 or #points > MAX_POINTS then return nil end - local clean = {} - for i = 1, #points do - local point = points[i] - if type(point) ~= "table" or not finite_coordinate(point[1]) or not finite_coordinate(point[2]) then - return nil - end - clean[#clean + 1] = { - math.floor(tonumber(point[1])), - math.floor(tonumber(point[2])), - } - end - return clean -end - -local function encode_points(points) - local parts = {} - for i = 1, #points do - parts[#parts + 1] = tostring(points[i][1]) .. "," .. tostring(points[i][2]) - end - return table.concat(parts, ";") -end - -local function decode_points(text) - local points = {} - if type(text) ~= "string" or text == "" then return nil end - for pair in string.gmatch(text, "[^;]+") do - if #points >= MAX_POINTS then return nil end - local xs, ys = string.match(pair, "^(-?%d+),(-?%d+)$") - if xs == nil or not finite_coordinate(xs) or not finite_coordinate(ys) then return nil end - points[#points + 1] = { math.floor(tonumber(xs)), math.floor(tonumber(ys)) } - end - return #points > 0 and points or nil -end - -local function encode_batch(material_name, solid, brush_index, points) - return table.concat({ - material_name, - solid and "1" or "0", - tostring(brush_index), - encode_points(points), - }, "|") -end - -local function decode_batch(batch) - if type(batch) ~= "string" or batch == "" or #batch > 2048 then return nil end - local material, solid_flag, brush_s, points_s = string.match(batch, "^([^|]*)|([01])|(%d+)|(.*)$") - local brush_index = tonumber(brush_s) - if not valid_material_name(material) or brush_index == nil or brush_index < 1 or brush_index > 5 then return nil end - local points = decode_points(points_s) - if points == nil then return nil end - return { - material_name = material, - solid = solid_flag == "1", - brush_index = math.floor(brush_index), - points = points, - } -end - -local function slot_key(sequence) - return OUTBOX_SLOT .. ":" .. tostring(((sequence - 1) % OUTBOX_CAPACITY) + 1) -end - -local function read_slot(sequence) - if sequence <= 0 then return nil end - local stored = GlobalsGetValue(slot_key(sequence), "") - if type(stored) ~= "string" or stored == "" then return nil end - local stored_sequence, payload = string.match(stored, "^(%d+)#(.*)$") - if tonumber(stored_sequence) ~= sequence then return nil end - return payload -end - -function sync.publish_batch(material_name, solid, brush_index, points) - if not ew_enabled() or not valid_material_name(material_name) then return false end - brush_index = math.floor(tonumber(brush_index) or 0) - if brush_index < 1 or brush_index > 5 then return false end - points = sanitize_points(points) - if points == nil then return false end - - cached_sequence = math.max(cached_sequence, tonumber(GlobalsGetValue(OUTBOX_SEQ, "0")) or 0) + 1 - local payload = encode_batch(material_name, solid == true, brush_index, points) - GlobalsSetValue(slot_key(cached_sequence), tostring(cached_sequence) .. "#" .. payload) - - local ack = tonumber(GlobalsGetValue(OUTBOX_ACK, "0")) or 0 - if cached_sequence - ack > OUTBOX_CAPACITY then - local dropped = tonumber(GlobalsGetValue(OUTBOX_DROPPED, "0")) or 0 - GlobalsSetValue(OUTBOX_DROPPED, tostring(dropped + 1)) - end - -- Publish the sequence last so the EW VM never observes a half-written slot. - GlobalsSetValue(OUTBOX_SEQ, tostring(cached_sequence)) - return true -end - -function sync.outbox_sequence() - return tonumber(GlobalsGetValue(OUTBOX_SEQ, "0")) or 0 -end - -function sync.ack_sequence() - return tonumber(GlobalsGetValue(OUTBOX_ACK, "0")) or 0 -end - -function sync.first_available_sequence(after_sequence) - local newest = sync.outbox_sequence() - local requested = math.max(0, math.floor(tonumber(after_sequence) or 0)) + 1 - local oldest = math.max(1, newest - OUTBOX_CAPACITY + 1) - return math.max(requested, oldest), newest -end - -function sync.read_outbox(sequence) - sequence = math.floor(tonumber(sequence) or 0) - return read_slot(sequence) -end - -function sync.clear_outbox(sequence) - sequence = math.floor(tonumber(sequence) or 0) - if read_slot(sequence) ~= nil then GlobalsSetValue(slot_key(sequence), "") end -end - -function sync.mark_ack(sequence) - sequence = math.max(0, math.floor(tonumber(sequence) or 0)) - local current = sync.ack_sequence() - if sequence > current then GlobalsSetValue(OUTBOX_ACK, tostring(sequence)) end -end - -function sync.decode_batch(batch) - return decode_batch(batch) -end - -function sync.capacity() return OUTBOX_CAPACITY end -function sync.max_points() return MAX_POINTS end - -METAMORPH_CREATIVE_MENU_EW_MATERIAL_PAINT_SYNC = sync -return sync diff --git a/metamorph_creative_menu/files/integrations/ew/perk_visibility.lua b/metamorph_creative_menu/files/integrations/ew/perk_visibility.lua deleted file mode 100644 index 9ce85c5..0000000 --- a/metamorph_creative_menu/files/integrations/ew/perk_visibility.lua +++ /dev/null @@ -1,34 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_EW_PERK_VISIBILITY) == "table" then return METAMORPH_CREATIVE_MENU_EW_PERK_VISIBILITY end - -local perk_visibility = {} -local ew_runtime = dofile("mods/metamorph_creative_menu/files/integrations/ew/runtime.lua") -local HIDDEN_COUNT_PREFIX = "mcm_creative_perk_hidden_count_v1:" - -local function key(perk_id) - return HIDDEN_COUNT_PREFIX .. tostring(perk_id or "") -end - -function perk_visibility.publish(perk_id, count) - if type(perk_id) ~= "string" or perk_id == "" then return false, "invalid" end - if not ew_runtime.enabled() then return true, "singleplayer" end - local value = math.max(0, math.floor(tonumber(count) or 0)) - GlobalsSetValue(key(perk_id), tostring(value)) - return true, "published" -end - -function perk_visibility.refresh(perk_id, player, transactions) - if not ew_runtime.enabled() then return true, "singleplayer" end - if type(transactions) ~= "table" or type(transactions.source_count) ~= "function" then - return false, "source_count_unavailable" - end - local count = transactions.source_count(perk_id, "mcm_creative", player) - return perk_visibility.publish(perk_id, count) -end - -function perk_visibility.hidden_count(perk_id) - if type(GlobalsGetValue) ~= "function" then return 0 end - return math.max(0, tonumber(GlobalsGetValue(key(perk_id), "0")) or 0) -end - -METAMORPH_CREATIVE_MENU_EW_PERK_VISIBILITY = perk_visibility -return perk_visibility diff --git a/metamorph_creative_menu/files/integrations/ew/possession_retire.lua b/metamorph_creative_menu/files/integrations/ew/possession_retire.lua index f1fc5ea..2bccefb 100644 --- a/metamorph_creative_menu/files/integrations/ew/possession_retire.lua +++ b/metamorph_creative_menu/files/integrations/ew/possession_retire.lua @@ -2,96 +2,22 @@ local possession_retire = {} local ew_runtime = dofile("mods/metamorph_creative_menu/files/integrations/ew/runtime.lua") local OUTBOX_SEQUENCE_KEY = "mcm_possession_retire_outbox_seq_v1" -local OUTBOX_ENTITY_KEY = "mcm_possession_retire_outbox_entity_v1" local OUTBOX_PATH_KEY = "mcm_possession_retire_outbox_path_v1" local OUTBOX_X_KEY = "mcm_possession_retire_outbox_x_v1" local OUTBOX_Y_KEY = "mcm_possession_retire_outbox_y_v1" -local OUTBOX_WAIT_KEY = "mcm_possession_retire_outbox_wait_v1" -local OUTBOX_RESPONSIBLE_KEY = "mcm_possession_retire_outbox_responsible_v1" local OUTBOX_ACK_KEY = "mcm_possession_retire_outbox_ack_v1" -local sequence = 0 +local sequence = math.max( + tonumber(GlobalsGetValue(OUTBOX_SEQUENCE_KEY, "0")) or 0, + tonumber(GlobalsGetValue(OUTBOX_ACK_KEY, "0")) or 0 +) local function valid_entity(entity) return entity ~= nil and entity ~= 0 and EntityGetIsAlive(entity) end -local function gid_component(entity) - if not valid_entity(entity) then return 0 end - for _, storage_component in ipairs(EntityGetComponentIncludingDisabled(entity, "VariableStorageComponent") or {}) do - local ok, name = pcall(ComponentGetValue2, storage_component, "name") - if ok and tostring(name or "") == "ew_gid_lid" then return storage_component end - end - return 0 -end - -local function root_of(entity) - if not valid_entity(entity) then return 0 end - if type(EntityGetRootEntity) == "function" then - local ok, root = pcall(EntityGetRootEntity, entity) - root = ok and (tonumber(root) or 0) or 0 - if root ~= 0 and valid_entity(root) then return root end - end - local current, guard = entity, 0 - while valid_entity(current) and guard < 64 and type(EntityGetParent) == "function" do - local ok, parent = pcall(EntityGetParent, current) - parent = ok and (tonumber(parent) or 0) or 0 - if parent == 0 or not valid_entity(parent) then break end - current, guard = parent, guard + 1 - end - return current -end - --- A click can land on a damage-bearing child while EW tracks a wrapper/root. Retiring --- only the child leaves the DES identity alive and the boss is recreated on the next --- network update. Resolve the entity that actually owns ew_gid_lid/ew_des first. -function possession_retire.network_entity(entity) - entity = tonumber(entity) or 0 - if not valid_entity(entity) then return 0 end - - local current, guard = entity, 0 - while valid_entity(current) and guard < 64 do - if gid_component(current) ~= 0 or EntityHasTag(current, "ew_des") then return current end - if type(EntityGetParent) ~= "function" then break end - local ok, parent = pcall(EntityGetParent, current) - parent = ok and (tonumber(parent) or 0) or 0 - if parent == 0 or not valid_entity(parent) then break end - current, guard = parent, guard + 1 - end - - local root = root_of(entity) - if root ~= 0 and type(EntityGetAllChildren) == "function" then - local queue, seen, index = {root}, {}, 1 - while index <= #queue and index <= 512 do - local candidate = queue[index]; index = index + 1 - if valid_entity(candidate) and not seen[candidate] then - seen[candidate] = true - if gid_component(candidate) ~= 0 or EntityHasTag(candidate, "ew_des") then return candidate end - for _, child in ipairs(EntityGetAllChildren(candidate) or {}) do queue[#queue + 1] = child end - end - end - end - return entity -end - -function possession_retire.network_kind(entity) - if not ew_runtime.enabled() then return "local", tonumber(entity) or 0 end - entity = possession_retire.network_entity(entity) - if entity == 0 then return "invalid", 0 end - if gid_component(entity) ~= 0 or EntityHasTag(entity, "ew_des") then return "des", entity end - if EntityHasTag(entity, "ew_replicated") then return "enemy_replica", entity end - return "local", entity -end - -function possession_retire.is_tracked(entity) - local kind = possession_retire.network_kind(entity) - return kind == "des" -end - function possession_retire.is_owned_locally(entity) if not ew_runtime.enabled() then return true end - entity = possession_retire.network_entity(entity) if not valid_entity(entity) then return false end - if EntityHasTag(entity, "ew_replicated") then return false end if ModDoesFileExist("mods/quant.ew/files/resource/util_min.lua") then local loaded, util_min = pcall(dofile, "mods/quant.ew/files/resource/util_min.lua") @@ -100,57 +26,35 @@ function possession_retire.is_owned_locally(entity) if checked then return owned == true end end end - local gid = gid_component(entity) - if gid ~= 0 then - local ok, owned = pcall(ComponentGetValue2, gid, "value_bool") - return ok and owned == true + + -- Unsynced entities have no EW GID and are local by construction. Older/newer EW + -- builds can expose local ownership through value_bool on the ew_gid_lid storage. + for _, storage_component in ipairs(EntityGetComponentIncludingDisabled(entity, "VariableStorageComponent") or {}) do + local name_read, name = pcall(ComponentGetValue2, storage_component, "name") + if name_read and name == "ew_gid_lid" then + local owner_read, owned = pcall(ComponentGetValue2, storage_component, "value_bool") + return owner_read and owned == true + end end return true end -local function event_values(entity, entity_filename, x, y, responsible) - entity = possession_retire.network_entity(entity) - if entity == 0 then return nil end - local path = tostring(EntityGetFilename(entity) or "") - if path == "" then path = tostring(entity_filename or "") end - local ex, ey = EntityGetTransform(entity) - ex, ey = tonumber(ex) or tonumber(x), tonumber(ey) or tonumber(y) - if path == "" or ex == nil or ey == nil then return nil end - return entity, path, ex, ey, tonumber(responsible) or 0 -end - --- Queue DES retirement through the EW extra-module VM and wait for its ACK before the --- main MCM context destroys the local entity tree. The entity id must remain alive until --- ewext.des_death_notify has had a chance to resolve its ew_gid_lid; killing it in the same --- update creates a race where DES keeps the authority and recreates the boss. -function possession_retire.queue_remote(entity, entity_filename, x, y, responsible) - if not ew_runtime.enabled() then return false, "singleplayer" end - local target, path, ex, ey, responsible_id = event_values(entity, entity_filename, x, y, responsible) - if target == nil then return false, "invalid" end - local kind = possession_retire.network_kind(target) - if kind ~= "des" then return false, kind == "enemy_replica" and "enemy_sync_replica" or "untracked", target end +function possession_retire.queue_remote(entity_filename, x, y) + if not ew_runtime.enabled() then return false end + if type(entity_filename) ~= "string" or entity_filename == "" or x == nil or y == nil then return false end - sequence = math.max(sequence, - tonumber(GlobalsGetValue(OUTBOX_SEQUENCE_KEY, "0")) or 0, - tonumber(GlobalsGetValue(OUTBOX_ACK_KEY, "0")) or 0) + 1 + sequence = sequence + 1 local suffix = "_" .. tostring(sequence) - local values = { - [OUTBOX_ENTITY_KEY]=tostring(target), [OUTBOX_PATH_KEY]=path, - [OUTBOX_X_KEY]=tostring(ex), [OUTBOX_Y_KEY]=tostring(ey), - [OUTBOX_WAIT_KEY]="0", [OUTBOX_RESPONSIBLE_KEY]=tostring(responsible_id), - } - for key, value in pairs(values) do - GlobalsSetValue(key, value) - GlobalsSetValue(key .. suffix, value) - end + -- Keep latest values for diagnostics/backwards compatibility and sequence-scoped + -- values for lossless processing of several possessions in quick succession. + GlobalsSetValue(OUTBOX_PATH_KEY, entity_filename) + GlobalsSetValue(OUTBOX_X_KEY, tostring(x)) + GlobalsSetValue(OUTBOX_Y_KEY, tostring(y)) + GlobalsSetValue(OUTBOX_PATH_KEY .. suffix, entity_filename) + GlobalsSetValue(OUTBOX_X_KEY .. suffix, tostring(x)) + GlobalsSetValue(OUTBOX_Y_KEY .. suffix, tostring(y)) GlobalsSetValue(OUTBOX_SEQUENCE_KEY, tostring(sequence)) - return true, "queued", target, sequence -end - -function possession_retire.acknowledged(request_sequence) - request_sequence = tonumber(request_sequence) - if request_sequence == nil then return false end - return (tonumber(GlobalsGetValue(OUTBOX_ACK_KEY, "0")) or 0) >= request_sequence + return true end return possession_retire diff --git a/metamorph_creative_menu/files/integrations/ew/resilience.lua b/metamorph_creative_menu/files/integrations/ew/resilience.lua index 3f2b2a4..43a528b 100644 --- a/metamorph_creative_menu/files/integrations/ew/resilience.lua +++ b/metamorph_creative_menu/files/integrations/ew/resilience.lua @@ -1,7 +1,5 @@ local ew_resilience = {} -local global_text = dofile("mods/metamorph_creative_menu/files/core/global_text.lua") - local SEEDED_RANDOM_FILES = { "data/scripts/gun/procedural/wand_petri.lua", "data/scripts/gun/procedural/chargegun.lua", @@ -27,24 +25,27 @@ local SEEDED_RANDOM_FILES = { "data/scripts/items/potion_starting.lua", } --- CrossCall wrappers must not be prepended to EW core modules because EW replaces those --- handlers while systems initialize. Only the specific external helper patched below uses --- the conservative missing-handler fallback. +-- These files execute outside EW's main module context (LuaComponents, appended +-- vanilla callbacks and entity scripts). EW leaves a Lua fallback named CrossCall +-- installed even when the matching optional system is disabled; that fallback then +-- indexes a missing callback and can generate thousands of errors followed by a Lua +-- stack overflow. Keep the call when registered and provide conservative semantics +-- only for the missing-handler case. +-- Never prepend a lexical CrossCall wrapper into Entangled Worlds itself. EW installs +-- and replaces CrossCall handlers as systems initialize; capturing a nil/string value at +-- file-load time poisoned the stable inventory API and produced the observed +-- ew_ff / ew_api_force_send_inventory failures. Keep the helper patcher below only for +-- explicit future compatibility use, but do not apply it to EW core/modules. +local EW_CROSSCALL_FILES = {} + local resilience_patches = dofile("mods/metamorph_creative_menu/files/integrations/ew/resilience_patches.lua") -local DEV_MODE = tonumber(dofile("mods/metamorph_creative_menu/dev_mode.lua")) == 1 -- Compatibility exports: callers that used the old resilience.patch_* API keep working, -- while the implementation now lives in the dedicated pure patch module. ew_resilience.patch_crosscall_source = resilience_patches.patch_crosscall_source -ew_resilience.patch_wand_pickup_killself_source = resilience_patches.patch_wand_pickup_killself_source -ew_resilience.patch_kolmi_boss_update_source = resilience_patches.patch_kolmi_boss_update_source -ew_resilience.patch_kolmi_spawn_source = resilience_patches.patch_kolmi_spawn_source ew_resilience.patch_seed_source = resilience_patches.patch_seed_source ew_resilience.patch_detour_source = resilience_patches.patch_detour_source ew_resilience.patch_world_sync_source = resilience_patches.patch_world_sync_source -ew_resilience.patch_polymorph_death_source = resilience_patches.patch_polymorph_death_source -ew_resilience.patch_polymorph_profile_source = resilience_patches.patch_polymorph_profile_source -ew_resilience.patch_material_pixel_scene_source = resilience_patches.patch_material_pixel_scene_source ew_resilience.patch_peer_perk_isolation_source = resilience_patches.patch_peer_perk_isolation_source ew_resilience.patch_perk_helper_sync_source = resilience_patches.patch_perk_helper_sync_source ew_resilience.patch_perk_mutation_sync_source = resilience_patches.patch_perk_mutation_sync_source @@ -63,27 +64,15 @@ local function patch_file(path, patcher) end local CRITICAL_PATCHES = { - form_death = { - path = "mods/quant.ew/files/system/polymorph/polymorph.lua", - marker = "mcm_poly_death_intercept_v1", - patcher = ew_resilience.patch_polymorph_death_source, - global_key = "mcm_compat_form_death_patch_v1", - }, world_sync = { path = "mods/quant.ew/files/system/world_sync/world_sync.lua", - marker = "mcm_poly_world_sync_v5", - patcher = function(content) return ew_resilience.patch_world_sync_source(content, DEV_MODE) end, + marker = "mcm_poly_world_sync_v3", + patcher = ew_resilience.patch_world_sync_source, global_key = "mcm_compat_world_sync_patch_v1", }, - material_scenes = { - path = "mods/quant.ew/files/system/wang_hooks/synced_pixel_scenes.lua", - marker = "mcm_material_brush_pixel_scene_v1", - patcher = ew_resilience.patch_material_pixel_scene_source, - global_key = "mcm_compat_material_scene_patch_v1", - }, peer_perks = { path = "mods/quant.ew/files/core/perk_fns.lua", - marker = "mcm_peer_perk_sync_v4", + marker = "mcm_peer_perk_sync_v3", patcher = ew_resilience.patch_peer_perk_isolation_source, global_key = "mcm_compat_peer_perk_patch_v1", }, @@ -101,48 +90,17 @@ local CRITICAL_PATCHES = { }, } -local critical_patch_status = {form_death="unknown", world_sync="unknown", material_scenes="unknown", peer_perks="unknown", perk_helpers="unknown", perk_mutations="unknown"} -local surfaced_patch_failures = {} - -local function translated(key, fallback) - if type(GameTextGetTranslatedOrNot) == "function" then - local ok, value = pcall(GameTextGetTranslatedOrNot, key) - if ok and type(value) == "string" and value ~= "" and value ~= key then return value end - end - return fallback -end +local critical_patch_status = {world_sync="unknown", peer_perks="unknown", perk_helpers="unknown", perk_mutations="unknown"} local function publish_patch_status(name, status) critical_patch_status[name] = status local spec = CRITICAL_PATCHES[name] if spec ~= nil and type(GlobalsSetValue) == "function" then - pcall(GlobalsSetValue, spec.global_key, global_text.encode_diagnostic(status)) + pcall(GlobalsSetValue, spec.global_key, tostring(status)) end return status end -local function surface_critical_patch_failures() - for name, status in pairs(critical_patch_status) do - if status ~= "applied" and status ~= "already_present" and status ~= "disabled" then - local signature = tostring(name) .. ":" .. tostring(status) - if not surfaced_patch_failures[signature] then - surfaced_patch_failures[signature] = true - local diagnostic_message = "[Metamorph: Creative Menu] Entangled Worlds compatibility patch failed: " - .. tostring(name) .. " (" .. tostring(status) .. ")" - local user_message = translated("$mcm_ew_compat_failed", - "Entangled Worlds compatibility error") .. ": " - .. tostring(name) .. " (" .. tostring(status) .. ")" - print(diagnostic_message) - if type(GamePrint) == "function" then pcall(GamePrint, user_message) end - if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, - "ew.compatibility." .. tostring(name), tostring(status)) - end - end - end - end -end - local function apply_verified_patch(name) local spec = CRITICAL_PATCHES[name] if spec == nil then return "unknown" end @@ -169,25 +127,6 @@ local function patch_world_sync_file() return apply_verified_patch("world_sync") end -local function patch_polymorph_profile_file() - local path = "mods/quant.ew/files/system/polymorph/polymorph.lua" - local function finish(status) - if type(GlobalsSetValue) == "function" then - pcall(GlobalsSetValue, "mcm_compat_poly_profile_patch_v1", global_text.encode_diagnostic(status)) - end - return status - end - local ok, content = pcall(ModTextFileGetContent, path) - if not ok or type(content) ~= "string" or content == "" then return finish("read_failed") end - if string.find(content, "mcm_poly_profile_v1", 1, true) ~= nil then return finish("already_present") end - local patch_ok, changed, count = pcall(ew_resilience.patch_polymorph_profile_source, content) - if not patch_ok or type(changed) ~= "string" or (tonumber(count) or 0) <= 0 - or string.find(changed, "mcm_poly_profile_v1", 1, true) == nil - then return finish("anchor_mismatch") end - if not pcall(ModTextFileSetContent, path, changed) then return finish("write_failed") end - return finish("applied") -end - local function patch_peer_perk_isolation_file() return apply_verified_patch("peer_perks") end @@ -198,9 +137,7 @@ end function ew_resilience.critical_patch_status() return { - form_death=critical_patch_status.form_death, world_sync=critical_patch_status.world_sync, - material_scenes=critical_patch_status.material_scenes, peer_perks=critical_patch_status.peer_perks, perk_helpers=critical_patch_status.perk_helpers, perk_mutations=critical_patch_status.perk_mutations, @@ -250,85 +187,43 @@ end local function patch_crosscall_files() local count = 0 - -- This helper executes in an entity Lua VM. If a save resumes before EW's - -- ew_is_wand_pickup callback is registered, the unguarded line survives forever - -- and throws once per frame. Conservative failure semantics are to retire the - -- temporary helper, exactly as the normal false result does. - count = count + patch_file( - "mods/quant.ew/files/system/entity_sync_helper/scripts/killself.lua", - ew_resilience.patch_wand_pickup_killself_source) - return count -end - -local function patch_kolmi_files() - local count = 0 - count = count + patch_file( - "mods/quant.ew/files/system/kolmi/append/boss_update.lua", - ew_resilience.patch_kolmi_boss_update_source) - count = count + patch_file( - "mods/quant.ew/files/system/kolmi/append/spawn_kolmi.lua", - ew_resilience.patch_kolmi_spawn_source) + for _, relative in ipairs(EW_CROSSCALL_FILES) do + count = count + patch_file("mods/quant.ew/" .. relative, ew_resilience.patch_crosscall_source) + end return count end function ew_resilience.pre_init() if not enabled() then - publish_patch_status("form_death", "disabled") publish_patch_status("world_sync", "disabled") - publish_patch_status("material_scenes", "disabled") publish_patch_status("peer_perks", "disabled") publish_patch_status("perk_helpers", "disabled") publish_patch_status("perk_mutations", "disabled") - if type(GlobalsSetValue) == "function" then - pcall(GlobalsSetValue, "mcm_compat_poly_profile_patch_v1", "disabled") - end return 0 end - apply_verified_patch("form_death") patch_world_sync_file() - if DEV_MODE then - patch_polymorph_profile_file() - elseif type(GlobalsSetValue) == "function" then - pcall(GlobalsSetValue, "mcm_compat_poly_profile_patch_v1", "disabled") - end - apply_verified_patch("material_scenes") patch_peer_perk_isolation_file() patch_perk_helper_sync_file() apply_verified_patch("perk_mutations") - patch_kolmi_files() return patch_seed_files(), patch_crosscall_files() end function ew_resilience.post_init() if not enabled() then - publish_patch_status("form_death", "disabled") publish_patch_status("world_sync", "disabled") - publish_patch_status("material_scenes", "disabled") publish_patch_status("peer_perks", "disabled") publish_patch_status("perk_helpers", "disabled") publish_patch_status("perk_mutations", "disabled") - if type(GlobalsSetValue) == "function" then - pcall(GlobalsSetValue, "mcm_compat_poly_profile_patch_v1", "disabled") - end return 0, 0 end local seeds = patch_seed_files() local detours = 0 for _, path in ipairs(biome_scripts()) do detours = detours + patch_file(path, ew_resilience.patch_detour_source) end local crosscalls = patch_crosscall_files() - apply_verified_patch("form_death") patch_world_sync_file() - if DEV_MODE then - patch_polymorph_profile_file() - elseif type(GlobalsSetValue) == "function" then - pcall(GlobalsSetValue, "mcm_compat_poly_profile_patch_v1", "disabled") - end - apply_verified_patch("material_scenes") patch_peer_perk_isolation_file() patch_perk_helper_sync_file() apply_verified_patch("perk_mutations") - patch_kolmi_files() - surface_critical_patch_failures() return seeds, detours, crosscalls end diff --git a/metamorph_creative_menu/files/integrations/ew/resilience_patches.lua b/metamorph_creative_menu/files/integrations/ew/resilience_patches.lua index 4289f57..bb41564 100644 --- a/metamorph_creative_menu/files/integrations/ew/resilience_patches.lua +++ b/metamorph_creative_menu/files/integrations/ew/resilience_patches.lua @@ -1,18 +1,5 @@ local resilience_patches = {} -function resilience_patches.patch_wand_pickup_killself_source(content) - if type(content) ~= "string" or string.find(content, "mcm_wand_pickup_crosscall_guard_v1", 1, true) ~= nil then - return content, 0 - end - local anchor = 'if not CrossCall("ew_is_wand_pickup") then' - local first, last = string.find(content, anchor, 1, true) - if first == nil then return content, 0 end - local replacement = [[-- mcm_wand_pickup_crosscall_guard_v1 -local mcm_pickup_ok, mcm_is_wand_pickup = pcall(CrossCall, "ew_is_wand_pickup") -if not mcm_pickup_ok or not mcm_is_wand_pickup then]] - return string.sub(content, 1, first - 1) .. replacement .. string.sub(content, last + 1), 1 -end - function resilience_patches.patch_crosscall_source(content) if type(content) ~= "string" or string.find(content, "CrossCall", 1, true) == nil or string.find(content, "mcm_safe_crosscall_v1", 1, true) ~= nil @@ -44,9 +31,6 @@ local function CrossCall(name, ...) local mcm_signature = tostring(name) .. "|" .. tostring(a) if not mcm_crosscall_error_seen[mcm_signature] then mcm_crosscall_error_seen[mcm_signature] = true - if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "ew.crosscall." .. tostring(name), tostring(a)) - end print("[Metamorph: Creative Menu] unexpected EW CrossCall failure " .. tostring(name) .. ": " .. tostring(a)) end return nil @@ -55,65 +39,6 @@ end return guard .. content, 1 end - -function resilience_patches.patch_kolmi_boss_update_source(content) - if type(content) ~= "string" or content == "" then return content, 0 end - if string.find(content, "mcm_kolmi_crosscall_failopen_v1", 1, true) ~= nil then return content, 0 end - if string.find(content, 'CrossCall("ew_kolmi_', 1, true) == nil then return content, 0 end - local helper = [[-- mcm_kolmi_crosscall_failopen_v1 --- EW's Kolmi append is transport glue layered on top of the vanilla boss coroutine. --- A transport failure must never abort that coroutine: otherwise attacks, phase changes, --- minion spawning and the final kill_now all stop together. Prefer the normal entity-VM --- CrossCall registry, then the published NoitaPatcher bridge when this Lua state has it. -local mcm_kolmi_crosscall_error_seen = {} -local function mcm_kolmi_crosscall(name, ...) - local ok, a, b, c, d = false, nil, nil, nil, nil - if type(CrossCall) == "function" then - ok, a, b, c, d = pcall(CrossCall, name, ...) - if ok then return true, a, b, c, d end - end - if type(np) == "table" and type(np.CrossCall) == "function" then - ok, a, b, c, d = pcall(np.CrossCall, name, ...) - if ok then return true, a, b, c, d end - end - local signature = tostring(name) .. "|" .. tostring(a) - if not mcm_kolmi_crosscall_error_seen[signature] then - mcm_kolmi_crosscall_error_seen[signature] = true - if type(GlobalsSetValue) == "function" then - pcall(GlobalsSetValue, "mcm25_kolmi_crosscall_failure_v1", signature) - end - print("[Metamorph: Creative Menu] Kolmisilma EW CrossCall failed open: " .. signature) - end - return false, a -end -]] - local changed, count = string.gsub(content, "CrossCall%(", "mcm_kolmi_crosscall(") - if count <= 0 then return content, 0 end - return helper .. changed, count -end - -function resilience_patches.patch_kolmi_spawn_source(content) - if type(content) ~= "string" or content == "" then return content, 0 end - if string.find(content, "mcm_kolmi_spawn_crosscall_failopen_v1", 1, true) ~= nil then return content, 0 end - local before = ' CrossCall("ew_spawn_kolmi", ComponentGetValue2(gid, "value_string"))' - local first, last = string.find(content, before, 1, true) - if first == nil then return content, 0 end - local after = [[ -- mcm_kolmi_spawn_crosscall_failopen_v1 - local mcm_ok = false - if type(CrossCall) == "function" then - mcm_ok = pcall(CrossCall, "ew_spawn_kolmi", ComponentGetValue2(gid, "value_string")) - end - -- If the native cross-VM registry is split/unavailable, fail open into the - -- vanilla pickup path. A missed network convenience call is preferable to a - -- permanently inert final boss encounter. - if not mcm_ok then old(ent) end]] - local patched = string.sub(content, 1, first - 1) .. after .. string.sub(content, last + 1) - -- Be robust even if the target Lua file has no final newline and the engine's append - -- implementation concatenates bytes directly rather than inserting a separator. - if string.sub(patched, 1, 1) ~= "\n" then patched = "\n" .. patched end - return patched, 1 -end - function resilience_patches.patch_seed_source(content) if type(content) ~= "string" then return content, 0 end local replacement = [[local ew_seed_ok, sx, sy = pcall(CrossCall, "ew_per_peer_seed") @@ -132,13 +57,11 @@ function resilience_patches.patch_detour_source(content) end) end -function resilience_patches.patch_world_sync_source(content, metrics_enabled) - if type(content) ~= "string" or string.find(content, "mcm_poly_world_sync_v5", 1, true) ~= nil then +function resilience_patches.patch_world_sync_source(content) + if type(content) ~= "string" or string.find(content, "mcm_poly_world_sync_v3", 1, true) ~= nil then return content, 0 end local original = content - content = string.gsub(content, "\r\n", "\n") - content = string.gsub(content, "\r", "\n") local changed = 0 local function replace_once(source, before, after) local first, last = string.find(source, before, 1, true) @@ -146,23 +69,20 @@ function resilience_patches.patch_world_sync_source(content, metrics_enabled) return string.sub(source, 1, first - 1) .. after .. string.sub(source, last + 1), true end - -- Keep MCM's intended fast-form world-sync integration, but do not create a second - -- persistent 3x3 trail scheduler. EW's world frames and DES traffic share the same - -- netmanager connection; the old trail queue could dominate that connection while a - -- large worm/dragon form continuously destroys terrain. That starves entity death / - -- DeleteEntity traffic and also explains the severe lag seen by the observing peer. + -- State is deliberately tiny/bounded. It records chunks a fast polymorphed player + -- has just left so destructive worm/dragon movement cannot outrun the normal EW + -- camera-centred scheduler. local state_anchor = "local iter_slow_2 = 0" - local metrics_literal = metrics_enabled == true and "true" or "false" local state_block = [[local iter_slow_2 = 0 --- mcm_poly_world_sync_v5 -local EWCM_METRICS_ENABLED = ]] .. metrics_literal .. [[ +-- mcm_poly_world_sync_v3 +local mcm_trail_queue = {} +local mcm_trail_seen = {} +local mcm_last_poly_cx, mcm_last_poly_cy = nil, nil local mcm_sent_chunks, mcm_sent_bytes = 0, 0 local mcm_recv_chunks, mcm_recv_bytes = 0, 0 -local mcm_poly_scheduler_calls = 0 -local mcm_poly_scheduler_paused = 0 -local mcm_last_poly_cx, mcm_last_poly_cy = nil, nil -local MCM_BOSS_DEATH_PRESSURE_KEY = "mcm_ew_boss_death_pressure_until_v1"]] +local mcm_trail_sent = 0 +local EWCM_TRAIL_LIMIT = 96]] local ok content, ok = replace_once(content, state_anchor, state_block) if not ok then return original, 0 end @@ -174,45 +94,51 @@ local MCM_BOSS_DEATH_PRESSURE_KEY = "mcm_ew_boss_death_pressure_until_v1"]] end local int = 4 -- ctx.proxy_opt.world_sync_interval]] local send_block = [[ net.proxy_bin_send(KEY_WORLD_FRAME, str) - if EWCM_METRICS_ENABLED then - mcm_sent_chunks = mcm_sent_chunks + 1 - mcm_sent_bytes = mcm_sent_bytes + #str - end + mcm_sent_chunks = mcm_sent_chunks + 1 + mcm_sent_bytes = mcm_sent_bytes + #str end end end -local function mcm_boss_death_pressure_active() - if type(GlobalsGetValue) ~= "function" then return false end - local until_frame = tonumber(GlobalsGetValue(MCM_BOSS_DEATH_PRESSURE_KEY, "-1")) or -1 - return until_frame >= GameGetFrameNum() +local function mcm_enqueue_trail(cx, cy) + local key = tostring(cx) .. ":" .. tostring(cy) + if mcm_trail_seen[key] then return end + while #mcm_trail_queue >= EWCM_TRAIL_LIMIT do + local old = table.remove(mcm_trail_queue, 1) + if old ~= nil then mcm_trail_seen[tostring(old[1]) .. ":" .. tostring(old[2])] = nil end + end + mcm_trail_seen[key] = true + mcm_trail_queue[#mcm_trail_queue + 1] = { cx, cy } end -local function mcm_heavy_polymorph_active() - local entity = ctx.my_player ~= nil and ctx.my_player.entity or 0 - if entity == nil or entity == 0 or not EntityHasTag(entity, "polymorphed_player") then return false end - -- Giant articulated/destructive forms are exactly the forms for which MCM's extra - -- camera-independent chunk scheduler can outrun the network. EW still performs its - -- normal world synchronization; we only suppress MCM's additional fast-form path. - return EntityGetFirstComponentIncludingDisabled(entity, "BossDragonComponent") ~= nil - or EntityGetFirstComponentIncludingDisabled(entity, "WormComponent") ~= nil - or EntityGetFirstComponentIncludingDisabled(entity, "BossHealthBarComponent") ~= nil - or EntityGetFirstComponentIncludingDisabled(entity, "StreamingKeepAliveComponent") ~= nil +local function mcm_note_poly_chunk(cx, cy) + if mcm_last_poly_cx ~= nil and (cx ~= mcm_last_poly_cx or cy ~= mcm_last_poly_cy) then + for oy = -1, 1 do + for ox = -1, 1 do mcm_enqueue_trail(mcm_last_poly_cx + ox, mcm_last_poly_cy + oy) end + end + end + mcm_last_poly_cx, mcm_last_poly_cy = cx, cy +end + +local function mcm_drain_trail() + -- One full authoritative chunk every other frame is enough to guarantee eventual + -- convergence without turning a fast worm into a bandwidth DoS. + if #mcm_trail_queue == 0 or GameGetFrameNum() % 2 ~= 1 then return end + local item = table.remove(mcm_trail_queue, 1) + if item == nil then return end + mcm_trail_seen[tostring(item[1]) .. ":" .. tostring(item[2])] = nil + send_chunks(item[1], item[2]) + mcm_trail_sent = mcm_trail_sent + 1 end local function mcm_publish_world_metrics() - if not EWCM_METRICS_ENABLED or GameGetFrameNum() % 60 ~= 0 then return end + if GameGetFrameNum() % 60 ~= 0 then return end GlobalsSetValue("mcm_world_sync_sent_chunks_v1", tostring(mcm_sent_chunks)) GlobalsSetValue("mcm_world_sync_sent_bytes_v1", tostring(mcm_sent_bytes)) GlobalsSetValue("mcm_world_sync_recv_chunks_v1", tostring(mcm_recv_chunks)) GlobalsSetValue("mcm_world_sync_recv_bytes_v1", tostring(mcm_recv_bytes)) - -- Keep the historical keys so existing diagnostics remain readable. v4 has no - -- persistent trail queue by design, therefore backlog/sent are always zero. - GlobalsSetValue("mcm_world_sync_trail_backlog_v1", "0") - GlobalsSetValue("mcm_world_sync_trail_sent_v1", "0") - GlobalsSetValue("mcm_world_sync_trail_dropped_v1", "0") - GlobalsSetValue("mcm_world_sync_poly_scheduler_calls_v1", tostring(mcm_poly_scheduler_calls)) - GlobalsSetValue("mcm_world_sync_poly_scheduler_paused_v1", tostring(mcm_poly_scheduler_paused)) + GlobalsSetValue("mcm_world_sync_trail_backlog_v1", tostring(#mcm_trail_queue)) + GlobalsSetValue("mcm_world_sync_trail_sent_v1", tostring(mcm_trail_sent)) GlobalsSetValue("mcm_world_sync_last_poly_chunk_v1", tostring(mcm_last_poly_cx or "") .. ":" .. tostring(mcm_last_poly_cy or "")) end @@ -225,7 +151,7 @@ local int = 4 -- ctx.proxy_opt.world_sync_interval]] local n = 0]] local chunk_block = [[ local ocx, ocy = math.floor(px / CHUNK_SIZE), math.floor(py / CHUNK_SIZE) if EntityHasTag(ctx.my_player.entity, "polymorphed_player") then - mcm_last_poly_cx, mcm_last_poly_cy = ocx, ocy + mcm_note_poly_chunk(ocx, ocy) else mcm_last_poly_cx, mcm_last_poly_cy = nil, nil end @@ -243,15 +169,21 @@ local int = 4 -- ctx.proxy_opt.world_sync_interval]] else wait = GameGetFrameNum() + 30 end]] - local old_v3_far = [[ if ctx.spectating_over_peer_id ~= nil and ctx.spectating_over_peer_id ~= ctx.my_id then + local v2_far = [[ if ctx.spectating_over_peer_id ~= nil and ctx.spectating_over_peer_id ~= ctx.my_id then if GameGetFrameNum() % 3 ~= 2 then get_all_chunks(cx, cy, pos_data, 16, false) else get_all_chunks(ocx, ocy, pos_data, 16, true) end elseif EntityHasTag(ctx.my_player.entity, "polymorphed_player") then - -- Keep upstream's own per-frame ring scheduler centred on the actual form. - -- The trail queue below separately guarantees delivery of chunks we left. + -- mcm_fast_poly_world_sync_v2 + -- Fast playable worms/dragons can outrun the camera by several chunks. The + -- upstream pauses world sync for 30 frames in that case. An earlier patch tried to + -- compensate by calling get_all_chunks only on frame%%int==0, which meant + -- only the central chunk was ever emitted: get_all_chunks's neighbour-ring + -- phases happen on other frame residues. Call the normal budgeted scheduler + -- every frame around the *player* instead. It still sends only the chunks + -- EW normally budgets for that frame, while destruction cannot outrun sync. get_all_chunks(ocx, ocy, pos_data, 0, true) else wait = GameGetFrameNum() + 30 @@ -263,32 +195,14 @@ local int = 4 -- ctx.proxy_opt.world_sync_interval]] get_all_chunks(ocx, ocy, pos_data, 16, true) end elseif EntityHasTag(ctx.my_player.entity, "polymorphed_player") then - -- Fast playable forms can outrun the camera. Keep exactly ONE instance of - -- EW's normal ring scheduler centred on the form, rather than an additional - -- persistent trail queue. During a boss-death pressure window, yield these - -- optional world frames briefly so DES DeleteEntity/authority traffic wins. - if mcm_boss_death_pressure_active() or mcm_heavy_polymorph_active() then - -- Do not generate an MCM-owned terrain stream for giant boss/worm forms. - -- Their destruction volume can produce data faster than the observer can - -- consume it, which manifests as lag that keeps growing until unpolymorph. - mcm_poly_scheduler_paused = mcm_poly_scheduler_paused + 1 - wait = GameGetFrameNum() + 30 - else - -- Only the central chunk (frame %% int == 0) and the immediate 3x3 ring - -- (frame %% int == 2) are forced while the form outruns the camera. The - -- radius-2/radius-3 phases are intentionally skipped here; they were the - -- dominant bandwidth multiplier for huge destructive boss forms. - local phase = GameGetFrameNum() % int - if phase == 0 or phase == 2 then - mcm_poly_scheduler_calls = mcm_poly_scheduler_calls + 1 - get_all_chunks(ocx, ocy, pos_data, 0, true) - end - end + -- Keep upstream's own per-frame ring scheduler centred on the actual form. + -- The trail queue below separately guarantees delivery of chunks we left. + get_all_chunks(ocx, ocy, pos_data, 0, true) else wait = GameGetFrameNum() + 30 end]] content, ok = replace_once(content, upstream_far, far_block) - if not ok then content, ok = replace_once(content, old_v3_far, far_block) end + if not ok then content, ok = replace_once(content, v2_far, far_block) end if not ok then return original, 0 end changed = changed + 1 @@ -296,31 +210,23 @@ local int = 4 -- ctx.proxy_opt.world_sync_interval]] end local PixelRun_const_ptr]] - local old_v3_tail = [[ end + local tail_block = [[ end -- Keep draining after returning to human. Otherwise the last destroyed chunks of -- a fast form could remain queued forever at the exact moment the form ends. mcm_drain_trail() mcm_publish_world_metrics() end -local PixelRun_const_ptr]] - local tail_block = [[ end - mcm_publish_world_metrics() -end - local PixelRun_const_ptr]] content, ok = replace_once(content, tail_anchor, tail_block) - if not ok then content, ok = replace_once(content, old_v3_tail, tail_block) end if not ok then return original, 0 end changed = changed + 1 local recv_anchor = [[function world_sync.handle_world_data(datum) local grid_world = world_ffi.get_grid_world()]] local recv_block = [[function world_sync.handle_world_data(datum) - if EWCM_METRICS_ENABLED then - mcm_recv_chunks = mcm_recv_chunks + 1 - if type(datum) == "string" then mcm_recv_bytes = mcm_recv_bytes + #datum end - end + mcm_recv_chunks = mcm_recv_chunks + 1 + if type(datum) == "string" then mcm_recv_bytes = mcm_recv_bytes + #datum end local grid_world = world_ffi.get_grid_world()]] content, ok = replace_once(content, recv_anchor, recv_block) if not ok then return original, 0 end @@ -328,267 +234,12 @@ local PixelRun_const_ptr]] return content, changed end - -function resilience_patches.patch_entity_sync_death_source(content) - if type(content) ~= "string" or string.find(content, "mcm_immediate_des_death_v1", 1, true) ~= nil then - return content, 0 - end - local anchor = [[util.add_cross_call("ew_death_notify", function(entity, wait_on_kill, x, y, file, responsible) - table.insert(dead, { entity, wait_on_kill, x, y, file, responsible }) -end)]] - local block = [[util.add_cross_call("ew_death_notify", function(entity, wait_on_kill, x, y, file, responsible) - -- mcm_immediate_des_death_v1 - -- Stock EW queues this CrossCall until on_world_update_post. Global bosses are - -- authority-transfer entities, so only for BossHealthBar/StreamingKeepAlive roots - -- make the native DES death decision while the dying entity still has ew_gid_lid. - -- Ordinary enemies retain EW's stock deferred path. If the native API throws, - -- preserve EW's original deferred queue exactly as a compatibility fallback. - local submitted = false - local is_global_boss = EntityGetFirstComponentIncludingDisabled(entity, "BossHealthBarComponent") ~= nil - or EntityGetFirstComponentIncludingDisabled(entity, "StreamingKeepAliveComponent") ~= nil - if is_global_boss and type(ewext) == "table" and type(ewext.des_death_notify) == "function" then - submitted = pcall(ewext.des_death_notify, entity, wait_on_kill, x, y, file, responsible) - end - if not submitted then - table.insert(dead, { entity, wait_on_kill, x, y, file, responsible }) - end -end)]] - local first, last = string.find(content, anchor, 1, true) - if first == nil then return content, 0 end - return string.sub(content, 1, first - 1) .. block .. string.sub(content, last + 1), 1 -end - -function resilience_patches.patch_boss_death_pressure_source(content) - if type(content) ~= "string" or string.find(content, "mcm_boss_death_pressure_v1", 1, true) ~= nil then - return content, 0 - end - local anchor = [[ local ent = GetUpdatedEntityID() - local x, y = EntityGetTransform(ent)]] - local block = [[ local ent = GetUpdatedEntityID() - -- mcm_boss_death_pressure_v1 - -- This does NOT change EW death semantics. It only gives MCM's optional fast-form - -- world-sync traffic a short back-pressure hint so DES deletion/authority messages - -- are not queued behind large pixel-world frames when any global boss dies. - if EntityGetFirstComponentIncludingDisabled(ent, "BossHealthBarComponent") ~= nil - or EntityGetFirstComponentIncludingDisabled(ent, "StreamingKeepAliveComponent") ~= nil - then - local frame = type(GameGetFrameNum) == "function" and GameGetFrameNum() or 0 - GlobalsSetValue("mcm_ew_boss_death_pressure_until_v1", tostring((tonumber(frame) or 0) + 12)) - end - local x, y = EntityGetTransform(ent)]] - local first, last = string.find(content, anchor, 1, true) - if first == nil then return content, 0 end - return string.sub(content, 1, first - 1) .. block .. string.sub(content, last + 1), 1 -end - -local POLYMORPH_DEATH_ANCHOR = [[ if has_hp_component and hp <= 0 and not gameover_requested then - ctx.cap.health.on_poly_death() - gameover_requested = true - end]] - -local POLYMORPH_DEATH_BLOCK = [[ if has_hp_component and hp <= 0 and not gameover_requested then - -- mcm_poly_death_intercept_v1: a creative MCM form death is a form-lifecycle - -- transition, not an EW player death. Give MCM one synchronous chance to - -- commit its serialized human backup before local_health/shared-health can - -- create notplayer or trigger game over. Ordinary EW polymorphs keep the - -- exact upstream path when the acknowledgement is absent. - local mcm_old_entity = ctx.my_player.entity - local function mcm_death_ack_matches() - if type(GlobalsGetValue) ~= "function" then return false end - local ok_ack, ack = pcall(GlobalsGetValue, "mcm_form_death_intercept_ack_v1", "") - if not ok_ack then return false end - local ack_entity, ack_frame = tostring(ack or ""):match("^(%-?%d+):(%-?%d+)$") - if tonumber(ack_entity) ~= tonumber(mcm_old_entity) then return false end - if type(GameGetFrameNum) ~= "function" then return true end - local ok_frame, frame = pcall(GameGetFrameNum) - if not ok_frame then return true end - return math.abs((tonumber(frame) or 0) - (tonumber(ack_frame) or -100000)) <= 2 - end - -- The entity-side death() callback may have run earlier in this same frame. - -- Accept that recent committed acknowledgement even though corpse handoff has - -- already removed the MCM player-form tag from the old body. - local mcm_poly_death_handled = mcm_death_ack_matches() - -- EW replaces the global CrossCall in its own VM with a private local - -- callback table. MCM's handler lives in the mod VM, so the synchronous - -- fallback must use NoitaPatcher's native cross-VM entry point when present. - local mcm_cross_call = type(np) == "table" and type(np.CrossCall) == "function" - and np.CrossCall or CrossCall - if not mcm_poly_death_handled - and type(EntityHasTag) == "function" - and EntityHasTag(mcm_old_entity, "metamorph_creative_menu_network_form") - and type(mcm_cross_call) == "function" - then - if type(GlobalsSetValue) == "function" then - pcall(GlobalsSetValue, "mcm_form_death_intercept_ack_v1", "") - end - pcall(mcm_cross_call, "metamorph_creative_menu_form_died", mcm_old_entity, - "ew_poly_death", 0, nil, 0) - mcm_poly_death_handled = mcm_death_ack_matches() - end - if not mcm_poly_death_handled then - ctx.cap.health.on_poly_death() - gameover_requested = true - end - end]] - --- Keep the player-death guard independently patchable from optional profiling. A future --- EW revision may change serialization internals while retaining the same health path; --- losing metrics must never silently re-enable notplayer for an MCM creative form. -function resilience_patches.patch_polymorph_death_source(content) - if type(content) ~= "string" or string.find(content, "mcm_poly_death_intercept_v1", 1, true) ~= nil then - return content, 0 - end - local first, last = string.find(content, POLYMORPH_DEATH_ANCHOR, 1, true) - if first == nil then return content, 0 end - return string.sub(content, 1, first - 1) .. POLYMORPH_DEATH_BLOCK .. string.sub(content, last + 1), 1 -end - --- Instrument only EW's polymorph entity replacement path. This is intentionally not --- installed in util.serialize_entity()/deserialize_entity(), because those helpers are also --- hot paths for projectiles and world items. The measurements below are one-shot per --- polymorph replacement and do not alter the serialized payload. -function resilience_patches.patch_polymorph_profile_source(content) - if type(content) ~= "string" or string.find(content, "mcm_poly_profile_v1", 1, true) ~= nil then - return content, 0 - end - local original = content - local function replace_once(source, before, after) - local first, last = string.find(source, before, 1, true) - if first == nil then return source, false end - return string.sub(source, 1, first - 1) .. after .. string.sub(source, last + 1), true - end - - local module_anchor = "local module = {}" - local helpers = [=[local module = {} - --- mcm_poly_profile_v1 -local function mcm_poly_profile_now() - if type(GameGetRealWorldTimeSinceStarted) ~= "function" then return nil end - local ok, value = pcall(GameGetRealWorldTimeSinceStarted) - value = ok and tonumber(value) or nil - return value -end - -local function mcm_poly_profile_source(entity) - if entity == nil or entity == 0 then return "" end - for _, component in ipairs(EntityGetComponentIncludingDisabled(entity, "VariableStorageComponent") or {}) do - local ok_name, name = pcall(ComponentGetValue2, component, "name") - if ok_name and name == "metamorph_creative_menu_network_source" then - local ok_value, value = pcall(ComponentGetValue2, component, "value_string") - if ok_value and type(value) == "string" then return value end - end - end - local ok, filename = pcall(EntityGetFilename, entity) - return ok and tostring(filename or "") or "" -end - -local function mcm_poly_profile_kind(entity) - if entity == nil or entity == 0 then return "unknown" end - if EntityGetFirstComponentIncludingDisabled(entity, "BossDragonComponent") ~= nil then return "boss_dragon" end - if EntityGetFirstComponentIncludingDisabled(entity, "WormComponent") ~= nil then return "worm" end - if EntityGetFirstComponentIncludingDisabled(entity, "CharacterDataComponent") ~= nil then return "character" end - if EntityGetFirstComponentIncludingDisabled(entity, "AdvancedFishAIComponent") ~= nil then return "fish" end - return "other" -end - -local function mcm_poly_profile_publish(prefix, entity, byte_count, started, finished) - local tag_ok, is_mcm = false, false - if type(EntityHasTag) == "function" then tag_ok, is_mcm = pcall(EntityHasTag, entity, "metamorph_creative_menu_network_form") end - if not tag_ok or is_mcm ~= true then return end - local elapsed_ms = -1 - if started ~= nil and finished ~= nil then elapsed_ms = math.max(0, (finished - started) * 1000) end - local bytes = math.max(0, tonumber(byte_count) or 0) - local source = mcm_poly_profile_source(entity) - local kind = mcm_poly_profile_kind(entity) - if type(GlobalsSetValue) == "function" then - pcall(GlobalsSetValue, prefix .. "_ms_v1", string.format("%.3f", elapsed_ms)) - pcall(GlobalsSetValue, prefix .. "_bytes_v1", tostring(bytes)) - pcall(GlobalsSetValue, prefix .. "_source_v1", source) - pcall(GlobalsSetValue, prefix .. "_kind_v1", kind) - if type(GameGetFrameNum) == "function" then - local ok, frame = pcall(GameGetFrameNum) - if ok then pcall(GlobalsSetValue, prefix .. "_frame_v1", tostring(tonumber(frame) or -1)) end - end - end - if type(print) == "function" then - print("[MCM EW profile] " .. tostring(prefix) .. " ms=" .. string.format("%.3f", elapsed_ms) - .. " bytes=" .. tostring(bytes) .. " kind=" .. tostring(kind) .. " source=" .. tostring(source)) - end -end]=] - local ok - content, ok = replace_once(content, module_anchor, helpers) - if not ok then return original, 0 end - - if string.find(content, "mcm_poly_death_intercept_v1", 1, true) == nil then - local death_patched, death_count = resilience_patches.patch_polymorph_death_source(content) - if death_count ~= 1 then return original, 0 end - content = death_patched - end - - local serialize_anchor = [[ rpc.change_entity({ data = util.serialize_entity(ctx.my_player.entity) })]] - local serialize_block = [[ local mcm_profile_serialize_started = mcm_poly_profile_now() - local mcm_profile_serialized = util.serialize_entity(ctx.my_player.entity) - local mcm_profile_serialize_finished = mcm_poly_profile_now() - mcm_poly_profile_publish("mcm_ew_poly_serialize", ctx.my_player.entity, - type(mcm_profile_serialized) == "string" and #mcm_profile_serialized or 0, - mcm_profile_serialize_started, mcm_profile_serialize_finished) - rpc.change_entity({ data = mcm_profile_serialized })]] - content, ok = replace_once(content, serialize_anchor, serialize_block) - if not ok then return original, 0 end - - local deserialize_anchor = [[ local ent = util.deserialize_entity(seri_ent.data)]] - local deserialize_block = [[ local mcm_profile_deserialize_started = mcm_poly_profile_now() - local ent = util.deserialize_entity(seri_ent.data) - local mcm_profile_deserialize_finished = mcm_poly_profile_now() - mcm_poly_profile_publish("mcm_ew_poly_deserialize", ent, - type(seri_ent.data) == "string" and #seri_ent.data or 0, - mcm_profile_deserialize_started, mcm_profile_deserialize_finished)]] - content, ok = replace_once(content, deserialize_anchor, deserialize_block) - if not ok then return original, 0 end - - return content, 1 -end - - --- EW mirrors LoadPixelScene calls by filename, but its stock RPC does not forward the --- color_to_material_table argument. MCM's solid brush uses one tiny mask file plus a --- dynamic color->material mapping, so replaying that scene remotely would either load --- the wrong material or reference an MCM-owned file on a peer that does not have MCM. --- Keep the local LoadPixelScene result and let EW's normal grid-world synchronization --- transport the resulting cells instead. This patch affects only MCM brush assets. -function resilience_patches.patch_material_pixel_scene_source(content) - if type(content) ~= "string" then return content, 0 end - if string.find(content, "mcm_material_brush_pixel_scene_v1", 1, true) then return content, 0 end - local original = content - local function replace_material_scene_once(source, before, after) - local first, last = string.find(source, before, 1, true) - if first == nil then return source, false end - return string.sub(source, 1, first - 1) .. after .. string.sub(source, last + 1), true - end - local anchor = [[ CrossCall( - "ew_sync_pixel_scene",]] - local replacement = [[ -- mcm_material_brush_pixel_scene_v1: the MCM solid brush supplies its - -- material dynamically through color_to_material_table. EW's stock pixel-scene - -- RPC intentionally forwards only filenames/position, so replaying this MCM-owned - -- mask remotely cannot preserve the selected material and breaks peers without MCM. - -- The scene has already been applied locally above. Do not send a filename-only - -- replay that cannot represent the selected material; stock EW world synchronization - -- remains responsible for whatever resulting grid state its normal model can encode. - -- All non-MCM pixel scenes keep the exact upstream path. - if string.find(tostring(materials_filename or ""), - "mods/metamorph_creative_menu/files/features/materials/brushes/", 1, true) == 1 - then - return - end - CrossCall( - "ew_sync_pixel_scene",]] - local changed, ok = replace_material_scene_once(content, anchor, replacement) - if not ok then return original, 0 end - return changed, 1 -end - +-- EW deliberately treats a small perk set as global and mirrors it to every player. +-- Creative-menu editing needs per-peer ownership instead: removing/adding a perk on one +-- peer must never mutate another peer's perk count. Disable only EW's mirroring table; +-- local vanilla pickup mechanics remain untouched. function resilience_patches.patch_peer_perk_isolation_source(content) - if type(content) ~= "string" or string.find(content, "mcm_peer_perk_sync_v4", 1, true) ~= nil then + if type(content) ~= "string" or string.find(content, "mcm_peer_perk_sync_v3", 1, true) ~= nil then return content, 0 end local function replace_peer_once(source, before, after) @@ -599,9 +250,10 @@ function resilience_patches.patch_peer_perk_isolation_source(content) local original = content -- 0) Some perks are known by EW itself to be unsafe when their full vanilla mechanics - -- are executed on the synthetic remote-player replica. Keep the owner's real pickup + -- are executed on the synthetic remote-player replica. Keep the owner's real pickup -- untouched; only stop EW's replica reconstruction from running these mechanics a - -- second time. + -- second time. The remote UI icon is still created by give_one_perk(), while shared + -- world/entity consequences continue through EW's existing systems. if string.find(content, "mcm_peer_perk_remote_safety_v1", 1, true) == nil then local ignore_anchor = "local perks_to_ignore = {" local ignore_block = [[local perks_to_ignore = { @@ -615,23 +267,17 @@ function resilience_patches.patch_peer_perk_isolation_source(content) content = changed end - -- 1) Do not rewrite EW's global-perk policy. Instead, hide only tracked creative - -- copies from the *sender's* advertised count. A stock EW peer therefore never sees - -- that creative layer and cannot grant it to its own real player, while ordinary - -- vanilla pickups still follow EW's upstream global-perk semantics unchanged. - if string.find(content, "mcm_peer_perk_sender_filter_v1", 1, true) == nil then - local count_anchor = [[ local perk_count = tonumber(GlobalsGetValue(perk_flag_name .. "_PICKUP_COUNT", "0")) - if perk_count > 0 then]] - local count_block = [[ local perk_count = tonumber(GlobalsGetValue(perk_flag_name .. "_PICKUP_COUNT", "0")) - -- mcm_peer_perk_sender_filter_v1 - if global_perks[perk_id] then - local mcm_hidden = math.max(0, tonumber(GlobalsGetValue("mcm_creative_perk_hidden_count_v1:" .. perk_id, "0")) or 0) - if mcm_hidden > 0 then perk_count = math.max(0, perk_count - mcm_hidden) end - end - if perk_count > 0 then]] - local changed, ok = replace_peer_once(content, count_anchor, count_block) - if not ok then return original, 0 end - content = changed + -- 1) Ownership stays peer-local. EW may still render the perk on the remote replica, + -- but its special "global_perks" list must not grant the same owned perk to every + -- real player. + local isolation_marker = "mcm_peer_perk_isolation_v1" + if string.find(content, isolation_marker, 1, true) == nil then + local start_at = string.find(content, "local global_perks = {", 1, true) + if start_at == nil then return original, 0 end + local close_at = string.find(content, "\n}", start_at, true) + if close_at == nil then return original, 0 end + local replacement = "local global_perks = {} -- " .. isolation_marker + content = string.sub(content, 1, start_at - 1) .. replacement .. string.sub(content, close_at + 2) end -- 2) Reuse EW's own remote-perk rebuild path for removals. get_my_perks() omits @@ -653,11 +299,12 @@ function resilience_patches.patch_peer_perk_isolation_source(content) content = changed end - -- The marker is attached to the unchanged upstream global-perk table declaration so - -- verification also proves we did not replace the table with an MCM-owned policy. - local marker_anchor = "local global_perks = {" - local marked, marked_ok = replace_peer_once(content, marker_anchor, - marker_anchor .. " -- mcm_peer_perk_sync_v4") + -- Single critical marker means peer-local ownership, safe remote reconstruction and + -- removal propagation are present. Keep the old v2 marker if a previous virtual-file + -- patch already added it; v3 is the current verification marker. + local marker_anchor = "local global_perks = {} -- " .. isolation_marker + if string.find(content, marker_anchor, 1, true) == nil then return original, 0 end + local marked, marked_ok = replace_peer_once(content, marker_anchor, marker_anchor .. " mcm_peer_perk_sync_v3") if not marked_ok then return original, 0 end return marked, 1 end diff --git a/metamorph_creative_menu/files/integrations/ew/weather_sync.lua b/metamorph_creative_menu/files/integrations/ew/weather_sync.lua index 4afa796..04e206a 100644 --- a/metamorph_creative_menu/files/integrations/ew/weather_sync.lua +++ b/metamorph_creative_menu/files/integrations/ew/weather_sync.lua @@ -1,6 +1,5 @@ local sync = {} local ew_runtime = dofile("mods/metamorph_creative_menu/files/integrations/ew/runtime.lua") -local time_dt_owner = dofile("mods/metamorph_creative_menu/files/features/world_rules/time_dt.lua") local OUTBOX_SEQUENCE_KEY = "mcm_weather_outbox_seq_v1" local OUTBOX_VERSION_KEY = "mcm_weather_outbox_version_v1" @@ -10,9 +9,7 @@ local REMOTE_VERSION_KEY = "mcm_weather_remote_version_v1" local REMOTE_SNAPSHOT_KEY = "mcm_weather_remote_snapshot_v1" local SYNC_FIELDS = { "rain", "rain_target", "fog", "fog_target", "wind", "wind_speed" } --- Initialized lazily after WorldState exists; top-level GlobalsGetValue is an engine --- error during mod startup and used to poison crash-recovery logs. -local outbox_sequence = 0 +local outbox_sequence = tonumber(GlobalsGetValue(OUTBOX_SEQUENCE_KEY, "0")) or 0 local last_remote_sequence = "" local last_publish_frame = -100000 @@ -39,7 +36,8 @@ local function encode_state(state, world_component) local time_delta = nil local component = world_component() if component ~= nil then - time_delta = time_dt_owner.legacy_sync_value(component) + local read_succeeded, value = pcall(ComponentGetValue2, component, "time_dt") + if read_succeeded then time_delta = tonumber(value) end end parts[#parts + 1] = encode_number(time_delta) return table.concat(parts, "|") @@ -95,9 +93,8 @@ function sync.publish(state, world_component, force) GlobalsSetValue(OUTBOX_SEQUENCE_KEY, tostring(outbox_sequence)) end -function sync.consume(state, world_component, clear_state, shared_time_owner) +function sync.consume(state, world_component, clear_state) if not sync.enabled() then return false end - local time_owner = type(shared_time_owner) == "table" and shared_time_owner or time_dt_owner local sequence = GlobalsGetValue(REMOTE_SEQUENCE_KEY, "") if sequence == "" or sequence == last_remote_sequence then return false end last_remote_sequence = sequence @@ -107,16 +104,17 @@ function sync.consume(state, world_component, clear_state, shared_time_owner) if remote_state == nil then return false end local component = world_component() if not remote_state.active then - if time_owner.is_weather_frozen() then time_owner.set_weather_frozen(false) end clear_state() - if component ~= nil and remote_state.time_dt ~= nil and not time_owner.has_day_override() then - time_owner.accept_legacy_sync_value(remote_state.time_dt, component) + if component ~= nil and remote_state.time_dt ~= nil then + pcall(ComponentSetValue2, component, "time_dt", remote_state.time_dt) end return true end - local previous_lightning = tonumber(state.lightning) or 0 - local previous_active = state.active == true and previous_lightning > 0 + if state.active ~= true and state.original_time_dt == nil and component ~= nil then + local read_ok, current_time_dt = pcall(ComponentGetValue2, component, "time_dt") + if read_ok then state.original_time_dt = tonumber(current_time_dt) end + end state.active = true -- User rights are symmetric. The host is only the technical periodic rebroadcast -- point required by EW so late joiners receive the latest complete snapshot. @@ -125,20 +123,10 @@ function sync.consume(state, world_component, clear_state, shared_time_owner) state.values = remote_state.values state.rainfall = remote_state.rainfall state.lightning = remote_state.lightning - local next_lightning = tonumber(remote_state.lightning) or 0 - -- Periodic EW rebroadcasts carry a new sequence even when weather is unchanged. - -- Preserve the local scheduler for equivalent lightning state so sync cadence cannot - -- become the strike cadence. A real enable/rate change intentionally reschedules. - if next_lightning <= 0 or not previous_active or math.abs(previous_lightning - next_lightning) > 0.000001 then - state.next_lightning_frame = 0 - end - if state.time ~= nil then - time_owner.set_weather_frozen(true) - else - if time_owner.is_weather_frozen() then time_owner.set_weather_frozen(false) end - if component ~= nil and remote_state.time_dt ~= nil and not time_owner.has_day_override() then - time_owner.accept_legacy_sync_value(remote_state.time_dt, component) - end + state.next_lightning_frame = 0 + state.lightning_clear_frame = 0 + if component ~= nil and remote_state.time_dt ~= nil and state.time == nil then + pcall(ComponentSetValue2, component, "time_dt", remote_state.time_dt) end return true end diff --git a/metamorph_creative_menu/files/integrations/ew/world_entities.lua b/metamorph_creative_menu/files/integrations/ew/world_entities.lua deleted file mode 100644 index d91649b..0000000 --- a/metamorph_creative_menu/files/integrations/ew/world_entities.lua +++ /dev/null @@ -1,80 +0,0 @@ -local world_entities = {} -local ew_runtime = dofile("mods/metamorph_creative_menu/files/integrations/ew/runtime.lua") - --- Entangled Worlds 1.6.3 native DES discovers ordinary enemies itself. MCM must not --- allocate another GID, replace EW's death callback, or attach custom damage guards. --- Only unusual creature-like roots that are not covered by native DES receive EW's --- documented opt-in tag. -local NATIVE_TRACK_TAGS = { - "enemy", "ew_synced", "plague_rat", "seed_f", "seed_e", "seed_d", "seed_c", - "perk_fungus_tiny", "helpless_animal", "nest", -} - -local function valid(entity) - return entity ~= nil and tonumber(entity) ~= 0 - and type(EntityGetIsAlive) == "function" and EntityGetIsAlive(tonumber(entity)) == true -end - -local function root_entity(entity) - entity = tonumber(entity) or 0 - if not valid(entity) then return 0 end - if type(EntityGetRootEntity) == "function" then - local ok, root = pcall(EntityGetRootEntity, entity) - root = ok and (tonumber(root) or 0) or 0 - if root ~= 0 and valid(root) then return root end - end - return entity -end - -local function has_tag(entity, tag) - if type(EntityHasTag) ~= "function" then return false end - local ok, value = pcall(EntityHasTag, entity, tag) - return ok and value == true -end - -local function local_des_identity(entity) - if not valid(entity) or type(EntityGetComponentIncludingDisabled) ~= "function" then return false, "" end - local ok, values = pcall(EntityGetComponentIncludingDisabled, entity, "VariableStorageComponent") - if not ok or type(values) ~= "table" then return false, "" end - for _, storage in ipairs(values) do - local ok_name, name = pcall(ComponentGetValue2, storage, "name") - if ok_name and tostring(name or "") == "ew_gid_lid" then - local ok_owner, owner = pcall(ComponentGetValue2, storage, "value_bool") - local ok_gid, gid = pcall(ComponentGetValue2, storage, "value_string") - return ok_owner and owner == true, ok_gid and tostring(gid or "") or "" - end - end - return false, "" -end - -function world_entities.is_tracked(entity) - entity = root_entity(entity) - if entity == 0 then return false end - if has_tag(entity, "ew_des") then return true end - local _, gid = local_des_identity(entity) - return gid ~= "" -end - -function world_entities.track(entity, _options) - if not valid(entity) then return false, "invalid" end - if not ew_runtime.enabled() then return true, "singleplayer" end - entity = root_entity(entity) - if entity == 0 then return false, "root" end - - for _, tag in ipairs(NATIVE_TRACK_TAGS) do - if has_tag(entity, tag) then return true, "native_auto" end - end - - if type(EntityAddTag) ~= "function" then return false, "tag_api_unavailable" end - local ok = pcall(EntityAddTag, entity, "ew_synced") - return ok, ok and "native_forced" or "tag_failed" -end - --- Kept for the main-loop API; native DES owns lifecycle updates. -function world_entities.update() end - -function world_entities.outbox_state() - return 0, 0, "native_des_stock_only_v1" -end - -return world_entities diff --git a/metamorph_creative_menu/files/integrations/ew/world_items.lua b/metamorph_creative_menu/files/integrations/ew/world_items.lua index 346d6fd..dcb28fd 100644 --- a/metamorph_creative_menu/files/integrations/ew/world_items.lua +++ b/metamorph_creative_menu/files/integrations/ew/world_items.lua @@ -2,20 +2,13 @@ local world_items = {} local ew_runtime = dofile("mods/metamorph_creative_menu/files/integrations/ew/runtime.lua") -- Creative-menu code and EW's ew_thrown callback run in different Lua contexts. World --- items therefore cross that boundary through a sequence-keyed event mailbox when a --- direct CrossCall is unavailable. The EW-side consumer lives in bridge/items.lua. +-- items therefore cross that boundary through this bounded event journal when a direct +-- CrossCall is unavailable. The EW-side consumer lives in bridge/items.lua. local OUTBOX_SEQUENCE_KEY = "mcm_world_item_outbox_seq_v1" local OUTBOX_ENTITY_KEY = "mcm_world_item_outbox_entity_v1" local OUTBOX_ACK_KEY = "mcm_world_item_outbox_ack_v1" local OUTBOX_RESULT_KEY = "mcm_world_item_outbox_result_v1" -local outbox_sequence = 0 -- merge the persisted value lazily in notify_world_item() -local CREATIVE_PERK_SEQ_KEY = "mcm_creative_perk_spawn_seq_v1" -local CREATIVE_PERK_ENTITY_KEY = "mcm_creative_perk_spawn_entity_v1:" -local CREATIVE_PERK_ID_KEY = "mcm_creative_perk_spawn_id_v1:" -local CREATIVE_PERK_X_KEY = "mcm_creative_perk_spawn_x_v1:" -local CREATIVE_PERK_Y_KEY = "mcm_creative_perk_spawn_y_v1:" -local CREATIVE_PERK_FRAME_KEY = "mcm_creative_perk_spawn_frame_v1:" -local creative_perk_sequence = 0 +local outbox_sequence = tonumber(GlobalsGetValue(OUTBOX_SEQUENCE_KEY, "0")) or 0 local function valid_entity(entity) return entity ~= nil and entity ~= 0 and EntityGetIsAlive(entity) @@ -50,9 +43,6 @@ function world_items.notify_world_item(entity) if type(CrossCall) == "function" then local crosscall_succeeded, crosscall_error = pcall(CrossCall, "ew_thrown", entity) if crosscall_succeeded then return true, "direct" end - if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "item.ew_thrown", tostring(crosscall_error)) - end end outbox_sequence = math.max(outbox_sequence, tonumber(GlobalsGetValue(OUTBOX_SEQUENCE_KEY, "0")) or 0) + 1 @@ -63,28 +53,6 @@ function world_items.notify_world_item(entity) return true, "queued:" .. tostring(outbox_sequence) end -function world_items.register_creative_perk(entity, perk_id) - perk_id = tostring(perk_id or "") - if perk_id == "" or not valid_entity(entity) then return false, "invalid" end - if not ew_runtime.enabled() then return true, "singleplayer" end - if type(GlobalsSetValue) ~= "function" or type(GlobalsGetValue) ~= "function" then - return false, "globals_unavailable" - end - local x, y = EntityGetTransform(entity) - if tonumber(x) == nil or tonumber(y) == nil then return false, "position" end - creative_perk_sequence = math.max(creative_perk_sequence, - tonumber(GlobalsGetValue(CREATIVE_PERK_SEQ_KEY, "0")) or 0) + 1 - local suffix = tostring(creative_perk_sequence) - GlobalsSetValue(CREATIVE_PERK_ENTITY_KEY .. suffix, tostring(entity)) - GlobalsSetValue(CREATIVE_PERK_ID_KEY .. suffix, perk_id) - GlobalsSetValue(CREATIVE_PERK_X_KEY .. suffix, tostring(x)) - GlobalsSetValue(CREATIVE_PERK_Y_KEY .. suffix, tostring(y)) - GlobalsSetValue(CREATIVE_PERK_FRAME_KEY .. suffix, - tostring(type(GameGetFrameNum)=="function" and GameGetFrameNum() or 0)) - GlobalsSetValue(CREATIVE_PERK_SEQ_KEY, suffix) - return true, "registered:" .. suffix -end - function world_items.world_sync_state(entity) if not ew_runtime.enabled() then return true, "singleplayer", nil end if not valid_entity(entity) then return false, "dead", nil end diff --git a/metamorph_creative_menu/files/integrations/ew/world_rules_sync.lua b/metamorph_creative_menu/files/integrations/ew/world_rules_sync.lua index 5e2833d..76d0488 100644 --- a/metamorph_creative_menu/files/integrations/ew/world_rules_sync.lua +++ b/metamorph_creative_menu/files/integrations/ew/world_rules_sync.lua @@ -18,12 +18,11 @@ local WORLD_RULES_PROTOCOL = 2 local last_network_send_frame = -100000 local pending_local_snapshot = nil -local outbox_sequence = 0 +local outbox_sequence = tonumber(GlobalsGetValue(OUTBOX_SEQ, "0")) or 0 -- Ignore mailbox entries serialized by a previous Lua VM/save session. New bridge -- publications always advance these sequence values after the current module loads. -local last_remote_sequence = "" -local last_bridge_error_sequence = "" -local mailbox_initialized = false +local last_remote_sequence = GlobalsGetValue(REMOTE_SEQ, "") +local last_bridge_error_sequence = GlobalsGetValue(BRIDGE_ERROR_SEQ, "") local last_network_error = { signature=nil, frame=-100000 } local function ew_enabled() @@ -36,9 +35,6 @@ local function log_network_error(code, details) local signature = tostring(code) .. "\31" .. tostring(details or "") if last_network_error.signature == signature and frame - last_network_error.frame < 600 then return end last_network_error.signature, last_network_error.frame = signature, frame - if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "world_rules." .. tostring(code), tostring(details or "")) - end print("[Metamorph: Creative Menu] EW world-rules: " .. tostring(code) .. " - " .. tostring(details or "")) end @@ -98,21 +94,6 @@ function world_rule_sync.update(frame, callbacks) return end - if not mailbox_initialized then - -- Globals are unavailable while modules load, but are stable by the first - -- world update. Snapshot old sequences here so a resumed save is not mistaken - -- for a new remote edit. - outbox_sequence = tonumber(GlobalsGetValue(OUTBOX_SEQ, "0")) or 0 - -- A local click can race the first bridge publication in isolated/reloaded - -- Lua contexts. In that case consume the remote state for presentation while - -- retaining pending_local_snapshot as the user's exact outbound intent. - if pending_local_snapshot == nil then - last_remote_sequence = GlobalsGetValue(REMOTE_SEQ, "") - end - last_bridge_error_sequence = GlobalsGetValue(BRIDGE_ERROR_SEQ, "") - mailbox_initialized = true - end - consume_bridge_error() consume_remote_snapshot(callbacks) diff --git a/metamorph_creative_menu/files/platform/noita/action_bindings.lua b/metamorph_creative_menu/files/platform/noita/action_bindings.lua deleted file mode 100644 index 8d14ff5..0000000 --- a/metamorph_creative_menu/files/platform/noita/action_bindings.lua +++ /dev/null @@ -1,233 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_ACTION_BINDINGS) == "table" then - return METAMORPH_CREATIVE_MENU_ACTION_BINDINGS -end - -local bindings = {} -local registry = dofile("mods/metamorph_creative_menu/files/core/action_registry.lua") -local codec = dofile("mods/metamorph_creative_menu/files/core/binding_codec.lua") -local keycodes = dofile("mods/metamorph_creative_menu/files/platform/noita/keycodes.lua") -local binding_capture = dofile("mods/metamorph_creative_menu/files/platform/noita/binding_capture.lua") -local input_guard = dofile("mods/metamorph_creative_menu/files/platform/noita/input_guard.lua") -local text_entry_guard = dofile("mods/metamorph_creative_menu/files/platform/noita/text_entry_guard.lua") - -local cached = {} -local pressed = {} -local consumed = {} -local consumed_bindings = {} -local updated_frame = -100000 -local capture = nil -local last_capture_event = nil -local REFRESH_FRAMES = 30 - -local function frame_number() - if type(GameGetFrameNum) ~= "function" then return 0 end - local ok, value = pcall(GameGetFrameNum) - return ok and (tonumber(value) or 0) or 0 -end - -local function setting_value(action) - local frame = frame_number() - local record = cached[action.id] - if record ~= nil and frame < record.refresh_frame then return record.value end - local value = nil - if type(ModSettingGet) == "function" then - local ok, stored = pcall(ModSettingGet, action.setting_id) - if ok and type(stored) == "string" and stored ~= "" then value = stored end - if type(action.legacy_setting) == "string" then - local marker_id = action.setting_id .. "_legacy_migrated_v1" - local marker_ok, marker = pcall(ModSettingGet, marker_id) - if not marker_ok or marker ~= true then - local legacy_ok, legacy = pcall(ModSettingGet, action.legacy_setting) - if legacy_ok and type(legacy) == "string" and legacy ~= "" - and (value == nil or codec.normalize(value, action.default) == codec.normalize(action.default, "NONE")) - then - value = codec.normalize(legacy, action.default) - if type(ModSettingSet) == "function" then pcall(ModSettingSet, action.setting_id, value) end - end - if type(ModSettingSet) == "function" then pcall(ModSettingSet, marker_id, true) end - end - end - end - value = codec.normalize(value, action.default) - cached[action.id] = { value=value, refresh_frame=frame + REFRESH_FRAMES } - return value -end - -local function current_modifiers() - return binding_capture.current_modifiers() -end - -local function modifiers_match(required, actual) - if actual == nil then - local ctrl, shift, alt = current_modifiers() - actual = {CTRL=ctrl,SHIFT=shift,ALT=alt} - end - return actual.CTRL == (required.CTRL == true) - and actual.SHIFT == (required.SHIFT == true) - and actual.ALT == (required.ALT == true) -end - -local function parsed_just_down(parsed, actual_modifiers) - if parsed == nil or parsed.kind == "none" or not modifiers_match(parsed.modifiers or {}, actual_modifiers) then return false end - if parsed.kind == "mouse" then - if type(InputIsMouseButtonJustDown) ~= "function" then return false end - local ok, down = pcall(InputIsMouseButtonJustDown, parsed.mouse_code) - return ok and down == true - end - if type(InputIsKeyJustDown) ~= "function" then return false end - local code = keycodes.resolve(parsed.base) - if code == nil then return false end - local ok, down = pcall(InputIsKeyJustDown, code) - return ok and down == true -end - -local function parsed_down(parsed) - if parsed == nil or parsed.kind == "none" or not modifiers_match(parsed.modifiers or {}) then return false end - if parsed.kind == "mouse" then - if type(InputIsMouseButtonDown) ~= "function" then return false end - local ok, down = pcall(InputIsMouseButtonDown, parsed.mouse_code) - return ok and down == true - end - if type(InputIsKeyDown) ~= "function" then return false end - local code = keycodes.resolve(parsed.base) - if code == nil then return false end - local ok, down = pcall(InputIsKeyDown, code) - return ok and down == true -end - -local function finish_capture(kind, value) - local action_id = capture and capture.action_id or nil - capture = nil - if kind == "binding" and action_id ~= nil then bindings.set(action_id, value) end - last_capture_event = { kind=kind, action_id=action_id, value=value } -end - -local function update_capture(frame) - if capture == nil or frame <= capture.started_frame then return capture ~= nil end - if not input_guard.actions_allowed() then return true end - local event = binding_capture.poll(capture.started_frame, frame) - if event ~= nil then finish_capture(event.kind, event.value) end - return true -end - -function bindings.update() - local frame = frame_number() - if frame == updated_frame then return end - updated_frame, pressed, consumed, consumed_bindings = frame, {}, {}, {} - -- A physical key used to type into a focused field must never be dispatched again - -- as a creative-menu/gameplay hotkey on the same frame. - if text_entry_guard.active() then return end - if update_capture(frame) then return end - if not input_guard.actions_allowed() then return end - local ctrl, shift, alt = current_modifiers() - local actual_modifiers = {CTRL=ctrl,SHIFT=shift,ALT=alt} - local binding_edges = {} - for _, action in ipairs(registry.actions()) do - local value = setting_value(action) - local edge = binding_edges[value] - if edge == nil then - edge = parsed_just_down(codec.parse(value), actual_modifiers) - binding_edges[value] = edge - end - if edge then pressed[action.id] = true end - end -end - -function bindings.just_pressed(action_id) - bindings.update() - action_id = tostring(action_id or "") - local value = bindings.get(action_id) - return pressed[action_id] == true and consumed[action_id] ~= true - and consumed_bindings[value] ~= true -end - -function bindings.consume(action_id) - if not bindings.just_pressed(action_id) then return false end - action_id = tostring(action_id) - consumed[action_id] = true - consumed_bindings[bindings.get(action_id)] = true - return true -end - -function bindings.is_down(action_id) - if capture ~= nil or text_entry_guard.active() or not input_guard.actions_allowed() then return false end - local action = registry.get(action_id) - if action == nil then return false end - return parsed_down(codec.parse(setting_value(action))) -end - -function bindings.get(action_id) - local action = registry.get(action_id) - return action and setting_value(action) or "NONE" -end - -function bindings.set(action_id, value) - local action = registry.get(action_id) - if action == nil then return false, "unknown_action" end - local parsed, reason = codec.parse(value) - if parsed == nil then return false, reason end - local stored = parsed.canonical - if type(ModSettingSet) == "function" then pcall(ModSettingSet, action.setting_id, stored) end - if type(ModSettingSetNextValue) == "function" then pcall(ModSettingSetNextValue, action.setting_id, stored, false) end - cached[action.id] = { value=stored, refresh_frame=frame_number() + REFRESH_FRAMES } - return true, stored -end - -function bindings.reset(action_id) - local action = registry.get(action_id) - if action == nil then return false end - return bindings.set(action.id, action.default) -end - -function bindings.reset_all() - local changed = 0 - for _, action in ipairs(registry.actions()) do - if bindings.reset(action.id) then changed = changed + 1 end - end - return changed -end - -function bindings.start_capture(action_id) - if registry.get(action_id) == nil then return false, "unknown_action" end - capture = { action_id=tostring(action_id), started_frame=frame_number() } - last_capture_event = nil - pressed, consumed, consumed_bindings = {}, {}, {} - return true -end - -function bindings.cancel_capture() - if capture == nil then return false end - finish_capture("cancelled") - return true -end - -function bindings.capture_action() return capture and capture.action_id or nil end -function bindings.capture_event() - local event = last_capture_event - last_capture_event = nil - return event -end - -local function translated(key, fallback) - if type(GameTextGetTranslatedOrNot) ~= "function" then return fallback end - local ok, value = pcall(GameTextGetTranslatedOrNot, key) - return ok and type(value) == "string" and value ~= "" and value ~= key and value or fallback -end -function bindings.label(action_id) - return binding_capture.label(bindings.get(action_id), translated) -end - -function bindings.conflicts(action_id) - local value = bindings.get(action_id) - if value == "NONE" then return {} end - local result = {} - for _, other in ipairs(registry.actions()) do - if other.id ~= action_id and bindings.get(other.id) == value then result[#result + 1] = other end - end - return result -end - -function bindings.registry() return registry end - -METAMORPH_CREATIVE_MENU_ACTION_BINDINGS = bindings -return bindings diff --git a/metamorph_creative_menu/files/platform/noita/assets.lua b/metamorph_creative_menu/files/platform/noita/assets.lua index 3eae0b9..6489f3b 100644 --- a/metamorph_creative_menu/files/platform/noita/assets.lua +++ b/metamorph_creative_menu/files/platform/noita/assets.lua @@ -10,31 +10,26 @@ local asset_cache = {} local dimension_cache = {} local entity_icon_cache = {} local proxy_cache = {} +local proxy_meta = {} local GENERATED_DIR = "mods/metamorph_creative_menu/files/generated/" -local PLACEHOLDER_PATH = "data/ui_gfx/progress_menu/icon_unknown.png" --- Public GuiImage playback enum from data/scripts/lib/utilities.lua. A one-frame --- proxy can safely use PlayToEndAndPause; original multi-frame Sprite XML uses the --- same mode as a bounded fallback because the public API has no "pause on frame 0". -local PLAY_TO_END_AND_PAUSE = 1 - -local function static_path(path) - return string.sub(tostring(path or ""), 1, 5) ~= "mods/" -end +local function static_path(path) return string.sub(tostring(path or ""), 1, 5) ~= "mods/" end local function read_text(path) - if type(path) ~= "string" or path == "" then return nil end - if static_path(path) and text_cache[path] ~= nil then + if type(path) ~= "string" or path == "" then + return nil + end + if text_cache[path] ~= nil then return text_cache[path] ~= false and text_cache[path] or nil end local ok, content = pcall(ModTextFileGetContent, path) if not ok or type(content) ~= "string" or content == "" then - -- Vanilla data is immutable during a run. Mod/VFS resources can appear after - -- pre-init, so neither misses nor successful reads are frozen for mods/* paths. + -- Vanilla data is immutable during a run; mod/VFS paths may legitimately be + -- generated after our first probe, so never permanently cache those misses. if static_path(path) then text_cache[path] = false end return nil end - if static_path(path) then text_cache[path] = content end + text_cache[path] = content return content end @@ -43,10 +38,13 @@ local function attr(tag, name) or string.match(tag, name .. "%s*=%s*'([^']+)'") end + local function first_animation(content, preferred) local first = nil for tag in string.gmatch(content, "]*>") do - if first == nil then first = tag end + if first == nil then + first = tag + end if preferred ~= nil and preferred ~= "" and attr(tag, "name") == preferred then return tag end @@ -54,226 +52,174 @@ local function first_animation(content, preferred) return first end -local function parse_sprite(path, content) - if type(content) ~= "string" or content == "" then return nil, "sprite_xml_unreadable" end - local sprite_tag = string.match(content, "]*>") - if sprite_tag == nil then return nil, "sprite_xml_missing_sprite" end - local filename = attr(sprite_tag, "filename") - if filename == nil or filename == "" then return nil, "sprite_xml_missing_filename" end - local preferred = attr(sprite_tag, "default_animation") - local animation = first_animation(content, preferred) - if animation == nil then return nil, "sprite_xml_missing_animation" end - local frame_width = tonumber(attr(animation, "frame_width")) - local frame_height = tonumber(attr(animation, "frame_height")) - if frame_width == nil or frame_height == nil or frame_width <= 0 or frame_height <= 0 then - return nil, "sprite_xml_invalid_frame_size" - end - local animation_name = attr(animation, "name") or preferred or "" - local frame_count = math.max(1, tonumber(attr(animation, "frame_count")) or 1) - local frames_per_row = math.max(1, tonumber(attr(animation, "frames_per_row")) or 1) - local rect = { - name = animation_name, - pos_x = tonumber(attr(animation, "pos_x")) or 0, - pos_y = tonumber(attr(animation, "pos_y")) or 0, - frame_count = frame_count, - frame_width = frame_width, - frame_height = frame_height, - frame_wait = tonumber(attr(animation, "frame_wait")), - frames_per_row = frames_per_row, - loop = tonumber(attr(animation, "loop")) or 0, - } - return { - kind = "sprite_xml", - path = path, - source_path = path, - source_png = filename, - width = frame_width, - height = frame_height, - animation_name = animation_name, - playback_mode = PLAY_TO_END_AND_PAUSE, - rect_animation = rect, - single_frame = frame_count == 1, - source_is_atlas = frame_count > 1 or frames_per_row > 1, - proxy = false, - } -end - -local function placeholder(reason, source_path) - return { - kind = "placeholder", - path = PLACEHOLDER_PATH, - source_path = source_path, - reason = reason or "asset_unavailable", - animation_name = "", - single_frame = true, - source_is_atlas = false, - proxy = false, - } -end - local function raster_dimensions(path) if dimension_cache[path] ~= nil then local cached = dimension_cache[path] - if cached == false then return nil, nil end + if cached == false then + return nil, nil + end return cached[1], cached[2] end - if gui == nil then return nil, nil end + if gui == nil then + return nil, nil + end local ok, w, h = pcall(GuiGetImageDimensions, gui, path, 1) w, h = tonumber(w), tonumber(h) if not ok or w == nil or h == nil or w <= 0 or h <= 0 then - -- Resource availability can be frame-dependent; never poison the cache here. + -- Do not permanently poison an icon because its first dimensions query ran + -- before the GUI/image resource was ready. Status icons can become available + -- on a later frame. return nil, nil end dimension_cache[path] = { w, h } return w, h end -local function sibling_sprite_xml(path) - if type(path) ~= "string" or string.sub(string.lower(path), -4) ~= ".png" then return nil end - local candidate = string.sub(path, 1, #path - 4) .. ".xml" - if ModDoesFileExist(candidate) then return candidate end - return nil -end - -local function parse_sprite_path(sprite_path) - local content = read_text(sprite_path) - if content == nil then return nil, "sprite_xml_unreadable" end - return parse_sprite(sprite_path, content) -end - local function prepare_first_frame_proxy(sprite_path) - local cached = proxy_cache[sprite_path] - if cached ~= nil then return cached ~= false and cached or nil, cached == false and "proxy_unavailable" or nil end - - local source, reason = parse_sprite_path(sprite_path) - if source == nil then + if proxy_cache[sprite_path] ~= nil then + local cached = proxy_cache[sprite_path] + return cached ~= false and cached or nil + end + local content = read_text(sprite_path) + if content == nil then if static_path(sprite_path) then proxy_cache[sprite_path] = false end - return nil, reason + return nil + end + local sprite_tag = string.match(content, "]*>") + if sprite_tag == nil then + if static_path(sprite_path) then proxy_cache[sprite_path] = false end + return nil + end + local filename = attr(sprite_tag, "filename") + local animation = first_animation(content, attr(sprite_tag, "default_animation")) + if filename == nil or filename == "" or animation == nil then + if static_path(sprite_path) then proxy_cache[sprite_path] = false end + return nil + end + local fw = tonumber(attr(animation, "frame_width")) + local fh = tonumber(attr(animation, "frame_height")) + if fw == nil or fh == nil or fw <= 0 or fh <= 0 then + if static_path(sprite_path) then proxy_cache[sprite_path] = false end + return nil end - - local rect = source.rect_animation - local proxy_animation_name = source.animation_name ~= "" and source.animation_name or "default" local generated_path = GENERATED_DIR .. "asset_first_frame_" .. hash.hex64(sprite_path) .. ".xml" local generated = string.format( - '', - xml_utils.escape_attribute(source.source_png), - xml_utils.escape_attribute(proxy_animation_name), - xml_utils.escape_attribute(proxy_animation_name), - rect.pos_x, - rect.pos_y, - rect.frame_width, - rect.frame_height + '', + xml_utils.escape_attribute(filename), + tonumber(attr(animation, "pos_x")) or 0, + tonumber(attr(animation, "pos_y")) or 0, + fw, + fh, + tonumber(attr(animation, "frames_per_row")) or 1 ) local ok = pcall(ModTextFileSetContent, generated_path, generated) if not ok then - -- Never permanently cache a failed mods/* publication; load order can make a - -- resource temporarily unavailable during pre-init. if static_path(sprite_path) then proxy_cache[sprite_path] = false end - return nil, "proxy_write_failed" + return nil end - - local descriptor = { - kind = "sprite_proxy", - path = generated_path, - source_path = sprite_path, - source_png = source.source_png, - width = source.width, - height = source.height, - animation_name = proxy_animation_name, - playback_mode = PLAY_TO_END_AND_PAUSE, - rect_animation = { - name = proxy_animation_name, - pos_x = rect.pos_x, - pos_y = rect.pos_y, - frame_count = 1, - frame_width = rect.frame_width, - frame_height = rect.frame_height, - frame_wait = 999999, - frames_per_row = 1, - loop = 0, - }, - single_frame = true, - source_is_atlas = false, - proxy = true, - } - proxy_cache[sprite_path] = descriptor - asset_cache[sprite_path] = descriptor - asset_cache[generated_path] = descriptor - dimension_cache[generated_path] = { descriptor.width, descriptor.height } - return descriptor + proxy_cache[sprite_path] = generated_path + proxy_meta[generated_path] = { fw, fh } + return generated_path end -local function raster_descriptor(path) - if not ModDoesFileExist(path) then return nil end - local w, h = raster_dimensions(path) - return { - kind = "raster", - path = path, - source_path = path, - width = w, - height = h, - animation_name = "", - single_frame = true, - source_is_atlas = false, - proxy = false, - } +local function sibling_sprite_xml(path) + if type(path) ~= "string" then + return nil + end + local lower = string.lower(path) + if string.sub(lower, -4) ~= ".png" then + return nil + end + local candidate = string.sub(path, 1, #path - 4) .. ".xml" + if ModDoesFileExist(candidate) then + return candidate + end + return nil end local function resolve_asset(path, visited, depth) - if type(path) == "table" and type(path.path) == "string" then return path end - if type(path) ~= "string" or path == "" or depth > 10 then return nil end + if type(path) ~= "string" or path == "" or depth > 10 then + return nil + end local cached = asset_cache[path] - if cached ~= nil then return cached ~= false and cached or nil end + if cached ~= nil then + return cached ~= false and cached or nil + end visited = visited or {} - if visited[path] then return nil end + if visited[path] then + return nil + end visited[path] = true if string.sub(string.lower(path), -4) ~= ".xml" then - -- A PNG is safe only when it is a genuine raster icon. If a sibling Sprite XML - -- proves that this exact PNG is an animation atlas, route through the XML - -- descriptor/proxy instead of ever handing the atlas to GuiImage as an icon. + -- UI/status PNGs are already valid GuiImage assets. Prefer them directly; the + -- old sibling-XML-first rule needlessly generated animation proxies and could + -- leave effect slots blank when a sibling sprite failed to resolve once. + local w, h = raster_dimensions(path) + if w ~= nil then + local result = { path = path, width = w, height = h } + asset_cache[path] = result + return result + end local sibling = sibling_sprite_xml(path) if sibling ~= nil and not visited[sibling] then - local sibling_info = parse_sprite_path(sibling) - if sibling_info ~= nil and sibling_info.source_png == path and sibling_info.source_is_atlas then - local framed = resolve_asset(sibling, visited, depth + 1) - if framed ~= nil then - asset_cache[path] = framed - return framed - end - return placeholder("animated_raster_without_sprite", path) + local framed = resolve_asset(sibling, visited, depth + 1) + if framed ~= nil then + asset_cache[path] = framed + return framed end end - local result = raster_descriptor(path) - if result ~= nil and static_path(path) then asset_cache[path] = result end - return result + -- Missing/transient raster assets are retried rather than cached as failure. + return nil end - local source, reason = parse_sprite_path(path) - if source == nil then - local result = placeholder(reason, path) - if static_path(path) then asset_cache[path] = result end - return result + local content = read_text(path) + if content == nil then + if static_path(path) then asset_cache[path] = false end + return nil end - -- Prewarmed known icons are guaranteed one-frame proxies. Runtime-discovered mod - -- objects may not exist during OnModPreInit; for them keep the original Sprite XML - -- and pass its explicit RectAnimation metadata to GuiImage. This never exposes the - -- source PNG atlas as a successful icon descriptor. - local proxy = proxy_cache[path] - local result = type(proxy) == "table" and proxy or source - if static_path(path) then asset_cache[path] = result end - return result + if string.find(content, "]*>") + local filename = sprite_tag ~= nil and attr(sprite_tag, "filename") or nil + if filename ~= nil and filename ~= "" then + local result = resolve_asset(filename, visited, depth + 1) + if result ~= nil then asset_cache[path] = result elseif static_path(path) then asset_cache[path] = false end + return result + end + end + + if static_path(path) then asset_cache[path] = false end + return nil end local function score_sprite(image_path, tag, kind) local lower = string.lower((image_path or "") .. " " .. (tag or "")) local score = 0 local bad = { "particle", "glow", "light", "effect", "shadow", "mask", "emissive", "laser", "projectile", "status", "hurt", "damage", "spark", "muzzle", "ragdoll" } - for _, word in ipairs(bad) do if string.find(lower, word, 1, true) then score = score - 25 end end + for _, word in ipairs(bad) do + if string.find(lower, word, 1, true) then + score = score - 25 + end + end local good = { "character", "body", "enemy", "animal", "main", "sprite" } - for _, word in ipairs(good) do if string.find(lower, word, 1, true) then score = score + 4 end end - if kind == "item" and string.find(lower, "item", 1, true) then score = score + 6 end + for _, word in ipairs(good) do + if string.find(lower, word, 1, true) then + score = score + 4 + end + end + if kind == "item" and string.find(lower, "item", 1, true) then + score = score + 6 + end if kind == "form" then score = score + 1200 end if string.sub(string.lower(image_path or ""), -4) == ".xml" then score = score + 20 @@ -284,77 +230,54 @@ local function score_sprite(image_path, tag, kind) end local function collect_entity_candidates(path, kind, visited, depth, out) - if type(path) ~= "string" or path == "" or depth > 10 or visited[path] then return end + if type(path) ~= "string" or path == "" or depth > 10 or visited[path] then + return + end visited[path] = true local content = read_text(path) - if content == nil then return end + if content == nil then + return + end + -- UIIconComponent is the authoritative presentation for perks/status effects and + -- is also used by several pickups. Prefer it over guessing from decorative sprites. if kind ~= "form" and kind ~= "creature" then for tag in string.gmatch(content, "]*>") do local icon = attr(tag, "icon_sprite_file") - if icon ~= nil and icon ~= "" then out[#out + 1] = { path = icon, score = (kind == "effect" and 1600 or 1150) } end + if icon ~= nil and icon ~= "" then + out[#out + 1] = { path = icon, score = (kind == "effect" and 1600 or 1150) } + end end end if kind == "item" then for tag in string.gmatch(content, "]*>") do local ui = attr(tag, "ui_sprite") - if ui ~= nil and ui ~= "" then out[#out + 1] = { path = ui, score = 1500 } end + if ui ~= nil and ui ~= "" then + out[#out + 1] = { path = ui, score = 1500 } + end end end if kind == "item" or kind == "form" then + -- Many physics pickups (chests, utility boxes, quest props) have no Item UI + -- sprite at all; their PhysicsImageShape is the closest canonical visual. for tag in string.gmatch(content, "]*>") do local image = attr(tag, "image_file") - if image ~= nil and image ~= "" then out[#out + 1] = { path = image, score = kind == "form" and 700 or 900 } end + if image ~= nil and image ~= "" then + out[#out + 1] = { path = image, score = kind == "form" and 700 or 900 } + end end end for tag in string.gmatch(content, "]*>") do local image = attr(tag, "image_file") - if image ~= nil and image ~= "" then out[#out + 1] = { path = image, score = score_sprite(image, tag, kind) } end + if image ~= nil and image ~= "" then + out[#out + 1] = { path = image, score = score_sprite(image, tag, kind) } + end end for tag in string.gmatch(content, "]*>") do local base = attr(tag, "file") - if base ~= nil and base ~= "" then collect_entity_candidates(base, kind, visited, depth + 1, out) end - end -end - -local function ordered_entity_candidates(path, kind) - local candidates = {} - collect_entity_candidates(path, kind or "generic", {}, 0, candidates) - table.sort(candidates, function(a, b) return a.score > b.score end) - if kind ~= "item" then return candidates end - - local ordered = {} - for _, candidate in ipairs(candidates) do - local sibling = sibling_sprite_xml(candidate.path) - if sibling ~= nil then - ordered[#ordered + 1] = { path = sibling, score = candidate.score + 2000 } - elseif string.sub(string.lower(candidate.path or ""), -4) == ".xml" then - ordered[#ordered + 1] = { path = candidate.path, score = candidate.score + 1000 } - end - end - for _, candidate in ipairs(candidates) do ordered[#ordered + 1] = candidate end - table.sort(ordered, function(a, b) return a.score > b.score end) - return ordered -end - -local function prewarm_path(path) - if type(path) ~= "string" or path == "" then return false, "invalid_path" end - local lower = string.lower(path) - if string.sub(lower, -4) == ".xml" then - local descriptor, reason = prepare_first_frame_proxy(path) - return descriptor ~= nil, reason - end - if string.sub(lower, -4) == ".png" then - local sibling = sibling_sprite_xml(path) - if sibling ~= nil then - local info = parse_sprite_path(sibling) - if info ~= nil and info.source_png == path and info.source_is_atlas then - local descriptor, reason = prepare_first_frame_proxy(sibling) - return descriptor ~= nil, reason - end + if base ~= nil and base ~= "" then + collect_entity_candidates(base, kind, visited, depth + 1, out) end - return true, nil end - return false, "unsupported_asset" end function asset_service.bind_gui(value) @@ -365,83 +288,93 @@ function asset_service.resolve(path) return resolve_asset(path, {}, 0) end -function asset_service.prewarm(paths) - if type(paths) ~= "table" then return 0, 0 end - local seen = {} - local prepared, failed = 0, 0 - for _, path in ipairs(paths) do - path = tostring(path or "") - if path ~= "" and not seen[path] then - seen[path] = true - local ok = prewarm_path(path) - if ok then prepared = prepared + 1 else failed = failed + 1 end - end - end - return prepared, failed -end - -function asset_service.prewarm_entity(path, kind) - local seen = {} - for _, candidate in ipairs(ordered_entity_candidates(path, kind or "generic")) do - local candidate_path = tostring(candidate.path or "") - if candidate_path ~= "" and not seen[candidate_path] then - seen[candidate_path] = true - local ok = prewarm_path(candidate_path) - if ok then return true end - end - end - return false -end - --- Runtime forms must keep the source sprite animation. UI descriptors/proxies are never --- used for player-form world sprites. +-- Runtime forms must keep the source sprite animation. The normal UI resolver turns +-- Sprite XML into a generated one-frame proxy on purpose so icons stay readable; using +-- that same asset for a player form made animated items/spells look frozen. This resolver +-- returns the best original world sprite/physics image without generating an icon proxy. function asset_service.resolve_entity_live(path, kind) local candidates = {} collect_entity_candidates(path, kind or "form", {}, 0, candidates) table.sort(candidates, function(a, b) return a.score > b.score end) for _, candidate in ipairs(candidates) do local candidate_path = tostring(candidate.path or "") - if candidate_path ~= "" and ModDoesFileExist(candidate_path) then return candidate_path end + if candidate_path ~= "" and ModDoesFileExist(candidate_path) then + return candidate_path + end end return nil end function asset_service.resolve_entity(path, kind) local key = tostring(kind or "generic") .. "|" .. tostring(path or "") - if entity_icon_cache[key] ~= nil then return entity_icon_cache[key] end - for _, candidate in ipairs(ordered_entity_candidates(path, kind or "generic")) do + if entity_icon_cache[key] ~= nil then + local cached = entity_icon_cache[key] + return cached ~= false and cached or nil + end + local candidates = {} + collect_entity_candidates(path, kind or "generic", {}, 0, candidates) + table.sort(candidates, function(a, b) return a.score > b.score end) + + -- Item ui_sprite frequently points at an animation atlas rather than a single icon. + -- If there is any Sprite XML for the entity, prefer that XML (or the PNG's sibling + -- XML) before ever accepting a raw PNG. The XML resolver generates a one-frame + -- proxy, so animated hearts/orbs remain one readable picture instead of a strip of + -- tiny frames. Physics-only props still fall back to their raw shape image. + local ordered = {} + if kind == "item" then + for _, candidate in ipairs(candidates) do + local sibling = sibling_sprite_xml(candidate.path) + if sibling ~= nil then + ordered[#ordered + 1] = { path = sibling, score = candidate.score + 2000 } + elseif string.sub(string.lower(candidate.path or ""), -4) == ".xml" then + ordered[#ordered + 1] = { path = candidate.path, score = candidate.score + 1000 } + end + end + for _, candidate in ipairs(candidates) do ordered[#ordered + 1] = candidate end + table.sort(ordered, function(a, b) return a.score > b.score end) + else + ordered = candidates + end + + for _, candidate in ipairs(ordered) do local resolved = resolve_asset(candidate.path, {}, 0) - if resolved ~= nil and resolved.kind ~= "placeholder" then + if resolved ~= nil then entity_icon_cache[key] = resolved return resolved end end - -- Do not negative-cache entity presentation: mod/VFS resources can appear later. + -- Do not negative-cache entity presentation. Some UI/status resources become + -- queryable only after the owning script has initialized; a permanent miss made + -- previously visible effect icons turn into empty cells for the rest of the run. return nil end function asset_service.dimensions(asset_or_path) if type(asset_or_path) == "table" then - local w, h = tonumber(asset_or_path.width), tonumber(asset_or_path.height) - if w ~= nil and h ~= nil and w > 0 and h > 0 then return w, h end - return raster_dimensions(asset_or_path.path) + return asset_or_path.width, asset_or_path.height + end + if type(asset_or_path) ~= "string" or asset_or_path == "" then + return nil, nil + end + local meta = proxy_meta[asset_or_path] + if meta ~= nil then + return meta[1], meta[2] end - if type(asset_or_path) ~= "string" or asset_or_path == "" then return nil, nil end return raster_dimensions(asset_or_path) end function asset_service.path(asset_or_path) - if type(asset_or_path) == "table" then return asset_or_path.path end + if type(asset_or_path) == "table" then + return asset_or_path.path + end return asset_or_path end -function asset_service.placeholder(reason, source_path) - return placeholder(reason, source_path) -end - function asset_service.invalidate_entity(path) for key in pairs(entity_icon_cache) do - if string.find(key, "|" .. tostring(path), 1, true) ~= nil then entity_icon_cache[key] = nil end + if string.find(key, "|" .. tostring(path), 1, true) ~= nil then + entity_icon_cache[key] = nil + end end end diff --git a/metamorph_creative_menu/files/platform/noita/binding_capture.lua b/metamorph_creative_menu/files/platform/noita/binding_capture.lua deleted file mode 100644 index 17cb2df..0000000 --- a/metamorph_creative_menu/files/platform/noita/binding_capture.lua +++ /dev/null @@ -1,123 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_BINDING_CAPTURE) == "table" then - return METAMORPH_CREATIVE_MENU_BINDING_CAPTURE -end - -local binding_capture = {} -local codec = dofile("mods/metamorph_creative_menu/files/core/binding_codec.lua") -local keycodes = dofile("mods/metamorph_creative_menu/files/platform/noita/keycodes.lua") - -local MODIFIER_NAMES = { - LCTRL=true, RCTRL=true, LCONTROL=true, RCONTROL=true, - LSHIFT=true, RSHIFT=true, - LALT=true, RALT=true, -} - -local MODIFIER_KEYS = { - CTRL = { - {"Key_LCTRL", "KEY_LCTRL", "Key_LCONTROL"}, - {"Key_RCTRL", "KEY_RCTRL", "Key_RCONTROL"}, - }, - SHIFT = { - {"Key_LSHIFT", "KEY_LSHIFT"}, - {"Key_RSHIFT", "KEY_RSHIFT"}, - }, - ALT = { - {"Key_LALT", "KEY_LALT"}, - {"Key_RALT", "KEY_RALT"}, - }, -} - -local modifier_codes = nil - -local function is_down(code) - if code == nil or type(InputIsKeyDown) ~= "function" then return false end - local ok, down = pcall(InputIsKeyDown, code) - return ok and down == true -end - -local function just_down(name) - if type(InputIsKeyJustDown) ~= "function" then return false end - local code = keycodes.resolve(name) - if code == nil then return false end - local ok, down = pcall(InputIsKeyJustDown, code) - return ok and down == true -end - -local function resolved_modifiers() - if modifier_codes ~= nil then return modifier_codes end - modifier_codes = { CTRL={}, SHIFT={}, ALT={} } - for modifier, alternatives in pairs(MODIFIER_KEYS) do - local seen = {} - for _, names in ipairs(alternatives) do - local code = keycodes.resolve(unpack(names)) - if code ~= nil and not seen[code] then - seen[code] = true - modifier_codes[modifier][#modifier_codes[modifier] + 1] = code - end - end - end - return modifier_codes -end - -function binding_capture.is_modifier_name(name) - local raw = string.upper(string.gsub(tostring(name or ""), "^Key_", "")) - return MODIFIER_NAMES[raw] == true -end - -function binding_capture.current_modifiers() - local codes = resolved_modifiers() - local function any(values) - for _, code in ipairs(values or {}) do if is_down(code) then return true end end - return false - end - return any(codes.CTRL), any(codes.SHIFT), any(codes.ALT) -end - -function binding_capture.poll(started_frame, current_frame) - if (tonumber(current_frame) or 0) <= (tonumber(started_frame) or -1) then return nil end - if just_down("Key_ESCAPE") then return { kind="cancelled" } end - if just_down("Key_DELETE") or just_down("Key_BACKSPACE") then - return { kind="binding", value="NONE" } - end - - local ctrl, shift, alt = binding_capture.current_modifiers() - for _, record in ipairs(keycodes.available()) do - if not binding_capture.is_modifier_name(record.name) then - local ok, down = pcall(InputIsKeyJustDown, record.code) - if ok and down == true then - return { kind="binding", value=codec.compose(record.name, ctrl, shift, alt) } - end - end - end - if type(InputIsMouseButtonJustDown) == "function" then - for button = 0, 31 do - local ok, down = pcall(InputIsMouseButtonJustDown, button) - if ok and down == true then - return { kind="binding", value=codec.compose("Mouse:" .. tostring(button), ctrl, shift, alt) } - end - end - end - return nil -end - -function binding_capture.label(value, translate) - local parsed = codec.parse(value) - if parsed == nil or parsed.kind == "none" then return "—" end - translate = type(translate) == "function" and translate or function(_, fallback) return fallback end - local parts = {} - for _, modifier in ipairs(codec.modifier_order()) do - if parsed.modifiers[modifier] then parts[#parts + 1] = modifier end - end - if parsed.kind == "mouse" then - local mouse = translate("$mcm_key_mouse", "MOUSE") - local middle = translate("$mcm_key_mouse_middle", "MIDDLE") - parts[#parts + 1] = parsed.mouse_code == 3 and (mouse .. " " .. middle) - or (mouse .. " " .. tostring(parsed.mouse_code)) - else - parts[#parts + 1] = keycodes.pretty_name(parsed.base) - end - return table.concat(parts, " + ") -end - -METAMORPH_CREATIVE_MENU_BINDING_CAPTURE = binding_capture -return binding_capture diff --git a/metamorph_creative_menu/files/platform/noita/gameplay_input.lua b/metamorph_creative_menu/files/platform/noita/gameplay_input.lua deleted file mode 100644 index 8048510..0000000 --- a/metamorph_creative_menu/files/platform/noita/gameplay_input.lua +++ /dev/null @@ -1,97 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_GAMEPLAY_INPUT) == "table" then - return METAMORPH_CREATIVE_MENU_GAMEPLAY_INPUT -end - -local gameplay_input = {} -local input_guard = dofile("mods/metamorph_creative_menu/files/platform/noita/input_guard.lua") - -local ui_blocked = false -local release_latches = { primary=false, secondary=false } -local previous = { primary=false, secondary=false } - -local function mouse_code(surface) - if surface == "secondary" then - return tonumber(rawget(_G, "Mouse_right")) or tonumber(rawget(_G, "MOUSE_RIGHT")) or 2 - end - return tonumber(rawget(_G, "Mouse_left")) or tonumber(rawget(_G, "MOUSE_LEFT")) or 1 -end - -local function inventory_open() - if type(GameIsInventoryOpen) ~= "function" then return false end - local ok, value = pcall(GameIsInventoryOpen) - return ok and value == true -end - -local function raw_mouse_down(surface) - if type(InputIsMouseButtonDown) ~= "function" then return false end - local ok, down = pcall(InputIsMouseButtonDown, mouse_code(surface)) - return ok and down == true -end - -local function controls_down(controls, surface) - if controls == nil or controls == 0 or type(ComponentGetValue2) ~= "function" then return false end - local field = surface == "secondary" and "mButtonDownFire2" or "mButtonDownFire" - local ok, down = pcall(ComponentGetValue2, controls, field) - return ok and down == true -end - -local function input_allowed() - if ui_blocked or inventory_open() then return false end - if type(input_guard.actions_allowed) == "function" and input_guard.actions_allowed() ~= true then return false end - return true -end - -local function combined_down(controls, surface) - if not input_allowed() then return false end - return controls_down(controls, surface) or raw_mouse_down(surface) -end - -function gameplay_input.set_ui_blocked(blocked) - ui_blocked = blocked == true - if ui_blocked then - -- A mouse press used inside MCM must never become a gameplay edge when the menu - -- closes or a polymorph swaps the ControlsComponent under that same held click. - release_latches.primary = true - release_latches.secondary = true - previous.primary = false - previous.secondary = false - end -end - -function gameplay_input.ui_blocked() - return ui_blocked -end - -function gameplay_input.require_release(surface) - surface = surface == "secondary" and "secondary" or "primary" - release_latches[surface] = true - previous[surface] = false -end - -function gameplay_input.down(controls, surface) - surface = surface == "secondary" and "secondary" or "primary" - local down = combined_down(controls, surface) - if release_latches[surface] then - if not down then release_latches[surface] = false end - return false - end - return down -end - -function gameplay_input.just_pressed(controls, surface) - surface = surface == "secondary" and "secondary" or "primary" - local down = gameplay_input.down(controls, surface) - local pressed = down and not previous[surface] - previous[surface] = down - return pressed -end - -function gameplay_input.reset() - release_latches.primary = false - release_latches.secondary = false - previous.primary = false - previous.secondary = false -end - -METAMORPH_CREATIVE_MENU_GAMEPLAY_INPUT = gameplay_input -return gameplay_input diff --git a/metamorph_creative_menu/files/platform/noita/input_guard.lua b/metamorph_creative_menu/files/platform/noita/input_guard.lua index 01a2753..5792d4d 100644 --- a/metamorph_creative_menu/files/platform/noita/input_guard.lua +++ b/metamorph_creative_menu/files/platform/noita/input_guard.lua @@ -1,7 +1,6 @@ if type(METAMORPH_CREATIVE_MENU_INPUT_GUARD) == "table" then return METAMORPH_CREATIVE_MENU_INPUT_GUARD end local input_guard = {} -local text_entry_guard = dofile("mods/metamorph_creative_menu/files/platform/noita/text_entry_guard.lua") local keycodes = dofile("mods/metamorph_creative_menu/files/platform/noita/keycodes.lua") local alt_key_codes = nil @@ -83,11 +82,7 @@ function input_guard.update() -- while updates are suspended, so a discontinuity is a key-independent resume guard. local time_read_succeeded, real_time_seconds = pcall(GameGetRealWorldTimeSinceStarted) if time_read_succeeded and type(real_time_seconds) == "number" then - -- First-use search/localization work can exceed this threshold without any - -- window focus change. A timing gap alone must not tear down an owned editor. - -- Explicit Alt transitions above still quarantine input while typing. - if not text_entry_guard.active() and previous_real_time_seconds ~= nil - and real_time_seconds - previous_real_time_seconds > FOCUS_GAP_SECONDS then + if previous_real_time_seconds ~= nil and real_time_seconds - previous_real_time_seconds > FOCUS_GAP_SECONDS then quarantine_actions(frame_number, FOCUS_RESUME_QUARANTINE_FRAMES) mark_resume(frame_number, real_time_seconds, FOCUS_RESUME_HEAVY_QUARANTINE_SECONDS) end diff --git a/metamorph_creative_menu/files/platform/noita/inventory_slots.lua b/metamorph_creative_menu/files/platform/noita/inventory_slots.lua index 8974177..20b6d8d 100644 --- a/metamorph_creative_menu/files/platform/noita/inventory_slots.lua +++ b/metamorph_creative_menu/files/platform/noita/inventory_slots.lua @@ -1,8 +1,5 @@ -if type(METAMORPH_CREATIVE_MENU_INVENTORY_SLOTS) == "table" then return METAMORPH_CREATIVE_MENU_INVENTORY_SLOTS end - local inventory_slots = {} local policy = dofile("mods/metamorph_creative_menu/files/core/inventory_policy.lua") -local patcher_bridge = dofile("mods/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua") -- Noita represents both visible rows with coordinates 0..3. A wand and a potion -- may therefore legitimately share inventory_slot=0:0; their class selects the row. -- quick_inventory_slots=10 is an engine capacity, not a flat coordinate range. @@ -220,661 +217,4 @@ function inventory_slots.preflight(player, entity) return preflight(player, entity) end -local function magic_number(name, fallback) - if type(MagicNumbersGetValue) ~= "function" then return fallback end - local ok, value = pcall(MagicNumbersGetValue, name) - value = ok and tonumber(value) or nil - return value ~= nil and value or fallback -end - --- Returns the on-screen rectangle occupied by a vanilla inventory surface. Prefer live --- InventoryComponent geometry; the fallback derives the same top-right quick/full grids --- from the player's actual dimensions and Noita UI magic numbers. -function inventory_slots.native_drop_bounds(player, inventory_name, screen_width, screen_height) - inventory_name = tostring(inventory_name or "inventory_full") - if inventory_name ~= "inventory_full" and inventory_name ~= "inventory_quick" then return nil end - if inventory_name == "inventory_full" and type(GameIsInventoryOpen) == "function" then - local ok, opened = pcall(GameIsInventoryOpen) - if not ok or opened ~= true then return nil end - end - local inventory = named_inventory(player, inventory_name) - if inventory == 0 then return nil end - local width, height = inventory_limits(player, inventory) - if width == nil then return nil end - - local geometry = EntityGetFirstComponentIncludingDisabled(inventory, "InventoryComponent") - if valid(geometry) then - local ok_pos, x, y = pcall(ComponentGetValue2, geometry, "ui_position_on_screen") - local ok_item, item_w, item_h = pcall(ComponentGetValue2, geometry, "ui_element_size") - local ok_container, cells_x, cells_y = pcall(ComponentGetValue2, geometry, "ui_container_size") - x, y, item_w, item_h, cells_x, cells_y = tonumber(x), tonumber(y), tonumber(item_w), tonumber(item_h), tonumber(cells_x), tonumber(cells_y) - if ok_pos and ok_item and ok_container and x ~= nil and y ~= nil and item_w ~= nil and item_h ~= nil - and cells_x ~= nil and cells_y ~= nil and item_w > 0 and item_h > 0 and cells_x > 0 and cells_y > 0 - then - return {x=x, y=y, width=item_w * cells_x, height=item_h * cells_y, source="component"} - end - end - - screen_width, screen_height = tonumber(screen_width) or 427, tonumber(screen_height) or 242 - local icon_size = math.max(8, magic_number("INVENTORY_ICON_SIZE", 20)) - local margin_x = math.max(0, magic_number("UI_BARS_POS_X", 20)) - local top = math.max(0, magic_number("UI_BARS_POS_Y", 20) - 4) - local grid_width = math.min(screen_width, math.max(icon_size, width * icon_size)) - -- inventory_quick has two visible rows (wands and ordinary held items) sharing the - -- same numeric slot coordinates. Cover both rows so a catalog item can be dropped on - -- the vanilla quick inventory without inventing a second coordinate system. - local visible_rows = inventory_name == "inventory_quick" and 2 or height - local grid_height = math.min(screen_height - top, math.max(icon_size, visible_rows * icon_size + 8)) - local left = math.max(0, screen_width - grid_width - margin_x) - return {x=left, y=top, width=grid_width, height=grid_height, source="native_grid"} -end - -function inventory_slots.snapshot(player, inventory_name) - inventory_name = tostring(inventory_name or "") - local inventory = named_inventory(player, inventory_name) - if inventory == 0 then return nil, "inventory_missing" end - local width, height, kind = inventory_limits(player, inventory) - if width == nil then return nil, "inventory_shape" end - local result = { - inventory=inventory, name=inventory_name, kind=kind, width=width, height=height, - entries={}, by_slot={}, - } - for _, child in ipairs(EntityGetAllChildren(inventory) or {}) do - local x, y, item = item_slot(child) - if x ~= nil and valid(item) then - local record = { - entity=child, item_component=item, x=x, y=y, - is_action=is_action(child), is_wand=is_wand(child), - } - result.entries[#result.entries + 1] = record - local collides = inventory_name ~= "inventory_quick" or record.is_wand == true - local key = policy.slot_key(x, y) - if result.by_slot[key] == nil or collides then result.by_slot[key] = record end - end - end - table.sort(result.entries, function(a,b) - if a.y ~= b.y then return a.y < b.y end - if a.x ~= b.x then return a.x < b.x end - return tonumber(a.entity) < tonumber(b.entity) - end) - return result, "ok" -end - -local function restore_parent(entity, old_parent, old_x, old_y) - if EntityGetParent(entity) ~= nil and EntityGetParent(entity) ~= 0 then pcall(EntityRemoveFromParent, entity) end - if old_parent ~= nil and old_parent ~= 0 then - local item = EntityGetFirstComponentIncludingDisabled(entity, "ItemComponent") - -- Inventory containers snapshot an item's preferred slot while processing child - -- attachment. Restore the coordinate before reattaching, then confirm it again - -- afterwards so both ItemComponent and the native inventory view agree. - if valid(item) and old_x ~= nil then pcall(ComponentSetValue2, item, "inventory_slot", old_x, old_y or 0) end - pcall(EntityAddChild, old_parent, entity) - if valid(item) and old_x ~= nil then pcall(ComponentSetValue2, item, "inventory_slot", old_x, old_y or 0) end - if EntityGetName(old_parent) == "inventory_quick" or EntityGetName(old_parent) == "inventory_full" then enable_inventory(entity) end - else - enable_world(entity) - end -end - -local function force_refresh(player) - local inv2 = EntityGetFirstComponentIncludingDisabled(player, "Inventory2Component") - if valid(inv2) then pcall(ComponentSetValue2, inv2, "mForceRefresh", true) end -end - --- Existing action cards that have lived inside a wand are registered by Noita's native --- inventory system, not only by ItemComponent.inventory_slot. Direct child re-parenting can --- therefore be visually/readback-correct in Lua while InventoryGui later packs the card into --- another cell. Fresh catalogue cards do not suffer from this because they enter through the --- engine pickup path for the first time. --- --- Exact full-inventory moves below deliberately use GamePickUpInventoryItem as the authority. --- Before each pickup MCM temporarily occupies every other free spell cell with fresh action --- sentinels, so the requested cell is literally the only cell the engine can choose. The --- sentinels exist only inside this synchronous transaction and are removed before returning. --- This preserves the original spell entity and all of its runtime/modded components while --- letting Noita rebuild its private InventoryComponent.items registration itself. -local SENTINEL_ACTION = "LIGHT_BULLET" -local SENTINEL_TAG = "metamorph_creative_menu_inventory_sentinel" - -local function detach_verified(entity) - local parent = EntityGetParent(entity) or 0 - if parent == 0 then return true end - local removed = pcall(EntityRemoveFromParent, entity) - return removed and (EntityGetParent(entity) or 0) == 0 -end - -local function nested_parent_mode(entity, parent) - if parent == nil or parent == 0 then - enable_world(entity) - return - end - local name = EntityGetName(parent) - if name == "inventory_quick" or name == "inventory_full" then - enable_inventory(entity) - return - end - -- Action cards stored inside a wand are neither world nor player-inventory items. - EntitySetComponentsWithTagEnabled(entity, "enabled_in_hand", false) - EntitySetComponentsWithTagEnabled(entity, "enabled_in_world", false) - EntitySetComponentsWithTagEnabled(entity, "enabled_in_inventory", false) -end - -local function assignment_map(assignments) - local result = {} - for _, spec in ipairs(type(assignments) == "table" and assignments or {}) do - local entity = math.floor(tonumber(spec.entity) or 0) - local x, y = tonumber(spec.x), tonumber(spec.y) - if entity ~= 0 and x ~= nil and y ~= nil then - result[entity] = {x=math.floor(x), y=math.floor(y)} - end - end - return result -end - -local function destroy_sentinel(player, entity) - if entity == nil or entity == 0 or not EntityGetIsAlive(entity) then return end - -- GameKillInventoryItem is important here: the sentinel must leave the same private - -- InventoryComponent.items registration that GamePickUpInventoryItem created for it. - -- A bare EntityKill/RemoveFromParent would only clean the ECS hierarchy on some builds. - if type(GameKillInventoryItem) == "function" and parent_is_player_inventory(player, entity) then - pcall(GameKillInventoryItem, player, entity) - end - if EntityGetIsAlive(entity) then - if (EntityGetParent(entity) or 0) ~= 0 then pcall(EntityRemoveFromParent, entity) end - pcall(EntityKill, entity) - end -end - -local function destroy_sentinels(player, sentinels) - for index=#(sentinels or {}),1,-1 do destroy_sentinel(player, sentinels[index]) end -end - --- Register a temporary action through the *same engine pickup path* as the real card. This --- is intentionally not EntityAddChild: direct parenting updates the ECS tree but does not --- guarantee that Noita's private InventoryComponent.items vector knows about the child. --- The caller supplies the slot that should currently be the engine's first free cell and we --- verify that assumption after pickup. If a future Noita build orders free cells differently, --- the transaction fails safely instead of silently moving the user's spell. -local function create_registered_sentinel(player, inventory, expected_x, expected_y) - if type(CreateItemActionEntity) ~= "function" or type(GamePickUpInventoryItem) ~= "function" then - return 0, "sentinel_factory_missing" - end - local ok, entity = pcall(CreateItemActionEntity, SENTINEL_ACTION, 0, 0) - entity = ok and tonumber(entity) or 0 - if entity == nil or entity == 0 or not EntityGetIsAlive(entity) then return 0, "sentinel_create_failed" end - local item = EntityGetFirstComponentIncludingDisabled(entity, "ItemComponent") - if not valid(item) then pcall(EntityKill, entity); return 0, "sentinel_item_missing" end - if type(EntityAddTag) == "function" then pcall(EntityAddTag, entity, SENTINEL_TAG) end - pcall(ComponentSetValue2, item, "inventory_slot", expected_x, expected_y) - pcall(ComponentSetValue2, item, "permanently_attached", false) - pcall(ComponentSetValue2, item, "has_been_picked_by_player", true) - enable_world(entity) - - local picked = pcall(GamePickUpInventoryItem, player, entity, false) - local parent_ok = picked and EntityGetIsAlive(entity) - and tonumber(EntityGetParent(entity) or 0) == tonumber(inventory) - local read_ok, actual_x, actual_y = pcall(ComponentGetValue2, item, "inventory_slot") - local slot_ok = read_ok and tonumber(actual_x) == expected_x and (tonumber(actual_y) or 0) == expected_y - if not parent_ok or not slot_ok then - destroy_sentinel(player, entity) - return 0, not parent_ok and "sentinel_pickup_failed" or "sentinel_order_changed" - end - enable_inventory(entity) - return entity, "ok" -end - -local function linear_slot(x, y, width) - return (tonumber(y) or 0) * width + (tonumber(x) or 0) -end - --- Make the requested cell the first free native spell slot. Noita packs an existing card --- taken from a wand into its first free InventoryComponent.items position even when --- ItemComponent.inventory_slot already names a later cell. To beat that native packing rule --- without cloning the user's card, register short-lived action sentinels in every free cell --- *before* the target. They are picked up by Noita itself, so they really occupy its private --- vector. Once the target is first-free, the real card is picked up, then the sentinels are --- removed synchronously. Later holes are irrelevant and therefore never touched. -local function isolate_target_cell(player, inventory, width, height, target_x, target_y) - local current, reason = inventory_slots.snapshot(player, "inventory_full") - if current == nil or tonumber(current.inventory) ~= tonumber(inventory) then return nil, reason or "inventory_changed" end - local target_key = policy.slot_key(target_x, target_y) - if current.by_slot[target_key] ~= nil then return nil, "occupied" end - - local target_linear = linear_slot(target_x, target_y, width) - local sentinels = {} - for index=0,target_linear-1 do - local x = index % width - local y = math.floor(index / width) - if y < height then - local key = policy.slot_key(x, y) - if current.by_slot[key] == nil then - local sentinel, sentinel_reason = create_registered_sentinel(player, inventory, x, y) - if sentinel == 0 then - destroy_sentinels(player, sentinels) - return nil, sentinel_reason - end - sentinels[#sentinels+1] = sentinel - -- Keep the local occupancy model in sync so the expected first-free slot for - -- the next sentinel advances exactly as the engine did. - current.by_slot[key] = {entity=sentinel,x=x,y=y,is_action=true} - end - end - end - return sentinels, "ok" -end - -local function engine_pickup_exact(player, entity, inventory, width, height, x, y) - if not EntityGetIsAlive(entity) then return false, "invalid" end - local item = EntityGetFirstComponentIncludingDisabled(entity, "ItemComponent") - if not valid(item) then return false, "not_item" end - if (EntityGetParent(entity) or 0) ~= 0 and not detach_verified(entity) then return false, "detach_failed" end - - local sentinels, isolate_reason = isolate_target_cell(player, inventory, width, height, x, y) - if sentinels == nil then return false, isolate_reason end - - -- Publish the preferred cell too, but do not manually touch mItemUid/mFramePickedUp: - -- GamePickUpInventoryItem owns those native registration fields and is the operation - -- whose result we need InventoryGui to trust. - pcall(ComponentSetValue2, item, "inventory_slot", x, y) - pcall(ComponentSetValue2, item, "permanently_attached", false) - pcall(ComponentSetValue2, item, "has_been_picked_by_player", true) - enable_world(entity) - local picked = pcall(GamePickUpInventoryItem, player, entity, false) - local parent_ok = picked and EntityGetIsAlive(entity) and tonumber(EntityGetParent(entity) or 0) == tonumber(inventory) - local read_ok, actual_x, actual_y = pcall(ComponentGetValue2, item, "inventory_slot") - local slot_ok = read_ok and tonumber(actual_x) == x and (tonumber(actual_y) or 0) == y - - destroy_sentinels(player, sentinels) - if not parent_ok or not slot_ok then - if (EntityGetParent(entity) or 0) ~= 0 then pcall(EntityRemoveFromParent, entity) end - enable_world(entity) - return false, not parent_ok and "pickup_wrong_inventory" or "pickup_wrong_slot" - end - enable_inventory(entity) - return true, "ok" -end - --- Move one or more existing spell entities to exact inventory_full cells. Every requested --- entity is detached before the first pickup so swaps are collision-free. Each insertion is --- then performed by Noita itself with all non-target holes temporarily blocked. -local function rebuild_full_exact_legacy(player, assignments) - local snapshot, reason = inventory_slots.snapshot(player, "inventory_full") - if snapshot == nil then return false, reason end - local requested = assignment_map(assignments) - if next(requested) == nil then return false, "no_assignments" end - - local records = {} - local requested_entities = {} - for entity, desired in pairs(requested) do - if not EntityGetIsAlive(entity) or not is_action(entity) then return false, "invalid_action" end - local old_x, old_y, item = item_slot(entity) - if not valid(item) then return false, "not_item" end - if desired.x < 0 or desired.y < 0 or desired.x >= snapshot.width or desired.y >= snapshot.height then - return false, "slot_out_of_range" - end - records[#records+1] = { - entity=entity, item=item, old_parent=EntityGetParent(entity) or 0, - old_x=old_x, old_y=old_y or 0, x=desired.x, y=desired.y, - } - requested_entities[entity] = true - end - - -- Requested cells may be occupied by another requested entity (swap), but never by an - -- untouched spell. Also reject duplicate destination cells before mutating anything. - local destinations = {} - for _, record in ipairs(records) do - local key = policy.slot_key(record.x, record.y) - if destinations[key] ~= nil and destinations[key] ~= record.entity then return false, "occupied", destinations[key] end - destinations[key] = record.entity - local occupied = snapshot.by_slot[key] - if occupied ~= nil and occupied.entity ~= record.entity and requested_entities[occupied.entity] ~= true then - return false, "occupied", occupied.entity - end - end - - table.sort(records, function(a,b) - if a.y ~= b.y then return a.y < b.y end - if a.x ~= b.x then return a.x < b.x end - return tonumber(a.entity) < tonumber(b.entity) - end) - - local function direct_restore(record) - if not EntityGetIsAlive(record.entity) then return false end - if (EntityGetParent(record.entity) or 0) ~= 0 then pcall(EntityRemoveFromParent, record.entity) end - local item = EntityGetFirstComponentIncludingDisabled(record.entity, "ItemComponent") - if not valid(item) then return false end - record.item = item - if record.old_parent ~= 0 and EntityGetName(record.old_parent) == "inventory_full" - and EntityGetRootEntity(record.old_parent) == player and record.old_x ~= nil - then - local ok = engine_pickup_exact(player, record.entity, record.old_parent, snapshot.width, snapshot.height, record.old_x, record.old_y or 0) - if ok then return true end - end - if record.old_x ~= nil then pcall(ComponentSetValue2, item, "inventory_slot", record.old_x, record.old_y or 0) end - if record.old_parent ~= nil and record.old_parent ~= 0 then - pcall(EntityAddChild, record.old_parent, record.entity) - if record.old_x ~= nil then pcall(ComponentSetValue2, item, "inventory_slot", record.old_x, record.old_y or 0) end - end - nested_parent_mode(record.entity, record.old_parent) - return tonumber(EntityGetParent(record.entity) or 0) == tonumber(record.old_parent or 0) - end - - local function rollback() - for _, record in ipairs(records) do - if EntityGetIsAlive(record.entity) and (EntityGetParent(record.entity) or 0) ~= 0 then pcall(EntityRemoveFromParent, record.entity) end - end - -- Restore inventory cells first in original cell order; wand/other parents follow. - table.sort(records, function(a,b) - local ai = a.old_parent ~= 0 and EntityGetName(a.old_parent) == "inventory_full" and 0 or 1 - local bi = b.old_parent ~= 0 and EntityGetName(b.old_parent) == "inventory_full" and 0 or 1 - if ai ~= bi then return ai < bi end - if (a.old_y or 0) ~= (b.old_y or 0) then return (a.old_y or 0) < (b.old_y or 0) end - return (a.old_x or -1) < (b.old_x or -1) - end) - for _, record in ipairs(records) do direct_restore(record) end - force_refresh(player) - end - - for _, record in ipairs(records) do - if not detach_verified(record.entity) then rollback(); return false, "detach_failed" end - end - - local expected = {} - for _, record in ipairs(records) do - local placed, place_reason = engine_pickup_exact(player, record.entity, snapshot.inventory, snapshot.width, snapshot.height, record.x, record.y) - if not placed then rollback(); return false, place_reason end - expected[#expected+1] = {entity=record.entity,parent=snapshot.inventory,x=record.x,y=record.y} - -- Catch any immediate engine packing before inserting the next member of a swap. - for _, spec in ipairs(expected) do - local sx, sy = item_slot(spec.entity) - if tonumber(EntityGetParent(spec.entity) or 0) ~= tonumber(spec.parent) - or tonumber(sx) ~= tonumber(spec.x) or tonumber(sy or 0) ~= tonumber(spec.y) - then - rollback(); return false, "pickup_drift" - end - end - end - force_refresh(player) - return true, "ok", expected -end - -local function exact_rehydrate_bridge() - if type(patcher_bridge) ~= "table" or type(patcher_bridge.get) ~= "function" then return nil end - local bridge = patcher_bridge.get({bootstrap_if_installed=true,capability="SerializeEntity"}) - if type(bridge) ~= "table" or type(bridge.SerializeEntity) ~= "function" - or type(bridge.DeserializeEntity) ~= "function" or type(EntityCreateNew) ~= "function" - then return nil end - return bridge -end - -local function rehydrate_create(bridge, serialized, parent, x, y, inventory_parent) - local ok_new, entity = pcall(EntityCreateNew) - entity = ok_new and tonumber(entity) or 0 - if entity == nil or entity == 0 then return 0, "rehydrate_create_failed" end - local ok_deserialize = pcall(bridge.DeserializeEntity, entity, serialized) - if not ok_deserialize or not EntityGetIsAlive(entity) then - if EntityGetIsAlive(entity) then pcall(EntityKill, entity) end - return 0, "rehydrate_deserialize_failed" - end - local item = EntityGetFirstComponentIncludingDisabled(entity, "ItemComponent") - if not valid(item) or not is_action(entity) then pcall(EntityKill, entity); return 0, "rehydrate_components_missing" end - - -- Serialized cards deliberately keep gameplay/modded component state, but these two - -- fields are native inventory registration history and must belong to the new entity. - pcall(ComponentSetValue2, item, "mItemUid", 0) - pcall(ComponentSetValue2, item, "mFramePickedUp", 0) - if x ~= nil then pcall(ComponentSetValue2, item, "inventory_slot", x, y or 0) end - if inventory_parent then - pcall(ComponentSetValue2, item, "permanently_attached", false) - pcall(ComponentSetValue2, item, "has_been_picked_by_player", true) - end - local added = parent ~= nil and parent ~= 0 and pcall(EntityAddChild, parent, entity) - if parent ~= nil and parent ~= 0 and (not added or tonumber(EntityGetParent(entity) or 0) ~= tonumber(parent)) then - pcall(EntityKill, entity) - return 0, "rehydrate_attach_failed" - end - if x ~= nil then - pcall(ComponentSetValue2, item, "inventory_slot", x, y or 0) - local ok_slot, actual_x, actual_y = pcall(ComponentGetValue2, item, "inventory_slot") - if not ok_slot or tonumber(actual_x) ~= tonumber(x) or tonumber(actual_y or 0) ~= tonumber(y or 0) then - pcall(EntityKill, entity) - return 0, "rehydrate_slot_failed" - end - end - nested_parent_mode(entity, parent) - return entity, "ok" -end - --- Full/unsafe builds can sidestep the stale native Item/Inventory registration entirely. --- We serialize the real spell, recreate it as a fresh entity with the same complete ECS --- state, and attach that fresh entity at the requested inventory cell. This is the same --- class of entity that catalogue-created cards use, which is the path Noita already honors --- exactly. Old entities are retired through GameKillInventoryItem when applicable so the --- private InventoryComponent.items vector cannot keep a stale registration behind. -local function rebuild_full_exact_rehydrated(player, assignments) - local bridge = exact_rehydrate_bridge() - if bridge == nil then return nil, "rehydrate_unavailable" end - local snapshot, reason = inventory_slots.snapshot(player, "inventory_full") - if snapshot == nil then return false, reason end - local requested = assignment_map(assignments) - if next(requested) == nil then return false, "no_assignments" end - - local records, requested_entities, destinations = {}, {}, {} - local needs_rehydrate = false - for entity, desired in pairs(requested) do - if not EntityGetIsAlive(entity) or not is_action(entity) then return false, "invalid_action" end - local old_x, old_y, item = item_slot(entity) - if not valid(item) then return false, "not_item" end - if desired.x < 0 or desired.y < 0 or desired.x >= snapshot.width or desired.y >= snapshot.height then - return false, "slot_out_of_range" - end - local ok_uid, uid = pcall(ComponentGetValue2, item, "mItemUid") - local ok_frame, picked_frame = pcall(ComponentGetValue2, item, "mFramePickedUp") - if (ok_uid and (tonumber(uid) or 0) ~= 0) or (ok_frame and (tonumber(picked_frame) or 0) ~= 0) - or (EntityGetParent(entity) or 0) ~= 0 - then needs_rehydrate = true end - local ok_data, serialized = pcall(bridge.SerializeEntity, entity) - if not ok_data or serialized == nil then return false, "rehydrate_serialize_failed" end - local key = policy.slot_key(desired.x, desired.y) - if destinations[key] ~= nil and destinations[key] ~= entity then return false, "occupied", destinations[key] end - destinations[key] = entity - records[#records+1] = { - entity=entity, serialized=serialized, old_parent=EntityGetParent(entity) or 0, - old_x=old_x, old_y=old_y or 0, x=desired.x, y=desired.y, - } - requested_entities[entity]=true - end - if not needs_rehydrate then return nil, "fresh_entity" end - for _, record in ipairs(records) do - local occupied = snapshot.by_slot[policy.slot_key(record.x,record.y)] - if occupied ~= nil and occupied.entity ~= record.entity and requested_entities[occupied.entity] ~= true then - return false, "occupied", occupied.entity - end - end - table.sort(records,function(a,b) - if a.y ~= b.y then return a.y < b.y end - if a.x ~= b.x then return a.x < b.x end - return tonumber(a.entity) < tonumber(b.entity) - end) - - -- Retire every old registration only after every entity serialized successfully. - for _, record in ipairs(records) do - local old_parent_name = record.old_parent ~= 0 and EntityGetName(record.old_parent) or "" - if old_parent_name == "inventory_full" and EntityGetRootEntity(record.old_parent) == player - and type(GameKillInventoryItem) == "function" - then - pcall(GameKillInventoryItem, player, record.entity) - end - if EntityGetIsAlive(record.entity) then - if (EntityGetParent(record.entity) or 0) ~= 0 then pcall(EntityRemoveFromParent, record.entity) end - pcall(EntityKill, record.entity) - end - end - - local created = {} - - -- A fresh catalog card is the one path the real game demonstrably accepts into an exact - -- full-inventory cell. Rehydration therefore only creates the fresh ECS entity; the - -- final registration still goes through the exact same GamePickUpInventoryItem + - -- sentinel transaction as a catalog-created card. Direct EntityAddChild here would - -- recreate the original bug by bypassing InventoryComponent.items again. - local function create_into_inventory(serialized, x, y) - local entity, create_reason = rehydrate_create(bridge, serialized, 0, x, y, false) - if entity == 0 then return 0, create_reason end - local placed, place_reason = engine_pickup_exact(player, entity, snapshot.inventory, snapshot.width, snapshot.height, x, y) - if not placed then - if EntityGetIsAlive(entity) then pcall(EntityKill, entity) end - return 0, place_reason - end - return entity, "ok" - end - - local function rollback() - for _, entity in ipairs(created) do if EntityGetIsAlive(entity) then pcall(EntityKill,entity) end end - created = {} - -- Entity ids cannot be restored after rehydration, but the complete serialized - -- spell state and its original parent/slot are restored transactionally. Inventory - -- parents are restored through native pickup so their private item vector is rebuilt. - for _, record in ipairs(records) do - local inv_parent = record.old_parent ~= 0 and EntityGetName(record.old_parent) == "inventory_full" - and EntityGetRootEntity(record.old_parent) == player - local restored = 0 - if inv_parent then - restored = select(1, create_into_inventory(record.serialized, record.old_x, record.old_y)) - else - restored = select(1, rehydrate_create(bridge,record.serialized,record.old_parent,record.old_x,record.old_y,false)) - end - if restored ~= 0 then created[#created+1]=restored end - end - force_refresh(player) - end - - local expected, replacements = {}, {} - for _, record in ipairs(records) do - local entity, create_reason = create_into_inventory(record.serialized, record.x, record.y) - if entity == 0 then rollback(); return false, create_reason end - created[#created+1]=entity - replacements[record.entity]=entity - expected[#expected+1]={entity=entity,parent=snapshot.inventory,x=record.x,y=record.y,replaces=record.entity} - end - force_refresh(player) - return true, "rehydrated", expected, replacements -end - -local function rebuild_full_exact(player, assignments) - local ok, reason, expected, replacements = rebuild_full_exact_rehydrated(player, assignments) - if ok ~= nil then return ok, reason, expected, replacements end - return rebuild_full_exact_legacy(player, assignments) -end - -function inventory_slots.rebuild_full_exact(player, assignments) - return rebuild_full_exact(player, assignments) -end - -function inventory_slots.place_exact(player, entity, inventory_name, x, y) - if player == nil or player == 0 or entity == nil or entity == 0 or not EntityGetIsAlive(entity) then - return false, "invalid" - end - inventory_name = tostring(inventory_name or "") - if policy.destination(is_action(entity)) ~= inventory_name then return false, "wrong_inventory" end - local snapshot, reason = inventory_slots.snapshot(player, inventory_name) - if snapshot == nil then return false, reason end - x, y = math.floor(tonumber(x) or -1), math.floor(tonumber(y) or -1) - if x < 0 or y < 0 or x >= snapshot.width or y >= snapshot.height then return false, "slot_out_of_range" end - local occupied = snapshot.by_slot[policy.slot_key(x, y)] - if occupied ~= nil and occupied.entity ~= entity then return false, "occupied", occupied end - - if inventory_name == "inventory_full" then - return rebuild_full_exact(player, {{entity=entity,x=x,y=y}}) - end - - local item = EntityGetFirstComponentIncludingDisabled(entity, "ItemComponent") - if not valid(item) then return false, "not_item" end - local old_parent = EntityGetParent(entity) or 0 - local old_x, old_y = item_slot(entity) - if not detach_verified(entity) then return false, "detach_failed" end - local prepared = pcall(ComponentSetValue2, item, "inventory_slot", x, y) - if not prepared then - restore_parent(entity, old_parent, old_x, old_y) - return false, "slot_write_failed" - end - local added = pcall(EntityAddChild, snapshot.inventory, entity) - if not added or EntityGetParent(entity) ~= snapshot.inventory then - restore_parent(entity, old_parent, old_x, old_y) - return false, "attach_failed" - end - local wrote = pcall(ComponentSetValue2, item, "inventory_slot", x, y) - local read_ok, actual_x, actual_y = pcall(ComponentGetValue2, item, "inventory_slot") - if not wrote or not read_ok or tonumber(actual_x) ~= x or (tonumber(actual_y) or 0) ~= y then - restore_parent(entity, old_parent, old_x, old_y) - return false, "slot_write_failed" - end - enable_inventory(entity) - force_refresh(player) - return true, "ok", {{entity=entity,parent=snapshot.inventory,x=x,y=y}} -end - -function inventory_slots.swap_exact(player, left_entity, right_entity) - if left_entity == nil or right_entity == nil or left_entity == right_entity then return false, "invalid" end - if not EntityGetIsAlive(left_entity) or not EntityGetIsAlive(right_entity) then return false, "invalid" end - local left_parent, right_parent = EntityGetParent(left_entity), EntityGetParent(right_entity) - if left_parent == nil or left_parent == 0 or left_parent ~= right_parent then return false, "different_inventory" end - local name = EntityGetName(left_parent) - if name ~= "inventory_quick" and name ~= "inventory_full" then return false, "not_inventory" end - if EntityGetRootEntity(left_parent) ~= player then return false, "foreign_inventory" end - local lx, ly, li = item_slot(left_entity) - local rx, ry, ri = item_slot(right_entity) - if not valid(li) or not valid(ri) or lx == nil or rx == nil then return false, "slot_missing" end - - if name == "inventory_full" then - return rebuild_full_exact(player, { - {entity=left_entity,x=rx,y=ry}, - {entity=right_entity,x=lx,y=ly}, - }) - end - - local function restore_swap() - if (EntityGetParent(left_entity) or 0) ~= 0 then pcall(EntityRemoveFromParent, left_entity) end - if (EntityGetParent(right_entity) or 0) ~= 0 then pcall(EntityRemoveFromParent, right_entity) end - pcall(ComponentSetValue2, li, "inventory_slot", lx, ly) - pcall(ComponentSetValue2, ri, "inventory_slot", rx, ry) - pcall(EntityAddChild, left_parent, left_entity) - pcall(EntityAddChild, left_parent, right_entity) - pcall(ComponentSetValue2, li, "inventory_slot", lx, ly) - pcall(ComponentSetValue2, ri, "inventory_slot", rx, ry) - enable_inventory(left_entity) - enable_inventory(right_entity) - force_refresh(player) - end - - if not detach_verified(left_entity) or not detach_verified(right_entity) then - restore_swap() - return false, "swap_detach_failed" - end - local left_prepared = pcall(ComponentSetValue2, li, "inventory_slot", rx, ry) - local right_prepared = left_prepared and pcall(ComponentSetValue2, ri, "inventory_slot", lx, ly) - local left_added = right_prepared and pcall(EntityAddChild, left_parent, left_entity) - local right_added = left_added and pcall(EntityAddChild, left_parent, right_entity) - local left_ok = right_added and pcall(ComponentSetValue2, li, "inventory_slot", rx, ry) - local right_ok = left_ok and pcall(ComponentSetValue2, ri, "inventory_slot", lx, ly) - local verify_left, actual_lx, actual_ly = pcall(ComponentGetValue2, li, "inventory_slot") - local verify_right, actual_rx, actual_ry = pcall(ComponentGetValue2, ri, "inventory_slot") - if not right_ok or EntityGetParent(left_entity) ~= left_parent or EntityGetParent(right_entity) ~= left_parent - or not verify_left or not verify_right - or tonumber(actual_lx) ~= rx or (tonumber(actual_ly) or 0) ~= (tonumber(ry) or 0) - or tonumber(actual_rx) ~= lx or (tonumber(actual_ry) or 0) ~= (tonumber(ly) or 0) - then - restore_swap() - return false, "swap_failed" - end - enable_inventory(left_entity) - enable_inventory(right_entity) - force_refresh(player) - return true, "ok", { - {entity=left_entity,parent=left_parent,x=rx,y=ry}, - {entity=right_entity,parent=left_parent,x=lx,y=ly}, - } -end - -METAMORPH_CREATIVE_MENU_INVENTORY_SLOTS = inventory_slots return inventory_slots diff --git a/metamorph_creative_menu/files/platform/noita/keycodes.lua b/metamorph_creative_menu/files/platform/noita/keycodes.lua index 0ed7690..5ef8ecb 100644 --- a/metamorph_creative_menu/files/platform/noita/keycodes.lua +++ b/metamorph_creative_menu/files/platform/noita/keycodes.lua @@ -104,48 +104,5 @@ function keycodes.matching_name_fragment(fragment) return matching_codes end -local PRETTY_KEYS = { - RETURN="ENTER", ESCAPE="ESC", BACKSPACE="BACKSPACE", SPACE="SPACE", TAB="TAB", - DELETE="DELETE", INSERT="INSERT", HOME="HOME", END="END", PAGEUP="PAGE UP", - PAGEDOWN="PAGE DOWN", LEFT="LEFT", RIGHT="RIGHT", UP="UP", DOWN="DOWN", - LSHIFT="LEFT SHIFT", RSHIFT="RIGHT SHIFT", LCTRL="LEFT CTRL", RCTRL="RIGHT CTRL", - LALT="LEFT ALT", RALT="RIGHT ALT", CAPSLOCK="CAPS LOCK", NUMLOCKCLEAR="NUM LOCK", - PRINTSCREEN="PRINT SCREEN", SCROLLLOCK="SCROLL LOCK", PAUSE="PAUSE", -} - -function keycodes.pretty_name(name) - local raw = string.gsub(tostring(name or ""), "^Key_", "") - if PRETTY_KEYS[raw] ~= nil then return PRETTY_KEYS[raw] end - if string.match(raw, "^[a-z]$") then return string.upper(raw) end - if string.match(raw, "^%d$") or string.match(raw, "^F%d+$") then return raw end - raw = string.gsub(raw, "^KP_", "NUM ") - return string.upper(string.gsub(raw, "_", " ")) -end - -local available_cache = nil -function keycodes.available() - if available_cache ~= nil then return available_cache end - ensure_noita_globals() - local by_code = {} - local function consider(name, value) - value = tonumber(value) - if type(name) ~= "string" or string.sub(name, 1, 4) ~= "Key_" or value == nil then return end - local current = by_code[value] - if current == nil or #name < #current then by_code[value] = name end - end - for name, value in pairs(_G) do consider(name, value) end - for name, value in pairs(load_parsed_codes()) do consider(name, value) end - local result = {} - for code, name in pairs(by_code) do - result[#result + 1] = { code=code, name=name, label=keycodes.pretty_name(name) } - end - table.sort(result, function(a, b) - if a.code == b.code then return a.name < b.name end - return a.code < b.code - end) - available_cache = result - return result -end - METAMORPH_CREATIVE_MENU_NOITA_KEYCODES = keycodes return keycodes diff --git a/metamorph_creative_menu/files/platform/noita/localization.lua b/metamorph_creative_menu/files/platform/noita/localization.lua index b019bb1..3cb2416 100644 --- a/metamorph_creative_menu/files/platform/noita/localization.lua +++ b/metamorph_creative_menu/files/platform/noita/localization.lua @@ -1,100 +1,5 @@ local localization = {} local TRANSLATIONS = "mods/metamorph_creative_menu/translations.csv" -local catalog = nil -local detected_language_column = nil -local english_catalog = nil - -local function parse_csv_line(line) - local fields, field, quoted = {}, "", false - local index, length = 1, #tostring(line or "") - while index <= length do - local char = string.sub(line, index, index) - if char == '"' then - if quoted and string.sub(line, index + 1, index + 1) == '"' then - field = field .. '"' - index = index + 1 - else - quoted = not quoted - end - elseif char == "," and not quoted then - fields[#fields + 1], field = field, "" - else - field = field .. char - end - index = index + 1 - end - fields[#fields + 1] = field - return fields -end - -local function read_file(path) - if type(ModTextFileGetContent) ~= "function" then return nil end - local ok, value = pcall(ModTextFileGetContent, path) - return ok and type(value) == "string" and value or nil -end - -local function load_catalog() - if catalog ~= nil then return catalog end - catalog = { rows={}, header={} } - local content = read_file(TRANSLATIONS) - if content == nil or content == "" then return catalog end - local first = true - for line in string.gmatch(content .. "\n", "([^\r\n]*)[\r]?\n") do - local fields = parse_csv_line(line) - if first then - catalog.header = fields - first = false - elseif fields[1] ~= nil and fields[1] ~= "" then - catalog.rows["$" .. fields[1]] = fields - end - end - return catalog -end - - -local function load_english_catalog() - if english_catalog ~= nil then return english_catalog end - english_catalog = {} - local content = read_file("data/translations/common.csv") - if content == nil or content == "" then return english_catalog end - local first = true - for line in string.gmatch(content .. "\n", "([^\r\n]*)[\r]?\n") do - local fields = parse_csv_line(line) - if first then - first = false - elseif fields[1] ~= nil and fields[1] ~= "" and fields[2] ~= nil and fields[2] ~= "" then - english_catalog["$" .. fields[1]] = fields[2] - end - end - return english_catalog -end - -local function engine_translation(key) - if type(GameTextGetTranslatedOrNot) ~= "function" then return nil end - local ok, value = pcall(GameTextGetTranslatedOrNot, key) - return ok and type(value) == "string" and value or nil -end - -local function language_column() - if detected_language_column ~= nil then return detected_language_column end - local data = load_catalog() - -- These long-standing rows are already known to Noita before settings.lua runs, - -- and their values differ across the shipped languages. They let the separate - -- settings VM select the correct column for newly appended rows immediately. - for _, anchor in ipairs({ "$mcm_tab_spells", "$mcm_tab_items", "$mcm_tab_perks" }) do - local translated, row = engine_translation(anchor), data.rows[anchor] - if translated ~= nil and translated ~= anchor and row ~= nil then - for column = 2, #row do - if row[column] == translated then - detected_language_column = column - return column - end - end - end - end - detected_language_column = 2 -- CSV's English column is the safe final fallback. - return detected_language_column -end function localization.register() if not ModDoesFileExist(TRANSLATIONS) then return false end @@ -103,67 +8,9 @@ function localization.register() if not ok_base or not ok_append or type(base) ~= "string" or type(append) ~= "string" then return false end local body = string.match(append, "^[^\n]*\n(.*)$") or "" if body == "" then return false end - - -- settings.lua and init.lua execute in different Lua contexts. Registering from - -- both must therefore be idempotent, and an older partially appended table must - -- be upgraded instead of leaving every newly introduced label untranslated. - local existing = {} - for line in string.gmatch(base .. "\n", "([^\r\n]*)[\r]?\n") do - local key = string.match(line, "^([^,]+),") - if key ~= nil and key ~= "" then existing[key] = true end - end - local missing = {} - for line in string.gmatch(body .. "\n", "([^\r\n]*)[\r]?\n") do - local key = string.match(line, "^([^,]+),") - if key ~= nil and key ~= "" and not existing[key] then - missing[#missing + 1] = line - existing[key] = true - end - end - if #missing == 0 then return true end if not string.match(base, "\n$") then base = base .. "\n" end - ModTextFileSetContent("data/translations/common.csv", base .. table.concat(missing, "\n") .. "\n") + ModTextFileSetContent("data/translations/common.csv", base .. body .. (string.match(body, "\n$") and "" or "\n")) return true end -function localization.translate(key, fallback) - if key == nil or key == "" then return "" end - key = tostring(key) - local translated = engine_translation(key) - if translated ~= nil and translated ~= "" and translated ~= key then return translated end - local row = load_catalog().rows[key] - if row ~= nil then - local local_value = row[language_column()] - if type(local_value) == "string" and local_value ~= "" then return local_value end - end - return fallback or key -end - -function localization.english(key, fallback) - if key == nil or key == "" then return "" end - key = tostring(key) - local own_row = load_catalog().rows[key] - if own_row ~= nil and type(own_row[2]) == "string" and own_row[2] ~= "" then - return own_row[2] - end - local vanilla = load_english_catalog()[key] - if type(vanilla) == "string" and vanilla ~= "" then return vanilla end - return fallback or key -end - -function localization.search_aliases(key, fallback) - key = tostring(key or "") - local values, seen = {}, {} - local function add(value) - value = tostring(value or "") - if value ~= "" and not seen[value] then seen[value] = true; values[#values + 1] = value end - end - add(localization.translate(key, fallback)) - add(localization.english(key, fallback)) - add(fallback) - add(key) - if string.sub(key, 1, 1) == "$" then add(string.sub(key, 2)) end - return values -end - return localization diff --git a/metamorph_creative_menu/files/platform/noita/material_grid.lua b/metamorph_creative_menu/files/platform/noita/material_grid.lua deleted file mode 100644 index 16a50e7..0000000 --- a/metamorph_creative_menu/files/platform/noita/material_grid.lua +++ /dev/null @@ -1,228 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_MATERIAL_GRID_BACKEND) == "table" then - return METAMORPH_CREATIVE_MENU_MATERIAL_GRID_BACKEND -end - -local backend = {} - -local patcher_bridge = dofile("mods/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua") - -local world_ffi = nil -local init_failed_frame = -100000 -local INIT_RETRY_FRAMES = 120 -local CELL_TYPE_SOLID = 3 - -local function frame_number() - if type(GameGetFrameNum) ~= "function" then return 0 end - local ok, value = pcall(GameGetFrameNum) - return ok and (tonumber(value) or 0) or 0 -end - -local function ensure_world_ffi() - if type(world_ffi) == "table" then return world_ffi end - local frame = frame_number() - if frame - init_failed_frame < INIT_RETRY_FRAMES then return nil, "retry" end - local np = patcher_bridge.get({ bootstrap_if_installed=true, capability="GetWorldInfo" }) - if type(np) ~= "table" then init_failed_frame = frame; return nil, "noitapatcher" end - local ok, module = pcall(require, "noitapatcher.nsew.world_ffi") - if not ok or type(module) ~= "table" then init_failed_frame = frame; return nil, "world_ffi" end - world_ffi = module - return world_ffi -end - -local function material_id(material_name) - if type(material_name) ~= "string" or material_name == "" or type(CellFactory_GetType) ~= "function" then return nil end - local ok, value = pcall(CellFactory_GetType, material_name) - value = ok and tonumber(value) or nil - if value == nil or value < 0 then return nil end - return math.floor(value) -end - -function backend.status() - local ffi_world, reason = ensure_world_ffi() - if ffi_world == nil then return false, reason end - return true, "ready" -end - -function backend.material_id(material_name) return material_id(material_name) end - -function backend.material_color(material_name) - local id = material_id(material_name) - if id == nil then return nil end - local ffi_world = ensure_world_ffi() - if type(ffi_world) ~= "table" then return nil end - local ok, packed = pcall(function() - local ptr = ffi_world.get_material_ptr(id) - if ptr == nil then return nil end - local value = tonumber(ptr.default_primary_colour) - if value == nil or value == 0 then value = tonumber(ptr.wang_color) end - return value - end) - packed = ok and tonumber(packed) or nil - if packed == nil then return nil end - packed = packed % 4294967296 - local b = packed % 256 - local g = math.floor(packed / 256) % 256 - local r = math.floor(packed / 65536) % 256 - return { r / 255, g / 255, b / 255, 0.96 } -end - -function backend.begin_paint(material_name, options) - options = type(options) == "table" and options or {} - local id = material_id(material_name) - if id == nil then return nil, "material" end - local ffi_world, reason = ensure_world_ffi() - if ffi_world == nil then return nil, reason end - local ok, context, detail = pcall(function() - local grid_world = ffi_world.get_grid_world() - if grid_world == nil then return nil, "grid_world" end - local chunk_map = grid_world.vtable.get_chunk_map(grid_world) - if chunk_map == nil then return nil, "chunk_map" end - local ptr = nil - local cell_type = 0 - local liquid_static = false - local solid_static_type = 0 - local platform_type = 0 - local cell_holes_in_texture = false - if id ~= 0 then - ptr = ffi_world.get_material_ptr(id) - if ptr ~= nil then - local ok_fields, fields = pcall(function() - return { - cell_type=tonumber(ptr.cell_type), - liquid_static=ptr.liquid_static == true, - solid_static_type=tonumber(ptr.solid_static_type), - platform_type=tonumber(ptr.platform_type), - cell_holes_in_texture=ptr.cell_holes_in_texture == true, - } - end) - if ok_fields and type(fields) == "table" then - cell_type = math.floor(tonumber(fields.cell_type) or 0) - liquid_static = fields.liquid_static == true - solid_static_type = math.floor(tonumber(fields.solid_static_type) or 0) - platform_type = math.floor(tonumber(fields.platform_type) or 0) - cell_holes_in_texture = fields.cell_holes_in_texture == true - end - end - end - -- Noita's material taxonomy is not identical to CellData.cell_type. In - -- particular many authored terrain materials (for example *_static rock-like - -- materials) are represented internally as static liquid/sand cells so that - -- CellFactory_GetAllSolids() does not imply CELL_TYPE_SOLID. construct_cell() - -- may also legitimately return nil on a transparent material-texture texel. - -- Route every static/textured terrain material through PixelScene placement; - -- keep the proven direct-cell path for ordinary liquids/powders/gases/fires. - local requires_scene = options.force_scene == true - or (id ~= 0 and ptr == nil) - or cell_type == CELL_TYPE_SOLID - or liquid_static - or solid_static_type > 0 - or platform_type > 0 - or cell_holes_in_texture - return { - ffi=ffi_world, grid_world=grid_world, chunk_map=chunk_map, - material_id=id, material_ptr=ptr, material_name=material_name, - cell_type=cell_type, liquid_static=liquid_static, - solid_static_type=solid_static_type, platform_type=platform_type, - cell_holes_in_texture=cell_holes_in_texture, - mode=requires_scene and "solid_scene" or "direct_cell", - }, "ready" - end) - if not ok then return nil, "ffi_error" end - return context, detail -end - -local function restore_previous(context, ppixel, old_id, x, y) - if old_id == nil or old_id == 0 then return false end - local ffi_world = context.ffi - local old_ptr = ffi_world.get_material_ptr(old_id) - if old_ptr == nil then return false end - local old_cell = ffi_world.construct_cell(context.grid_world, x, y, old_ptr, nil) - if old_cell == nil then return false end - ppixel[0] = old_cell - return true -end - --- Fast direct-cell path for ordinary liquids, powders, gases and fires. Static/textured --- terrain is not constructed here: its CellData may even report liquid while construct_cell --- can legally return nil for material-texture holes. It uses LoadPixelScene via --- paint_solid_scene_prepared(), Noita's authored terrain placement path. -function backend.paint_cell_prepared(context, x, y) - if type(context) ~= "table" or type(context.ffi) ~= "table" then return false, "context" end - if context.mode == "solid_scene" then return false, "solid_scene_required" end - x, y = math.floor(tonumber(x) or 0), math.floor(tonumber(y) or 0) - local ok, result, detail = pcall(function() - local ffi_world = context.ffi - if ffi_world.chunk_loaded(context.chunk_map, x, y) ~= true then return false, "unloaded" end - local ppixel = ffi_world.get_cell(context.chunk_map, x, y) - if ppixel == nil then return false, "cell_slot" end - local current = ppixel[0] - local current_id = 0 - if current ~= nil then - local current_material = current.vtable.get_material(current) - if current_material ~= nil then current_id = math.floor(tonumber(ffi_world.get_material_id(current_material)) or 0) end - if current_id == context.material_id then return true, "unchanged" end - ffi_world.remove_cell(context.grid_world, current, x, y, false) - end - if context.material_id == 0 then return true, "erased" end - local cell = ffi_world.construct_cell(context.grid_world, x, y, context.material_ptr, nil) - if cell == nil then - if current ~= nil then restore_previous(context, ppixel, current_id, x, y) end - return false, "construct" - end - ppixel[0] = cell - return true, "painted" - end) - if not ok then return false, "ffi_error" end - return result == true, detail -end - - -local function material_scene_transport_safe() - if type(GlobalsGetValue) ~= "function" then return true end - local ok, status = pcall(GlobalsGetValue, "mcm_compat_material_scene_patch_v1", "") - if not ok then return true end - status = tostring(status or "") - return status ~= "read_failed" and status ~= "anchor_mismatch" - and status ~= "verification_failed" and status ~= "write_failed" -end - -function backend.paint_scene_prepared(context, mask_file, top_left_x, top_left_y, mask_color) - if type(context) ~= "table" or type(context.material_name) ~= "string" - or context.material_name == "" - then - return false, "context" - end - -- On an incompatible EW build, fail closed instead of letting stock EW replay an - -- MCM-owned mask without its dynamic color/material map. In single-player the - -- compatibility status is "disabled" (or absent), so this remains a pure no-op. - if not material_scene_transport_safe() then return false, "ew_material_scene_patch" end - if type(LoadPixelScene) ~= "function" then return false, "pixel_scene" end - top_left_x, top_left_y = math.floor(tonumber(top_left_x) or 0), math.floor(tonumber(top_left_y) or 0) - if type(mask_file) ~= "string" or mask_file == "" then return false, "mask" end - local radius_probe = 1 - if type(DoesWorldExistAt) == "function" then - local ok_loaded, loaded = pcall(DoesWorldExistAt, - top_left_x - radius_probe, top_left_y - radius_probe, - top_left_x + 16, top_left_y + 16) - if ok_loaded and loaded ~= true then return false, "unloaded" end - end - local color_map = { [tostring(mask_color or "ff5ac75a")] = tostring(context.material_name or "") } - local ok = pcall(LoadPixelScene, mask_file, "", top_left_x, top_left_y, "", - true, false, color_map, 50, true) - return ok == true, ok and "painted" or "pixel_scene" -end - -function backend.paint_solid_scene_prepared(context, mask_file, top_left_x, top_left_y, mask_color) - if type(context) ~= "table" or context.mode ~= "solid_scene" then return false, "mode" end - return backend.paint_scene_prepared(context, mask_file, top_left_x, top_left_y, mask_color) -end - -function backend.paint_cell(material_name, x, y) - local context, reason = backend.begin_paint(material_name) - if context == nil then return false, reason end - if context.mode == "solid_scene" then return false, "solid_scene_required" end - return backend.paint_cell_prepared(context, x, y) -end - -METAMORPH_CREATIVE_MENU_MATERIAL_GRID_BACKEND = backend -return backend diff --git a/metamorph_creative_menu/files/platform/noita/material_preview.lua b/metamorph_creative_menu/files/platform/noita/material_preview.lua deleted file mode 100644 index 10a9060..0000000 --- a/metamorph_creative_menu/files/platform/noita/material_preview.lua +++ /dev/null @@ -1,225 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_MATERIAL_PREVIEW) == "table" then - return METAMORPH_CREATIVE_MENU_MATERIAL_PREVIEW -end - -local preview = {} - -local MATERIALS_XML = "data/materials.xml" -local PROBE_ENTITY_PATH = "data/entities/items/pickup/potion_empty.xml" --- This is the actual ItemComponent.ui_sprite used by vanilla potions. The 9x8 --- data/items_gfx/potion.png image is the small in-world hand sprite and has a different --- silhouette; flask_liquid.png is authored for that world sprite and is not an inventory --- mask. Vanilla colours this grayscale 16x16 UI sprite with the potion's computed colour. -local DEFAULT_POTION_UI_ICON = "data/ui_gfx/items/potion.png" -local DEFAULT_POTION_UI_CAP = "mods/metamorph_creative_menu/files/platform/noita/potion_cap.png" -local PROBE_VERTICAL_OFFSET = -100000 -local MATERIAL_AMOUNT = 1000 - -local definitions = nil -local resolved_texture = {} -local resolved_tint = {} -local liquid_colors = {} -local potion_ui_icon = DEFAULT_POTION_UI_ICON -local potion_ui_cap = DEFAULT_POTION_UI_CAP - -local function valid_entity(entity_id) - return entity_id ~= nil and entity_id ~= 0 and EntityGetIsAlive(entity_id) -end - -local function attribute(text, name) - return string.match(tostring(text or ""), "%f[%w_]" .. name - .. '%f[^%w_]%s*=%s*"([^"]*)"') -end - -local function decode_argb(value) - value = tostring(value or "") - value = string.gsub(value, "^0[xX]", "") - if #value == 6 then value = "ff" .. value end - if #value ~= 8 then return nil end - local alpha = tonumber(string.sub(value, 1, 2), 16) - local red = tonumber(string.sub(value, 3, 4), 16) - local green = tonumber(string.sub(value, 5, 6), 16) - local blue = tonumber(string.sub(value, 7, 8), 16) - if alpha == nil or red == nil or green == nil or blue == nil then return nil end - return { red / 255, green / 255, blue / 255, alpha / 255 } -end - -local function load_definitions() - if definitions ~= nil then return definitions end - definitions = {} - if type(ModTextFileGetContent) ~= "function" then return definitions end - local ok, content = pcall(ModTextFileGetContent, MATERIALS_XML) - if not ok or type(content) ~= "string" or content == "" then return definitions end - - local cursor = 1 - while cursor <= #content do - local open_start, open_end, tag, attributes = string.find(content, - "<(CellData[%a]*)%s+([^>]*)>", cursor) - if open_start == nil then break end - cursor = open_end + 1 - if tag == "CellData" or tag == "CellDataChild" then - local body = "" - if not string.match(attributes, "/%s*$") then - local close_start, close_end = string.find(content, "", cursor) - if close_start ~= nil then - body = string.sub(content, cursor, close_start - 1) - cursor = close_end + 1 - end - end - local name = attribute(attributes, "name") - if type(name) == "string" and name ~= "" then - local graphics = string.match(body, "]*)>") or "" - definitions[name] = { - parent=attribute(attributes, "_parent"), - texture=attribute(graphics, "texture_file"), - tint=decode_argb(attribute(graphics, "color")), - } - end - end - end - return definitions -end - -local function inherited_field(material_id, field, visited) - local values = load_definitions() - local definition = values[tostring(material_id or "")] - if definition == nil then return nil end - if definition[field] ~= nil then return definition[field] end - local parent = tostring(definition.parent or "") - if parent == "" then return nil end - visited = visited or {} - if visited[parent] then return nil end - visited[parent] = true - return inherited_field(parent, field, visited) -end - -local function decode_potion_color(packed_color) - packed_color = tonumber(packed_color) - if packed_color == nil then return nil end - -- GameGetPotionColorUint is encoded as 0xBBGGRR: low byte is red. - local red = packed_color % 256 - local green = math.floor(packed_color / 256) % 256 - local blue = math.floor(packed_color / 65536) % 256 - return { red / 255, green / 255, blue / 255, 1 } -end - -local function sample_liquid_color(player_entity_id, material_id) - if not valid_entity(player_entity_id) or type(material_id) ~= "string" or material_id == "" then return nil end - local player_x, player_y = EntityGetTransform(player_entity_id) - if player_x == nil then return nil end - local probe_entity_id = EntityLoad(PROBE_ENTITY_PATH, player_x, - (player_y or 0) + PROBE_VERTICAL_OFFSET) or 0 - if probe_entity_id == 0 then return nil end - - local color = nil - local operation_succeeded = pcall(function() - RemoveMaterialInventoryMaterial(probe_entity_id) - AddMaterialInventoryMaterial(probe_entity_id, material_id, MATERIAL_AMOUNT) - local item_component = EntityGetFirstComponentIncludingDisabled(probe_entity_id, "ItemComponent") - if item_component ~= nil and item_component ~= 0 then - local icon_ok, actual_icon = pcall(ComponentGetValue2, item_component, "ui_sprite") - if icon_ok and type(actual_icon) == "string" and actual_icon ~= "" then - -- Respect an intentional potion.xml override from another mod. - potion_ui_icon = actual_icon - potion_ui_cap = actual_icon == DEFAULT_POTION_UI_ICON and DEFAULT_POTION_UI_CAP or false - end - end - color = decode_potion_color(GameGetPotionColorUint(probe_entity_id)) - end) - if EntityGetIsAlive(probe_entity_id) then EntityKill(probe_entity_id) end - if not operation_succeeded then return nil end - return color -end - -function preview.texture(material_id) - material_id = tostring(material_id or "") - if resolved_texture[material_id] ~= nil then - return resolved_texture[material_id] or nil - end - local texture = inherited_field(material_id, "texture") - if type(texture) ~= "string" or texture == "" then texture = false end - resolved_texture[material_id] = texture - return texture or nil -end - -function preview.tint(material_id) - material_id = tostring(material_id or "") - if resolved_tint[material_id] ~= nil then return resolved_tint[material_id] or nil end - local tint = inherited_field(material_id, "tint") or false - resolved_tint[material_id] = tint - return tint or nil -end - -function preview.liquid_icon() return potion_ui_icon end -function preview.liquid_cap_icon() return potion_ui_cap or nil end - -function preview.liquid_color(material_id) - local value = liquid_colors[tostring(material_id or "")] - return type(value) == "table" and value or nil -end - -function preview.sample_liquid_color(player_entity_id, material_id) - material_id = tostring(material_id or "") - if liquid_colors[material_id] ~= nil then return preview.liquid_color(material_id) end - local color = sample_liquid_color(player_entity_id, material_id) - if color == nil then - local authored = preview.tint(material_id) - if type(authored) == "table" then - color = { - tonumber(authored[1]) or 1, - tonumber(authored[2]) or 1, - tonumber(authored[3]) or 1, - 1, - } - end - end - liquid_colors[material_id] = color or false - return preview.liquid_color(material_id) -end - -function preview.new_liquid_warmup() - return { cursor=1, count=-1, key=nil, complete=false } -end - -function preview.warm_liquid_colors(player_entity_id, entries, state, budget, key) - entries = type(entries) == "table" and entries or {} - state = type(state) == "table" and state or preview.new_liquid_warmup() - budget = math.max(1, math.floor(tonumber(budget) or 2)) - key = tostring(key == nil and #entries or key) - if state.count ~= #entries or state.key ~= key then - state.cursor, state.count, state.key, state.complete = 1, #entries, key, false - end - if state.complete or #entries == 0 then state.complete = true; return true end - - local sampled, scanned = 0, 0 - while sampled < budget and scanned < #entries do - if state.cursor > #entries then state.cursor = 1 end - local entry = entries[state.cursor] - state.cursor = state.cursor + 1 - scanned = scanned + 1 - local material_id = type(entry) == "table" and entry.id or entry - material_id = tostring(material_id or "") - if material_id ~= "" and liquid_colors[material_id] == nil then - preview.sample_liquid_color(player_entity_id, material_id) - sampled = sampled + 1 - end - end - state.complete = true - for _, entry in ipairs(entries) do - local material_id = tostring(type(entry) == "table" and entry.id or entry or "") - if material_id ~= "" and liquid_colors[material_id] == nil then - state.complete = false - break - end - end - return state.complete -end - -function preview.reset() - definitions, resolved_texture, resolved_tint, liquid_colors = nil, {}, {}, {} - potion_ui_icon = DEFAULT_POTION_UI_ICON - potion_ui_cap = DEFAULT_POTION_UI_CAP -end - -METAMORPH_CREATIVE_MENU_MATERIAL_PREVIEW = preview -return preview diff --git a/metamorph_creative_menu/files/platform/noita/menu_inventory_guard.lua b/metamorph_creative_menu/files/platform/noita/menu_inventory_guard.lua index 16700df..f1ce27a 100644 --- a/metamorph_creative_menu/files/platform/noita/menu_inventory_guard.lua +++ b/metamorph_creative_menu/files/platform/noita/menu_inventory_guard.lua @@ -2,51 +2,11 @@ if type(METAMORPH_CREATIVE_MENU_MENU_INVENTORY_GUARD) == "table" then return MET local menu_inventory_guard = {} local patcher_bridge = dofile("mods/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua") -local manual_owner = nil -local text_inventory_owner = nil local function valid_component(component_id) return component_id ~= nil and component_id ~= 0 end -local function suppress_pointer_clicks(controls_component) - -- MCM reads raw InputIsMouseButton* state, so clearing the player-facing fields does - -- not affect its own drag. It does prevent vanilla InventoryGui/gameplay code from - -- completing the same press or release behind the creative window. - for _, field in ipairs({"mButtonDownLeftClick", "mButtonDownRightClick"}) do - pcall(ComponentSetValue2, controls_component, field, false) - end - for _, field in ipairs({"mButtonFrameLeftClick", "mButtonFrameRightClick"}) do - pcall(ComponentSetValue2, controls_component, field, -1) - end -end - -local TEXT_BUTTONS = { - "Fire", "Fire2", "Action", "Throw", "Interact", - "Left", "Right", "Up", "Down", "Jump", "Run", "Fly", "Dig", - "ChangeItemR", "ChangeItemL", "Inventory", "HolsterItem", "DropItem", - "Kick", "Eat", "LeftClick", "RightClick", -} - --- Text entry must suppress gameplay/inventory actions without disabling ControlsComponent. --- On the native inventory screen Noita can close the inventory when the component itself is --- disabled; that made the first typed movement key (notably S) tear down both inventory and --- MCM. These fields are transient per-frame input state, so clearing them needs no restore. -function menu_inventory_guard.suppress_text_controls(player_entity_id) - player_entity_id = tonumber(player_entity_id) or 0 - if player_entity_id == 0 or not EntityGetIsAlive(player_entity_id) then return false end - local controls = EntityGetFirstComponentIncludingDisabled(player_entity_id, "ControlsComponent") - if not valid_component(controls) then return false end - for _, suffix in ipairs(TEXT_BUTTONS) do - pcall(ComponentSetValue2, controls, "mButtonDown" .. suffix, false) - pcall(ComponentSetValue2, controls, "mButtonFrame" .. suffix, -1) - end - pcall(ComponentSetValue2, controls, "mButtonCountChangeItemR", 0) - pcall(ComponentSetValue2, controls, "mButtonCountChangeItemL", 0) - suppress_pointer_clicks(controls) - return true -end - function menu_inventory_guard.controls_disabled(player_entity_id) if player_entity_id == nil or player_entity_id == 0 then return false end local controls_component = EntityGetFirstComponentIncludingDisabled(player_entity_id, "ControlsComponent") @@ -54,10 +14,8 @@ function menu_inventory_guard.controls_disabled(player_entity_id) local controls_enabled = ComponentGetValue2(controls_component, "enabled") local component_enabled = true - if type(ComponentGetIsEnabled) == "function" then - local read_succeeded, enabled = pcall(ComponentGetIsEnabled, controls_component) - if read_succeeded then component_enabled = enabled == true end - end + local read_succeeded, enabled = pcall(EntityGetComponentIsEnabled, player_entity_id, controls_component) + if read_succeeded then component_enabled = enabled == true end return controls_enabled == false or component_enabled == false end @@ -77,71 +35,6 @@ function menu_inventory_guard.inventory_open(player_entity_id) return inventory_open end --- InventoryGui also consumes native keyboard input after OnWorldPreUpdate. Preserve --- its open/closed state for the lifetime of text focus, before visibility sync and --- again in post-update. This covers I (or a rebound inventory key) without rebinding --- the user's controls or disabling the native GUI component. -function menu_inventory_guard.hold_text_inventory(player, native_open) - if not player or player == 0 or not EntityGetIsAlive(player) then - text_inventory_owner = nil - return native_open == true - end - local component = EntityGetFirstComponentIncludingDisabled(player, "InventoryGuiComponent") - if not valid_component(component) then - text_inventory_owner = nil - return native_open == true - end - if text_inventory_owner == nil or text_inventory_owner.player ~= player - or text_inventory_owner.component ~= component then - text_inventory_owner = {player=player, component=component, open=native_open == true} - end - local target = text_inventory_owner.open - local ok, current = pcall(ComponentGetValue2, component, "mActive") - if ok and current ~= target then pcall(ComponentSetValue2, component, "mActive", target) end - return target -end - -function menu_inventory_guard.release_text_inventory() - text_inventory_owner = nil -end - --- Editable creative-menu fields temporarily reserve player input while the user types. --- The menu itself no longer owns gameplay controls: movement, firing, interaction and --- item selection remain live whenever no text/numeric field has focus. On release, --- restore the captured value only while the field still reads disabled; same-value --- writes by another system cannot be distinguished. -function menu_inventory_guard.acquire_manual_controls(player_entity_id) - player_entity_id = tonumber(player_entity_id) or 0 - if manual_owner ~= nil and manual_owner.player == player_entity_id then - suppress_pointer_clicks(manual_owner.component) - return true - end - menu_inventory_guard.release_manual_controls() - if player_entity_id == 0 or not EntityGetIsAlive(player_entity_id) then return false end - local controls = EntityGetFirstComponentIncludingDisabled(player_entity_id, "ControlsComponent") - if not valid_component(controls) then return false end - local ok, baseline = pcall(ComponentGetValue2, controls, "enabled") - if not ok then return false end - if not pcall(ComponentSetValue2, controls, "enabled", false) then return false end - suppress_pointer_clicks(controls) - manual_owner = { player=player_entity_id, component=controls, baseline=baseline == true } - return true -end - -function menu_inventory_guard.release_manual_controls() - local owner = manual_owner - manual_owner = nil - if owner == nil or owner.player == 0 or not EntityGetIsAlive(owner.player) then return false end - local ok, current = pcall(ComponentGetValue2, owner.component, "enabled") - if not ok then return false end - if current == false then pcall(ComponentSetValue2, owner.component, "enabled", owner.baseline) end - return true -end - -function menu_inventory_guard.manual_controls_owned() - return manual_owner ~= nil -end - function menu_inventory_guard.capture_scroll_selection(player_entity_id) if player_entity_id == nil or player_entity_id == 0 then return nil end local inventory_component = EntityGetFirstComponentIncludingDisabled(player_entity_id, "Inventory2Component") diff --git a/metamorph_creative_menu/files/platform/noita/native_gameover_patch.lua b/metamorph_creative_menu/files/platform/noita/native_gameover_patch.lua deleted file mode 100644 index 0e15208..0000000 --- a/metamorph_creative_menu/files/platform/noita/native_gameover_patch.lua +++ /dev/null @@ -1,183 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_NATIVE_GAMEOVER_PATCH) == "table" then - return METAMORPH_CREATIVE_MENU_NATIVE_GAMEOVER_PATCH -end - --- Thin Lua facade over mcm_native_gameover.dll. --- --- Native ownership deliberately lives in the DLL rather than LuaJIT FFI: --- * the DLL owns the request byte and localization-key storage for its whole lifetime; --- * it resolves the running PE32 layout and locates stock Game Over code adaptively; --- * the stock hidden Save Replay button builder becomes MCM's fourth stock button; --- * its click handler only raises an MCM-owned request byte; --- * Game Over is released only from the normal OnWorldPreUpdate recovery path. --- --- Lua owns orchestration only. It never writes executable memory or holds pointers used --- by patched machine code. -local native_gameover_patch = {} - -local PROFILE = { - name = "adaptive_noita_pe32_gameover_native_r10", - mode = "pe32_import_resolution_and_signature_scan", - compatibility = "stock Noita x86 Game Over / Save Replay semantics", - fixed_exe_hash = false, - fixed_virtual_addresses = false, -} - -local MODULE_NAME = "mcm_native_gameover" -local MODULE_CPATH = "./mods/metamorph_creative_menu/?.dll" - -local state = { - module = nil, - load_attempted = false, - load_reason = nil, - installed = false, - install_reason = nil, -} - -local function ew_active() - if type(ModIsEnabled) ~= "function" then return false end - local ok, enabled = pcall(ModIsEnabled, "quant.ew") - return ok and enabled == true -end - -local function append_cpath_once() - local cpath = tostring(package and package.cpath or "") - if not cpath:find(MODULE_CPATH, 1, true) then - package.cpath = cpath .. ";" .. MODULE_CPATH - end -end - -local function load_native_module() - if type(state.module) == "table" then return state.module end - if state.load_attempted then return nil, state.load_reason end - state.load_attempted = true - - if type(package) ~= "table" or type(package.cpath) ~= "string" then - state.load_reason = "package_loader_unavailable" - return nil, state.load_reason - end - - append_cpath_once() - local ok, module_or_error = pcall(require, MODULE_NAME) - if not ok then - state.load_reason = "native_dll_load_failed:" .. tostring(module_or_error) - return nil, state.load_reason - end - if type(module_or_error) ~= "table" then - state.load_reason = "native_dll_invalid_module" - return nil, state.load_reason - end - - local required = { - "install", "is_installed", "has_revive_request", "clear_revive_request", - "is_game_over_active", "cancel_game_over_state", "cleanup_post_revive", "reason", - } - for _, name in ipairs(required) do - if type(module_or_error[name]) ~= "function" then - state.load_reason = "native_dll_missing_api:" .. name - return nil, state.load_reason - end - end - - state.module = module_or_error - state.load_reason = nil - return state.module -end - -function native_gameover_patch.install() - -- Death recovery is deliberately singleplayer-only. In Entangled Worlds the network - -- health/notplayer lifecycle owns Game Over, so do not even load the native DLL. - -- Besides avoiding state conflicts, this makes a different noita.exe build on a peer - -- incapable of crashing startup through this optional feature. - if ew_active() then - state.install_reason = "disabled_in_entangled_worlds" - return false, state.install_reason - end - local module, load_reason = load_native_module() - if module == nil then - state.install_reason = load_reason - return false, load_reason - end - - local ok, installed, reason = pcall(module.install) - if not ok then - reason = "native_dll_install_call_failed:" .. tostring(installed) - state.install_reason = reason - return false, reason - end - state.installed = installed == true - state.install_reason = tostring(reason or (state.installed and "installed" or "unknown")) - return state.installed, state.install_reason -end - -function native_gameover_patch.is_installed() - local module = state.module - if type(module) ~= "table" then return false end - local ok, value = pcall(module.is_installed) - state.installed = ok and value == true - return state.installed -end - -function native_gameover_patch.has_revive_request() - local module = state.module - if type(module) ~= "table" then return false end - local ok, value = pcall(module.has_revive_request) - return ok and value == true -end - -function native_gameover_patch.clear_revive_request() - local module = state.module - if type(module) ~= "table" then return false end - local ok, value = pcall(module.clear_revive_request) - return ok and value == true -end - -function native_gameover_patch.is_game_over_active() - local module = state.module - if type(module) ~= "table" then return false, "native_dll_unavailable" end - local ok, value = pcall(module.is_game_over_active) - if not ok then return false, "native_dll_gameover_query_failed:" .. tostring(value) end - return value == true, "ok" -end - -function native_gameover_patch.cancel_game_over_state() - local module = state.module - if type(module) ~= "table" then return false, "native_dll_unavailable" end - local ok, value, reason = pcall(module.cancel_game_over_state) - if not ok then return false, "native_dll_cancel_failed:" .. tostring(value) end - return value == true, tostring(reason or (value and "cleared" or "unknown")) -end - -function native_gameover_patch.cleanup_post_revive() - local module = state.module - if type(module) ~= "table" then return false, "native_dll_unavailable" end - local ok, value, reason = pcall(module.cleanup_post_revive) - if not ok then return false, "native_dll_cleanup_failed:" .. tostring(value) end - return value == true, tostring(reason or (value and "post_revive_cleaned" or "unknown")) -end - -function native_gameover_patch.status() - local native_reason = nil - if type(state.module) == "table" and type(state.module.reason) == "function" then - local ok, value = pcall(state.module.reason) - if ok then native_reason = tostring(value) end - end - return { - installed = native_gameover_patch.is_installed(), - load_reason = state.load_reason, - install_reason = state.install_reason, - native_reason = native_reason, - profile = PROFILE.name, - backend = "native_x86_lua_c_module_adaptive", - revive_request_pending = native_gameover_patch.has_revive_request(), - stock_button_builder = "save_replay_slot", - network_disabled = ew_active(), - } -end - -function native_gameover_patch.profile() - return PROFILE -end - -METAMORPH_CREATIVE_MENU_NATIVE_GAMEOVER_PATCH = native_gameover_patch -return native_gameover_patch diff --git a/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua b/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua index 17c9b68..1400e29 100644 --- a/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua +++ b/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua @@ -8,7 +8,6 @@ local last_explicit_attempt_frame = -100000 local last_bootstrap_frame = -100000 local retry_frames = 60 local BUNDLED_PATCHER_PATH = "mods/metamorph_creative_menu/NoitaPatcher/load.lua" -local EW_PATCHER_PATH = "mods/quant.ew/NoitaPatcher/load.lua" local function valid_bridge(value, capability) if type(value) ~= "table" then return false end @@ -50,11 +49,10 @@ local function maybe_bootstrap(options) if frame - last_bootstrap_frame < retry_frames then return end last_bootstrap_frame = frame - local bootstrap_path = BUNDLED_PATCHER_PATH - if active and type(ModDoesFileExist) == "function" and ModDoesFileExist(EW_PATCHER_PATH) == true then - bootstrap_path = EW_PATCHER_PATH - end - pcall(dofile_once, bootstrap_path) + -- Prefer an already-published NoitaPatcher bridge (including EW's instance). + -- When none exists, explicit MCM features bootstrap the bundled local copy. + -- This keeps singleplayer independent from the presence of quant.ew. + pcall(dofile_once, BUNDLED_PATCHER_PATH) end function patcher_bridge.bootstrap_available() diff --git a/metamorph_creative_menu/files/platform/noita/pointer.lua b/metamorph_creative_menu/files/platform/noita/pointer.lua deleted file mode 100644 index 00bed36..0000000 --- a/metamorph_creative_menu/files/platform/noita/pointer.lua +++ /dev/null @@ -1,89 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_POINTER) == "table" then return METAMORPH_CREATIVE_MENU_POINTER end - -local pointer = {} - -local function magic_number(name) - if type(MagicNumbersGetValue) ~= "function" then return nil end - local ok, value = pcall(MagicNumbersGetValue, name) - return ok and tonumber(value) or nil -end - -function pointer.gui_position(screen_width, screen_height) - screen_width, screen_height = tonumber(screen_width), tonumber(screen_height) - if screen_width ~= nil and screen_height ~= nil - and type(DEBUG_GetMouseWorld) == "function" and type(GameGetCameraPos) == "function" - then - local ok_mouse, world_x, world_y = pcall(DEBUG_GetMouseWorld) - local ok_camera, camera_x, camera_y = pcall(GameGetCameraPos) - local virtual_width = magic_number("VIRTUAL_RESOLUTION_X") - local virtual_offset = magic_number("VIRTUAL_RESOLUTION_OFFSET_X") or 0 - virtual_width = virtual_width ~= nil and virtual_width + virtual_offset or nil - if ok_mouse and ok_camera and tonumber(world_x) ~= nil and tonumber(world_y) ~= nil - and tonumber(camera_x) ~= nil and tonumber(camera_y) ~= nil - and virtual_width ~= nil and virtual_width > 0 - then - local scale = screen_width / virtual_width - return screen_width * 0.5 + (world_x - camera_x) * scale, - screen_height * 0.5 + (world_y - camera_y) * scale - end - end - if type(InputGetMousePosOnScreen) == "function" then - local ok, mouse_x, mouse_y = pcall(InputGetMousePosOnScreen) - if ok and tonumber(mouse_x) ~= nil and tonumber(mouse_y) ~= nil then - return tonumber(mouse_x), tonumber(mouse_y) - end - end - return nil, nil -end - -function pointer.world_position() - if type(DEBUG_GetMouseWorld) ~= "function" then return nil, nil end - local ok, world_x, world_y = pcall(DEBUG_GetMouseWorld) - if not ok or tonumber(world_x) == nil or tonumber(world_y) == nil then return nil, nil end - return tonumber(world_x), tonumber(world_y) -end - -local function mouse_code(...) - for index = 1, select("#", ...) do - local value = rawget(_G, select(index, ...)) - if tonumber(value) ~= nil then return tonumber(value) end - end - return nil -end - -local function button_down(default_code, ...) - if type(InputIsMouseButtonDown) ~= "function" then return false end - local code = mouse_code(...) or default_code - local ok, result = pcall(InputIsMouseButtonDown, code) - return ok and result == true -end - -local function button_just_down(default_code, ...) - if type(InputIsMouseButtonJustDown) ~= "function" then return false end - local code = mouse_code(...) or default_code - local ok, result = pcall(InputIsMouseButtonJustDown, code) - return ok and result == true -end - -function pointer.left_down() return button_down(1, "Mouse_left", "MOUSE_LEFT") end -function pointer.left_just_down() return button_just_down(1, "Mouse_left", "MOUSE_LEFT") end -function pointer.right_down() return button_down(2, "Mouse_right", "MOUSE_RIGHT") end - -function pointer.wheel_delta() - if type(InputIsMouseButtonJustDown) ~= "function" then return 0 end - local ok_up, up = pcall(InputIsMouseButtonJustDown, 4) - local ok_down, down = pcall(InputIsMouseButtonJustDown, 5) - if ok_up and up == true then return -1 end - if ok_down and down == true then return 1 end - return 0 -end - -function pointer.inside(x, y, width, height, mouse_x, mouse_y) - x, y, width, height = tonumber(x), tonumber(y), tonumber(width), tonumber(height) - mouse_x, mouse_y = tonumber(mouse_x), tonumber(mouse_y) - return x ~= nil and y ~= nil and width ~= nil and height ~= nil and mouse_x ~= nil and mouse_y ~= nil - and mouse_x >= x and mouse_x <= x + width and mouse_y >= y and mouse_y <= y + height -end - -METAMORPH_CREATIVE_MENU_POINTER = pointer -return pointer diff --git a/metamorph_creative_menu/files/platform/noita/potion_cap.png b/metamorph_creative_menu/files/platform/noita/potion_cap.png deleted file mode 100644 index f8c38be..0000000 Binary files a/metamorph_creative_menu/files/platform/noita/potion_cap.png and /dev/null differ diff --git a/metamorph_creative_menu/files/platform/noita/text_entry_guard.lua b/metamorph_creative_menu/files/platform/noita/text_entry_guard.lua deleted file mode 100644 index 9aee628..0000000 --- a/metamorph_creative_menu/files/platform/noita/text_entry_guard.lua +++ /dev/null @@ -1,58 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_TEXT_ENTRY_GUARD) == "table" then - return METAMORPH_CREATIVE_MENU_TEXT_ENTRY_GUARD -end - --- Single keyboard-focus owner shared by editable UI fields and the input layers that --- must suppress gameplay/hotkeys while typing. Runtime widgets own focus transitions; --- consumers only query active()/key(). A blur callback lets stateful editors resolve --- an unfinished draft even when the focused widget disappears on a tab/context switch. -local guard = {} -local active_key = nil -local blur_callback = nil - -local function normalized_key(key) - key = tostring(key or "") - return key ~= "" and key or "text" -end - -local function invoke_blur(callback, reason, key) - if type(callback) == "function" then pcall(callback, reason or "blur", key) end -end - -function guard.focus(key, on_blur) - key = normalized_key(key) - if active_key ~= nil and active_key ~= key then - local previous_key, previous_callback = active_key, blur_callback - active_key, blur_callback = nil, nil - invoke_blur(previous_callback, "replaced", previous_key) - end - active_key = key - blur_callback = type(on_blur) == "function" and on_blur or nil - return active_key -end - -function guard.set_blur_callback(key, on_blur) - if active_key ~= normalized_key(key) then return false end - blur_callback = type(on_blur) == "function" and on_blur or nil - return true -end - -function guard.clear(key, reason) - if key ~= nil and active_key ~= normalized_key(key) then return false end - if active_key == nil then return false end - local previous_key, previous_callback = active_key, blur_callback - active_key, blur_callback = nil, nil - invoke_blur(previous_callback, reason or "blur", previous_key) - return true -end - -function guard.active() - return active_key ~= nil -end - -function guard.key() - return active_key -end - -METAMORPH_CREATIVE_MENU_TEXT_ENTRY_GUARD = guard -return guard diff --git a/metamorph_creative_menu/files/platform/noita/wand.lua b/metamorph_creative_menu/files/platform/noita/wand.lua deleted file mode 100644 index c1592f8..0000000 --- a/metamorph_creative_menu/files/platform/noita/wand.lua +++ /dev/null @@ -1,72 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_WAND_API) == "table" then return METAMORPH_CREATIVE_MENU_WAND_API end - -local wand_api = {} - -local function valid(component) - return component ~= nil and component ~= 0 -end - -function wand_api.ability(entity) - if entity == nil or entity == 0 or not EntityGetIsAlive(entity) then return 0 end - local ability = EntityGetFirstComponentIncludingDisabled(entity, "AbilityComponent") - if not valid(ability) then return 0 end - local ok, use_gun_script = pcall(ComponentGetValue2, ability, "use_gun_script") - return ok and use_gun_script == true and ability or 0 -end - -function wand_api.held(player) - if player == nil or player == 0 or not EntityGetIsAlive(player) then return 0 end - local inventory = EntityGetFirstComponentIncludingDisabled(player, "Inventory2Component") - if not valid(inventory) then return 0 end - local candidates = {} - for _, field in ipairs({"mActualActiveItem", "mActiveItem"}) do - local ok, value = pcall(ComponentGetValue2, inventory, field) - value = ok and (tonumber(value) or 0) or 0 - if value ~= 0 then candidates[#candidates + 1] = value end - end - local seen = {} - for _, entity in ipairs(candidates) do - if not seen[entity] then - seen[entity] = true - if wand_api.ability(entity) ~= 0 then return entity end - end - end - return 0 -end - -function wand_api.get_scalar(ability, field) - if not valid(ability) then return nil, false end - local ok, value = pcall(ComponentGetValue2, ability, field) - return ok and value or nil, ok -end - -function wand_api.set_scalar(ability, field, value) - if not valid(ability) then return false end - local ok = pcall(ComponentSetValue2, ability, field, value) - if not ok then return false end - local read_ok, actual = pcall(ComponentGetValue2, ability, field) - if not read_ok then return false end - if type(value) == "number" then return tonumber(actual) == tonumber(value) end - if type(value) == "boolean" then return actual == value end - return tostring(actual or "") == tostring(value or "") -end - -function wand_api.get_object(ability, object_name, field) - if not valid(ability) then return nil, false end - local ok, value = pcall(ComponentObjectGetValue2, ability, object_name, field) - return ok and value or nil, ok -end - -function wand_api.set_object(ability, object_name, field, value) - if not valid(ability) then return false end - local ok = pcall(ComponentObjectSetValue2, ability, object_name, field, value) - if not ok then return false end - local read_ok, actual = pcall(ComponentObjectGetValue2, ability, object_name, field) - if not read_ok then return false end - if type(value) == "number" then return tonumber(actual) == tonumber(value) end - if type(value) == "boolean" then return actual == value end - return tostring(actual or "") == tostring(value or "") -end - -METAMORPH_CREATIVE_MENU_WAND_API = wand_api -return wand_api diff --git a/metamorph_creative_menu/files/qa/baselines.lua b/metamorph_creative_menu/files/qa/baselines.lua index 02eb39b..e4f3c83 100644 --- a/metamorph_creative_menu/files/qa/baselines.lua +++ b/metamorph_creative_menu/files/qa/baselines.lua @@ -4,9 +4,7 @@ local baselines = {} local world_rules = dofile("mods/metamorph_creative_menu/files/features/world_rules/service.lua") local weather = dofile("mods/metamorph_creative_menu/files/features/weather/service.lua") local perk_root_companions = dofile("mods/metamorph_creative_menu/files/features/perks/root_companions.lua") -local perk_transactions = dofile("mods/metamorph_creative_menu/files/features/perks/transactions.lua") -local perk_nested_pickups = dofile("mods/metamorph_creative_menu/files/features/perks/nested_pickups.lua") -local perk_locomotion_guard = dofile("mods/metamorph_creative_menu/files/features/perks/locomotion_guard.lua") +local perk_service = dofile("mods/metamorph_creative_menu/files/features/perks/service.lua") local function valid(entity) return entity ~= nil and entity ~= 0 and EntityGetIsAlive(entity) @@ -168,19 +166,10 @@ local function perk_guard_snapshot(p) local ok_owned, owned_counts=pcall(perk_root_companions.owned_counts) if ok_owned and type(owned_counts)=="table" then result.owned_roots=owned_counts end end - local global_owners, flag_owners = 0, 0 - if type(perk_transactions.active_global_owner_counts)=="function" then - local ok_counts, globals_count, flags_count=pcall(perk_transactions.active_global_owner_counts) - if ok_counts then global_owners, flag_owners=tonumber(globals_count) or 0, tonumber(flags_count) or 0 end + if type(perk_service.ownership_state)=="function" then + local ok_state, ownership=pcall(perk_service.ownership_state) + if ok_state and type(ownership)=="table" then result.ownership=ownership end end - result.ownership={ - transactions=type(perk_transactions.active_count)=="function" and perk_transactions.active_count() or 0, - mutations=type(perk_transactions.active_mutation_count)=="function" and perk_transactions.active_mutation_count() or 0, - global_owners=global_owners, run_flag_owners=flag_owners, - cleanup=type(perk_transactions.cleanup_state)=="function" and perk_transactions.cleanup_state() or {pending=0,failed=0}, - nested=type(perk_nested_pickups.state_snapshot)=="function" and perk_nested_pickups.state_snapshot() or {scopes=0,children=0}, - locomotion_baselines=type(perk_locomotion_guard.baseline_count)=="function" and perk_locomotion_guard.baseline_count() or 0, - } if type(EntityGetWithTag)=="function" then local seen={homunculus={},lukki_minion={}} for _, tag in ipairs({"homunculus","lukki_minion","perk_lukki_minion","lukki_minion_friend"}) do diff --git a/metamorph_creative_menu/files/qa/runner.lua b/metamorph_creative_menu/files/qa/runner.lua index 6d406a5..ede5957 100644 --- a/metamorph_creative_menu/files/qa/runner.lua +++ b/metamorph_creative_menu/files/qa/runner.lua @@ -11,7 +11,6 @@ local perk_service = dofile("mods/metamorph_creative_menu/files/features/perks/s local effect_service = dofile("mods/metamorph_creative_menu/files/features/effects/service.lua") local item_catalog = dofile("mods/metamorph_creative_menu/files/features/items/catalog.lua") local item_service = dofile("mods/metamorph_creative_menu/files/features/items/service.lua") -local ew_world_items = dofile("mods/metamorph_creative_menu/files/integrations/ew/world_items.lua") local possession = dofile("mods/metamorph_creative_menu/files/features/possession/service.lua") local player_avatar = dofile("mods/metamorph_creative_menu/files/features/companion/player_avatar.lua") @@ -113,14 +112,6 @@ local function restore_human_and_baseline() if state and state.world_baseline then restore_world_controls(state.world_baseline) else pcall(weather.release); pcall(world_rules.reset) end end -local function mcm_text(key, fallback) - if type(GameTextGetTranslatedOrNot) == "function" then - local ok, value = pcall(GameTextGetTranslatedOrNot, key) - if ok and type(value) == "string" and value ~= "" and value ~= key then return value end - end - return fallback -end - local function finish(reason) if not state then return end restore_human_and_baseline() @@ -130,11 +121,7 @@ local function finish(reason) log("AUTOTEST SUMMARY", string.format("run=%s outcome=%s reason=%s pass=%d warn=%d fail=%d dirty=%s spell=%s forms=%d/%d(catalog=%d) items=%d/%d(catalog=%d) item_runtime=%d/%d perks=%d/%d effects=%d/%d peers=%d->%d", tostring(state.id),outcome,final_reason,state.pass,state.warn,state.fail,tostring(state.dirty),tostring(state.spell_result or "not_run"),state.form_done,#state.forms,state.forms_catalog or #state.forms,state.item_done,#state.items,state.items_catalog or #state.items,state.item_runtime_done or 0,#(state.item_runtime_cases or {}),state.perk_done,#state.perks,state.effect_done,#state.effects,state.peer_baseline or 0,remote_peer_count())) log("=== EWCM AUTOTEST END", "run="..tostring(state.id).." outcome="..outcome.." result="..final_reason) - GamePrintImportant(mcm_text("$mcm_qa_title", "Metamorph: Creative Menu QA"), - mcm_text("$mcm_qa_summary", "Result") .. ": " .. outcome .. " " - .. mcm_text("$mcm_qa_pass", "pass") .. "=" .. state.pass .. " " - .. mcm_text("$mcm_qa_warn", "warn") .. "=" .. state.warn .. " " - .. mcm_text("$mcm_qa_fail", "fail") .. "=" .. state.fail) + GamePrintImportant("Metamorph: Creative Menu QA", outcome..": pass="..state.pass.." warn="..state.warn.." fail="..state.fail) METAMORPH_CREATIVE_MENU_QA_ACTIVE = false GlobalsSetValue("mcm_qa_active_v1", "0"); GlobalsSetValue("mcm_qa_phase_v1", "done"); GlobalsSetValue("mcm_qa_step_v1", tostring(reason or "complete")) state=nil @@ -271,8 +258,8 @@ local function gravity_rule() end local function gravity_debug_ok(expected_factor) - if type(world_rules.local_gravity_debug) ~= "function" then return false, "debug_api_missing" end - local ok, info = pcall(world_rules.local_gravity_debug) + if type(world_rules.local_gravity_state) ~= "function" then return false, "debug_api_missing" end + local ok, info = pcall(world_rules.local_gravity_state) if not ok or type(info) ~= "table" then return false, "debug_failed" end local rows = info.rows or {} if #rows == 0 then return false, "no_player_gravity_components" end @@ -388,8 +375,8 @@ local begin_spell_roundtrip = spell_roundtrip.begin local cleanup_spell_roundtrip = spell_roundtrip.cleanup local function start() - if state then GamePrint(mcm_text("$mcm_qa_already_running", "Metamorph: Creative Menu QA is already running")); return false end - local p=player(); if p==0 then GamePrint(mcm_text("$mcm_qa_no_player", "Metamorph: Creative Menu QA: no player")); return false end + if state then GamePrint("Metamorph: Creative Menu QA already running"); return false end + local p=player(); if p==0 then GamePrint("Metamorph: Creative Menu QA: no player"); return false end run_counter=run_counter+1 -- The independent diagnostic scan must start even when scenario preflight later fails. -- This guarantees that Z always produces actionable evidence instead of a silent abort. @@ -403,7 +390,7 @@ local function start() end if not preflight_ok then log("AUTOTEST PREFLIGHT FAIL", "issues="..table.concat(preflight_fatal, ";")) - GamePrintImportant(mcm_text("$mcm_qa_title", "Metamorph: Creative Menu QA"), mcm_text("$mcm_qa_preflight_failed", "Scenario preflight failed; diagnostic scan is still running")) + GamePrintImportant("Metamorph: Creative Menu QA", "ABORTED: scenario preflight failed; diagnostic scan is still running") return false end local forms = networked and select_named_entries(forms_all, NETWORK_FORM_CASES, function(v) return tostring(v or "") end) or forms_all @@ -411,7 +398,7 @@ local function start() local item_runtime_cases = networked and {} or select_named_entries(items_all, SINGLEPLAYER_ITEM_RUNTIME_CASES, function(v) return tostring(type(v)=="table" and v.path or v or "") end) if not networked and #item_runtime_cases ~= #SINGLEPLAYER_ITEM_RUNTIME_CASES then log("AUTOTEST PREFLIGHT FAIL", "issues=runtime_item_cases="..tostring(#item_runtime_cases).."/"..tostring(#SINGLEPLAYER_ITEM_RUNTIME_CASES)) - GamePrintImportant(mcm_text("$mcm_qa_title", "Metamorph: Creative Menu QA"), mcm_text("$mcm_qa_items_incomplete", "Representative item set is incomplete; diagnostic scan is still running")) + GamePrintImportant("Metamorph: Creative Menu QA", "ABORTED: representative item set incomplete; diagnostic scan is still running") return false end state={id=tostring(os and os.time and os.time() or frame()).."-"..tostring(run_counter),phase="baseline",pass=0,warn=0,fail=0,dirty=false, @@ -423,7 +410,7 @@ local function start() GlobalsSetValue("mcm_qa_active_v1", "1"); GlobalsSetValue("mcm_qa_run_v1", tostring(state.id)); GlobalsSetValue("mcm_qa_peer_baseline_v1", tostring(peers)) log("=== EWCM AUTOTEST BEGIN",string.format("run=%s frame=%d perks=%d effects=%d forms_test=%d forms_catalog=%d mobs_total=%d items_test=%d items_catalog=%d ew=%s peers=%d network_safe=%s",state.id,frame(),#perks,#effects,#forms,#forms_all,ui_mob_count,#items,#items_all,tostring(ModIsEnabled("quant.ew")),peers,tostring(networked))) log("TEST CONFIG","destructive=true rollback=phase_checkpoint fail_fast_dirty=true item_mode="..(networked and "network_representative" or "singleplayer_static_catalog+visible_representative").." crash_resume_marker=true multiplayer_representative="..tostring(networked)) - GamePrintImportant(mcm_text("$mcm_qa_title", "Metamorph: Creative Menu QA"), mcm_text("$mcm_qa_started", "Started. Z runs the full scenario test; diagnostics run in parallel.")) + GamePrintImportant("Metamorph: Creative Menu QA","STARTED. Z runs full scenario test; diagnostics are running in parallel.") return true end @@ -475,8 +462,8 @@ local function update_state() local locked=weather.is_locked() local weather_ok=state.pending_ok and locked local extra="" - if state.pending_name=="clear" and type(weather.debug_state)=="function" then - local ok_state,dbg=pcall(weather.debug_state) + if state.pending_name=="clear" and type(weather.state_snapshot)=="function" then + local ok_state,dbg=pcall(weather.state_snapshot) local rainfall=ok_state and type(dbg)=="table" and tonumber(dbg.rainfall) or nil local rain=ok_state and type(dbg)=="table" and tonumber(dbg.rain) or nil local target=ok_state and type(dbg)=="table" and tonumber(dbg.rain_target) or nil @@ -903,7 +890,7 @@ local function update_state() end if state.phase=="item_verify" then local e=state.pending_item; local ent=tonumber(state.pending_item_entity) or 0; local ok=valid(ent) - local sync_ok, sync_state, gid = ew_world_items.world_sync_state(ent) + local sync_ok, sync_state, gid = item_service.world_sync_state(ent) if ok and not sync_ok and f < (state.pending_item_deadline or f) then set_wait(2,"item_verify"); return end mark(ok and sync_ok and "PASS" or "FAIL","item."..tostring(e.path), "entity="..tostring(ent).." spawn="..tostring(state.pending_reason).." ew_registered="..tostring(sync_ok).." sync_state="..tostring(sync_state).." gid="..tostring(gid or "")) diff --git a/metamorph_creative_menu/files/ui/assets/page_left.png b/metamorph_creative_menu/files/ui/assets/page_left.png deleted file mode 100644 index 84d4194..0000000 Binary files a/metamorph_creative_menu/files/ui/assets/page_left.png and /dev/null differ diff --git a/metamorph_creative_menu/files/ui/assets/page_right.png b/metamorph_creative_menu/files/ui/assets/page_right.png deleted file mode 100644 index a7c58c5..0000000 Binary files a/metamorph_creative_menu/files/ui/assets/page_right.png and /dev/null differ diff --git a/metamorph_creative_menu/files/ui/components/fixed_numeric_editor.lua b/metamorph_creative_menu/files/ui/components/fixed_numeric_editor.lua deleted file mode 100644 index a85338c..0000000 --- a/metamorph_creative_menu/files/ui/components/fixed_numeric_editor.lua +++ /dev/null @@ -1,198 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_FIXED_NUMERIC_EDITOR) == "table" then - return METAMORPH_CREATIVE_MENU_FIXED_NUMERIC_EDITOR -end - -local numeric_editor = {} -local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") - -local drafts = {} - -local function format_number(value, integer) - value = tonumber(value) - if value == nil then return "?" end - if integer then return tostring(math.floor(value + (value >= 0 and 0.5 or -0.5))) end - return string.format("%.6g", value) -end - -local function truncate_to_width(text, width) - text = tostring(text or "") - width = math.max(1, tonumber(width) or 1) - if ui.text_width(text) <= width then return text end - if type(ui.truncate_text) == "function" then return ui.truncate_text(text, width) end - local suffix = "..." - local result = "" - local allowed = math.max(1, width - ui.text_width(suffix)) - for character in string.gmatch(text, "[\1-\127\194-\244][\128-\191]*") do - if ui.text_width(result .. character) > allowed then break end - result = result .. character - end - return result .. suffix -end - -local function fixed_label(text, width, tooltip_description) - width = math.max(8, tonumber(width) or 86) - text = tostring(text or "") - local display = truncate_to_width(text, width) - ui.white_text(0, 1, display) - if tooltip_description ~= nil and tostring(tooltip_description) ~= "" and type(GuiTooltip) == "function" then - GuiTooltip(ui.gui(), text, tostring(tooltip_description)) - elseif display ~= text and type(GuiTooltip) == "function" then - GuiTooltip(ui.gui(), text) - end - GuiLayoutAddHorizontalSpacing(ui.gui(), math.max(0, width - ui.text_width(display))) -end - -local function button_natural_width(label) - return math.max(8, ui.text_width(label) + 6) -end - -local function fixed_button(label, width) - width = math.max(8, tonumber(width) or button_natural_width(label)) - local clicked = ui.button(0, 0, label) - GuiLayoutAddHorizontalSpacing(ui.gui(), math.max(0, width - button_natural_width(label))) - return clicked -end - --- A numeric stat always uses the same four columns: label, decrement, field, increment. --- Widths depend only on the panel and options, never on the current value/draft. This is --- especially important for capacity: 1 -> 26 -> 64 must not move either arrow under the --- pointer. Long translations are clipped inside the label cell instead of pushing controls. -local function row_layout(options) - options = type(options) == "table" and options or {} - local available = math.max(64, tonumber(options.max_width) or 220) - local left_label = tostring(options.left_label or "<") - local right_label = tostring(options.right_label or ">") - local left_width = math.max(12, tonumber(options.arrow_width) or button_natural_width(left_label)) - local right_width = math.max(12, tonumber(options.arrow_width) or button_natural_width(right_label)) - local suffix = tostring(options.suffix or "") - local suffix_width = suffix ~= "" and math.max(8, tonumber(options.suffix_width) or 12) or 0 - local preferred_value = math.max(36, tonumber(options.value_width) or 58) - local preferred_label = math.max(28, tonumber(options.label_width) or 94) - local minimum_label = math.max(8, tonumber(options.min_label_width) or 8) - - local fixed = left_width + right_width + suffix_width - local value_width = math.min(preferred_value, math.max(36, available - fixed - minimum_label)) - local label_width = math.min(preferred_label, math.max(minimum_label, available - fixed - value_width)) - if label_width + fixed + value_width > available then - label_width = math.max(8, available - fixed - value_width) - end - return { - available=available, - label_width=label_width, - left_width=left_width, - value_width=value_width, - right_width=right_width, - suffix_width=suffix_width, - left_label=left_label, - right_label=right_label, - suffix=suffix, - } -end - -function numeric_editor.reset(prefix) - local focused_key = type(ui.text_input_focus_key) == "function" and ui.text_input_focus_key() or nil - if prefix == nil then - if focused_key ~= nil and type(ui.blur_text_input) == "function" then - ui.blur_text_input("numeric_reset") - end - drafts = {} - return - end - prefix = tostring(prefix) - if focused_key ~= nil and string.sub(focused_key, 1, #prefix) == prefix - and type(ui.blur_text_input) == "function" - then - ui.blur_text_input("numeric_reset") - end - for key in pairs(drafts) do - if string.sub(key, 1, #prefix) == prefix then drafts[key] = nil end - end -end - -function numeric_editor.draw(key, label, current, options) - options = type(options) == "table" and options or {} - key = tostring(key or "numeric") - local integer = options.integer == true - local step = tonumber(options.step) or 1 - local current_number = tonumber(current) - local canonical = format_number(current_number, integer) - if drafts[key] == nil or options.force_sync == true then drafts[key] = canonical end - - local changed, last_error = false, nil - local value_chars = tonumber(options.value_chars) or 24 - local layout = row_layout(options) - - local function normalize_candidate(value) - value = tonumber(value) - if value == nil or value ~= value or value == math.huge or value == -math.huge then return nil end - if integer then value = math.floor(value + (value >= 0 and 0.5 or -0.5)) end - local minimum = tonumber(options.min) - local maximum = tonumber(options.max) - if minimum ~= nil then value = math.max(minimum, value) end - if maximum ~= nil then value = math.min(maximum, value) end - return value - end - - local function apply(value, source) - if type(options.on_apply) ~= "function" then return false, "missing_callback" end - value = normalize_candidate(value) - if value == nil then return false, "invalid_value" end - local ok, reason = options.on_apply(value, source) - if ok then - changed = true - drafts[key] = format_number(value, integer) - else - last_error = reason - end - return ok - end - - local before = drafts[key] - local edited, focused - -- Numeric values commit as soon as they parse successfully. On blur/context change, - -- only an unfinished intermediate draft (for example "-" or ".") needs cancelling; - -- resetting to the current canonical value gives that same rule to clicks, ESC, tab - -- switches and wand switches, including when the widget disappears before redraw. - local function cancel_unfinished_draft() - drafts[key] = canonical - end - - GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true) - fixed_label(label, layout.label_width, options.label_tooltip_description) - if fixed_button(layout.left_label, layout.left_width) and current_number ~= nil then - apply(current_number - step, "decrement") - end - edited, focused = ui.text_input(before, layout.value_width, value_chars, key, { - allowed_characters=options.allowed_characters or "0123456789.-", - escape_clears=false, - on_blur=cancel_unfinished_draft, - }) - if fixed_button(layout.right_label, layout.right_width) and current_number ~= nil then - apply(current_number + step, "increment") - end - if layout.suffix_width > 0 then fixed_label(layout.suffix, layout.suffix_width) end - GuiLayoutEnd(ui.gui()) - - if edited ~= before then - drafts[key] = edited - local numeric = tonumber(edited) - -- Intermediate values such as '-' or '.' remain editable without generating - -- an error. A valid number is committed immediately and can be coalesced by - -- the caller's history layer. Bounded fields replace the draft with the clamped - -- value returned through apply(), so typing 1000 into a 64-slot field visibly - -- becomes 64 instead of leaving a stale unsafe number on screen. - if numeric ~= nil then apply(numeric, "typing") end - elseif not changed then - drafts[key] = edited - end - - if not focused and not changed then drafts[key] = canonical end - return changed, last_error, drafts[key], focused -end - -function numeric_editor.format(value, integer) - return format_number(value, integer == true) -end - -METAMORPH_CREATIVE_MENU_FIXED_NUMERIC_EDITOR = numeric_editor -return numeric_editor diff --git a/metamorph_creative_menu/files/ui/components/spell_inventory_strip.lua b/metamorph_creative_menu/files/ui/components/spell_inventory_strip.lua deleted file mode 100644 index cb1510c..0000000 --- a/metamorph_creative_menu/files/ui/components/spell_inventory_strip.lua +++ /dev/null @@ -1,84 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_SPELL_INVENTORY_UI) == "table" then return METAMORPH_CREATIVE_MENU_SPELL_INVENTORY_UI end - -local spell_inventory_ui = {} -local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") -local drag_drop = dofile("mods/metamorph_creative_menu/files/ui/drag_drop.lua") -local inventory_service = dofile("mods/metamorph_creative_menu/files/features/spells/inventory_service.lua") - -local function bounds(x,y,width,height) - if x == nil or y == nil or width == nil or height == nil then return nil end - return {x=x,y=y,width=width,height=height} -end - -function spell_inventory_ui.draw(player, panel_width, screen_width, screen_height, presentation, options) - options = type(options) == "table" and options or {} - local layout, reason = inventory_service.contents(player) - if layout == nil then return nil, reason end - ui.white_text(0, 0, ui.tr("$mcm_spell_inventory", "SPELL INVENTORY")) - - -- The vanilla spell inventory is small (16 cells in the stock player) and should stay - -- visible as one logical surface. Pagination made a narrow MCM panel show only a subset - -- even though there was plenty of vertical room. Render every cell and wrap only the - -- presentation rows; target_index still maps to Noita's original inventory coordinates. - -- These are the native 18 px inventory cells, not catalog entries. Do not charge the - -- catalog's 20 px pitch plus an artificial 10 px reserve: at the minimum MCM width - -- that caused an early 8+8 wrap with visibly unused room. Pack by the actual slot - -- footprint and wrap only when the next native cell genuinely cannot fit. - local slot_pitch = 18 - local columns = math.max(1, math.floor(math.max(slot_pitch, (tonumber(panel_width) or slot_pitch) - 2) / slot_pitch)) - local function draw_slot(index) - local entry = layout.by_index[index] - local action = entry and options.action_by_id and options.action_by_id[entry.action_id] or nil - local name, description - if entry == nil then - name, description = ui.tr("$mcm_empty", "Empty"), "" - elseif action ~= nil and type(presentation) == "function" then - name, description = presentation(action) - else - name = ui.tr("$mcm_spell_unknown", "Unknown/modded spell") .. " [" .. tostring(entry.action_id or "?") .. "]" - description = "" - end - local background = type(options.background) == "function" and options.background(action) or ui.EMPTY_SLOT - local icon = type(options.icon) == "function" and options.icon(action) or ui.EMPTY_SLOT - local clicked, right, hovered, x, y, width, height = ui.tile(0, 0, background, icon, ui.EMPTY_SLOT, - ui.tr("$mcm_inventory_slot", "INVENTORY") .. " " .. tostring(index + 1), name .. (description ~= "" and ("\n" .. description) or ""), false, - {target_size=18,max_scale=2.0}) - local target_bounds = bounds(x,y,width,height) - if entry ~= nil and target_bounds ~= nil then - drag_drop.source("spells.inventory." .. tostring(entry.entity), { - kind="inventory_spell", entity=entry.entity, index=index, action_id=entry.action_id, - icon=icon, background=background, - }, target_bounds, options.clip_bounds) - end - if target_bounds ~= nil then - drag_drop.target("spells.inventory_target." .. tostring(index), target_bounds, - function(payload) - return type(payload) == "table" and (payload.kind == "catalog_spell" - or payload.kind == "wand_spell" or payload.kind == "wand_permanent_spell" - or payload.kind == "inventory_spell") - end, - function(payload) - if type(options.on_drop) ~= "function" then return false, "drop_unavailable" end - return options.on_drop(index, entry, payload, layout) - end, 95) - end - if right and entry ~= nil and type(options.on_right_click) == "function" then options.on_right_click(entry, layout) end - return clicked, false, hovered, x, y, width, height - end - - local cursor = 0 - while cursor < layout.capacity do - GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true, 0, 0) - for _ = 1, columns do - if cursor >= layout.capacity then break end - draw_slot(cursor) - cursor = cursor + 1 - end - GuiLayoutEnd(ui.gui()) - end - - return layout, "ok" -end - -METAMORPH_CREATIVE_MENU_SPELL_INVENTORY_UI = spell_inventory_ui -return spell_inventory_ui diff --git a/metamorph_creative_menu/files/ui/components/wand_always_cast_strip.lua b/metamorph_creative_menu/files/ui/components/wand_always_cast_strip.lua deleted file mode 100644 index 29025b9..0000000 --- a/metamorph_creative_menu/files/ui/components/wand_always_cast_strip.lua +++ /dev/null @@ -1,93 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_WAND_ALWAYS_CAST_UI) == "table" then - return METAMORPH_CREATIVE_MENU_WAND_ALWAYS_CAST_UI -end - -local always_cast_ui = {} -local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") -local horizontal_strip = dofile("mods/metamorph_creative_menu/files/ui/widgets/horizontal_strip.lua") -local drag_drop = dofile("mods/metamorph_creative_menu/files/ui/drag_drop.lua") - -local function bounds(x, y, width, height) - if x == nil or y == nil or width == nil or height == nil then return nil end - return {x=x, y=y, width=width, height=height} -end - -local function payload_allowed(payload) - return type(payload) == "table" and (payload.kind == "catalog_spell" - or payload.kind == "wand_spell" or payload.kind == "inventory_spell") -end - -function always_cast_ui.draw(permanent_entries, panel_width, screen_width, screen_height, presentation, options) - permanent_entries = type(permanent_entries) == "table" and permanent_entries or {} - options = type(options) == "table" and options or {} - - GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true) - ui.white_text(0, 1, ui.tr("$mcm_wand_always_cast", "ALWAYS CAST")) - local add_clicked, _, add_x, add_y, add_w, add_h = ui.button(0, 0, " + ") - local add_bounds = bounds(add_x, add_y, add_w, add_h) - if add_bounds ~= nil then - drag_drop.target("spells.always_cast.add", add_bounds, payload_allowed, function(payload) - if type(options.on_add_drop) ~= "function" then return false, "drop_unavailable" end - return options.on_add_drop(payload) - end, 110) - end - GuiLayoutEnd(ui.gui()) - -- Releasing a dragged spell on the + widget is both a native GuiButton click and a - -- drag/drop completion on the same frame. Only the drop may mutate the wand in that - -- case; otherwise the selected slot and the dragged slot can both be promoted, growing - -- raw deck capacity twice and creating two Always Cast cards from one gesture. - local drag_owns_release = type(drag_drop.pending) == "function" and drag_drop.pending() == true - if add_clicked and not drag_owns_release and type(options.on_add_click) == "function" then - options.on_add_click() - end - - if #permanent_entries == 0 then - ui.wrapped_text(0, 0, ui.tr("$mcm_wand_always_cast_empty", "Drop a spell on + to make it Always Cast."), panel_width - 12) - return {first=0,last=-1,visible_count=0} - end - - local strip = horizontal_strip.draw("spells.always_cast", #permanent_entries, panel_width - 10, ui.ICON_STEP, - function(index) - local entry = permanent_entries[index + 1] - local action = entry and options.action_by_id and options.action_by_id[entry.action_id] or nil - local name, description - if action ~= nil and type(presentation) == "function" then - name, description = presentation(action) - else - name = ui.tr("$mcm_spell_unknown", "Unknown/modded spell") .. " [" .. tostring(entry and entry.action_id or "?") .. "]" - description = "" - end - local background = type(options.background) == "function" and options.background(action) or ui.EMPTY_SLOT - local icon = type(options.icon) == "function" and options.icon(action) or ui.EMPTY_SLOT - local _, right, hovered, x, y, width, height = ui.tile(0, 0, background, icon, ui.EMPTY_SLOT, - ui.tr("$mcm_wand_always_cast", "ALWAYS CAST"), name .. (description ~= "" and ("\n" .. description) or ""), true, - {target_size=18,max_scale=2.0}) - - local target_bounds = bounds(x, y, width, height) - if entry ~= nil and target_bounds ~= nil then - drag_drop.source("spells.always_cast.source." .. tostring(entry.entity), { - kind="wand_permanent_spell", entity=entry.entity, action_id=entry.action_id, - icon=icon, background=background, - }, target_bounds, options.clip_bounds) - end - if target_bounds ~= nil then - drag_drop.target("spells.always_cast.target." .. tostring(entry.entity), target_bounds, - function(payload) - return type(payload) == "table" and (payload.kind == "catalog_spell" - or payload.kind == "wand_spell" or payload.kind == "inventory_spell") - end, - function(payload) - if type(options.on_replace_drop) ~= "function" then return false, "drop_unavailable" end - return options.on_replace_drop(entry, payload) - end, 120) - end - if right and type(options.on_right_click) == "function" then options.on_right_click(entry) end - return false, false, hovered, x, y, width, height - end, - {gui=ui.gui(), screen_width=screen_width, screen_height=screen_height, next_id=ui.next_id}) - - return strip -end - -METAMORPH_CREATIVE_MENU_WAND_ALWAYS_CAST_UI = always_cast_ui -return always_cast_ui diff --git a/metamorph_creative_menu/files/ui/components/wand_appearance_editor.lua b/metamorph_creative_menu/files/ui/components/wand_appearance_editor.lua deleted file mode 100644 index b54d1d5..0000000 --- a/metamorph_creative_menu/files/ui/components/wand_appearance_editor.lua +++ /dev/null @@ -1,162 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_WAND_APPEARANCE_EDITOR_UI) == "table" then return METAMORPH_CREATIVE_MENU_WAND_APPEARANCE_EDITOR_UI end - -local appearance_editor = {} -local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") -local appearance = dofile("mods/metamorph_creative_menu/files/features/wands/appearance.lua") -local history = dofile("mods/metamorph_creative_menu/files/features/wands/history.lua") -local numeric = dofile("mods/metamorph_creative_menu/files/ui/components/fixed_numeric_editor.lua") -local skin_picker = dofile("mods/metamorph_creative_menu/files/ui/components/wand_skin_picker.lua") - -local expanded = false -local tracked_wand = 0 -local name_draft = "" -local sprite_draft = "" -local last_error = nil - -local function report_error(reason) - if reason == nil then return end - reason = tostring(reason) - if last_error == reason then return end - last_error = reason - local message = ui.tr("$mcm_wand_appearance_error", "Could not change appearance") .. ": " .. reason - if type(GamePrint) == "function" then pcall(GamePrint, message) end - if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "wand.appearance", reason) - end -end - -local function clear_error() - last_error = nil -end - -local function perform(player, wand, label, callback, options) - local ok, reason = history.perform(player, wand, label, callback, options) - if ok then clear_error() else report_error(reason) end - return ok, reason -end - -local function reset(snapshot) - name_draft = tostring(snapshot.name or "") - sprite_draft = tostring(snapshot.sprite_file or snapshot.image_file or "") - numeric.reset("wand.appearance.") -end - -local function draw_unavailable(label, tooltip) - local unavailable = ui.tr("$mcm_wand_geometry_unavailable", "NOT AVAILABLE FOR THIS WAND") - ui.white_text(0, 1, tostring(label or "") .. ": " .. unavailable) - if type(GuiTooltip) == "function" then GuiTooltip(ui.gui(), tostring(label or ""), tostring(tooltip or unavailable)) end -end - -local function draw_geometry(player, wand, snapshot, id, label, value, key, panel_width, tooltip) - if value == nil then - draw_unavailable(label, tooltip) - return - end - local _, reason = numeric.draw("wand.appearance." .. tostring(wand) .. "." .. id, label, value, { - step=1, label_width=112, value_width=58, value_chars=18, - max_width=math.max(64, (tonumber(panel_width) or 220) - 12), - label_tooltip_description=tooltip, - on_apply=function(new_value) - local values = {}; values[key] = new_value - return perform(player, wand, "appearance " .. id, function() - return appearance.set_visual(player, wand, values) - end, {coalesce_key="appearance." .. id,coalesce_frames=45}) - end, - }) - if reason ~= nil then report_error(reason) end -end - -function appearance_editor.draw(player, wand, panel_width) - panel_width = math.max(48, tonumber(panel_width) or 220) - local snapshot, reason = appearance.snapshot(wand) - if snapshot == nil then return false, reason end - if tracked_wand ~= wand then - tracked_wand = wand - reset(snapshot) - clear_error() - end - - local label = (expanded and "- " or "+ ") .. ui.tr("$mcm_wand_appearance", "APPEARANCE & LOCKS") - if ui.button_grid({{label=label,selected=expanded,tooltip_title=ui.tr("$mcm_wand_appearance", "APPEARANCE & LOCKS")}}, - math.max(32,panel_width-12)) == 1 then expanded = not expanded end - if not expanded then return true end - - -- Keep the native name field on its own row so narrow layouts cannot displace it; - -- its semantic focus key preserves native input identity and the in-progress draft. - ui.white_text(0, 1, ui.tr("$mcm_wand_name", "NAME")) - name_draft = ui.text_input(name_draft, math.max(36, panel_width - 12), 80, - "wand.name." .. tostring(wand), {escape_clears=false}) - if ui.button(0, 0, " " .. ui.tr("$mcm_apply", "APPLY") .. " ") then - local current = select(1, appearance.snapshot(wand)) or snapshot - local ok = perform(player, wand, "wand name", function() - return appearance.set_name(player, wand, name_draft, current.show_name_in_ui) - end, {coalesce_key="appearance.name",coalesce_frames=8}) - if ok then snapshot = select(1, appearance.snapshot(wand)) or snapshot end - end - - local on_text = ui.tr("$mcm_rule_on", "ON") - local off_text = ui.tr("$mcm_rule_off", "OFF") - local mixed_text = ui.tr("$mcm_mixed", "MIXED") - local all_frozen, mixed, spell_count = appearance.spell_freeze_state(wand) - local spell_state = mixed and mixed_text or (all_frozen and on_text or off_text) - local lock_clicked = ui.button_grid({ - {label=ui.tr("$mcm_wand_show_name", "SHOW NAME") .. ": " .. (snapshot.show_name_in_ui and on_text or off_text), selected=snapshot.show_name_in_ui}, - {label=ui.tr("$mcm_wand_lock", "WAND LOCK") .. ": " .. (snapshot.wand_frozen and on_text or off_text), selected=snapshot.wand_frozen}, - {label=ui.tr("$mcm_wand_spell_lock", "SPELL LOCK") .. ": " .. spell_state, selected=all_frozen or mixed}, - }, math.max(32, panel_width - 12)) - if lock_clicked == 1 then - local current = select(1, appearance.snapshot(wand)) or snapshot - local ok = perform(player, wand, "show wand name", function() - return appearance.set_name(player, wand, name_draft, not current.show_name_in_ui) - end) - if ok then snapshot = select(1, appearance.snapshot(wand)) or snapshot end - elseif lock_clicked == 2 then - local current = select(1, appearance.snapshot(wand)) or snapshot - local ok = perform(player, wand, "wand lock", function() - return appearance.set_wand_frozen(player, wand, not current.wand_frozen) - end) - if ok then snapshot = select(1, appearance.snapshot(wand)) or snapshot end - elseif lock_clicked == 3 and spell_count > 0 then - local actual_all_frozen = select(1, appearance.spell_freeze_state(wand)) - local ok = perform(player, wand, "spell locks", function() - return appearance.set_spells_frozen(player, wand, not actual_all_frozen) - end) - if ok then - snapshot = select(1, appearance.snapshot(wand)) or snapshot - all_frozen, mixed, spell_count = appearance.spell_freeze_state(wand) - end - end - - ui.white_text(0, 1, ui.tr("$mcm_wand_skin", "SKIN")) - sprite_draft = ui.text_input(sprite_draft, math.max(36, panel_width - 12), 180, - "wand.skin." .. tostring(wand), {escape_clears=false}) - if ui.button_grid({{label=ui.tr("$mcm_apply", "APPLY")}}, math.max(32, panel_width - 12)) == 1 then - local ok = perform(player, wand, "skin path", function() - return appearance.set_visual(player, wand, {sprite_file=sprite_draft}) - end, {coalesce_key="appearance.skin",coalesce_frames=8}) - if ok then snapshot = select(1, appearance.snapshot(wand)) or snapshot end - end - - local skin_changed, skin_error = skin_picker.draw(player, wand, snapshot, panel_width) - if skin_changed then - snapshot = select(1, appearance.snapshot(wand)) or snapshot - sprite_draft = tostring(snapshot.sprite_file or "") - clear_error() - elseif skin_error ~= nil then - report_error(skin_error) - end - - draw_geometry(player, wand, snapshot, "offset_x", ui.tr("$mcm_wand_sprite_x", "IMAGE OFFSET X"), snapshot.offset_x, - "offset_x", panel_width, ui.tr("$mcm_wand_sprite_x_desc", "Horizontal offset of the visible wand image in pixels.")) - draw_geometry(player, wand, snapshot, "offset_y", ui.tr("$mcm_wand_sprite_y", "IMAGE OFFSET Y"), snapshot.offset_y, - "offset_y", panel_width, ui.tr("$mcm_wand_sprite_y_desc", "Vertical offset of the visible wand image in pixels.")) - draw_geometry(player, wand, snapshot, "tip_x", ui.tr("$mcm_wand_tip_x", "SHOOT TIP X"), snapshot.tip_x, - "tip_x", panel_width, nil) - draw_geometry(player, wand, snapshot, "tip_y", ui.tr("$mcm_wand_tip_y", "SHOOT TIP Y"), snapshot.tip_y, - "tip_y", panel_width, nil) - - return true -end - -METAMORPH_CREATIVE_MENU_WAND_APPEARANCE_EDITOR_UI = appearance_editor -return appearance_editor diff --git a/metamorph_creative_menu/files/ui/components/wand_editor.lua b/metamorph_creative_menu/files/ui/components/wand_editor.lua deleted file mode 100644 index 6f6ff34..0000000 --- a/metamorph_creative_menu/files/ui/components/wand_editor.lua +++ /dev/null @@ -1,116 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_WAND_EDITOR_UI) == "table" then return METAMORPH_CREATIVE_MENU_WAND_EDITOR_UI end - -local wand_editor = {} -local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") -local wand_service = dofile("mods/metamorph_creative_menu/files/features/wands/service.lua") -local history = dofile("mods/metamorph_creative_menu/files/features/wands/history.lua") -local history_bar = dofile("mods/metamorph_creative_menu/files/ui/components/wand_history_bar.lua") -local numeric = dofile("mods/metamorph_creative_menu/files/ui/components/fixed_numeric_editor.lua") -local appearance_editor = dofile("mods/metamorph_creative_menu/files/ui/components/wand_appearance_editor.lua") - -local expanded = true -local tracked_wand = 0 -local last_error = nil - -local FIELDS = { - {id="slots", key="$mcm_wand_slots", fallback="SLOTS"}, - {id="actions_per_round", key="$mcm_wand_spells_per_cast", fallback="SPELLS/CAST"}, - {id="reload_time", key="$mcm_wand_recharge", fallback="RECHARGE", - tooltip_key="$mcm_wand_recharge_desc", tooltip_fallback="Recharge time in game frames."}, - {id="fire_rate_wait", key="$mcm_wand_cast_delay", fallback="CAST DELAY", - tooltip_key="$mcm_wand_cast_delay_desc", tooltip_fallback="Delay between casts in game frames."}, - {id="spread_degrees", key="$mcm_wand_spread", fallback="SPREAD", - tooltip_key="$mcm_wand_spread_desc", tooltip_fallback="Spread angle in degrees."}, - {id="speed_multiplier", key="$mcm_wand_speed", fallback="SPEED", step=0.05}, - {id="mana_max", key="$mcm_wand_mana_max", fallback="MANA MAX"}, - {id="mana_charge_speed", key="$mcm_wand_mana_charge", fallback="MANA CHARGE"}, - {id="item_recoil_recovery_speed", key="$mcm_wand_recoil_recovery", fallback="RECOIL REC."}, - {id="gun_level", key="$mcm_wand_level", fallback="LEVEL"}, -} - -local function perform(player, wand, label, callback, options) - local ok, reason = history.perform(player, wand, label, callback, options) - last_error = ok and nil or reason - return ok, reason -end - -local function reset_controls(wand) - numeric.reset("wand.stat." .. tostring(wand) .. ".") -end - -local function draw_stat(player, wand, snapshot, field, panel_width) - local definition = wand_service.definition(field.id) or {} - local _, reason = numeric.draw("wand.stat." .. tostring(wand) .. "." .. field.id, - ui.tr(field.key, field.fallback), snapshot.stats[field.id], { - integer=definition.integer == true, - step=field.step or definition.step or 1, - min=definition.min, - max=definition.max, - label_tooltip_description=field.tooltip_key and ui.tr(field.tooltip_key, field.tooltip_fallback) or nil, - label_width=94, - value_width=58, - value_chars=24, - max_width=math.max(96, (tonumber(panel_width) or 220) - 12), - on_apply=function(value) - return perform(player, wand, field.id, function() - return wand_service.set_stat(player, wand, field.id, value) - end, {coalesce_key="stat." .. field.id,coalesce_frames=45}) - end, - }) - if reason ~= nil then last_error = reason end -end - -function wand_editor.draw(player, wand, panel_width) - local snapshot, reason = wand_service.snapshot(wand) - if snapshot == nil then return false, reason end - if tracked_wand ~= wand then - tracked_wand = wand - reset_controls(wand) - last_error = nil - end - - local history_changed, history_error = history_bar.draw(player, wand) - if history_changed then - snapshot = select(1, wand_service.snapshot(wand)) or snapshot - reset_controls(wand) - last_error = nil - elseif history_error ~= nil then - last_error = history_error - end - - local toggle = (expanded and "- " or "+ ") .. ui.tr("$mcm_wand_stats", "WAND STATS") - if ui.button(0, 0, toggle, expanded) then expanded = not expanded end - if expanded then - snapshot = select(1, wand_service.snapshot(wand)) or snapshot - for _, field in ipairs(FIELDS) do draw_stat(player, wand, snapshot, field, panel_width) end - - local on_text = ui.tr("$mcm_rule_on", "ON") - local off_text = ui.tr("$mcm_rule_off", "OFF") - local toggles = { - {label=ui.tr("$mcm_wand_shuffle", "SHUFFLE") .. ": " .. (snapshot.stats.shuffle and on_text or off_text), selected=snapshot.stats.shuffle}, - {label=ui.tr("$mcm_wand_never_reload", "NO RELOAD") .. ": " .. (snapshot.stats.never_reload and on_text or off_text), selected=snapshot.stats.never_reload}, - } - local toggle_clicked = ui.button_grid(toggles, math.max(96, panel_width - 12)) - if toggle_clicked == 1 then - perform(player, wand, "shuffle", function() - return wand_service.set_boolean(player, wand, "shuffle", not snapshot.stats.shuffle) - end) - elseif toggle_clicked == 2 then - perform(player, wand, "never_reload", function() - return wand_service.set_boolean(player, wand, "never_reload", not snapshot.stats.never_reload) - end) - end - end - - appearance_editor.draw(player, wand, panel_width) - - -- Mutation reasons are developer diagnostics, not persistent release UI. The normal - -- controls already keep/restore the real wand state when a write fails. - if METAMORPH_CREATIVE_MENU_DEV_MODE == true and last_error ~= nil then - ui.wrapped_text(0, 0, ui.tr("$mcm_wand_edit_error", "Could not change wand") .. ": " .. tostring(last_error), panel_width - 12) - end - return true -end - -METAMORPH_CREATIVE_MENU_WAND_EDITOR_UI = wand_editor -return wand_editor diff --git a/metamorph_creative_menu/files/ui/components/wand_history_bar.lua b/metamorph_creative_menu/files/ui/components/wand_history_bar.lua deleted file mode 100644 index 294fc6e..0000000 --- a/metamorph_creative_menu/files/ui/components/wand_history_bar.lua +++ /dev/null @@ -1,36 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_WAND_HISTORY_UI) == "table" then return METAMORPH_CREATIVE_MENU_WAND_HISTORY_UI end - -local history_bar = {} -local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") -local history = dofile("mods/metamorph_creative_menu/files/features/wands/history.lua") - -function history_bar.draw(player, wand) - local changed, error_reason = false, nil - GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true) - - local undo_label = history.undo_label(wand) - local undo_tip = undo_label and (ui.tr("$mcm_wand_undo", "Undo") .. ": " .. undo_label) - or ui.tr("$mcm_wand_nothing_undo", "Nothing to undo") - if ui.button(0, 0, " " .. ui.tr("$mcm_wand_undo", "UNDO") .. " ", false, undo_tip, "") then - if history.can_undo(wand) then - local ok, reason = history.undo(player, wand) - changed, error_reason = ok, ok and nil or reason - end - end - - local redo_label = history.redo_label(wand) - local redo_tip = redo_label and (ui.tr("$mcm_wand_redo", "Redo") .. ": " .. redo_label) - or ui.tr("$mcm_wand_nothing_redo", "Nothing to redo") - if ui.button(0, 0, " " .. ui.tr("$mcm_wand_redo", "REDO") .. " ", false, redo_tip, "") then - if history.can_redo(wand) then - local ok, reason = history.redo(player, wand) - changed, error_reason = ok, ok and nil or reason - end - end - - GuiLayoutEnd(ui.gui()) - return changed, error_reason -end - -METAMORPH_CREATIVE_MENU_WAND_HISTORY_UI = history_bar -return history_bar diff --git a/metamorph_creative_menu/files/ui/components/wand_presets.lua b/metamorph_creative_menu/files/ui/components/wand_presets.lua deleted file mode 100644 index 396d230..0000000 --- a/metamorph_creative_menu/files/ui/components/wand_presets.lua +++ /dev/null @@ -1,108 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_WAND_PRESETS_UI) == "table" then return METAMORPH_CREATIVE_MENU_WAND_PRESETS_UI end - -local wand_presets = {} -local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") -local presets = dofile("mods/metamorph_creative_menu/files/features/wands/presets.lua") -local history = dofile("mods/metamorph_creative_menu/files/features/wands/history.lua") - -local expanded = false -local name = "" -local confirm_delete = nil -local DEFAULT_WAND_ICON = "data/items_gfx/handgun.xml" - -local function set_status(ok, reason) - if ok then ui.clear_error_notice("wand.presets") - else ui.report_error_once("wand.presets", ui.tr("$mcm_wand_preset_error", "Preset error"), reason, "wand.presets") end - return ok -end - -local function preset_icon(preset) - local blueprint = type(preset) == "table" and preset.blueprint or nil - local meta = type(blueprint) == "table" and type(blueprint.meta) == "table" and blueprint.meta or {} - return ui.resolve(meta.image_file) or ui.resolve(type(blueprint) == "table" and blueprint.sprite_file or nil) - or ui.resolve(DEFAULT_WAND_ICON) or DEFAULT_WAND_ICON -end - -local function draw_preset_icon(preset) - local icon = preset_icon(preset) - local width, height = ui.dimensions(icon) - width, height = math.max(1, tonumber(width) or 16), math.max(1, tonumber(height) or 16) - local scale = math.min(14 / width, 14 / height, 1.5) - GuiZSetForNextWidget(ui.gui(), -103) - GuiImage(ui.gui(), ui.next_id(), 0, 0, icon, 1, scale, scale) -end - -local function draw_preset_card(index, preset, player, wand, content_width) - content_width = math.max(48, tonumber(content_width) or 120) - GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true) - draw_preset_icon(preset) - ui.white_text(2, 1, ui.truncate_text(tostring(preset.name), math.max(24, content_width - 20))) - GuiLayoutEnd(ui.gui()) - - local apply_label = ui.tr("$mcm_wand_preset_apply", "APPLY") - local copy_label = ui.tr("$mcm_wand_preset_copy", "GET COPY") - local delete_label = ui.tr("$mcm_delete", "DELETE") - local confirm_label = ui.tr("$mcm_confirm", "CONFIRM") - local cancel_label = ui.tr("$mcm_cancel", "CANCEL") - local buttons = { - {label=apply_label,tooltip_title=apply_label,tooltip_description=ui.tr("$mcm_wand_preset_apply_hint", "Apply to the held wand")}, - {label=copy_label,tooltip_title=copy_label,tooltip_description=ui.tr("$mcm_wand_preset_copy_hint", "Create a separate saved wand")}, - } - if confirm_delete == index then - buttons[#buttons + 1] = {label=confirm_label} - buttons[#buttons + 1] = {label=cancel_label} - else - buttons[#buttons + 1] = {label=delete_label} - end - local clicked = ui.button_grid(buttons, math.max(32, content_width - 16)) - if clicked == 1 then - local ok, reason = history.perform(player, wand, "load preset", function() - return presets.load(index, player, wand) - end) - set_status(ok, reason); confirm_delete = nil - elseif clicked == 2 then - local ok, reason = presets.give(index, player) - set_status(ok, reason); confirm_delete = nil - elseif clicked == 3 then - if confirm_delete == index then - local ok, reason = presets.delete(index) - set_status(ok, reason); confirm_delete = nil - else - confirm_delete = index - end - elseif clicked == 4 and confirm_delete == index then - confirm_delete = nil - end - GuiLayoutAddVerticalSpacing(ui.gui(), 2) -end - -function wand_presets.draw(player, wand, panel_width) - panel_width = math.max(48, tonumber(panel_width) or 220) - local label = (expanded and "- " or "+ ") .. ui.tr("$mcm_wand_presets", "WAND PRESETS") - if ui.button_grid({{label=label,selected=expanded,tooltip_title=ui.tr("$mcm_wand_presets", "WAND PRESETS")}}, - math.max(32,panel_width-12)) == 1 then expanded = not expanded end - if not expanded then return end - - name = ui.text_input(name, math.max(36, panel_width - 12), 40, "wand.preset_name", { - escape_clears=false, - }) - if ui.button_grid({{label=ui.tr("$mcm_save", "SAVE")}}, math.max(32, panel_width - 12)) == 1 then - local ok, reason = presets.save(name, wand) - set_status(ok, reason) - end - - local list = presets.all() - if #list == 0 then - ui.white_text(0, 0, ui.tr("$mcm_wand_no_presets", "No saved wands")) - else - -- The whole WAND workspace is already a bounded scroll viewport. A second scroll - -- container here made wheel/drag ownership ambiguous and felt like a list inside a - -- list. Let the single outer workspace scroll all preset cards naturally. - for index, preset in ipairs(list) do - draw_preset_card(index, preset, player, wand, math.max(48, panel_width - 8)) - end - end -end - -METAMORPH_CREATIVE_MENU_WAND_PRESETS_UI = wand_presets -return wand_presets diff --git a/metamorph_creative_menu/files/ui/components/wand_skin_picker.lua b/metamorph_creative_menu/files/ui/components/wand_skin_picker.lua deleted file mode 100644 index fdbb97d..0000000 --- a/metamorph_creative_menu/files/ui/components/wand_skin_picker.lua +++ /dev/null @@ -1,83 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_WAND_SKIN_PICKER_UI) == "table" then return METAMORPH_CREATIVE_MENU_WAND_SKIN_PICKER_UI end - -local skin_picker = {} -local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") -local skins = dofile("mods/metamorph_creative_menu/files/features/wands/skins.lua") -local appearance = dofile("mods/metamorph_creative_menu/files/features/wands/appearance.lua") -local history = dofile("mods/metamorph_creative_menu/files/features/wands/history.lua") - -local expanded = false -local search = "" -local SCROLL_ID = 17841 - -local function apply_skin(player, wand, entry) - return history.perform(player, wand, "skin", function() - return appearance.set_visual(player, wand, { - sprite_file=entry.sprite_file, image_file=entry.image_file, - offset_x=entry.offset_x, offset_y=entry.offset_y, - tip_x=entry.tip_x, tip_y=entry.tip_y, - }) - end, {coalesce_key="skin", coalesce_frames=8}) -end - -function skin_picker.draw(player, wand, current_visual, panel_width) - local label = (expanded and "- " or "+ ") .. ui.tr("$mcm_wand_skins", "SKINS") - if ui.button_grid({{label=label,selected=expanded,tooltip_title=ui.tr("$mcm_wand_skins", "SKINS")}}, - math.max(32,(tonumber(panel_width) or 220)-12)) == 1 then expanded = not expanded end - if not expanded then return false, nil end - - search = ui.search_input(search, math.max(68, (tonumber(panel_width) or 220) - 28), 64, "wand.skins") - local current_sprite = type(current_visual) == "table" and current_visual.sprite_file or current_visual - local current_image = type(current_visual) == "table" and current_visual.image_file or current_sprite - local all = skins.entries() - local matched = {} - for _, entry in ipairs(all) do - if search == "" or ui.search_score(search, entry.name, entry.source_path, entry.sprite_file, entry.image_file) ~= nil then - matched[#matched + 1] = entry - end - end - - if #matched == 0 then - ui.white_text(0, 0, ui.tr("$mcm_no_results", "No results")) - return false, nil - end - - local preliminary_columns = math.max(1, ui.columns(panel_width - 8)) - local rows = math.ceil(#matched / preliminary_columns) - local height = math.min(76, math.max(22, rows * ui.ICON_STEP + 4)) - local scroll = ui.begin_scroll_viewport("wand.skins", SCROLL_ID, 0, 0, panel_width - 8, height, {layout="free"}) - local columns = math.max(1, ui.columns(scroll.content_width, ui.ICON_STEP, {reserve_scrollbar=false})) - rows = math.ceil(#matched / columns) - local changed, reason = false, nil - for index, entry in ipairs(matched) do - local i = index - 1 - local name = ui.translated(entry.name) - if name == tostring(entry.name or "") and string.sub(tostring(entry.name or ""), 1, 1) ~= "$" then - name = tostring(entry.name or entry.source_path or "Wand") - end - local details = tostring(entry.sprite_file) - if entry.image_file ~= nil and tostring(entry.image_file) ~= tostring(entry.sprite_file) then - details = details .. "\nimage " .. tostring(entry.image_file) - end - if entry.offset_x ~= nil and entry.offset_y ~= nil then - details = details .. "\n" .. string.format("offset %.2g, %.2g", entry.offset_x, entry.offset_y) - end - local clicked = ui.tile(scroll.padding_left + (i % columns) * ui.ICON_STEP, - ui.scroll_y(scroll, math.floor(i / columns) * ui.ICON_STEP), - ui.EMPTY_SLOT, entry.icon, ui.EMPTY_SLOT, - name, details, - tostring(current_sprite or "") == tostring(entry.sprite_file) - and tostring(current_image or current_sprite or "") == tostring(entry.image_file or entry.sprite_file), - {target_size=18,max_scale=2.0}) - if clicked then - local ok, err = apply_skin(player, wand, entry) - changed, reason = ok, ok and nil or err - if ok then current_sprite, current_image = entry.sprite_file, entry.image_file or entry.sprite_file end - end - end - ui.end_scroll_viewport(scroll, rows * ui.ICON_STEP) - return changed, reason -end - -METAMORPH_CREATIVE_MENU_WAND_SKIN_PICKER_UI = skin_picker -return skin_picker diff --git a/metamorph_creative_menu/files/ui/drag_drop.lua b/metamorph_creative_menu/files/ui/drag_drop.lua deleted file mode 100644 index c77531c..0000000 --- a/metamorph_creative_menu/files/ui/drag_drop.lua +++ /dev/null @@ -1,165 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_DRAG_DROP) == "table" then return METAMORPH_CREATIVE_MENU_DRAG_DROP end - -local drag_drop = {} -local pointer = dofile("mods/metamorph_creative_menu/files/platform/noita/pointer.lua") - -local state = nil -local targets = {} -local screen_width, screen_height = nil, nil -local last_result = nil -local DRAG_THRESHOLD = 4 - -local function point_inside(bounds, x, y) - if type(bounds) ~= "table" then return false end - return pointer.inside(bounds.x, bounds.y, bounds.width, bounds.height, x, y) -end - -local function source_hit(source_bounds, clip_bounds, x, y, hovered) - if type(source_bounds) == "table" then - if not point_inside(source_bounds, x, y) then return false end - if type(clip_bounds) == "table" and not point_inside(clip_bounds, x, y) then return false end - return true - end - -- Backward-compatible fallback for older callers. New draggable tiles should pass - -- their measured bounds so ownership follows the physical press origin rather than - -- GuiGetPreviousWidgetInfo().hovered, which can change at clipping boundaries. - return hovered == true or source_bounds == true -end - -function drag_drop.begin_frame(width, height) - screen_width, screen_height = tonumber(width), tonumber(height) - targets = {} - if state ~= nil then - local x, y = pointer.gui_position(screen_width, screen_height) - if x ~= nil then - state.mouse_x, state.mouse_y = x, y - if pointer.left_down() and state.start_x ~= nil then - local dx, dy = x - state.start_x, y - state.start_y - if dx * dx + dy * dy >= DRAG_THRESHOLD * DRAG_THRESHOLD then state.active = true end - end - end - end -end - --- source_bounds is the measured tile hitbox. clip_bounds is optional and should be the --- visible viewport when a tile is rendered inside a clipped scroll container. Once the --- press is claimed, neither later hover state nor the source being virtualized away can --- revoke ownership; only release/cancel can finish the gesture. -function drag_drop.source(id, payload, source_bounds, clip_bounds, hovered) - if state ~= nil or not pointer.left_just_down() then return false end - local x, y = pointer.gui_position(screen_width, screen_height) - if x == nil or not source_hit(source_bounds, clip_bounds, x, y, hovered) then return false end - state = { - id=tostring(id or "source"), payload=payload, - start_x=x, start_y=y, mouse_x=x, mouse_y=y, active=false, - } - return true -end - -function drag_drop.target(id, bounds, accepts, on_drop, priority) - if type(bounds) ~= "table" or tonumber(bounds.x) == nil or tonumber(bounds.y) == nil - or tonumber(bounds.width) == nil or tonumber(bounds.height) == nil - then - return - end - targets[#targets + 1] = { - id=tostring(id or "target"), bounds=bounds, accepts=accepts, on_drop=on_drop, - priority=tonumber(priority) or 0, - } -end - -function drag_drop.active() - return state ~= nil and state.active == true -end - -function drag_drop.pending() - return state ~= nil -end - -function drag_drop.press_owned() - return state ~= nil -end - -function drag_drop.payload() - return drag_drop.active() and state.payload or nil -end - -function drag_drop.source_id() - return state and state.id or nil -end - -function drag_drop.mouse_position() - if state ~= nil then return state.mouse_x, state.mouse_y end - return pointer.gui_position(screen_width, screen_height) -end - -function drag_drop.cancel() - state = nil - -- Closing, minimizing or suppressing the menu is a lifecycle boundary. A completed - -- release that was not consumed by its source tab must not survive that boundary and - -- execute unexpectedly when the menu is opened again. - last_result = nil -end - -function drag_drop.take_result() - local result = last_result - last_result = nil - return result -end - -function drag_drop.end_frame() - if state == nil or pointer.left_down() then return nil end - local completed = state - state = nil - local release_x, release_y = pointer.gui_position(screen_width, screen_height) - local world_x, world_y = pointer.world_position() - local release = { - start_x=completed.start_x, start_y=completed.start_y, - release_x=release_x, release_y=release_y, - world_x=world_x, world_y=world_y, - } - if completed.active ~= true then - last_result = { - source=completed.id, click=true, ok=true, payload=completed.payload, - start_x=release.start_x, start_y=release.start_y, - release_x=release.release_x, release_y=release.release_y, - world_x=release.world_x, world_y=release.world_y, - } - return last_result - end - local candidates = {} - for _, target in ipairs(targets) do - if pointer.inside(target.bounds.x, target.bounds.y, target.bounds.width, target.bounds.height, release_x, release_y) then - local accepted = true - if type(target.accepts) == "function" then - local ok, value = pcall(target.accepts, completed.payload) - accepted = ok and value == true - end - if accepted then candidates[#candidates + 1] = target end - end - end - table.sort(candidates, function(a, b) return a.priority > b.priority end) - local target = candidates[1] - if target ~= nil and type(target.on_drop) == "function" then - local ok, result, reason = pcall(target.on_drop, completed.payload) - last_result = { - source=completed.id, target=target.id, ok=ok and result ~= false, result=result, reason=reason, payload=completed.payload, - start_x=release.start_x, start_y=release.start_y, - release_x=release.release_x, release_y=release.release_y, - world_x=release.world_x, world_y=release.world_y, - } - else - last_result = { - source=completed.id, target=nil, ok=false, reason="no_target", payload=completed.payload, - start_x=release.start_x, start_y=release.start_y, - release_x=release.release_x, release_y=release.release_y, - world_x=release.world_x, world_y=release.world_y, - } - end - return last_result -end - -function drag_drop.last_result() return last_result end - -METAMORPH_CREATIVE_MENU_DRAG_DROP = drag_drop -return drag_drop diff --git a/metamorph_creative_menu/files/ui/menu_controller.lua b/metamorph_creative_menu/files/ui/menu_controller.lua index acec113..0cd84fa 100644 --- a/metamorph_creative_menu/files/ui/menu_controller.lua +++ b/metamorph_creative_menu/files/ui/menu_controller.lua @@ -3,217 +3,52 @@ if type(METAMORPH_CREATIVE_MENU_MENU_CONTROLLER) == "table" then return METAMORP local menu_controller = {} local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") -local audit = ui.audit local player_locator = dofile("mods/metamorph_creative_menu/files/platform/noita/player_locator.lua") local input_guard = dofile("mods/metamorph_creative_menu/files/platform/noita/input_guard.lua") +local keycodes = dofile("mods/metamorph_creative_menu/files/platform/noita/keycodes.lua") local menu_inventory_guard = dofile("mods/metamorph_creative_menu/files/platform/noita/menu_inventory_guard.lua") -local action_bindings = dofile("mods/metamorph_creative_menu/files/platform/noita/action_bindings.lua") -local panel_layout = dofile("mods/metamorph_creative_menu/files/core/panel_layout.lua") -local visibility_policy = dofile("mods/metamorph_creative_menu/files/core/menu_visibility_policy.lua") -local pointer = dofile("mods/metamorph_creative_menu/files/platform/noita/pointer.lua") -local drag_drop = dofile("mods/metamorph_creative_menu/files/ui/drag_drop.lua") -local gameplay_input = dofile("mods/metamorph_creative_menu/files/platform/noita/gameplay_input.lua") local tabs = { { id="spells", key="$mcm_tab_spells", fallback="SPELLS", icon="data/ui_gfx/gun_actions/light_bullet.png", fallback_icon="data/ui_gfx/inventory/icon_gun.png", module=dofile("mods/metamorph_creative_menu/files/ui/tabs/spells.lua") }, { id="items", key="$mcm_tab_items", fallback="ITEMS", icon="data/ui_gfx/items/potion.png", fallback_icon="data/ui_gfx/items/ingredient_1.png", module=dofile("mods/metamorph_creative_menu/files/ui/tabs/items.lua") }, - { id="materials", key="$mcm_tab_materials", fallback="MATERIALS", icon="data/ui_gfx/items/material_pouch.png", fallback_icon="data/ui_gfx/items/potion.png", module=dofile("mods/metamorph_creative_menu/files/ui/tabs/materials.lua") }, - { id="perks", key="$mcm_tab_perks", fallback="PERKS", icon="data/ui_gfx/perk_icons/respawn.png", fallback_icon="data/ui_gfx/perk_icons/extra_hp.png", icon_fill=1.55, icon_max_scale=3.5, module=dofile("mods/metamorph_creative_menu/files/ui/tabs/perks.lua") }, + { id="perks", key="$mcm_tab_perks", fallback="PERKS", icon="data/ui_gfx/perk_icons/edit_wands_everywhere.png", fallback_icon="data/ui_gfx/perk_icons/extra_hp.png", module=dofile("mods/metamorph_creative_menu/files/ui/tabs/perks.lua") }, { id="mobs", key="$mcm_tab_creatures", fallback="MOBS", icon="data/ui_gfx/animal_icons/sheep.png", fallback_icon="data/ui_gfx/animal_icons/zombie.png", module=dofile("mods/metamorph_creative_menu/files/ui/tabs/creatures.lua") }, - { id="effects", key="$mcm_tab_effects", fallback="EFFECTS", icon="data/ui_gfx/status_indicators/hp_regeneration.png", fallback_icon="data/ui_gfx/status_indicators/wet.png", module=dofile("mods/metamorph_creative_menu/files/ui/tabs/effects.lua") }, - { id="weather", key="$mcm_tab_weather", fallback="WEATHER", icon="data/ui_gfx/status_indicators/wet.png", fallback_icon="data/ui_gfx/items/waterstone.png", module=dofile("mods/metamorph_creative_menu/files/ui/tabs/weather.lua") }, + { id="effects", key="$mcm_tab_effects", fallback="EFFECTS", icon="data/ui_gfx/status_indicators/wet.png", fallback_icon="data/ui_gfx/status_indicators/on_fire.png", module=dofile("mods/metamorph_creative_menu/files/ui/tabs/effects.lua") }, + { id="weather", key="$mcm_tab_weather", fallback="WEATHER", icon="data/ui_gfx/items/thunderstone.png", fallback_icon="data/ui_gfx/items/waterstone.png", module=dofile("mods/metamorph_creative_menu/files/ui/tabs/weather.lua") }, { id="rules", key="$mcm_tab_rules", fallback="RULES", icon="data/ui_gfx/perk_icons/peace_with_gods.png", fallback_icon="data/ui_gfx/perk_icons/gold_is_forever.png", module=dofile("mods/metamorph_creative_menu/files/ui/tabs/world_rules.lua") }, - { id="players", key="$mcm_tab_players", fallback="TELEPORTATION", icon="data/ui_gfx/gun_actions/teleport_projectile_static.png", fallback_icon="data/ui_gfx/gun_actions/light_bullet.png", module=dofile("mods/metamorph_creative_menu/files/ui/tabs/players.lua") }, - { id="controls", action="open_controls", key="$mcm_tab_controls", fallback="CONTROLS", icon="data/ui_gfx/gun_actions/divide_2.png", fallback_icon="data/ui_gfx/gun_actions/light_bullet.png", module=dofile("mods/metamorph_creative_menu/files/ui/tabs/controls.lua") }, } -for _, tab in ipairs(tabs) do if tab.action == nil then tab.action = "open_" .. tab.id end end - local gui = nil local active_tab = 1 local panel_hovered = false -local pending_selection_restore = nil +local pending_scroll_restore = nil local last_panel_width = 260 -local layout = nil -local pointer_operation = nil local tab_error_signatures = {} +local emergency_open = false local last_resume_serial = 0 local suppress_inventory_until_closed = false local warmup_tab_cursor = 1 local warmup_done = {} -local background_warmup_first_frame = nil -local background_warmup_unlocked = false -local BACKGROUND_WARMUP_DELAY_FRAMES = 180 -local BACKGROUND_WARMUP_INTERVAL_FRAMES = 4 -local menu_opened = false -local visible_last_frame = false -local visibility_state = visibility_policy.new() -local remembered_open = true -local remembered_loaded = false -local resize_hover_edges = nil -local frame_insets = nil -local layout_reset_pending = false -local restored_last_tab = false -local LAST_TAB_SETTING = "metamorph_creative_menu.ui_last_tab" -local LAYOUT_X_SETTING = "metamorph_creative_menu.ui_panel_x" -local LAYOUT_Y_SETTING = "metamorph_creative_menu.ui_panel_y" -local LAYOUT_WIDTH_SETTING = "metamorph_creative_menu.ui_panel_width" -local LAYOUT_HEIGHT_SETTING = "metamorph_creative_menu.ui_panel_height" -local INVENTORY_POLICY_SETTING = "metamorph_creative_menu.inventory_open_policy" -local INVENTORY_REMEMBERED_SETTING = "metamorph_creative_menu.ui_inventory_remembered_open" -local HEADER_CONTROL_RESERVE = 34 -local BORDER_VISUAL_OUTSET = 2 -local BORDER_VISUAL_THICKNESS = 5 -local DEFAULT_FRAME_INSET = 4 - - -local function layout_outsets() - local insets = frame_insets or {left=DEFAULT_FRAME_INSET,right=DEFAULT_FRAME_INSET,top=DEFAULT_FRAME_INSET,bottom=DEFAULT_FRAME_INSET} - return { - left=(tonumber(insets.left) or DEFAULT_FRAME_INSET) + BORDER_VISUAL_OUTSET, - right=(tonumber(insets.right) or DEFAULT_FRAME_INSET) + BORDER_VISUAL_OUTSET, - top=(tonumber(insets.top) or DEFAULT_FRAME_INSET) + BORDER_VISUAL_OUTSET, - bottom=(tonumber(insets.bottom) or DEFAULT_FRAME_INSET) + BORDER_VISUAL_OUTSET, - } -end - -local function tab_index_by_id(id) - for index, tab in ipairs(tabs) do if tab.id == id then return index end end - return nil -end - -local function save_active_tab() - local tab = tabs[active_tab] - if tab ~= nil and type(ModSettingSet) == "function" then - pcall(ModSettingSet, LAST_TAB_SETTING, tab.id) - end -end - -local function set_active_tab(index, source) - index = tonumber(index) - if index == nil or tabs[index] == nil then return false end - if index ~= active_tab and type(ui.blur_text_input) == "function" then - ui.blur_text_input("tab_changed") - end - active_tab = index - save_active_tab() - audit("menu.tab", "id=" .. tostring(tabs[index].id) .. " source=" .. tostring(source or "ui")) - return true -end - -local function open_tab(id, source) - local index = tab_index_by_id(id) - return index ~= nil and set_active_tab(index, source) or false -end - -local function restore_last_tab() - if restored_last_tab then return end - restored_last_tab = true - if type(ModSettingGet) ~= "function" then return end - local ok, id = pcall(ModSettingGet, LAST_TAB_SETTING) - if ok and type(id) == "string" then - local index = tab_index_by_id(id) - if index ~= nil then active_tab = index end - end -end - -local function step_tab(direction) - local count = #tabs - if count == 0 then return end - set_active_tab(((active_tab - 1 + direction) % count) + 1, "shortcut") -end - -local function read_setting(id) - if type(ModSettingGet) ~= "function" then return nil end - local ok, value = pcall(ModSettingGet, id) - if not ok then return nil end - return value -end - -local function current_inventory_policy() - return visibility_policy.normalize_mode(read_setting(INVENTORY_POLICY_SETTING)) -end - - -local function load_remembered_open() - if remembered_loaded then return remembered_open end - remembered_loaded = true - local value = read_setting(INVENTORY_REMEMBERED_SETTING) - if type(value) == "boolean" then remembered_open = value else remembered_open = true end - return remembered_open -end - -local function save_remembered_open(value) - remembered_open = value == true - remembered_loaded = true - if type(ModSettingSet) == "function" then - pcall(ModSettingSet, INVENTORY_REMEMBERED_SETTING, remembered_open) - end -end -local function set_user_visible(visible) - local preference = visibility_policy.set_user_visible(visibility_state, visible == true) - if preference ~= nil then save_remembered_open(preference) end -end -local function close_menu() - set_user_visible(false) - menu_opened = false - if type(ui.reset_text_inputs) == "function" then ui.reset_text_inputs() end - drag_drop.cancel() +local function update_emergency_open(player) + if not menu_inventory_guard.controls_disabled(player) then emergency_open = false; return end + if not input_guard.actions_allowed() then return end + local key = keycodes.resolve("Key_TAB", "KEY_TAB"); if key == nil then return end + local ok, pressed = pcall(InputIsKeyJustDown, key) + if ok and pressed == true then emergency_open = not emergency_open end end -local function process_shortcuts(native_open) - action_bindings.update() - local visible_before = visibility_policy.visible(visibility_state) - if action_bindings.consume("menu_toggle") then - if visible_before then close_menu() else set_user_visible(true) end - elseif action_bindings.consume("menu_close") and visible_before then - close_menu() - end - - for _, tab in ipairs(tabs) do - if action_bindings.consume(tab.action) then - open_tab(tab.id, "shortcut") - set_user_visible(true) - end - end - - if visibility_policy.visible(visibility_state) then - if action_bindings.consume("tab_previous") then step_tab(-1) end - if action_bindings.consume("tab_next") then step_tab(1) end - end -end local function background_warmup() - -- A fresh world should do zero hidden catalogue work. The menus already resolve - -- their visible content on demand, so background prewarming is unlocked only after - -- the player has actually opened MCM once in this session. This keeps EW/world - -- streaming and large perk trees uncontended during initial spawn. - if background_warmup_unlocked ~= true then return end - -- Do not spend the first few seconds after joining a world prewarming hidden UI. - -- EW, world streaming and perk reconstruction are busiest there; doing catalogue XML - -- work every rendered frame produces a visible short FPS sag on large saves. Once the - -- initial join burst has settled, amortize the same work at one step every few frames. - local frame = 0 - if type(GameGetFrameNum) == "function" then - local ok, value = pcall(GameGetFrameNum) - if ok then frame = tonumber(value) or 0 end - end - if background_warmup_first_frame == nil then background_warmup_first_frame = frame end - if frame - background_warmup_first_frame < BACKGROUND_WARMUP_DELAY_FRAMES then return end - if BACKGROUND_WARMUP_INTERVAL_FRAMES > 1 and frame % BACKGROUND_WARMUP_INTERVAL_FRAMES ~= 0 then return end - - -- Warm catalog-heavy tabs by stable ids rather than positional indexes so adding a - -- new UI section cannot silently redirect background work to the wrong feature. - local order = { tab_index_by_id("mobs"), tab_index_by_id("perks") } - local all_done = true - for _, index in ipairs(order) do if index ~= nil and not warmup_done[index] then all_done = false; break end end - if all_done then return end + -- Warm the two catalog-heavy tabs incrementally. Singleplayer can spend a slightly + -- larger budget because no networking work shares the frame. + local order = {4, 3} + if warmup_done[4] and warmup_done[3] then return end for _ = 1, #order do local index = order[warmup_tab_cursor] warmup_tab_cursor = warmup_tab_cursor % #order + 1 - if index ~= nil and not warmup_done[index] then + if not warmup_done[index] then local warmup_tab_module = tabs[index] and tabs[index].module or nil if type(warmup_tab_module) == "table" and type(warmup_tab_module.warmup_step) == "function" then -- Keep background work conservative on every machine. Feature modules may @@ -228,219 +63,30 @@ local function background_warmup() end end -local function read_number_setting(id) - if type(ModSettingGet) ~= "function" then return nil end - local ok, value = pcall(ModSettingGet, id) - return ok and tonumber(value) or nil -end - -local function save_layout() - if layout == nil or type(ModSettingSet) ~= "function" then return end - pcall(ModSettingSet, LAYOUT_X_SETTING, math.floor(layout.x + 0.5)) - pcall(ModSettingSet, LAYOUT_Y_SETTING, math.floor(layout.y + 0.5)) - pcall(ModSettingSet, LAYOUT_WIDTH_SETTING, math.floor(layout.width + 0.5)) - pcall(ModSettingSet, LAYOUT_HEIGHT_SETTING, math.floor(layout.height + 0.5)) -end - -local function ensure_layout(screen_width, screen_height) - if layout == nil then - layout = panel_layout.create(screen_width, screen_height, { - x=read_number_setting(LAYOUT_X_SETTING), - y=read_number_setting(LAYOUT_Y_SETTING), - width=read_number_setting(LAYOUT_WIDTH_SETTING), - height=read_number_setting(LAYOUT_HEIGHT_SETTING), - }, layout_outsets()) - else - panel_layout.clamp(layout, screen_width, screen_height, nil, layout_outsets()) - end - last_panel_width = layout.width -end - -local function mouse_gui_position(screen_width, screen_height) - return pointer.gui_position(screen_width, screen_height) -end - -local function left_mouse(action) - -- Do not express this as `just_down and ... or down`: in Lua that falls through - -- to the held state whenever just_down is false, which makes an already-held - -- pointer look like a fresh click as soon as it crosses the frame/title bar. - if action == "just_down" then return pointer.left_just_down() end - return pointer.left_down() -end - -local function begin_pointer_operation(kind, screen_width, screen_height, edges) - if pointer_operation ~= nil or not left_mouse("just_down") then return end - local mouse_x, mouse_y = mouse_gui_position(screen_width, screen_height) - if mouse_x == nil then return end - pointer_operation = { - kind=kind, - mouse_x=mouse_x, - mouse_y=mouse_y, - x=layout.x, - y=layout.y, - width=layout.width, - height=layout.height, - edges=edges, - } - audit("menu.layout_begin", "kind=" .. tostring(kind)) -end - -local function update_pointer_operation(screen_width, screen_height) - if pointer_operation == nil then return end - if not left_mouse("down") then - audit("menu.layout_end", "kind=" .. tostring(pointer_operation.kind)) - pointer_operation = nil - save_layout() - return - end - local mouse_x, mouse_y = mouse_gui_position(screen_width, screen_height) - if mouse_x == nil then return end - local delta_x, delta_y = mouse_x - pointer_operation.mouse_x, mouse_y - pointer_operation.mouse_y - if pointer_operation.kind == "move" then - panel_layout.move(layout, pointer_operation.x + delta_x, pointer_operation.y + delta_y, - screen_width, screen_height, nil, layout_outsets()) - else - panel_layout.resize_edges(layout, pointer_operation, pointer_operation.edges, - delta_x, delta_y, screen_width, screen_height, layout_outsets()) - end - last_panel_width = layout.width -end - -local function border_edges_at_mouse(screen_width, screen_height) - local mouse_x, mouse_y = mouse_gui_position(screen_width, screen_height) - if mouse_x == nil or mouse_y == nil then return nil end - local insets = frame_insets or {left=0,right=0,top=0,bottom=0} - local left = layout.x - (tonumber(insets.left) or 0) - local top = layout.y - (tonumber(insets.top) or 0) - local right = layout.x + layout.width + (tonumber(insets.right) or 0) - local bottom = layout.y + layout.height + (tonumber(insets.bottom) or 0) - - -- The hit band is exactly the visible frame: part of it overlays the stock menu - -- edge and part sits just outside it. Nothing is armed merely by crossing this - -- band while the mouse button is already held; begin_border_operation only asks - -- for these edges on the frame where a new press begins. This makes the rule - -- universal for spells, items and any future draggable payloads. - local outer = BORDER_VISUAL_OUTSET - local inner = math.max(1, BORDER_VISUAL_THICKNESS - BORDER_VISUAL_OUTSET) - if mouse_x < left - outer or mouse_x > right + outer - or mouse_y < top - outer or mouse_y > bottom + outer - then - return nil - end - local edges = { - left=mouse_x <= left + inner, - right=mouse_x >= right - inner, - top=mouse_y <= top + inner, - bottom=mouse_y >= bottom - inner, - } - return (edges.left or edges.right or edges.top or edges.bottom) and edges or nil -end - -local function begin_border_operation(screen_width, screen_height) - local hovered_edges = border_edges_at_mouse(screen_width, screen_height) - -- Hover advertises that the frame can resize, but a held press that originated - -- elsewhere never arms or highlights the frame while crossing it. - resize_hover_edges = pointer_operation == nil and not left_mouse("down") and hovered_edges or nil - if pointer_operation ~= nil or not left_mouse("just_down") then return end - local pressed_edges = hovered_edges - if pressed_edges == nil then return end - begin_pointer_operation("resize", screen_width, screen_height, pressed_edges) -end - -local function begin_titlebar_operation(screen_width, screen_height) - if pointer_operation ~= nil or not left_mouse("just_down") then return end - local mouse_x, mouse_y = mouse_gui_position(screen_width, screen_height) - if mouse_x == nil or mouse_y == nil then return end - local insets = frame_insets or {left=0,top=0} - local left = layout.x - (tonumber(insets.left) or 0) + 5 - local right = layout.x + layout.width - HEADER_CONTROL_RESERVE - local top = layout.y - (tonumber(insets.top) or 0) + 5 - local bottom = layout.y + 24 - if mouse_x >= left and mouse_x <= right and mouse_y >= top and mouse_y <= bottom then - begin_pointer_operation("move", screen_width, screen_height) - end -end - -local function reset_layout(screen_width, screen_height) - -- The reset button is drawn after GuiLayoutBeginVertical/GuiBeginAutoBox have already - -- captured this frame's old origin. Mutating layout immediately makes finish_auto_box() - -- compare the old measured frame against the new default coordinates, producing bogus - -- outsets and a one-click jump toward the middle. Defer the actual reset until the - -- current AutoBox is closed; the next frame then starts at the canonical default in one - -- click instead of requiring a corrective second click. - layout_reset_pending = true - pointer_operation = nil - audit("menu.layout_reset_requested", "screen=" .. tostring(screen_width) .. "x" .. tostring(screen_height)) +local function panel_width(screen_width) + screen_width = tonumber(screen_width) or 320 + -- Responsive in Noita GUI coordinates: keep the world readable while allowing + -- wider grids on high-resolution displays. All tabs consume this same metric. + return math.max(240, math.min(360, math.floor(screen_width - 112))) end local function draw_tab_bar(width) -- The header is a responsive grid rather than a permanently growing row. New -- tabs can be added without changing panel geometry or depending on label length. - -- GuiImageButton uses an 18 px slot background here; ICON_STEP (20) is a catalog-grid - -- pitch with extra breathing room. Using it for the navigation bar made the tenth tab - -- wrap at the minimum panel width even though the actual button still fit. Reserve only - -- the tiny AutoBox edge allowance and pack by the real tile footprint. - local tab_pitch = 18 - local per_row = math.max(1, math.floor(((tonumber(width) or 260) - 2) / tab_pitch)) + local per_row = math.max(1, math.floor(((tonumber(width) or 260) - 8) / ui.ICON_STEP)) for start = 1, #tabs, per_row do GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true) for index = start, math.min(start + per_row - 1, #tabs) do local tab = tabs[index] local title = ui.tr(tab.key, tab.fallback) local clicked = ui.tile(0, 0, ui.EMPTY_SLOT, tab.icon, tab.fallback_icon or ui.EMPTY_SLOT, - title, nil, active_tab == index, { target_size=18, - max_scale=tab.icon_max_scale or 2.5, fill=tab.icon_fill or 1.0, padding=1 }) - if clicked then set_active_tab(index, "ui") end + title, "", active_tab == index, { target_size=18, max_scale=2.5, padding=1 }) + if clicked then active_tab = index end end GuiLayoutEnd(ui.gui()) end end -local function draw_header(native_open, screen_width, screen_height) - local tab = tabs[active_tab] - local title = tab and ui.tr(tab.key, tab.fallback) or ui.tr("$mcm_menu_title", "CREATIVE MENU") - local title_width = type(ui.text_width) == "function" and ui.text_width(title) or #title * 5 - local space_width = type(ui.text_width) == "function" and ui.text_width(" ") or 3 - local remaining = math.max(1, last_panel_width - title_width - HEADER_CONTROL_RESERVE) - local drag_text = title .. string.rep(" ", math.max(1, math.floor(remaining / math.max(1, space_width)))) - GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true) - if type(ui.drag_handle) == "function" then - ui.drag_handle(0, 0, drag_text, title, - ui.tr("$mcm_menu_drag", "Drag menu"), ui.tr("$mcm_menu_drag_hint", "Hold and move the mouse")) - else - ui.button(0, 0, drag_text, true, - ui.tr("$mcm_menu_drag", "Drag menu"), ui.tr("$mcm_menu_drag_hint", "Hold and move the mouse")) - end - if ui.button(0, 0, "R", false, ui.tr("$mcm_menu_layout_reset", "Reset menu position and size"), "") then - reset_layout(screen_width, screen_height) - end - if ui.button(0, 0, "-", false, ui.tr("$mcm_menu_minimize", "Hide window"), "") then - close_menu() - end - if ui.button(0, 0, "X", false, ui.tr("$mcm_bind_menu_close", "Close creative menu"), "") then - close_menu() - end - GuiLayoutEnd(ui.gui()) -end - -local function pointer_inside_window(screen_width, screen_height) - if layout == nil then return false end - local mouse_x, mouse_y = mouse_gui_position(screen_width, screen_height) - local insets = frame_insets or {left=0,right=0,top=0,bottom=0} - local x = layout.x - (tonumber(insets.left) or 0) - BORDER_VISUAL_OUTSET - local y = layout.y - (tonumber(insets.top) or 0) - BORDER_VISUAL_OUTSET - local width = layout.width + (tonumber(insets.left) or 0) + (tonumber(insets.right) or 0) - + BORDER_VISUAL_OUTSET * 2 - local height = layout.height + (tonumber(insets.top) or 0) - + (tonumber(insets.bottom) or 0) + BORDER_VISUAL_OUTSET * 2 - return pointer.inside(x, y, width, height, mouse_x, mouse_y) -end - -local function pointer_click_inside_window(screen_width, screen_height) - local clicking = pointer.left_down() or (type(pointer.right_down) == "function" and pointer.right_down()) - return clicking and pointer_inside_window(screen_width, screen_height) -end - local function remember_scroll_selection(player_entity_id) if not panel_hovered or player_entity_id == 0 then return end local wheel_up_succeeded, wheel_up_pressed = pcall(InputIsMouseButtonJustDown, 4) @@ -448,11 +94,9 @@ local function remember_scroll_selection(player_entity_id) local wheel_changed = (wheel_up_succeeded and wheel_up_pressed == true) or (wheel_down_succeeded and wheel_down_pressed == true) if not wheel_changed then return end - pending_selection_restore = menu_inventory_guard.capture_scroll_selection(player_entity_id) + pending_scroll_restore = menu_inventory_guard.capture_scroll_selection(player_entity_id) end function menu_controller.draw() - menu_opened = false - gameplay_input.set_ui_blocked(false) if gui == nil then gui = GuiCreate() end GuiStartFrame(gui) GuiOptionsAdd(gui, GUI_OPTION.NoPositionTween) @@ -461,24 +105,9 @@ function menu_controller.draw() panel_hovered = false local player = player_locator.get() - restore_last_tab() + update_emergency_open(player) local native_inventory_open = menu_inventory_guard.inventory_open(player) - -- Resolve text ownership before a native inventory edge can close the panel and - -- reset its focused field. The guard also restores edges processed after our draw. - if type(menu_inventory_guard.hold_text_inventory) == "function" then - if visible_last_frame and type(ui.text_input_active) == "function" - and ui.text_input_active() == true and not input_guard.blocked() then - native_inventory_open = menu_inventory_guard.hold_text_inventory(player, native_inventory_open) - else - menu_inventory_guard.release_text_inventory() - end - end - visibility_policy.sync_inventory(visibility_state, native_inventory_open, - current_inventory_policy(), load_remembered_open()) - process_shortcuts(native_inventory_open) - if not native_inventory_open and not visibility_policy.visible(visibility_state) then background_warmup() end - local screen_width, screen_height = GuiGetScreenDimensions(gui) - local input_blocked = input_guard.blocked() == true + if not native_inventory_open and not emergency_open then background_warmup() end local serial = type(input_guard.resume_serial) == "function" and input_guard.resume_serial() or 0 if serial ~= last_resume_serial then last_resume_serial = serial @@ -486,73 +115,18 @@ function menu_controller.draw() -- hundreds of creative-menu tiles while EW is draining its resume backlog. if native_inventory_open then suppress_inventory_until_closed = true end end - if suppress_inventory_until_closed then - if native_inventory_open then - if visible_last_frame and type(ui.reset_text_inputs) == "function" then ui.reset_text_inputs() end - visible_last_frame = false - drag_drop.cancel() - menu_inventory_guard.release_manual_controls() - return - end + if native_inventory_open then return end suppress_inventory_until_closed = false end - local opened = visibility_policy.visible(visibility_state) - gameplay_input.set_ui_blocked(opened) - if not opened or input_blocked then - if visible_last_frame and type(ui.reset_text_inputs) == "function" then ui.reset_text_inputs() end - visible_last_frame = false - drag_drop.cancel() - menu_inventory_guard.release_manual_controls() - return - end - visible_last_frame = true - drag_drop.begin_frame(screen_width, screen_height) - ensure_layout(screen_width, screen_height) - local text_entry_active = type(ui.text_input_active) == "function" and ui.text_input_active() == true - -- Ownership includes the release frame. Re-enabling Controls before drag_drop.end_frame - -- allowed vanilla InventoryGui to finish the same drag behind MCM and overwrite the - -- exact slot chosen in this window. - local pointer_drag_active = pointer_operation ~= nil - or (type(drag_drop.pending) == "function" and drag_drop.pending() == true) - -- Hovering keeps gameplay live. Pointer gestures still take temporary full ownership - -- so vanilla cannot finish the same click/drag behind MCM. Text entry is different: - -- disabling ControlsComponent while the vanilla inventory is open can close that native - -- inventory on the first typed movement key. Keep the component enabled and clear only - -- its transient action fields instead. - if text_entry_active then - menu_inventory_guard.release_manual_controls() - if type(menu_inventory_guard.suppress_text_controls) == "function" then - menu_inventory_guard.suppress_text_controls(player) - end - elseif pointer_click_inside_window(screen_width, screen_height) or pointer_drag_active then - menu_inventory_guard.acquire_manual_controls(player) - else - menu_inventory_guard.release_manual_controls() - end - menu_opened = true - background_warmup_unlocked = true - -- Continue an already-owned window gesture before layout. A new press is not armed - -- until after tile sources have been drawn, so a spell press at the inner frame edge - -- gets first refusal and cannot be stolen by move/resize. - if pointer_operation ~= nil then update_pointer_operation(screen_width, screen_height) end - if type(ui.set_panel_bounds) == "function" then - ui.set_panel_bounds(layout.x, layout.y, layout.width, layout.height) - end - local fixed_frame = type(ui.fixed_panel_frame) == "function" - local frame_x, frame_y, measured_width, measured_height - if fixed_frame then - -- The frame is explicitly sized from layout. Active-tab contents are never allowed - -- to resize the user's window; each tab is responsible for scrolling/clipping. - frame_x, frame_y, measured_width, measured_height = - ui.fixed_panel_frame(layout.x, layout.y, layout.width, layout.height, DEFAULT_FRAME_INSET) - end - GuiLayoutBeginVertical(gui, layout.x, layout.y, true) - if not fixed_frame then - GuiBeginAutoBox(gui) - if type(ui.panel_width_anchor) == "function" then ui.panel_width_anchor(last_panel_width) end - end - draw_header(native_inventory_open, screen_width, screen_height) + local opened = native_inventory_open or emergency_open + if not opened or input_guard.blocked() then return end + + local screen_width, screen_height = GuiGetScreenDimensions(gui) + last_panel_width = panel_width(screen_width) + local x = math.max(4, screen_width - last_panel_width - 6) + GuiLayoutBeginVertical(gui, x, 46, true) + GuiBeginAutoBox(gui) draw_tab_bar(last_panel_width) if player == 0 then @@ -560,19 +134,12 @@ function menu_controller.draw() else local tab = tabs[active_tab] if tab ~= nil and type(tab.module) == "table" and type(tab.module.draw) == "function" then - local available_height = math.max(96, layout.height - 42) - local ok, err = pcall(tab.module.draw, player, last_panel_width, available_height, { - open_tab=function(id) return open_tab(id, "home") end, - close=function() close_menu() end, - }) + local ok, err = pcall(tab.module.draw, player, last_panel_width, screen_height) if not ok then ui.white_text(0, 2, ui.tr("$mcm_tab_runtime_error", "This section could not be drawn")) local signature = tostring(err) if tab_error_signatures[tab.id] ~= signature then tab_error_signatures[tab.id] = signature - if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "ui." .. tostring(tab.id), signature) - end print("[Metamorph: Creative Menu] UI tab '" .. tostring(tab.id) .. "' failed: " .. signature) end else @@ -581,112 +148,19 @@ function menu_controller.draw() end end - -- Sources are registered during tab draw. Only a press that no draggable tile claimed - -- may start a window move/resize operation. This intentionally adds at most one frame - -- before the panel begins moving, while preserving press-origin ownership for tiles. - local tile_press_owned = type(drag_drop.pending) == "function" and drag_drop.pending() == true - if pointer_operation == nil and not tile_press_owned then - begin_border_operation(screen_width, screen_height) - if pointer_operation == nil then begin_titlebar_operation(screen_width, screen_height) end - end - - if type(ui.end_frame) == "function" then ui.end_frame() end - -- Release the pointer's full Controls ownership on the SAME frame that its click - -- focuses an editor; waiting until the next draw leaves the first character exposed. - if type(ui.text_input_active) == "function" and ui.text_input_active() == true then - menu_inventory_guard.release_manual_controls() - if type(menu_inventory_guard.suppress_text_controls) == "function" then - menu_inventory_guard.suppress_text_controls(player) - end - if type(menu_inventory_guard.hold_text_inventory) == "function" then - menu_inventory_guard.hold_text_inventory(player, native_inventory_open) - end - elseif type(menu_inventory_guard.release_text_inventory) == "function" then - menu_inventory_guard.release_text_inventory() - end - drag_drop.end_frame() - - if not fixed_frame then - frame_x, frame_y, measured_width, measured_height = ui.finish_auto_box(5, layout.width, layout.height) - end - if tonumber(frame_x) ~= nil and tonumber(frame_y) ~= nil - and tonumber(measured_width) ~= nil and tonumber(measured_height) ~= nil - then - frame_x, frame_y = tonumber(frame_x), tonumber(frame_y) - measured_width, measured_height = tonumber(measured_width), tonumber(measured_height) - frame_insets = { - left=math.max(0, layout.x - frame_x), - top=math.max(0, layout.y - frame_y), - right=math.max(0, frame_x + measured_width - (layout.x + layout.width)), - bottom=math.max(0, frame_y + measured_height - (layout.y + layout.height)), - } - if type(ui.resize_affordances) == "function" then - local highlighted = pointer_operation ~= nil and pointer_operation.kind == "resize" - and pointer_operation.edges or resize_hover_edges - ui.resize_affordances(frame_x, frame_y, measured_width, measured_height, highlighted, - pointer_operation ~= nil and pointer_operation.kind == "resize") - end - end + ui.finish_auto_box(5) GuiLayoutEnd(gui) - if layout_reset_pending then - layout_reset_pending = false - frame_insets = { - left=DEFAULT_FRAME_INSET, right=DEFAULT_FRAME_INSET, - top=DEFAULT_FRAME_INSET, bottom=DEFAULT_FRAME_INSET, - } - layout = panel_layout.create(screen_width, screen_height, {}, layout_outsets()) - last_panel_width = layout.width - save_layout() - audit("menu.layout_reset", "width=" .. tostring(math.floor(layout.width + 0.5))) - end - local before_x, before_y, before_width, before_height = layout.x, layout.y, layout.width, layout.height - panel_layout.clamp(layout, screen_width, screen_height, nil, layout_outsets()) - last_panel_width = layout.width - if pointer_operation == nil and (layout.x ~= before_x or layout.y ~= before_y - or layout.width ~= before_width or layout.height ~= before_height) - then - save_layout() - end panel_hovered = ui.hovered() - -- Native InventoryGui can still process number keys on some builds even while the - -- player ControlsComponent is disabled. Snapshot the selected held item while typing - -- and restore it after the frame so "2"/"3" remain text instead of wand-slot input. - if type(ui.text_input_active) == "function" and ui.text_input_active() == true and player ~= 0 then - pending_selection_restore = menu_inventory_guard.capture_scroll_selection(player) - else - remember_scroll_selection(player) - end + remember_scroll_selection(player) end function menu_controller.post_update() - if menu_opened and type(ui.text_input_active) == "function" and ui.text_input_active() == true - and not input_guard.blocked() then - local player = player_locator.get() - if type(menu_inventory_guard.suppress_text_controls) == "function" then - menu_inventory_guard.suppress_text_controls(player) - end - if type(menu_inventory_guard.hold_text_inventory) == "function" then - menu_inventory_guard.hold_text_inventory(player, menu_inventory_guard.inventory_open(player)) - end - elseif type(menu_inventory_guard.release_text_inventory) == "function" then - menu_inventory_guard.release_text_inventory() - end - local selection_snapshot = pending_selection_restore - pending_selection_restore = nil + local selection_snapshot = pending_scroll_restore + pending_scroll_restore = nil if selection_snapshot ~= nil then menu_inventory_guard.restore_scroll_selection(selection_snapshot) end end function menu_controller.active_tab() return tabs[active_tab] and tabs[active_tab].id or "" end function menu_controller.is_hovered() return panel_hovered end -function menu_controller.is_open() return menu_opened end -function menu_controller.layout() - if layout == nil then return nil end - return { x=layout.x, y=layout.y, width=layout.width, height=layout.height, minimized=false } -end -function menu_controller.open_tab(id) - local opened = open_tab(id, "external") - if opened then set_user_visible(true) end - return opened -end METAMORPH_CREATIVE_MENU_MENU_CONTROLLER = menu_controller return menu_controller diff --git a/metamorph_creative_menu/files/ui/runtime.lua b/metamorph_creative_menu/files/ui/runtime.lua index 7e63479..5ed0e36 100644 --- a/metamorph_creative_menu/files/ui/runtime.lua +++ b/metamorph_creative_menu/files/ui/runtime.lua @@ -4,27 +4,14 @@ local ui_runtime = {} local asset_api = dofile("mods/metamorph_creative_menu/files/platform/noita/assets.lua") local input_guard = dofile("mods/metamorph_creative_menu/files/platform/noita/input_guard.lua") -local pointer = dofile("mods/metamorph_creative_menu/files/platform/noita/pointer.lua") -local text_entry_guard = dofile("mods/metamorph_creative_menu/files/platform/noita/text_entry_guard.lua") local keycodes = dofile("mods/metamorph_creative_menu/files/platform/noita/keycodes.lua") -local localization = dofile("mods/metamorph_creative_menu/files/platform/noita/localization.lua") -local search_engine = dofile("mods/metamorph_creative_menu/files/core/search_engine.lua") -local scroll_model = dofile("mods/metamorph_creative_menu/files/ui/widgets/scroll_model.lua") local gui = nil local next_widget_id = 1000 local hovered = false -local text_focus_seen = false +local search_focus = nil local search_keycodes = nil local search_repeat = { key = nil, started = -1, last = -1 } -local text_input_reset_generation = 0 -local text_input_seen_generation = {} -local text_capture_gui = nil -local panel_bounds = nil -local draw_colored_rect = nil -local confirmations = {} -local scroll_states = {} -local error_notices = {} ui_runtime.EMPTY_SLOT = "data/ui_gfx/inventory/inventory_box.png" ui_runtime.ICON_STEP = 20 @@ -38,32 +25,6 @@ end function ui_runtime.begin_frame() next_widget_id = 1000 hovered = false - panel_bounds = nil - text_focus_seen = false - if text_capture_gui ~= nil and type(GuiStartFrame) == "function" then GuiStartFrame(text_capture_gui) end - scroll_model.begin_frame() -end - -function ui_runtime.end_frame() - if text_entry_guard.active() and not text_focus_seen then - text_entry_guard.clear(nil, "unmounted") - end -end - -function ui_runtime.set_panel_bounds(x, y, width, height) - panel_bounds = { - x=tonumber(x) or 0, y=tonumber(y) or 0, - width=math.max(1, tonumber(width) or 1), - height=math.max(1, tonumber(height) or 1), - } -end - -function ui_runtime.panel_bounds() - if panel_bounds == nil then return nil end - return { - x=panel_bounds.x, y=panel_bounds.y, - width=panel_bounds.width, height=panel_bounds.height, - } end function ui_runtime.gui() return gui end @@ -71,7 +32,7 @@ function ui_runtime.next_id() next_widget_id = next_widget_id + 1; return next_w function ui_runtime.translated(text) if text == nil or text == "" then return "" end - return localization.translate(text, text) + return GameTextGetTranslatedOrNot(text) end function ui_runtime.tr(key, fallback) @@ -80,38 +41,6 @@ function ui_runtime.tr(key, fallback) return value end -function ui_runtime.search_aliases(key, fallback) - if type(localization.search_aliases) == "function" then return localization.search_aliases(key, fallback) end - return { ui_runtime.translated(key), fallback or key, key } -end - -function ui_runtime.audit(action, details) - if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_USER_ACTION) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_USER_ACTION, action, details) - end -end - --- User-visible operation failures are transient notifications, not permanent layout rows. --- Keep de-duplication here so individual tabs do not grow their own stale-error state. -function ui_runtime.report_error_once(key, title, reason, diagnostic_scope) - if reason == nil then return false end - key = tostring(key or diagnostic_scope or "ui") - reason = tostring(reason) - local signature = tostring(title or "") .. "\31" .. reason - if error_notices[key] == signature then return false end - error_notices[key] = signature - local message = tostring(title or "Error") - if reason ~= "" then message = message .. ": " .. reason end - if type(GamePrint) == "function" then pcall(GamePrint, message) end - if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, tostring(diagnostic_scope or key), reason) - end - return true -end - -function ui_runtime.clear_error_notice(key) - if key ~= nil then error_notices[tostring(key)] = nil end -end function ui_runtime.actions_allowed() return input_guard.actions_allowed() end function ui_runtime.mark_hovered(value) if value == true then hovered = true end end @@ -123,281 +52,20 @@ function ui_runtime.white_text(x, y, text) GuiText(gui, x, y, text or "") end -function ui_runtime.colored_text(x, y, text, color) - color = type(color) == "table" and color or {1,1,1,1} - GuiColorSetForNextWidget(gui, tonumber(color[1]) or 1, tonumber(color[2]) or 1, - tonumber(color[3]) or 1, tonumber(color[4]) or 1) - GuiZSetForNextWidget(gui, -102) - GuiText(gui, x, y, text or "") -end - -local function approximate_character_count(value) - local count = 0 - for byte in string.gmatch(tostring(value or ""), "[\1-\127\194-\244][\128-\191]*") do - if byte ~= "" then count = count + 1 end - end - return count -end - -function ui_runtime.text_width(text) - if type(GuiGetTextDimensions) == "function" then - local ok, width = pcall(GuiGetTextDimensions, gui, tostring(text or "")) - if ok and tonumber(width) ~= nil then return tonumber(width) end - end - return approximate_character_count(text) * 5 -end - -local function utf8_characters(value) - return string.gmatch(tostring(value or ""), "[\1-\127\194-\244][\128-\191]*") -end - -local function split_token(token, max_width) - local chunks, chunk = {}, "" - for character in utf8_characters(token) do - local candidate = chunk .. character - if chunk ~= "" and ui_runtime.text_width(candidate) > max_width then - chunks[#chunks + 1], chunk = chunk, character - else - chunk = candidate - end - end - if chunk ~= "" then chunks[#chunks + 1] = chunk end - return chunks -end - -local function wrap_lines(text, max_width) - text = tostring(text or "") - max_width = math.max(24, tonumber(max_width) or 220) - local lines = {} - for paragraph in string.gmatch(text .. "\n", "([^\n]*)\n") do - local current = "" - for word in string.gmatch(paragraph, "%S+") do - local chunks = ui_runtime.text_width(word) > max_width and split_token(word, max_width) or {word} - for _, chunk in ipairs(chunks) do - local candidate = current == "" and chunk or (current .. " " .. chunk) - if current ~= "" and ui_runtime.text_width(candidate) > max_width then - lines[#lines + 1] = current - current = chunk - else - current = candidate - end - end - end - if current ~= "" then lines[#lines + 1] = current - elseif paragraph == "" then lines[#lines + 1] = "" end - end - if #lines == 0 then lines[1] = "" end - return lines -end - -function ui_runtime.truncate_text(text, max_width) - text = tostring(text or "") - max_width = math.max(8, tonumber(max_width) or 80) - if ui_runtime.text_width(text) <= max_width then return text end - local suffix = "..." - local allowed = math.max(1, max_width - ui_runtime.text_width(suffix)) - local result = "" - for character in utf8_characters(text) do - if ui_runtime.text_width(result .. character) > allowed then break end - result = result .. character - end - return result .. suffix -end - -function ui_runtime.wrapped_text(x, y, text, max_width, color) - local lines = wrap_lines(text, max_width) - for index, line in ipairs(lines) do - if color ~= nil then ui_runtime.colored_text(x, index == 1 and y or 0, line, color) - else ui_runtime.white_text(x, index == 1 and y or 0, line) end - end - return #lines -end - --- AutoBox derives its size from children. This zero-height row makes the requested --- panel width real even on sparse tabs, so resizing is visible. -function ui_runtime.panel_width_anchor(width) - GuiLayoutBeginHorizontal(gui, 0, 0, true) - GuiLayoutAddHorizontalSpacing(gui, math.max(1, (tonumber(width) or 240) - 10)) - GuiLayoutEnd(gui) -end - --- Draw the menu frame at an explicit rectangle instead of asking AutoBox to size itself --- from the active tab. AutoBox has only minimum-size arguments, so a wide/tall child can --- enlarge the visible window for one tab and then shrink it on the next. A fixed nine-piece --- frame keeps the user's chosen layout authoritative while tabs manage their own scrolling. -function ui_runtime.fixed_panel_frame(x, y, width, height, inset) - inset = math.max(0, tonumber(inset) or 4) - x, y = tonumber(x) or 0, tonumber(y) or 0 - width, height = math.max(1, tonumber(width) or 1), math.max(1, tonumber(height) or 1) - GuiOptionsAddForNextWidget(gui, GUI_OPTION.Layout_NoLayouting) - GuiZSetForNextWidget(gui, ui_runtime.BACKGROUND_Z) - GuiImageNinePiece(gui, ui_runtime.next_id(), x - inset, y - inset, - width + inset * 2, height + inset * 2, 1) - local _, _, is_hovered, actual_x, actual_y, actual_w, actual_h = GuiGetPreviousWidgetInfo(gui) - ui_runtime.mark_hovered(is_hovered) - return tonumber(actual_x) or (x - inset), tonumber(actual_y) or (y - inset), - tonumber(actual_w) or (width + inset * 2), tonumber(actual_h) or (height + inset * 2) -end - --- Draw translated text buttons in width-aware rows. Fixed "four buttons per row" --- layouts overflow as soon as Russian/German labels or a narrow panel are used. -function ui_runtime.button_grid(items, max_width) - items = type(items) == "table" and items or {} - max_width = math.max(32, tonumber(max_width) or 220) - local rows, row, row_width = {}, {}, 0 - for index, item in ipairs(items) do - local label = tostring(item.label or "") - local display_label = ui_runtime.truncate_text(label, max_width - 8) - local width = math.min(max_width, math.max(10, ui_runtime.text_width(display_label) + 8)) - if #row > 0 and row_width + width + 2 > max_width then - rows[#rows + 1], row, row_width = row, {}, 0 - end - row[#row + 1] = {index=index,item=item,display_label=display_label} - row_width = row_width + width + (#row > 1 and 2 or 0) - end - if #row > 0 then rows[#rows + 1] = row end - - local clicked_index = nil - for _, current_row in ipairs(rows) do - GuiLayoutBeginHorizontal(gui, 0, 0, true) - for _, record in ipairs(current_row) do - local item = record.item - local tooltip_title = item.tooltip_title - if tooltip_title == nil and record.display_label ~= tostring(item.label or "") then - tooltip_title = item.label - end - if ui_runtime.button(0, 0, record.display_label, item.selected == true, - tooltip_title, item.tooltip_description) then - clicked_index = record.index - end - end - GuiLayoutEnd(gui) - end - return clicked_index -end - -local function set_text_focus(key, on_blur) - key = tostring(key or "text") - text_entry_guard.focus(key, on_blur) - text_focus_seen = true -end - -local function clear_text_focus(key, reason) - return text_entry_guard.clear(key, reason or "blur") -end - -local function focused_text_key() - return text_entry_guard.key() -end - local function clear_search_focus_on_action(clicked) - if clicked == true then clear_text_focus() end -end - -local function tooltip_text(value) - if value == nil then return nil end - value = tostring(value) - if string.match(value, "^%s*$") or value == "—" then return nil end - return value -end - -local function show_tooltip(title, description) - title, description = tooltip_text(title), tooltip_text(description) - if title == nil and description == nil then return end - -- Noita's GuiTooltip binding expects both text parameters on builds where a - -- one-argument call silently draws nothing. An empty description keeps the section - -- name while avoiding the old placeholder dash on a second line. - GuiTooltip(gui, title or "", description or "") + if clicked == true then search_focus = nil end end -function ui_runtime.button(x, y, text, selected, tooltip_title, tooltip_description) +function ui_runtime.button(x, y, text, selected) if selected then GuiColorSetForNextWidget(gui, 1.0, 0.78, 0.2, 1.0) else GuiColorSetForNextWidget(gui, 1, 1, 1, 1) end GuiZSetForNextWidget(gui, -103) local clicked = GuiButton(gui, ui_runtime.next_id(), x, y, text or "") - local _, _, is_hovered, actual_x, actual_y, actual_w, actual_h = GuiGetPreviousWidgetInfo(gui) + local _, _, is_hovered = GuiGetPreviousWidgetInfo(gui) ui_runtime.mark_hovered(is_hovered) - show_tooltip(tooltip_title, tooltip_description) local accepted = clicked == true and ui_runtime.actions_allowed() clear_search_focus_on_action(accepted) - return accepted, is_hovered == true, tonumber(actual_x), tonumber(actual_y), tonumber(actual_w), tonumber(actual_h) -end - -function ui_runtime.drag_handle(x, y, layout_text, title, tooltip_title, tooltip_description) - GuiColorSetForNextWidget(gui, 1, 1, 1, 0) - GuiZSetForNextWidget(gui, -103) - GuiButton(gui, ui_runtime.next_id(), x, y, layout_text or title or "") - local _, _, is_hovered, actual_x, actual_y, actual_w, actual_h = GuiGetPreviousWidgetInfo(gui) - actual_x, actual_y = tonumber(actual_x) or 0, tonumber(actual_y) or 0 - actual_w, actual_h = math.max(16, tonumber(actual_w) or 16), math.max(9, tonumber(actual_h) or 9) - ui_runtime.mark_hovered(is_hovered) - local base = {0.30,0.23,0.14,0.96} - local hover = {0.42,0.32,0.18,0.98} - local edge = {0.62,0.47,0.25,0.95} - local grip = {0.72,0.57,0.34,0.88} - draw_colored_rect(actual_x, actual_y, actual_w, actual_h, is_hovered and hover or base, -106) - draw_colored_rect(actual_x, actual_y, actual_w, 1, edge, -107) - draw_colored_rect(actual_x, actual_y + actual_h - 1, actual_w, 1, edge, -107) - local grip_x = actual_x + actual_w - 10 - draw_colored_rect(grip_x, actual_y + 3, 6, 1, grip, -108) - draw_colored_rect(grip_x, actual_y + 5, 6, 1, grip, -108) - GuiOptionsAddForNextWidget(gui, GUI_OPTION.Layout_NoLayouting) - GuiColorSetForNextWidget(gui, 0.90, 0.80, 0.58, 1) - GuiZSetForNextWidget(gui, -109) - GuiText(gui, actual_x + 4, actual_y + 1, title or "") - show_tooltip(tooltip_title, tooltip_description) - return is_hovered == true, actual_x, actual_y, actual_w, actual_h -end - -function ui_runtime.stepper(label, value, options) - options = type(options) == "table" and options or {} - local delta = 0 - local prefix = tostring(label or "") - local display = (prefix ~= "" and (prefix .. ": ") or "") .. tostring(value or "") - local max_width = tonumber(options.max_width) - local estimated = ui_runtime.text_width(display) + 26 - if max_width ~= nil and estimated > math.max(32, max_width) then - ui_runtime.wrapped_text(0, tonumber(options.y) or 0, display, math.max(24, max_width)) - local clicked = ui_runtime.button_grid({ - {label="-", selected=false}, {label="+", selected=false}, - }, math.max(24, max_width)) - if clicked == 1 and options.decrease_enabled ~= false then delta = -1 end - if clicked == 2 and options.increase_enabled ~= false then delta = 1 end - return delta - end - GuiLayoutBeginHorizontal(gui, 0, tonumber(options.y) or 0, true) - if ui_runtime.button(0, 0, "-") and options.decrease_enabled ~= false then delta = -1 end - ui_runtime.white_text(0, 1, display) - if ui_runtime.button(0, 0, "+") and options.increase_enabled ~= false then delta = 1 end - GuiLayoutEnd(gui) - return delta -end - -local function current_frame() - if type(GameGetFrameNum) ~= "function" then return 0 end - local ok, frame = pcall(GameGetFrameNum) - return ok and (tonumber(frame) or 0) or 0 -end - -function ui_runtime.confirm_button(key, label, confirmation_label, timeout_frames, max_width) - key = tostring(key or label or "confirm") - local frame = current_frame() - local armed = frame <= (tonumber(confirmations[key]) or -1) - local visible_label = armed and tostring(confirmation_label or ui_runtime.tr("$mcm_confirm", "CONFIRM")) - or tostring(label or ui_runtime.tr("$mcm_confirm", "CONFIRM")) - local clicked - if tonumber(max_width) ~= nil then - clicked = ui_runtime.button_grid({{label=visible_label,selected=armed,tooltip_title=visible_label}}, math.max(24,tonumber(max_width))) == 1 - else - clicked = ui_runtime.button(0, 0, visible_label, armed) - end - if not clicked then return false end - if armed then - confirmations[key] = nil - return true - end - confirmations[key] = frame + math.max(1, tonumber(timeout_frames) or 120) - return false + return accepted end local function load_search_keycodes() @@ -441,343 +109,199 @@ local function utf8_pop_word(value) return value end --- Gui widget IDs are frame-local inputs to Noita's retained native widget state. --- Editable fields therefore derive their ID from the semantic focus key instead of --- the dynamic draw order: the same field is the same GuiTextInput before, during and --- after logical focus changes. Keep this range well away from ordinary next_id() IDs. -local function stable_text_input_id(focus_key) - local hash = 0 - focus_key = tostring(focus_key or "text") - for index = 1, #focus_key do - hash = (hash * 131 + string.byte(focus_key, index)) % 1000000000 - end - return 1000000 + hash -end - --- GuiTextInput is officially intended for mod-settings UI and can lose native keyboard --- focus when another widget in the same Gui becomes mouse-focused. Keep MCM's logical focus --- independent from the pointer and use a tiny, separate Gui only as a character collector --- while the pointer is away from the visible field. Because this collector has its own Gui --- context it cannot steal focus from, scroll, or relayout the MCM page when hovering buttons. -local function stable_text_capture_id(focus_key) - local visible = stable_text_input_id(focus_key) - return 1100000000 + (visible % 900000000) -end - -local function filter_captured_text(value, allowed_characters) - value = tostring(value or "") - allowed_characters = tostring(allowed_characters or "") - if allowed_characters == "" then return value end - local result = {} - for character in string.gmatch(value, "[\1-\127\194-\244][\128-\191]*") do - if string.find(allowed_characters, character, 1, true) ~= nil then - result[#result + 1] = character - end - end - return table.concat(result) -end - -local function utf8_take(value, maximum) - value = tostring(value or "") - maximum = math.max(0, math.floor(tonumber(maximum) or 0)) - if maximum == 0 then return "" end - local result, count = {}, 0 - for character in string.gmatch(value, "[\1-\127\194-\244][\128-\191]*") do - if count >= maximum then break end - count = count + 1 - result[#result + 1] = character - end - return table.concat(result) -end - -local function ensure_text_capture_gui() - if text_capture_gui == nil and type(GuiCreate) == "function" then - local ok, created = pcall(GuiCreate) - if ok then text_capture_gui = created end - if text_capture_gui ~= nil and type(GuiStartFrame) == "function" then pcall(GuiStartFrame, text_capture_gui) end - end - return text_capture_gui -end - -local function capture_text_away_from_field(focus_key, max_length, allowed_characters) - local capture_gui = ensure_text_capture_gui() - if capture_gui == nil or type(GuiTextInput) ~= "function" or type(GuiGetScreenDimensions) ~= "function" then return "" end - local ok_dims, screen_width, screen_height = pcall(GuiGetScreenDimensions, capture_gui) - if not ok_dims then return "" end - local mouse_x, mouse_y = pointer.gui_position(screen_width, screen_height) - if tonumber(mouse_x) == nil or tonumber(mouse_y) == nil then return "" end - - if type(GuiOptionsAddForNextWidget) == "function" and type(GUI_OPTION) == "table" then - if GUI_OPTION.Layout_NoLayouting ~= nil then - pcall(GuiOptionsAddForNextWidget, capture_gui, GUI_OPTION.Layout_NoLayouting) - end - if GUI_OPTION.ForceFocusable ~= nil then - pcall(GuiOptionsAddForNextWidget, capture_gui, GUI_OPTION.ForceFocusable) - end - end - if type(GuiZSetForNextWidget) == "function" then pcall(GuiZSetForNextWidget, capture_gui, -100000) end - local ok, captured = pcall(GuiTextInput, capture_gui, stable_text_capture_id(focus_key), - mouse_x, mouse_y - 6, " ", 0, math.max(2, tonumber(max_length) or 64) + 1, "") - if not ok or type(captured) ~= "string" or captured == " " then return "" end - return filter_captured_text(string.sub(captured, 2), allowed_characters) -end - -local function draw_text_input_placeholder(placeholder, actual_x, actual_y) - placeholder = tostring(placeholder or "") - if placeholder == "" or tonumber(actual_x) == nil or tonumber(actual_y) == nil then return end - GuiOptionsAddForNextWidget(gui, GUI_OPTION.Layout_NoLayouting) - GuiColorSetForNextWidget(gui, 0.72, 0.72, 0.72, 0.72) - GuiZSetForNextWidget(gui, -104) - GuiText(gui, actual_x + 3, actual_y + 1, placeholder) -end - -function ui_runtime.reset_text_inputs() - clear_text_focus(nil, "reset") - search_repeat.key, search_repeat.started, search_repeat.last = nil, -1, -1 - text_input_reset_generation = text_input_reset_generation + 1 - confirmations = {} -end - -function ui_runtime.text_input(value, width, max_length, focus_key, options) +-- Centralized native text input. GuiTextInput is the only text-entry widget exposed by +-- Noita. Keeping it behind the same input quarantine as every other action means a +-- stale Alt-Tab event can never edit a query or trigger a neighbouring action. +function ui_runtime.search_input(value, width, max_length, focus_key) value = tostring(value or "") - focus_key = tostring(focus_key or "text") - options = type(options) == "table" and options or {} - local field_width = math.max(36, tonumber(width) or 112) - if text_input_seen_generation[focus_key] ~= text_input_reset_generation then - text_input_seen_generation[focus_key] = text_input_reset_generation - if options.clear_on_reset == true then value = "" end - end - - local active_before = focused_text_key() == focus_key - if active_before then - text_focus_seen = true - if type(text_entry_guard.set_blur_callback) == "function" then - text_entry_guard.set_blur_callback(focus_key, options.on_blur) - end - end + focus_key = tostring(focus_key or "search") + local field_width = math.max(68, tonumber(width) or 112) + GuiLayoutBeginHorizontal(gui, 0, 0, true) + -- A single slash is deliberately used instead of a wide SEARCH label: it reads as + -- a filter/search affordance in every language and keeps dense catalog tabs compact. + ui_runtime.white_text(0, 1, "/") GuiColorSetForNextWidget(gui, 1, 1, 1, 1) GuiZSetForNextWidget(gui, -103) - if type(GUI_OPTION) == "table" and GUI_OPTION.ForceFocusable ~= nil - and type(GuiOptionsAddForNextWidget) == "function" then - GuiOptionsAddForNextWidget(gui, GUI_OPTION.ForceFocusable) - end - local widget_id = stable_text_input_id(focus_key) - local ok, native_value = pcall(GuiTextInput, gui, widget_id, 0, 0, value, - field_width, math.max(1, tonumber(max_length) or 64), tostring(options.allowed_characters or "")) - local _, _, is_hovered, actual_x, actual_y = GuiGetPreviousWidgetInfo(gui) + local ok, new_value = pcall(GuiTextInput, gui, ui_runtime.next_id(), 0, 0, value, + field_width, math.max(8, tonumber(max_length) or 64), "") + local clicked, _, is_hovered = GuiGetPreviousWidgetInfo(gui) ui_runtime.mark_hovered(is_hovered) - - local click_on_field = pointer.left_just_down() and is_hovered == true - if click_on_field and ui_runtime.actions_allowed() then - set_text_focus(focus_key, options.on_blur) - search_repeat.key, search_repeat.started, search_repeat.last = nil, -1, -1 - elseif active_before and pointer.left_just_down() and is_hovered ~= true then - clear_text_focus(focus_key, "pointer") - end - - local active = focused_text_key() == focus_key - if active then - text_focus_seen = true - if type(text_entry_guard.set_blur_callback) == "function" then - text_entry_guard.set_blur_callback(focus_key, options.on_blur) + if clicked == true or (ok and type(new_value) == "string" and new_value ~= value) then + search_focus = focus_key + end + if not ok or not ui_runtime.actions_allowed() then new_value = value end + + -- GuiTextInput accepts held keyboard input differently across Noita builds. Keep + -- native editing authoritative, but if a held Backspace produced no native change, + -- provide deterministic repeat after a short delay. This never double-erases a + -- frame where GuiTextInput already changed the value. + if search_focus == focus_key and ui_runtime.actions_allowed() then + local keys = load_search_keycodes() + local frame = GameGetFrameNum() + if keys.escape ~= nil then + local ok_esc, esc = pcall(InputIsKeyJustDown, keys.escape) + if ok_esc and esc == true then new_value, search_focus = "", nil end end - end - - local new_value = value - if active and ui_runtime.actions_allowed() then - if is_hovered == true and ok and type(native_value) == "string" then - -- While hovering, let the real widget own caret/editing exactly as before. - new_value = native_value - elseif is_hovered ~= true then - local captured = capture_text_away_from_field(focus_key, max_length, options.allowed_characters) - if captured ~= "" then - new_value = utf8_take(value .. captured, math.max(1, tonumber(max_length) or 64)) - end - end - end - if value == "" and not active then - draw_text_input_placeholder(options.placeholder, actual_x, actual_y) - end - - local keys = load_search_keycodes() - local frame = GameGetFrameNum() - if keys.escape ~= nil and active then - local ok_esc, esc = pcall(InputIsKeyJustDown, keys.escape) - if ok_esc and esc == true then - if options.escape_clears == true then new_value = "" end - clear_text_focus(focus_key, "escape") - active = false - end - end - if keys.backspace ~= nil and active then - local ok_just, just_down = pcall(InputIsKeyJustDown, keys.backspace) - local ok_down, down = pcall(InputIsKeyDown, keys.backspace) - if ok_down and down == true then - local ctrl = false - for _, key in ipairs({keys.ctrl_l, keys.ctrl_r}) do - if key ~= nil then - local ok_ctrl, down_ctrl = pcall(InputIsKeyDown, key) - ctrl = ctrl or (ok_ctrl and down_ctrl == true) + if keys.backspace ~= nil then + local ok_down, down = pcall(InputIsKeyDown, keys.backspace) + if ok_down and down == true then + if search_repeat.key ~= focus_key then + search_repeat.key, search_repeat.started, search_repeat.last = focus_key, frame, frame + elseif type(new_value) == "string" and new_value == value + and frame - search_repeat.started >= 18 and frame - search_repeat.last >= 3 + then + local ctrl = false + for _, key in ipairs({keys.ctrl_l, keys.ctrl_r}) do + if key ~= nil then local ok_ctrl, down_ctrl = pcall(InputIsKeyDown, key); ctrl = ctrl or (ok_ctrl and down_ctrl == true) end + end + new_value = ctrl and utf8_pop_word(new_value) or utf8_pop(new_value) + search_repeat.last = frame end + elseif search_repeat.key == focus_key then + search_repeat.key, search_repeat.started, search_repeat.last = nil, -1, -1 end - -- The hidden off-hover capture cannot edit the visible buffer itself, so handle - -- the first backspace edge here. Hovered fields keep native caret semantics. - if is_hovered ~= true and ok_just and just_down == true then - new_value = ctrl and utf8_pop_word(new_value) or utf8_pop(new_value) - search_repeat.key, search_repeat.started, search_repeat.last = focus_key, frame, frame - elseif search_repeat.key ~= focus_key then - search_repeat.key, search_repeat.started, search_repeat.last = focus_key, frame, frame - elseif new_value == value and frame - search_repeat.started >= 18 and frame - search_repeat.last >= 3 then - new_value = ctrl and utf8_pop_word(new_value) or utf8_pop(new_value) - search_repeat.last = frame - end - elseif search_repeat.key == focus_key then - search_repeat.key, search_repeat.started, search_repeat.last = nil, -1, -1 end end - return new_value, focused_text_key() == focus_key -end - -function ui_runtime.text_input_active() - return text_entry_guard.active() -end - -function ui_runtime.text_input_focus_key() - return focused_text_key() -end -function ui_runtime.blur_text_input(reason) - return clear_text_focus(nil, reason or "context_changed") -end - -function ui_runtime.search_input(value, width, max_length, focus_key) - value = tostring(value or "") - focus_key = tostring(focus_key or "search") - local field_width = math.max(68, tonumber(width) or 112) - GuiLayoutBeginHorizontal(gui, 0, 0, true) - local new_value = ui_runtime.text_input(value, field_width, max_length, focus_key, { - clear_on_reset=true, escape_clears=true, - placeholder=ui_runtime.tr("$mcm_search_placeholder", "Search..."), - }) - if new_value ~= "" then - if ui_runtime.button(0, 0, "X") then - new_value = "" - clear_text_focus() - end - else - -- Reserve the clear button's ID before the first character too. Otherwise - -- every following button inherits a different native identity on first input. - ui_runtime.next_id() + if new_value ~= "" and ui_runtime.button(0, 0, "X") then + new_value, search_focus = "", nil end GuiLayoutEnd(gui) - return new_value -end - -local function expanded_search_fields(...) - local fields, seen = {}, {} - local function add(value) - if type(value) == "table" then - for _, nested in ipairs(value) do add(nested) end - return + return type(new_value) == "string" and new_value or value +end + +local function utf8_encode_codepoint(codepoint) + if codepoint < 0x80 then return string.char(codepoint) end + if codepoint < 0x800 then + return string.char(0xC0 + math.floor(codepoint / 0x40), 0x80 + (codepoint % 0x40)) + end + if codepoint < 0x10000 then + return string.char(0xE0 + math.floor(codepoint / 0x1000), + 0x80 + (math.floor(codepoint / 0x40) % 0x40), 0x80 + (codepoint % 0x40)) + end + return string.char(0xF0 + math.floor(codepoint / 0x40000), + 0x80 + (math.floor(codepoint / 0x1000) % 0x40), + 0x80 + (math.floor(codepoint / 0x40) % 0x40), 0x80 + (codepoint % 0x40)) +end + +local function lower_search(value) + value = string.lower(tostring(value or "")) + local out, index, length = {}, 1, #value + while index <= length do + local first = string.byte(value, index) + local codepoint, width + if first < 0x80 then + codepoint, width = first, 1 + elseif first < 0xE0 and index + 1 <= length then + codepoint = (first - 0xC0) * 0x40 + (string.byte(value, index + 1) - 0x80) + width = 2 + elseif first < 0xF0 and index + 2 <= length then + codepoint = (first - 0xE0) * 0x1000 + + (string.byte(value, index + 1) - 0x80) * 0x40 + + (string.byte(value, index + 2) - 0x80) + width = 3 + elseif index + 3 <= length then + codepoint = (first - 0xF0) * 0x40000 + + (string.byte(value, index + 1) - 0x80) * 0x1000 + + (string.byte(value, index + 2) - 0x80) * 0x40 + + (string.byte(value, index + 3) - 0x80) + width = 4 + else + out[#out + 1], index = string.sub(value, index, index), index + 1 + codepoint = nil end - value = tostring(value or "") - if value == "" or seen[value] then return end - seen[value], fields[#fields + 1] = true, value - if string.sub(value, 1, 1) == "$" then - for _, alias in ipairs(ui_runtime.search_aliases(value, value)) do - alias = tostring(alias or "") - if alias ~= "" and not seen[alias] then seen[alias], fields[#fields + 1] = true, alias end - end + if codepoint ~= nil then + -- Russian uppercase U+0410..U+042F maps to lowercase by +0x20. + -- U+0401/U+0451 (Yo/yo) is normalized to U+0435 (e) for search. + if codepoint >= 0x0410 and codepoint <= 0x042F then codepoint = codepoint + 0x20 end + if codepoint == 0x0401 or codepoint == 0x0451 then codepoint = 0x0435 end + out[#out + 1] = utf8_encode_codepoint(codepoint) + index = index + width end end - for index = 1, select("#", ...) do - add(select(index, ...)) - end - return fields + return table.concat(out) end -function ui_runtime.search_score(query, ...) - return search_engine.score(query, expanded_search_fields(...)) -end +local normalized_search_cache = {} +local last_query_text = nil +local last_query_positive = nil +local last_query_negative = nil -function ui_runtime.matches_search(query, ...) - return ui_runtime.search_score(query, ...) ~= nil +local function normalize_search(value) + local cache_key = tostring(value or "") + local cached = normalized_search_cache[cache_key] + if cached ~= nil then return cached end + value = lower_search(cache_key) + value = string.gsub(value, "[_%-%./\\]+", " ") + value = string.gsub(value, "%s+", " ") + value = string.gsub(value, "^%s+", "") + value = string.gsub(value, "%s+$", "") + normalized_search_cache[cache_key] = value + return value end -function ui_runtime.rank_entries(query, entries, fields_for, identity_for) - entries = type(entries) == "table" and entries or {} - query = tostring(query or "") - if query == "" then return entries end - fields_for = type(fields_for) == "function" and fields_for or function(entry) return entry end - identity_for = type(identity_for) == "function" and identity_for or function(entry) return tostring(entry) end - local ranked = {} - for index, entry in ipairs(entries) do - local score = ui_runtime.search_score(query, fields_for(entry)) - if score ~= nil then - ranked[#ranked + 1] = {entry=entry, score=score, index=index, identity=tostring(identity_for(entry) or "")} +local function query_terms(query) + local query_key = tostring(query or "") + if query_key == last_query_text then return last_query_positive, last_query_negative end + local positive, negative = {}, {} + query = lower_search(query_key) + -- Detect exclusion prefixes BEFORE separator normalization: normalizing the whole + -- query first would turn "-boss" into " boss" and silently lose the operator. + for raw in string.gmatch(query, "%S+") do + local excluded = (string.sub(raw, 1, 1) == "!" or string.sub(raw, 1, 1) == "-") and #raw > 1 + local body = excluded and string.sub(raw, 2) or raw + body = normalize_search(body) + for token in string.gmatch(body, "%S+") do + local target = excluded and negative or positive + target[#target + 1] = token end end - table.sort(ranked, function(a, b) - if a.score ~= b.score then return a.score > b.score end - if a.identity ~= b.identity then return a.identity < b.identity end - return a.index < b.index - end) - local result = {} - for _, record in ipairs(ranked) do result[#result + 1] = record.entry end - return result + last_query_text = query_key + last_query_positive = positive + last_query_negative = negative + return positive, negative end -function ui_runtime.search_status(query, count) - if tostring(query or "") == "" then return end - count = math.max(0, math.floor(tonumber(count) or 0)) - if count == 0 then - ui_runtime.white_text(2, 1, ui_runtime.tr("$mcm_no_results", "No results")) - else - ui_runtime.white_text(0, 1, ui_runtime.tr("$mcm_results", "Results") .. ": " .. tostring(count)) +-- Search is token based rather than a single literal substring. "fire bolt" means +-- both words may occur anywhere across name/id/path/description; -boss or !boss excludes +-- a term. Separators in ids and file paths are normalized to spaces, so e.g. +-- "extra hp" matches EXTRA_HP and paths containing extra_hp. +function ui_runtime.matches_search(query, ...) + local positive, negative = query_terms(query) + if #positive == 0 and #negative == 0 then return true end + local haystacks = {} + for index = 1, select("#", ...) do + local value = normalize_search(select(index, ...)) + if value ~= "" then haystacks[#haystacks + 1] = value end end -end - -function ui_runtime.asset(path) - if type(path) == "table" and type(path.path) == "string" then return path end - if type(path) ~= "string" or path == "" then return nil end - return asset_api.resolve(path) + local joined = table.concat(haystacks, " ") + for _, token in ipairs(negative) do + if token ~= "" and string.find(joined, token, 1, true) ~= nil then return false end + end + for _, token in ipairs(positive) do + if token ~= "" and string.find(joined, token, 1, true) == nil then return false end + end + return true end function ui_runtime.resolve(path) - return asset_api.path(ui_runtime.asset(path)) -end - -function ui_runtime.entity_asset(path, role) - return asset_api.resolve_entity(path, role) + if type(path) ~= "string" or path == "" then return nil end + local asset = asset_api.resolve(path) + return asset_api.path(asset) end function ui_runtime.entity_icon(path, role) - return asset_api.path(ui_runtime.entity_asset(path, role)) + local asset = asset_api.resolve_entity(path, role) + return asset_api.path(asset) end -function ui_runtime.dimensions(path) - if type(asset_api.dimensions) == "function" then return asset_api.dimensions(path) end - return 1, 1 -end - -local function draw_asset_image(asset, id, x, y, alpha, scale_x, scale_y) - if type(asset) ~= "table" then asset = ui_runtime.asset(asset) end - if type(asset) ~= "table" or type(asset.path) ~= "string" or asset.path == "" then return false end - local animation_name = tostring(asset.animation_name or "") - if animation_name ~= "" then - GuiImage(gui, id, x, y, asset.path, alpha, scale_x, scale_y, 0, - tonumber(asset.playback_mode) or 1, animation_name) - else - GuiImage(gui, id, x, y, asset.path, alpha, scale_x, scale_y) - end - return true -end +function ui_runtime.dimensions(path) return asset_api.dimensions(path) end local SOLID_TEXTURE = "data/ui_gfx/health_slider_front.png" -draw_colored_rect = function(x, y, width, height, color, z) - if type(GuiImage) ~= "function" or type(color) ~= "table" or width <= 0 or height <= 0 then return end +local function draw_colored_rect(x, y, width, height, color, z) + if type(color) ~= "table" or width <= 0 or height <= 0 then return end local r = math.max(0, math.min(1, tonumber(color[1]) or 1)) local g = math.max(0, math.min(1, tonumber(color[2]) or 1)) local b = math.max(0, math.min(1, tonumber(color[3]) or 1)) @@ -790,68 +314,20 @@ draw_colored_rect = function(x, y, width, height, color, z) GuiImage(gui, ui_runtime.next_id(), x, y, SOLID_TEXTURE, 1, width / texture_w, height / texture_h) end -function ui_runtime.resize_affordances(x, y, width, height, hovered_edges, active_drag) - x, y = tonumber(x) or 0, tonumber(y) or 0 - width, height = math.max(16, tonumber(width) or 16), math.max(16, tonumber(height) or 16) - hovered_edges = type(hovered_edges) == "table" and hovered_edges or {} - local base = {0.43,0.32,0.19,0.88} - local hover = {0.60,0.45,0.25,0.95} - local active = {0.72,0.53,0.28,1.00} - local shadow = {0.17,0.12,0.07,0.62} - local accent = active_drag == true and active or hover - local top = hovered_edges.top and accent or base - local bottom = hovered_edges.bottom and accent or base - local left = hovered_edges.left and accent or base - local right = hovered_edges.right and accent or base - - -- The visible frame straddles the measured AutoBox edge; resize hitboxes use the - -- same outset so every visible edge remains reachable after viewport clamping. - local outset, thickness = 2, 5 - local outer_x, outer_y = x - outset, y - outset - local outer_w, outer_h = width + outset * 2, height + outset * 2 - draw_colored_rect(outer_x, outer_y, outer_w, thickness, top, -109) - draw_colored_rect(outer_x, outer_y + outer_h - thickness, outer_w, thickness, bottom, -109) - draw_colored_rect(outer_x, outer_y, thickness, outer_h, left, -109) - draw_colored_rect(outer_x + outer_w - thickness, outer_y, thickness, outer_h, right, -109) - draw_colored_rect(x + 3, y + 3, math.max(1, width - 6), 1, shadow, -108) - draw_colored_rect(x + 3, y + height - 4, math.max(1, width - 6), 1, shadow, -108) - draw_colored_rect(x + 3, y + 3, 1, math.max(1, height - 6), shadow, -108) - draw_colored_rect(x + width - 4, y + 3, 1, math.max(1, height - 6), shadow, -108) -end - local function draw_marker(actual_x, actual_y, actual_w, actual_h, marker) if type(marker) ~= "table" then return end draw_colored_rect(actual_x + 2, actual_y + actual_h - 3, math.max(1, actual_w - 4), 2, marker, -107) end -local function draw_material_swatch(actual_x, actual_y, actual_w, actual_h, color) +local function draw_bottle_fill(actual_x, actual_y, actual_w, actual_h, color) if type(color) ~= "table" then return end - draw_colored_rect(actual_x + 2, actual_y + 2, - math.max(1, actual_w - 4), math.max(1, actual_h - 4), color, -105) - -- A narrow highlight keeps very dark materials readable while still showing their - -- actual primary in-world colour instead of a generic material bag. - draw_colored_rect(actual_x + 3, actual_y + 3, - math.max(1, actual_w - 6), 2, - { math.min(1, (color[1] or 0) + 0.18), math.min(1, (color[2] or 0) + 0.18), - math.min(1, (color[3] or 0) + 0.18), color[4] or 0.96 }, -106) -end - -local function draw_icon_overlay(rendered_icon, overlay_path, z, alpha) - if type(rendered_icon) ~= "table" or type(overlay_path) ~= "string" or overlay_path == "" then return end - local overlay = ui_runtime.asset(overlay_path) - local overlay_w, overlay_h = ui_runtime.dimensions(overlay) - if type(overlay) ~= "table" or overlay_w == nil or overlay_h == nil then return end - local scale = tonumber(rendered_icon.scale) - if scale == nil or scale <= 0 then return end - local rendered_w, rendered_h = overlay_w * scale, overlay_h * scale - GuiOptionsAddForNextWidget(gui, GUI_OPTION.Layout_NoLayouting) - GuiColorSetForNextWidget(gui, 1, 1, 1, 1) - GuiZSetForNextWidget(gui, z) - draw_asset_image(overlay, ui_runtime.next_id(), - rendered_icon.x + (rendered_icon.width - rendered_w) * 0.5, - rendered_icon.y + (rendered_icon.height - rendered_h) * 0.5, - tonumber(alpha) or 1, scale, scale) + -- The vanilla inventory bottle is a grayscale silhouette. A small colour patch + -- drawn over its lower bulb reads as the liquid while leaving the bright glass + -- rim visible. Keeping the patch inside the silhouette avoids a square swatch + -- floating outside the bottle at unusual UI scales. + draw_colored_rect(actual_x + actual_w * 0.36, actual_y + actual_h * 0.58, + actual_w * 0.28, actual_h * 0.25, color, -106) end function ui_runtime.tile(x, y, background, icon, fallback_icon, title, description, selected, options) @@ -864,19 +340,16 @@ function ui_runtime.tile(x, y, background, icon, fallback_icon, title, descripti if not ui_runtime.actions_allowed() then clicked, right_clicked = false, false end local _, _, is_hovered, actual_x, actual_y, actual_w, actual_h = GuiGetPreviousWidgetInfo(gui) ui_runtime.mark_hovered(is_hovered) - show_tooltip(title, description) + GuiTooltip(gui, title or "", description or "") actual_x = tonumber(actual_x) or 0 actual_y = tonumber(actual_y) or 0 actual_w = tonumber(actual_w) or 18 actual_h = tonumber(actual_h) or 18 - draw_material_swatch(actual_x, actual_y, actual_w, actual_h, options.swatch_color) - local draw_icon = ui_runtime.asset(icon) or ui_runtime.asset(fallback_icon) + local draw_icon = ui_runtime.resolve(icon) or ui_runtime.resolve(fallback_icon) local icon_w, icon_h = ui_runtime.dimensions(draw_icon) - local rendered_icon = nil - if type(draw_icon) == "table" and type(draw_icon.path) == "string" and draw_icon.path ~= "" - and icon_w ~= nil and icon_h ~= nil then + if type(draw_icon) == "string" and draw_icon ~= "" and icon_w ~= nil and icon_h ~= nil then local padding = math.max(0, tonumber(options.padding) or 1) local target = tonumber(options.target_size) or math.min(actual_w, actual_h) local max_scale = tonumber(options.max_scale) or 2.0 @@ -890,175 +363,31 @@ function ui_runtime.tile(x, y, background, icon, fallback_icon, title, descripti if scale > 0 then local rendered_w, rendered_h = icon_w * scale, icon_h * scale GuiOptionsAddForNextWidget(gui, GUI_OPTION.Layout_NoLayouting) - local tint = options.icon_tint - if type(tint) == "table" then - GuiColorSetForNextWidget(gui, tonumber(tint[1]) or 1, tonumber(tint[2]) or 1, - tonumber(tint[3]) or 1, tonumber(tint[4]) or 1) - end GuiZSetForNextWidget(gui, -105) - local rendered_x = actual_x + (actual_w - rendered_w) * 0.5 - local rendered_y = actual_y + (actual_h - rendered_h) * 0.5 - draw_asset_image(draw_icon, ui_runtime.next_id(), rendered_x, rendered_y, 1, scale, scale) - rendered_icon = {x=rendered_x, y=rendered_y, width=rendered_w, height=rendered_h, scale=scale} + GuiImage(gui, ui_runtime.next_id(), actual_x + (actual_w - rendered_w) * 0.5, + actual_y + (actual_h - rendered_h) * 0.5, draw_icon, 1, scale, scale) end end - draw_icon_overlay(rendered_icon, options.icon_overlay, -106, 1) + draw_bottle_fill(actual_x, actual_y, actual_w, actual_h, options.bottle_fill_color) draw_marker(actual_x, actual_y, actual_w, actual_h, options.marker_color) clear_search_focus_on_action(clicked == true or right_clicked == true) - return clicked == true, right_clicked == true, is_hovered == true, actual_x, actual_y, actual_w, actual_h -end - -function ui_runtime.drag_ghost(background, icon, x, y, options) - options = type(options) == "table" and options or {} - x, y = tonumber(x), tonumber(y) - if x == nil or y == nil then return end - background = ui_runtime.resolve(background or ui_runtime.EMPTY_SLOT) or ui_runtime.EMPTY_SLOT - GuiOptionsAddForNextWidget(gui, GUI_OPTION.Layout_NoLayouting) - GuiColorSetForNextWidget(gui, 1, 1, 1, 0.78) - GuiZSetForNextWidget(gui, -120) - GuiImage(gui, ui_runtime.next_id(), x + 5, y + 5, background, 0.78, 1, 1) - local draw_icon = ui_runtime.asset(icon) - local icon_w, icon_h = ui_runtime.dimensions(draw_icon) - local rendered_icon = nil - if draw_icon ~= nil and icon_w ~= nil and icon_h ~= nil and icon_w > 0 and icon_h > 0 then - local scale = math.min(16 / icon_w, 16 / icon_h, 2) - GuiOptionsAddForNextWidget(gui, GUI_OPTION.Layout_NoLayouting) - local tint = options.icon_tint - if type(tint) == "table" then - GuiColorSetForNextWidget(gui, tonumber(tint[1]) or 1, tonumber(tint[2]) or 1, - tonumber(tint[3]) or 1, (tonumber(tint[4]) or 1) * 0.9) - else - GuiColorSetForNextWidget(gui, 1, 1, 1, 0.9) - end - GuiZSetForNextWidget(gui, -121) - local rendered_x = x + 6 + (16 - icon_w * scale) * 0.5 - local rendered_y = y + 6 + (16 - icon_h * scale) * 0.5 - draw_asset_image(draw_icon, ui_runtime.next_id(), rendered_x, rendered_y, 0.9, scale, scale) - rendered_icon = {x=rendered_x, y=rendered_y, width=icon_w * scale, height=icon_h * scale, scale=scale} - end - draw_icon_overlay(rendered_icon, options.icon_overlay, -122, 0.9) + return clicked == true, right_clicked == true, is_hovered == true end -function ui_runtime.finish_auto_box(padding, minimum_width, minimum_height) +function ui_runtime.finish_auto_box(padding) GuiZSetForNextWidget(gui, ui_runtime.BACKGROUND_Z) - GuiEndAutoBoxNinePiece(gui, padding or 4, - math.max(0, tonumber(minimum_width) or 0), math.max(0, tonumber(minimum_height) or 0)) - local _, _, is_hovered, actual_x, actual_y, actual_w, actual_h = GuiGetPreviousWidgetInfo(gui) - ui_runtime.mark_hovered(is_hovered) - return tonumber(actual_x), tonumber(actual_y), tonumber(actual_w), tonumber(actual_h) -end - -function ui_runtime.grid_metrics(container_width, step, options) - return scroll_model.grid_metrics(container_width, step or ui_runtime.ICON_STEP, options) -end - -function ui_runtime.columns(container_width, step, options) - return ui_runtime.grid_metrics(container_width or 260, step or ui_runtime.ICON_STEP, options).columns -end - -local function scroll_state_for(key) - key = tostring(key or "scroll") - local state = scroll_states[key] - if state == nil then - state = {offset=0, content_height=0} - scroll_states[key] = state - end - return state -end - --- Keep scroll contents inside Noita's native container coordinate space. Starting a --- GuiLayout layer *inside* a scroll container detaches its children from that space in --- the real engine (their origin becomes the top-left of the screen), even though this is --- easy to miss in a Lua-only layout mock. The shared wrapper still centralises geometry --- and padding, but lets the engine own the vertical offset and clipping. -function ui_runtime.begin_scroll_viewport(key, id, x, y, width, height, options) - options = type(options) == "table" and options or {} - width = math.max(16, tonumber(width) or 220) - height = math.max(16, tonumber(height) or 80) - local padding_left = math.max(0, tonumber(options.padding_left) or scroll_model.PADDING_X) - local padding_right = math.max(0, tonumber(options.padding_right) or scroll_model.PADDING_X) - local padding_top = math.max(0, tonumber(options.padding_top) or scroll_model.PADDING_Y) - local padding_bottom = math.max(0, tonumber(options.padding_bottom) or scroll_model.PADDING_Y) - local scrollbar_width = options.reserve_scrollbar == false and 0 - or math.max(0, tonumber(options.scrollbar_width) or scroll_model.SCROLLBAR_WIDTH) - local state = scroll_state_for(key) - local viewport_height = math.max(1, height - padding_top - padding_bottom) - -- Manual offsets belonged to the detached-layer implementation. Reset them so a - -- save/reload or hot-reload cannot shift correctly attached native contents twice. - state.offset = 0 - - GuiBeginScrollContainer(gui, id, x or 0, y or 0, width, height, false, 0, 0) - local _, _, is_hovered, actual_x, actual_y, actual_w, actual_h = GuiGetPreviousWidgetInfo(gui) - actual_x, actual_y = tonumber(actual_x) or tonumber(x) or 0, tonumber(actual_y) or tonumber(y) or 0 - actual_w, actual_h = math.max(1, tonumber(actual_w) or width), math.max(1, tonumber(actual_h) or height) + GuiEndAutoBoxNinePiece(gui, padding or 4) + local _, _, is_hovered = GuiGetPreviousWidgetInfo(gui) ui_runtime.mark_hovered(is_hovered) - - local layout_mode = options.layout == "free" and "free" or "vertical" - if layout_mode == "vertical" then - GuiLayoutBeginVertical(gui, padding_left, padding_top, true, 0, 0) - end - return { - key=tostring(key or "scroll"), state=state, hovered=is_hovered == true, - x=actual_x, y=actual_y, width=actual_w, height=actual_h, - padding_left=padding_left, padding_right=padding_right, - padding_top=padding_top, padding_bottom=padding_bottom, - scrollbar_width=scrollbar_width, - content_width=math.max(1, actual_w - padding_left - padding_right - scrollbar_width), - viewport_height=math.max(1, actual_h - padding_top - padding_bottom), - step=math.max(1, tonumber(options.step) or scroll_model.VERTICAL_STEP), - layout_mode=layout_mode, - } -end - -function ui_runtime.scroll_y(context, y) - if type(context) ~= "table" then return tonumber(y) or 0 end - return context.padding_top + (tonumber(y) or 0) -end - -function ui_runtime.end_scroll_viewport(context, explicit_content_height) - if type(context) ~= "table" or type(context.state) ~= "table" then return end - if context.layout_mode == "vertical" then - -- A transparent sentinel gives tests/diagnostics a stable logical content - -- height; it remains in the native scroll coordinate space. - GuiColorSetForNextWidget(gui, 1, 1, 1, 0) - GuiZSetForNextWidget(gui, -99) - GuiText(gui, 0, 0, " ") - local _, _, _, _, sentinel_y, _, sentinel_h = GuiGetPreviousWidgetInfo(gui) - sentinel_y, sentinel_h = tonumber(sentinel_y), tonumber(sentinel_h) - if sentinel_y ~= nil and sentinel_h ~= nil then - local logical_top = context.y + context.padding_top - context.state.content_height = math.max(0, sentinel_y + sentinel_h - logical_top) - end - GuiLayoutEnd(gui) - else - context.state.content_height = math.max(0, tonumber(explicit_content_height) or context.state.content_height or 0) - end - GuiEndScrollContainer(gui) - context.state.maximum = math.max(0, context.state.content_height - context.viewport_height) - context.state.drag = nil - return 0, context.state.maximum, context.state.content_height end -function ui_runtime.grid_height(available_height, reserved_height) - -- Catalogues consume the remaining resizable panel height instead of a fixed height. - return math.max(64, (tonumber(available_height) or 240) - (tonumber(reserved_height) or 110)) +function ui_runtime.columns(panel_width, step) + step = math.max(1, tonumber(step) or ui_runtime.ICON_STEP) + return math.max(1, math.floor(((tonumber(panel_width) or 260) - 8) / step)) end --- Catalogues measure the last real widget above them and consume the remaining panel --- height. The numeric fallback keeps isolated tabs/tests usable, while real menu layout --- no longer depends on guessed per-tab constants or the number of translated rows. -function ui_runtime.scroll_height(available_height, reserved_height, bottom_padding) - local fallback = ui_runtime.grid_height(available_height, reserved_height) - if panel_bounds == nil or type(GuiGetPreviousWidgetInfo) ~= "function" then return fallback end - local ok, _, _, _, _, actual_y, _, actual_h = pcall(GuiGetPreviousWidgetInfo, gui) - actual_y, actual_h = tonumber(actual_y), tonumber(actual_h) - if not ok or actual_y == nil or actual_h == nil or actual_h < 0 then return fallback end - local target_bottom = panel_bounds.y + panel_bounds.height - - math.max(2, tonumber(bottom_padding) or 7) - if actual_y < panel_bounds.y - 8 or actual_y > target_bottom + 8 then return fallback end - local remaining = math.floor(target_bottom - (actual_y + actual_h) - 2) - local maximum = math.max(64, math.floor(panel_bounds.height - 28)) - return math.max(64, math.min(maximum, remaining)) +function ui_runtime.grid_height(screen_height, preferred, reserved) + return math.max(64, math.min(preferred or 190, (tonumber(screen_height) or 240) - (reserved or 110))) end METAMORPH_CREATIVE_MENU_UI_RUNTIME = ui_runtime diff --git a/metamorph_creative_menu/files/ui/tabs/controls.lua b/metamorph_creative_menu/files/ui/tabs/controls.lua deleted file mode 100644 index a0f7102..0000000 --- a/metamorph_creative_menu/files/ui/tabs/controls.lua +++ /dev/null @@ -1,72 +0,0 @@ -local controls_tab = {} -local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") -local bindings = dofile("mods/metamorph_creative_menu/files/platform/noita/action_bindings.lua") - -local search = "" - -function controls_tab.draw(_, panel_width, screen_height) - bindings.update() - GuiLayoutBeginVertical(ui.gui(), 0, 2, true) - ui.wrapped_text(0, 0, ui.tr("$mcm_controls_hint", "Click a binding, then press a key or mouse button. Modifiers are supported."), panel_width - 12) - ui.wrapped_text(0, 0, ui.tr("$mcm_controls_clear_hint", "DELETE/BACKSPACE clears; ESC cancels capture."), panel_width - 12) - search = ui.search_input(search, math.max(68, panel_width - 28), 64, "controls") - - if ui.confirm_button("controls.reset_all", ui.tr("$mcm_controls_reset_all", "RESET ALL"), - ui.tr("$mcm_controls_reset_confirm", "CLICK AGAIN TO RESET ALL"), nil, math.max(32,panel_width-12)) - then - bindings.reset_all() - end - - local active_capture = bindings.capture_action() - if active_capture ~= nil then - local action = bindings.registry().get(active_capture) - ui.colored_text(0, 2, ui.tr("$mcm_controls_waiting", "PRESS A KEY...") .. " " - .. (action and ui.tr(action.key, action.fallback) or active_capture), {1.0,0.78,0.2,1.0}) - end - - local registry = bindings.registry() - local visible_actions = ui.rank_entries(search, registry.actions(), function(action) - return {action.key, ui.tr(action.key, action.fallback), action.id, bindings.label(action.id)} - end, function(action) return action.id end) - local actions_by_section = {} - for _, action in ipairs(visible_actions) do - actions_by_section[action.section] = actions_by_section[action.section] or {} - actions_by_section[action.section][#actions_by_section[action.section] + 1] = action - end - ui.search_status(search, #visible_actions) - - local height = ui.scroll_height(screen_height, 116) - local scroll = ui.begin_scroll_viewport("controls.main", 16100, 0, 0, panel_width - 4, height) - for _, section in ipairs(registry.sections()) do - local section_actions = actions_by_section[section.id] or {} - if #section_actions > 0 then - ui.colored_text(0, 4, ui.tr(section.key, section.fallback), {1.0,0.78,0.2,1.0}) - for _, action in ipairs(section_actions) do - ui.wrapped_text(0, 1, ui.tr(action.key, action.fallback), math.max(24, scroll.content_width - 4)) - local waiting = active_capture == action.id - local label = waiting and ui.tr("$mcm_controls_waiting_short", "PRESS...") or bindings.label(action.id) - local action_buttons = { - {label="[ " .. label .. " ]",selected=waiting,tooltip_title=ui.tr(action.key,action.fallback)}, - {label="X",tooltip_title=ui.tr("$mcm_controls_clear", "Clear binding")}, - } - local has_reset = bindings.get(action.id) ~= action.default - if has_reset then action_buttons[#action_buttons+1]={label="R",tooltip_title=ui.tr("$mcm_controls_reset", "Restore default")} end - local action_clicked=ui.button_grid(action_buttons,math.max(24,scroll.content_width-4)) - if action_clicked==1 then bindings.start_capture(action.id) - elseif action_clicked==2 then bindings.set(action.id,"NONE") - elseif action_clicked==3 and has_reset then bindings.reset(action.id) end - local conflicts = bindings.conflicts(action.id) - if #conflicts > 0 then - local names = {} - for _, conflict in ipairs(conflicts) do names[#names + 1] = ui.tr(conflict.key, conflict.fallback) end - ui.wrapped_text(4, 0, ui.tr("$mcm_controls_conflict", "CONFLICT") .. ": " - .. table.concat(names, ", "), math.max(24, scroll.content_width - 8), {1.0,0.35,0.25,1.0}) - end - end - end - end - ui.end_scroll_viewport(scroll) - GuiLayoutEnd(ui.gui()) -end - -return controls_tab diff --git a/metamorph_creative_menu/files/ui/tabs/creatures.lua b/metamorph_creative_menu/files/ui/tabs/creatures.lua index 88adbca..ba0998c 100644 --- a/metamorph_creative_menu/files/ui/tabs/creatures.lua +++ b/metamorph_creative_menu/files/ui/tabs/creatures.lua @@ -1,20 +1,13 @@ local creatures_tab = {} -local DEV_MODE = METAMORPH_CREATIVE_MENU_DEV_MODE == true - local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") -local audit = ui.audit local form_manager = dofile("mods/metamorph_creative_menu/files/features/forms/manager.lua") local entity_catalog = dofile("mods/metamorph_creative_menu/files/features/creatures/ui_catalog.lua") local player_avatar = dofile("mods/metamorph_creative_menu/files/features/companion/player_avatar.lua") -local bindings = dofile("mods/metamorph_creative_menu/files/platform/noita/action_bindings.lua") -local drag_drop = dofile("mods/metamorph_creative_menu/files/ui/drag_drop.lua") local creature_service, catalog, filters, filtered = nil, nil, nil, {} local selected_filter = 1 local icon_cache = {} local search = "" -local review_mode = "play" -local review_marks = {} local search_cache_key = nil local search_cache_value = nil local warmup_cursor = 1 @@ -27,7 +20,7 @@ local ICON_ALIASES = { scorpion_watchtower="scorpion", fireskull_weak="fireskull", slimeshooter_nontoxic="slimeshooter", fungus_tiny="fungus", wand_ghost_charmed="wand_ghost", wand_ghost_with_sampo="wand_ghost", - darkghost="wraith", apparition="playerghost", lukki_creepy="lukki_creepy_long", + darkghost="wraith", apparition="playerghost", } @@ -79,9 +72,12 @@ local function search_values(index, query) if query == "" then return values end local key = tostring(index) .. "\31" .. query if key == search_cache_key and search_cache_value ~= nil then return search_cache_value end - local result = ui.rank_entries(query, values, function(entry) - return {entry.name, entry.display_name, entry.id, entry.path, entry.display_description} - end, function(entry) return entry.id or entry.path end) + local result = {} + for _, entry in ipairs(values) do + if ui.matches_search(query, entry.display_name, entry.id, entry.path, entry.display_description) then + result[#result + 1] = entry + end + end search_cache_key, search_cache_value = key, result return result end @@ -131,52 +127,11 @@ local function presentation(entry) local text = tostring(entry.display_description or "") if text ~= "" then text = text .. "\n" end text = text .. tostring(entry.path or "") - if review_mode == "play" then - if entry.special == "player" then - text = text .. "\n" .. ui.tr("$mcm_player_clone_left", "LMB: create PLAYER clone") - .. "\n" .. ui.tr("$mcm_player_clone_right", "RMB: human form (no change if human)") - else - text = text .. "\n" .. ui.tr("$mcm_left_spawn", "LMB: spawn nearby") - .. "\n" .. ui.tr("$mcm_creature_drag_spawn", "DRAG: spawn at cursor") - .. "\n" .. ui.tr("$mcm_right_transform", "RMB: transform") - end - else - local mode_key = "$mcm_mobs_mode_log_retest" - local mode_fallback = "LOG RETEST" - if review_mode == "safe" then mode_key, mode_fallback = "$mcm_mobs_mode_log_safe", "LOG SAFE" end - if review_mode == "unsafe" then mode_key, mode_fallback = "$mcm_mobs_mode_log_unsafe", "LOG UNSAFE" end - text = text .. "\n" .. ui.tr("$mcm_mobs_review_click", "CLICK: log") .. " " .. ui.tr(mode_key, mode_fallback) - if creature_service ~= nil and type(creature_service.compatibility_status) == "function" then - local checked, status, reason = pcall(creature_service.compatibility_status, entry.path) - if checked then text = text .. "\nAUTO: " .. tostring(status or "unknown") .. " / " .. tostring(reason or "unknown") end - end - local marked = review_marks[tostring(entry.path)] - if marked ~= nil then - local marked_key, marked_fallback = "$mcm_mobs_mode_log_retest", "LOG RETEST" - if marked == "safe" then marked_key, marked_fallback = "$mcm_mobs_mode_log_safe", "LOG SAFE" end - if marked == "unsafe" then marked_key, marked_fallback = "$mcm_mobs_mode_log_unsafe", "LOG UNSAFE" end - text = text .. "\n" .. ui.tr("$mcm_mobs_marked", "MARKED") .. ": " .. ui.tr(marked_key, marked_fallback) - end - end + .. "\n" .. ui.tr("$mcm_left_spawn", "LMB: spawn nearby") + .. "\n" .. ui.tr("$mcm_right_transform", "RMB: transform") return { description = text } end -local function point_inside(bounds, x, y) - return type(bounds) == "table" and tonumber(x) ~= nil and tonumber(y) ~= nil - and tonumber(x) >= tonumber(bounds.x or 0) and tonumber(x) <= tonumber(bounds.x or 0) + tonumber(bounds.width or 0) - and tonumber(y) >= tonumber(bounds.y or 0) and tonumber(y) <= tonumber(bounds.y or 0) + tonumber(bounds.height or 0) -end - -local function drag_bounds(x, y, width, height) - x, y, width, height = tonumber(x), tonumber(y), tonumber(width), tonumber(height) - if x == nil or y == nil or width == nil or height == nil or width <= 0 or height <= 0 then return nil end - return {x=x, y=y, width=width, height=height} -end - -local function report_spawn_failure(name) - GamePrint(ui.tr("$mcm_creature_spawn_failed", "Could not spawn creature") .. ": " .. tostring(name or "")) -end - local function spawn(player, entry) if entry.special == "player" then local ok = player_avatar.request_spawn(player, 32, -4) @@ -186,50 +141,11 @@ local function spawn(player, entry) return (creature_service.spawn_near_player(player, entry.path, 32, -4) or 0) ~= 0 end -local function handle_completed_drag(player) - local result = drag_drop.take_result() - if result == nil or type(result.payload) ~= "table" then return end - local payload = result.payload - if payload.kind ~= "catalog_creature" then return end - - if result.click == true then - local entity, reason = creature_service.spawn_near_player(player, payload.path, 32, -4) - audit("creature.spawn", "path=" .. tostring(payload.path) .. " entity=" .. tostring(entity) - .. " reason=" .. tostring(reason)) - if entity == 0 then report_spawn_failure(payload.display_name) end - return - end - - if result.target ~= nil then return end - local release_x, release_y = tonumber(result.release_x), tonumber(result.release_y) - if release_x == nil or release_y == nil then - audit("creature.drag.cancel", "path=" .. tostring(payload.path) .. " reason=release_position") - return - end - local menu_bounds = type(ui.panel_bounds) == "function" and ui.panel_bounds() or nil - if point_inside(menu_bounds, release_x, release_y) then - audit("creature.drag.cancel", "path=" .. tostring(payload.path) .. " reason=in_menu") - return - end - - local world_x, world_y = tonumber(result.world_x), tonumber(result.world_y) - if world_x == nil or world_y == nil then - audit("creature.drag.world", "path=" .. tostring(payload.path) .. " entity=0 reason=position") - report_spawn_failure(payload.display_name) - return - end - local entity, reason = creature_service.spawn_at(payload.path, world_x, world_y) - audit("creature.drag.world", "path=" .. tostring(payload.path) .. " entity=" .. tostring(entity) - .. " reason=" .. tostring(reason) .. " x=" .. tostring(world_x) .. " y=" .. tostring(world_y)) - if entity == 0 then report_spawn_failure(payload.display_name) end -end - local function transform_creature(player, creature) if creature_service ~= nil and type(creature_service.compatibility_status) == "function" then local checked, status, reason = pcall(creature_service.compatibility_status, creature.path) if checked and status == "unsafe" then GamePrint(ui.tr("$mcm_creature_poly_failed", "Could not transform") .. ": " .. tostring(creature.display_name or creature.path)) - audit("creature.transform.blocked", "path="..tostring(creature.path).." reason="..tostring(reason or "known_unsafe")) return false, tostring(reason or "known_unsafe") end end @@ -247,27 +163,9 @@ local function transform_creature(player, creature) local ok, canonical = pcall(creature_service.canonical_transform_path, creature.path) if ok and type(canonical) == "string" and canonical ~= "" then target = canonical end end - -- Spawn identity and transform identity are intentionally separate for confirmed - -- crash-prone placement wrappers: LMB keeps the authored biome XML, while player - -- polymorph may use its same-species base body. requested_target preserves the - -- exact menu/possession identity for diagnostics and UI. if form_manager.exact_effect_path_for_target(target) == nil then pcall(form_manager.prepare_exact_effect_paths, {target}) end - -- Persist intent before entering native polymorph code. If Noita hard-crashes during - -- transformation, the diagnostics log still has the exact requested/canonical path - -- and the non-destructive compatibility preflight verdict from before the attempt. - local compatibility_status, compatibility_reason = "unknown", "unavailable" - if creature_service ~= nil and type(creature_service.compatibility_status) == "function" then - local checked, status, reason = pcall(creature_service.compatibility_status, creature.path) - if checked then - compatibility_status = tostring(status or "unknown") - compatibility_reason = tostring(reason or "unknown") - end - end - audit("creature.transform.begin", "path="..tostring(creature.path).." target="..tostring(target) - .." transform_mode="..transform_mode.." transform_reason="..transform_reason - .." compatibility="..compatibility_status.." compatibility_reason="..compatibility_reason) local ok, reason = form_manager.transform_creature(player, target, nil, true, { requested_target=creature.path, compatibility_mode=transform_mode, @@ -293,32 +191,19 @@ end function creatures_tab.draw(player, panel_width, screen_height) if not ensure_catalog() then ui.white_text(0, 2, ui.tr("$mcm_creatures_failed", "Creature list failed to load")); return end catalog_scan_complete = creatures_tab.warmup_step(1) == true - handle_completed_drag(player) GuiLayoutBeginVertical(ui.gui(), 0, 2, true) - local filter_buttons = {} - for index, f in ipairs(filters) do - filter_buttons[index] = {label=ui.tr(f[1], f[2]),selected=selected_filter == index} - end - local clicked_filter = ui.button_grid(filter_buttons, panel_width - 10) - if clicked_filter ~= nil then selected_filter = clicked_filter end - if DEV_MODE then + for start = 1, #filters, 4 do GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true) - if ui.button(0, 0, ui.tr("$mcm_mobs_mode_play", "PLAY"), review_mode == "play") then review_mode = "play" end - if ui.button(0, 0, ui.tr("$mcm_mobs_mode_log_safe", "LOG SAFE"), review_mode == "safe") then review_mode = "safe" end - if ui.button(0, 0, ui.tr("$mcm_mobs_mode_log_unsafe", "LOG UNSAFE"), review_mode == "unsafe") then review_mode = "unsafe" end - if ui.button(0, 0, ui.tr("$mcm_mobs_mode_log_retest", "LOG RETEST"), review_mode == "retest") then review_mode = "retest" end + for i = start, math.min(start + 3, #filters) do + local f = filters[i] + if ui.button(0, 0, ui.tr(f[1], f[2]), selected_filter == i) then selected_filter = i end + end GuiLayoutEnd(ui.gui()) - else - review_mode = "play" - end - if review_mode == "play" then - local help = ui.tr("$mcm_creature_controls_dynamic", "LMB: SPAWN DRAG: DROP RMB: TRANSFORM {BIND}: HUMAN") - ui.wrapped_text(0, 0, string.gsub(help, "{BIND}", "[" .. bindings.label("return_human") .. "]"), panel_width - 12) - else - ui.wrapped_text(0, 0, ui.tr("$mcm_mobs_review_help", "REVIEW: click a mob to log its exact path; no transform/spawn occurs"), panel_width - 12) end + ui.white_text(0, 0, ui.tr("$mcm_creature_controls", "LMB: SPAWN RMB: TRANSFORM TAB: HUMAN")) search = ui.search_input(search, math.max(88, panel_width - 54), 64, "creatures") + local columns = ui.columns(panel_width) local results = search_values(selected_filter, search) -- ALL is one continuous list; direct/alias icons are cached and deeper XML icon -- resolution is deferred until an entry is actually hovered. @@ -327,58 +212,23 @@ function creatures_tab.draw(player, panel_width, screen_height) catalog_status = " | " .. ui.tr("$mcm_mobs_scanning", "SCANNING...") end ui.white_text(0, 0, ui.tr("$mcm_mobs_showing", "SHOWING") .. " " .. tostring(#results) .. " / " .. tostring(#(catalog or {})) .. catalog_status) - local h = ui.scroll_height(screen_height, 145) - local scroll = ui.begin_scroll_viewport("creatures.catalog." .. tostring(selected_filter), - 12100 + selected_filter * 100, 0, 0, panel_width - 4, h, {layout="free"}) - local columns = ui.columns(scroll.content_width, ui.ICON_STEP, {reserve_scrollbar=false}) + local h = ui.grid_height(screen_height, 220, 145) + GuiBeginScrollContainer(ui.gui(), 12100 + selected_filter * 100, 0, 0, panel_width - 4, h, false, 1, 1) + local _, _, hov = GuiGetPreviousWidgetInfo(ui.gui()); ui.mark_hovered(hov) for index, entry in ipairs(results) do local p = presentation(entry) local i = index - 1 - local entry_icon = icon(entry, false) - local clicked, right, hovered, tile_x, tile_y, tile_w, tile_h = ui.tile( - scroll.padding_left + (i % columns) * ui.ICON_STEP, - ui.scroll_y(scroll, math.floor(i / columns) * ui.ICON_STEP), - ui.EMPTY_SLOT, entry_icon, CREATURE_ICON_FALLBACK, entry.display_name, p.description, false, + local clicked, right, hovered = ui.tile((i % columns) * ui.ICON_STEP, math.floor(i / columns) * ui.ICON_STEP, + ui.EMPTY_SLOT, icon(entry, false), CREATURE_ICON_FALLBACK, entry.display_name, p.description, false, {target_size=18, max_scale=18, padding=0}) - if hovered and icon_cache[tostring(entry.path)] == nil then entry_icon = icon(entry, true) end - if review_mode == "play" and entry.special ~= "player" then - local bounds = drag_bounds(tile_x, tile_y, tile_w, tile_h) - if bounds ~= nil then - drag_drop.source("creatures.catalog." .. tostring(entry.path), { - kind="catalog_creature", path=entry.path, display_name=entry.display_name, - background=ui.EMPTY_SLOT, icon=entry_icon, - }, bounds, {x=scroll.x, y=scroll.y, width=scroll.width, height=scroll.height}) - end - end - if review_mode ~= "play" and (clicked or right) then - review_marks[tostring(entry.path)] = review_mode - local compatibility_status, compatibility_reason = "unknown", "unavailable" - if creature_service ~= nil and type(creature_service.compatibility_status) == "function" then - local checked, status, reason = pcall(creature_service.compatibility_status, entry.path) - if checked then compatibility_status, compatibility_reason = tostring(status or "unknown"), tostring(reason or "unknown") end - end - audit("creature.review", "verdict="..tostring(review_mode).." path="..tostring(entry.path).." id="..tostring(entry.id or "") - .." name="..tostring(entry.display_name or "").." auto="..compatibility_status.." auto_reason="..compatibility_reason) - local label = ui.tr("$mcm_mobs_mode_log_retest", "LOG RETEST") - if review_mode == "safe" then label = ui.tr("$mcm_mobs_mode_log_safe", "LOG SAFE") end - if review_mode == "unsafe" then label = ui.tr("$mcm_mobs_mode_log_unsafe", "LOG UNSAFE") end - GamePrint(ui.tr("$mcm_mobs_review_logged", "MOBS REVIEW") .. " " .. label .. ": " .. tostring(entry.path)) - elseif clicked and entry.special == "player" then - local ok=spawn(player, entry) - audit("creature.spawn", "path="..tostring(entry.path).." result="..tostring(ok)) + if hovered and icon_cache[tostring(entry.path)] == nil then icon(entry, true) end + if clicked then + spawn(player, entry) elseif right then - local ok, reason=transform(player, entry) - audit("creature.transform", "path="..tostring(entry.path).." result="..tostring(ok).." reason="..tostring(reason)) - end - end - ui.end_scroll_viewport(scroll, math.ceil(#results / columns) * ui.ICON_STEP) - if drag_drop.active() then - local payload = drag_drop.payload() - local mouse_x, mouse_y = drag_drop.mouse_position() - if type(payload) == "table" and payload.kind == "catalog_creature" then - ui.drag_ghost(payload.background, payload.icon, mouse_x, mouse_y) + transform(player, entry) end end + GuiEndScrollContainer(ui.gui()) GuiLayoutEnd(ui.gui()) end diff --git a/metamorph_creative_menu/files/ui/tabs/effects.lua b/metamorph_creative_menu/files/ui/tabs/effects.lua index b77cefa..dbfcba7 100644 --- a/metamorph_creative_menu/files/ui/tabs/effects.lua +++ b/metamorph_creative_menu/files/ui/tabs/effects.lua @@ -1,18 +1,12 @@ local effects_tab = {} local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") -local audit = ui.audit local effect_service = dofile("mods/metamorph_creative_menu/files/features/effects/service.lua") local catalog = nil local filter = "all" +local duration_index = 3 local search = "" -local selected_key = nil -local selected_variant = {} --- Duration is remembered per logical effect family. Changing the editor for one effect must --- never rewrite the active time or next-click preset of another effect. -local duration_index_by_key = {} -local DEFAULT_DURATION_INDEX = 2 local DURATIONS = { { frames=600, key="$mcm_effect_duration_10s", fallback="10s" }, { frames=1800, key="$mcm_effect_duration_30s", fallback="30s" }, @@ -40,191 +34,69 @@ local function icon(entry) local resolved = ui.entity_icon(entry.path, "effect") if resolved ~= nil then return resolved end end - return ui.EMPTY_SLOT -end - -local function family_key(entry) - if type(effect_service.family_key) == "function" then return effect_service.family_key(entry) end - return tostring(entry and (entry.id or entry.path) or "") -end - -local function find_selected(entries) - if selected_key == nil then return nil end - for _, entry in ipairs(entries or catalog or {}) do - if family_key(entry) == selected_key then return entry end - end - return nil -end - -local function search_fields(entry) - local fields = {entry.name_key, entry.description_key, entry.display_name, entry.id, entry.path, entry.display_description} - for _, variant in ipairs(type(entry.variants) == "table" and entry.variants or {}) do - fields[#fields+1] = variant.name_key - fields[#fields+1] = variant.description_key - fields[#fields+1] = variant.display_name - fields[#fields+1] = variant.id - fields[#fields+1] = variant.path - end - return fields -end - -local function chosen_variant(player, entry, snapshot) - local key = family_key(entry) - if selected_variant[key] == nil then - selected_variant[key] = type(effect_service.active_variant_index) == "function" - and effect_service.active_variant_index(player, entry, snapshot) or 1 - end - local count = math.max(1, tonumber(effect_service.variant_count(entry)) or 1) - selected_variant[key] = math.max(1, math.min(count, tonumber(selected_variant[key]) or 1)) - return selected_variant[key] -end - -local function duration_index_for(entry) - local key = family_key(entry) - local index = tonumber(duration_index_by_key[key]) or DEFAULT_DURATION_INDEX - index = math.max(1, math.min(#DURATIONS, math.floor(index))) - duration_index_by_key[key] = index - return index -end - -local function click_duration(entry) - if type(effect_service.duration_policy) == "function" and effect_service.duration_policy(entry) ~= "editable" then - return nil - end - local index = duration_index_for(entry) - return DURATIONS[index] and DURATIONS[index].frames or nil -end - -local function apply_tile(player, entry, snapshot) - local index = chosen_variant(player, entry, snapshot) - local selected = effect_service.resolve_variant(entry, index) - local frames = click_duration(selected) - local ok, reason - if type(effect_service.set_variant) == "function" then - ok, reason = effect_service.set_variant(player, entry, index, frames) - else - ok, reason = effect_service.add(player, selected, frames) - end - audit("effect.add", "id="..tostring(selected.id or selected.path).." variant="..tostring(index).." result="..tostring(ok).." reason="..tostring(reason)) - return ok, reason -end - -local function draw_duration_bar(player, entry, panel_width, snapshot) - if type(entry) ~= "table" then return 0 end - local variant_index = chosen_variant(player, entry, snapshot) - local resolved = effect_service.resolve_variant(entry, variant_index) - if type(effect_service.duration_policy) == "function" and effect_service.duration_policy(resolved) ~= "editable" then - return 0 - end - local key = family_key(entry) - local current_index = duration_index_for(entry) - local buttons = {} - for i, duration in ipairs(DURATIONS) do - buttons[#buttons+1] = { - label=ui.tr(duration.key,duration.fallback), selected=i==current_index, - tooltip_title=ui.tr("$mcm_effect_duration","DURATION"), - } - end - local picked = ui.button_grid(buttons, math.max(48,panel_width-10)) - if picked ~= nil and DURATIONS[picked] ~= nil then - duration_index_by_key[key] = picked - -- This editor is contextual: changing BERSERK to 60s can update an already-active - -- BERSERK, but it must never touch ALCOHOLIC or any other selected family. - local active = type(effect_service.is_active)=="function" and effect_service.is_active(player,resolved,snapshot) - if active and type(effect_service.set_remaining)=="function" then - local frames=DURATIONS[picked].frames - local ok,reason=effect_service.set_remaining(player,resolved,frames) - audit("effect.duration", "id="..tostring(resolved.id or resolved.path).." value="..tostring(frames).." result="..tostring(ok).." reason="..tostring(reason)) - end - end - return 13 -end - -local function draw_editor(player, entry, panel_width, snapshot) - if type(entry) ~= "table" then return 0 end - local height = 0 - local key = family_key(entry) - local count = math.max(1, tonumber(effect_service.variant_count(entry)) or 1) - if count > 1 then - local index = chosen_variant(player, entry, snapshot) - local active = type(effect_service.is_family_active)=="function" and effect_service.is_family_active(player,entry,snapshot) - or effect_service.is_active(player,effect_service.resolve_variant(entry,index),snapshot) - local buttons = {} - for variant_index=1,count do - local resolved = effect_service.resolve_variant(entry,variant_index) - buttons[#buttons+1] = { - label=tostring(variant_index), selected=variant_index==index, - tooltip_title=tostring(resolved.display_name or ""), - tooltip_description=tostring(resolved.display_description or ""), - } + -- A number of status entries omit ui_icon even though vanilla ships the standard + -- status indicator under a predictable id-based filename. Only use it when the + -- file really exists; otherwise leave the neutral slot instead of caching a bad path. + local id = string.lower(tostring(entry.id or "")) + if id ~= "" then + local candidate = "data/ui_gfx/status_indicators/" .. id .. ".png" + if ModDoesFileExist(candidate) then + local resolved = ui.resolve(candidate) + if resolved ~= nil then return resolved end end - local picked = ui.button_grid(buttons, math.max(48,panel_width-10)) - if picked ~= nil and picked ~= index then - selected_variant[key] = picked - if active and type(effect_service.switch_variant) == "function" then - local ok, reason = effect_service.switch_variant(player,entry,picked) - audit("effect.variant", "id="..tostring(entry.id or entry.path).." variant="..tostring(picked).." result="..tostring(ok).." reason="..tostring(reason)) - end - end - height = height + 13 end - height = height + draw_duration_bar(player, entry, panel_width, snapshot) - return height + return ui.EMPTY_SLOT end function effects_tab.draw(player, panel_width, screen_height) if not ensure_catalog() then ui.white_text(0, 2, ui.tr("$mcm_effects_failed", "Effect list failed to load")); return end GuiLayoutBeginVertical(ui.gui(), 0, 2, true) - - local filter_records = { {"all","$mcm_effect_filter_all","ALL"}, {"status","$mcm_effect_filter_status","STATUS"}, {"game_effect","$mcm_effect_filter_timed","EFFECTS"} } - local filter_buttons = {} - for index, f in ipairs(filter_records) do filter_buttons[index] = {label=ui.tr(f[2],f[3]),selected=filter==f[1]} end - local clicked_filter = ui.button_grid(filter_buttons, panel_width - 10) - if clicked_filter ~= nil then filter = filter_records[clicked_filter][1] end - - search = ui.search_input(search, math.max(68, panel_width - 28), 64, "effects") - - local filtered = {} - for _, entry in ipairs(catalog) do if filter == "all" or entry.kind == filter then filtered[#filtered+1] = entry end end - local results = ui.rank_entries(search, filtered, search_fields, function(entry) return family_key(entry) end) - ui.search_status(search, #results) - - local snapshot = effect_service.active_snapshot(player) - local selected = find_selected(catalog) - local editor_height = selected ~= nil and draw_editor(player,selected,panel_width,snapshot) or 0 - - local remove_all_label = ui.tr("$mcm_effect_remove_all", "REMOVE ALL") - if ui.confirm_button("effects.remove_all", remove_all_label, - ui.tr("$mcm_confirm", "CONFIRM") .. ": " .. remove_all_label, nil, math.max(32,panel_width-10)) - then + GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true) + for _, f in ipairs({ {"all","$mcm_effect_filter_all","ALL"}, {"status","$mcm_effect_filter_status","STATUS"}, {"game_effect","$mcm_effect_filter_timed","EFFECTS"} }) do + if ui.button(0,0,ui.tr(f[2],f[3]),filter==f[1]) then filter=f[1] end + end + GuiLayoutEnd(ui.gui()) + GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true) + ui.white_text(0, 1, ui.tr("$mcm_effect_duration", "DURATION")) + if ui.button(0,0," - ") then duration_index=math.max(1,duration_index-1) end + local d = DURATIONS[duration_index] + ui.white_text(0,1,ui.tr(d.key,d.fallback)) + if ui.button(0,0," + ") then duration_index=math.min(#DURATIONS,duration_index+1) end + if ui.button(0,0,ui.tr("$mcm_effect_remove_all","REMOVE ALL")) then local removed=effect_service.remove_all(player) - audit("effect.remove_all", "removed="..tostring(removed)) end + GuiLayoutEnd(ui.gui()) + search = ui.search_input(search, math.max(88, panel_width - 54), 64, "effects") - local h=ui.scroll_height(screen_height, 142 + editor_height) - local scroll=ui.begin_scroll_viewport("effects.catalog",11100,0,0,panel_width-4,h,{layout="free"}) - local columns=ui.columns(scroll.content_width,ui.ICON_STEP,{reserve_scrollbar=false}) + local columns=ui.columns(panel_width) + local h=ui.grid_height(screen_height,205,145) + GuiBeginScrollContainer(ui.gui(), 11100, 0,0,panel_width-4,h,false,1,1) + local _,_,hov=GuiGetPreviousWidgetInfo(ui.gui()); ui.mark_hovered(hov) local visible=0 - for _,entry in ipairs(results) do - local key=family_key(entry) - local family_active = type(effect_service.is_family_active)=="function" and effect_service.is_family_active(player,entry,snapshot) - or effect_service.is_active(player,entry,snapshot) - local description=tostring(entry.display_description or "") - local i=visible; visible=visible+1 - local clicked,right=ui.tile(scroll.padding_left+(i%columns)*ui.ICON_STEP,ui.scroll_y(scroll,math.floor(i/columns)*ui.ICON_STEP), - ui.EMPTY_SLOT,icon(entry),ui.EMPTY_SLOT,entry.display_name or "",description, - selected_key==key or family_active,{target_size=18,max_scale=3.0,fill=1.15}) - if clicked then - selected_key=key - apply_tile(player,entry,snapshot) - elseif right then - selected_key=key - local removed=type(effect_service.remove_family)=="function" and effect_service.remove_family(player,entry) - or effect_service.remove(player,entry) - audit("effect.remove", "id="..tostring(entry.id or entry.path).." family=true removed="..tostring(removed)) + local active_snapshot = effect_service.active_snapshot(player) + for _,entry in ipairs(catalog) do + if (filter=="all" or entry.kind==filter) and ui.matches_search(search, entry.display_name, entry.id, entry.path, entry.display_description) then + local active=effect_service.is_active(player,entry,active_snapshot) + local description=tostring(entry.display_description or "") + local detail=tostring(entry.id or entry.path or "") + if detail~="" then description=description .. (description~="" and "\n" or "") .. detail end + if entry.kind=="status" then + description=description.."\n"..ui.tr("$mcm_effect_status_native_decay","Applied at 100%; Noita controls natural decay") + else + description=description.."\n"..ui.tr("$mcm_effect_selected_duration","Selected duration")..": "..ui.tr(d.key,d.fallback) + end + local i=visible; visible=visible+1 + local clicked,right=ui.tile((i%columns)*ui.ICON_STEP,math.floor(i/columns)*ui.ICON_STEP, + ui.EMPTY_SLOT,icon(entry),ui.EMPTY_SLOT,entry.display_name or detail,description,active,{target_size=18,max_scale=3.0,fill=1.15}) + if clicked then + local frames=entry.kind=="status" and nil or d.frames + local ok,reason=effect_service.add(player,entry,frames) + elseif right then + local removed=effect_service.remove(player,entry) + end end end - ui.end_scroll_viewport(scroll,math.ceil(visible/columns)*ui.ICON_STEP) + GuiEndScrollContainer(ui.gui()) GuiLayoutEnd(ui.gui()) end diff --git a/metamorph_creative_menu/files/ui/tabs/items.lua b/metamorph_creative_menu/files/ui/tabs/items.lua index 29dea58..e6b66ed 100644 --- a/metamorph_creative_menu/files/ui/tabs/items.lua +++ b/metamorph_creative_menu/files/ui/tabs/items.lua @@ -1,25 +1,21 @@ local items_tab = {} local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") -local audit = ui.audit local item_service = dofile("mods/metamorph_creative_menu/files/features/items/service.lua") local item_catalog = dofile("mods/metamorph_creative_menu/files/features/items/ui_catalog.lua") -local material_preview = dofile("mods/metamorph_creative_menu/files/platform/noita/material_preview.lua") -local drag_drop = dofile("mods/metamorph_creative_menu/files/ui/drag_drop.lua") -local inventory_slots = dofile("mods/metamorph_creative_menu/files/platform/noita/inventory_slots.lua") +local liquid_preview = dofile("mods/metamorph_creative_menu/files/features/items/liquid_preview.lua") local catalog, filters = nil, nil local selected_filter = 1 local search = "" local icon_cache = {} -local liquid_warmup = material_preview.new_liquid_warmup() +local liquid_color_cache = {} +local liquid_color_cursor = 1 +local liquid_colors_complete = false +local POTION_ICON = "data/ui_gfx/items/potion.png" local search_cache_key = nil local search_cache_value = nil -local function liquid_cap_icon() - return type(material_preview.liquid_cap_icon) == "function" and material_preview.liquid_cap_icon() or nil -end - local FALLBACK = { CONTAINERS = "data/ui_gfx/items/potion.png", STONES = "data/ui_gfx/items/waterstone.png", @@ -54,216 +50,103 @@ local function icon(item) local explicit = tostring(item.icon or "") local meaningful_explicit = explicit ~= "" and explicit ~= ui.EMPTY_SLOT and explicit ~= "data/ui_gfx/inventory/inventory_box.png" - local resolve_asset = type(ui.asset) == "function" and ui.asset or ui.resolve - local resolve_entity = type(ui.entity_asset) == "function" and ui.entity_asset or ui.entity_icon - local resolved = meaningful_explicit and resolve_asset(explicit) or nil - local used_fallback = false - if resolved == nil or (type(resolved) == "table" and resolved.kind == "placeholder") then - resolved = resolve_entity(item.path, "item") or resolved - end - if resolved == nil and explicit ~= "" then resolved = resolve_asset(explicit) end - if resolved == nil or (type(resolved) == "table" and resolved.kind == "placeholder") then - resolved = resolve_asset(FALLBACK[item.category]) - used_fallback = true - end - if resolved == nil then - resolved = resolve_asset(ui.EMPTY_SLOT) - used_fallback = true - end - -- External VFS resources can legitimately appear after this tab's first render. - -- Cache only a proven item-specific descriptor; a placeholder/category fallback is - -- returned for this frame but retried later instead of becoming permanent. - if not used_fallback and type(resolved) == "table" and resolved.kind ~= "placeholder" then - icon_cache[item.path] = resolved - end + local resolved = meaningful_explicit and ui.resolve(explicit) or nil + if resolved == nil then resolved = ui.entity_icon(item.path, "item") end + if resolved == nil and explicit ~= "" then resolved = ui.resolve(explicit) end + if resolved == nil then resolved = ui.resolve(FALLBACK[item.category]) end + resolved = resolved or ui.EMPTY_SLOT + icon_cache[item.path] = resolved return resolved end -local function point_inside(bounds, x, y) - return type(bounds) == "table" and tonumber(x) ~= nil and tonumber(y) ~= nil - and tonumber(x) >= tonumber(bounds.x or 0) and tonumber(x) <= tonumber(bounds.x or 0) + tonumber(bounds.width or 0) - and tonumber(y) >= tonumber(bounds.y or 0) and tonumber(y) <= tonumber(bounds.y or 0) + tonumber(bounds.height or 0) -end - -local function drag_bounds(x, y, width, height) - x, y, width, height = tonumber(x), tonumber(y), tonumber(width), tonumber(height) - if x == nil or y == nil or width == nil or height == nil or width <= 0 or height <= 0 then return nil end - return {x=x, y=y, width=width, height=height} -end - -local function report_create_failure(name) - GamePrint(ui.tr("$mcm_item_create_failed", "Could not create item") .. ": " .. tostring(name or "")) -end - -local function handle_completed_drag(player, screen_width, screen_height) - local result = drag_drop.take_result() - if result == nil or type(result.payload) ~= "table" then return end - local payload = result.payload - if payload.kind ~= "catalog_item" and payload.kind ~= "catalog_liquid" then return end - - if result.click == true then - if payload.kind == "catalog_liquid" then - local ok, reason = item_service.spawn_filled_flask(player, payload.material_id, false) - audit("item.spawn_liquid", "id=" .. tostring(payload.material_id) .. " result=" .. tostring(ok) .. " reason=" .. tostring(reason)) - if not ok then report_create_failure(payload.display_name) end - else - local entity, reason = item_service.spawn_near(player, payload.path) - audit("item.spawn", "path=" .. tostring(payload.path) .. " entity=" .. tostring(entity) .. " reason=" .. tostring(reason)) - if entity ~= 0 then - GamePrint(ui.tr("$mcm_created_nearby", "Spawned nearby") .. ": " .. tostring(payload.display_name or "")) - else - report_create_failure(payload.display_name) - end - end - return - end - - -- Catalog cards are immutable templates. A completed drag creates exactly one entity - -- only after the release destination has been classified. Missing targets inside the - -- menu are a cancel; outside the menu, native inventory wins over the game world. - if result.target ~= nil then return end - local release_x, release_y = tonumber(result.release_x), tonumber(result.release_y) - local menu_bounds = type(ui.panel_bounds) == "function" and ui.panel_bounds() or nil - if release_x == nil or release_y == nil or point_inside(menu_bounds, release_x, release_y) then - audit("item.drag.cancel", "kind=" .. tostring(payload.kind) .. " reason=in_menu") - return - end - - local native_inventory = inventory_slots.native_drop_bounds(player, "inventory_quick", screen_width, screen_height) - if point_inside(native_inventory, release_x, release_y) then - local ok, reason - if payload.kind == "catalog_liquid" then - ok, reason = item_service.give_filled_flask_strict(player, payload.material_id) - else - ok, reason = item_service.give_strict(player, payload.path, payload.category ~= "WANDS") - end - audit("item.drag.native_inventory", "kind=" .. tostring(payload.kind) .. " result=" .. tostring(ok) .. " reason=" .. tostring(reason)) - if ok then - GamePrint(ui.tr("$mcm_received", "Received") .. ": " .. tostring(payload.display_name or "")) - else - GamePrint(ui.tr("$mcm_item_receive_failed", "Item was not added to inventory") .. ": " .. tostring(payload.display_name or "")) - end - return - end - - local world_x, world_y = tonumber(result.world_x), tonumber(result.world_y) - if world_x == nil or world_y == nil then - audit("item.drag.world", "kind=" .. tostring(payload.kind) .. " result=false reason=position") - report_create_failure(payload.display_name) - return - end - - if payload.kind == "catalog_liquid" then - local ok, reason = item_service.spawn_filled_flask_at(payload.material_id, world_x, world_y) - audit("item.drag.world_liquid", "id=" .. tostring(payload.material_id) .. " result=" .. tostring(ok) - .. " reason=" .. tostring(reason) .. " x=" .. tostring(world_x) .. " y=" .. tostring(world_y)) - if not ok then report_create_failure(payload.display_name) end - else - local entity, reason = item_service.spawn_at(payload.path, world_x, world_y) - audit("item.drag.world", "path=" .. tostring(payload.path) .. " entity=" .. tostring(entity) - .. " reason=" .. tostring(reason) .. " x=" .. tostring(world_x) .. " y=" .. tostring(world_y)) - if entity == 0 then report_create_failure(payload.display_name) end - end -end - -local function is_liquid_entry(entry) - return type(entry) == "table" and entry.kind == "liquid" -end - -local function search_values(index, query, values) +local function search_values(index, query, values, is_liquid) query = tostring(query or "") if query == "" then return values end - local key = tostring(index) .. "\31" .. query + local key = tostring(index) .. "\31" .. query .. "\31" .. (is_liquid and "L" or "I") if key == search_cache_key and search_cache_value ~= nil then return search_cache_value end - local result = ui.rank_entries(query, values, function(entry) - if is_liquid_entry(entry) then - local aliases = type(ui.search_aliases) == "function" - and ui.search_aliases(entry.ui_name_key, entry.id) or {} - local fields = {entry.display_name, entry.id, entry.ui_name_key} - for _, alias in ipairs(aliases) do fields[#fields + 1] = alias end - return fields + local result = {} + for _, entry in ipairs(values or {}) do + local description = is_liquid and tostring(entry.id) or tostring(entry.display_description or "") + if ui.matches_search(query, entry.display_name, entry.id, entry.path, description) then + result[#result + 1] = entry end - return {entry.name, entry.description, entry.display_name, entry.id, entry.path, entry.display_description} - end, function(entry) return entry.id or entry.path end) + end search_cache_key, search_cache_value = key, result return result end function items_tab.draw(player, panel_width, screen_height) if not ensure_catalog() then ui.white_text(0, 2, ui.tr("$mcm_items_failed", "Item catalog failed to load")); return end - local screen_width, actual_screen_height = GuiGetScreenDimensions(ui.gui()) - handle_completed_drag(player, screen_width, actual_screen_height) GuiLayoutBeginVertical(ui.gui(), 0, 2, true) - local filter_buttons = {} - for index, f in ipairs(filters) do - filter_buttons[index] = {label=ui.tr(f[1], f[2]),selected=selected_filter == index} + for start = 1, #filters, 4 do + GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true) + for index = start, math.min(start + 3, #filters) do + local f = filters[index] + if ui.button(0, 0, ui.tr(f[1], f[2]), selected_filter == index) then selected_filter = index end + end + GuiLayoutEnd(ui.gui()) end - local clicked_filter = ui.button_grid(filter_buttons, panel_width - 10) - if clicked_filter ~= nil then selected_filter = clicked_filter end - search = ui.search_input(search, math.max(68, panel_width - 28), 64, "items") + search = ui.search_input(search, math.max(88, panel_width - 54), 64, "items") local filter = filters[selected_filter] local values = filter[4] == "LIQUIDS" and item_catalog.liquids(ui.translated) or entries_for(selected_filter) - local results = search_values(selected_filter, search, values) - - -- Both ITEMS and MATERIALS use this same bounded probe/cache. A real filled flask - -- is the engine-authoritative preview for liquids; two new materials per frame keep - -- catalogue opening independent of the number of installed materials. Only warm the - -- liquid entries that survived search so ALL does not eagerly probe the entire material - -- catalogue when the user is looking for one specific item. - local liquid_entries = {} - for _, entry in ipairs(results) do - if is_liquid_entry(entry) then liquid_entries[#liquid_entries + 1] = entry end - end - if #liquid_entries > 0 then - material_preview.warm_liquid_colors(player, liquid_entries, liquid_warmup, 2, - "items:" .. tostring(selected_filter) .. ":" .. tostring(search)) + -- Lazily sample two liquid colors per frame. GameGetPotionColorUint needs a real + -- material inventory entity; doing every material in one frame would recreate the + -- old multi-second catalogue hitch. The cache fills progressively while the tab is + -- open and is reused for the rest of the run. + if filter[4] == "LIQUIDS" and #values > 0 and not liquid_colors_complete then + local sampled = 0 + local scanned = 0 + while sampled < 2 and scanned < #values do + if liquid_color_cursor > #values then liquid_color_cursor = 1 end + local entry = values[liquid_color_cursor] + liquid_color_cursor = liquid_color_cursor + 1 + scanned = scanned + 1 + if liquid_color_cache[entry.id] == nil then + liquid_color_cache[entry.id] = liquid_preview.sample_color(player, entry.id) or false + sampled = sampled + 1 + end + end + local complete = true + for _, entry in ipairs(values) do if liquid_color_cache[entry.id] == nil then complete = false; break end end + liquid_colors_complete = complete end - ui.search_status(search, #results) - ui.wrapped_text(0, 0, ui.tr("$mcm_item_controls", "LMB: SPAWN DRAG: DROP RMB: TAKE"), math.max(24, panel_width - 8)) - local grid_h = ui.scroll_height(screen_height, 160) - local scroll = ui.begin_scroll_viewport("items.catalog." .. tostring(selected_filter), - 9100 + selected_filter, 0, 0, panel_width - 4, grid_h, {layout="free"}) - local columns = ui.columns(scroll.content_width, ui.ICON_STEP, {reserve_scrollbar=false}) + local columns = ui.columns(panel_width) + local grid_h = ui.grid_height(screen_height, 190, 160) + ui.white_text(0, 0, ui.tr("$mcm_item_controls", "LMB: SPAWN RMB: TAKE")) + GuiBeginScrollContainer(ui.gui(), 9100 + selected_filter, 0, 0, panel_width - 4, grid_h, false, 1, 1) + local _, _, hov = GuiGetPreviousWidgetInfo(ui.gui()); ui.mark_hovered(hov) local visible = 0 - for _, entry in ipairs(results) do - local is_liquid = is_liquid_entry(entry) + local is_liquid = filter[4] == "LIQUIDS" + for _, entry in ipairs(search_values(selected_filter, search, values, is_liquid)) do local description = is_liquid and tostring(entry.id) or tostring(entry.display_description or "") local i = visible; visible = visible + 1 - local entry_icon = is_liquid and material_preview.liquid_icon() or icon(entry) - local _, right, _, tile_x, tile_y, tile_w, tile_h = ui.tile( - scroll.padding_left + (i % columns) * ui.ICON_STEP, - ui.scroll_y(scroll, math.floor(i / columns) * ui.ICON_STEP), + local entry_icon = is_liquid and POTION_ICON or icon(entry) + local clicked, right = ui.tile((i % columns) * ui.ICON_STEP, math.floor(i / columns) * ui.ICON_STEP, ui.EMPTY_SLOT, entry_icon, ui.EMPTY_SLOT, entry.display_name, description, false, - { target_size=18, max_scale=3.0, - icon_tint=is_liquid and material_preview.liquid_color(entry.id) or nil, - icon_overlay=is_liquid and liquid_cap_icon() or nil }) - local bounds = drag_bounds(tile_x, tile_y, tile_w, tile_h) - if bounds ~= nil then - drag_drop.source("items.catalog." .. (is_liquid and ("liquid." .. tostring(entry.id)) or tostring(entry.path)), { - kind=is_liquid and "catalog_liquid" or "catalog_item", - material_id=is_liquid and entry.id or nil, path=not is_liquid and entry.path or nil, - category=not is_liquid and entry.category or nil, display_name=entry.display_name, - background=ui.EMPTY_SLOT, icon=entry_icon, - liquid_color=is_liquid and material_preview.liquid_color(entry.id) or nil, - icon_overlay=is_liquid and liquid_cap_icon() or nil, - }, bounds, {x=scroll.x, y=scroll.y, width=scroll.width, height=scroll.height}) - end - if right then - if is_liquid then - local ok, reason = item_service.spawn_filled_flask(player, entry.id, true) - audit("item.take_liquid", "id="..tostring(entry.id).." result="..tostring(ok).." reason="..tostring(reason)) + { target_size=18, max_scale=3.0, bottle_fill_color=is_liquid and (liquid_color_cache[entry.id] or nil) or nil }) + if is_liquid then + if clicked or right then + local ok, reason, entity = item_service.spawn_filled_flask(player, entry.id, right) if not ok and reason == "full" then GamePrint(ui.tr("$mcm_inventory_full_spawned", "Inventory full — spawned nearby") .. ": " .. entry.display_name) elseif not ok then - report_create_failure(entry.display_name) + GamePrint(ui.tr("$mcm_item_create_failed", "Could not create item") .. ": " .. entry.display_name) end - else + end + else + if clicked then + local entity, reason = item_service.spawn_near(player, entry.path) + if entity ~= 0 then + GamePrint(ui.tr("$mcm_created_nearby", "Spawned nearby") .. ": " .. entry.display_name) + else + GamePrint(ui.tr("$mcm_item_create_failed", "Could not create item") .. ": " .. entry.display_name) + end + elseif right then local ok, reason, entity, spawned_nearby = item_service.give(player, entry.path, entry.category ~= "WANDS") - audit("item.take", "path="..tostring(entry.path).." result="..tostring(ok).." reason="..tostring(reason).." entity="..tostring(entity)) if ok then GamePrint(ui.tr("$mcm_received", "Received") .. ": " .. entry.display_name) elseif reason == "full" then @@ -277,15 +160,7 @@ function items_tab.draw(player, panel_width, screen_height) end end end - ui.end_scroll_viewport(scroll, math.ceil(visible / columns) * ui.ICON_STEP) - if drag_drop.active() then - local payload = drag_drop.payload() - local mouse_x, mouse_y = drag_drop.mouse_position() - if type(payload) == "table" and (payload.kind == "catalog_item" or payload.kind == "catalog_liquid") then - ui.drag_ghost(payload.background, payload.icon, mouse_x, mouse_y, - {icon_tint=payload.liquid_color, icon_overlay=payload.icon_overlay}) - end - end + GuiEndScrollContainer(ui.gui()) GuiLayoutEnd(ui.gui()) end diff --git a/metamorph_creative_menu/files/ui/tabs/materials.lua b/metamorph_creative_menu/files/ui/tabs/materials.lua deleted file mode 100644 index 9aba8f2..0000000 --- a/metamorph_creative_menu/files/ui/tabs/materials.lua +++ /dev/null @@ -1,154 +0,0 @@ -local materials_tab = {} - -local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") -local audit = ui.audit -local material_catalog = dofile("mods/metamorph_creative_menu/files/features/materials/catalog.lua") -local painter = dofile("mods/metamorph_creative_menu/files/features/materials/painter.lua") -local bindings = dofile("mods/metamorph_creative_menu/files/platform/noita/action_bindings.lua") -local material_preview = dofile("mods/metamorph_creative_menu/files/platform/noita/material_preview.lua") - -local categories = nil -local selected_filter = 2 -local search = "" -local search_cache_key = nil -local search_cache_value = nil - -local CATALOG_BUDGET_PER_FRAME = 24 -local active_catalog_request = nil -local liquid_warmup = material_preview.new_liquid_warmup() - -local function liquid_cap_icon() - return type(material_preview.liquid_cap_icon) == "function" and material_preview.liquid_cap_icon() or nil -end - -local function ensure_catalog(category_id) - if categories == nil then categories = material_catalog.categories() end - if type(categories) ~= "table" then return false, false end - if active_catalog_request ~= category_id then - active_catalog_request = category_id - return true, material_catalog.is_ready(category_id) - end - local ready = material_catalog.step(category_id, ui.translated, CATALOG_BUDGET_PER_FRAME) - local values = material_catalog.entries_for(category_id) - if painter.get_material() == nil and type(values) == "table" and values[1] ~= nil then - painter.set_material(values[1].id, { solid=values[1].categories and (values[1].categories.SOLIDS == true or values[1].categories.STATIC == true) }) - end - return true, ready == true -end - -local function visible_entries(category_id, query) - local values = material_catalog.entries_for(category_id) - query = tostring(query or "") - if query == "" then return values end - local key = tostring(category_id) .. "\31" .. query .. "\31" .. tostring(#(values or {})) - if key == search_cache_key and search_cache_value ~= nil then return search_cache_value end - local result = ui.rank_entries(query, values, function(entry) - return {entry.name_key, entry.display_name, entry.id, entry.category} - end, function(entry) return entry.id end) - search_cache_key, search_cache_value = key, result - return result -end - -function materials_tab.draw(player, panel_width, screen_height) - if categories == nil then categories = material_catalog.categories() end - local category = categories and (categories[selected_filter] or categories[2] or categories[1]) or nil - if category == nil then - ui.white_text(0, 2, ui.tr("$mcm_materials_failed", "Material catalog failed to load")) - return - end - local catalog_category = search ~= "" and "ALL" or category.id - local catalog_ok, catalog_ready = ensure_catalog(catalog_category) - if not catalog_ok then - ui.white_text(0, 2, ui.tr("$mcm_materials_failed", "Material catalog failed to load")) - return - end - GuiLayoutBeginVertical(ui.gui(), 0, 2, true) - - local category_buttons = {} - for index, entry in ipairs(categories) do - category_buttons[index] = {label=ui.tr(entry.key, entry.fallback),selected=selected_filter == index} - end - local clicked_category = ui.button_grid(category_buttons, panel_width - 10) - if clicked_category ~= nil then - selected_filter = clicked_category - search_cache_key, search_cache_value = nil, nil - end - - search = ui.search_input(search, math.max(88, panel_width - 54), 64, "materials") - - local brush_delta = ui.stepper(ui.tr("$mcm_material_brush", "BRUSH"), - tostring(painter.brush_diameter()) .. " px") - if brush_delta ~= 0 then painter.adjust_brush(brush_delta) end - - local selected_id = painter.get_material() - local selected = selected_id and material_catalog.get(selected_id) or nil - if selected ~= nil then - ui.wrapped_text(0, 0, ui.tr("$mcm_material_selected", "Selected") .. ": " - .. tostring(selected.display_name) .. " [" .. tostring(selected.id) .. "]", panel_width - 12) - end - if painter.is_enabled() then - if ui.button(0, 0, ui.tr("$mcm_material_paint_cancel", "CANCEL PAINT"), true) then - painter.set_enabled(false) - audit("material.paint_mode", "enabled=false") - end - local armed = ui.tr("$mcm_material_paint_armed_dynamic", "ARMED - close inventory; hold {BIND}") - ui.wrapped_text(0, 1, string.gsub(armed, "{BIND}", "[" .. bindings.label("paint_draw") .. "]"), panel_width - 12) - else - if ui.button(0, 0, ui.tr("$mcm_material_paint_start", "START PAINTING")) then - local ok, reason = painter.set_enabled(true) - audit("material.paint_mode", "enabled=" .. tostring(ok) .. " material=" .. tostring(painter.get_material()) .. " reason=" .. tostring(reason)) - if not ok and type(GamePrint) == "function" then - local message = ui.tr("$mcm_material_backend_failed", "Material brush unavailable") - pcall(GamePrint, "[MCM] " .. message .. ": " .. tostring(reason or "backend")) - end - end - end - local controls = ui.tr("$mcm_material_controls_dynamic", "Close inventory then hold {BIND} in the world. Reopen to stop.") - ui.wrapped_text(0, 0, string.gsub(controls, "{BIND}", "[" .. bindings.label("paint_draw") .. "]"), panel_width - 12) - - local result_category = search ~= "" and "ALL" or category.id - if result_category ~= catalog_category then catalog_ready = material_catalog.is_ready(result_category) end - if not catalog_ready then - ui.white_text(0, 0, ui.tr("$mcm_material_loading", "Loading materials...") ) - end - local values = visible_entries(result_category, search) - ui.search_status(search, #values) - local liquid_entries = {} - for _, entry in ipairs(values) do - if type(entry.categories) == "table" and entry.categories.LIQUIDS == true then - liquid_entries[#liquid_entries + 1] = entry - end - end - material_preview.warm_liquid_colors(player, liquid_entries, liquid_warmup, 2, - "materials:" .. tostring(category.id) .. ":" .. tostring(search)) - local grid_h = ui.scroll_height(screen_height, 194) - local scroll = ui.begin_scroll_viewport("materials.catalog." .. tostring(selected_filter), - 9700 + selected_filter, 0, 0, panel_width - 4, grid_h, {layout="free"}) - local columns = ui.columns(scroll.content_width, ui.ICON_STEP, {reserve_scrollbar=false}) - for index, entry in ipairs(values) do - local i = index - 1 - local is_liquid = type(entry.categories) == "table" and entry.categories.LIQUIDS == true - local texture = not is_liquid and material_preview.texture(entry.id) or nil - local tint = texture ~= nil and material_preview.tint(entry.id) or nil - if not is_liquid and texture == nil and entry.preview_color == nil then - entry.preview_color = painter.material_color(entry.id) or {0.45,0.45,0.45,0.96} - end - local clicked = ui.tile(scroll.padding_left + (i % columns) * ui.ICON_STEP, ui.scroll_y(scroll, math.floor(i / columns) * ui.ICON_STEP), - ui.EMPTY_SLOT, is_liquid and material_preview.liquid_icon() or texture, nil, - entry.display_name, tostring(entry.id) .. "\n" .. tostring(entry.category), - selected_id == entry.id, { - swatch_color=not is_liquid and texture == nil and entry.preview_color or nil, - icon_tint=is_liquid and material_preview.liquid_color(entry.id) or tint, - icon_overlay=is_liquid and liquid_cap_icon() or nil, - target_size=18, max_scale=3.0, padding=1, - }) - if clicked then - local ok, reason = painter.set_material(entry.id, { solid=entry.categories and (entry.categories.SOLIDS == true or entry.categories.STATIC == true) }) - audit("material.select", "id=" .. tostring(entry.id) .. " result=" .. tostring(ok) .. " reason=" .. tostring(reason)) - end - end - ui.end_scroll_viewport(scroll, math.ceil(#values / columns) * ui.ICON_STEP) - GuiLayoutEnd(ui.gui()) -end - -return materials_tab diff --git a/metamorph_creative_menu/files/ui/tabs/perks.lua b/metamorph_creative_menu/files/ui/tabs/perks.lua index 2083520..c6d4882 100644 --- a/metamorph_creative_menu/files/ui/tabs/perks.lua +++ b/metamorph_creative_menu/files/ui/tabs/perks.lua @@ -1,27 +1,19 @@ local perks_tab = {} local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") -local audit = ui.audit local perk_service = dofile("mods/metamorph_creative_menu/files/features/perks/service.lua") local perk_catalog = dofile("mods/metamorph_creative_menu/files/features/perks/catalog.lua") -local drag_drop = dofile("mods/metamorph_creative_menu/files/ui/drag_drop.lua") -local non_perk_states = nil -do - local ok, module = pcall(dofile, "mods/metamorph_creative_menu/files/features/perks/non_perk_states.lua") - if ok and type(module) == "table" then non_perk_states = module - else non_perk_states = { list=function() return {} end, remove=function() return false, "unavailable" end } end -end local catalog = nil local remove_mode = false -local take_amount = 1 -local right_latched = false local search = "" local PERK_STEP = ui.ICON_STEP local PERK_ICON_SIZE = 18 local warmup_cursor = 1 local perk_icon_cache = {} local PERK_ICON_FALLBACK = ui.EMPTY_SLOT +local page, last_page_key = 1, nil +local PAGE_ROWS = 7 local function ensure_catalog() if catalog ~= nil then return true end @@ -34,9 +26,7 @@ local function ensure_catalog() catalog[#catalog + 1] = { id = perk.id, name = name, - name_key = perk.ui_name, description = ui.translated(perk.ui_description), - description_key = perk.ui_description, icon = perk.ui_icon, data = perk, } @@ -65,167 +55,62 @@ local function display_icon(perk, force_resolve) return PERK_ICON_FALLBACK end -local function active_job() - return type(perk_service.job_status) == "function" and perk_service.job_status() or nil -end - -local function report_busy() - GamePrint(ui.tr("$mcm_perk_job_busy", "Finish or cancel the current perk job first")) -end - local function apply_or_spawn(player_entity_id, perk_entry, take_immediately) local perk_data = type(perk_entry.data) == "table" and perk_entry.data or perk_entry if take_immediately then - if active_job() ~= nil then return false, "busy" end - if take_amount > 1 and type(perk_service.start_take_job) == "function" then - return perk_service.start_take_job(player_entity_id, perk_data, take_amount) - end local applied, reason = perk_service.apply(player_entity_id, perk_data) if not applied and reason == "pickup_failed" then GamePrint(ui.tr("$mcm_perk_apply_failed", "Could not apply perk") .. ": " .. tostring(perk_entry.name or perk_data.id)) end return applied, reason end - if active_job() ~= nil then return false, "busy" end return perk_service.spawn(player_entity_id, perk_data) end -local function point_inside(bounds, x, y) - return type(bounds) == "table" and tonumber(x) ~= nil and tonumber(y) ~= nil - and tonumber(x) >= tonumber(bounds.x or 0) and tonumber(x) <= tonumber(bounds.x or 0) + tonumber(bounds.width or 0) - and tonumber(y) >= tonumber(bounds.y or 0) and tonumber(y) <= tonumber(bounds.y or 0) + tonumber(bounds.height or 0) -end - -local function drag_bounds(x, y, width, height) - x, y, width, height = tonumber(x), tonumber(y), tonumber(width), tonumber(height) - if x == nil or y == nil or width == nil or height == nil or width <= 0 or height <= 0 then return nil end - return {x=x, y=y, width=width, height=height} -end - -local function report_spawn_failure(name) - GamePrint(ui.tr("$mcm_perk_spawn_failed", "Could not spawn perk") .. ": " .. tostring(name or "")) -end - -local function handle_completed_drag(player) - local result = drag_drop.take_result() - if result == nil or type(result.payload) ~= "table" then return end - local payload = result.payload - if payload.kind ~= "catalog_perk" then return end - - if result.click == true then - local ok, reason = apply_or_spawn(player, payload.data or {id=payload.id}, false) - audit("perk.spawn", "id=" .. tostring(payload.id) .. " result=" .. tostring(ok) .. " reason=" .. tostring(reason)) - if reason == "busy" then report_busy() elseif not ok then report_spawn_failure(payload.display_name) end - return - end - if result.target ~= nil then return end - - local release_x, release_y = tonumber(result.release_x), tonumber(result.release_y) - local menu_bounds = type(ui.panel_bounds) == "function" and ui.panel_bounds() or nil - if release_x == nil or release_y == nil or point_inside(menu_bounds, release_x, release_y) then - audit("perk.drag.cancel", "id=" .. tostring(payload.id) .. " reason=in_menu") - return - end - local world_x, world_y = tonumber(result.world_x), tonumber(result.world_y) - if world_x == nil or world_y == nil then - audit("perk.drag.world", "id=" .. tostring(payload.id) .. " result=false reason=position") - report_spawn_failure(payload.display_name) - return - end - if active_job() ~= nil then report_busy(); return end - local ok, reason = perk_service.spawn_at(payload.data or {id=payload.id}, world_x, world_y) - audit("perk.drag.world", "id=" .. tostring(payload.id) .. " result=" .. tostring(ok) - .. " reason=" .. tostring(reason) .. " x=" .. tostring(world_x) .. " y=" .. tostring(world_y)) - if not ok then report_spawn_failure(payload.display_name) end -end - -local function draw_job_status(panel_width) - if type(perk_service.consume_job_notice) == "function" then - local notice = perk_service.consume_job_notice() - if type(notice) == "table" and notice.state ~= "cancelled" then - GamePrint(ui.tr("$mcm_perk_job_failed", "Perk job stopped") .. ": " .. tostring(notice.reason or notice.state or "unknown")) - end - end - local job = active_job() - if job == nil then return false end - local label = job.kind == "remove_all" and ui.tr("$mcm_perk_job_remove", "REMOVING") - or ui.tr("$mcm_perk_job_take", "TAKING") - local wait = job.waiting_async and (" " .. ui.tr("$mcm_perk_job_waiting", "WAIT")) or "" - ui.wrapped_text(0, 0, label .. " " .. tostring(job.perk_id or "") .. " " - .. tostring(job.completed or 0) .. "/" .. tostring(job.total or 0) .. wait, math.max(32, panel_width - 10)) - if ui.button_grid({{label=ui.tr("$mcm_perk_job_cancel", "CANCEL")}}, math.max(32, panel_width - 10)) == 1 - and type(perk_service.cancel_job) == "function" - then - perk_service.cancel_job() - end - return true -end - function perks_tab.draw(player, panel_width, screen_height) if not ensure_catalog() then ui.white_text(0, 2, ui.tr("$mcm_perks_failed", "Perk list failed to load")); return end perks_tab.warmup_step(1) - handle_completed_drag(player) GuiLayoutBeginVertical(ui.gui(), 0, 2, true) - local mode_clicked = ui.button_grid({ - {label=ui.tr("$mcm_perk_mode_add", "ADD"),selected=not remove_mode}, - {label=ui.tr("$mcm_perk_mode_remove", "REMOVE"),selected=remove_mode}, - }, math.max(32, panel_width - 10)) - if mode_clicked == 1 then remove_mode = false elseif mode_clicked == 2 then remove_mode = true end - local control_text = remove_mode and ui.tr("$mcm_perk_remove_controls", "LMB: -1 RMB: REMOVE ALL") - or (ui.tr("$mcm_perk_add_controls", "LMB: SPAWN RMB: TAKE") .. " " - .. ui.tr("$mcm_creature_drag_spawn", "DRAG: spawn at cursor")) - ui.wrapped_text(0, 0, control_text, math.max(24,panel_width-10)) - if not remove_mode then - ui.white_text(0, 0, ui.tr("$mcm_perk_take_amount", "TAKE") .. ":") - local amounts={1,10,100}; local amount_buttons={} - for index, amount in ipairs(amounts) do amount_buttons[index]={label=tostring(amount),selected=take_amount==amount} end - local amount_clicked=ui.button_grid(amount_buttons,math.max(32,panel_width-10)) - if amount_clicked~=nil then take_amount=amounts[amount_clicked] end - end - draw_job_status(panel_width) - - search = ui.search_input(search, math.max(68, panel_width - 28), 64, "perks") - - local eligible = {} + GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true) + if ui.button(0, 0, ui.tr("$mcm_perk_mode_add", "ADD"), not remove_mode) then remove_mode = false end + if ui.button(0, 0, ui.tr("$mcm_perk_mode_remove", "REMOVE"), remove_mode) then remove_mode = true end + GuiLayoutEnd(ui.gui()) + ui.white_text(0, 0, remove_mode and ui.tr("$mcm_perk_remove_controls", "LMB: -1 RMB: REMOVE ALL") + or ui.tr("$mcm_perk_add_controls", "LMB: SPAWN RMB: TAKE")) + search = ui.search_input(search, math.max(88, panel_width - 54), 64, "perks") + + local columns = ui.columns(panel_width, PERK_STEP) + -- Build a cheap matching index, but instantiate GUI widgets only for one page. + -- Widget creation is the expensive part on weak CPUs; all perks remain reachable. + local matches = {} for _, perk in ipairs(catalog) do local count = remove_mode and perk_service.count(perk.id) or 0 - if not remove_mode or count > 0 then - eligible[#eligible + 1] = {perk=perk, count=count} - end - end - -- Active essences/curses use the same removal grid, search, counters and mouse - -- controls as perks. They never appear in ADD or enter the perk transaction journal. - if remove_mode then - for _, state in ipairs(non_perk_states.list(player)) do - local name = ui.translated(state.name_key) - if name == "" or name == state.name_key then name = state.id end - eligible[#eligible + 1] = { count=state.count, perk={ - id=state.id, name=name, name_key=state.name_key, - description=ui.translated(state.description_key), description_key=state.description_key, - icon=state.icon, removable_state=true, - } } + if (not remove_mode or count > 0) and ui.matches_search(search, perk.name, perk.id, perk.description) then + matches[#matches + 1] = {perk=perk, count=count} end end - local matches = ui.rank_entries(search, eligible, function(record) - local perk = record.perk - return {perk.name_key, perk.description_key, perk.name, perk.id, perk.description} - end, function(record) return record.perk.id end) - ui.search_status(search, #matches) - local h = ui.scroll_height(screen_height, 132) - local scroll = ui.begin_scroll_viewport("perks.catalog." .. tostring(remove_mode), - 10100 + (remove_mode and 1000 or 0), 0, 0, panel_width - 4, h, {layout="free"}) - local columns = ui.columns(scroll.content_width, PERK_STEP, {reserve_scrollbar=false}) - local right_down = false - if type(InputIsMouseButtonDown) == "function" then - local ok, down = pcall(InputIsMouseButtonDown, tonumber(rawget(_G, "Mouse_right")) or tonumber(rawget(_G, "MOUSE_RIGHT")) or 2) - right_down = ok and down == true - end + local page_key = tostring(remove_mode) .. "\31" .. tostring(search) + if page_key ~= last_page_key then page, last_page_key = 1, page_key end + local page_size = math.max(columns, columns * PAGE_ROWS) + local page_count = math.max(1, math.ceil(#matches / page_size)) + page = math.max(1, math.min(page, page_count)) + GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true) + if ui.button(0, 0, "<", false) and page > 1 then page = page - 1 end + ui.white_text(0, 0, tostring(page) .. "/" .. tostring(page_count)) + if ui.button(0, 0, ">", false) and page < page_count then page = page + 1 end + GuiLayoutEnd(ui.gui()) + + local h = ui.grid_height(screen_height, 212, 145) + GuiBeginScrollContainer(ui.gui(), 10100 + (remove_mode and 1000 or 0) + page, 0, 0, panel_width - 4, h, false, 1, 1) + local _, _, hov = GuiGetPreviousWidgetInfo(ui.gui()); ui.mark_hovered(hov) + local first = (page - 1) * page_size + 1 + local last = math.min(#matches, first + page_size - 1) local visible = 0 - for index, match in ipairs(matches) do - local perk = match.perk - local count = match.count + for index = first, last do + local perk = matches[index].perk + local count = matches[index].count local can_remove, reason = true, nil - if remove_mode and not perk.removable_state then + if remove_mode then -- Perks are peer-local state. A removal click must never mutate another player. can_remove, reason = perk_service.can_remove(perk.data, player) end @@ -238,68 +123,23 @@ function perks_tab.draw(player, panel_width, screen_height) end end local i = visible; visible = visible + 1 - local clicked, right, hovered, tile_x, tile_y, tile_w, tile_h = ui.tile(scroll.padding_left + (i % columns) * PERK_STEP, - ui.scroll_y(scroll, math.floor(i / columns) * PERK_STEP), - ui.EMPTY_SLOT, display_icon(perk, perk.removable_state == true), PERK_ICON_FALLBACK, perk.name, desc, - remove_mode and count > 0 and can_remove, - { target_size=PERK_ICON_SIZE, icon_box_size=PERK_ICON_SIZE, max_scale=8.0, fill=1.15, padding=0, - icon_tint=remove_mode and not can_remove and {0.48,0.48,0.48,0.82} or nil, - marker_color=remove_mode and not can_remove and {1.0,0.25,0.20,0.92} or nil }) + local clicked, right, hovered = ui.tile((i % columns) * PERK_STEP, math.floor(i / columns) * PERK_STEP, + ui.EMPTY_SLOT, display_icon(perk, false), PERK_ICON_FALLBACK, perk.name, desc, remove_mode and count > 0, + { target_size=PERK_ICON_SIZE, icon_box_size=PERK_ICON_SIZE, max_scale=8.0, fill=1.15, padding=0 }) if hovered and perk_icon_cache[tostring(perk.id or perk.icon or "")] == nil then display_icon(perk, true) end - if not remove_mode then - local bounds = drag_bounds(tile_x, tile_y, tile_w, tile_h) - if bounds ~= nil then - drag_drop.source("perks.catalog." .. tostring(perk.id), { - kind="catalog_perk", id=perk.id, data=perk.data, display_name=perk.name, - background=ui.EMPTY_SLOT, icon=display_icon(perk, false), - }, bounds, {x=scroll.x, y=scroll.y, width=scroll.width, height=scroll.height}) - end - end - local right_command = right and not right_latched - if right_command and right_down then right_latched = true end - local job_running = active_job() ~= nil if remove_mode then - if can_remove and clicked and count > 0 then - if job_running then - report_busy() - else - local ok, reason - if perk.removable_state then ok, reason = non_perk_states.remove_one(player, perk.id) - else ok, reason = perk_service.remove_one(player, perk.data) end - audit("perk.remove_one", "id="..tostring(perk.id).." result="..tostring(ok).." reason="..tostring(reason).." scope=peer_local") - if not ok then GamePrint(ui.tr("$mcm_perk_remove_failed", "Could not safely remove perk") .. ": " .. perk.name) end - end - elseif can_remove and right_command and count > 0 then - if job_running then - report_busy() - else - local queued, reason - if perk.removable_state then queued, reason = non_perk_states.remove(player, perk.id) - else queued, reason = perk_service.start_remove_all_job(player, perk.data) end - audit("perk.remove_all.queue", "id="..tostring(perk.id).." queued="..tostring(queued).." reason="..tostring(reason).." scope=peer_local") - if not queued then GamePrint(ui.tr("$mcm_perk_remove_failed", "Could not safely remove perk") .. ": " .. tostring(reason)) end - end + if clicked and count > 0 then + local ok, reason = perk_service.remove_one(player, perk.data) + if not ok then GamePrint(ui.tr("$mcm_perk_remove_failed", "Could not safely remove perk") .. ": " .. perk.name) end + elseif right and count > 0 then + local removed, reason = perk_service.remove_all(player, perk.data) + if removed == 0 then GamePrint(ui.tr("$mcm_perk_remove_failed", "Could not safely remove perk") .. ": " .. perk.name) end end else - -- LMB is owned by drag_drop: a short press becomes exactly one spawn on the - -- next frame, while a real drag may place the perk at world coordinates. - -- Do not also consume ui.tile's click signal or short clicks would duplicate. - if right_command then - local ok, reason=apply_or_spawn(player, perk, true) - audit("perk.take", "id="..tostring(perk.id).." amount="..tostring(take_amount).." result="..tostring(ok).." reason="..tostring(reason)) - if reason == "busy" then report_busy() end - end - end - end - if not right_down then right_latched = false end - ui.end_scroll_viewport(scroll, math.ceil(visible / columns) * PERK_STEP) - if drag_drop.active() then - local payload = drag_drop.payload() - local mouse_x, mouse_y = drag_drop.mouse_position() - if type(payload) == "table" and payload.kind == "catalog_perk" then - ui.drag_ghost(payload.background, payload.icon, mouse_x, mouse_y) + if clicked then apply_or_spawn(player, perk, false) elseif right then apply_or_spawn(player, perk, true) end end end + GuiEndScrollContainer(ui.gui()) GuiLayoutEnd(ui.gui()) end diff --git a/metamorph_creative_menu/files/ui/tabs/players.lua b/metamorph_creative_menu/files/ui/tabs/players.lua deleted file mode 100644 index 4fbf65f..0000000 --- a/metamorph_creative_menu/files/ui/tabs/players.lua +++ /dev/null @@ -1,105 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_PLAYERS_TAB) == "table" then return METAMORPH_CREATIVE_MENU_PLAYERS_TAB end - -local tab = {} - -local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") -local player_tools = dofile("mods/metamorph_creative_menu/files/features/player_tools/service.lua") - -local coordinate_x = "0" -local coordinate_y = "0" - -function tab.draw(player, width, height) - GuiLayoutBeginVertical(ui.gui(), 0, 2, true) - ui.wrapped_text(0, 0, ui.tr("$mcm_players_hint", - "Safe teleports to players and world locations"), width - 12) - if player_tools.has_pending_teleport() then - ui.colored_text(0, 1, ui.tr("$mcm_teleport_loading", "LOADING DESTINATION..."), - {1.0,0.78,0.2,1.0}) - end - - local scroll_height = ui.scroll_height(height, 52) - local scroll = ui.begin_scroll_viewport("players.main", 17100, 0, 0, width - 4, scroll_height) - local content_width = math.max(24, scroll.content_width) - - ui.colored_text(0, 2, ui.tr("$mcm_teleport_players", "NETWORK PLAYERS"), {1.0,0.78,0.2,1.0}) - local players = {} - for _, entity in ipairs(player_tools.visible_players()) do - if tonumber(entity) ~= tonumber(player) then players[#players + 1] = entity end - end - table.sort(players, function(a, b) return (tonumber(a) or 0) < (tonumber(b) or 0) end) - if #players == 0 then - ui.wrapped_text(0, 1, ui.tr("$mcm_players_empty", "No remote players found"), content_width) - end - for index, entity in ipairs(players) do - local x, y = player_tools.position(entity) - local label = ui.tr("$mcm_players_player", "Player") .. " " .. tostring(index) - .. " (" .. tostring(math.floor(x)) .. ", " .. tostring(math.floor(y)) .. ")" - ui.wrapped_text(0, 2, label, content_width) - local remote_action = ui.button_grid({ - {label=ui.tr("$mcm_teleport_to_player", "GO TO"), - tooltip_description=ui.tr("$mcm_teleport_to_player_hint", "Teleport safely beside this player")}, - {label=ui.tr("$mcm_teleport_bring_player", "BRING HERE"), - tooltip_description=ui.tr("$mcm_teleport_bring_hint", "Ask this peer to teleport safely to you")}, - }, math.max(32, content_width - 4)) - if remote_action == 1 then player_tools.teleport_to(entity, player) - elseif remote_action == 2 then player_tools.bring_to_me(entity, player) end - end - - ui.colored_text(0, 6, ui.tr("$mcm_teleport_coordinates", "COORDINATES"), {1.0,0.78,0.2,1.0}) - local current_x, current_y = player_tools.position(player) - ui.wrapped_text(0, 0, "X " - .. string.format("%.1f", tonumber(current_x) or 0) .. " Y " - .. string.format("%.1f", tonumber(current_y) or 0), math.max(32, content_width - 8)) - if content_width >= 132 then - GuiLayoutBeginHorizontal(ui.gui(), 0, 1, true) - local field_width = math.max(36, math.floor((content_width - 28) * 0.5)) - ui.white_text(0, 0, "X") - coordinate_x = ui.text_input(coordinate_x, field_width, 18, - "teleport.coordinate_x", {allowed_characters="-0123456789."}) - ui.white_text(2, 0, "Y") - coordinate_y = ui.text_input(coordinate_y, field_width, 18, - "teleport.coordinate_y", {allowed_characters="-0123456789."}) - GuiLayoutEnd(ui.gui()) - else - GuiLayoutBeginHorizontal(ui.gui(), 0, 1, true) - ui.white_text(0, 0, "X") - coordinate_x = ui.text_input(coordinate_x, math.max(36, content_width - 12), 18, - "teleport.coordinate_x", {allowed_characters="-0123456789."}) - GuiLayoutEnd(ui.gui()) - GuiLayoutBeginHorizontal(ui.gui(), 0, 1, true) - ui.white_text(0, 0, "Y") - coordinate_y = ui.text_input(coordinate_y, math.max(36, content_width - 12), 18, - "teleport.coordinate_y", {allowed_characters="-0123456789."}) - GuiLayoutEnd(ui.gui()) - end - if ui.button(0, 1, ui.tr("$mcm_teleport_coordinates_go", "TELEPORT"), false, - ui.tr("$mcm_teleport_coordinates", "COORDINATES"), - ui.tr("$mcm_teleport_coordinates_go_hint", "Teleport to the entered X/Y position")) then - local x, y = tonumber(coordinate_x), tonumber(coordinate_y) - if x ~= nil and y ~= nil then player_tools.teleport_position(player, x, y) end - end - - ui.colored_text(0, 6, ui.tr("$mcm_teleport_locations", "WORLD LOCATIONS"), {1.0,0.78,0.2,1.0}) - ui.wrapped_text(0, 0, ui.tr("$mcm_teleport_locations_hint", - "The destination is streamed first and adjusted to nearby free space."), content_width) - local location_buttons = {} - for _, location in ipairs(player_tools.locations()) do - location_buttons[#location_buttons + 1] = { - label=ui.tr(location.key, location.fallback), - location=location, - tooltip_title=ui.tr(location.key, location.fallback), - tooltip_description="(" .. tostring(math.floor(location.x)) .. ", " - .. tostring(math.floor(location.y)) .. ")", - } - end - local clicked = ui.button_grid(location_buttons, content_width) - if clicked ~= nil and location_buttons[clicked] ~= nil then - player_tools.teleport_location(location_buttons[clicked].location.id, player) - end - - ui.end_scroll_viewport(scroll) - GuiLayoutEnd(ui.gui()) -end - -METAMORPH_CREATIVE_MENU_PLAYERS_TAB = tab -return tab diff --git a/metamorph_creative_menu/files/ui/tabs/spells.lua b/metamorph_creative_menu/files/ui/tabs/spells.lua index 100d430..11d56e6 100644 --- a/metamorph_creative_menu/files/ui/tabs/spells.lua +++ b/metamorph_creative_menu/files/ui/tabs/spells.lua @@ -1,24 +1,13 @@ local spells_tab = {} local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") -local audit = ui.audit local spell_service = dofile("mods/metamorph_creative_menu/files/features/spells/service.lua") local spell_catalog = dofile("mods/metamorph_creative_menu/files/features/spells/catalog.lua") -local horizontal_strip = dofile("mods/metamorph_creative_menu/files/ui/widgets/horizontal_strip.lua") -local wand_editor = dofile("mods/metamorph_creative_menu/files/ui/components/wand_editor.lua") -local wand_presets = dofile("mods/metamorph_creative_menu/files/ui/components/wand_presets.lua") -local drag_drop = dofile("mods/metamorph_creative_menu/files/ui/drag_drop.lua") -local wand_history = dofile("mods/metamorph_creative_menu/files/features/wands/history.lua") -local spell_inventory_ui = dofile("mods/metamorph_creative_menu/files/ui/components/spell_inventory_strip.lua") -local spell_inventory_service = dofile("mods/metamorph_creative_menu/files/features/spells/inventory_service.lua") -local permanent_service = dofile("mods/metamorph_creative_menu/files/features/spells/permanent_service.lua") -local always_cast_ui = dofile("mods/metamorph_creative_menu/files/ui/components/wand_always_cast_strip.lua") -local inventory_slots = dofile("mods/metamorph_creative_menu/files/platform/noita/inventory_slots.lua") local action_by_id, filters = nil, nil local selected_filter = 1 local selected_slot = 0 -local workspace = "catalog" +local slot_page = 0 local tracked_wand = 0 local search = "" @@ -64,6 +53,7 @@ local function action_presentation(action) return name, description end + local function load_actions() if action_by_id ~= nil then return true end if not spell_catalog.load() then return false end @@ -83,10 +73,13 @@ local function actions_for_search(index, query) if key == search_result_cache_key and search_result_cache_value ~= nil then return search_result_cache_value end - local result = ui.rank_entries(query, actions_for_filter(index), function(action) + local result = {} + for _, action in ipairs(actions_for_filter(index)) do local name, description = action_presentation(action) - return {action.name, action.description, name, action.id, description} - end, function(action) return action.id end) + if ui.matches_search(query, name, action.id, description) then + result[#result + 1] = action + end + end search_result_cache_key = key search_result_cache_value = result return result @@ -100,64 +93,6 @@ local function icon(action) return type(action) == "table" and type(action.sprite) == "string" and action.sprite ~= "" and action.sprite or ui.EMPTY_SLOT end -local function drag_payload_for_action(action) - return { - kind="catalog_spell", action_id=action.id, - icon=icon(action), background=background(action), - } -end - -local function drag_bounds(x, y, width, height) - if x == nil or y == nil or width == nil or height == nil then return nil end - return {x=x, y=y, width=width, height=height} -end - -local function point_inside(bounds, x, y) - if type(bounds) ~= "table" or tonumber(x) == nil or tonumber(y) == nil then return false end - local bx, by = tonumber(bounds.x), tonumber(bounds.y) - local bw, bh = tonumber(bounds.width), tonumber(bounds.height) - return bx ~= nil and by ~= nil and bw ~= nil and bh ~= nil - and x >= bx and x <= bx + bw and y >= by and y <= by + bh -end - -local function record_result(scope, ok, reason) - audit(scope, "result=" .. tostring(ok) .. " reason=" .. tostring(reason or "")) - return ok, reason -end - - -local function mutate_wand(player, wand, label, callback, options) - return wand_history.perform(player, wand, label, callback, options) -end - -local function source_entry_for_payload(payload, slots, entries) - if type(payload) ~= "table" or payload.kind ~= "wand_spell" then return nil end - local direct = slots[tonumber(payload.slot)] - if direct ~= nil and tonumber(direct.entity) == tonumber(payload.entity) then return direct end - for _, entry in ipairs(entries or {}) do - if tonumber(entry.entity) == tonumber(payload.entity) then return entry end - end - return nil -end - -local function permanent_entry_for_payload(payload, permanent_entries) - if type(payload) ~= "table" or payload.kind ~= "wand_permanent_spell" then return nil end - for _, entry in ipairs(permanent_entries or {}) do - if tonumber(entry.entity) == tonumber(payload.entity) then return entry end - end - return nil -end - -local function inventory_entry_for_payload(payload, layout) - if type(payload) ~= "table" or payload.kind ~= "inventory_spell" or type(layout) ~= "table" then return nil end - local direct = layout.by_index and layout.by_index[tonumber(payload.index)] or nil - if direct ~= nil and tonumber(direct.entity) == tonumber(payload.entity) then return direct end - for _, entry in ipairs(layout.entries or {}) do - if tonumber(entry.entity) == tonumber(payload.entity) then return entry end - end - return nil -end - function spells_tab.draw(player, panel_width, screen_height) if not load_actions() then ui.white_text(0, 2, ui.tr("$mcm_spells_failed", "Spell list failed to load")); return @@ -166,410 +101,76 @@ function spells_tab.draw(player, panel_width, screen_height) local wand = spell_service.held_wand(player) if wand == 0 then ui.white_text(0, 2, ui.tr("$mcm_hold_wand", "Hold a wand")); return end - local screen_width, actual_screen_height = GuiGetScreenDimensions(ui.gui()) - local workspace_content_width = math.max(ui.ICON_STEP, panel_width - 16) - local slots, highest, permanent, entries, permanent_entries = spell_service.contents(wand) + local columns = ui.columns(panel_width) + local slots, highest, permanent, entries = spell_service.contents(wand) local cap = spell_service.capacity(wand, highest, permanent) - if tracked_wand ~= wand then - tracked_wand, selected_slot = wand, 0 - horizontal_strip.reset("spells.wand_slots") - horizontal_strip.reset("spells.inventory_slots") - horizontal_strip.reset("spells.always_cast") - end + if tracked_wand ~= wand then tracked_wand, selected_slot, slot_page = wand, 0, 0 end selected_slot = math.max(0, math.min(selected_slot, cap - 1)) + local pages = math.max(1, math.ceil(cap / columns)) + slot_page = math.max(0, math.min(slot_page, pages - 1)) - -- A short catalog click keeps the fast "click -> selected slot" workflow. A real - -- drag is resolved by where the press is released: native spell inventory if the - -- pointer is over it, otherwise the game world when released outside this menu. - local previous_drag = drag_drop.take_result() - if previous_drag ~= nil and previous_drag.click == true and type(previous_drag.payload) == "table" - and previous_drag.payload.kind == "catalog_spell" - then - local ok, reason = mutate_wand(player, wand, "replace spell", function() - return spell_service.replace(player, wand, selected_slot, - previous_drag.payload.action_id, slots[selected_slot], entries) - end) - record_result("spell.replace", ok, reason) - slots, highest, permanent, entries, permanent_entries = spell_service.contents(wand) - cap = spell_service.capacity(wand, highest, permanent) - selected_slot = math.max(0, math.min(selected_slot, cap - 1)) - elseif previous_drag ~= nil and previous_drag.click ~= true and previous_drag.target == nil - and type(previous_drag.payload) == "table" - then - local release_x, release_y = tonumber(previous_drag.release_x), tonumber(previous_drag.release_y) - local menu_bounds = type(ui.panel_bounds) == "function" and ui.panel_bounds() or nil - -- Missing a target inside the creative menu means "cancel", not "spawn a card". - -- Only crossing the actual menu boundary turns the gesture into a world/native-UI drop. - if release_x ~= nil and release_y ~= nil and not point_inside(menu_bounds, release_x, release_y) then - local payload = previous_drag.payload - local native_inventory = inventory_slots.native_drop_bounds(player, "inventory_full", screen_width, actual_screen_height) - local over_native_inventory = point_inside(native_inventory, release_x, release_y) - local ok, reason - - if over_native_inventory then - if payload.kind == "catalog_spell" then - ok, reason = spell_service.give(player, payload.action_id) - elseif payload.kind == "inventory_spell" then - ok, reason = true, "already_in_inventory" - elseif payload.kind == "wand_permanent_spell" then - local source_entry = permanent_entry_for_payload(payload, permanent_entries) - if source_entry == nil then ok, reason = false, "source_missing" - else ok, reason = permanent_service.move_to_inventory(player, wand, source_entry) end - elseif payload.kind == "wand_spell" then - local source_entry = source_entry_for_payload(payload, slots, entries) - if source_entry == nil then ok, reason = false, "source_missing" - else ok, reason = spell_service.move_to_inventory(player, wand, source_entry, entries) end - end - if ok ~= nil then record_result("spell.drag.native_inventory", ok, reason) end - else - local target_x, target_y = tonumber(previous_drag.world_x), tonumber(previous_drag.world_y) - if payload.kind == "catalog_spell" then - ok, reason = spell_service.throw_catalog(player, payload.action_id, target_x, target_y) - elseif payload.kind == "inventory_spell" then - local layout = select(1, spell_inventory_service.contents(player)) - local source_entry = inventory_entry_for_payload(payload, layout) - if source_entry == nil then - ok, reason = false, "source_missing" - else - ok, reason = spell_inventory_service.drop_to_world(player, source_entry, target_x, target_y) - end - elseif payload.kind == "wand_permanent_spell" then - local source_entry = permanent_entry_for_payload(payload, permanent_entries) - if source_entry == nil then - ok, reason = false, "source_missing" - else - ok, reason = permanent_service.drop_to_world(player, wand, source_entry, target_x, target_y) - end - elseif payload.kind == "wand_spell" then - local source_entry = source_entry_for_payload(payload, slots, entries) - if source_entry == nil then - ok, reason = false, "source_missing" - else - ok, reason = spell_service.drop_to_world(player, wand, source_entry, entries, target_x, target_y) - end - end - if ok ~= nil then record_result("spell.drag.world", ok, reason) end - end - - if ok and (payload.kind == "wand_spell" or payload.kind == "wand_permanent_spell") then - slots, highest, permanent, entries, permanent_entries = spell_service.contents(wand) - cap = spell_service.capacity(wand, highest, permanent) - selected_slot = math.max(0, math.min(selected_slot, cap - 1)) - end - end - end - - local inventory_layout = nil - local workspace_drag_clip = nil - - local function draw_trash_zone() - local _, _, _, x, y, width, height = ui.tile(0, 0, - "data/ui_gfx/inventory/full_inventory_box.png", - "data/ui_gfx/cross_red.png", ui.EMPTY_SLOT, - ui.tr("$mcm_delete", "DELETE"), "", false, - {target_size=14, max_scale=2.0, padding=2}) - local bounds = drag_bounds(x, y, width, height) - if bounds == nil then return end - drag_drop.target("spells.zone.trash", bounds, - function(payload) - return type(payload) == "table" and (payload.kind == "catalog_spell" - or payload.kind == "wand_spell" or payload.kind == "wand_permanent_spell" - or payload.kind == "inventory_spell") - end, - function(payload) - if payload.kind == "catalog_spell" then - audit("spell.drag.discard_template", "action=" .. tostring(payload.action_id or "")) - return true, "discarded_template" - elseif payload.kind == "inventory_spell" then - local layout = inventory_layout or select(1, spell_inventory_service.contents(player)) - local source_entry = inventory_entry_for_payload(payload, layout) - if source_entry == nil then return false, "source_missing" end - local ok, reason = spell_inventory_service.remove(player, source_entry, false) - return record_result("spell.drag.inventory_delete", ok, reason) - elseif payload.kind == "wand_permanent_spell" then - local source_entry = permanent_entry_for_payload(payload, permanent_entries) - if source_entry == nil then return false, "source_missing" end - local ok, reason = mutate_wand(player, wand, "delete always cast", function() - return permanent_service.remove(player, wand, source_entry, false) - end) - return record_result("spell.drag.always_delete", ok, reason) - end - local source_entry = source_entry_for_payload(payload, slots, entries) - if source_entry == nil then return false, "source_missing" end - local ok, reason = mutate_wand(player, wand, "delete spell", function() - return spell_service.remove(player, wand, source_entry, entries, false) - end) - return record_result("spell.drag.delete", ok, reason) - end, 200) - end - - local function draw_slot_strip() - GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true) - ui.white_text(0, 1, ui.tr("$mcm_spell_wand_slots", "WAND SLOTS")) - ui.white_text(0, 1, " " .. ui.tr("$mcm_slot", "SLOT") .. " " .. tostring(selected_slot + 1) .. "/" .. tostring(cap)) - GuiLayoutAddHorizontalSpacing(ui.gui(), 4) - draw_trash_zone() - GuiLayoutEnd(ui.gui()) - - local strip = horizontal_strip.draw("spells.wand_slots", cap, math.max(ui.ICON_STEP, workspace_content_width - 8), ui.ICON_STEP, function(slot) - local entry = slots[slot] - local action = entry and action_by_id[entry.action_id] or nil - local slot_name - if entry == nil then - slot_name = ui.tr("$mcm_empty", "Empty") - elseif action ~= nil then - slot_name = select(1, action_presentation(action)) - else - slot_name = ui.tr("$mcm_spell_unknown", "Unknown/modded spell") .. " [" .. tostring(entry.action_id or "?") .. "]" - end - local clicked, right, hovered, x, y, width, height = ui.tile(0, 0, background(action), icon(action), ui.EMPTY_SLOT, - ui.tr("$mcm_slot", "SLOT") .. " " .. tostring(slot + 1), slot_name, slot == selected_slot, - { target_size = 18, max_scale = 2.0 }) - local slot_bounds = drag_bounds(x, y, width, height) - if entry ~= nil and slot_bounds ~= nil then - drag_drop.source("spells.slot." .. tostring(entry.entity), { - kind="wand_spell", slot=slot, entity=entry.entity, action_id=entry.action_id, - icon=icon(action), background=background(action), - }, slot_bounds, workspace_drag_clip) - end - if slot_bounds ~= nil then - drag_drop.target("spells.slot_target." .. tostring(slot), slot_bounds, - function(payload) - return type(payload) == "table" and (payload.kind == "catalog_spell" or payload.kind == "wand_spell" - or payload.kind == "wand_permanent_spell" or payload.kind == "inventory_spell") - end, - function(payload) - local ok, reason - if payload.kind == "catalog_spell" then - ok, reason = mutate_wand(player, wand, "replace spell", function() - return spell_service.replace(player, wand, slot, payload.action_id, slots[slot], entries) - end) - elseif payload.kind == "inventory_spell" then - local source_entry = inventory_entry_for_payload(payload, inventory_layout) - if source_entry == nil then return false, "source_missing" end - ok, reason = spell_service.adopt_inventory(player, wand, source_entry, slot, slots[slot], entries) - elseif payload.kind == "wand_permanent_spell" then - local source_entry = permanent_entry_for_payload(payload, permanent_entries) - if source_entry == nil then return false, "source_missing" end - if slots[slot] ~= nil then - ok, reason = mutate_wand(player, wand, "swap always cast", function() - return permanent_service.swap_with_slot(player, wand, source_entry, slots[slot]) - end) - else - ok, reason = mutate_wand(player, wand, "demote always cast", function() - return permanent_service.demote(player, wand, source_entry, slot) - end) - end - else - local source_entry = source_entry_for_payload(payload, slots, entries) - if source_entry == nil then return false, "source_missing" end - ok, reason = mutate_wand(player, wand, "move spell", function() - return spell_service.move(player, wand, source_entry, slot, slots[slot], entries) - end) - end - if ok then selected_slot = slot end - return record_result("spell.drag.slot", ok, reason) - end, 100) - end - return clicked, right, hovered, x, y, width, height - end, { - gui=ui.gui(), screen_width=screen_width, screen_height=screen_height, next_id=ui.next_id, - }) - if strip.clicked ~= nil then selected_slot = strip.clicked end - if strip.right_clicked ~= nil and slots[strip.right_clicked] ~= nil then - selected_slot = strip.right_clicked - local ok, reason = spell_service.remove(player, wand, slots[selected_slot], entries, true) - record_result("spell.drop", ok, reason) - end + GuiLayoutBeginVertical(ui.gui(), 0, 2, true) + GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true) + ui.white_text(0, 0, ui.tr("$mcm_slot", "SLOT") .. " " .. tostring(selected_slot + 1)) + if pages > 1 then + if ui.button(0, 0, " < ") then slot_page = (slot_page - 1 + pages) % pages end + ui.white_text(0, 0, tostring(slot_page + 1) .. "/" .. tostring(pages)) + if ui.button(0, 0, " > ") then slot_page = (slot_page + 1) % pages end end - - local function draw_always_cast() - always_cast_ui.draw(permanent_entries, workspace_content_width, screen_width, screen_height, action_presentation, { - action_by_id=action_by_id, background=background, icon=icon, - on_add_click=function() - local entry = slots[selected_slot] - if entry == nil then return record_result("spell.always.promote", false, "empty_slot") end - local ok, reason = mutate_wand(player, wand, "promote always cast", function() - return permanent_service.promote(player, wand, entry) - end) - return record_result("spell.always.promote", ok, reason) - end, - on_add_drop=function(payload) - local ok, reason - if payload.kind == "catalog_spell" then - ok, reason = mutate_wand(player, wand, "add always cast", function() - return permanent_service.add(player, wand, payload.action_id) - end) - elseif payload.kind == "wand_spell" then - local source_entry = source_entry_for_payload(payload, slots, entries) - if source_entry == nil then return false, "source_missing" end - ok, reason = mutate_wand(player, wand, "promote always cast", function() - return permanent_service.promote(player, wand, source_entry) - end) - elseif payload.kind == "inventory_spell" then - local source_entry = inventory_entry_for_payload(payload, inventory_layout) - if source_entry == nil then return false, "source_missing" end - ok, reason = permanent_service.adopt_inventory(player, wand, source_entry) - else - return false, "unsupported_source" - end - return record_result("spell.drag.always", ok, reason) - end, - on_replace_drop=function(permanent_entry, payload) - local ok, reason - if payload.kind == "catalog_spell" then - ok, reason = mutate_wand(player, wand, "replace always cast", function() - return permanent_service.replace(player, wand, permanent_entry, payload.action_id) - end) - elseif payload.kind == "wand_spell" then - local source_entry = source_entry_for_payload(payload, slots, entries) - if source_entry == nil then return false, "source_missing" end - ok, reason = mutate_wand(player, wand, "swap always cast", function() - return permanent_service.swap_with_slot(player, wand, permanent_entry, source_entry) - end) - elseif payload.kind == "inventory_spell" then - local source_entry = inventory_entry_for_payload(payload, inventory_layout) - if source_entry == nil then return false, "source_missing" end - ok, reason = permanent_service.swap_with_inventory(player, wand, permanent_entry, source_entry) - else - return false, "unsupported_source" - end - return record_result("spell.drag.always_replace", ok, reason) - end, - on_right_click=function(entry) - local ok, reason = permanent_service.remove(player, wand, entry, true) - record_result("spell.always.drop", ok, reason) - end, - clip_bounds=workspace_drag_clip, - }) + if ui.button(0, 0, ui.tr("$mcm_delete", "DELETE")) and slots[selected_slot] ~= nil then + local ok = spell_service.remove(player, wand, slots[selected_slot], entries, false) end - - local function draw_inventory() - inventory_layout = select(1, spell_inventory_ui.draw(player, workspace_content_width, screen_width, screen_height, action_presentation, { - action_by_id=action_by_id, background=background, icon=icon, - on_drop=function(target_index, target_entry, payload, layout) - local ok, reason - if payload.kind == "catalog_spell" then - ok, reason = spell_inventory_service.create_at(player, payload.action_id, target_index) - elseif payload.kind == "inventory_spell" then - local source_entry = inventory_entry_for_payload(payload, layout) - if source_entry == nil then return false, "source_missing" end - ok, reason = spell_inventory_service.move(player, source_entry, target_index) - elseif payload.kind == "wand_permanent_spell" then - local source_entry = permanent_entry_for_payload(payload, permanent_entries) - if source_entry == nil then return false, "source_missing" end - if target_entry ~= nil then - ok, reason = permanent_service.swap_with_inventory(player, wand, source_entry, target_entry) - else - local x = target_index % layout.width - local y = math.floor(target_index / layout.width) - ok, reason = permanent_service.export_to_inventory_slot(player, wand, source_entry, x, y) - end - else - local source_entry = source_entry_for_payload(payload, slots, entries) - if source_entry == nil then return false, "source_missing" end - if target_entry ~= nil then - ok, reason = spell_service.adopt_inventory(player, wand, target_entry, source_entry.slot, source_entry, entries) - else - local x = target_index % layout.width - local y = math.floor(target_index / layout.width) - ok, reason = spell_service.export_to_inventory_slot(player, wand, source_entry, entries, x, y) - end - end - return record_result("spell.drag.inventory_slot", ok, reason) - end, - on_right_click=function(entry) - local ok, reason = spell_inventory_service.remove(player, entry, true) - record_result("spell.inventory.drop", ok, reason) - end, - clip_bounds=workspace_drag_clip, - })) + if ui.button(0, 0, ui.tr("$mcm_drop", "DROP")) and slots[selected_slot] ~= nil then + local ok = spell_service.remove(player, wand, slots[selected_slot], entries, true) end + GuiLayoutEnd(ui.gui()) - local function draw_catalog() - ui.wrapped_text(0, 0, ui.tr("$mcm_spell_controls_hint", - "LMB spell: replace selected slot. Drag: slot / Always Cast / inventory."), - math.max(96, workspace_content_width - 8)) - draw_slot_strip() - draw_always_cast() - draw_inventory() - - local filter_buttons = {} - for index, f in ipairs(filters) do - filter_buttons[index] = {label=ui.tr(f.key, f.fallback),selected=selected_filter == index} - end - local clicked_filter = ui.button_grid(filter_buttons, math.max(72, workspace_content_width - 8)) - if clicked_filter ~= nil then selected_filter = clicked_filter end - search = ui.search_input(search, math.max(88, workspace_content_width - 44), 64, "spells") - local visible_actions = actions_for_search(selected_filter, search) - ui.search_status(search, #visible_actions) - if #visible_actions == 0 then - if search == "" then ui.white_text(2, 2, ui.tr("$mcm_no_results", "No results")) end - return - end - - local columns = ui.columns(workspace_content_width, ui.ICON_STEP, {reserve_scrollbar=false}) - local cursor = 1 - while cursor <= #visible_actions do - GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true) - for _ = 1, columns do - local action = visible_actions[cursor] - if action == nil then break end - local name, description = action_presentation(action) - local _, _, _, tile_x, tile_y, tile_w, tile_h = ui.tile(0, 0, background(action), icon(action), ui.EMPTY_SLOT, - name .. " [" .. action.id .. "]", description, false, - { target_size = 18, max_scale = 2.0 }) - local tile_bounds = drag_bounds(tile_x, tile_y, tile_w, tile_h) - if tile_bounds ~= nil then - drag_drop.source("spells.catalog." .. tostring(action.id), drag_payload_for_action(action), - tile_bounds, workspace_drag_clip) - end - cursor = cursor + 1 - end - GuiLayoutEnd(ui.gui()) + GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true) + local first = slot_page * columns + for slot = first, math.min(cap - 1, first + columns - 1) do + local entry = slots[slot] + local action = entry and action_by_id[entry.action_id] or nil + local slot_name = action and select(1, action_presentation(action)) or ui.tr("$mcm_empty", "Empty") + local clicked, right = ui.tile(0, 0, background(action), icon(action), ui.EMPTY_SLOT, + ui.tr("$mcm_slot", "SLOT") .. " " .. tostring(slot + 1), + slot_name, slot == selected_slot, + { target_size = 18, max_scale = 2.0 }) + if clicked then selected_slot = slot elseif right and entry ~= nil then + selected_slot = slot + local ok = spell_service.remove(player, wand, entry, entries, true) end end + GuiLayoutEnd(ui.gui()) - local function draw_wand_tools() - wand_editor.draw(player, wand, workspace_content_width) - wand_presets.draw(player, wand, workspace_content_width) + for _, row in ipairs({ {1,2,3,4}, {5,6,7}, {8,9} }) do + GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true) + for _, index in ipairs(row) do + local f = filters[index] + if ui.button(0, 0, ui.tr(f.key, f.fallback), selected_filter == index) then selected_filter = index end + end + GuiLayoutEnd(ui.gui()) end + search = ui.search_input(search, math.max(88, panel_width - 54), 64, "spells") - GuiLayoutBeginVertical(ui.gui(), 0, 2, true) - local workspace_items = { - {label=ui.tr("$mcm_spell_workspace_catalog", "CATALOG"), selected=workspace == "catalog"}, - {label=ui.tr("$mcm_spell_workspace_wand", "WAND"), selected=workspace == "wand"}, - } - local workspace_clicked = ui.button_grid(workspace_items, panel_width - 10) - if workspace_clicked == 1 then workspace = "catalog" - elseif workspace_clicked == 2 then workspace = "wand" end - - -- The entire active workspace lives inside one bounded vertical viewport. This is the - -- key containment rule for the spells tab: no combination of translated labels, - -- expanded wand tools or inventory rows is allowed to grow the menu beyond its panel. - local workspace_height = ui.scroll_height(screen_height, 26, 8) - workspace_height = math.max(28, math.min(workspace_height, math.max(28, screen_height - 22))) - local workspace_id = workspace == "catalog" and (8300 + selected_filter) or 8402 - local workspace_scroll = ui.begin_scroll_viewport("spells.workspace." .. workspace, workspace_id, - 0, 0, panel_width - 4, workspace_height) - workspace_content_width = workspace_scroll.content_width - workspace_drag_clip = { - x=workspace_scroll.x, y=workspace_scroll.y, - width=math.max(1, workspace_scroll.width - workspace_scroll.scrollbar_width), - height=workspace_scroll.height, - } - - if workspace == "wand" then draw_wand_tools() - else draw_catalog() end - - ui.end_scroll_viewport(workspace_scroll) + local grid_h = ui.grid_height(screen_height, 168, 174) + GuiBeginScrollContainer(ui.gui(), 8200 + selected_filter, 0, 0, panel_width - 4, grid_h, false, 1, 1) + local _, _, hov = GuiGetPreviousWidgetInfo(ui.gui()); ui.mark_hovered(hov) + local picked, visible = nil, 0 + for _, action in ipairs(actions_for_search(selected_filter, search)) do + local name, description = action_presentation(action) + local i = visible; visible = visible + 1 + local clicked = ui.tile((i % columns) * ui.ICON_STEP, math.floor(i / columns) * ui.ICON_STEP, + background(action), icon(action), ui.EMPTY_SLOT, + name .. " [" .. action.id .. "]", description, false, + { target_size = 18, max_scale = 2.0 }) + if clicked then picked = action end + end + GuiEndScrollContainer(ui.gui()) + GuiLayoutEnd(ui.gui()) - if drag_drop.active() then - local payload = drag_drop.payload() - local mouse_x, mouse_y = drag_drop.mouse_position() - if type(payload) == "table" then ui.drag_ghost(payload.background, payload.icon, mouse_x, mouse_y) end + if picked ~= nil then + local ok = spell_service.replace(player, wand, selected_slot, picked.id, slots[selected_slot], entries) end - GuiLayoutEnd(ui.gui()) end return spells_tab diff --git a/metamorph_creative_menu/files/ui/tabs/weather.lua b/metamorph_creative_menu/files/ui/tabs/weather.lua index 4ce6a64..a357f48 100644 --- a/metamorph_creative_menu/files/ui/tabs/weather.lua +++ b/metamorph_creative_menu/files/ui/tabs/weather.lua @@ -1,91 +1,59 @@ local weather_tab = {} local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") -local audit = ui.audit local weather_service = dofile("mods/metamorph_creative_menu/files/features/weather/service.lua") local advanced=false local function time(name) - local ok,reason=weather_service.set_time_preset(name) - audit("weather.time", "preset="..tostring(name).." result="..tostring(ok).." reason="..tostring(reason)) + weather_service.set_time_preset(name) end local function preset(name) - local ok,reason=weather_service.apply_preset(name) - local detail="" - if type(weather_service.debug_state)=="function" then - local good,state=pcall(weather_service.debug_state) - if good and type(state)=="table" then - detail=" rainfall="..tostring(state.rainfall).." rain="..tostring(state.rain).." rain_target="..tostring(state.rain_target) - .." last_emit="..tostring(state.last_rain_emit_frame).." stop_guard_until="..tostring(state.rain_stop_guard_until) - end - end - audit("weather.preset", "preset="..tostring(name).." result="..tostring(ok).." reason="..tostring(reason)..detail) + weather_service.apply_preset(name) end local function tile(icon,key,fallback,fn) - local clicked=ui.tile(0,0,ui.EMPTY_SLOT,icon,ui.EMPTY_SLOT,ui.tr(key,fallback),nil,false,{target_size=18,max_scale=2.5}) + local clicked=ui.tile(0,0,ui.EMPTY_SLOT,icon,ui.EMPTY_SLOT,ui.tr(key,fallback),"",false,{target_size=18,max_scale=2.5}) if clicked then fn() end end -local function tile_group(items,panel_width,y) - local columns=math.max(1,ui.columns(math.max(ui.ICON_STEP,(tonumber(panel_width) or 220)-10),ui.ICON_STEP,{reserve_scrollbar=false})) - local cursor=1 - while cursor<=#items do - GuiLayoutBeginHorizontal(ui.gui(),0,y or 0,true) - for _=1,columns do - local item=items[cursor]; if item==nil then break end - tile(item[1],item[2],item[3],item[4]); cursor=cursor+1 - end - GuiLayoutEnd(ui.gui()) - y=0 - end -end function weather_tab.draw(_, panel_width, screen_height) local can_edit,mode=weather_service.can_edit() GuiLayoutBeginVertical(ui.gui(),0,2,true) - local mode_label=mode=="ew_host" and ui.tr("$mcm_weather_mode_ew_host","EW HOST") - or (mode=="ew_peer" and ui.tr("$mcm_weather_mode_ew_client","EW CLIENT") - or ui.tr("$mcm_weather_mode_local","LOCAL")) - ui.wrapped_text(0,0,mode_label..(weather_service.is_locked() and (" • "..ui.tr("$mcm_weather_locked","LOCKED")) or ""),math.max(24,panel_width-10)) if not can_edit then ui.white_text(0,0,ui.tr("$mcm_weather_unavailable","Weather editing unavailable")) elseif not advanced then ui.white_text(0,0,ui.tr("$mcm_weather_time_presets","TIME OF DAY")) - tile_group({ - {"data/ui_gfx/items/sunseed.png","$mcm_weather_morning","MORNING",function() time("morning") end}, - {"data/ui_gfx/items/sunseed_2.png","$mcm_weather_day","DAY",function() time("day") end}, - {"data/ui_gfx/items/moon.png","$mcm_weather_evening","EVENING",function() time("evening") end}, - {"data/ui_gfx/items/moon.png","$mcm_weather_night","NIGHT",function() time("night") end}, - },panel_width,0) - tile_group({ - {"data/ui_gfx/items/waterstone.png","$mcm_weather_clear","CLEAR",function() preset("clear") end}, - {"data/ui_gfx/items/material_pouch.png","$mcm_weather_cloudy","CLOUDY",function() preset("cloudy") end}, - {"data/ui_gfx/items/evil_eye.png","$mcm_weather_foggy","FOGGY",function() preset("foggy") end}, - {"data/ui_gfx/items/brimstone.png","$mcm_weather_storm","STORM",function() preset("storm") end}, - },panel_width,2) - local actions={{label=ui.tr("$mcm_weather_advanced","ADVANCED")}} - if weather_service.is_locked() then actions[#actions+1]={label=ui.tr("$mcm_weather_release","RELEASE")} end - local clicked=ui.button_grid(actions,math.max(32,panel_width-10)) - if clicked==1 then advanced=true - elseif clicked==2 and weather_service.is_locked() then local ok=weather_service.release(); audit("weather.release", "result="..tostring(ok)) end + GuiLayoutBeginHorizontal(ui.gui(),0,0,true) + tile("data/ui_gfx/items/sunseed.png","$mcm_weather_morning","MORNING",function() time("morning") end) + tile("data/ui_gfx/items/sunseed_2.png","$mcm_weather_day","DAY",function() time("day") end) + tile("data/ui_gfx/items/moon.png","$mcm_weather_evening","EVENING",function() time("evening") end) + tile("data/ui_gfx/items/moon.png","$mcm_weather_night","NIGHT",function() time("night") end) + GuiLayoutEnd(ui.gui()) + ui.white_text(0,2,ui.tr("$mcm_weather_presets","WEATHER")) + GuiLayoutBeginHorizontal(ui.gui(),0,0,true) + tile("data/ui_gfx/items/waterstone.png","$mcm_weather_clear","CLEAR",function() preset("clear") end) + tile("data/ui_gfx/items/material_pouch.png","$mcm_weather_cloudy","CLOUDY",function() preset("cloudy") end) + tile("data/ui_gfx/items/evil_eye.png","$mcm_weather_foggy","FOGGY",function() preset("foggy") end) + tile("data/ui_gfx/items/brimstone.png","$mcm_weather_storm","STORM",function() preset("storm") end) + GuiLayoutEnd(ui.gui()) + GuiLayoutBeginHorizontal(ui.gui(),0,3,true) + if ui.button(0,0,ui.tr("$mcm_weather_advanced","ADVANCED")) then advanced=true end + if weather_service.is_locked() and ui.button(0,0,ui.tr("$mcm_weather_release","RELEASE")) then weather_service.release() end + GuiLayoutEnd(ui.gui()) else - local actions={{label=ui.tr("$mcm_weather_back","BACK")}} - if weather_service.is_locked() then actions[#actions+1]={label=ui.tr("$mcm_weather_release","RELEASE")} end - local clicked=ui.button_grid(actions,math.max(32,panel_width-10)) - if clicked==1 then advanced=false - elseif clicked==2 and weather_service.is_locked() then local ok=weather_service.release(); audit("weather.release", "result="..tostring(ok)) end - local scroll_height=ui.scroll_height(screen_height,72) - local scroll=ui.begin_scroll_viewport("weather.advanced",12100,0,0,panel_width-4,scroll_height) + GuiLayoutBeginHorizontal(ui.gui(),0,0,true) + if ui.button(0,0,ui.tr("$mcm_weather_back","BACK")) then advanced=false end + if weather_service.is_locked() and ui.button(0,0,ui.tr("$mcm_weather_release","RELEASE")) then weather_service.release() end + GuiLayoutEnd(ui.gui()) for _,field in ipairs(weather_service.fields()) do local value=weather_service.get(field) if type(value)=="number" then - local formatted=string.format("%."..tostring(tonumber(field.decimals) or 2).."f",value) - local delta=ui.stepper(ui.tr(field.label,field.id),formatted,{ - decrease_enabled=field.wrap==true or field.min==nil or value>field.min, - increase_enabled=field.wrap==true or field.max==nil or value unpaged_visible_count - local arrow_reserve = paged and 22 or 0 - local visible_count = math.max(1, math.floor(math.max(step, viewport_width - arrow_reserve) / step)) - local maximum_offset = math.max(0, count - visible_count) - local state = state_for(key) - state.offset = math.max(0, math.min(math.floor(tonumber(state.offset) or 0), maximum_offset)) - - local clicked_index, right_index = nil, nil - local first_x, first_y, last_x, last_y = nil, nil, nil, nil - local first = state.offset - local last = math.min(count - 1, first + visible_count - 1) - - GuiLayoutBeginHorizontal(options.gui, 0, 0, true, 0, 0) - if paged then - -- A single tall, thick image-arrow is easier to see than text chevrons while - -- reserving less horizontal room for navigation. - GuiColorSetForNextWidget(options.gui, 1, 1, 1, 1) - GuiZSetForNextWidget(options.gui, -112) - local previous = select(1, GuiImageButton(options.gui, next_id(options), 0, 0, "", ARROW_LEFT)) - if previous then - state.offset = math.max(0, state.offset - visible_count) - first = state.offset - last = math.min(count - 1, first + visible_count - 1) - end - end - for index = first, last do - local clicked, right, _, x, y, width, height = draw_item(index) - x, y, width, height = tonumber(x), tonumber(y), tonumber(width), tonumber(height) - if x ~= nil and y ~= nil and width ~= nil and height ~= nil then - first_x = first_x == nil and x or math.min(first_x, x) - first_y = first_y == nil and y or math.min(first_y, y) - last_x = last_x == nil and (x + width) or math.max(last_x, x + width) - last_y = last_y == nil and (y + height) or math.max(last_y, y + height) - end - if clicked == true then clicked_index = index end - if right == true then right_index = index end - end - if paged then - GuiColorSetForNextWidget(options.gui, 1, 1, 1, 1) - GuiZSetForNextWidget(options.gui, -112) - local following = select(1, GuiImageButton(options.gui, next_id(options), 0, 0, "", ARROW_RIGHT)) - if following then state.offset = math.min(maximum_offset, state.offset + visible_count) end - end - GuiLayoutEnd(options.gui) - - local hovered = false - local mouse_x, mouse_y = pointer.gui_position(options.screen_width, options.screen_height) - if first_x ~= nil then - hovered = pointer.inside(first_x, first_y, last_x - first_x, last_y - first_y, mouse_x, mouse_y) - end - - return { - clicked=clicked_index, - right_clicked=right_index, - offset=state.offset, - visible_count=visible_count, - first=first, - last=last, - total=count, - hovered=hovered, - bounds=first_x and {x=first_x,y=first_y,width=last_x-first_x,height=last_y-first_y} or nil, - } -end - -METAMORPH_CREATIVE_MENU_HORIZONTAL_STRIP = horizontal_strip -return horizontal_strip diff --git a/metamorph_creative_menu/files/ui/widgets/scroll_model.lua b/metamorph_creative_menu/files/ui/widgets/scroll_model.lua deleted file mode 100644 index 10d2ac9..0000000 --- a/metamorph_creative_menu/files/ui/widgets/scroll_model.lua +++ /dev/null @@ -1,94 +0,0 @@ -if type(METAMORPH_CREATIVE_MENU_SCROLL_MODEL) == "table" then return METAMORPH_CREATIVE_MENU_SCROLL_MODEL end - -local scroll_model = {} -local pointer = dofile("mods/metamorph_creative_menu/files/platform/noita/pointer.lua") - -scroll_model.VERTICAL_STEP = 20 -scroll_model.HORIZONTAL_STEP = 3 -scroll_model.SCROLLBAR_WIDTH = 8 -scroll_model.PADDING_X = 2 -scroll_model.PADDING_Y = 2 - -local wheel_delta = nil -local wheel_owner = nil -local wheel_delivered = false - -local function clamp(value, minimum, maximum) - value = tonumber(value) or 0 - minimum = tonumber(minimum) or 0 - maximum = math.max(minimum, tonumber(maximum) or minimum) - return math.max(minimum, math.min(maximum, value)) -end - -function scroll_model.begin_frame() - wheel_delta = nil - wheel_owner = nil - wheel_delivered = false -end - -local function current_wheel() - if wheel_delta == nil then wheel_delta = pointer.wheel_delta() end - return tonumber(wheel_delta) or 0 -end - --- Wheel ownership is deliberately first-claim within the rendered hierarchy. Nested --- horizontal strips and nested viewports finish before their parent viewport, so the --- child consumes the tick and the parent sees zero for the same physical event. -function scroll_model.consume_wheel(owner, hovered) - local delta = current_wheel() - if delta == 0 or hovered ~= true then return 0 end - owner = tostring(owner or "scroll") - if wheel_owner == nil then wheel_owner = owner end - if wheel_owner ~= owner or wheel_delivered then return 0 end - wheel_delivered = true - return delta -end - -function scroll_model.scroll_offset(offset, content_height, viewport_height, wheel, step) - viewport_height = math.max(1, tonumber(viewport_height) or 1) - content_height = math.max(0, tonumber(content_height) or 0) - local maximum = math.max(0, content_height - viewport_height) - local next_offset = clamp(offset, 0, maximum) - wheel = tonumber(wheel) or 0 - step = math.max(1, tonumber(step) or scroll_model.VERTICAL_STEP) - if wheel ~= 0 then next_offset = clamp(next_offset + wheel * step, 0, maximum) end - return next_offset, maximum -end - -function scroll_model.horizontal_offset(offset, count, visible_count, wheel, step) - count = math.max(0, math.floor(tonumber(count) or 0)) - visible_count = math.max(1, math.floor(tonumber(visible_count) or 1)) - local maximum = math.max(0, count - visible_count) - local next_offset = clamp(math.floor(tonumber(offset) or 0), 0, maximum) - wheel = tonumber(wheel) or 0 - step = math.max(1, math.floor(tonumber(step) or scroll_model.HORIZONTAL_STEP)) - if wheel ~= 0 then next_offset = clamp(next_offset + wheel * step, 0, maximum) end - return next_offset, maximum -end - -function scroll_model.grid_metrics(container_width, step, options) - options = type(options) == "table" and options or {} - container_width = math.max(1, tonumber(container_width) or 1) - step = math.max(1, tonumber(step) or 20) - local padding_left = math.max(0, tonumber(options.padding_left) or scroll_model.PADDING_X) - local padding_right = math.max(0, tonumber(options.padding_right) or scroll_model.PADDING_X) - local scrollbar_width = options.reserve_scrollbar == false and 0 - or math.max(0, tonumber(options.scrollbar_width) or scroll_model.SCROLLBAR_WIDTH) - local content_width = math.max(step, container_width - padding_left - padding_right - scrollbar_width) - local columns = math.max(1, math.floor(content_width / step)) - local grid_width = columns * step - return { - container_width=container_width, - content_width=content_width, - columns=columns, - grid_width=grid_width, - step=step, - padding_left=padding_left, - padding_right=padding_right, - scrollbar_width=scrollbar_width, - remainder=math.max(0, content_width - grid_width), - } -end - -METAMORPH_CREATIVE_MENU_SCROLL_MODEL = scroll_model -return scroll_model diff --git a/metamorph_creative_menu/init.lua b/metamorph_creative_menu/init.lua index 7058f1c..1367e22 100644 --- a/metamorph_creative_menu/init.lua +++ b/metamorph_creative_menu/init.lua @@ -1,253 +1,77 @@ dofile_once("data/scripts/lib/utilities.lua") -local dev_mode_value = dofile("mods/metamorph_creative_menu/dev_mode.lua") -local DEV_MODE = tonumber(dev_mode_value) == 1 -METAMORPH_CREATIVE_MENU_DEV_MODE = DEV_MODE - -- init.lua is intentionally only the lifecycle/composition root. Feature state and -- behavior live in services, form adapters and UI tab modules under files/. -local global_text = dofile("mods/metamorph_creative_menu/files/core/global_text.lua") local localization = dofile("mods/metamorph_creative_menu/files/platform/noita/localization.lua") local input_guard = dofile("mods/metamorph_creative_menu/files/platform/noita/input_guard.lua") -local action_bindings = dofile("mods/metamorph_creative_menu/files/platform/noita/action_bindings.lua") -local diagnostics = DEV_MODE and dofile("mods/metamorph_creative_menu/files/diagnostics/service.lua") or nil -local qa_controller = DEV_MODE and dofile("mods/metamorph_creative_menu/files/qa/controller.lua") or nil local form_manager = dofile("mods/metamorph_creative_menu/files/features/forms/manager.lua") local weather = dofile("mods/metamorph_creative_menu/files/features/weather/service.lua") local world_rules = dofile("mods/metamorph_creative_menu/files/features/world_rules/service.lua") local menu_controller = dofile("mods/metamorph_creative_menu/files/ui/menu_controller.lua") local keybinds = dofile("mods/metamorph_creative_menu/files/features/possession/keybinds.lua") local ew_resilience = dofile("mods/metamorph_creative_menu/files/integrations/ew/resilience.lua") -local ew_world_entities = dofile("mods/metamorph_creative_menu/files/integrations/ew/world_entities.lua") --- Load the legacy peer-local perk API for compatibility; its old mailbox has no producer --- and is intentionally not part of the per-frame lifecycle anymore. -dofile("mods/metamorph_creative_menu/files/integrations/ew/perk_sync.lua") +local ew_perk_sync = dofile("mods/metamorph_creative_menu/files/integrations/ew/perk_sync.lua") local perk_service = dofile("mods/metamorph_creative_menu/files/features/perks/service.lua") local effect_service = dofile("mods/metamorph_creative_menu/files/features/effects/service.lua") local player_avatar = dofile("mods/metamorph_creative_menu/files/features/companion/player_avatar.lua") -local material_painter = dofile("mods/metamorph_creative_menu/files/features/materials/painter.lua") -local player_tools = dofile("mods/metamorph_creative_menu/files/features/player_tools/service.lua") -local asset_service = dofile("mods/metamorph_creative_menu/files/platform/noita/assets.lua") -local item_catalog = dofile("mods/metamorph_creative_menu/files/features/items/ui_catalog.lua") -local spell_slot_settler = dofile("mods/metamorph_creative_menu/files/features/spells/slot_settler.lua") -local death_recovery = dofile("mods/metamorph_creative_menu/files/features/death_recovery/service.lua") local MOD_NAME = "Metamorph: Creative Menu" local EW_RULES_MODULE = "mods/metamorph_creative_menu/files/integrations/ew/bootstrap.lua" local PERK_PICKUP_HOOK = "mods/metamorph_creative_menu/files/features/perks/pickup_hook.lua" -local runtime_error_seen = {} -local RUNTIME_ERROR_REPEAT_FRAMES = 600 - -local function translated(key, fallback) - if type(localization.translate) == "function" then - local ok, value = pcall(localization.translate, key, fallback) - if ok and type(value) == "string" and value ~= "" then return value end - end - if type(GameTextGetTranslatedOrNot) == "function" then - local ok, value = pcall(GameTextGetTranslatedOrNot, key) - if ok and type(value) == "string" and value ~= "" and value ~= key then return value end - end - return fallback -end - -local function report_runtime_error(scope, failure) - scope = tostring(scope or "unknown") - failure = tostring(failure) - local signature = scope .. "|" .. failure - local frame = 0 - if type(GameGetFrameNum) == "function" then - local ok, value = pcall(GameGetFrameNum) - if ok then frame = tonumber(value) or 0 end - end - local last = runtime_error_seen[signature] - if last ~= nil and frame - last < RUNTIME_ERROR_REPEAT_FRAMES then return end - runtime_error_seen[signature] = frame - local diagnostic_message = "[" .. MOD_NAME .. "] " .. scope .. " failed: " .. failure - local user_message = translated("$mcm_runtime_error", "Mod runtime error") - .. ": " .. scope .. " — " .. failure - print(diagnostic_message) - if type(GlobalsSetValue) == "function" then - pcall(GlobalsSetValue, "mcm_runtime_error_last_v1", global_text.encode_diagnostic(scope .. ": " .. tostring(failure))) - end - if type(GamePrint) == "function" then pcall(GamePrint, user_message) end - if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "runtime." .. scope, tostring(failure)) - end -end - -local function protected_call(scope, callback, ...) - if type(callback) ~= "function" then - report_runtime_error(scope, "callback_unavailable") - return false, nil - end - local ok, first, second, third = pcall(callback, ...) - if not ok then - report_runtime_error(scope, first) - return false, nil - end - return true, first, second, third -end - -local teleport_cursor = 0 -local function native_inventory_open() - if type(GameIsInventoryOpen) ~= "function" then return false end - local ok, value = pcall(GameIsInventoryOpen) - return ok and value == true -end - -local function run_assignable_world_actions(player, menu_was_open) - if menu_was_open == true or native_inventory_open() then return end - if action_bindings.consume("brush_smaller") then - protected_call("hotkey.brush_smaller", material_painter.adjust_brush, -1) - end - if action_bindings.consume("brush_larger") then - protected_call("hotkey.brush_larger", material_painter.adjust_brush, 1) - end - if action_bindings.consume("paint_toggle") then - local enabled = material_painter.is_enabled() == true - local _, ok, reason = protected_call("hotkey.paint_toggle", material_painter.set_enabled, not enabled) - if ok ~= true and type(GamePrint) == "function" then - pcall(GamePrint, translated("$mcm_material_backend_failed", "Material brush unavailable") - .. ": " .. tostring(reason or "backend")) - end - end - if action_bindings.consume("effects_clear") then - protected_call("hotkey.effects_clear", effect_service.remove_all, player) - end - if action_bindings.consume("weather_release") then - protected_call("hotkey.weather_release", weather.release) - end - if action_bindings.consume("rules_reset") then - protected_call("hotkey.rules_reset", world_rules.reset) - end - if action_bindings.consume("teleport_next_player") then - local ok, visible = protected_call("hotkey.players", player_tools.visible_players) - local targets = {} - if ok and type(visible) == "table" then - for _, entity in ipairs(visible) do - if tonumber(entity) ~= tonumber(player) then targets[#targets + 1] = entity end - end - end - table.sort(targets, function(a, b) return (tonumber(a) or 0) < (tonumber(b) or 0) end) - if #targets > 0 then - teleport_cursor = teleport_cursor % #targets + 1 - protected_call("hotkey.teleport", player_tools.teleport_to, targets[teleport_cursor], player) - end - end -end function OnModPreInit() - protected_call("localization.register", localization.register) + localization.register() -- Observe real vanilla perk pickups from temples/other mods as well as menu pickups. -- This makes later removal use the same ownership journal regardless of acquisition source. - protected_call("perk_pickup_hook.append", ModLuaFileAppend, "data/scripts/perks/perk.lua", PERK_PICKUP_HOOK) - protected_call("ew_resilience.pre_init", ew_resilience.pre_init) + ModLuaFileAppend("data/scripts/perks/perk.lua", PERK_PICKUP_HOOK) + ew_resilience.pre_init() -- EW's stable RPC API is available only inside EW's own Lua context. Registering -- this file as an official extra module lets EW load it after net/ctx exist while -- keeping all source and behaviour owned by this mod. if ModIsEnabled("quant.ew") and ModDoesFileExist(EW_RULES_MODULE) then - protected_call("ew_boss_death.append", ModLuaFileAppend, - "mods/quant.ew/files/system/entity_sync_helper/death_notify.lua", - "mods/metamorph_creative_menu/files/integrations/ew/boss_death_notify.lua") - protected_call("ew_extra_module.append", ModLuaFileAppend, - "mods/quant.ew/files/api/extra_modules.lua", EW_RULES_MODULE) + ModLuaFileAppend("mods/quant.ew/files/api/extra_modules.lua", EW_RULES_MODULE) end end function OnModPostInit() - -- Resolve known vanilla/MCM sprite atlases only after every mod had its normal VFS - -- setup opportunity. External manifests remain lazy so mixed load orders cannot - -- permanently cache a placeholder. OnModPostInit is still inside Noita's supported - -- ModTextFileSetContent initialization window for generated one-frame proxies. - protected_call("item_icons.prewarm", item_catalog.prewarm_icons, asset_service) - local ok_resilience, seed_fallbacks, biome_fallbacks, crosscall_guards = - protected_call("ew_resilience.post_init", ew_resilience.post_init) - if not ok_resilience then seed_fallbacks, biome_fallbacks, crosscall_guards = 0, 0, 0 end - if (tonumber(seed_fallbacks) or 0) > 0 or (tonumber(biome_fallbacks) or 0) > 0 - or (tonumber(crosscall_guards) or 0) > 0 - then - print("[" .. MOD_NAME .. "] EW resilience: seed=" .. tostring(seed_fallbacks) - .. ", biome=" .. tostring(biome_fallbacks) .. ", crosscall=" .. tostring(crosscall_guards)) - end + ew_resilience.post_init() -- Noita resolves polymorph effect entity paths early enough that publishing the -- generated XML only from a menu click is not reliable on every runtime path. -- Prewarm the exact effect wrappers after all mods finish VFS setup; transform_creature() -- keeps its lazy publisher as a fallback for dynamically-added creature paths. local ok_forms, prepared = pcall(form_manager.prepare_exact_effect_paths_from_catalog) if not ok_forms then - if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then - pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "init.polymorph_prewarm", tostring(prepared)) - end print("[" .. MOD_NAME .. "] polymorph prewarm failed: " .. tostring(prepared)) - elseif DEV_MODE and tonumber(prepared) ~= nil then - print("[" .. MOD_NAME .. "] polymorph effects prepared: " .. tostring(prepared)) end end function OnWorldPreUpdate() - protected_call("death_recovery.update", death_recovery.update) - protected_call("input_guard.update", input_guard.update) - protected_call("action_bindings.update", action_bindings.update) - protected_call("spell_slot_settler.update", spell_slot_settler.update) - if diagnostics ~= nil then protected_call("diagnostics.update", diagnostics.update) end - protected_call("effect_service.update", effect_service.update) - if qa_controller ~= nil then protected_call("qa_controller.update", qa_controller.update) end - protected_call("weather.update.pre", weather.update) - protected_call("world_rules.update", world_rules.update) - -- Reserved legacy perk-removal mailbox has no producer. Keep the compatibility - -- module/API loaded, but do not poll an acknowledge-only Global every frame. - local _, current = protected_call("form_manager.current_player", form_manager.current_player) - local _, menu_was_open = protected_call("menu_controller.is_open", menu_controller.is_open) - run_assignable_world_actions(tonumber(current) or 0, menu_was_open == true) - -- Material painting follows the assignable paint-draw action (middle mouse by - -- default) and writes directly to Noita's loaded cell grid. It runs in pre-update so - -- the world operation is not coupled to GUI timing. - protected_call("material_painter.update", material_painter.update, tonumber(current) or 0) - -- This is O(1) while idle. It only advances a location teleport whose destination - -- is currently being streamed; player lists are never scanned in the background. - protected_call("player_tools.update", player_tools.update) - protected_call("perk_service.update", perk_service.update, tonumber(current) or 0) - protected_call("ew_world_entities.update", ew_world_entities.update) - protected_call("player_avatar.update", player_avatar.update) - protected_call("form_manager.update", form_manager.update) - -- Vanilla HUD already reads the playerized form DamageModelComponent. The legacy - -- draw_form_health hook is retained for compatibility but is a deliberate no-op and - -- does not belong in the per-frame runtime path. - protected_call("possession_keybinds.update", keybinds.update, menu_was_open == true) - - -- The assignable return-human action (TAB by default) belongs to the form lifecycle. - -- Input quarantine blocks stale focus-transition input after Alt-Tab before forms or - -- UI consume it. - local _, input_blocked = protected_call("input_guard.blocked", input_guard.blocked) - local return_pressed = false - local _, has_form = protected_call("form_manager.has_active_form", form_manager.has_active_form) - if has_form == true then - local _, pressed = protected_call("action_bindings.return_human", action_bindings.consume, "return_human") - return_pressed = pressed == true - end - local _, handled_return = protected_call("form_manager.handle_tab_return", - form_manager.handle_tab_return, input_blocked == true, return_pressed) - if handled_return == true then - -- A form return changes the controlled player entity. Do not carry an armed - -- world brush across that lifecycle boundary. - protected_call("material_painter.disable_on_form_return", material_painter.set_enabled, false) + input_guard.update() + effect_service.update() + weather.update() + world_rules.update() + ew_perk_sync.update() + perk_service.update(form_manager.current_player()) + player_avatar.update() + form_manager.update() + form_manager.draw_form_health() + keybinds.update() + + -- TAB while transformed belongs to the form lifecycle. Input quarantine blocks + -- the same physical key event after Alt-Tab before either forms or UI consume it. + if form_manager.handle_tab_return(input_guard.blocked()) then return end - protected_call("menu_controller.draw", menu_controller.draw) -end - -function OnPausePreUpdate() - -- Death recovery is intentionally world-update owned. Keep pause lifecycle free of - -- Game Over state mutation. + menu_controller.draw() end function OnWorldPostUpdate() -- Reassert persistent weather after world scripts and restore inventory selection -- if vanilla InventoryGui saw the same wheel event as our scroll container. - protected_call("weather.update.post", weather.update) - protected_call("world_rules.post_update", world_rules.post_update) - protected_call("form_manager.post_update", form_manager.post_update) - protected_call("menu_controller.post_update", menu_controller.post_update) + weather.update() + world_rules.post_update() + form_manager.post_update() + menu_controller.post_update() end diff --git a/metamorph_creative_menu/mcm_native_gameover.dll b/metamorph_creative_menu/mcm_native_gameover.dll deleted file mode 100644 index 293edae..0000000 Binary files a/metamorph_creative_menu/mcm_native_gameover.dll and /dev/null differ diff --git a/metamorph_creative_menu/mcm_native_gameover.lib b/metamorph_creative_menu/mcm_native_gameover.lib deleted file mode 100644 index 0df0cfa..0000000 Binary files a/metamorph_creative_menu/mcm_native_gameover.lib and /dev/null differ diff --git a/metamorph_creative_menu/mod.xml b/metamorph_creative_menu/mod.xml index a7753a1..f1d6b3b 100644 --- a/metamorph_creative_menu/mod.xml +++ b/metamorph_creative_menu/mod.xml @@ -1,7 +1,6 @@ diff --git a/metamorph_creative_menu/native_src/README.md b/metamorph_creative_menu/native_src/README.md deleted file mode 100644 index 3da452a..0000000 --- a/metamorph_creative_menu/native_src/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Native Game Over recovery module - -`mcm_native_gameover.c` builds the optional native bridge used by the standalone MCM build for the **I didn't die** Game Over recovery action. - -The module is intentionally small and self-contained. It does not link the C runtime and does not import Lua or Win32 functions directly. At runtime it resolves the functions it needs from Noita's own PE import table. - -## Target format - -The shipped module is expected to remain: - -- a **32-bit x86 PE DLL**; -- without a DLL entry point (`AddressOfEntryPoint = 0`); -- without a PE import table; -- with `luaopen_mcm_native_gameover` as its Lua module export; -- compatible with Noita's 32-bit process. - -The scanner inside the module is adaptive: it locates supported Game Over structures from the running executable and fails closed when it cannot identify them safely. The build must not add hard-coded Noita virtual addresses or a CRT startup dependency. - -## Building with MSVC - -Use an **x86 Native Tools Command Prompt for Visual Studio** and run: - -```bat -native_src\build_msvc.bat -``` - -The script compiles the C source and writes these files to the mod root: - -- `mcm_native_gameover.dll` — runtime module shipped in the standalone/player build; -- `mcm_native_gameover.lib` — import library retained only in the full development source. - -The intermediate object file is removed after a successful link. - -## Verification - -After rebuilding, run the normal MCM regression suite from the mod root: - -```text -python tests/run_all.py . -``` - -For an additional PE-level check with Visual Studio tools: - -```bat -dumpbin /headers mcm_native_gameover.dll -dumpbin /exports mcm_native_gameover.dll -dumpbin /imports mcm_native_gameover.dll -``` - -The DLL should report machine type x86, an entry point of zero, the `luaopen_mcm_native_gameover` export, and no imported DLLs. diff --git a/metamorph_creative_menu/native_src/build_msvc.bat b/metamorph_creative_menu/native_src/build_msvc.bat deleted file mode 100644 index 1c84619..0000000 --- a/metamorph_creative_menu/native_src/build_msvc.bat +++ /dev/null @@ -1,30 +0,0 @@ -@echo off -setlocal - -set "ROOT=%~dp0.." -set "SRC=%~dp0mcm_native_gameover.c" -set "OBJ=%~dp0mcm_native_gameover.obj" -set "DLL=%ROOT%\mcm_native_gameover.dll" -set "LIB=%ROOT%\mcm_native_gameover.lib" - -where cl >nul 2>nul || ( - echo ERROR: cl.exe was not found. Run this from an x86 Native Tools Command Prompt for Visual Studio. - exit /b 1 -) -where link >nul 2>nul || ( - echo ERROR: link.exe was not found. Run this from an x86 Native Tools Command Prompt for Visual Studio. - exit /b 1 -) - -cl /nologo /c /O2 /GS- /Zl /TC "%SRC%" /Fo"%OBJ%" -if errorlevel 1 exit /b %errorlevel% - -link /nologo /DLL /NOENTRY /NODEFAULTLIB /MACHINE:X86 /DYNAMICBASE /NXCOMPAT /NOSEH /SUBSYSTEM:WINDOWS,6.00 /OUT:"%DLL%" /IMPLIB:"%LIB%" "%OBJ%" -if errorlevel 1 exit /b %errorlevel% - -del /q "%OBJ%" >nul 2>nul - -echo Built: -echo %DLL% -echo %LIB% -exit /b 0 diff --git a/metamorph_creative_menu/native_src/mcm_native_gameover.c b/metamorph_creative_menu/native_src/mcm_native_gameover.c deleted file mode 100644 index 2ffdbf3..0000000 --- a/metamorph_creative_menu/native_src/mcm_native_gameover.c +++ /dev/null @@ -1,1016 +0,0 @@ -/* - * Metamorph Creative Menu - adaptive native Game Over button bridge. - * Target: 32-bit Noita PE builds whose stock Game Over / Save Replay code still - * exposes the same semantic structures. No absolute noita.exe VAs are stored. - * - * The module has no CRT dependency and no fixed Lua/Win32 IAT addresses. It: - * - parses the running PE32 image from the PEB; - * - resolves Lua and Win32 imports by name from noita.exe's import table; - * - locates the Save Replay builder and Game Over state through strings and - * bounded machine-code signatures; - * - derives mutable globals/structure offsets from the matched stock code; - * - fails closed if a future build no longer matches those semantics. - * - * This is deliberately a compatibility scanner, not a promise that arbitrary - * future rewrites of Noita's Game Over implementation can be patched safely. - */ - -typedef unsigned char u8; -typedef unsigned short u16; -typedef unsigned int u32; -typedef int BOOL; -typedef unsigned long DWORD; -typedef void* HANDLE; -typedef void* LPVOID; -typedef const void* LPCVOID; - -typedef struct lua_State lua_State; -typedef int (__cdecl *lua_CFunction)(lua_State* L); -typedef void (__cdecl *lua_createtable_t)(lua_State*, int, int); -typedef void (__cdecl *lua_pushcclosure_t)(lua_State*, lua_CFunction, int); -typedef void (__cdecl *lua_setfield_t)(lua_State*, int, const char*); -typedef void (__cdecl *lua_pushboolean_t)(lua_State*, int); -typedef void (__cdecl *lua_pushstring_t)(lua_State*, const char*); - -typedef BOOL (__stdcall *VirtualProtect_t)(LPVOID, u32, DWORD, DWORD*); -typedef BOOL (__stdcall *FlushInstructionCache_t)(HANDLE, LPCVOID, u32); -typedef HANDLE (__stdcall *GetCurrentProcess_t)(void); - -typedef void* (__cdecl *get_game_global_object_t)(void); -typedef void* (__thiscall *audio_handle_lookup_t)(void* audio_manager, int handle); - -#define PAGE_EXECUTE_READWRITE 0x40u -#define IMAGE_SCN_MEM_EXECUTE 0x20000000u -#define IMAGE_SCN_MEM_READ 0x40000000u -#define IMAGE_SCN_MEM_WRITE 0x80000000u -#define MAX_SECTIONS 16u -#define MAX_CLICK_PATCH 64u -#define MAX_CALL_TARGETS 32u -#define MAX_SSE_CANDIDATES 16u - -static volatile u8 g_revive_request = 0; -static volatile u8 g_installed = 0; -static volatile u8 g_profile_ready = 0; -static const char g_label[] = "$mcm_death_not_dead"; -static const char* g_last_reason = "not_installed"; - -/* clang may lower small fixed-size array initialization to memset even with /Zl. - * Keep the module CRT-free by providing the tiny intrinsic-compatible implementation. */ -void* __cdecl memset(void* destination, int value, u32 size) -{ - volatile u8* p = (volatile u8*)destination; - u32 i; - for (i = 0; i < size; ++i) p[i] = (u8)value; - return destination; -} - -static lua_createtable_t g_lua_createtable = 0; -static lua_setfield_t g_lua_setfield = 0; -static lua_pushcclosure_t g_lua_pushcclosure = 0; -static lua_pushstring_t g_lua_pushstring = 0; -static lua_pushboolean_t g_lua_pushboolean = 0; -static VirtualProtect_t g_virtual_protect = 0; -static FlushInstructionCache_t g_flush_icache = 0; -static GetCurrentProcess_t g_current_process = 0; - -#if defined(_MSC_VER) -unsigned long __readfsdword(unsigned long offset); -#pragma intrinsic(__readfsdword) -#endif - -typedef struct image_section { - u32 start; - u32 size; - u32 characteristics; -} image_section; - -typedef struct image_layout { - u32 base; - u32 size; - u32 text_start; - u32 text_size; - u32 optional_header; - image_section sections[MAX_SECTIONS]; - u32 section_count; -} image_layout; - -typedef struct adaptive_profile { - u32 replay_gate; - u32 replay_label; - u32 replay_click; - u32 replay_click_size; - u32 replay_skip_target; - u8 original_gate[5]; - u8 original_label[5]; - u8 original_click[MAX_CLICK_PATCH]; - - u32 game_mode_pointer; - u32 gameover_flag_offset; - u32 gameover_trigger_entry; - u32 gameover_init; - u32 get_game_global_object; - - u32 audio_handle_lookup; - u32 audio_manager_offset; - u32 audio_record_active_offset; - u32 music_root_offset; - u32 music_manager_offset; - u32 music_death_latch_offset; - - u32 ui_menu_music_handle; - u32 ui_gameover_stats_handle; - u32 ui_gameover_frame_counter; - u32 ui_gameover_state; - u32 ui_gameover_fade; - - u8 cleanup_ready; -} adaptive_profile; - -static image_layout g_image; -static adaptive_profile g_profile; - -static u32 process_image_base(void) -{ - u32 peb = (u32)__readfsdword(0x30u); - if (!peb) return 0; - return *(volatile const u32*)(peb + 0x08u); -} - -static u16 read_u16(u32 address) -{ - volatile const u8* p = (volatile const u8*)address; - return (u16)((u16)p[0] | ((u16)p[1] << 8)); -} - -static u32 read_u32(u32 address) -{ - volatile const u8* p = (volatile const u8*)address; - return (u32)p[0] | ((u32)p[1] << 8) | ((u32)p[2] << 16) | ((u32)p[3] << 24); -} - -static int read_s32(u32 address) -{ - return (int)read_u32(address); -} - -static void encode_u32(u8* out, u32 value) -{ - out[0] = (u8)(value & 0xffu); - out[1] = (u8)((value >> 8) & 0xffu); - out[2] = (u8)((value >> 16) & 0xffu); - out[3] = (u8)((value >> 24) & 0xffu); -} - -static u32 string_length(const char* s) -{ - u32 n = 0; - while (s && s[n]) ++n; - return n; -} - -static int string_equal(const char* a, const char* b) -{ - u32 i = 0; - if (!a || !b) return 0; - while (a[i] && b[i]) { - if (a[i] != b[i]) return 0; - ++i; - } - return a[i] == b[i]; -} - -static void copy_bytes(u8* dst, u32 src, u32 size) -{ - u32 i; - volatile const u8* p = (volatile const u8*)src; - for (i = 0; i < size; ++i) dst[i] = p[i]; -} - -static int bytes_equal(u32 address, const u8* expected, u32 size) -{ - u32 i; - volatile const u8* p = (volatile const u8*)address; - for (i = 0; i < size; ++i) { - if (p[i] != expected[i]) return 0; - } - return 1; -} - -static int parse_image_layout(void) -{ - u32 base = process_image_base(); - u32 pe_offset, pe, optional, size_of_image, section_table; - u16 sections, optional_size; - u32 i, kept = 0; - - if (!base) { g_last_reason = "process_image_base_unavailable"; return 0; } - if (read_u16(base) != 0x5a4du) { g_last_reason = "noita_image_missing_mz"; return 0; } - pe_offset = read_u32(base + 0x3cu); - if (pe_offset < 0x40u || pe_offset > 0x2000u) { g_last_reason = "noita_pe_offset_invalid"; return 0; } - pe = base + pe_offset; - if (read_u32(pe) != 0x00004550u) { g_last_reason = "noita_image_missing_pe"; return 0; } - if (read_u16(pe + 4u) != 0x014cu) { g_last_reason = "noita_not_x86"; return 0; } - sections = read_u16(pe + 6u); - optional_size = read_u16(pe + 20u); - optional = pe + 24u; - if (read_u16(optional) != 0x010bu) { g_last_reason = "noita_not_pe32"; return 0; } - size_of_image = read_u32(optional + 56u); - if (size_of_image < 0x10000u || size_of_image > 0x10000000u) { - g_last_reason = "noita_image_size_invalid"; - return 0; - } - if (!sections || sections > 96u || optional_size < 96u) { - g_last_reason = "noita_section_table_invalid"; - return 0; - } - - g_image.base = base; - g_image.size = size_of_image; - g_image.optional_header = optional; - g_image.text_start = 0; - g_image.text_size = 0; - g_image.section_count = 0; - section_table = optional + (u32)optional_size; - - for (i = 0; i < (u32)sections; ++i) { - u32 sh = section_table + i * 40u; - u32 virtual_size = read_u32(sh + 8u); - u32 virtual_address = read_u32(sh + 12u); - u32 raw_size = read_u32(sh + 16u); - u32 characteristics = read_u32(sh + 36u); - u32 size = virtual_size > raw_size ? virtual_size : raw_size; - u32 start; - if (!size || virtual_address >= size_of_image || size > size_of_image - virtual_address) continue; - start = base + virtual_address; - if ((characteristics & IMAGE_SCN_MEM_EXECUTE) && !g_image.text_start) { - g_image.text_start = start; - g_image.text_size = size; - } - if (kept < MAX_SECTIONS) { - g_image.sections[kept].start = start; - g_image.sections[kept].size = size; - g_image.sections[kept].characteristics = characteristics; - ++kept; - } - } - g_image.section_count = kept; - if (!g_image.text_start || !g_image.text_size) { - g_last_reason = "noita_text_section_missing"; - return 0; - } - return 1; -} - -static int address_in_image(u32 address, u32 size) -{ - if (!g_image.base || !g_image.size) return 0; - if (address < g_image.base || size > g_image.size) return 0; - return address - g_image.base <= g_image.size - size; -} - -static int address_has_characteristic(u32 address, u32 characteristic) -{ - u32 i; - for (i = 0; i < g_image.section_count; ++i) { - image_section* s = &g_image.sections[i]; - if (address >= s->start && address - s->start < s->size) { - return (s->characteristics & characteristic) != 0; - } - } - return 0; -} - -static int address_is_writable_image(u32 address) -{ - return address_in_image(address, 1u) && address_has_characteristic(address, IMAGE_SCN_MEM_WRITE); -} - -static int address_is_text(u32 address) -{ - return address >= g_image.text_start && address - g_image.text_start < g_image.text_size; -} - -static u32 find_ascii_in_image(const char* needle) -{ - u32 length = string_length(needle); - u32 s, i, j; - if (!length) return 0; - for (s = 0; s < g_image.section_count; ++s) { - image_section* sec = &g_image.sections[s]; - volatile const u8* p; - if (!(sec->characteristics & IMAGE_SCN_MEM_READ)) continue; - if (sec->characteristics & IMAGE_SCN_MEM_EXECUTE) continue; - if (sec->size <= length) continue; - p = (volatile const u8*)sec->start; - for (i = 0; i + length < sec->size; ++i) { - if (p[i] != (u8)needle[0]) continue; - for (j = 1; j < length; ++j) if (p[i + j] != (u8)needle[j]) break; - if (j == length && p[i + length] == 0) return sec->start + i; - } - } - return 0; -} - -static u32 rel32_target(u32 instruction) -{ - return instruction + 5u + (u32)read_s32(instruction + 1u); -} - -static u32 branch_target(u32 branch) -{ - volatile const u8* p = (volatile const u8*)branch; - if (p[0] == 0x74u) { - int rel = (int)(signed char)p[1]; - return branch + 2u + (u32)rel; - } - if (p[0] == 0x0fu && p[1] == 0x84u) { - return branch + 6u + (u32)read_s32(branch + 2u); - } - return 0; -} - -static u32 find_function_start(u32 inside, u32 max_back) -{ - u32 min = inside > max_back ? inside - max_back : g_image.text_start; - u32 p = inside; - if (min < g_image.text_start) min = g_image.text_start; - while (p >= min + 3u) { - --p; - if (*(volatile u8*)p == 0x55u && *(volatile u8*)(p + 1u) == 0x8bu && *(volatile u8*)(p + 2u) == 0xecu) { - return p; - } - } - return 0; -} - -static u32 resolve_import(const char* function_name) -{ - u32 optional = g_image.optional_header; - u32 import_rva, import_size, descriptor; - if (!optional) return 0; - import_rva = read_u32(optional + 104u); - import_size = read_u32(optional + 108u); - if (!import_rva || import_rva >= g_image.size || import_size < 20u) return 0; - descriptor = g_image.base + import_rva; - while (address_in_image(descriptor, 20u)) { - u32 original = read_u32(descriptor + 0u); - u32 name_rva = read_u32(descriptor + 12u); - u32 first = read_u32(descriptor + 16u); - u32 index = 0; - if (!original && !name_rva && !first) break; - if (!first) { descriptor += 20u; continue; } - if (!original) original = first; - while (address_in_image(g_image.base + original + index * 4u, 4u) - && address_in_image(g_image.base + first + index * 4u, 4u)) - { - u32 thunk = read_u32(g_image.base + original + index * 4u); - if (!thunk) break; - if (!(thunk & 0x80000000u)) { - u32 ibn = g_image.base + thunk; - if (address_in_image(ibn, 3u)) { - const char* imported_name = (const char*)(ibn + 2u); - if (string_equal(imported_name, function_name)) { - return read_u32(g_image.base + first + index * 4u); - } - } - } - ++index; - } - descriptor += 20u; - } - return 0; -} - -static int resolve_runtime_imports(void) -{ - if (!parse_image_layout()) return 0; - g_lua_createtable = (lua_createtable_t)resolve_import("lua_createtable"); - g_lua_setfield = (lua_setfield_t)resolve_import("lua_setfield"); - g_lua_pushcclosure = (lua_pushcclosure_t)resolve_import("lua_pushcclosure"); - g_lua_pushstring = (lua_pushstring_t)resolve_import("lua_pushstring"); - g_lua_pushboolean = (lua_pushboolean_t)resolve_import("lua_pushboolean"); - g_virtual_protect = (VirtualProtect_t)resolve_import("VirtualProtect"); - g_flush_icache = (FlushInstructionCache_t)resolve_import("FlushInstructionCache"); - g_current_process = (GetCurrentProcess_t)resolve_import("GetCurrentProcess"); - if (!g_lua_createtable || !g_lua_setfield || !g_lua_pushcclosure || !g_lua_pushstring || !g_lua_pushboolean) { - g_last_reason = "lua_import_resolution_failed"; - return 0; - } - if (!g_virtual_protect || !g_flush_icache || !g_current_process) { - g_last_reason = "win32_import_resolution_failed"; - return 0; - } - return 1; -} - -static int write_bytes(u32 address, const u8* bytes, u32 size) -{ - volatile u8* dst = (volatile u8*)address; - DWORD old_protect = 0, ignored = 0; - u32 i; - if (!g_virtual_protect || !g_flush_icache || !g_current_process) return 0; - if (!address_is_text(address) || !address_is_text(address + size - 1u)) return 0; - if (!g_virtual_protect((LPVOID)address, size, PAGE_EXECUTE_READWRITE, &old_protect)) return 0; - for (i = 0; i < size; ++i) dst[i] = bytes[i]; - g_flush_icache(g_current_process(), (LPCVOID)address, size); - g_virtual_protect((LPVOID)address, size, old_protect, &ignored); - return bytes_equal(address, bytes, size); -} - -static int locate_replay_sites(void) -{ - u32 save_string = find_ascii_in_image("$menugameover_savereplay"); - u32 p; - if (!save_string) { g_last_reason = "save_replay_string_not_found"; return 0; } - - for (p = g_image.text_start; p + 5u < g_image.text_start + g_image.text_size; ++p) { - u32 label, gate = 0, skip = 0, click = 0, click_size = 0; - u32 q, begin; - if (*(volatile u8*)p != 0x68u || read_u32(p + 1u) != save_string) continue; - label = p; - begin = label > 128u ? label - 128u : g_image.text_start; - if (begin < g_image.text_start) begin = g_image.text_start; - - for (q = begin; q + 37u <= label; ++q) { - u32 target1, target2; - if (*(volatile u8*)q != 0xa1u || *(volatile u8*)(q + 5u) != 0xb9u) continue; - if (read_u32(q + 1u) != read_u32(q + 6u)) continue; - if (*(volatile u8*)(q + 10u) != 0xffu || *(volatile u8*)(q + 11u) != 0x90u) continue; - if (*(volatile u8*)(q + 16u) != 0x85u || *(volatile u8*)(q + 17u) != 0xc0u) continue; - if (*(volatile u8*)(q + 18u) != 0x0fu || *(volatile u8*)(q + 19u) != 0x84u) continue; - if (*(volatile u8*)(q + 24u) != 0x80u || *(volatile u8*)(q + 25u) != 0xb8u || *(volatile u8*)(q + 30u) != 0x00u) continue; - if (*(volatile u8*)(q + 31u) != 0x0fu || *(volatile u8*)(q + 32u) != 0x84u) continue; - target1 = q + 24u + (u32)read_s32(q + 20u); - target2 = q + 37u + (u32)read_s32(q + 33u); - if (target1 != target2 || target1 <= label || target1 - label > 1024u) continue; - gate = q; - skip = target1; - break; - } - if (!gate || !skip) continue; - - for (q = label + 5u; q + 2u < skip; ++q) { - u32 target = 0, action_start = 0; - if (*(volatile u8*)q != 0x84u) continue; - if (*(volatile u8*)(q + 2u) == 0x74u) { - target = branch_target(q + 2u); - action_start = q + 4u; - } else if (*(volatile u8*)(q + 2u) == 0x0fu && *(volatile u8*)(q + 3u) == 0x84u) { - target = branch_target(q + 2u); - action_start = q + 8u; - } - if (target != skip || action_start >= skip) continue; - if (skip - action_start < 7u || skip - action_start > MAX_CLICK_PATCH) continue; - click = action_start; - click_size = skip - action_start; - break; - } - if (!click) continue; - - g_profile.replay_gate = gate; - g_profile.replay_label = label; - g_profile.replay_click = click; - g_profile.replay_click_size = click_size; - g_profile.replay_skip_target = skip; - copy_bytes(g_profile.original_gate, gate, 5u); - copy_bytes(g_profile.original_label, label, 5u); - copy_bytes(g_profile.original_click, click, click_size); - return 1; - } - g_last_reason = "save_replay_builder_signature_not_found"; - return 0; -} - -static u32 most_repeated_call_target(u32 start, u32 end, u32 excluded) -{ - u32 targets[MAX_CALL_TARGETS]; - u8 counts[MAX_CALL_TARGETS]; - u32 used = 0, p, i, best = 0; - u8 best_count = 0; - if (start < g_image.text_start) start = g_image.text_start; - if (end > g_image.text_start + g_image.text_size) end = g_image.text_start + g_image.text_size; - for (p = start; p + 5u <= end; ++p) { - u32 target; - if (*(volatile u8*)p != 0xe8u) continue; - target = rel32_target(p); - if (target == excluded || !address_is_text(target)) continue; - for (i = 0; i < used; ++i) if (targets[i] == target) break; - if (i == used) { - if (used >= MAX_CALL_TARGETS) continue; - targets[used] = target; - counts[used] = 1u; - ++used; - } else if (counts[i] < 255u) { - ++counts[i]; - } - } - for (i = 0; i < used; ++i) { - if (counts[i] > best_count) { best_count = counts[i]; best = targets[i]; } - } - return best_count >= 2u ? best : 0; -} - -static int decode_mov_from_eax_or_esi(u32 p, u8 base_reg, u32* offset_out) -{ - volatile const u8* b = (volatile const u8*)p; - /* mov r32,[eax+imm8] / mov r32,[esi+imm8] common encodings used by Noita. */ - if (b[0] == 0x8bu) { - u8 modrm = b[1]; - u8 rm = (u8)(modrm & 7u); - u8 mod = (u8)((modrm >> 6) & 3u); - if (rm == base_reg && mod == 1u) { - *offset_out = (u32)(u8)b[2]; - return 3; - } - if (rm == base_reg && mod == 2u) { - *offset_out = read_u32(p + 2u); - return 6; - } - } - return 0; -} - -static int locate_gameover_core(void) -{ - u32 event_string = find_ascii_in_image("event_cues/game_over/create"); - u32 xref; - if (!event_string) { g_last_reason = "gameover_event_string_not_found"; return 0; } - - for (xref = g_image.text_start; xref + 5u < g_image.text_start + g_image.text_size; ++xref) { - u32 cmp, cmp_start, set_flag = 0, flag_offset = 0, trigger_entry, init_call = 0, get_global; - u32 p; - if (*(volatile u8*)xref != 0x68u || read_u32(xref + 1u) != event_string) continue; - cmp_start = xref > 192u ? xref - 192u : g_image.text_start; - if (cmp_start < g_image.text_start) cmp_start = g_image.text_start; - cmp = 0; - for (p = cmp_start; p + 7u < xref; ++p) { - u32 q; - if (*(volatile u8*)p != 0x80u || *(volatile u8*)(p + 1u) != 0xb9u || *(volatile u8*)(p + 6u) != 0x00u) continue; - flag_offset = read_u32(p + 2u); - if (flag_offset > 0x10000u) continue; - for (q = p + 7u; q + 7u <= xref && q < p + 96u; ++q) { - if (*(volatile u8*)q == 0xc6u && *(volatile u8*)(q + 1u) == 0x81u - && read_u32(q + 2u) == flag_offset && *(volatile u8*)(q + 6u) == 0x01u) - { - cmp = p; - set_flag = q; - break; - } - } - if (cmp) break; - } - if (!cmp || !set_flag) continue; - trigger_entry = find_function_start(cmp, 256u); - if (!trigger_entry) continue; - - for (p = set_flag + 7u; p < set_flag + 32u; ++p) { - if (*(volatile u8*)p == 0xe8u) { init_call = rel32_target(p); break; } - } - if (!init_call || !address_is_text(init_call)) continue; - get_global = most_repeated_call_target(set_flag + 7u, xref + 160u, init_call); - if (!get_global) continue; - - g_profile.gameover_flag_offset = flag_offset; - g_profile.gameover_trigger_entry = trigger_entry; - g_profile.gameover_init = init_call; - g_profile.get_game_global_object = get_global; - - /* Derive GameMode** from the stock call site that passes the global object in ECX. */ - for (p = g_image.text_start; p + 5u < g_image.text_start + g_image.text_size; ++p) { - if (*(volatile u8*)p == 0xe8u && rel32_target(p) == trigger_entry && p >= g_image.text_start + 10u) { - u32 s = p - 10u; - if (*(volatile u8*)s == 0x8bu && *(volatile u8*)(s + 1u) == 0x0du - && *(volatile u8*)(s + 6u) == 0x85u && *(volatile u8*)(s + 7u) == 0xc9u - && (*(volatile u8*)(s + 8u) == 0x74u || *(volatile u8*)(s + 8u) == 0x75u)) - { - u32 global = read_u32(s + 2u); - if (address_is_writable_image(global)) { g_profile.game_mode_pointer = global; break; } - } - } - } - if (!g_profile.game_mode_pointer) continue; - - /* Music manager chain and death latch are read from the stock trigger path. */ - for (p = cmp; p + 5u < xref; ++p) { - if (*(volatile u8*)p == 0xe8u && rel32_target(p) == get_global) { - u32 a, root_off = 0, manager_off = 0; - int root_len = 0, manager_len = 0; - for (a = p + 5u; a < p + 28u; ++a) { - root_len = decode_mov_from_eax_or_esi(a, 0u, &root_off); /* EAX base */ - if (root_len) break; - } - if (!root_len) continue; - for (a = a + (u32)root_len; a < p + 40u; ++a) { - manager_len = decode_mov_from_eax_or_esi(a, 6u, &manager_off); /* ESI base */ - if (manager_len) break; - } - if (!manager_len) continue; - for (; a + 7u < p + 64u; ++a) { - if (*(volatile u8*)a == 0xc6u && *(volatile u8*)(a + 1u) == 0x80u && *(volatile u8*)(a + 6u) == 0x01u) { - u32 latch = read_u32(a + 2u); - if (latch <= 0x10000u) { - g_profile.music_root_offset = root_off; - g_profile.music_manager_offset = manager_off; - g_profile.music_death_latch_offset = latch; - break; - } - } - } - if (g_profile.music_death_latch_offset) break; - } - } - - /* Audio manager offset: the event builder fetches game->audio after the same string. */ - for (p = xref; p < xref + 96u; ++p) { - if (*(volatile u8*)p == 0xe8u && rel32_target(p) == get_global) { - u32 a, off = 0; - int len; - for (a = p + 5u; a < p + 28u; ++a) { - len = decode_mov_from_eax_or_esi(a, 0u, &off); - if (len && off <= 0x1000u) { g_profile.audio_manager_offset = off; break; } - } - if (g_profile.audio_manager_offset) break; - } - } - return 1; - } - g_last_reason = "gameover_trigger_signature_not_found"; - return 0; -} - -static int locate_gameover_init_globals(void) -{ - u32 p, end, frame = 0, state = 0, stats = 0; - if (!g_profile.gameover_init) return 0; - end = g_profile.gameover_init + 192u; - if (end > g_image.text_start + g_image.text_size) end = g_image.text_start + g_image.text_size; - for (p = g_profile.gameover_init; p + 10u <= end; ++p) { - if (!frame && *(volatile u8*)p == 0xc7u && *(volatile u8*)(p + 1u) == 0x05u - && read_u32(p + 6u) == 0u && address_is_writable_image(read_u32(p + 2u))) - { - frame = read_u32(p + 2u); - } - if (!state && *(volatile u8*)p == 0xc6u && *(volatile u8*)(p + 1u) == 0x05u - && *(volatile u8*)(p + 6u) == 0x00u && address_is_writable_image(read_u32(p + 2u))) - { - state = read_u32(p + 2u); - } - if (!stats && *(volatile u8*)p == 0xc7u && *(volatile u8*)(p + 1u) == 0x05u - && read_u32(p + 6u) == 0xffffffffu && address_is_writable_image(read_u32(p + 2u))) - { - stats = read_u32(p + 2u); - } - if (frame && state && stats) break; - } - if (!frame || !state || !stats) return 0; - if ((frame > state ? frame - state : state - frame) > 0x400u) return 0; - if ((stats > state ? stats - state : state - stats) > 0x400u) return 0; - g_profile.ui_gameover_frame_counter = frame; - g_profile.ui_gameover_state = state; - g_profile.ui_gameover_stats_handle = stats; - return 1; -} - -static int match_get_global_audio_lookup_block(u32 cmp_site, u32 handle, u32* lookup_out, u32* audio_off_out) -{ - u32 p, call_global = 0, push_handle = 0, mov_audio = 0, lookup = 0, off = 0; - for (p = cmp_site + 7u; p < cmp_site + 36u; ++p) { - if (*(volatile u8*)p == 0xe8u && rel32_target(p) == g_profile.get_game_global_object) { call_global = p; break; } - } - if (!call_global) return 0; - for (p = call_global + 5u; p < call_global + 24u; ++p) { - if (*(volatile u8*)p == 0xffu && *(volatile u8*)(p + 1u) == 0x35u && read_u32(p + 2u) == handle) { - push_handle = p; break; - } - } - if (!push_handle) return 0; - for (p = push_handle + 6u; p < push_handle + 18u; ++p) { - int len = decode_mov_from_eax_or_esi(p, 0u, &off); - if (len) { mov_audio = p; p += (u32)len; break; } - } - if (!mov_audio) return 0; - for (; p < push_handle + 28u; ++p) { - if (*(volatile u8*)p == 0xe8u) { lookup = rel32_target(p); break; } - } - if (!lookup || !address_is_text(lookup)) return 0; - *lookup_out = lookup; - *audio_off_out = off; - return 1; -} - -static int locate_audio_cleanup(void) -{ - u32 stats = g_profile.ui_gameover_stats_handle; - u32 p; - if (!stats || !g_profile.get_game_global_object) return 0; - for (p = g_image.text_start; p + 7u < g_image.text_start + g_image.text_size; ++p) { - u32 lookup = 0, audio_off = 0, menu_cmp = 0, q, menu = 0; - if (*(volatile u8*)p != 0x83u || *(volatile u8*)(p + 1u) != 0x3du || read_u32(p + 2u) != stats || *(volatile u8*)(p + 6u) != 0xffu) continue; - if (!match_get_global_audio_lookup_block(p, stats, &lookup, &audio_off)) continue; - - q = p; - while (q > g_image.text_start + 7u && p - q < 96u) { - --q; - if (*(volatile u8*)q == 0x83u && *(volatile u8*)(q + 1u) == 0x3du && *(volatile u8*)(q + 6u) == 0xffu) { - u32 candidate = read_u32(q + 2u), lookup2 = 0, audio2 = 0; - if (candidate != stats && address_is_writable_image(candidate) - && match_get_global_audio_lookup_block(q, candidate, &lookup2, &audio2) - && lookup2 == lookup && audio2 == audio_off) - { - menu_cmp = q; - menu = candidate; - break; - } - } - } - if (!menu_cmp) continue; - - g_profile.ui_menu_music_handle = menu; - g_profile.audio_handle_lookup = lookup; - g_profile.audio_manager_offset = audio_off; - - /* Derive the AudioRecord active byte from stock fade completion. */ - for (q = menu_cmp; q < p + 192u; ++q) { - if (*(volatile u8*)q == 0xe8u && rel32_target(q) == lookup) { - u32 r; - for (r = q + 5u; r < q + 24u; ++r) { - if (*(volatile u8*)r == 0xc6u && *(volatile u8*)(r + 1u) == 0x40u && *(volatile u8*)(r + 3u) == 0x00u) { - g_profile.audio_record_active_offset = *(volatile u8*)(r + 2u); - break; - } - } - if (g_profile.audio_record_active_offset) break; - } - } - - /* Find the repeatedly-used writable scalar controlling this fade function. */ - { - u32 addresses[MAX_SSE_CANDIDATES]; - u8 counts[MAX_SSE_CANDIDATES]; - u32 used = 0, r, best = 0; - u8 best_count = 0; - u32 scan_start = menu_cmp > 96u ? menu_cmp - 96u : g_image.text_start; - u32 scan_end = p + 224u; - if (scan_start < g_image.text_start) scan_start = g_image.text_start; - if (scan_end > g_image.text_start + g_image.text_size) scan_end = g_image.text_start + g_image.text_size; - for (r = scan_start; r + 8u <= scan_end; ++r) { - u32 addr = 0, i; - if (*(volatile u8*)r != 0xf3u || *(volatile u8*)(r + 1u) != 0x0fu) continue; - if ((*(volatile u8*)(r + 2u) == 0x10u || *(volatile u8*)(r + 2u) == 0x11u - || *(volatile u8*)(r + 2u) == 0x58u || *(volatile u8*)(r + 2u) == 0x5cu) - && *(volatile u8*)(r + 3u) == 0x05u) - { - addr = read_u32(r + 4u); - } - if (!addr || !address_is_writable_image(addr)) continue; - for (i = 0; i < used; ++i) if (addresses[i] == addr) break; - if (i == used) { - if (used >= MAX_SSE_CANDIDATES) continue; - addresses[used] = addr; - counts[used] = 1u; - ++used; - } else if (counts[i] < 255u) ++counts[i]; - } - for (r = 0; r < used; ++r) if (counts[r] > best_count) { best_count = counts[r]; best = addresses[r]; } - if (best_count >= 2u) g_profile.ui_gameover_fade = best; - } - - if (g_profile.audio_handle_lookup && g_profile.audio_record_active_offset - && g_profile.music_root_offset && g_profile.music_manager_offset - && g_profile.music_death_latch_offset && g_profile.ui_menu_music_handle - && g_profile.ui_gameover_stats_handle && g_profile.ui_gameover_frame_counter - && g_profile.ui_gameover_state) - { - g_profile.cleanup_ready = 1u; - } - return 1; - } - return 0; -} - -static int resolve_adaptive_profile(void) -{ - if (g_profile_ready) return 1; - if (!locate_replay_sites()) return 0; - if (!locate_gameover_core()) return 0; - locate_gameover_init_globals(); - locate_audio_cleanup(); - g_profile_ready = 1u; - g_last_reason = g_profile.cleanup_ready ? "adaptive_profile_resolved" : "adaptive_profile_resolved_cleanup_partial"; - return 1; -} - -static int install_patch(void) -{ - u8 gate_patch[5], label_patch[5], click_patch[MAX_CLICK_PATCH]; - u32 i; - if (g_installed) { g_last_reason = "installed"; return 1; } - if (!resolve_adaptive_profile()) return 0; - - if (!bytes_equal(g_profile.replay_gate, g_profile.original_gate, 5u) - || !bytes_equal(g_profile.replay_label, g_profile.original_label, 5u) - || !bytes_equal(g_profile.replay_click, g_profile.original_click, g_profile.replay_click_size)) - { - g_last_reason = "replay_sites_changed_restart_required"; - return 0; - } - - gate_patch[0] = 0xe9u; - encode_u32(gate_patch + 1u, g_profile.replay_label - (g_profile.replay_gate + 5u)); - label_patch[0] = 0x68u; - encode_u32(label_patch + 1u, (u32)g_label); - click_patch[0] = 0xc6u; - click_patch[1] = 0x05u; - encode_u32(click_patch + 2u, (u32)&g_revive_request); - click_patch[6] = 0x01u; - for (i = 7u; i < g_profile.replay_click_size; ++i) click_patch[i] = 0x90u; - - /* Publish owned pointers/actions first; expose the formerly-hidden button last. */ - if (!write_bytes(g_profile.replay_label, label_patch, 5u)) { g_last_reason = "label_write_failed"; return 0; } - if (!write_bytes(g_profile.replay_click, click_patch, g_profile.replay_click_size)) { - write_bytes(g_profile.replay_label, g_profile.original_label, 5u); - g_last_reason = "click_write_failed"; - return 0; - } - if (!write_bytes(g_profile.replay_gate, gate_patch, 5u)) { - write_bytes(g_profile.replay_click, g_profile.original_click, g_profile.replay_click_size); - write_bytes(g_profile.replay_label, g_profile.original_label, 5u); - g_last_reason = "gate_write_failed"; - return 0; - } - if (!bytes_equal(g_profile.replay_gate, gate_patch, 5u) - || !bytes_equal(g_profile.replay_label, label_patch, 5u) - || !bytes_equal(g_profile.replay_click, click_patch, g_profile.replay_click_size)) - { - write_bytes(g_profile.replay_gate, g_profile.original_gate, 5u); - write_bytes(g_profile.replay_click, g_profile.original_click, g_profile.replay_click_size); - write_bytes(g_profile.replay_label, g_profile.original_label, 5u); - g_last_reason = "post_install_verify_failed"; - return 0; - } - - g_revive_request = 0; - g_installed = 1; - g_last_reason = g_profile.cleanup_ready ? "installed_adaptive" : "installed_adaptive_cleanup_partial"; - return 1; -} - -static int game_over_active(void) -{ - u32 game_mode; - if (!g_profile_ready || !g_profile.game_mode_pointer) return 0; - game_mode = *(volatile u32*)g_profile.game_mode_pointer; - if (!game_mode) return 0; - return *((volatile u8*)(game_mode + g_profile.gameover_flag_offset)) != 0; -} - -static int cancel_game_over(void) -{ - u32 game_mode; - if (!g_installed) { g_last_reason = "not_installed"; return 0; } - if (!g_profile.game_mode_pointer) { g_last_reason = "game_mode_pointer_unresolved"; return 0; } - game_mode = *(volatile u32*)g_profile.game_mode_pointer; - if (!game_mode) { g_last_reason = "game_mode_unavailable"; return 0; } - *((volatile u8*)(game_mode + g_profile.gameover_flag_offset)) = 0; - g_last_reason = "cleared"; - return 1; -} - -static void stop_tracked_gameover_audio_handle(volatile int* handle_ptr, void* audio_manager) -{ - int handle; - void* record; - audio_handle_lookup_t lookup; - if (!handle_ptr) return; - handle = *handle_ptr; - if (handle == -1) return; - if (audio_manager && g_profile.audio_handle_lookup) { - lookup = (audio_handle_lookup_t)g_profile.audio_handle_lookup; - record = lookup(audio_manager, handle); - if (record && g_profile.audio_record_active_offset) { - *((volatile u8*)record + g_profile.audio_record_active_offset) = 0; - } - } - *handle_ptr = -1; -} - -static int cleanup_post_revive_runtime(void) -{ - get_game_global_object_t get_global; - void* game; - void* audio_manager = 0; - void* music_root = 0; - void* music_manager = 0; - int partial = 0; - - if (!g_installed) { g_last_reason = "not_installed"; return 0; } - if (!g_profile.get_game_global_object) { g_last_reason = "game_global_unresolved"; return 0; } - get_global = (get_game_global_object_t)g_profile.get_game_global_object; - game = get_global(); - if (!game) { g_last_reason = "game_global_unavailable"; return 0; } - - if (g_profile.audio_manager_offset && g_profile.audio_handle_lookup - && g_profile.ui_menu_music_handle && g_profile.ui_gameover_stats_handle) - { - audio_manager = *(void**)((u8*)game + g_profile.audio_manager_offset); - stop_tracked_gameover_audio_handle((volatile int*)g_profile.ui_menu_music_handle, audio_manager); - stop_tracked_gameover_audio_handle((volatile int*)g_profile.ui_gameover_stats_handle, audio_manager); - } else partial = 1; - - if (g_profile.music_root_offset && g_profile.music_manager_offset && g_profile.music_death_latch_offset) { - music_root = *(void**)((u8*)game + g_profile.music_root_offset); - music_manager = music_root ? *(void**)((u8*)music_root + g_profile.music_manager_offset) : 0; - if (music_manager) *((volatile u8*)music_manager + g_profile.music_death_latch_offset) = 0; - else partial = 1; - } else partial = 1; - - if (g_profile.ui_gameover_frame_counter) *(volatile u32*)g_profile.ui_gameover_frame_counter = 0; - else partial = 1; - if (g_profile.ui_gameover_state) *(volatile u8*)g_profile.ui_gameover_state = 0; - else partial = 1; - if (g_profile.ui_gameover_fade) *(volatile u32*)g_profile.ui_gameover_fade = 0; - else partial = 1; - - g_last_reason = partial ? "post_revive_cleaned_partial" : "post_revive_cleaned"; - return 1; -} - -static int push_bool_reason(lua_State* L, int ok, const char* reason) -{ - g_lua_pushboolean(L, ok ? 1 : 0); - g_lua_pushstring(L, reason ? reason : "unknown"); - return 2; -} - -static int __cdecl lua_install(lua_State* L) -{ - int ok = install_patch(); - return push_bool_reason(L, ok, g_last_reason); -} - -static int __cdecl lua_is_installed(lua_State* L) -{ - g_lua_pushboolean(L, g_installed ? 1 : 0); - return 1; -} - -static int __cdecl lua_has_request(lua_State* L) -{ - g_lua_pushboolean(L, (g_installed && g_revive_request) ? 1 : 0); - return 1; -} - -static int __cdecl lua_clear_request(lua_State* L) -{ - g_revive_request = 0; - g_lua_pushboolean(L, 1); - return 1; -} - -static int __cdecl lua_is_game_over_active(lua_State* L) -{ - g_lua_pushboolean(L, (g_installed && game_over_active()) ? 1 : 0); - return 1; -} - -static int __cdecl lua_cancel_game_over(lua_State* L) -{ - int ok = cancel_game_over(); - return push_bool_reason(L, ok, g_last_reason); -} - -static int __cdecl lua_cleanup_post_revive(lua_State* L) -{ - int ok = cleanup_post_revive_runtime(); - return push_bool_reason(L, ok, g_last_reason); -} - -static int __cdecl lua_reason(lua_State* L) -{ - g_lua_pushstring(L, g_last_reason ? g_last_reason : "unknown"); - return 1; -} - -static void table_function(lua_State* L, const char* name, lua_CFunction fn) -{ - g_lua_pushcclosure(L, fn, 0); - g_lua_setfield(L, -2, name); -} - -__declspec(dllexport) int __cdecl luaopen_mcm_native_gameover(lua_State* L) -{ - /* Only PE parsing and import-table reads occur before Lua API calls. No profiled VA is - * dereferenced, so an unknown build can fail module loading without touching code/data. */ - if (!resolve_runtime_imports()) return 0; - - g_lua_createtable(L, 0, 8); - table_function(L, "install", lua_install); - table_function(L, "is_installed", lua_is_installed); - table_function(L, "has_revive_request", lua_has_request); - table_function(L, "clear_revive_request", lua_clear_request); - table_function(L, "is_game_over_active", lua_is_game_over_active); - table_function(L, "cancel_game_over_state", lua_cancel_game_over); - table_function(L, "cleanup_post_revive", lua_cleanup_post_revive); - table_function(L, "reason", lua_reason); - return 1; -} diff --git a/metamorph_creative_menu/settings.lua b/metamorph_creative_menu/settings.lua index cd58430..3b2f657 100644 --- a/metamorph_creative_menu/settings.lua +++ b/metamorph_creative_menu/settings.lua @@ -1,98 +1,169 @@ dofile("data/scripts/lib/mod_settings.lua") +pcall(dofile, "data/scripts/debug/keycodes.lua") local mod_id = "metamorph_creative_menu" -mod_settings_version = 6 +mod_settings_version = 2 --- Mod settings use a separate Lua context on some Noita builds. Register translations --- here as well as from init.lua so newly added action names never fall back to English. -local localization = dofile("mods/metamorph_creative_menu/files/platform/noita/localization.lua") -pcall(localization.register) - -local registry = dofile("mods/metamorph_creative_menu/files/core/action_registry.lua") -local codec = dofile("mods/metamorph_creative_menu/files/core/binding_codec.lua") -local binding_capture = dofile("mods/metamorph_creative_menu/files/platform/noita/binding_capture.lua") local capture_setting_id = nil local capture_started_frame = -1 +local key_records = nil +local mouse_labels = nil -local function frame_number() +local function frame_num() local ok, frame = pcall(GameGetFrameNum) return ok and (tonumber(frame) or 0) or 0 end -local function tr(value, fallback) - return localization.translate(value, fallback) +local PRETTY_KEYS = { + RETURN = "ENTER", ESCAPE = "ESC", BACKSPACE = "BACKSPACE", SPACE = "SPACE", + TAB = "TAB", DELETE = "DELETE", INSERT = "INSERT", HOME = "HOME", END = "END", + PAGEUP = "PAGE UP", PAGEDOWN = "PAGE DOWN", LEFT = "LEFT", RIGHT = "RIGHT", + UP = "UP", DOWN = "DOWN", LSHIFT = "LEFT SHIFT", RSHIFT = "RIGHT SHIFT", + LCTRL = "LEFT CTRL", RCTRL = "RIGHT CTRL", LALT = "LEFT ALT", RALT = "RIGHT ALT", + CAPSLOCK = "CAPS LOCK", NUMLOCKCLEAR = "NUM LOCK", PRINTSCREEN = "PRINT SCREEN", + SCROLLLOCK = "SCROLL LOCK", PAUSE = "PAUSE", +} + +local function translated(value, fallback) + local text = GameTextGetTranslatedOrNot(value) + if type(text) ~= "string" or text == "" or text == value then return fallback or value end + return text +end + +local function pretty_key(name) + local raw = string.gsub(tostring(name or ""), "^Key_", "") + if PRETTY_KEYS[raw] ~= nil then return PRETTY_KEYS[raw] end + if string.match(raw, "^[a-z]$") then return string.upper(raw) end + if string.match(raw, "^%d$") then return raw end + if string.match(raw, "^F%d+$") then return raw end + raw = string.gsub(raw, "^KP_", "NUM ") + raw = string.gsub(raw, "_", " ") + return string.upper(raw) +end + +local function collect_keys() + if key_records ~= nil then return key_records end + local by_code = {} + for name, value in pairs(_G) do + if type(name) == "string" and string.match(name, "^Key_") and type(value) == "number" then + -- TAB is the menu/return lifecycle key and cannot safely mean possession too. + if name ~= "Key_TAB" then + local current = by_code[value] + if current == nil or #name < #current then by_code[value] = name end + end + end + end + local result = {} + for code, name in pairs(by_code) do + result[#result + 1] = { code=code, name=name, label=pretty_key(name) } + end + table.sort(result, function(a, b) + if a.code == b.code then return a.name < b.name end + return a.code < b.code + end) + key_records = result + return result +end + +local function collect_mouse_labels() + if mouse_labels ~= nil then return mouse_labels end + local result = {} + -- Different Noita builds expose slightly different symbolic names. We don't depend + -- on them for capture: the raw numeric button id is what InputIsMouseButtonJustDown + -- actually consumes. Symbols are used only to make the settings label nicer. + for name, value in pairs(_G) do + if type(name) == "string" and type(value) == "number" + and (string.match(name, "^[Mm]ouse_") or string.match(name, "^MOUSE_")) + then + local label = string.gsub(name, "^[Mm][Oo][Uu][Ss][Ee]_", "") + label = string.gsub(label, "_", " ") + result[value] = "MOUSE " .. string.upper(label) + end + end + mouse_labels = result + return result end local function pretty_binding(value) - return binding_capture.label(value, tr) + value = tostring(value or "") + local code = string.match(value, "^Mouse:(%-?%d+)$") + if code ~= nil then + code = tonumber(code) + local names = collect_mouse_labels() + return names[code] or ("MOUSE BUTTON " .. tostring(code)) + end + return pretty_key(value) end local function keybind_ui(mod_id_value, gui, in_main_menu, im_id, setting) - local setting_id = mod_setting_get_id(mod_id_value, setting) - local value = ModSettingGetNextValue(setting_id) - value = codec.normalize(value, setting.value_default) - local waiting = capture_setting_id == setting_id - local label = waiting and tr("$mcm_controls_waiting_short", "PRESS...") or pretty_binding(value) - local title = tr(setting.ui_name, setting.fallback_name) - - if GuiButton(gui, im_id, mod_setting_group_x_offset, 0, title .. ": [ " .. label .. " ]") then - if waiting then capture_setting_id, capture_started_frame = nil, -1 - else capture_setting_id, capture_started_frame = setting_id, frame_number() end + local id = mod_setting_get_id(mod_id_value, setting) + local value = ModSettingGetNextValue(id) + if type(value) ~= "string" or value == "" then value = setting.value_default end + local waiting = capture_setting_id == id + local title = translated(setting.ui_name, "Possession key") + local label = waiting + and translated("$mcm_setting_key_waiting", "PRESS A KEY OR MOUSE BUTTON...") + or pretty_binding(value) + + if GuiButton(gui, im_id, mod_setting_group_x_offset, 0, title .. ": " .. label) then + if waiting then + capture_setting_id = nil + capture_started_frame = -1 + else + capture_setting_id = id + capture_started_frame = frame_num() + end + waiting = capture_setting_id == id end mod_setting_tooltip(mod_id_value, gui, in_main_menu, setting) - if capture_setting_id ~= setting_id then return end - local event = binding_capture.poll(capture_started_frame, frame_number()) - if event == nil then return end - if event.kind == "binding" then ModSettingSetNextValue(setting_id, event.value, false) end - capture_setting_id, capture_started_frame = nil, -1 -end -local settings_by_section = {} -mod_settings = {} -for _, section in ipairs(registry.sections()) do - local category = { - category_id = "binding_section_" .. section.id, - ui_name = tr(section.key, section.fallback), - ui_description = tr("$mcm_setting_keybind_desc", "Assign shortcuts for this group."), - foldable = true, - _folded = section.id ~= "interface", - settings = {}, - } - settings_by_section[section.id] = category.settings - mod_settings[#mod_settings + 1] = category + if not waiting then return end + -- Don't capture the click that opened capture mode itself. + local frame = frame_num() + if frame <= capture_started_frame then return end + + for _, record in ipairs(collect_keys()) do + local ok, pressed = pcall(InputIsKeyJustDown, record.code) + if ok and pressed == true then + ModSettingSetNextValue(id, record.name, false) + capture_setting_id = nil + capture_started_frame = -1 + return + end + end + + -- Raw probing means X1/X2 and other additional mouse buttons work even if keycodes.lua + -- has no human-readable symbol for them. 0..31 comfortably covers normal mouse devices. + for button = 0, 31 do + local ok, pressed = pcall(InputIsMouseButtonJustDown, button) + if ok and pressed == true then + ModSettingSetNextValue(id, "Mouse:" .. tostring(button), false) + capture_setting_id = nil + capture_started_frame = -1 + return + end + end end -local interface_settings = settings_by_section.interface -if interface_settings ~= nil then - interface_settings[#interface_settings + 1] = { - id = "inventory_open_policy", - ui_name = tr("$mcm_setting_inventory_open_policy", "Inventory opening behavior"), - ui_description = tr("$mcm_setting_inventory_open_policy_desc", - "Choose whether the creative menu opens with the native inventory."), - value_default = "always_open", - values = { - {"always_open", tr("$mcm_setting_inventory_always_open", "Always Open")}, - {"always_closed", tr("$mcm_setting_inventory_always_closed", "Always Closed")}, - {"remember", tr("$mcm_setting_inventory_remember", "Remember Last State")}, - }, +mod_settings = { + { + id = "possession_key", + ui_name = "$mcm_setting_possession_key", + ui_description = "$mcm_setting_possession_key_desc", + value_default = "Key_g", scope = MOD_SETTING_SCOPE_RUNTIME, - } + ui_fn = keybind_ui, + }, +} + +function ModSettingsUpdate(init_scope) + mod_settings_update(mod_id, mod_settings, init_scope) end -for _, action in ipairs(registry.actions()) do - local section_settings = settings_by_section[action.section] - if section_settings ~= nil then - section_settings[#section_settings + 1] = { - id = "binding_" .. action.id, - ui_name = action.key, - fallback_name = action.fallback, - ui_description = tr("$mcm_setting_keybind_desc", "Click and press a key or mouse button."), - value_default = action.default, - scope = MOD_SETTING_SCOPE_RUNTIME, - ui_fn = keybind_ui, - } - end + +function ModSettingsGuiCount() + return mod_settings_gui_count(mod_id, mod_settings) end -function ModSettingsUpdate(init_scope) mod_settings_update(mod_id, mod_settings, init_scope) end -function ModSettingsGuiCount() return mod_settings_gui_count(mod_id, mod_settings) end -function ModSettingsGui(gui, in_main_menu) mod_settings_gui(mod_id, mod_settings, gui, in_main_menu) end +function ModSettingsGui(gui, in_main_menu) + mod_settings_gui(mod_id, mod_settings, gui, in_main_menu) +end diff --git a/metamorph_creative_menu/tests/TESTING.txt b/metamorph_creative_menu/tests/TESTING.txt index 1374427..947c86a 100644 --- a/metamorph_creative_menu/tests/TESTING.txt +++ b/metamorph_creative_menu/tests/TESTING.txt @@ -1,7 +1,7 @@ Metamorph: Creative Menu — test infrastructure ============================================== -This file documents the automated regression-test system only. Player-facing behavior is documented in the repository's multilingual `README*.md` files. The source-package `README.txt` only identifies the full development tree and is not an executable behavior contract. +This file documents the automated regression-test system only. The project behavior and architecture contract lives in the root `README.txt`. Testing principle ----------------- @@ -27,18 +27,6 @@ From the mod root: `run_all.py` automatically discovers all `*_contract.py` and `*_mock.lua` files, so a new test does not need to be added to a manual runner list. -Repository CI layers --------------------- - -The repository uses separate checks for separate failure domains: - -- `Check repository tooling` compiles the repository-level Python tools and runs their contract tests. -- `Check full source` runs source hygiene, the complete regression suite and the deterministic full-source archive build for development-tree changes. -- `Publish ready-to-install build` runs only for changes that can affect the player package. It rebuilds and validates the cleaned player archive before updating the stable release asset. -- `Import full MCM source archive` validates a manually uploaded full-source archive before it is allowed to replace the checked-in development tree. - -Keeping source CI separate from publishing means QA, tests, native-source documentation and other development-only changes are still verified without needlessly replacing the player release asset. - Behavior coverage ----------------- @@ -47,40 +35,27 @@ Behavior coverage Covered scenarios include, among others: - top menu/header survival when the active tab throws a runtime error; -- full title-bar movement, four-edge/corner resizing, persistent two-dimensional layout - and visible frame affordances; -- measured catalogue height shared by every scrollable section, including translated - narrow layouts and enlarged windows; -- native scroll-container coordinate ownership, preventing detached viewport contents - from starting at the screen origin; - vanilla inventory state preservation while the menu is open; -- spell catalog and wand operations, including transactional DROP and EW world-item handoff; +- spell catalog and wand operations, including synchronization; - item LMB world spawn and EW registration; - item RMB inventory delivery and inventory resync; - separate four-wand/four-item quick-slot capacity and safe world overflow; -- filled containers, shared Items/Materials liquid previews and hidden probe cleanup; -- authored material texture/tint inheritance and colour fallback behavior; -- nonstandard vanilla item roles including Sampo/Crystal Key/rewards without admitting - true creature hybrids; -- perk LMB spawn, vanilla/EW world-item handoff, canonical pickup and RMB direct apply; -- perk transaction rollback, creative-source accounting, bounded failed-cleanup backoff and special inverses; -- sender-side EW global-perk filtering that preserves stock EW semantics for ordinary pickups; -- MOBS catalog/prewarm, single LMB spawn and exact-coordinate drag spawn; +- filled containers and liquid-preview cleanup; +- perk LMB spawn, canonical pickup and RMB direct apply; +- perk transaction rollback and special inverses; +- MOBS catalog/prewarm and LMB spawn; - RMB transform with a complete form session; - TAB return and TAB/G separation; - form death -> living human + corpse handoff; - Boss Dragon/Maggot Tiny player-driver behavior; -- form combat, authority switching, transform-flash ownership, event-driven network metadata and runtime reset; +- form combat, authority switching, transform-flash ownership and runtime reset; - possession retirement only after confirmed transformation; - EW target-search tolerance/fallback; - time presets, advanced weather and EW latest-snapshot roundtrips; - World Rules/MagicNumbers/physics ownership, bounded scans, dirty publication and EW mailbox state; - equal user-facing host/peer rights; -- demand-driven player/location teleport streaming, safe free-space placement and - targeted BRING HERE requests; - companion health/spawn guard and peer-to-host request routing; -- EW RPC namespace, serialization/profiling, remote-form generation reset and compatibility-patch status; -- bounded material-paint mailboxes, backpressure recovery and stock-EW terrain/entity transport for peers without MCM; +- EW RPC namespace, serialization and compatibility-patch status; - bounded diagnostics and runtime scanning; - Alt-Tab/focus quarantine and TAB/G separation; - lazy loading of the built-in Z QA runner. diff --git a/metamorph_creative_menu/tests/action_bindings_mock.lua b/metamorph_creative_menu/tests/action_bindings_mock.lua deleted file mode 100644 index a92a37c..0000000 --- a/metamorph_creative_menu/tests/action_bindings_mock.lua +++ /dev/null @@ -1,120 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile -local frame=1 -local just_keys={} -local held_keys={} -local just_mouse={} -local held_mouse={} -local allowed=true -local text_active=false -local stored={['metamorph_creative_menu.possession_key']='CTRL+Key_k'} -local codes={Key_F4=4,Key_g=10,Key_k=11,Key_TAB=43,Key_ESCAPE=27,Key_DELETE=46,Key_BACKSPACE=8, - Key_LCTRL=100,Key_LSHIFT=101,Key_LALT=102,Key_ALTERASE=103} - -local keycodes={ - resolve=function(name) return codes[name] end, - available=function() - return { - {code=4,name='Key_F4',label='F4'}, {code=10,name='Key_g',label='G'}, - {code=11,name='Key_k',label='K'}, {code=27,name='Key_ESCAPE',label='ESC'}, - {code=46,name='Key_DELETE',label='DELETE'}, {code=100,name='Key_LCTRL',label='LEFT CTRL'}, - {code=101,name='Key_LSHIFT',label='LEFT SHIFT'}, {code=102,name='Key_LALT',label='LEFT ALT'}, - {code=103,name='Key_ALTERASE',label='ALTERASE'}, - } - end, - pretty_name=function(name) return string.upper(string.gsub(name,'^Key_','')) end, -} - -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/platform/noita/keycodes.lua' then return keycodes end - if path=='mods/metamorph_creative_menu/files/platform/noita/input_guard.lua' then - return {actions_allowed=function() return allowed end} - end - if path=='mods/metamorph_creative_menu/files/platform/noita/text_entry_guard.lua' then - return {active=function() return text_active end} - end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -GameGetFrameNum=function() return frame end -ModSettingGet=function(id) return stored[id] end -ModSettingSet=function(id,value) stored[id]=value end -ModSettingSetNextValue=function(id,value) stored[id]=value end -InputIsKeyJustDown=function(code) return just_keys[code]==true end -InputIsKeyDown=function(code) return held_keys[code]==true end -InputIsMouseButtonJustDown=function(code) return just_mouse[code]==true end -InputIsMouseButtonDown=function(code) return held_mouse[code]==true end - -METAMORPH_CREATIVE_MENU_ACTION_BINDINGS=nil -METAMORPH_CREATIVE_MENU_ACTION_REGISTRY=nil -METAMORPH_CREATIVE_MENU_BINDING_CODEC=nil -METAMORPH_CREATIVE_MENU_BINDING_CAPTURE=nil -local bindings=assert(native_dofile(root..'/files/platform/noita/action_bindings.lua')) -local codec=assert(native_dofile(root..'/files/core/binding_codec.lua')) -local malformed, malformed_reason=codec.parse('Key_g+Key_k') -assert(malformed==nil and malformed_reason=='multiple_base','binding codec accepted multiple base keys') - --- Defaults work immediately and an action can only be consumed once per frame. -just_keys[4]=true -bindings.update() -assert(bindings.consume('menu_toggle')==true and bindings.consume('menu_toggle')==false, - 'default menu binding was not edge-triggered/consumable') - --- The old possession setting is migrated once and modifiers are matched exactly. -frame=2; just_keys={ [11]=true }; held_keys={ [100]=true } -bindings.update() -assert(bindings.consume('possession')==true,'legacy modified possession key was not migrated') -assert(stored['metamorph_creative_menu.binding_possession']=='CTRL+Key_k','legacy binding was not persisted') -frame=3; just_keys={ [11]=true }; held_keys={} -bindings.update() -assert(bindings.just_pressed('possession')==false,'unmodified key triggered a CTRL binding') - --- Held actions support remappable mouse buttons (the material brush path). -assert(bindings.set('paint_draw','SHIFT+Mouse:4')) -held_keys={[101]=true}; held_mouse={[4]=true} -assert(bindings.is_down('paint_draw')==true,'modified held mouse binding was not recognized') -held_keys={} -assert(bindings.is_down('paint_draw')==false,'held binding ignored exact modifiers') - --- Conflicts are observable rather than silently choosing one action. -assert(bindings.set('tab_previous','Key_k')) -assert(bindings.set('tab_next','Key_k')) -local conflicts=bindings.conflicts('tab_previous') -assert(#conflicts==1 and conflicts[1].id=='tab_next','binding conflict was not reported') -frame=4; just_keys={[11]=true}; held_keys={} -bindings.update() -assert(bindings.consume('tab_previous')==true and bindings.consume('tab_next')==false, - 'one physical conflict dispatched more than one action') - --- In-game capture accepts modifiers and suppresses normal actions on the capture frame. -frame=10; just_keys={}; held_keys={} -assert(bindings.start_capture('menu_close')) -bindings.update() -frame=11; just_keys={[11]=true}; held_keys={[101]=true} -bindings.update() -assert(bindings.get('menu_close')=='SHIFT+Key_k','capture lost key modifiers') -assert(bindings.just_pressed('tab_next')==false,'capture leaked the key into gameplay actions') - --- DELETE is a deliberate unbind operation; reset restores the registry default. -frame=12; just_keys={}; held_keys={}; assert(bindings.start_capture('menu_toggle')); bindings.update() -frame=13; just_keys={[46]=true}; bindings.update() -assert(bindings.get('menu_toggle')=='NONE','DELETE did not clear captured binding') -assert(bindings.reset('menu_toggle') and bindings.get('menu_toggle')=='Key_F4','default reset failed') - --- SDL's ALTERASE key contains the letters ALT but is an ordinary bindable key. -frame=14; just_keys={}; held_keys={}; assert(bindings.start_capture('menu_close')); bindings.update() -frame=15; just_keys={[103]=true}; bindings.update() -assert(bindings.get('menu_close')=='Key_ALTERASE','ALTERASE was mistaken for the ALT modifier') - -text_active=true; allowed=true; frame=16; just_keys={[4]=true}; held_keys={[101]=true}; held_mouse={[4]=true} -bindings.update() -assert(bindings.just_pressed('menu_toggle')==false,'focused text leaked a key into action bindings') -assert(bindings.is_down('paint_draw')==false,'focused text leaked a held binding into gameplay') - -text_active=false; allowed=false; frame=17; just_keys={[4]=true}; held_keys={}; held_mouse={} -bindings.update() -assert(bindings.just_pressed('menu_toggle')==false,'input quarantine did not block action registry') - -print('action_bindings=PASS defaults=true migration=true modifiers=true malformed_rejected=true alterase=true mouse_hold=true conflicts=true capture=true text_focus=true quarantine=true') diff --git a/metamorph_creative_menu/tests/architecture_contract.py b/metamorph_creative_menu/tests/architecture_contract.py index a54f2fc..3bd0932 100644 --- a/metamorph_creative_menu/tests/architecture_contract.py +++ b/metamorph_creative_menu/tests/architecture_contract.py @@ -142,10 +142,17 @@ def visit(node): all_runtime_sources.append((path, read(path))) allowed_root_lua = {'item_registry.lua', 'creature_registry.lua'} +developer_only_modules = { + 'files/features/creatures/diagnostics.lua', + 'files/integrations/ew/bridge/qa.lua', + 'files/integrations/ew/bridge/qa_reserved.lua', +} for path in sorted((root / 'files').rglob('*.lua')): relative = path.relative_to(root).as_posix() if path.parent == root / 'files' and path.name in allowed_root_lua: continue + if relative.startswith('files/diagnostics/') or relative.startswith('files/qa/') or relative in developer_only_modules: + continue runtime_path = 'mods/metamorph_creative_menu/' + relative referenced = any(runtime_path in source for source_path, source in all_runtime_sources if source_path != path) if not referenced: diff --git a/metamorph_creative_menu/tests/behavior_coverage_contract.py b/metamorph_creative_menu/tests/behavior_coverage_contract.py index 5103f6f..6604ee5 100644 --- a/metamorph_creative_menu/tests/behavior_coverage_contract.py +++ b/metamorph_creative_menu/tests/behavior_coverage_contract.py @@ -8,113 +8,45 @@ # refactored into any files as long as at least one executable regression scenario still # proves its externally important behavior. BEHAVIORS = { - 'assignable_actions_support_modifiers_mouse_conflicts_capture_and_migration': { - 'action_bindings_mock.lua', 'settings_action_registry_mock.lua', - }, - 'menu_has_useful_default_direct_navigation_persistence_windows_layout_and_readable_controls': { - 'menu_navigation_bindings_mock.lua', 'ui_responsive_layout_mock.lua', - 'controls_scroll_layout_mock.lua', 'ui_adaptive_sections_mock.lua', - 'menu_inventory_visibility_policy_mock.lua', - 'menu_inventory_policy_controller_mock.lua', - }, - 'long_menu_scroll_is_native_contained_grid_fits_and_horizontal_wheel_is_owned': { - 'scroll_grid_model_mock.lua', 'runtime_scroll_viewport_mock.lua', - }, 'menu_survives_tab_runtime_error': {'menu_error_isolation_mock.lua'}, - 'runtime_update_failure_is_isolated': {'runtime_update_isolation_mock.lua'}, - 'network_modules_do_not_touch_worldstate_during_source_load': {'startup_no_world_globals_mock.lua'}, 'menu_inventory_state_is_preserved': {'menu_inventory_guard_mock.lua'}, - 'spells_catalog_and_wand_operations_sync': { - 'spell_catalog_mock.lua', 'spell_service_mock.lua', 'spell_inventory_transfer_mock.lua', 'spell_permanent_service_mock.lua', - 'drag_drop_release_mock.lua', 'native_inventory_drop_bounds_mock.lua', 'wand_always_cast_drop_click_mock.lua', - }, - 'spells_drag_uses_press_origin_survives_reflow_and_commits_transactionally_once': { - 'drag_drop_lifecycle_mock.lua', 'horizontal_strip_drag_ownership_mock.lua', 'spell_drag_world_transaction_mock.lua', - }, - 'spells_catalog_renders_real_placement_surfaces_with_shared_drag_sources': { - 'spells_interaction_surface_mock.lua', 'spell_inventory_grid_mock.lua', - }, - 'catalog_search_is_multilingual_ranked_and_requires_explicit_pointer_focus': { - 'search_engine_mock.lua', 'search_field_cache_mock.lua', 'search_focus_mock.lua', 'text_focus_owner_mock.lua', - }, - 'wand_blueprints_preserve_card_state_and_creative_spawning_does_not_unlock_progression': { - 'wand_blueprint_codec_mock.lua', 'wand_blueprints_v2_mock.lua', 'wand_presets_persistence_mock.lua', 'wand_presets_copy_mock.lua', 'wand_presets_ui_mock.lua', 'wand_history_mock.lua', 'history_external_mock.lua', 'spell_factory_unlock_mock.lua', - }, - 'wand_editor_stats_appearance_and_skin_geometry_are_transactional': { - 'wand_appearance_mock.lua', 'wand_appearance_editor_mock.lua', 'wand_skin_catalog_mock.lua', - 'fixed_numeric_editor_bounds_mock.lua', 'fixed_numeric_focus_draft_mock.lua', 'wand_service_slot_limit_mock.lua', 'negative_wand_mock.lua', - 'wand_stats_layout_mock.lua', - }, - 'items_lmb_spawn_rmb_inventory_sync_and_nonstandard_vanilla_item_roles': { - 'item_world_spawn_sync_mock.lua', 'item_inventory_sync_mock.lua', 'item_outlier_catalog_mock.lua', - 'item_icon_descriptor_mock.lua', 'item_icon_transient_fallback_mock.lua', 'item_outbox_failure_mock.lua', - }, - 'items_catalog_drag_uses_shared_threshold_exact_world_coordinates_inventory_cancel_and_transactional_cleanup': { - 'items_drag_interaction_mock.lua', 'item_drag_transaction_mock.lua', - }, + 'spells_catalog_and_wand_operations_sync': {'spell_catalog_mock.lua', 'spell_service_mock.lua'}, + 'items_lmb_spawn_and_rmb_inventory_sync': {'item_world_spawn_sync_mock.lua', 'item_inventory_sync_mock.lua'}, 'items_four_wand_four_item_capacity_and_overflow': {'inventory_capacity_mock.lua'}, - 'items_liquid_lmb_rmb_and_preview_cleanup': { - 'item_liquid_service_mock.lua', 'item_liquid_preview_mock.lua', 'liquid_tile_render_mock.lua', - 'item_all_liquid_catalog_mock.lua', 'item_liquid_localized_search_mock.lua', - }, - 'materials_catalog_selection_and_world_painting': { - 'material_catalog_mock.lua', 'material_complete_catalog_mock.lua', 'material_vanilla_data_mock.lua', 'material_painter_mock.lua', - 'material_grid_backend_mock.lua', 'material_feature_wiring_mock.lua', - 'material_tab_deferred_catalog_mock.lua', 'material_tab_preview_mock.lua', - 'ew_material_pixel_scene_patch_mock.lua', - 'material_paint_transport_mock.lua', 'ew_material_stock_transport_mock.lua', - 'ew_material_backpressure_mock.lua', - }, + 'items_liquid_lmb_rmb_and_preview_cleanup': {'item_liquid_service_mock.lua', 'item_liquid_preview_mock.lua'}, 'perks_spawn_pickup_apply_transaction': { 'perk_spawn_mock.lua', 'perk_direct_apply_mock.lua', 'perk_pickup_hook_mock.lua', 'perk_external_pickup_mock.lua', - 'perk_drag_interaction_mock.lua', }, - 'perks_global_state_is_transactional': {'perk_global_journal_mock.lua', 'perk_mutation_journal_mock.lua', 'perk_owned_residue_failure_mock.lua', 'perk_pending_cleanup_timeout_mock.lua', 'perk_failed_pickup_rollback_backoff_mock.lua'}, + 'perks_global_state_is_transactional': {'perk_global_journal_mock.lua', 'perk_mutation_journal_mock.lua', 'perk_owned_residue_failure_mock.lua', 'perk_pending_cleanup_timeout_mock.lua'}, 'perks_stacks_remove_without_residue_across_repeated_cycles': {'perk_service_repeated_cycle_mock.lua', 'perk_player_rebind_mock.lua', 'perk_service_rebind_wiring_mock.lua'}, - 'perks_mass_take_and_remove_are_bounded_transactional_jobs': {'perk_batch_queue_mock.lua', 'perk_tab_batch_ui_mock.lua', 'perk_scroll_surface_mock.lua'}, - 'perks_non_perk_essences_and_curses_are_removable': {'non_perk_states_mock.lua', 'perk_states_grid_mock.lua'}, 'perks_special_inverse_cleanup': { 'extra_mana_mock.lua', 'gamble_mock.lua', 'ghost_extra_inverse_mock.lua', 'ghost_root_ownership_mock.lua', 'beamstone_ownership_mock.lua', 'perk_peer_root_isolation_mock.lua', 'protection_presentation_mock.lua', 'lukki_order_mock.lua', 'perk_radar_invisibility_regression_mock.lua', 'perk_locomotion_guard_mock.lua', 'perk_always_cast_ownership_mock.lua', - 'perk_tab_unsafe_removal_mock.lua', }, 'creature_catalog_and_spawn_surface': { - 'creature_catalog_mock.lua', 'creature_spawn_mock.lua', 'creature_drag_interaction_mock.lua', 'creature_kolmi_spawn_compat_mock.lua', 'creature_kolmi_bootstrap_mock.lua', 'creature_kolmi_encounter_controller_mock.lua', 'creature_kolmi_combat_verify_mock.lua', - 'ew_creature_track_bridge_mock.lua', 'ew_boss_network_pressure_mock.lua', 'form_network_pressure_mock.lua', 'creature_structural_admission_mock.lua', 'creature_compatibility_policy_mock.lua', + 'creature_catalog_mock.lua', 'creature_spawn_mock.lua', + 'creature_structural_admission_mock.lua', 'creature_compatibility_policy_mock.lua', 'creature_exact_target_mock.lua', 'creature_menu_visibility_mock.lua', }, - 'form_rmb_transform_creates_session_and_chained_switch_preserves_human_frame': {'form_transform_session_mock.lua', 'form_chained_transform_human_frame_mock.lua', 'creature_exact_target_mock.lua', 'form_network_marker_hotpath_mock.lua'}, - 'form_tab_returns_to_human_without_g_collision_and_after_save_recovery': { - 'form_tab_return_mock.lua', 'form_return_controls_mock.lua', - 'possession_keybind_routing_mock.lua', 'form_save_recovery_mock.lua', - }, - 'form_native_and_external_death_restore_human_and_preserve_corpse': { + 'form_rmb_transform_creates_session': {'form_transform_session_mock.lua', 'creature_exact_target_mock.lua'}, + 'form_tab_returns_to_human_without_g_collision': {'form_tab_return_mock.lua', 'possession_keybind_routing_mock.lua'}, + 'form_native_death_restores_human_and_preserves_corpse': { 'form_death_guard_mock.lua', 'form_death_handoff_integration_mock.lua', - 'form_external_polymorph_adoption_mock.lua', 'form_corpse_tree_freeze_mock.lua', - 'form_death_channel_ack_mock.lua', 'ew_form_death_intercept_patch_mock.lua', - 'form_network_body_retire_mock.lua', }, - 'vanilla_gameover_exposes_native_revive_and_restores_player_authority': { - 'death_recovery_native_service_mock.lua', 'native_gameover_ew_gate_mock.lua', - 'death_recovery_ew_disabled_mock.lua', - }, - 'giant_forms_disable_ai_and_accept_player_input': {'boss_dragon_adapter_mock.lua', 'form_physics_static_ik_mock.lua', 'form_physics_flying_ik_mock.lua'}, + 'giant_forms_disable_ai_and_accept_player_input': {'boss_dragon_adapter_mock.lua'}, 'form_runtime_combat_authority_and_presentation': { - 'form_runtime_reset_mock.lua', 'form_combat_laser_mock.lua', 'form_combat_singleton_cleanup_mock.lua', 'form_projectile_failure_mock.lua', - 'form_attack_profile_mock.lua', 'form_primary_projectile_replay_mock.lua', 'form_multiattack_replay_mock.lua', 'form_ranged_aim_replay_mock.lua', 'form_rotating_muzzle_origin_mock.lua', 'form_static_turret_arc_mock.lua', 'form_contextual_attack_fallback_mock.lua', 'form_dormant_attack_mock.lua', 'form_dynamic_attack_state_mock.lua', 'form_late_first_attack_mock.lua', 'form_scripted_attack_math_mock.lua', 'form_scripted_attack_replay_mock.lua', 'form_scripted_attack_state_mock.lua', - 'form_player_authority_mock.lua', 'form_transform_flash_mock.lua', 'form_tank_special_case_removal_mock.lua', 'form_static_tank_aim_mock.lua', 'form_static_weapon_presentation_mock.lua', 'form_manual_actions_mock.lua', 'form_wand_ghost_controller_mock.lua', 'form_fish_breathing_mock.lua', + 'form_runtime_reset_mock.lua', 'form_combat_laser_mock.lua', 'form_projectile_failure_mock.lua', + 'form_player_authority_mock.lua', 'form_transform_flash_mock.lua', }, 'g_possession_replaces_original_and_preserves_position': { 'possession_replacement_mock.lua', 'possession_client_fallback_mock.lua', - 'possession_remote_replica_mock.lua', 'possession_des_ack_retire_mock.lua', - 'ew_mailbox_drain_mock.lua', 'possession_tree_retirement_mock.lua', - 'possession_creature_policy_mock.lua', 'possession_fast_reject_mock.lua', 'possession_crypt_replacement_mock.lua', - 'possession_robobase_replacement_mock.lua', 'possession_outbox_retention_mock.lua', + 'possession_creature_policy_mock.lua', 'possession_crypt_replacement_mock.lua', + 'possession_robobase_replacement_mock.lua', }, - 'effects_apply_become_active_remove_and_expire': {'effect_lifecycle_mock.lua', 'effect_variant_catalog_mock.lua', 'effect_editor_catalog_mock.lua', 'effect_control_lock_safety_mock.lua', 'effect_lifetime_entity_mock.lua', 'effect_perk_duration_mock.lua'}, - 'weather_time_preset_precise_fields_and_ew_snapshot': {'weather_state_sync_mock.lua', 'weather_clouds_mock.lua', 'lightning_sync_mock.lua'}, + 'effects_apply_become_active_remove_and_expire': {'effect_lifecycle_mock.lua'}, + 'weather_time_preset_precise_fields_and_ew_snapshot': {'weather_state_sync_mock.lua'}, 'world_rules_apply_reset_and_network_dirty_state': { 'world_rules_lifecycle_mock.lua', 'world_rules_sync_mock.lua', 'gravity_mock.lua', 'world_state_native_restore_mock.lua', 'world_rules_physics_ownership_mock.lua', @@ -123,35 +55,25 @@ 'world_rules_stain_ownership_mock.lua', 'world_rules_magic_multiplier_mock.lua', 'world_rules_bridge_restart_mailbox_mock.lua', 'world_rules_recovery_write_budget_mock.lua', 'world_rules_world_state_roundtrip_mock.lua', 'world_rules_magic_partial_rollback_mock.lua', - 'world_rules_sync_local_intent_race_mock.lua', 'world_rules_time_dt_owner_mock.lua', 'false_recovery_mock.lua', 'physics_reentry_mock.lua', + 'world_rules_sync_local_intent_race_mock.lua', }, 'host_and_client_have_equal_menu_rights': {'equal_peer_rights_mock.lua'}, - 'teleports_are_demand_driven_stream_destinations_use_free_space_and_target_bring_requests': { - 'player_tools_safe_teleport_mock.lua', 'teleport_pending_mock.lua', 'teleport_current_coordinates_ui_mock.lua', 'ew_teleport_bring_mock.lua', 'ew_mailbox_drain_mock.lua', - }, - 'companion_spawn_health_and_network_request': {'companion_guard_mock.lua', 'companion_network_des_mock.lua', 'client_clone_ai_mock.lua', 'clone_idle_scan_mock.lua'}, - 'ew_rpc_protocol_and_serialization_are_stable': {'ew_protocol_mock.lua', 'ew_serialization_mock.lua', 'ew_polymorph_profile_patch_mock.lua', 'form_remote_prepare_profile_mock.lua', 'form_pose_entity_generation_mock.lua'}, - 'creative_perk_network_visibility_preserves_stock_ew_semantics': { - 'perk_transaction_source_mock.lua', 'ew_perk_visibility_mock.lua', 'ew_perk_sync_patch_mock.lua', - 'creative_perk_registration_mock.lua', 'creative_perk_peer_consume_mock.lua', - }, - 'standalone_noitapatcher_bootstrap_and_ew_reuse': {'standalone_patcher_mock.lua', 'patcher_bridge_ew_shared_path_mock.lua'}, - 'ew_compatibility_source_patches_are_verified_and_runtime_bridges_remain_bounded': { - 'ew_resilience_status_mock.lua', 'ew_world_sync_patch_mock.lua', 'ew_perk_sync_patch_mock.lua', 'ew_perk_helper_sync_patch_mock.lua', 'ew_perk_mutation_sync_patch_mock.lua', 'ew_perk_runtime_guard_mock.lua', 'ew_wand_pickup_guard_mock.lua', 'ew_kolmi_crosscall_failopen_mock.lua', 'ew_kolmi_spawn_failopen_mock.lua', - }, - 'ew_global_boss_tracking_is_demoted_before_native_des_classification': { - 'ew_boss_global_demote_mock.lua', 'creature_kolmi_arena_dedupe_mock.lua', 'ew_kolmi_lifecycle_mock.lua', 'ew_boss_lifecycle_mock.lua', + 'companion_spawn_health_and_network_request': {'companion_guard_mock.lua', 'companion_request_mock.lua'}, + 'ew_rpc_protocol_and_serialization_are_stable': {'ew_protocol_mock.lua', 'ew_serialization_mock.lua'}, + 'standalone_noitapatcher_bootstrap_and_ew_reuse': {'standalone_patcher_mock.lua'}, + 'ew_compatibility_patch_failures_are_visible': { + 'ew_resilience_status_mock.lua', 'ew_perk_sync_patch_mock.lua', 'ew_perk_helper_sync_patch_mock.lua', 'ew_perk_mutation_sync_patch_mock.lua', 'ew_perk_runtime_guard_mock.lua', }, 'qa_is_lazy_and_has_consistent_state_graph': {'qa_controller_mock.lua', 'qa_baselines_scope_mock.lua'}, 'release_dev_mode_unloads_runtime_diagnostics_and_preserves_ew_protocol_slot': { 'dev_mode_runtime_gate_mock.lua', 'ew_dev_mode_bridge_gate_mock.lua', }, 'diagnostics_are_bounded_and_runtime_scanner_executes': { - 'bounded_log_mock.lua', 'diagnostics_scanner_load_mock.lua', 'global_text_mock.lua', + 'bounded_log_mock.lua', 'diagnostics_scanner_load_mock.lua', }, 'input_focus_and_tab_g_keycodes_are_safe': {'keycodes_mock.lua', 'input_guard_mock.lua'}, 'catalog_metadata_localization_and_generated_form_prewarm': { - 'entity_catalog_icon_contract_mock.lua', 'form_prewarm_mock.lua', 'localization_context_fallback_mock.lua', + 'entity_catalog_icon_contract_mock.lua', 'form_prewarm_mock.lua', }, } @@ -160,7 +82,6 @@ 'perk_catalog_mock.lua', # catalogue normalization/uniqueness 'perk_transaction_capture_wiring_mock.lua', # transaction journal delegation 'xml_utils_mock.lua', # generated XML escaping primitive - 'utf8_text_regression_mock.lua', # engine-independent UTF-8 truncation primitive } missing = [] @@ -170,7 +91,7 @@ missing.append(f'{behavior}: missing {", ".join(absent)}') # Every major user-facing feature must have explicit behavioral ownership in the map. -required_domains = ('spells', 'items', 'materials', 'perks', 'creature', 'form', 'possession', 'effects', 'weather', 'world_rules') +required_domains = ('spells', 'items', 'perks', 'creature', 'form', 'possession', 'effects', 'weather', 'world_rules') covered_names = ' '.join(BEHAVIORS) for domain in required_domains: if domain not in covered_names: diff --git a/metamorph_creative_menu/tests/client_clone_ai_mock.lua b/metamorph_creative_menu/tests/client_clone_ai_mock.lua deleted file mode 100644 index 565cca3..0000000 --- a/metamorph_creative_menu/tests/client_clone_ai_mock.lua +++ /dev/null @@ -1,41 +0,0 @@ -local root=assert(arg[1]) -local frame=1 -local owned=true -local velocity={0,0} -local vars={ - [201]={name='mcm_companion_owner',value_int=1}, - [202]={name='mcm_companion_target',value_int=0}, - [203]={name='ew_gid_lid',value_bool=true}, -} -local function comp_by_tag(tag) if tag=='mcm_companion_owner' then return 201 elseif tag=='mcm_companion_target' then return 202 end return 0 end -function GetUpdatedEntityID() return 10 end -function EntityGetIsAlive(e) return e==10 or e==1 end -function EntityGetFirstComponentIncludingDisabled(e,k,tag) - if e~=10 then return 0 end - if k=='ControlsComponent' then return 100 elseif k=='Inventory2Component' then return 101 elseif k=='CharacterDataComponent' then return 102 elseif k=='VariableStorageComponent' then return comp_by_tag(tag) end - return 0 -end -function EntityGetComponentIncludingDisabled(e,k) if e==10 and k=='VariableStorageComponent' then return {201,202,203} end return {} end -function ComponentGetValue2(c,f) - if vars[c] then if c==203 and f=='value_bool' then return owned end return vars[c][f] end - if c==101 and (f=='mActualActiveItem' or f=='mActiveItem') then return 0 end - if c==102 and f=='mVelocity' then return velocity[1],velocity[2] end - return 0 -end -function ComponentSetValue2(c,f,a,b) if vars[c] then vars[c][f]=a elseif c==102 and f=='mVelocity' then velocity={a,b} end end -function ModIsEnabled(name) return name=='quant.ew' end -function EntityHasTag(e,t) if e==1 and t=='player_unit' then return true end if e==10 and t=='metamorph_creative_menu_companion' then return true end return false end -function EntityGetWithTag() return {} end -function EntityGetTransform(e) if e==10 then return 0,0 elseif e==1 then return 100,0 end return 0,0 end -function EntityGetFirstHitboxCenter(e) return EntityGetTransform(e) end -function EntityGetInRadiusWithTag() return {} end -function GameGetFrameNum() return frame end -function RaytracePlatforms() return false end -function ModDoesFileExist() return false end -owned=true; velocity={0,0}; frame=1 -assert(loadfile(root..'/files/features/companion/ai.lua'))() -assert(velocity[1]>0,'EW client-owned clone returned before running AI') -owned=false; velocity={0,0}; frame=2 -assert(loadfile(root..'/files/features/companion/ai.lua'))() -assert(velocity[1]==0 and velocity[2]==0,'remote EW replica ran local AI') -print('client_clone_ai=PASS client_owner_runs=true remote_replica_passive=true') diff --git a/metamorph_creative_menu/tests/clone_idle_scan_mock.lua b/metamorph_creative_menu/tests/clone_idle_scan_mock.lua deleted file mode 100644 index c9d46f5..0000000 --- a/metamorph_creative_menu/tests/clone_idle_scan_mock.lua +++ /dev/null @@ -1,37 +0,0 @@ -local root=assert(arg[1]) -local frame=1 -local scans=0 -local velocity={0,0} -local vars={ - [201]={name='mcm_companion_owner',value_int=1}, - [202]={name='mcm_companion_target',value_int=0}, - [203]={name='ew_gid_lid',value_bool=true}, -} -local function comp_by_tag(tag) if tag=='mcm_companion_owner' then return 201 elseif tag=='mcm_companion_target' then return 202 end return 0 end -function GetUpdatedEntityID() return 13 end -function EntityGetIsAlive(e) return e==13 or e==1 end -function EntityGetFirstComponentIncludingDisabled(e,k,tag) - if e~=13 then return 0 end - if k=='ControlsComponent' then return 100 elseif k=='Inventory2Component' then return 101 elseif k=='CharacterDataComponent' then return 102 elseif k=='VariableStorageComponent' then return comp_by_tag(tag) end - return 0 -end -function EntityGetComponentIncludingDisabled(e,k) if e==13 and k=='VariableStorageComponent' then return {201,202,203} end return {} end -function ComponentGetValue2(c,f) - if vars[c] then return vars[c][f] end - if c==101 and (f=='mActualActiveItem' or f=='mActiveItem') then return 0 end - if c==102 and f=='mVelocity' then return velocity[1],velocity[2] end - return 0 -end -function ComponentSetValue2(c,f,a,b) if vars[c] then vars[c][f]=a elseif c==102 and f=='mVelocity' then velocity={a,b} end end -function ModIsEnabled(name) return name=='quant.ew' end -function EntityHasTag(e,t) if e==1 and t=='player_unit' then return true end if e==13 and t=='metamorph_creative_menu_companion' then return true end return false end -function EntityGetWithTag() return {} end -function EntityGetTransform(e) if e==13 then return 0,0 elseif e==1 then return 20,0 end return 0,0 end -function EntityGetFirstHitboxCenter(e) return EntityGetTransform(e) end -function EntityGetInRadiusWithTag() scans=scans+1; return {} end -function GameGetFrameNum() return frame end -function RaytracePlatforms() return false end -function ModDoesFileExist() return false end -for f=1,30 do frame=f; assert(loadfile(root..'/files/features/companion/ai.lua'))() end -assert(scans==3,'idle clone scanned every frame or wrong cadence: '..tostring(scans)) -print('clone_idle_scan=PASS scans_3_of_30=true') diff --git a/metamorph_creative_menu/tests/companion_network_des_mock.lua b/metamorph_creative_menu/tests/companion_network_des_mock.lua deleted file mode 100644 index bf961b3..0000000 --- a/metamorph_creative_menu/tests/companion_network_des_mock.lua +++ /dev/null @@ -1,17 +0,0 @@ -local root=assert(arg[1],'root required') -local function read(path) - local f=assert(io.open(root..'/'..path,'rb')); local s=f:read('*a'); f:close(); return s -end -local source=read('files/features/companion/player_avatar.lua') -assert(string.find(source,'local CLONE_PATH = "data/entities/misc/player_drone_clone.xml"',1,true), - 'PLAYER network base is not a vanilla Noita entity available to stock peers') -assert(not string.find(source,'local CLONE_PATH = "mods/metamorph_creative_menu/',1,true), - 'PLAYER still exposes an MCM-only filename to native DES') -assert(string.find(source,'ensure_companion_structure(clone)',1,true), - 'vanilla network base is not upgraded with the local companion controls/inventory') -assert(string.find(source,'EntityAddTag(clone, tag)',1,true), - 'vanilla PLAYER base is not made DES-eligible as an enemy') -assert(not string.find(source,'companion_request.lua',1,true),'client companion still depends on an MCM RPC handler at the host') -assert(string.find(source,'ew_world_entities.is_tracked(clone)',1,true),'MCM-only controller is not delayed until after DES identity exists') -assert(string.find(source,'"local_des"',1,true),'network spawn no longer reports local DES ownership') -print('companion_network_des=PASS vanilla_filename=true stock_peer_no_mcm=true local_controller_after_des=true') diff --git a/metamorph_creative_menu/tests/companion_request_mock.lua b/metamorph_creative_menu/tests/companion_request_mock.lua new file mode 100644 index 0000000..cc3a54c --- /dev/null +++ b/metamorph_creative_menu/tests/companion_request_mock.lua @@ -0,0 +1,23 @@ +local root = assert(arg[1], "root required") +local stored = {} +local frame = 321 + +function GameGetFrameNum() return frame end +function GlobalsSetValue(key, value) stored[key] = tostring(value) end + +METAMORPH_CREATIVE_MENU_EW_COMPANION_REQUEST = nil +local request = assert(dofile(root .. "/files/integrations/ew/companion_request.lua")) + +local ok, reason = request.enqueue(48, -12) +assert(ok == true and reason == "queued", "companion request was not queued") +assert(stored.mcm_companion_request_x_v1 == "48", "x offset mailbox mismatch") +assert(stored.mcm_companion_request_y_v1 == "-12", "y offset mailbox mismatch") +assert(stored.mcm_companion_request_seq_v1 == "321:1", "first request sequence mismatch") + +frame = 322 +request.enqueue(nil, nil) +assert(stored.mcm_companion_request_x_v1 == "32", "default x offset changed") +assert(stored.mcm_companion_request_y_v1 == "-4", "default y offset changed") +assert(stored.mcm_companion_request_seq_v1 == "322:2", "request sequence did not advance") + +print("companion_request=PASS mailbox_owned_by_integration=true") diff --git a/metamorph_creative_menu/tests/controls_scroll_layout_mock.lua b/metamorph_creative_menu/tests/controls_scroll_layout_mock.lua deleted file mode 100644 index 2d6924c..0000000 --- a/metamorph_creative_menu/tests/controls_scroll_layout_mock.lua +++ /dev/null @@ -1,67 +0,0 @@ -local root = assert(arg[1], "root required") - -local events = {} -local in_scroll = false -local ui = { - gui=function() return 1 end, - tr=function(_, fallback) return fallback end, - wrapped_text=function() end, - colored_text=function() end, - search_input=function(value) return value end, - scroll_height=function() return 100 end, - begin_scroll_viewport=function() - events[#events+1]="scroll_begin" - events[#events+1]="scroll_vertical" - return {content_width=220,padding_left=2,padding_top=2,state={offset=0}} - end, - end_scroll_viewport=function() events[#events+1]="scroll_end" end, - button=function() return false end, - button_grid=function() return nil end, - confirm_button=function() return false end, - rank_entries=function(_,entries) return entries end, - search_status=function() end, - mark_hovered=function() end, -} -local registry = { - sections=function() return {{id="menu",key="$section",fallback="MENU"}} end, - actions=function() return {{id="toggle",section="menu",key="$action",fallback="TOGGLE",default="F1"}} end, - get=function() return nil end, -} -local bindings = { - update=function() end, - label=function() return "F1" end, - capture_action=function() return nil end, - registry=function() return registry end, - conflicts=function() return {} end, - get=function() return "F1" end, -} - -local original_dofile = dofile -function dofile(path) - if path == "mods/metamorph_creative_menu/files/ui/runtime.lua" then return ui end - if path == "mods/metamorph_creative_menu/files/platform/noita/action_bindings.lua" then return bindings end - return original_dofile(path) -end -function GameGetFrameNum() return 1 end -function GuiLayoutBeginVertical() - events[#events+1]=in_scroll and "scroll_vertical" or "outer_vertical" -end -function GuiLayoutBeginHorizontal() events[#events+1]="horizontal" end -function GuiLayoutEnd() events[#events+1]="layout_end" end -function GuiBeginScrollContainer() error("controls bypassed shared scroll viewport") end -function GuiEndScrollContainer() error("controls bypassed shared scroll viewport") end -function GuiGetPreviousWidgetInfo() return false,false,false end - -local tab = assert(loadfile(root .. "/files/ui/tabs/controls.lua"))() -tab.draw(1, 240, 160) - -local begin_index, vertical_index, end_index = nil, nil, nil -for index, event in ipairs(events) do - if event == "scroll_begin" then begin_index=index end - if event == "scroll_vertical" and vertical_index == nil then vertical_index=index end - if event == "scroll_end" then end_index=index end -end -assert(begin_index ~= nil and vertical_index == begin_index + 1 and end_index > vertical_index, - "controls inside the scroll container are not arranged by their own vertical layout") - -print("controls_scroll_layout=PASS nested_vertical=true overlap_regression=true") diff --git a/metamorph_creative_menu/tests/creative_perk_peer_consume_mock.lua b/metamorph_creative_menu/tests/creative_perk_peer_consume_mock.lua deleted file mode 100644 index 9536927..0000000 --- a/metamorph_creative_menu/tests/creative_perk_peer_consume_mock.lua +++ /dev/null @@ -1,47 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile -local globals={ - mcm_creative_perk_spawn_seq_v1='1', - ['mcm_creative_perk_spawn_entity_v1:1']='77', - ['mcm_creative_perk_spawn_id_v1:1']='TEST_PERK', - ['mcm_creative_perk_spawn_x_v1:1']='100', - ['mcm_creative_perk_spawn_y_v1:1']='200', - ['mcm_creative_perk_spawn_frame_v1:1']='10', -} -GlobalsGetValue=function(k,d) return globals[k] or d end -GlobalsSetValue=function(k,v) globals[k]=tostring(v) end -local alive={[77]=true,[88]=true} -local positions={[77]={100,200},[88]={105,202}} -EntityGetIsAlive=function(e) return alive[e]==true end -EntityGetTransform=function(e) return positions[e][1],positions[e][2] end -local killed={} -local network_deaths={} -local order={} -ewext={des_death_notify=function(e,wait,x,y,path,responsible) - network_deaths[#network_deaths+1]={e=e,wait=wait,x=x,y=y,path=path,responsible=responsible}; order[#order+1]='notify' -end} -EntityGetFilename=function(e) return e==77 and 'data/entities/items/pickup/perk.xml' or '' end -EntityKill=function(e) killed[#killed+1]=e; order[#order+1]='kill'; alive[e]=false end -local frame=20 -GameGetFrameNum=function() return frame end -local counts={TEST_PERK=0} -util={get_ent_variable=function(entity,key) - assert(entity==88 and key=='ew_current_perks'); return counts -end} -ctx={my_id='me',players={friend={entity=88}}} -local bridge=assert(native_dofile(root..'/files/integrations/ew/bridge/creative_perks.lua')) -bridge.init({report_error=function(scope,detail) error(scope..':'..tostring(detail)) end}) --- Empty first observation establishes no pickup. A first non-empty observation would still --- be proximity-gated instead of being silently accepted as an unverified baseline. -bridge.update() -assert(#killed==0 and globals.mcm_creative_perk_spawn_ack_v1=='1','creative perk registration/baseline failed') --- A stock peer advertises perk counts every 120 frames. The owner correlates the --- increment with the peer's recent path and kills the authoritative pickup locally. -frame=140; counts={TEST_PERK=1}; bridge.update() -assert(#network_deaths==1 and network_deaths[1].e==77 and network_deaths[1].wait==false, - 'remote stock-EW perk increment did not submit authoritative DES deletion') -assert(#killed==1 and killed[1]==77,'remote stock-EW perk increment did not consume owner authority') -assert(order[1]=='notify' and order[2]=='kill','creative perk authority was killed before DES deletion was submitted') -assert(string.find(globals.mcm_creative_perk_consume_status_v1 or '','consumed:TEST_PERK',1,true), - 'creative perk consume status not published') -print('creative_perk_peer_consume=PASS stock_peer_no_mcm=true owner_authority_killed=true des_delete_before_kill=true recent_position_correlation=true') diff --git a/metamorph_creative_menu/tests/creative_perk_registration_mock.lua b/metamorph_creative_menu/tests/creative_perk_registration_mock.lua deleted file mode 100644 index 47bc15f..0000000 --- a/metamorph_creative_menu/tests/creative_perk_registration_mock.lua +++ /dev/null @@ -1,24 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile -local runtime={enabled=function() return true end} -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/integrations/ew/runtime.lua' then return runtime end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -local globals={} -GlobalsGetValue=function(k,d) return globals[k] or d end -GlobalsSetValue=function(k,v) globals[k]=tostring(v) end -EntityGetIsAlive=function(e) return e==77 end -EntityGetTransform=function(e) assert(e==77); return 123.5,-44 end -GameGetFrameNum=function() return 321 end -local world=assert(native_dofile(root..'/files/integrations/ew/world_items.lua')) -local ok,reason=world.register_creative_perk(77,'TEST_PERK') -assert(ok==true and reason=='registered:1','creative perk registration failed') -assert(globals.mcm_creative_perk_spawn_seq_v1=='1','sequence not published last') -assert(globals['mcm_creative_perk_spawn_entity_v1:1']=='77','entity mailbox mismatch') -assert(globals['mcm_creative_perk_spawn_id_v1:1']=='TEST_PERK','perk id mailbox mismatch') -assert(globals['mcm_creative_perk_spawn_x_v1:1']=='123.5' and globals['mcm_creative_perk_spawn_y_v1:1']=='-44','position mailbox mismatch') -assert(globals['mcm_creative_perk_spawn_frame_v1:1']=='321','frame mailbox mismatch') -print('creative_perk_registration=PASS atomic_mailbox=true position=true frame=true') diff --git a/metamorph_creative_menu/tests/creature_drag_interaction_mock.lua b/metamorph_creative_menu/tests/creature_drag_interaction_mock.lua deleted file mode 100644 index 8a45451..0000000 --- a/metamorph_creative_menu/tests/creature_drag_interaction_mock.lua +++ /dev/null @@ -1,187 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local prefix="mods/metamorph_creative_menu/" - -local target_path="data/entities/animals/test_creature.xml" -local target_icon="data/ui_gfx/animal_icons/test_creature.png" -local entries={ - {id="test_creature",path=target_path,category="ANIMALS",display_name="Test Creature",display_description="Test",icon=target_icon}, - {id="player",path="PLAYER",category="HUMANOIDS",display_name="Player",display_description="",icon="player.png",special="player"}, -} - -local audits,prints,source_calls,ghosts={},{},{},{} -local tile_actions={} -local tile_call=0 -local panel_bounds={x=0,y=0,width=140,height=180} -local queued_result=nil -local drag_active=false -local active_payload=nil -local nearby_calls,world_calls,clone_calls,transform_calls={},{},{},{} - -local ui={ - EMPTY_SLOT="data/ui_gfx/inventory/inventory_box.png",ICON_STEP=20, - audit=function(event,detail) audits[#audits+1]={event=event,detail=detail} end, - translated=function(value) return tostring(value or "") end, - tr=function(_,fallback) return fallback end, - resolve=function(path) return path end, - entity_icon=function() return target_icon end, - rank_entries=function(_,values) return values end, - gui=function() return 1 end, - white_text=function() end, - button_grid=function() return nil end, - button=function() return false end, - wrapped_text=function() end, - search_input=function(value) return value end, - scroll_height=function() return 100 end, - begin_scroll_viewport=function() - return {padding_left=0,content_width=100,x=10,y=20,width=100,height=80} - end, - columns=function() return 5 end, - scroll_y=function(_,value) return value end, - tile=function(x,y) - tile_call=tile_call+1 - local action=tile_actions[tile_call] or {} - return action.clicked==true,action.right==true,action.hovered==true,x,y,18,18 - end, - end_scroll_viewport=function() end, - active_scroll_region=function() return nil end, - panel_bounds=function() return panel_bounds end, - drag_ghost=function(background,icon,x,y) - ghosts[#ghosts+1]={background=background,icon=icon,x=x,y=y} - end, -} - -local drag_drop={ - take_result=function() - local value=queued_result - queued_result=nil - return value - end, - source=function(id,payload,bounds,clip) - source_calls[#source_calls+1]={id=id,payload=payload,bounds=bounds,clip=clip} - return true - end, - active=function() return drag_active end, - payload=function() return active_payload end, - mouse_position=function() return 211,133 end, -} - -local creature_service={ - spawn_near_player=function(player,path,offset_x,offset_y) - nearby_calls[#nearby_calls+1]={player=player,path=path,offset_x=offset_x,offset_y=offset_y} - return 501,"spawned" - end, - spawn_at=function(path,x,y) - world_calls[#world_calls+1]={path=path,x=x,y=y} - return 502,"spawned" - end, - compatibility_status=function() return "safe","test" end, - transform_plan=function(path) return {target_path=path,mode="direct",reason="direct"} end, -} - -local form_manager={ - exact_effect_path_for_target=function() return "effect.xml" end, - prepare_exact_effect_paths=function() return true end, - transform_creature=function(player,path) - transform_calls[#transform_calls+1]={player=player,path=path} - return true,"transformed" - end, - return_to_human=function() return true end, -} - -local stubs={ - [prefix.."files/ui/runtime.lua"]=ui, - [prefix.."files/ui/drag_drop.lua"]=drag_drop, - [prefix.."files/features/forms/manager.lua"]=form_manager, - [prefix.."files/features/creatures/ui_catalog.lua"]={ - collect=function() return entries end, - warmup_step=function() return true,false end, - }, - [prefix.."files/features/creatures/service.lua"]=creature_service, - [prefix.."files/features/companion/player_avatar.lua"]={ - request_spawn=function(player,offset_x,offset_y) - clone_calls[#clone_calls+1]={player=player,offset_x=offset_x,offset_y=offset_y} - return true - end, - }, - [prefix.."files/platform/noita/action_bindings.lua"]={label=function() return "TAB" end}, -} - -dofile=function(path) - if stubs[path]~=nil then return stubs[path] end - if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -function ModDoesFileExist(path) - return path==target_path or path==target_icon or path=="player.png" -end -function GuiLayoutBeginVertical() end -function GuiLayoutBeginHorizontal() end -function GuiLayoutEnd() end -function GamePrint(text) prints[#prints+1]=tostring(text) end - -local tab=assert(native_dofile(root.."/files/ui/tabs/creatures.lua")) -local function draw() - tile_call=0 - tab.draw(1,140,180) - tile_actions={} -end -local function creature_payload() - return {kind="catalog_creature",path=target_path,display_name="Test Creature",background=ui.EMPTY_SLOT,icon=target_icon} -end - --- Only ordinary creature cards become drag sources; the special PLAYER clone keeps its old click action. -draw() -assert(#source_calls==1,"creature catalog did not register exactly one ordinary-mob drag source") -local source=source_calls[1] -assert(source.id=="creatures.catalog."..target_path and source.payload.kind=="catalog_creature" - and source.payload.path==target_path,"creature drag source payload changed") -assert(source.bounds.x==0 and source.bounds.y==0 and source.bounds.width==18 and source.bounds.height==18, - "creature drag source did not use measured tile bounds") -assert(source.clip.x==10 and source.clip.y==20 and source.clip.width==100 and source.clip.height==80, - "creature drag source did not preserve viewport clipping") - --- A short release is the sole owner of LMB spawn, even if GuiImageButton also reports a click. -queued_result={click=true,payload=creature_payload(),release_x=5,release_y=5,world_x=1,world_y=2} -tile_actions[1]={clicked=true} -draw() -assert(#nearby_calls==1 and nearby_calls[1].path==target_path,"short creature click did not spawn nearby exactly once") -assert(#world_calls==0,"short creature click also performed world placement") - --- A completed drag outside the menu creates one creature at the exact world cursor position. -queued_result={click=false,target=nil,payload=creature_payload(),release_x=200,release_y=150,world_x=777,world_y=-55} -draw() -assert(#world_calls==1 and world_calls[1].path==target_path and world_calls[1].x==777 and world_calls[1].y==-55, - "creature drag did not preserve exact world coordinates") - --- Releasing over the menu or without a world position is a cancel/failure, never a hidden spawn. -queued_result={click=false,target=nil,payload=creature_payload(),release_x=100,release_y=100,world_x=900,world_y=900} -draw() -assert(#world_calls==1,"in-menu creature drag created a hidden creature") -local prints_before=#prints -queued_result={click=false,target=nil,payload=creature_payload(),release_x=200,release_y=150,world_x=nil,world_y=nil} -draw() -assert(#world_calls==1 and #prints==prints_before+1,"missing creature world coordinates were not handled as a visible failure") - --- Existing target ownership prevents a second world action, and the active drag uses the creature icon ghost. -queued_result={click=false,target="future.target",payload=creature_payload(),release_x=200,release_y=150,world_x=4,world_y=5} -draw() -assert(#world_calls==1,"creature drag executed twice after another target accepted it") -drag_active=true -active_payload=creature_payload() -draw() -drag_active=false -active_payload=nil -assert(#ghosts==1 and ghosts[1].icon==target_icon and ghosts[1].x==211 and ghosts[1].y==133, - "creature drag ghost did not use the selected creature icon") - --- RMB transformation and the special PLAYER clone action remain separate from dragging. -tile_actions[1]={right=true} -draw() -assert(#transform_calls==1 and transform_calls[1].path==target_path,"RMB creature transformation regressed") -tile_actions[2]={clicked=true} -draw() -assert(#clone_calls==1 and clone_calls[1].player==1,"PLAYER clone LMB action regressed") - -print("creature_drag_interaction=PASS source=true click_once=true exact_world=true in_menu_cancel=true missing_coords=true ghost=true rmb_transform=true player_clone=true") diff --git a/metamorph_creative_menu/tests/creature_kolmi_arena_dedupe_mock.lua b/metamorph_creative_menu/tests/creature_kolmi_arena_dedupe_mock.lua deleted file mode 100644 index e9a37e1..0000000 --- a/metamorph_creative_menu/tests/creature_kolmi_arena_dedupe_mock.lua +++ /dev/null @@ -1,79 +0,0 @@ -local root=assert(arg[1],"root required") -local natural=50 -local reference=51 -local created=77 -local alive={[natural]=true,[reference]=true,[created]=true} -local tags={ - [natural]={boss_centipede=true}, - [reference]={reference=true}, - [created]={}, -} -local pos={[natural]={100,200},[reference]={100,200},[created]={100,200}} -local comps={} -local by_entity={[natural]={},[reference]={},[created]={}} -local next_comp=100 -local killed={} -local notified={} -local globals={mcm31_kolmi_lifecycle_ready_v1="1"} - -local function add_comp(e,t,v) - next_comp=next_comp+1 - comps[next_comp]={type=t,values=v or {},tags={}} - by_entity[e]=by_entity[e] or {}; table.insert(by_entity[e],next_comp) - return next_comp -end -add_comp(natural,"VariableStorageComponent",{name="ew_gid_lid",value_string="123456",value_bool=true}) -add_comp(natural,"DamageModelComponent",{wait_for_kill_flag_on_death=true,kill_now=false}) -add_comp(natural,"LuaComponent",{script_death="death.lua",script_damage_received="damage.lua",script_damage_about_to_be_received="about.lua"}) - --- Real menu VM: ewext exists only in EW's VM. An earlier implementation silently killed the global --- arena boss here without ever queuing DeleteEntity. Keep this regression realistic. -ewext=nil -function GlobalsGetValue(k,d) return globals[k] or d end -function GlobalsSetValue(k,v) globals[k]=v end -function EntityGetIsAlive(e) return alive[e]==true end -function EntityGetTransform(e) local p=pos[e] or {0,0}; return p[1],p[2] end -function EntityHasTag(e,t) return tags[e] and tags[e][t]==true or false end -function EntityAddTag(e,t) tags[e]=tags[e] or {}; tags[e][t]=true end -function EntityGetWithTag(tag) - local out={} - for e,tt in pairs(tags) do if alive[e] and tt[tag] then out[#out+1]=e end end - table.sort(out); return out -end -function EntityGetComponentIncludingDisabled(e,t) - local out={}; for _,id in ipairs(by_entity[e] or {}) do if comps[id] and comps[id].type==t then out[#out+1]=id end end; return out -end -function EntityGetFirstComponentIncludingDisabled(e,t) local v=EntityGetComponentIncludingDisabled(e,t); return v[1] end -function ComponentGetValue2(id,n) return comps[id] and comps[id].values[n] end -function ComponentSetValue2(id,n,v) comps[id].values[n]=v end -function ComponentAddTag(id,t) comps[id].tags[t]=true end -function EntitySetComponentIsEnabled() end -function EntityRemoveComponent(e,id) comps[id]=nil end -function EntityKill(e) alive[e]=false; killed[#killed+1]=e end -function EntityAddComponent2(e,t,v) return add_comp(e,t,v) end -function EntityLoad(path,x,y) error("reference should be reused, not loaded: "..tostring(path)) end - -local m=assert(loadfile(root.."/files/features/creatures/spawn_compat.lua"))() -local ctx=m.before_spawn(m.KOLMI_PATH,100,200) -assert(type(ctx)=="table" and ctx.kind=="kolmi","Kolmi preflight did not return Kolmi context") -assert(ctx.reference_id==reference,"existing authored arena reference was not reused") -assert(ctx.replaced_entity==natural,"existing authored Kolmi was not identified for replacement") -assert(alive[natural]==true,"tracked arena body was killed before EW acknowledged removal of its GID") -assert(globals["mcm31_kolmi_retire_request_v1_50"]=="123456", - "menu did not hand the exact GID to the EW VM") -assert(#killed==0,"a retirement request must not kill the local body") -local ok,reason=m.after_spawn(m.KOLMI_PATH,created,100,200,ctx) -assert(ok and reason=="creative_kolmi_prepared","creative replacement preparation failed") -assert(EntityHasTag(created,m.CREATIVE_MARKER),"creative replacement marker missing") -local ref_id=nil -for _,id in ipairs(EntityGetComponentIncludingDisabled(created,"VariableStorageComponent")) do - if ComponentGetValue2(id,"name")==m.CREATIVE_REF_ID_VAR then ref_id=ComponentGetValue2(id,"value_int") end -end -assert(tonumber(ref_id)==reference,"creative replacement did not retain authored arena reference") --- If EW's lifecycle bridge is unavailable, reuse the retained root. Never return a --- new-spawn context after failure to retire it. -globals.mcm31_kolmi_lifecycle_ready_v1="" -local fallback=m.before_spawn(m.KOLMI_PATH,100,200) -assert(fallback.kind=="reuse" and fallback.entity==natural,"failed retirement created another arena boss") -assert(alive[natural],"unavailable EW bridge killed a tracked root") -print("creature_kolmi_arena_dedupe=PASS separate_vm=true waits_for_des=true reference_reused=true failure_reuses=true") diff --git a/metamorph_creative_menu/tests/creature_kolmi_bootstrap_mock.lua b/metamorph_creative_menu/tests/creature_kolmi_bootstrap_mock.lua deleted file mode 100644 index 45ba187..0000000 --- a/metamorph_creative_menu/tests/creature_kolmi_bootstrap_mock.lua +++ /dev/null @@ -1,99 +0,0 @@ -local root=assert(arg[1],"root required") -local entity=77 -local reference=90 -local bootstrap_component=700 -local frame=100 -local alive={[entity]=true,[reference]=true} -local tags={[entity]={},[reference]={}} -local components={ - [501]={type="VariableStorageComponent",values={name="mcm_creative_kolmi_reference_id_v3",value_int=reference}}, - [bootstrap_component]={type="LuaComponent",values={}}, -} -local entity_components={[entity]={501,bootstrap_component},[reference]={}} -local removed={} -local created={} -local next_entity=900 -local ew_on=true - -local real_dofile=dofile -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/integrations/ew/runtime.lua" then - return {enabled=function() return ew_on end} - end - return real_dofile(path) -end - -function GetUpdatedEntityID() return entity end -function GetUpdatedComponentID() return bootstrap_component end -function EntityGetIsAlive(e) return alive[e]==true end -function GameGetFrameNum() return frame end -function EntityGetComponentIncludingDisabled(e,ctype) - local out={} - for _,id in ipairs(entity_components[e] or {}) do if components[id] and components[id].type==ctype then out[#out+1]=id end end - return out -end -function ComponentGetValue2(id,name) return components[id] and components[id].values[name] end -function EntityAddComponent2(e,ctype,values) - local id=1000+#(entity_components[e] or {}) - while components[id] do id=id+1 end - components[id]={type=ctype,values=values or {}} - entity_components[e]=entity_components[e] or {};table.insert(entity_components[e],id) - return id -end -function EntityGetTransform(e) return 10,20 end -function EntityCreateNew(name) - next_entity=next_entity+1;alive[next_entity]=true;tags[next_entity]={};entity_components[next_entity]={};created[next_entity]={name=name};return next_entity -end -function EntitySetTransform(e,x,y) created[e]=created[e] or {};created[e].x=x;created[e].y=y end -function EntityAddTag(e,tag) tags[e]=tags[e] or {};tags[e][tag]=true end -function EntityRemoveComponent(e,c) removed[c]=true;components[c]=nil end - -local script=root.."/files/features/creatures/kolmi_creative_bootstrap.lua" -assert(loadfile(script))() -assert(next_entity==900 and not removed[bootstrap_component],"Kolmi controller started before owner GID existed") - -components[502]={type="VariableStorageComponent",values={name="ew_gid_lid",value_string="123",value_bool=false}} -table.insert(entity_components[entity],502) -frame=101 -assert(loadfile(script))() -assert(next_entity==900 and not removed[bootstrap_component],"remote Kolmi replica started the encounter") - -components[502].values.value_bool=true -frame=102 -assert(loadfile(script))() -assert(next_entity==901,"external Kolmi encounter controller was not created") -local controller=901 -assert(tags[controller].ew_no_enemy_sync==true,"controller can enter DES enemy sync") -assert(tags[entity].mcm_creative_kolmi_activation_queued_v3==true,"boss activation queue marker missing") -assert(created[controller].x==10 and created[controller].y==100,"controller does not sit at authored Sampo +80 offset") -local boss_id,ref_id,controller_lua=nil,nil,nil -for _,id in ipairs(entity_components[controller] or {}) do - local c=components[id] - if c.type=="VariableStorageComponent" and c.values.name=="mcm_kolmi_boss_id_v3" then boss_id=c.values.value_int end - if c.type=="VariableStorageComponent" and c.values.name=="mcm_kolmi_reference_id_v3" then ref_id=c.values.value_int end - if c.type=="LuaComponent" then controller_lua=c end -end -assert(boss_id==entity and ref_id==reference,"controller did not receive exact boss/reference ids") -assert(controller_lua and controller_lua.values.script_source_file=="mods/metamorph_creative_menu/files/features/creatures/kolmi_encounter_start.lua","controller does not execute stock-pickup bridge") -assert(controller_lua.values.execute_on_added==false and controller_lua.values.execute_every_n_frame==1,"controller did not yield to an independent next-frame VM update") -assert(removed[bootstrap_component]==true,"bootstrap stayed on boss after controller handoff") - --- Singleplayer branch: no GID is required, but it must yield one frame after EntityLoad. -local entity2=177 -local reference2=190 -alive[entity2]=true;alive[reference2]=true;tags[entity2]={};entity_components[entity2]={} -components[601]={type="VariableStorageComponent",values={name="mcm_creative_kolmi_reference_id_v3",value_int=reference2}} -components[701]={type="LuaComponent",values={}} -entity_components[entity2]={601,701} -ew_on=false -function GetUpdatedEntityID() return entity2 end -function GetUpdatedComponentID() return 701 end -frame=200 -assert(loadfile(script))() -local before=next_entity -frame=201 -assert(loadfile(script))() -assert(next_entity==before+1,"singleplayer Kolmi did not start after authored init frame") - -dofile=real_dofile -print("creature_kolmi_bootstrap=PASS requires_owner_gid=true remote_replica_blocked=true external_vm_controller=true singleplayer_deferred=true") diff --git a/metamorph_creative_menu/tests/creature_kolmi_combat_verify_mock.lua b/metamorph_creative_menu/tests/creature_kolmi_combat_verify_mock.lua deleted file mode 100644 index fdf2546..0000000 --- a/metamorph_creative_menu/tests/creature_kolmi_combat_verify_mock.lua +++ /dev/null @@ -1,73 +0,0 @@ -local root=assert(arg[1],"root required") -local script=root.."/files/features/creatures/kolmi_combat_verify.lua" -local controller=500 -local boss=77 -local alive={[controller]=true,[boss]=true} -local frame=100 -local next_id=1000 -local components={ - [1]={owner=controller,type="VariableStorageComponent",values={name="mcm_kolmi_boss_id_v3",value_int=boss}}, - [2]={owner=controller,type="VariableStorageComponent",values={name="mcm_kolmi_verify_start_frame_v1",value_int=100}}, - [3]={owner=boss,type="VariableStorageComponent",values={name="initialized",value_bool=false}}, - [4]={owner=boss,type="LuaComponent",enabled=true,values={script_source_file="data/entities/animals/boss_centipede/boss_centipede_update.lua"}}, -} -local entity_components={[controller]={1,2},[boss]={3,4}} -local globals={} -local removed={} -local added={} -local enabled={} - -local function list(e,ctype) - local out={} - for _,id in ipairs(entity_components[e] or {}) do - local c=components[id] - if c and c.type==ctype then out[#out+1]=id end - end - return out -end -function GetUpdatedEntityID() return controller end -function GameGetFrameNum() return frame end -function EntityGetIsAlive(e) return alive[e]==true end -function EntityGetComponentIncludingDisabled(e,ctype) return list(e,ctype) end -function ComponentGetValue2(id,name) return components[id] and components[id].values[name] end -function ComponentSetValue2(id,name,value) components[id].values[name]=value end -function EntityRemoveComponent(e,id) - removed[id]=true - components[id]=nil -end -function EntityAddComponent2(e,ctype,values) - next_id=next_id+1 - components[next_id]={owner=e,type=ctype,enabled=true,values=values or {}} - entity_components[e]=entity_components[e] or {};table.insert(entity_components[e],next_id) - added[#added+1]={id=next_id,entity=e,ctype=ctype,values=values or {}} - return next_id -end -function EntitySetComponentIsEnabled(e,id,value) enabled[id]=value; if components[id] then components[id].enabled=value end end -function GlobalsSetValue(k,v) globals[k]=v end -function EntityKill(e) alive[e]=false end - --- During grace: no mutation. -assert(loadfile(script))() -assert(components[4]~=nil and #added==0,"verifier restarted authored VM before grace") - --- After grace, still uninitialized: stale authored VM is replaced once with exact stock path. -frame=109 -assert(loadfile(script))() -assert(removed[4]==true,"stale combat LuaComponent was not removed") -local fresh=nil -for _,r in ipairs(added) do if r.ctype=="LuaComponent" then fresh=r end end -assert(fresh~=nil,"fresh combat LuaComponent not created") -assert(fresh.values.script_source_file=="data/entities/animals/boss_centipede/boss_centipede_update.lua","verifier substituted non-stock combat script") -assert(fresh.values.enable_coroutines==true and fresh.values.execute_on_added==true,"fresh combat VM lacks authored coroutine startup semantics") -assert(fresh.values.execute_every_n_frame==-1 and fresh.values.execute_times==1,"fresh combat VM execution contract changed") -assert(fresh.values.vm_type=="ONE_PER_COMPONENT_INSTANCE","fresh combat VM type changed") -assert(enabled[fresh.id]==true,"fresh combat VM was not explicitly enabled") -assert(globals.mcm25_kolmi_combat_status_v1=="restarted_component","restart status not published") - --- Simulate init_boss() running in the fresh VM; verifier must retire without touching combat. -components[3].values.value_bool=true -frame=110 -assert(loadfile(script))() -assert(alive[controller]==false,"verifier survived after stock combat initialized") -assert(globals.mcm25_kolmi_combat_status_v1=="initialized_stock" or globals.mcm25_kolmi_combat_status_v1=="initialized_after_restart","initialized status missing") -print("creature_kolmi_combat_verify=PASS startup_grace=true stock_vm_restart_once=true no_phase_emulation=true") diff --git a/metamorph_creative_menu/tests/creature_kolmi_encounter_controller_mock.lua b/metamorph_creative_menu/tests/creature_kolmi_encounter_controller_mock.lua deleted file mode 100644 index 7ac05de..0000000 --- a/metamorph_creative_menu/tests/creature_kolmi_encounter_controller_mock.lua +++ /dev/null @@ -1,89 +0,0 @@ -local root=assert(arg[1],"root required") - -local initf=assert(io.open(root.."/init.lua","rb")) -local init_source=initf:read("*a");initf:close() -assert(not string.find(init_source,"kolmi_update_append",1,true),"MCM still appends into boss_centipede_update.lua") -assert(not string.find(init_source,"kolmi_creative_update.append",1,true),"legacy Kolmi update patch is still wired") -local controller=500 -local boss=77 -local reference=90 -local player=10 -local other_boss=78 -local other_ref=91 -local alive={[controller]=true,[boss]=true,[reference]=true,[player]=true,[other_boss]=true,[other_ref]=true} -local positions={[controller]={100,280},[boss]={100,200},[reference]={100,200},[player]={120,210},[other_boss]={999,999},[other_ref]={999,999}} -local tags={[boss]={sampo_or_boss=true},[other_boss]={sampo_or_boss=true},[reference]={reference=true},[other_ref]={reference=true},[player]={player_unit=true}} -local components={ - [1]={name="mcm_kolmi_boss_id_v3",value_int=boss}, - [2]={name="mcm_kolmi_reference_id_v3",value_int=reference}, -} -local globals={} -local added_components={} -local next_component=100 -local call_snapshot=nil -local original_get=nil -local killed={} -local dofile_path=nil -local important={} - -function GetUpdatedEntityID() return controller end -function EntityGetComponentIncludingDisabled(e,ctype) if e==controller and ctype=="VariableStorageComponent" then return {1,2} end return {} end -function ComponentGetValue2(id,name) return components[id] and components[id][name] end -function EntityGetIsAlive(e) return alive[e]==true end -function EntityGetTransform(e) local p=positions[e];return p[1],p[2] end -function EntityGetWithTag(tag) - local out={} - for e,t in pairs(tags) do if alive[e] and t[tag] then out[#out+1]=e end end - table.sort(out) - return out -end -original_get=EntityGetWithTag -function EntityAddTag(e,tag) tags[e]=tags[e] or {};tags[e][tag]=true end -function GlobalsSetValue(k,v) globals[k]=v end -function GameGetFrameNum() return 123 end -function EntityAddComponent2(e,ctype,values) - next_component=next_component+1 - added_components[#added_components+1]={entity=e,ctype=ctype,values=values,id=next_component} - return next_component -end -function EntityKill(e) alive[e]=false;killed[e]=true end -function GamePrintImportant(a,b) important={a,b} end - -local real_dofile=dofile -dofile=function(path) - dofile_path=path - assert(path=="data/entities/animals/boss_centipede/sampo_pickup.lua","controller loaded a replacement instead of stock Sampo pickup") - function item_pickup(entity_item,entity_who_picked,name) - call_snapshot={ - entity_item=entity_item,player=entity_who_picked,name=name, - bosses=EntityGetWithTag("sampo_or_boss"),refs=EntityGetWithTag("reference"),players=EntityGetWithTag("player_unit") - } - end -end - -assert(loadfile(root.."/files/features/creatures/kolmi_encounter_start.lua"))() -dofile=real_dofile -assert(dofile_path=="data/entities/animals/boss_centipede/sampo_pickup.lua","stock Sampo entrypoint was not loaded") -assert(call_snapshot~=nil,"stock Sampo item_pickup was not called") -assert(call_snapshot.entity_item==controller and call_snapshot.player==player,"stock pickup call did not use external controller/local player") -assert(#call_snapshot.bosses==1 and call_snapshot.bosses[1]==boss,"stock pickup could activate an unrelated Kolmi/Sampo") -assert(#call_snapshot.refs==1 and call_snapshot.refs[1]==reference,"stock pickup did not receive the authored creative reference") -assert(#call_snapshot.players==1 and call_snapshot.players[1]==player,"stock pickup did not receive the selected combat player") -assert(EntityGetWithTag==original_get,"controller leaked its temporary EntityGetWithTag isolation into the VM") -assert(tags[boss].mcm_creative_kolmi_encounter_started_v3==true,"successful stock pickup did not mark encounter started") -assert(globals.mcm25_kolmi_encounter_status_v1=="started_via_stock_sampo_pickup","encounter status is not stock-pickup success") -assert(killed[controller]~=true,"controller died before combat startup verification") -local saw_verify=false -local saw_start=false -for _,record in ipairs(added_components) do - if record.entity==controller and record.ctype=="LuaComponent" and record.values.script_source_file=="mods/metamorph_creative_menu/files/features/creatures/kolmi_combat_verify.lua" then - saw_verify=true - end - if record.entity==controller and record.ctype=="VariableStorageComponent" and record.values.name=="mcm_kolmi_verify_start_frame_v1" and record.values.value_int==123 then - saw_start=true - end -end -assert(saw_verify,"successful stock pickup did not schedule combat verifier") -assert(saw_start,"combat verifier start frame was not recorded") -assert(#important==0,"successful controller printed an error") -print("creature_kolmi_encounter_controller=PASS stock_sampo_pickup=true external_vm=true isolated_roots=true globals_restored=true stock_update_unpatched=true verifier_scheduled=true") diff --git a/metamorph_creative_menu/tests/creature_kolmi_spawn_compat_mock.lua b/metamorph_creative_menu/tests/creature_kolmi_spawn_compat_mock.lua deleted file mode 100644 index aadb591..0000000 --- a/metamorph_creative_menu/tests/creature_kolmi_spawn_compat_mock.lua +++ /dev/null @@ -1,76 +0,0 @@ -local root=assert(arg[1],"root required") -local kolmi=77 -local ordinary=88 -local components={} -local entity_components={[kolmi]={},[ordinary]={}} -local tags={[kolmi]={},[ordinary]={}} -local alive={[kolmi]=true,[ordinary]=true} -local next_component=100 -local next_entity=200 -local loads={} - -function EntityGetIsAlive(e) return alive[e]==true end -function EntityGetComponentIncludingDisabled(e,ctype) - local out={} - for _,id in ipairs(entity_components[e] or {}) do - if components[id] and components[id].type==ctype then out[#out+1]=id end - end - return out -end -function ComponentGetValue2(id,name) return components[id] and components[id].values[name] end -function ComponentSetValue2(id,name,value) components[id].values[name]=value end -function ComponentAddTag(id,tag) components[id].tags[tag]=true end -function EntityAddComponent2(e,ctype,values) - next_component=next_component+1 - components[next_component]={type=ctype,values=values or {},tags={}} - if type(values)=="table" and type(values._tags)=="string" then - for tag in string.gmatch(values._tags,"[^,]+") do components[next_component].tags[tag]=true end - end - entity_components[e]=entity_components[e] or {} - table.insert(entity_components[e],next_component) - return next_component -end -function EntityAddTag(e,tag) tags[e]=tags[e] or {}; tags[e][tag]=true end -function EntityHasTag(e,tag) return tags[e] and tags[e][tag]==true or false end -function EntityLoad(path,x,y) - next_entity=next_entity+1 - alive[next_entity]=true; tags[next_entity]={}; entity_components[next_entity]={} - loads[#loads+1]={id=next_entity,path=path,x=x,y=y} - return next_entity -end - -local module=assert(loadfile(root.."/files/features/creatures/spawn_compat.lua"))() -local ok,reason=module.after_spawn(module.KOLMI_PATH,kolmi,123.5,-44.25) -assert(ok and reason=="creative_kolmi_prepared","creative Kolmi preparation failed") -assert(EntityHasTag(kolmi,module.CREATIVE_MARKER),"creative Kolmi marker missing") -assert(not EntityHasTag(kolmi,"boss_centipede_active"),"Kolmi was activated before stock Sampo entrypoint") -assert(#loads==1 and loads[1].path==module.REF_PATH,"authored Kolmi reference_point was not spawned") -assert(EntityHasTag(loads[1].id,module.CREATIVE_REF_TAG),"creative reference marker missing") -assert(EntityHasTag(loads[1].id,"ew_synced"),"creative reference is not published for peer authority transfer") - -local ref_var=nil -local ref_id_var=nil -local bootstrap=nil -for _,id in ipairs(EntityGetComponentIncludingDisabled(kolmi,"VariableStorageComponent")) do - local c=components[id] - if c.values.name==module.CREATIVE_REF_VAR then ref_var=c end - if c.values.name==module.CREATIVE_REF_ID_VAR then ref_id_var=c end -end -for _,id in ipairs(EntityGetComponentIncludingDisabled(kolmi,"LuaComponent")) do - local c=components[id] - if c.values.script_source_file==module.BOOTSTRAP then bootstrap=c end -end -assert(ref_var~=nil and ref_var.tags.ew_synced_var==true,"creative Kolmi coordinates are not authority-transfer synced") -local x,y=tostring(ref_var.values.value_string):match("^([^,]+),([^,]+)$") -assert(math.abs(tonumber(x)-123.5)<0.001 and math.abs(tonumber(y)+44.25)<0.001,"creative arena reference coordinates changed") -assert(ref_id_var~=nil and tonumber(ref_id_var.values.value_int)==loads[1].id,"local authored reference id was not retained") -assert(ref_id_var.tags.ew_remove_on_send==true,"peer-local reference id leaked into DES serialization") -assert(bootstrap~=nil,"deferred stock-Sampo bootstrap component missing") - -local before_components=#(entity_components[ordinary] or {}) -local before_loads=#loads -ok,reason=module.after_spawn("data/entities/animals/sheep.xml",ordinary,0,0) -assert(ok and reason=="ordinary","ordinary creature compatibility result changed") -assert(#(entity_components[ordinary] or {})==before_components and #loads==before_loads,"ordinary creature was mutated by Kolmi compatibility") - -print("creature_kolmi_spawn_compat=PASS pre_fight_untouched=true authored_reference=true synced_reference=true stock_pickup_deferred=true ordinary_untouched=true") diff --git a/metamorph_creative_menu/tests/creature_menu_visibility_mock.lua b/metamorph_creative_menu/tests/creature_menu_visibility_mock.lua index 0f915f6..c7d3ffa 100644 --- a/metamorph_creative_menu/tests/creature_menu_visibility_mock.lua +++ b/metamorph_creative_menu/tests/creature_menu_visibility_mock.lua @@ -7,7 +7,6 @@ local similar_visible_path="data/entities/animals/drone_physics.xml" local hidden_projectile="data/entities/animals/boss_wizard/meteor.xml" local hidden_boss_limbs="data/entities/animals/boss_limbs/boss_limbs_physics.xml" local ordinary_path="data/entities/animals/sheep.xml" -local hidden_endcrystal="data/entities/animals/ending_placeholder/boss_dragon_endcrystal.xml" local creature_service={ catalog_version=function() return 1 end, @@ -17,7 +16,6 @@ local creature_service={ {path=hidden_projectile,id="meteor",display_name="Meteor",category="OTHER"}, {path=hidden_boss_limbs,id="boss_limbs_physics",display_name="Boss limbs physics",category="OTHER"}, {path=ordinary_path,id="sheep",display_name="Sheep",category="ANIMALS"}, - {path=hidden_endcrystal,id="boss_dragon_endcrystal",display_name="Dragon placeholder",category="BOSSES"}, } end, warmup_step=function() return true,false end, } @@ -36,14 +34,13 @@ assert(visibility.visible(hidden_projectile)==false,"second exact reviewed path assert(visibility.visible(hidden_boss_limbs)==false,"boss limbs physics must be hidden only from MOBS picker") assert(visibility.visible(similar_visible_path)==true,"menu exclusion must be exact-path only") assert(visibility.visible(ordinary_path)==true,"ordinary creature was accidentally hidden") -assert(visibility.visible(hidden_endcrystal)==false,"ending-placeholder dragon duplicate must be hidden from MOBS") -assert(#visibility.hidden_paths()==12,"exact MOBS hidden set changed unexpectedly") +assert(#visibility.hidden_paths()==11,"exact MOBS hidden set changed unexpectedly") local catalog=assert(loadfile(root.."/files/features/creatures/ui_catalog.lua"))() local values=assert(catalog.collect()) local seen={} for _,entry in ipairs(values) do seen[entry.path]=true end -assert(not seen[hidden_path] and not seen[hidden_projectile] and not seen[hidden_boss_limbs] and not seen[hidden_endcrystal],"reviewed non-playable forms leaked into MOBS picker") +assert(not seen[hidden_path] and not seen[hidden_projectile] and not seen[hidden_boss_limbs],"reviewed non-playable forms leaked into MOBS picker") assert(seen[similar_visible_path] and seen[ordinary_path],"menu-only filter removed unrelated creatures") assert(seen["metamorph_creative_menu://player"],"player menu entry was lost") -print("creature_menu_visibility=PASS hidden_exact=12 unrelated_preserved=true ui_only=true") +print("creature_menu_visibility=PASS hidden_exact=11 unrelated_preserved=true ui_only=true") diff --git a/metamorph_creative_menu/tests/creature_spawn_mock.lua b/metamorph_creative_menu/tests/creature_spawn_mock.lua index 94905aa..8531b6c 100644 --- a/metamorph_creative_menu/tests/creature_spawn_mock.lua +++ b/metamorph_creative_menu/tests/creature_spawn_mock.lua @@ -1,10 +1,7 @@ local root = assert(arg[1], "root required") local native_dofile = dofile local loaded_path, loaded_x, loaded_y = nil,nil,nil -local load_count = 0 local target_path = "data/entities/animals/test_creature.xml" -local tracked_entities = {} -local lifecycle_events = {} local stubs = { ["mods/metamorph_creative_menu/files/features/creatures/metadata.lua"]={basename=function(path) return path:match("([^/]+)%.xml$") or "" end}, ["mods/metamorph_creative_menu/files/features/creatures/classification.lua"]={ @@ -26,13 +23,6 @@ local stubs = { static_catalog=function() return {{path=target_path}} end, progress_index=function() return 1 end, }, - ["mods/metamorph_creative_menu/files/integrations/ew/world_entities.lua"]={ - track=function(entity) lifecycle_events[#lifecycle_events+1]="track"; tracked_entities[#tracked_entities+1]=entity; return true,"queued" end, - }, - ["mods/metamorph_creative_menu/files/features/creatures/spawn_compat.lua"]={ - before_spawn=function(path,x,y) return {kind="ordinary"} end, - after_spawn=function(path,entity,x,y,context) lifecycle_events[#lifecycle_events+1]="compat"; return true,"ordinary" end, - }, } dofile=function(path) @@ -42,9 +32,9 @@ dofile=function(path) return native_dofile(path) end function ModDoesFileExist(path) return path==target_path end -function EntityGetIsAlive(entity) return entity==1 or entity==77 end +function EntityGetIsAlive(entity) return entity==1 end function EntityGetTransform(entity) assert(entity==1); return 100,200 end -function EntityLoad(path,x,y) load_count=load_count+1; loaded_path,loaded_x,loaded_y=path,x,y; return 77 end +function EntityLoad(path,x,y) loaded_path,loaded_x,loaded_y=path,x,y; return 77 end METAMORPH_CREATIVE_MENU_CREATURE_SERVICE=nil METAMORPH_CREATIVE_MENU_CREATURE_API=nil @@ -54,21 +44,4 @@ local entity=service.spawn_near_player(1,target_path,32,-4) assert(entity==77,"LMB creature spawn did not return loaded entity") assert(loaded_path==target_path and loaded_x==132 and loaded_y==196,"creature spawn position/path changed") -local exact,exact_reason=service.spawn_at(target_path,777,-55) -assert(exact==77 and exact_reason=="spawned","exact creature world spawn failed") -assert(loaded_path==target_path and loaded_x==777 and loaded_y==-55,"exact creature spawn changed cursor coordinates") -assert(#tracked_entities==2 and tracked_entities[1]==77 and tracked_entities[2]==77, - "creative creature spawns were not explicitly submitted for EW DES tracking") -assert(table.concat(lifecycle_events,",")=="compat,track,compat,track", - "creature encounter compatibility did not run before EW tracking") - -local before_invalid=load_count -local invalid,invalid_reason=service.spawn_at(target_path,nil,10) -assert(invalid==0 and invalid_reason=="invalid","missing creature coordinates were accepted") -assert(load_count==before_invalid,"invalid creature spawn reached EntityLoad") - -local nan=0/0 -invalid,invalid_reason=service.spawn_at(target_path,nan,10) -assert(invalid==0 and invalid_reason=="invalid" and load_count==before_invalid,"NaN creature coordinates were accepted") - -print("creature_spawn=PASS transformable=true lmb_spawn=true exact_world=true spawn_compat_before_ew=true ew_track=true invalid_coordinates_rejected=true") +print("creature_spawn=PASS transformable=true lmb_spawn=true") diff --git a/metamorph_creative_menu/tests/day_speed_safe_contract.py b/metamorph_creative_menu/tests/day_speed_safe_contract.py deleted file mode 100644 index 1cbaee3..0000000 --- a/metamorph_creative_menu/tests/day_speed_safe_contract.py +++ /dev/null @@ -1,61 +0,0 @@ -from pathlib import Path -import os, sys - -root = Path(sys.argv[1]).resolve() -defs = (root / 'files/features/world_rules/definitions.lua').read_text(encoding='utf-8') -service = (root / 'files/features/world_rules/service.lua').read_text(encoding='utf-8') -weather = (root / 'files/features/weather/service.lua').read_text(encoding='utf-8') -sync = (root / 'files/integrations/ew/weather_sync.lua').read_text(encoding='utf-8') -owner = (root / 'files/features/world_rules/time_dt.lua').read_text(encoding='utf-8') -magic = (root / 'files/features/world_rules/magic_numbers.lua').read_text(encoding='utf-8') - -assert 'id="day_speed"' in defs and 'kind="time_dt_multiplier"' in defs -assert 'DESIGN_DAY_CYCLE_SPEED' not in defs, 'Day Speed still depends on unsafe MagicNumbers in rule definitions' -assert 'time_dt_owner.apply_day_multiplier' in service -assert 'time_dt_owner.release_day_multiplier' in service -assert 'time_dt_owner.has_persisted_recovery' in service -assert 'original_time_dt' not in weather -assert 'time_dt_owner.set_weather_frozen(true)' in weather -assert 'time_dt_owner.set_weather_frozen(false)' in weather -assert 'write_value(component, "time_dt", 0)' not in weather -assert 'legacy_sync_value' in sync, 'weather v1 compatibility must transport baseline time_dt' -assert 'Compare-and-swap' in owner and 'external_preserved' in owner -assert 'LEGACY_RECOVERY_KEYS' in magic and 'DESIGN_DAY_CYCLE_SPEED' in magic, 'upgrade recovery for pre-safe Day Speed was lost' - -# Optional executable grounding. The supplied Noita build uses: -# xmm1 = game step -# xmm2 = WorldStateComponent.time_dt (+0x54) -# xmm1 *= DESIGN_DAY_CYCLE_SPEED -# xmm1 *= xmm2 -# time (+0x4c) and time_total (+0x50) += xmm1 -exe_raw = os.environ.get('NOITA_EXE_UNDER_TEST', '') -if exe_raw: - exe = Path(exe_raw) - assert exe.is_file(), f'NOITA_EXE_UNDER_TEST does not exist: {exe}' - data = exe.read_bytes() - def va_off(va: int) -> int: - return 0x400 + (va - 0x401000) - va = 0xC4303D - expected = bytes.fromhex( - 'f3 0f 10 57 54 ' # movss xmm2,[edi+54] time_dt - 'f3 0f 10 48 24 ' # movss xmm1,[eax+24] game step - '0f 28 c1 ' - 'f3 0f 59 c2 ' # mulss xmm0,xmm2 (parallel delta) - 'f3 0f 11 44 24 10 ' - '75 28 ' - 'f3 0f 59 0d 60 25 15 01 ' # mulss xmm1,[DESIGN_DAY_CYCLE_SPEED] - 'f3 0f 10 47 4c ' - 'f3 0f 59 ca ' # mulss xmm1,xmm2 (time_dt) - 'f3 0f 58 c1 ' - 'f3 0f 11 47 4c ' - 'f3 0f 10 47 50 ' - 'f3 0f 58 c1 ' - 'f3 0f 11 47 50' - ) - got = data[va_off(va):va_off(va)+len(expected)] - assert got == expected, f'Day Speed binary signature mismatch at {va:#x}: {got.hex()} != {expected.hex()}' - binary = 'PASS' -else: - binary = 'SKIPPED' - -print(f'day_speed_safe_contract=PASS safe_time_dt=true weather_owner=true cas=true legacy_ew_baseline=true binary_validation={binary}') diff --git a/metamorph_creative_menu/tests/death_recovery_ew_disabled_mock.lua b/metamorph_creative_menu/tests/death_recovery_ew_disabled_mock.lua deleted file mode 100644 index ef85610..0000000 --- a/metamorph_creative_menu/tests/death_recovery_ew_disabled_mock.lua +++ /dev/null @@ -1,24 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local native_calls={install=0,request=0} -local mocks={ - ["mods/metamorph_creative_menu/files/platform/noita/player_locator.lua"]={get_human=function() return 1 end}, - ["mods/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua"]={get=function() error("bridge must not be used") end}, - ["mods/metamorph_creative_menu/files/platform/noita/native_gameover_patch.lua"]={ - install=function() native_calls.install=native_calls.install+1; return true,"bad" end, - is_installed=function() return false end, - has_revive_request=function() native_calls.request=native_calls.request+1; return true end, - status=function() return {installed=false} end, - }, - ["mods/metamorph_creative_menu/files/features/forms/human_restore.lua"]={}, - ["mods/metamorph_creative_menu/files/features/death_recovery/post_revive_cleanup.lua"]={status=function() return {} end}, - ["mods/metamorph_creative_menu/files/integrations/ew/runtime.lua"]={enabled=function() return true end}, -} -dofile=function(path) if mocks[path]~=nil then return mocks[path] end return native_dofile(path) end -local service=assert(loadfile(root.."/files/features/death_recovery/service.lua"))() -local ok,reason=service.install(); assert(ok==false and reason=="disabled_in_entangled_worlds") -ok,reason=service.update(); assert(ok==false and reason=="disabled_in_entangled_worlds") -ok,reason=service.revive(); assert(ok==false and reason=="disabled_in_entangled_worlds") -assert(native_calls.install==0 and native_calls.request==0,"disabled EW recovery touched native backend") -assert(service.status().network_disabled==true,"EW-disabled state missing from service status") -print("death_recovery_ew_disabled=PASS native_backend_untouched=true form_system_separate=true") diff --git a/metamorph_creative_menu/tests/death_recovery_native_contract.py b/metamorph_creative_menu/tests/death_recovery_native_contract.py deleted file mode 100644 index f78fb33..0000000 --- a/metamorph_creative_menu/tests/death_recovery_native_contract.py +++ /dev/null @@ -1,93 +0,0 @@ -from pathlib import Path -import csv, io, sys -root=Path(sys.argv[1]).resolve() -init=(root/'init.lua').read_text(encoding='utf-8') -patch=(root/'files/platform/noita/native_gameover_patch.lua').read_text(encoding='utf-8') -service=(root/'files/features/death_recovery/service.lua').read_text(encoding='utf-8') -native_src=(root/'native_src/mcm_native_gameover.c').read_text(encoding='utf-8') -native_dll=root/'mcm_native_gameover.dll' - -# The abandoned fake/death-boundary paths must stay gone. -assert 'death_recovery_screen.lua' not in init -assert not (root/'files/ui/death_recovery_screen.lua').exists() -assert not (root/'files/features/death_recovery/death_snapshot_hook.lua').exists() -assert 'DEATH_SNAPSHOT_HOOK' not in init -assert 'OnPlayerDied' not in service -assert 'GameTriggerGameOver' not in service -assert 'GameDestroyInventoryItems' not in service -assert 'SetPauseState' not in service - -# Native ownership lives in a real x86 Lua C module, never in LuaJIT pointers. -assert native_dll.is_file(), 'native Game Over DLL missing' -dll=native_dll.read_bytes() -assert dll[:2] == b'MZ', 'native bridge is not a PE DLL' -assert b'luaopen_mcm_native_gameover' in dll, 'Lua C-module export missing' -assert 'native_x86_lua_c_module_adaptive' in patch -assert 'require, MODULE_NAME' in patch -assert 'MODULE_NAME = "mcm_native_gameover"' in patch -assert 'require("ffi")' not in patch and 'ffi.' not in patch, 'Lua executable-memory FFI patch survived' -assert 'write_bytes' not in patch, 'Lua still owns executable-memory writes' -assert 'fixed_exe_hash = false' in patch and 'fixed_virtual_addresses = false' in patch -assert '808d2a0ab51e' not in patch, 'Lua facade still pins one noita.exe hash' - -# R10 resolves the running PE/import table and discovers stock Game Over semantics. -for token in [ - 'parse_image_layout', - 'resolve_import("lua_createtable")', - 'resolve_import("VirtualProtect")', - 'find_ascii_in_image("$menugameover_savereplay")', - 'find_ascii_in_image("event_cues/game_over/create")', - 'locate_replay_sites', - 'locate_gameover_core', - 'locate_gameover_init_globals', - 'locate_audio_cleanup', - 'resolve_adaptive_profile', - 'static volatile u8 g_revive_request = 0;', - 'static const char g_label[] = "$mcm_death_not_dead";', - 'encode_u32(gate_patch + 1u, g_profile.replay_label - (g_profile.replay_gate + 5u));', - 'encode_u32(click_patch + 2u, (u32)&g_revive_request);', - 'write_bytes(g_profile.replay_gate, gate_patch, 5u)', - 'stop_tracked_gameover_audio_handle', - 'cleanup_post_revive_runtime', - 'table_function(L, "cleanup_post_revive", lua_cleanup_post_revive);', -]: - assert token in native_src, token - -# Old build-specific addresses/signatures must not return as compatibility policy. -for forbidden in [ - '#define NOITA_BASE', '#define REPLAY_GATE', '#define REPLAY_LABEL', '#define REPLAY_CLICK', - '#define GAME_MODE_POINTER', '#define IAT_LUA_CREATETABLE', '#define UI_MENU_MUSIC_HANDLE', - '0x006e62a7', '0x006e62e1', '0x006e6393', '0x01204bc0', '0x00f077a4', -]: - assert forbidden not in native_src, forbidden -assert 'pause' not in native_src.lower(), 'native bridge must not own pause-state' -assert 'OnPlayerDied' not in native_src -luaopen=native_src.index('__declspec(dllexport) int __cdecl luaopen_mcm_native_gameover') -preflight=native_src.index('if (!resolve_runtime_imports()) return 0;', luaopen) -first_lua=native_src.index('g_lua_createtable(L, 0, 8);', luaopen) -assert preflight < first_lua, 'native DLL calls Lua before PE/import preflight' -assert 'disabled_in_entangled_worlds' in patch and 'disabled_in_entangled_worlds' in service - -# Recovery ordering remains: player authority first, Game Over release second. -assert 'BACKUP_INTERVAL_FRAMES = 60' in service -assert 'LOW_HP_INTERVAL_FRAMES = 10' in service -assert 'INVINCIBILITY_FRAMES = 30 * 60' in service -assert 'make_authoritative(bridge, entity)' in service -assert service.index('make_authoritative(bridge, entity)') < service.index('native_gameover.cancel_game_over_state()') -assert 'native_gameover.clear_revive_request()' in service -assert 'post_revive_cleanup.apply(entity)' in service -assert service.index('native_gameover.cancel_game_over_state()') < service.index('post_revive_cleanup.apply(entity)') < service.index('native_gameover.clear_revive_request()') -cleanup=(root/'files/features/death_recovery/post_revive_cleanup.lua').read_text(encoding='utf-8') -assert 'native_gameover.cleanup_post_revive()' in cleanup -assert 'GameTriggerMusicFadeOutAndDequeueAll' not in cleanup -assert 'protected_call("death_recovery.update", death_recovery.update)' in init -assert 'protected_call("death_recovery.install", death_recovery.install)' not in init -assert 'function native_gameover_patch.is_installed()' in patch -assert service.index('track_live_player()') < service.index('if not native_gameover.is_installed() then\n return false') - -rows=list(csv.reader(io.StringIO((root/'translations.csv').read_text(encoding='utf-8-sig')))) -row=next((r for r in rows if r and r[0]=='mcm_death_not_dead'),None) -assert row is not None and len(row)>=12 -assert all(str(value).strip() for value in row[1:12]) -assert 'native Game Over button patch: installed' in service -print('death_recovery_native_contract=PASS death_hook=false native_dll=true lua_ffi=false adaptive_pe32=true fixed_va=false authority_before_release=true post_cleanup=true locales=11') diff --git a/metamorph_creative_menu/tests/death_recovery_native_service_mock.lua b/metamorph_creative_menu/tests/death_recovery_native_service_mock.lua deleted file mode 100644 index ddd8a05..0000000 --- a/metamorph_creative_menu/tests/death_recovery_native_service_mock.lua +++ /dev/null @@ -1,108 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local frame=0 -local alive={[1]=true,[2]=false,[3]=false} -local authoritative=1 -local positions={[1]={12,34},[2]={0,0},[3]={0,0}} -local values={[101]={hp=4,max_hp=4,kill_now=false,invincibility_frames=0},[201]={hp=0,max_hp=5,kill_now=true,invincibility_frames=0},[301]={hp=0,max_hp=6,kill_now=true,invincibility_frames=0}} -local native_requested=false -local gameover_active=false -local serialize_count=0 -local deserialize_count=0 -local kill_count=0 -local events={} - -GameGetFrameNum=function() return frame end -EntityGetIsAlive=function(e) return alive[e]==true end -EntityGetTransform=function(e) local p=positions[e] or {0,0}; return p[1],p[2] end -EntityGetWithTag=function(tag) if tag=='player_unit' and authoritative~=0 and alive[authoritative] then return {authoritative} end return {} end -EntityHasTag=function() return false end -EntityGetFirstComponentIncludingDisabled=function(e,t) - if t=='DamageModelComponent' then return e==1 and 101 or e==2 and 201 or e==3 and 301 or 0 end - return 0 -end -ComponentGetValue2=function(c,k) return values[c] and values[c][k] end -ComponentSetValue2=function(c,k,v) values[c]=values[c] or {}; values[c][k]=v end -local next_entity=2 -EntityCreateNew=function(name) assert(name=='mcm_death_recovery_player'); local e=next_entity; next_entity=next_entity+1; alive[e]=true; return e end -EntityLoad=function(path,x,y) error('fresh fallback should not be needed') end -EntityKill=function(e) kill_count=kill_count+1; alive[e]=false end - -local bridge={} -bridge.SerializeEntity=function(e) serialize_count=serialize_count+1; events[#events+1]='serialize:'..e; return 'snapshot:'..tostring(e)..':'..tostring(frame) end -bridge.DeserializeEntity=function(e,data,x,y) - deserialize_count=deserialize_count+1; events[#events+1]='deserialize:'..e - assert(data:match('^snapshot:1:'),'wrong rolling snapshot') - positions[e]={x,y} - local c=e==2 and 201 or 301 - values[c].hp=0; values[c].kill_now=true -end -bridge.SetPlayerEntity=function(e) events[#events+1]='authority:'..e; authoritative=e end -bridge.GetPlayerEntity=function() return authoritative end -bridge.RegisterPlayerEntityId=function(e) authoritative=e end - -local native_patch={ - install=function() return true,'profile' end, - is_installed=function() return true end, - has_revive_request=function() return native_requested end, - clear_revive_request=function() local had=native_requested; native_requested=false; events[#events+1]='request_clear'; return had end, - is_game_over_active=function() return gameover_active,'ok' end, - cancel_game_over_state=function() events[#events+1]='gameover_clear'; gameover_active=false; return true,'cleared' end, - cleanup_post_revive=function() events[#events+1]='post_revive_cleanup'; return true,'post_revive_cleaned' end, - status=function() return {installed=true,game_over_active=gameover_active} end, -} - -local mocks={ - ['mods/metamorph_creative_menu/files/platform/noita/player_locator.lua']={get_human=function() return authoritative~=0 and alive[authoritative] and authoritative or 0 end}, - ['mods/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua']={get=function() return bridge end}, - ['mods/metamorph_creative_menu/files/platform/noita/native_gameover_patch.lua']=native_patch, - ['mods/metamorph_creative_menu/files/integrations/ew/runtime.lua']={enabled=function() return false end}, - ['mods/metamorph_creative_menu/files/features/death_recovery/post_revive_cleanup.lua']={ - apply=function() return native_patch.cleanup_post_revive() end, - status=function() return {attempts=1,successes=1,last_reason='post_revive_cleaned'} end, - }, - ['mods/metamorph_creative_menu/files/features/forms/human_restore.lua']={ - restore_controls=function() return true end, - protect_player=function(e,frames) - local c=e==1 and 101 or e==2 and 201 or 301 - values[c].invincibility_frames=math.max(values[c].invincibility_frames or 0,frames or 0) - values[c].kill_now=false - end, - }, -} -dofile=function(path) if mocks[path] then return mocks[path] end return native_dofile(path) end - -local service=assert(loadfile(root..'/files/features/death_recovery/service.lua'))() -assert(service.install()==true) - --- Rolling backup is created during an ordinary live world frame, not from OnPlayerDied. -service.update() -assert(serialize_count==1 and deserialize_count==0) - --- Position tracking remains current even when serialization is throttled. -frame=5; positions[1]={77,88}; service.update() -assert(serialize_count==1,'rolling backup ignored throttle') - --- Model a completely vanilla death. MCM is not called at the death boundary at all. -alive[1]=false; authoritative=0; gameover_active=true -assert(deserialize_count==0 and kill_count==0) - --- Stock fourth button writes only request=1. The later OnWorldPreUpdate restores player, --- establishes authority, and only then releases stock Game Over. -native_requested=true -local handled,source,entity=service.update() -assert(handled==true and source=='snapshot' and entity==2) -assert(authoritative==2 and alive[2]) -assert(positions[2][1]==77 and positions[2][2]==88,'recovery did not use latest tracked position') -assert(values[201].hp==5 and values[201].kill_now==false) -assert(values[201].invincibility_frames==1800) -assert(gameover_active==false and native_requested==false) -assert(deserialize_count==1 and kill_count==0) -local authority_i, clear_i, cleanup_i -for i,v in ipairs(events) do - if v=='authority:2' then authority_i=i elseif v=='gameover_clear' then clear_i=i elseif v=='post_revive_cleanup' then cleanup_i=i end -end -assert(authority_i and clear_i and authority_i=x and px<=x+w and py>=y and py<=y+h - end, -} -dofile=function(path) - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -METAMORPH_CREATIVE_MENU_DRAG_DROP=nil -local drag=assert(native_dofile(root..'/files/ui/drag_drop.lua')) -local visible={x=0,y=0,width=40,height=30} -local tile={x=4,y=4,width=18,height=18} - --- Measured press-origin wins even when Gui hover is false. Clipping still prevents a --- hidden/off-viewport tile from claiming a press. -down=true; just_down=true; mouse_x=10; mouse_y=10 -drag.begin_frame(320,240) -assert(drag.source('hidden',{kind='catalog_spell'},tile,{x=30,y=30,width=20,height=20},false)==false, - 'clipped-away source claimed the press') -assert(drag.source('catalog.A',{kind='catalog_spell',action_id='A'},tile,visible,false)==true, - 'measured tile bounds did not claim press-origin without hover') -assert(drag.pending() and not drag.active(),'fresh source should be pending before threshold') -assert(drag.end_frame()==nil,'held press completed in source frame') - --- The source is intentionally not registered again: it disappeared from the viewport. --- begin_frame must keep the original owner and payload alive while pointer movement crosses --- the threshold. Reflow may replace every target geometry without touching the source. -just_down=false; mouse_x=30; mouse_y=10 -drag.begin_frame(320,240) -assert(drag.active()==true and drag.source_id()=='catalog.A','source vanished when its tile left the viewport') -assert(drag.payload().action_id=='A','payload changed after source virtualization') -drag.target('old.slot',{x=60,y=0,width=18,height=18},function() return true end,function() error('stale target fired') end,10) -assert(drag.end_frame()==nil,'held drag completed during reflow frame') - --- A later frame represents scroll/reflow: old targets are gone because begin_frame resets --- them, and only the newly drawn target may commit on release. -local commits=0 -mouse_x=82; mouse_y=12; down=true -drag.begin_frame(320,240) -drag.target('new.slot',{x=74,y=4,width=18,height=18},function(payload) return payload.action_id=='A' end,function() - commits=commits+1; return true,'moved' -end,20) -assert(drag.end_frame()==nil,'held drag completed before release') - -down=false; mouse_x=82; mouse_y=12 -drag.begin_frame(320,240) -local gx,gy=drag.mouse_position() -assert(gx==82 and gy==12 and drag.active()==true,'ghost did not survive through the release frame') -drag.target('new.slot',{x=74,y=4,width=18,height=18},function(payload) return payload.action_id=='A' end,function() - commits=commits+1; return true,'moved' -end,20) -local result=assert(drag.end_frame()) -assert(result.target=='new.slot' and result.ok==true and commits==1,'reflowed target did not receive exactly one commit') - --- A target that refuses the payload must not run its mutation callback and must not turn --- the same release into a second operation. -down=true; just_down=true; mouse_x=10; mouse_y=10 -drag.begin_frame(320,240) -assert(drag.source('wand.1',{kind='wand_spell',entity=11},tile,visible)==true) -just_down=false; mouse_x=30; drag.begin_frame(320,240); assert(drag.active()) -local refused_calls=0 -drag.target('reject',{x=45,y=4,width=18,height=18},function() return false end,function() refused_calls=refused_calls+1; return true end,100) -down=false; mouse_x=50; drag.begin_frame(320,240) -drag.target('reject',{x=45,y=4,width=18,height=18},function() return false end,function() refused_calls=refused_calls+1; return true end,100) -result=assert(drag.end_frame()) -assert(result.target==nil and result.ok==false and refused_calls==0,'refused target mutated source or reported a commit') - --- Outside release is metadata only at the generic layer. The Spells tab decides whether --- this no-target release is world/native-inventory or an in-menu cancel. -down=true; just_down=true; mouse_x=10; mouse_y=10 -drag.begin_frame(320,240); assert(drag.source('inventory.7',{kind='inventory_spell',entity=7},tile,visible)) -just_down=false; mouse_x=30; drag.begin_frame(320,240); assert(drag.active()) -down=false; mouse_x=300; mouse_y=200; world_x=777; world_y=-55 -drag.begin_frame(320,240) -result=assert(drag.end_frame()) -assert(result.target==nil and result.reason=='no_target' and result.release_x==300 and result.world_x==777, - 'outside release metadata changed') - --- Sub-threshold motion remains an ordinary click; drag sensitivity was not lowered. -down=true; just_down=true; mouse_x=10; mouse_y=10 -drag.begin_frame(320,240); assert(drag.source('catalog.click',{kind='catalog_spell',action_id='CLICK'},tile,visible)) -just_down=false; mouse_x=12; mouse_y=11; drag.begin_frame(320,240) -assert(not drag.active(),'ordinary click crossed drag threshold too early') -down=false; drag.begin_frame(320,240) -result=assert(drag.end_frame()) -assert(result.click==true and result.payload.action_id=='CLICK','ordinary click no longer resolves as click') - --- Overlapping accepted targets still execute at most one operation: highest priority wins. -down=true; just_down=true; mouse_x=10; mouse_y=10 -drag.begin_frame(320,240); assert(drag.source('single.release',{kind='wand_spell',entity=9},tile,visible)) -just_down=false; mouse_x=30; drag.begin_frame(320,240); assert(drag.active()) -down=false; mouse_x=55; mouse_y=10; drag.begin_frame(320,240) -local high,low=0,0 -local overlap={x=45,y=4,width=20,height=20} -drag.target('low',overlap,function() return true end,function() low=low+1; return true end,1) -drag.target('high',overlap,function() return true end,function() high=high+1; return true end,200) -result=assert(drag.end_frame()) -assert(result.target=='high' and high==1 and low==0,'one release executed more than one target operation') - --- A completed result is intentionally consumed on the following GUI frame. If the menu --- closes/minimizes before that frame, cancel() must discard it instead of replaying the --- stale click/drop after the menu is opened again. -down=true; just_down=true; mouse_x=10; mouse_y=10 -drag.begin_frame(320,240); assert(drag.source('stale.result',{kind='catalog_item'},tile,visible)) -just_down=false; down=false -drag.begin_frame(320,240); result=assert(drag.end_frame()) -assert(result.click==true and drag.last_result()~=nil,'completed result was not retained for normal next-frame consumption') -drag.cancel() -assert(drag.take_result()==nil and drag.last_result()==nil,'cancel kept a stale completed result across menu lifecycle') - - -local function read(path) - local handle=assert(io.open(root..'/'..path,'rb')) - local text=handle:read('*a'); handle:close(); return text -end -local menu=read('files/ui/menu_controller.lua') -local tab_draw=assert(string.find(menu,'pcall(tab.module.draw',1,true),'menu no longer draws tab module through expected lifecycle') -local arbitrate=assert(string.find(menu,'if pointer_operation == nil and not tile_press_owned',1,true), - 'window move/resize does not yield to tile press owner') -assert(tab_draw=x and px<=x+w and py>=y and py<=y+h - end, -} -local drag=assert(native_dofile(root..'/files/ui/drag_drop.lua')) -drag.begin_frame(427,242) -assert(drag.source('source',{kind='anything'},true)==true,'fresh press did not arm source') -assert(drag.end_frame()==nil,'held press completed too early') - -just_down=false; mouse_x=30; mouse_y=10 -drag.begin_frame(427,242) -assert(drag.active()==true,'drag threshold did not activate') -assert(drag.end_frame()==nil,'active held drag completed too early') - -down=false; mouse_x=300; mouse_y=120; world_x=777; world_y=-55 -drag.begin_frame(427,242) -local result=assert(drag.end_frame()) -assert(result.target==nil and result.reason=='no_target','empty release unexpectedly found a target') -assert(result.start_x==10 and result.start_y==10,'press origin metadata lost') -assert(result.release_x==300 and result.release_y==120,'release gui position metadata lost') -assert(result.world_x==777 and result.world_y==-55,'release world position metadata lost') -print('drag_drop_release=PASS generic_release_metadata=true no_target=true') diff --git a/metamorph_creative_menu/tests/effect_control_lock_safety_mock.lua b/metamorph_creative_menu/tests/effect_control_lock_safety_mock.lua deleted file mode 100644 index 12c3246..0000000 --- a/metamorph_creative_menu/tests/effect_control_lock_safety_mock.lua +++ /dev/null @@ -1,104 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile -local frame=100 -local alive={[1]=true} -local children={[1]={}} -local tags={} -local filenames={} -local component_owner={} -local component_type={} -local fields={} -local next_entity=10 -local next_component=100 - -local electro={ - kind='game_effect', id='ELECTROCUTION', path='data/entities/misc/effect_electricity.xml', - icon='data/ui_gfx/gun_actions/electrocution_field.png', display_name='Electrified', - display_description='', game_effect='ELECTROCUTION', authored_frames=40, disable_movement=true, -} -local catalog_stub={ - entries=function() return {electro} end, - status_entries=function() return {} end, - reserved_effects=function() return {POLYMORPH=true} end, -} - -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/features/effects/catalog.lua' then return catalog_stub end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -local function add_component(entity,kind,data) - local id=next_component; next_component=next_component+1 - component_owner[id]=entity; component_type[id]=kind; fields[id]=data or {} - return id -end -function EntityGetIsAlive(e) return alive[e]==true end -function ModDoesFileExist(path) return path==electro.path or path==electro.icon end -function LoadGameEffectEntityTo(player,path) - assert(player==1 and path==electro.path,'wrong electrocution target') - local e=next_entity; next_entity=next_entity+1 - alive[e]=true; children[e]={}; tags[e]={}; filenames[e]=path - add_component(e,'GameEffectComponent',{effect='ELECTROCUTION',custom_effect_id='',frames=40,disable_movement=true}) - children[1][#children[1]+1]=e - return e -end -function EntityAddTag(e,tag) tags[e]=tags[e] or {}; tags[e][tag]=true end -function EntityHasTag(e,tag) return tags[e]~=nil and tags[e][tag]==true end -function EntityGetWithTag(tag) - local out={} - for e,set in pairs(tags) do if alive[e] and set[tag] then out[#out+1]=e end end - return out -end -function EntityGetAllChildren(e) - local out={} - for _,c in ipairs(children[e] or {}) do if alive[c] then out[#out+1]=c end end - return out -end -function EntityGetFilename(e) return filenames[e] or '' end -function EntityGetComponentIncludingDisabled(e,kind) - local out={} - for id,owner in pairs(component_owner) do if owner==e and component_type[id]==kind then out[#out+1]=id end end - return out -end -function EntityGetFirstComponentIncludingDisabled(e,kind) - local list=EntityGetComponentIncludingDisabled(e,kind); return list[1] or 0 -end -function EntityAddComponent2(e,kind,data) return add_component(e,kind,data) end -function ComponentGetValue2(c,f) return fields[c] and fields[c][f] end -function ComponentSetValue2(c,f,v) fields[c]=fields[c] or {}; fields[c][f]=v end -function GameGetFrameNum() return frame end -function EntityKill(e) alive[e]=false end - -METAMORPH_CREATIVE_MENU_EFFECT_SERVICE=nil -METAMORPH_CREATIVE_MENU_EFFECT_EDITOR=nil -local effects=assert(native_dofile(root..'/files/features/effects/service.lua')) -assert(effects.duration_policy(electro)=='authored','control-lock effect duration is editable') -assert(effects.authored_frames(electro)==40,'authored electrocution duration lost') - --- A caller may ask for minutes or infinity; movement-lock effects must always stay at the --- authored short pulse so the menu cannot accidentally lock TAB/normal controls for minutes. -local ok,reason=effects.add(1,electro,-1) -assert(ok==true,'electrocution apply failed') -local child=EntityGetAllChildren(1)[1] -local comp=EntityGetFirstComponentIncludingDisabled(child,'GameEffectComponent') -assert(ComponentGetValue2(comp,'frames')==40,'infinite request escaped authored safety duration') -local count_before=#EntityGetAllChildren(1) -ok,reason=effects.add(1,electro,18000) -assert(ok==true and reason=='retriggered','control-lock repeat should retrigger, not stack') -assert(ComponentGetValue2(comp,'frames')==40,'repeated control-lock effect accumulated duration') -assert(#EntityGetAllChildren(1)==count_before,'repeated control-lock effect cloned a HUD row') - --- Repair state left by older MCM builds that stretched this effect before the safety rule. -ComponentSetValue2(comp,'frames',-1) -frame=frame+1 -effects.update() -assert(ComponentGetValue2(comp,'frames')==40,'legacy infinite movement lock was not clamped') -ComponentSetValue2(comp,'frames',3600) -frame=frame+1 -effects.update() -assert(ComponentGetValue2(comp,'frames')==40,'legacy long movement lock was not clamped') - -assert(effects.set_remaining(1,electro,600)==false,'editor exposed duration editing for a control-lock effect') -print('effect_control_lock_safety=PASS authored_duration=40 no_stack=true no_clone=true legacy_clamp=true editor_fixed=true') diff --git a/metamorph_creative_menu/tests/effect_editor_catalog_mock.lua b/metamorph_creative_menu/tests/effect_editor_catalog_mock.lua deleted file mode 100644 index 461e9a9..0000000 --- a/metamorph_creative_menu/tests/effect_editor_catalog_mock.lua +++ /dev/null @@ -1,140 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile - -dofile=function(path) - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -local paths={ - oiled='data/entities/misc/effect_oiled.xml', - drunk='data/entities/misc/effect_drunk.xml', - drunk2='data/entities/misc/effect_drunk_01.xml', - berserk='data/entities/misc/effect_berserk.xml', - trip0='data/entities/misc/effect_trip_00.xml', - trip1='data/entities/misc/effect_trip_01.xml', - trip2='data/entities/misc/effect_trip_02.xml', - trip3='data/entities/misc/effect_trip_03.xml', - breath='data/entities/misc/effect_breath_underwater.xml', - technical='data/entities/misc/effect_no_heal.xml', - spirit_berserk='data/entities/misc/effect_spirit_berserk.xml', - twitchy='data/entities/misc/effect_twitchy.xml', -} -local icons={ - oiled='data/ui_gfx/status_indicators/oiled.png', - alcoholic='data/ui_gfx/status_indicators/alcoholic.png', - drunk='data/ui_gfx/status_indicators/drunk.png', - berserk='data/ui_gfx/status_indicators/berserk.png', - trip='data/ui_gfx/status_indicators/trip.png', - breath='data/ui_gfx/perk_icons/breath_underwater.png', - technical='data/ui_gfx/status_indicators/weakness.png', - twitchy='data/ui_gfx/status_indicators/twitchy.png', -} - -function dofile_once(path) - if path=='data/scripts/perks/perk_list.lua' then perk_list={{game_effect='BREATH_UNDERWATER',ui_name='$breath',ui_description='$breath_desc',ui_icon=icons.breath}}; return end - assert(path=='data/scripts/status_effects/status_list.lua','unexpected status source') - status_effects={ - {id='OILED',ui_name='$oiled',ui_description='$oiled_desc',ui_icon=icons.oiled,effect_entity=paths.oiled}, - {id='HYDRATED',ui_name='$oiled',ui_description='$oiled_desc',ui_icon=icons.oiled,effect_entity=paths.oiled}, - {id='ALCOHOLIC',ui_name='$alcoholic',ui_description='$alcoholic_desc',ui_icon=icons.alcoholic,effect_entity=paths.drunk}, - {id='INGESTION_DRUNK',ui_name='$drunk0',ui_description='$drunk0_desc',ui_icon=icons.drunk,effect_entity=paths.drunk,min_threshold_normalized=0}, - {id='INGESTION_DRUNK',ui_name='$drunk1',ui_description='$drunk1_desc',ui_icon=icons.drunk,effect_entity=paths.drunk,min_threshold_normalized=.25}, - {id='INGESTION_DRUNK',ui_name='$drunk2',ui_description='$drunk2_desc',ui_icon=icons.drunk,effect_entity=paths.drunk2,min_threshold_normalized=.75}, - {id='BERSERK',ui_name='$berserk',ui_description='$berserk_desc',ui_icon=icons.berserk,effect_entity=paths.berserk}, - {id='TRIP',ui_name='$trip0',ui_description='$trip0_desc',ui_icon=icons.trip,effect_entity=paths.trip0}, - {id='TRIP',ui_name='$trip1',ui_description='$trip1_desc',ui_icon=icons.trip,effect_entity=paths.trip1,min_threshold_normalized=.5}, - {id='TRIP',ui_name='$trip2',ui_description='$trip2_desc',ui_icon=icons.trip,effect_entity=paths.trip2,min_threshold_normalized=1.5}, - {id='TRIP',ui_name='$trip3',ui_description='$trip3_desc',ui_icon=icons.trip,effect_entity=paths.trip3,min_threshold_normalized=3}, - } -end - -local translated={ - ['$oiled']='Oiled', ['$oiled_desc']='Oil', - ['$alcoholic']='Alcoholic', ['$alcoholic_desc']='Alcohol', - ['$drunk0']='Tipsy', ['$drunk0_desc']='Mild alcohol', - ['$drunk1']='Drunk', ['$drunk1_desc']='Alcohol', - ['$drunk2']='Very drunk', ['$drunk2_desc']='Strong alcohol', - ['$berserk']='Berserk', ['$berserk_desc']='Rage', - ['$trip0']='Light trip', ['$trip0_desc']='Trip 1', - ['$trip1']='Trip', ['$trip1_desc']='Trip 2', - ['$trip2']='Strong trip', ['$trip2_desc']='Trip 3', - ['$trip3']='Extreme trip', ['$trip3_desc']='Trip 4', - ['$breath']='Breathe underwater', ['$breath_desc']='Breathe while submerged', - ['$twitchy']='Twitchy', ['$twitchy_desc']='Random firing', -} -function GameTextGetTranslatedOrNot(key) return translated[key] or key end - -local existing={} -for _,v in pairs(paths) do existing[v]=true end -for _,v in pairs(icons) do existing[v]=true end -function ModDoesFileExist(path) return existing[path]==true end -function ModTextFileGetContent(path) - if not existing[path] or string.sub(path,-4)~='.xml' then return '' end - if path==paths.breath then - return '' - end - if path==paths.technical then - -- Deliberately has a visible icon and a raw English developer label. It must still be - -- rejected because no localized/status/perk presentation exists for this technical XML. - return '' - end - if path==paths.spirit_berserk then - return '' - end - if path==paths.twitchy then - return '' - end - if path==paths.berserk then - return '' - end - local custom='TEST_'..string.gsub(path,'[^%w]','_') - return '' -end - -METAMORPH_CREATIVE_MENU_EFFECT_CATALOG=nil -METAMORPH_CREATIVE_MENU_EFFECT_SERVICE=nil -METAMORPH_CREATIVE_MENU_EFFECT_EDITOR=nil -local catalog=assert(native_dofile(root..'/files/features/effects/catalog.lua')) -local entries=catalog.entries() -local by_id={} -for _,entry in ipairs(entries) do - by_id[entry.id]=by_id[entry.id] or {} - by_id[entry.id][#by_id[entry.id]+1]=entry -end -assert(by_id.ALCOHOLIC and #by_id.ALCOHOLIC==1,'alcoholic status disappeared from effect editor catalog') -assert(by_id.INGESTION_DRUNK and #by_id.INGESTION_DRUNK==1,'ingestion alcohol family disappeared') -assert(type(by_id.INGESTION_DRUNK[1].variants)=='table' and #by_id.INGESTION_DRUNK[1].variants==2, - 'ingestion alcohol should expose only two distinct authored implementations') -assert(by_id.TRIP and #by_id.TRIP==1 and type(by_id.TRIP[1].variants)=='table' and #by_id.TRIP[1].variants==4, - 'trip editor family must expose four authored stages') -assert(by_id.BERSERK and #by_id.BERSERK==1 and by_id.BERSERK[1].variants==nil, - 'single-state effect unexpectedly got a fake stage selector or duplicate implementation') -local oil_aliases=(by_id.OILED and #by_id.OILED or 0)+(by_id.HYDRATED and #by_id.HYDRATED or 0) -assert(oil_aliases==1,'exact OILED/HYDRATED presentation alias was not deduplicated') -assert(by_id.BREATH_UNDERWATER==nil, - 'perk-backed passive GameEffect leaked into the EFFECTS catalog') -assert(by_id.TECHNICAL_ONLY==nil,'raw English technical GameEffect leaked into user-facing catalog') -local twitchy_entry=nil -for _,entry in ipairs(entries) do if entry.path==paths.twitchy then twitchy_entry=entry; break end end -assert(twitchy_entry~=nil and twitchy_entry.authored_lifetime==1200, - 'localized LifetimeComponent status effect disappeared from editor catalog') - -local ui_source=assert(io.open(root..'/files/ui/tabs/effects.lua','rb')):read('*a') -assert(not string.find(ui_source,'ui.stepper',1,true),'effects editor still uses tiny +/- stepper controls') -assert(not string.find(ui_source,'$mcm_apply',1,true),'effects editor still requires a separate APPLY button') -assert(string.find(ui_source,'apply_tile(player,entry,snapshot)',1,true), - 'LMB tile no longer applies the selected effect immediately') -assert(string.find(ui_source,'label=tostring(variant_index)',1,true), - 'multi-stage effect editor is not using direct stage buttons') -assert(string.find(ui_source,'effect_service.switch_variant',1,true), - 'active stage selection is not an immediate editor operation') -assert(string.find(ui_source,'local duration_index_by_key = {}',1,true), - 'effect duration presets are still shared globally instead of being per effect') -assert(string.find(ui_source,'draw_duration_bar(player, entry, panel_width, snapshot)',1,true), - 'selected effect no longer exposes its contextual duration editor') -assert(not string.find(ui_source,'local duration_index = 2',1,true), - 'legacy global effect duration state is still present') -assert(not string.find(ui_source,'fallback="AUTO"',1,true),'effects editor still exposes ambiguous AUTO duration clutter') -print('effect_editor_catalog=PASS alcohol=true ingestion_variants=2 trip_variants=4 single_state_clean=true aliases_deduped=true technical_hidden=true perk_hidden=true lifetime_effects=true status_impl_deduped=true one_click_apply=true per_effect_duration=true immediate_editor=true') diff --git a/metamorph_creative_menu/tests/effect_lifecycle_mock.lua b/metamorph_creative_menu/tests/effect_lifecycle_mock.lua index 24d8b9f..70f5e27 100644 --- a/metamorph_creative_menu/tests/effect_lifecycle_mock.lua +++ b/metamorph_creative_menu/tests/effect_lifecycle_mock.lua @@ -1,42 +1,23 @@ local root = assert(arg[1], "root required") local native_dofile = dofile local frame = 10 -local alive = {[1]=true, [10]=true, [11]=true} -local children = {[1]={11}} +local alive = {[1]=true, [10]=true} +local children = {[1]={10}} local tags = {} -local frames = {[100]=-1, [101]=-1, [110]=900} -local filenames = { - [10]="data/entities/misc/effect_trip_00.xml", - [11]="data/entities/misc/effect_trip_01.xml", -} +local frames = {[100]=-1} +local filenames = {[10]="data/entities/misc/effect_test.xml"} local effect_entry = { kind="game_effect", - id="TRIP", - path="data/entities/misc/effect_trip_00.xml", - icon="data/ui_gfx/status_indicators/trip.png", - display_name="Light trip", + id="TEST_EFFECT", + path="data/entities/misc/effect_test.xml", + icon="data/ui_gfx/status_indicators/test.png", + display_name="Test effect", display_description="", - game_effect="CUSTOM", - custom_effect_id="TRIP_00", -} -local stronger_entry = { - kind="game_effect", - id="TRIP", - path="data/entities/misc/effect_trip_01.xml", - icon=effect_entry.icon, - display_name="Medium trip", - display_description="", - game_effect="CUSTOM", - custom_effect_id="TRIP_00", -} -local family_entry = { - kind="game_effect", id="TRIP", path=effect_entry.path, icon=effect_entry.icon, - display_name="Trip", display_description="", game_effect="CUSTOM", custom_effect_id="TRIP_00", - variants={effect_entry,stronger_entry}, + game_effect="TEST_EFFECT", } local catalog_stub = { - entries=function() return {effect_entry,stronger_entry} end, + entries=function() return {effect_entry} end, status_entries=function() return {} end, reserved_effects=function() return {POLYMORPH=true} end, } @@ -51,10 +32,9 @@ dofile = function(path) end function EntityGetIsAlive(entity) return alive[entity] == true end -function ModDoesFileExist(path) return path == effect_entry.path or path == stronger_entry.path or path == effect_entry.icon end +function ModDoesFileExist(path) return path == effect_entry.path or path == effect_entry.icon end function LoadGameEffectEntityTo(player, path) assert(player == 1 and path == effect_entry.path, "effect attached to wrong target") - children[1][#children[1]+1]=10 return 10 end function EntityAddTag(entity, tag) @@ -62,39 +42,27 @@ function EntityAddTag(entity, tag) tags[entity][tag] = true end function EntityHasTag(entity, tag) return tags[entity] ~= nil and tags[entity][tag] == true end -function EntityGetAllChildren(entity) - local result={} - for _,child in ipairs(children[entity] or {}) do if alive[child] then result[#result+1]=child end end - return result -end +function EntityGetAllChildren(entity) return children[entity] or {} end function EntityGetFilename(entity) return filenames[entity] or "" end -function EntityGetComponentIncludingDisabled(entity, component_type) - if entity == 10 and component_type == "GameEffectComponent" then return {100,101} end - if entity == 11 and component_type == "GameEffectComponent" then return {110} end - return {} -end function EntityGetFirstComponentIncludingDisabled(entity, component_type) - local values=EntityGetComponentIncludingDisabled(entity,component_type) - if #values>0 then return values[1] end - if (entity == 10 or entity == 11) and component_type == "UIIconComponent" then return 0 end + if entity == 10 and component_type == "GameEffectComponent" then return 100 end + if entity == 10 and component_type == "UIIconComponent" then return 0 end if entity == 1 and component_type == "StatusEffectDataComponent" then return 0 end return 0 end function EntityAddComponent2(entity, component_type, values) - assert((entity == 10 or entity == 11) and component_type == "UIIconComponent", "unexpected component creation") + assert(entity == 10 and component_type == "UIIconComponent", "unexpected component creation") assert(values.display_in_hud == true and values.is_perk == false, "effect HUD ownership changed") - return 200 + entity + return 200 end function ComponentGetValue2(component, field) - if component == 100 or component == 101 or component == 110 then - if field == "effect" then return "CUSTOM" end - if field == "custom_effect_id" then return "TRIP_00" end - if field == "frames" then return frames[component] end - end + if component == 100 and field == "effect" then return "TEST_EFFECT" end + if component == 100 and field == "custom_effect_id" then return "" end + if component == 100 and field == "frames" then return frames[100] end return nil end function ComponentSetValue2(component, field, value) - if (component == 100 or component == 101 or component == 110) and field == "frames" then frames[component] = value end + if component == 100 and field == "frames" then frames[100] = value end end function GameGetFrameNum() return frame end function EntityKill(entity) alive[entity] = false end @@ -103,34 +71,17 @@ METAMORPH_CREATIVE_MENU_EFFECT_SERVICE = nil METAMORPH_CREATIVE_MENU_EFFECT_EDITOR = nil local effects = assert(native_dofile(root .. "/files/features/effects/service.lua")) --- A stronger variant with the same CUSTOM id already exists. Exact-path ownership means --- it must not make the light variant active or absorb/remove light-variant operations. -local snapshot=effects.active_snapshot(1) -assert(effects.is_active(1,effect_entry,snapshot)==false,"shared custom id incorrectly activated another variant") -assert(effects.is_active(1,stronger_entry,snapshot)==true,"exact stronger variant not reported active") - -assert(effects.active_variant_index(1,family_entry,snapshot)==2,"editor did not select the currently active authored stage") -local added, reason = effects.set_variant(1, family_entry, 1, 600) -assert(added == true and reason == "TRIP_00", "family editor did not apply selected stage") -assert(frames[110] == 1, "changing stage did not retire the previously active sibling stage") -assert(frames[100] == 600 and frames[101] == 600, "selected duration was not applied to every component") +local added, reason = effects.add(1, effect_entry, 600) +assert(added == true and reason == "TEST_EFFECT", "game effect was not applied") +assert(frames[100] == 600, "selected duration was not applied") assert(EntityHasTag(10, "metamorph_creative_menu_effect"), "effect service did not mark ownership") -assert(#EntityGetAllChildren(1)==2,"first apply produced unexpected child count") - --- Reapply exact variant: extend the existing entity instead of creating a second HUD row. -local stacked,stack_reason=effects.add(1,effect_entry,600) -assert(stacked==true and stack_reason=="stacked","same variant did not stack duration") -assert(frames[100]==1200 and frames[101]==1200,"stacking did not extend duration") -assert(#EntityGetAllChildren(1)==2,"stacking cloned the effect entity") -assert(effects.residue_count(1,effect_entry)==1,"stacked effect became duplicate residues") +assert(effects.is_active(1, effect_entry) == true, "new effect is not reported active") +assert(effects.residue_count(1, effect_entry) == 1, "active effect residue count changed") -frames[110]=900 -- simulate an independently reintroduced stronger stage local removed = effects.remove(1, effect_entry) -assert(removed == 1 and frames[100] == 1 and frames[101] == 1, "remove did not expire every component") -assert(frames[110]==900,"removing light trip touched stronger same-id variant") +assert(removed == 1 and frames[100] == 1, "remove did not request normal Noita expiry") frame = frame + 5 effects.update() assert(alive[10] == false, "owned persistent effect was not retired after bounded expiry window") -assert(alive[11] == true,"variant-specific cleanup killed the wrong effect") -print("effect_lifecycle=PASS variants_exact=true active_stage_detected=true stage_replace=true stack_extends=true single_hud_entity=true multi_component_duration=true remove_exact=true bounded_cleanup=true") +print("effect_lifecycle=PASS apply=true active=true remove=true bounded_cleanup=true") diff --git a/metamorph_creative_menu/tests/effect_lifetime_entity_mock.lua b/metamorph_creative_menu/tests/effect_lifetime_entity_mock.lua deleted file mode 100644 index 7bc0d2e..0000000 --- a/metamorph_creative_menu/tests/effect_lifetime_entity_mock.lua +++ /dev/null @@ -1,83 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile -local frame=100 -local alive={[1]=true} -local children={[1]={}} -local tags={} -local filenames={} -local component_owner={} -local component_type={} -local fields={} -local next_entity=10 -local next_component=100 - -local twitchy={ - kind='game_effect',id='TWITCHY',path='data/entities/misc/effect_twitchy.xml', - icon='data/ui_gfx/status_indicators/twitchy.png',display_name='Twitchy',display_description='', - authored_lifetime=1200, -} -local catalog_stub={ - entries=function() return {twitchy} end, - status_entries=function() return {} end, - reserved_effects=function() return {POLYMORPH=true} end, -} -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/features/effects/catalog.lua' then return catalog_stub end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -local function add_component(e,kind,data) - local id=next_component; next_component=next_component+1 - component_owner[id]=e; component_type[id]=kind; fields[id]=data or {} - return id -end -function EntityGetIsAlive(e) return alive[e]==true end -function ModDoesFileExist(path) return path==twitchy.path or path==twitchy.icon end -function LoadGameEffectEntityTo(player,path) - assert(player==1 and path==twitchy.path,'wrong lifetime effect target') - local e=next_entity; next_entity=next_entity+1 - alive[e]=true; children[e]={}; tags[e]={}; filenames[e]=path - add_component(e,'LifetimeComponent',{lifetime=1200}) - add_component(e,'UIIconComponent',{icon_sprite_file=twitchy.icon}) - children[1][#children[1]+1]=e - return e -end -function EntityAddTag(e,tag) tags[e]=tags[e] or {}; tags[e][tag]=true end -function EntityHasTag(e,tag) return tags[e]~=nil and tags[e][tag]==true end -function EntityGetAllChildren(e) - local out={}; for _,c in ipairs(children[e] or {}) do if alive[c] then out[#out+1]=c end end; return out -end -function EntityGetFilename(e) return filenames[e] or '' end -function EntityGetComponentIncludingDisabled(e,kind) - local out={}; for id,owner in pairs(component_owner) do if owner==e and component_type[id]==kind then out[#out+1]=id end end; table.sort(out); return out -end -function EntityGetFirstComponentIncludingDisabled(e,kind) return EntityGetComponentIncludingDisabled(e,kind)[1] or 0 end -function EntityAddComponent2(e,kind,data) return add_component(e,kind,data) end -function ComponentGetValue2(c,f) return fields[c] and fields[c][f] end -function ComponentSetValue2(c,f,v) fields[c]=fields[c] or {}; fields[c][f]=v end -function GameGetFrameNum() return frame end -function EntityKill(e) alive[e]=false end -function EntityGetWithTag() return {} end - -METAMORPH_CREATIVE_MENU_EFFECT_SERVICE=nil -METAMORPH_CREATIVE_MENU_EFFECT_EDITOR=nil -local effects=assert(native_dofile(root..'/files/features/effects/service.lua')) -assert(effects.authored_frames(twitchy)==1200,'LifetimeComponent authored duration not exposed') -local ok,reason=effects.add(1,twitchy,1800) -assert(ok==true and reason=='TWITCHY','lifetime-backed effect failed to apply') -local child=EntityGetAllChildren(1)[1] -local life=EntityGetFirstComponentIncludingDisabled(child,'LifetimeComponent') -assert(ComponentGetValue2(life,'lifetime')==1800,'requested duration not written to LifetimeComponent') -assert(effects.is_active(1,twitchy,effects.active_snapshot(1))==true,'lifetime-backed effect not visible as active') -frame=frame+300 -ok,reason=effects.add(1,twitchy,600) -assert(ok==true and reason=='stacked','lifetime-backed reapply did not stack') -assert(ComponentGetValue2(life,'lifetime')==2400,'lifetime stacking did not add requested duration') -local set,why=effects.set_remaining(1,twitchy,600) -assert(set==true and why=='set','lifetime editor could not set remaining time') -assert(ComponentGetValue2(life,'lifetime')==900,'remaining-time edit ignored elapsed lifetime') -assert(effects.remaining_frames(1,twitchy)==600,'remaining lifetime calculation incorrect') -local removed=effects.remove(1,twitchy) -assert(removed==1 and alive[child]==false,'lifetime-backed effect removal did not run exact child cleanup') -print('effect_lifetime_entity=PASS catalog_duration=true apply=true stack=true set_remaining=true active=true remove=true') diff --git a/metamorph_creative_menu/tests/effect_perk_duration_mock.lua b/metamorph_creative_menu/tests/effect_perk_duration_mock.lua deleted file mode 100644 index 19a6182..0000000 --- a/metamorph_creative_menu/tests/effect_perk_duration_mock.lua +++ /dev/null @@ -1,47 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local path='data/entities/misc/effect_test.xml' -local entry={kind='game_effect',id='TEST',path=path,icon='',display_name='Test',display_description='',game_effect='CUSTOM',custom_effect_id='TEST'} -local alive={[1]=true,[10]=true} -local children={[1]={10}} -local frames={[100]=600,[110]=0} -local tags={[10]={perk_entity=true}} -local catalog={entries=function() return {entry} end,status_entries=function() return {} end,reserved_effects=function() return {} end} -dofile=function(p) - if p=='mods/metamorph_creative_menu/files/features/effects/catalog.lua' then return catalog end - local prefix='mods/metamorph_creative_menu/' - if string.sub(p,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(p,#prefix+1)) end - return native_dofile(p) -end -function EntityGetIsAlive(e) return alive[e]==true end -function EntityGetAllChildren(e) local o={} for _,c in ipairs(children[e] or {}) do if alive[c] then o[#o+1]=c end end return o end -function EntityGetFilename(e) return (e==10 or e==11) and path or '' end -function EntityHasTag(e,t) return tags[e] and tags[e][t] or false end -function EntityAddTag(e,t) tags[e]=tags[e] or {}; tags[e][t]=true end -function EntityGetComponentIncludingDisabled(e,k) - if k=='GameEffectComponent' then if e==10 then return {100} elseif e==11 then return {110} end end - return {} -end -function EntityGetFirstComponentIncludingDisabled(e,k) - local v=EntityGetComponentIncludingDisabled(e,k); if #v>0 then return v[1] end - return 0 -end -function ComponentGetValue2(c,f) - if c==100 or c==110 then if f=='frames' then return frames[c] elseif f=='effect' then return 'CUSTOM' elseif f=='custom_effect_id' then return 'TEST' end end - return nil -end -function ComponentSetValue2(c,f,v) if f=='frames' then frames[c]=v end end -function ModDoesFileExist(p) return p==path end -function LoadGameEffectEntityTo(player,p) assert(player==1 and p==path); alive[11]=true; children[1][#children[1]+1]=11; return 11 end -function EntityAddComponent2() return 500 end -function EntityKill(e) alive[e]=false end -function GameGetFrameNum() return 1 end -METAMORPH_CREATIVE_MENU_EFFECT_SERVICE=nil; METAMORPH_CREATIVE_MENU_EFFECT_EDITOR=nil -local effects=assert(native_dofile(root..'/files/features/effects/service.lua')) -local ok,why=effects.set_remaining(1,entry,120) -assert(ok==false and why=='inactive' and frames[100]==600,'duration editor modified perk-owned effect') -ok,why=effects.add(1,entry,300) -assert(ok==true and frames[100]==600 and frames[110]==300,'adding editor effect reused/extended perk-owned entity') -assert(tags[11] and tags[11].metamorph_creative_menu_effect==true,'new editor effect was not ownership-tagged') -assert(effects.set_remaining(1,entry,90)==true and frames[110]==90 and frames[100]==600,'editable effect duration did not stay isolated from perk') -print('effect_perk_duration=PASS perk_untouched=true editor_owned_copy=true') diff --git a/metamorph_creative_menu/tests/effect_variant_catalog_mock.lua b/metamorph_creative_menu/tests/effect_variant_catalog_mock.lua deleted file mode 100644 index 5a29f23..0000000 --- a/metamorph_creative_menu/tests/effect_variant_catalog_mock.lua +++ /dev/null @@ -1,66 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -dofile=function(path) - local prefix="mods/metamorph_creative_menu/" - if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -local paths={ - "data/entities/misc/effect_trip_00.xml", - "data/entities/misc/effect_trip_01.xml", - "data/entities/misc/effect_trip_02.xml", - "data/entities/misc/effect_trip_03.xml", -} -local names={"Light trip","Trip","Strong trip","Extreme trip"} -function dofile_once(path) - assert(path=="data/scripts/status_effects/status_list.lua","unexpected dofile_once") - status_effects={} - for i=1,4 do - status_effects[#status_effects+1]={id="TRIP",ui_name="$trip_"..i,ui_description="$tripdesc_"..i,ui_icon="trip.png",effect_entity=paths[i],min_threshold_normalized=(i-1)*0.5} - end - -- Same implementation path at another ingestion threshold must not create a fake fifth intensity. - status_effects[#status_effects+1]={id="TRIP",ui_name="$trip_duplicate",ui_description="$tripdesc_duplicate",ui_icon="trip.png",effect_entity=paths[2],min_threshold_normalized=9} -end -function GameTextGetTranslatedOrNot(key) - for i=1,4 do if key=="$trip_"..i then return names[i] end end - return key -end -function ModDoesFileExist(path) - for _,candidate in ipairs(paths) do if path==candidate then return true end end - return path=="trip.png" -end -function ModTextFileGetContent(path) - for _,candidate in ipairs(paths) do - if path==candidate then - return [[]] - end - end - return "" -end - -METAMORPH_CREATIVE_MENU_EFFECT_CATALOG=nil -local catalog=assert(native_dofile(root.."/files/features/effects/catalog.lua")) -local entries=catalog.entries() -local trip={} -for _,entry in ipairs(entries) do - if entry.id=="TRIP" then trip[#trip+1]=entry end -end -assert(#trip==1,"TRIP should be one clean semantic tile, got "..tostring(#trip)) -local family=trip[1] -assert(type(family.variants)=="table" and #family.variants==4,"TRIP strength variants missing: "..tostring(type(family.variants)=="table" and #family.variants or 0)) -local seen={} -for index,variant in ipairs(family.variants) do - assert(variant.path~="" and not seen[variant.path],"variant path identity lost") - seen[variant.path]=true - assert(variant.custom_effect_id=="TRIP_00","shared vanilla custom id metadata changed") - assert(variant.variant_index==nil,"catalog should not pre-bind UI variant index") -end -for _,path in ipairs(paths) do assert(seen[path],"missing trip intensity "..path) end -METAMORPH_CREATIVE_MENU_EFFECT_SERVICE=nil -local service=assert(native_dofile(root.."/files/features/effects/service.lua")) -assert(service.max_variant_count()==4,"global strength selector did not expose all authored trip levels") -for i,path in ipairs(paths) do - local resolved=service.resolve_variant(family,i) - assert(resolved.path==path and resolved.variant_index==i and resolved.variant_count==4,"strength resolver selected wrong trip level "..i) -end -print("effect_variant_catalog=PASS trip_tile=1 strengths=4 selector=4 same_path_alias_merged=true") diff --git a/metamorph_creative_menu/tests/ew_boss_global_demote_mock.lua b/metamorph_creative_menu/tests/ew_boss_global_demote_mock.lua deleted file mode 100644 index 4809cd1..0000000 --- a/metamorph_creative_menu/tests/ew_boss_global_demote_mock.lua +++ /dev/null @@ -1,282 +0,0 @@ -local root=assert(arg[1],"root required") -METAMORPH_CREATIVE_MENU_TESTING = true - -local frame=100 -local next_component=500 -local entities={ - [101]={alive=true,tags={},components={},filename="data/entities/animals/maggot_tiny/maggot_tiny.xml"}, - [102]={alive=true,tags={},components={},filename="data/entities/animals/sheep.xml"}, - [103]={alive=true,tags={ew_des=true},components={},filename="data/entities/animals/boss_dragon.xml"}, - [107]={alive=true,tags={boss=true,boss_centipede=true,mcm_creative_kolmi_v3=true},components={},filename="data/entities/animals/boss_centipede/boss_centipede.xml"}, - [108]={alive=true,tags={boss=true,miniboss=true},components={},filename="data/entities/animals/boss_limbs/boss_limbs.xml"}, - [109]={alive=true,tags={boss=true,boss_centipede=true},components={},filename="data/entities/animals/boss_centipede/boss_centipede.xml"}, - [110]={alive=true,tags={enemy=true},components={},filename="data/entities/animals/example_keepalive_mob.xml"}, -} -local comps={} -local globals={} - -local function ensure_entity(id,tags,filename) - entities[id]=entities[id] or {alive=true,tags={},components={},filename=filename or ""} - if filename then entities[id].filename=filename end - for k,v in pairs(tags or {}) do entities[id].tags[k]=v end - return entities[id] -end - -local function add_component(entity,ctype,members,enabled,tags,id) - ensure_entity(entity) - id=id or next_component; next_component=math.max(next_component+1,id+1) - comps[id]={entity=entity,type=ctype,members=members or {},enabled=enabled~=false,tags=tags or ""} - table.insert(entities[entity].components,id) - return id -end - --- Normal demoted creative boss. Keep IDs for BossDragon + StreamingKeepAlive. -add_component(101,"StreamingKeepAliveComponent",{},true,"",201) -add_component(101,"BossDragonComponent",{speed="8",part_distance="56",projectile_1=""},true,"dragon_tag",202) -add_component(101,"BossHealthBarComponent",{gui="0",in_world="1",gui_max_distance_visible="600"},true,"",203) -add_component(101,"DamageModelComponent",{hp="10",wait_for_kill_flag_on_death=false},true,"",211) -add_component(102,"DamageModelComponent",{hp="10",wait_for_kill_flag_on_death=false},true,"",204) --- Existing remote boss must be untouched. -add_component(103,"StreamingKeepAliveComponent",{},true,"",205) -add_component(103,"BossDragonComponent",{speed="9"},true,"",206) -add_component(103,"BossHealthBarComponent",{},true,"",207) -add_component(103,"DamageModelComponent",{hp="5",wait_for_kill_flag_on_death=false},true,"",212) -add_component(103,"VariableStorageComponent",{name="ew_gid_lid",value_string="remote-gid",value_bool=false},true,"",208) --- Creative Kolmisilma: disabled-at-start boss components + wait-for-kill handshake. -add_component(107,"StreamingKeepAliveComponent",{},false,"disabled_at_start",301) -add_component(107,"BossHealthBarComponent",{gui_special_final_boss="1"},false,"disabled_at_start",302) -add_component(107,"DamageModelComponent",{hp="56.5",wait_for_kill_flag_on_death=true},true,"",303) --- Boss limbs uses the same delayed-kill contract and should also bypass demotion. -add_component(108,"BossHealthBarComponent",{},true,"",304) -add_component(108,"DamageModelComponent",{hp="20",wait_for_kill_flag_on_death=true},true,"",305) --- Natural Kolmisilma keeps stock global lifecycle. -add_component(109,"StreamingKeepAliveComponent",{},false,"disabled_at_start",308) -add_component(109,"BossHealthBarComponent",{gui_special_final_boss="1"},false,"disabled_at_start",309) -add_component(109,"DamageModelComponent",{hp="56.5",wait_for_kill_flag_on_death=true},true,"",310) -add_component(110,"StreamingKeepAliveComponent",{},true,"",306) -add_component(110,"DamageModelComponent",{hp="3",wait_for_kill_flag_on_death=false},true,"",307) - -function GameGetFrameNum() return frame end -function GlobalsGetValue(k,d) local v=globals[k]; if v==nil then return d end return v end -function GlobalsSetValue(k,v) globals[k]=tostring(v) end -function EntityGetIsAlive(e) return entities[e] and entities[e].alive or false end -function EntityHasTag(e,t) return entities[e] and entities[e].tags[t] == true or false end -function EntityAddTag(e,t) ensure_entity(e).tags[t]=true end -function EntityGetFilename(e) return entities[e] and entities[e].filename or "" end -function EntitiesGetMaxID() - local m=0 - for id in pairs(entities) do if id>m then m=id end end - return m -end -function EntityGetComponentIncludingDisabled(e,ctype) - local out={} - for _,id in ipairs((entities[e] and entities[e].components) or {}) do - local c=comps[id] - if c and c.type==ctype then out[#out+1]=id end - end - return out -end -function EntityGetFirstComponentIncludingDisabled(e,ctype) - return (EntityGetComponentIncludingDisabled(e,ctype) or {})[1] -end -function EntityGetFirstComponent(e,ctype) - for _,id in ipairs(EntityGetComponentIncludingDisabled(e,ctype) or {}) do - if comps[id] and comps[id].enabled then return id end - end - return nil -end -function ComponentGetMembers(id) - local out={} - for k,v in pairs(assert(comps[id]).members) do out[k]=v end - return out -end -function ComponentGetValue(id,name) return assert(comps[id]).members[name] end -function ComponentSetValue(id,name,value) assert(comps[id]).members[name]=tostring(value) end -function ComponentGetValue2(id,name) - local v=assert(comps[id]).members[name] - if name=="value_bool" or name=="wait_for_kill_flag_on_death" then - return v==true or v=="1" or v==1 - end - return v -end -function ComponentGetIsEnabled(id) return assert(comps[id]).enabled end -function ComponentGetTags(id) return assert(comps[id]).tags end -function ComponentAddTag(id,tag) - local c=assert(comps[id]) - if c.tags=="" then c.tags=tag else c.tags=c.tags..","..tag end -end -function EntityRemoveComponent(e,id) - local list=assert(entities[e]).components - for i,v in ipairs(list) do if v==id then table.remove(list,i); comps[id]=nil; return end end - error("component not found") -end -function EntityAddComponent2(e,ctype,_) - return add_component(e,ctype,{},true,"") -end -function EntityAddComponent(e,ctype,_) - return add_component(e,ctype,{},true,"") -end -function EntitySetComponentIsEnabled(e,id,value) - assert(comps[id],"component missing: "..tostring(id)).enabled=value==true -end - -local function native_consume_camera_bound(entity) - for _,id in ipairs(EntityGetComponentIncludingDisabled(entity,"CameraBoundComponent") or {}) do - EntityRemoveComponent(entity,id) - end -end - --- Native reconstruction entry points. -local raw_entity_load_calls=0 -function EntityLoad(file,x,y) - raw_entity_load_calls=raw_entity_load_calls+1 - assert(file=="data/entities/animals/boss_alchemist/boss_alchemist.xml") - ensure_entity(105,{boss=true,miniboss=true},file) - if not EntityGetFirstComponentIncludingDisabled(105,"BossHealthBarComponent") then - add_component(105,"BossHealthBarComponent",{},true,"") - add_component(105,"DamageModelComponent",{hp="2",wait_for_kill_flag_on_death=false},true,"") - end - return 105 -end -local raw_deserialize_calls=0 -function EwextDeserialize(_blob) - raw_deserialize_calls=raw_deserialize_calls+1 - ensure_entity(106,{boss=true},"data/entities/animals/boss_wizard/boss_wizard.xml") - if not EntityGetFirstComponentIncludingDisabled(106,"BossHealthBarComponent") then - add_component(106,"BossHealthBarComponent",{},true,"") - add_component(106,"DamageModelComponent",{hp="12",wait_for_kill_flag_on_death=false},true,"") - end - return 106 -end - -local track_creative_now=false -local original_new_calls=0 -local original_update_calls=0 -local loaded_authority_once=false -ewext={} -ewext.module_on_new_entity=function(arr,len) - original_new_calls=original_new_calls+1 - assert(len==#arr,"length changed") - assert(EntityGetFirstComponentIncludingDisabled(101,"BossHealthBarComponent")==nil,"healthbar visible during native new-entity enqueue") - assert(EntityGetFirstComponentIncludingDisabled(101,"StreamingKeepAliveComponent")==201,"keepalive component ID was destroyed") - assert(EntityGetFirstComponent(101,"StreamingKeepAliveComponent")==nil,"keepalive still enabled during native enqueue") - assert(EntityGetFirstComponentIncludingDisabled(101,"BossDragonComponent")==202,"BossDragon component ID was destroyed") - assert(EntityGetFirstComponent(101,"BossDragonComponent")==nil,"BossDragon still enabled; EW could recreate keepalive") - assert(#EntityGetComponentIncludingDisabled(101,"CameraBoundComponent")>=1,"filename spawn-info marker missing") - assert(EntityGetFirstComponentIncludingDisabled(103,"BossDragonComponent")~=nil,"existing remote DES boss was modified") - -- Creative Kolmi is deliberately hidden until native tracking, while natural - -- wait-for-kill bosses keep their stock component state. - assert(EntityGetFirstComponentIncludingDisabled(107,"BossHealthBarComponent")==nil,"creative Kolmisilma healthbar remained visible") - assert(EntityGetFirstComponentIncludingDisabled(107,"StreamingKeepAliveComponent")==301,"creative Kolmi keepalive ID was destroyed") - assert(EntityGetFirstComponent(107,"StreamingKeepAliveComponent")==nil,"creative Kolmi keepalive remained enabled") - assert(EntityGetFirstComponentIncludingDisabled(108,"BossHealthBarComponent")==304,"boss_limbs wait-kill healthbar was removed") - assert(EntityGetFirstComponentIncludingDisabled(109,"BossHealthBarComponent")==309,"natural Kolmisilma healthbar was removed") - assert(EntityGetFirstComponentIncludingDisabled(109,"StreamingKeepAliveComponent")==308,"natural Kolmisilma keepalive was removed") -end - -ewext.module_on_world_update=function() - original_update_calls=original_update_calls+1 - assert(EntityGetFirstComponentIncludingDisabled(101,"BossDragonComponent")==202,"creative BossDragon ID changed before tracking") - assert(EntityGetFirstComponent(101,"BossDragonComponent")==nil,"creative boss restored before native track_entity") - assert(#EntityGetComponentIncludingDisabled(101,"CameraBoundComponent")>=1,"creative boss did not force Filename spawn-info") - if entities[111] and not entities[111].tags.ew_des then - assert(EntityGetFirstComponentIncludingDisabled(111,"BossHealthBarComponent")==nil,"gap natural alchemist visible to native fallback tracking") - assert(#EntityGetComponentIncludingDisabled(111,"CameraBoundComponent")>=1,"gap boss did not force Filename spawn-info") - native_consume_camera_bound(111) - entities[111].tags.ew_des=true - add_component(111,"VariableStorageComponent",{name="ew_gid_lid",value_string="natural-gid",value_bool=true},true,"") - end - if not loaded_authority_once then - loaded_authority_once=true - local loaded=EntityLoad("data/entities/animals/boss_alchemist/boss_alchemist.xml",0,0) - assert(loaded==105 and EntityGetFirstComponentIncludingDisabled(105,"BossHealthBarComponent")==nil, - "EntityLoad authority reconstruction was not hidden before native track_entity") - assert(#EntityGetComponentIncludingDisabled(105,"CameraBoundComponent")>=1,"EntityLoad boss did not force Filename spawn-info") - native_consume_camera_bound(105) - entities[105].tags.ew_des=true - add_component(105,"VariableStorageComponent",{name="ew_gid_lid",value_string="authority-file-gid",value_bool=true},true,"") - - local deser=EwextDeserialize("serialized-boss") - assert(deser==106 and EntityGetFirstComponentIncludingDisabled(106,"BossHealthBarComponent")==nil, - "EwextDeserialize authority reconstruction was not hidden before native track_entity") - assert(#EntityGetComponentIncludingDisabled(106,"CameraBoundComponent")>=1,"deserialized boss did not force Filename spawn-info") - native_consume_camera_bound(106) - entities[106].tags.ew_des=true - add_component(106,"VariableStorageComponent",{name="ew_gid_lid",value_string="authority-ser-gid",value_bool=true},true,"") - end - if track_creative_now then - native_consume_camera_bound(101) - entities[101].tags.ew_des=true - add_component(101,"VariableStorageComponent",{name="ew_gid_lid",value_string="creative-gid",value_bool=true},true,"") - native_consume_camera_bound(107) - entities[107].tags.ew_des=true - add_component(107,"VariableStorageComponent",{name="ew_gid_lid",value_string="creative-kolmi-gid",value_bool=true},true,"") - end -end - -local module=assert(loadfile(root.."/files/integrations/ew/boss_global_demote.lua"))() -local ok,reason=module.install() -assert(ok and reason=="installed","install failed: "..tostring(reason)) - --- Normal top-level EW discovery. Kolmisilma and boss_limbs bypass demotion. -ewext.module_on_new_entity({101,102,103,107,108,109,110},7) -assert(original_new_calls==1,"native new entity wrapper did not delegate") -assert(module._test.pending_count()==2,"normal boss + creative Kolmi should be pending") -assert(EntityGetFirstComponentIncludingDisabled(101,"BossDragonComponent")==202,"BossDragon ID changed after enqueue") -assert(EntityGetFirstComponent(101,"BossDragonComponent")==nil,"BossDragon restored immediately after on_new_entity") -assert(EntityGetFirstComponentIncludingDisabled(102,"DamageModelComponent")~=nil,"ordinary entity mutated") -assert(EntityGetFirstComponentIncludingDisabled(103,"BossHealthBarComponent")~=nil,"remote boss mutated") -assert(EntityGetFirstComponentIncludingDisabled(107,"BossHealthBarComponent")==nil,"creative Kolmi restored before native tracking") -assert(EntityGetFirstComponentIncludingDisabled(108,"BossHealthBarComponent")==304,"boss_limbs mutated outside native call") -assert(EntityGetFirstComponentIncludingDisabled(109,"BossHealthBarComponent")==309,"natural Kolmi mutated outside native call") -assert(EntityGetFirstComponentIncludingDisabled(110,"StreamingKeepAliveComponent")==306 and ComponentGetIsEnabled(306)==true,"ordinary keepalive-only mob was demoted") -assert(tonumber(globals["mcm21_boss_wait_kill_bypass_v1"] or "0")>=2,"natural wait-for-kill bosses were not bypassed") -assert(globals["mcm27_creative_kolmi_non_global_v1"]=="1","creative Kolmi was not opted into non-global classification") - --- Natural Alchemist appears after Lua discovery but before native update. -ensure_entity(104,{boss=true,miniboss=true},"data/entities/animals/boss_alchemist/boss_alchemist.xml") -add_component(104,"BossHealthBarComponent",{},true,"",209) -add_component(104,"DamageModelComponent",{hp="40",wait_for_kill_flag_on_death=false},true,"",210) -assert(module._test.last_seen_entity_id()==110,"Lua discovery high-water mark incorrect") --- Use a newer id because 104 is below current high-water in this deterministic mock. -entities[111]=entities[104]; entities[104]=nil -for _,cid in ipairs(entities[111].components) do comps[cid].entity=111 end - -ewext.module_on_world_update() -assert(original_update_calls==1,"native update did not run") -assert(module._test.pending_count()==2,"budget-delayed normal boss + creative Kolmi should remain pending") -assert(EntityGetFirstComponentIncludingDisabled(111,"BossHealthBarComponent")~=nil,"gap natural boss components not restored after native GID") -assert(EntityGetFirstComponentIncludingDisabled(105,"BossHealthBarComponent")~=nil,"EntityLoad authority boss not restored") -assert(EntityGetFirstComponentIncludingDisabled(106,"BossHealthBarComponent")~=nil,"deserialize authority boss not restored") -assert(globals["mcm21_boss_global_gap_hidden_v1"]=="1","one-frame native gap was not caught") -assert(globals["mcm21_boss_global_load_hidden_v1"]=="1","EntityLoad authority path was not caught") -assert(globals["mcm21_boss_global_deserialize_hidden_v1"]=="1","deserialize authority path was not caught") -assert(raw_entity_load_calls==1 and raw_deserialize_calls==1,"wrapped native spawn functions did not delegate exactly once") - -frame=101 -track_creative_now=true -ewext.module_on_world_update() -assert(original_update_calls==2,"second native update did not run") -assert(module._test.pending_count()==0,"tracked creative boss was not restored") -assert(EntityGetFirstComponentIncludingDisabled(101,"BossDragonComponent")==202,"BossDragon original component ID was not preserved") -assert(EntityGetFirstComponentIncludingDisabled(101,"StreamingKeepAliveComponent")==201,"KeepAlive original component ID was not preserved") -assert(ComponentGetIsEnabled(202)==true and ComponentGetIsEnabled(201)==true,"preserved boss components were not re-enabled") -local health=assert(EntityGetFirstComponentIncludingDisabled(101,"BossHealthBarComponent"),"healthbar was not recreated") -assert(health~=203,"test expected healthbar recreation only") -assert(ComponentGetValue(health,"gui")=="0" and ComponentGetValue(health,"in_world")=="1", - "restoration changed authored worm-style in-world bar into a GUI bar") -assert(ComponentGetValue(health,"gui_max_distance_visible")=="600","healthbar range changed") -assert(ComponentGetValue(202,"speed")=="8","BossDragon authored speed changed") -assert(ComponentGetValue(202,"part_distance")=="56","BossDragon authored part_distance changed") -assert(EntityGetFirstComponentIncludingDisabled(107,"StreamingKeepAliveComponent")==301,"creative Kolmi KeepAlive component ID changed") -assert(ComponentGetIsEnabled(301)==false,"creative Kolmi pre-fight KeepAlive state was not restored") -local kolmi_health=assert(EntityGetFirstComponentIncludingDisabled(107,"BossHealthBarComponent"),"creative Kolmi healthbar was not recreated") -assert(kolmi_health~=302,"creative Kolmi test expected healthbar recreation") -assert(ComponentGetIsEnabled(kolmi_health)==false,"creative Kolmi healthbar pre-fight enabled state changed") -assert(EntityGetFirstComponentIncludingDisabled(109,"BossHealthBarComponent")==309,"natural Kolmi component changed") -assert(ComponentGetTags(202):find("dragon_tag",1,true),"BossDragon component tags changed") -assert(tonumber(globals["mcm21_boss_global_hidden_v1"] or "0")==5,"hide counter mismatch") -assert(tonumber(globals["mcm21_boss_global_restored_v1"] or "0")==5,"restore counter mismatch") -assert(tonumber(globals["mcm21_boss_filename_spawninfo_v1"] or "0")==5,"Filename spawn-info was not forced for all demoted bosses") - -io.write("ew_boss_global_demote=PASS keepalive_only_mob_untouched=true wait_kill_bypass=true creative_kolmi_non_global=true natural_kolmi_preserved=true boss_limbs_preserved=true dragon_id_preserved=true keepalive_id_preserved=true healthbar_only_recreated=true filename_spawninfo=true native_gap=true authority_paths=true remote_untouched=true ordinary_untouched=true\n") diff --git a/metamorph_creative_menu/tests/ew_boss_lifecycle_mock.lua b/metamorph_creative_menu/tests/ew_boss_lifecycle_mock.lua deleted file mode 100644 index dac47c0..0000000 --- a/metamorph_creative_menu/tests/ew_boss_lifecycle_mock.lua +++ /dev/null @@ -1,402 +0,0 @@ -local root=assert(arg[1],"root required") -local test_root=(arg[0] or ""):match("^(.*)/tests/[^/]+$") or root -local native_dofile=dofile -local globals, entities, comps = {}, {}, {} -local tracked, storage, queue, deleted, released, packets = {}, {}, {}, {}, {}, {} -local next_comp, max_id, frame = 0, 0, 100 -local inside_native, reject_notify, hold_update, fail_resolve = false,false,false,false -local during_native -local tag_queries=0 -local stock_queue={} -local function component(e,t,v) - next_comp=next_comp+1 - comps[next_comp]={entity=e,type=t,v=v or {},enabled=true} - entities[e].components[#entities[e].components+1]=next_comp - return next_comp -end -local function spawn(e,gid,opts) - opts=opts or {} - max_id=math.max(max_id,e) - entities[e]={alive=true,tags=opts.tags or {ew_des=true},components={},children={}, - file=opts.file or "mods/example/entities/ancient_guardian.xml",x=100,y=200} - component(e,"VariableStorageComponent",{name="ew_gid_lid",value_string=gid,value_bool=opts.owner~=false}) - local damage=component(e,"DamageModelComponent",{hp=opts.hp or 46, - wait_for_kill_flag_on_death=opts.wait==true,kill_now=false}) - if opts.trait~=false then component(e,opts.trait or "BossHealthBarComponent",{}) end - component(e,"LuaComponent",{script_death="custom_loot.lua",script_source_file="custom_phases.lua"}) - component(e,"ItemChestComponent",{}) - tracked[gid]=e; storage[gid]={entity=e,file=entities[e].file,owner=opts.owner~=false} - return damage -end -function EntityGetIsAlive(e) return entities[e] and entities[e].alive or false end -function EntityHasTag(e,t) return entities[e] and entities[e].tags[t] or false end -function EntityGetRootEntity(e) return entities[e] and (entities[e].parent or e) or 0 end -function EntityGetAllChildren(e) return entities[e].children end -function EntityGetWithTag(t) - tag_queries=tag_queries+1 - local out={}; for e in pairs(entities) do if EntityGetIsAlive(e) and EntityHasTag(e,t) then out[#out+1]=e end end - return out -end -function EntityGetTransform(e) return entities[e].x,entities[e].y end -function EntityGetFilename(e) return entities[e] and entities[e].file or "" end -function EntityGetComponentIncludingDisabled(e,t) - local out={} - for _,id in ipairs((entities[e] or {}).components or {}) do - if comps[id] and comps[id].type==t then out[#out+1]=id end - end - return out -end -function EntityGetFirstComponentIncludingDisabled(e,t) return EntityGetComponentIncludingDisabled(e,t)[1] end -function ComponentGetValue2(c,k) return assert(comps[c]).v[k] end -function ComponentSetValue2(c,k,v) assert(comps[c]).v[k]=v end -function EntitySetComponentIsEnabled(e,c,v) comps[c].enabled=v end -function EntityRemoveComponent(e,c) comps[c]=nil end -function EntityKill(e) - entities[e].alive=false - for _,child in ipairs(entities[e].children) do EntityKill(child) end -end -function GlobalsGetValue(k,d) return globals[k] or d end -function GlobalsSetValue(k,v) globals[k]=v end -function EntitiesGetMaxID() return max_id end -function GameGetFrameNum() return frame end -dofile=function(p) - local rel=p:match("^mods/metamorph_creative_menu/(.*)$") - return native_dofile(rel and root.."/"..rel or p) -end -dofile_once=dofile -ewext={} -function ewext.find_by_gid(gid) - assert(not inside_native,"reentrant DES lookup") - assert(type(gid)=="string","GID lost u64 precision") - if fail_resolve then error("lookup failed") end - return tracked[gid] -end -function ewext.des_death_notify(e,w,x,y,file,responsible) - assert(not inside_native,"reentrant DES death notification") - if reject_notify then error("notification failed") end - queue[#queue+1]={e=e,wait=w,file=file,responsible=responsible} -end -function ewext.module_on_new_entity(arr,len) assert(len==#arr) end --- Relevant actual EW order: pending_death_notify -> tracked !alive handling. --- A missed callback leaves FullEntityData retained after global ReleaseAuthority. -function ewext.module_on_world_update() - frame=frame+1 - if hold_update then return end - inside_native=true - for _,n in ipairs(queue) do - for gid,e in pairs(tracked) do - if e==n.e and storage[gid].owner then - tracked[gid]=nil; storage[gid]=nil - deleted[gid]=(deleted[gid] or 0)+1 - packets[#packets+1]={gid=gid,file=n.file,wait=n.wait,responsible=n.responsible,kind="KillEntity"} - end - end - end - queue={} - for gid,e in pairs(tracked) do - if storage[gid].owner and not EntityGetIsAlive(e) then tracked[gid]=nil; released[gid]=true end - end - if during_native then local f=during_native; during_native=nil; f() end - inside_native=false -end -local function flush_stock() - local q=stock_queue; stock_queue={} - for _,n in ipairs(q) do ewext.des_death_notify(table.unpack(n)) end -end -local function stock_death(e,responsible) - -- Independent entity VM; execute the supplied stock callback plus the real append. - local env=setmetatable({}, {__index=_G}) - env.GetUpdatedEntityID=function() return e end - env.CrossCall=function(name,...) assert(name=="ew_death_notify"); stock_queue[#stock_queue+1]={...} end - assert(loadfile(test_root.."/tests/fixtures/ew163_death_notify.lua","t",env))() - local append=loadfile(root.."/files/integrations/ew/boss_death_notify.lua","t",env) - if append then append() end - env.death(1,"lethal hit",responsible or 0,true) -end - --- Install the confirmed Kolmi guard together with the new guard, in production order. -assert(native_dofile(root.."/files/integrations/ew/kolmi_lifecycle.lua").install()) -local general=loadfile(root.."/files/integrations/ew/boss_lifecycle.lua") -if general then local m=general(); assert(m.install()); assert(m.install()) end - --- Regression: a generic mod boss with only a standard healthbar can die between --- frames; the retired GID must not retain FullEntityData after native release. -spawn(100,"9007199254740992") -ewext.module_on_world_update() -EntityKill(100) -ewext.module_on_world_update() -assert(deleted["9007199254740992"]==1 and not released["9007199254740992"], - "non-Kolmi boss was released and retained instead of permanently deleted") -assert(packets[#packets].file=="" and packets[#packets].kind=="KillEntity", "boss XML scheduled for death replay") - --- Standard callback + real entity-VM append preserves killer and handles a boss that --- dies before observation. The stock callback still runs; it is not swallowed in Lua. -spawn(110,"110",{file="mods/unlisted_mod/monarch.xml"}) -stock_death(110,77) -assert(#stock_queue==1,"append replaced rather than delegated the stock callback") -EntityKill(110) -flush_stock() -ewext.module_on_world_update() -assert(deleted["110"]==1 and packets[#packets].responsible==77,"early death mailbox lost identity/killer") -assert(packets[#packets].file=="","early callback retained a boss SpawnOnce filename") - --- The actual Koipi-style 40 x wait(3) delayed death must retain the original body, --- scripts and loot until kill_now. Zero HP itself is not final, including custom mods. -local d=spawn(120,"120",{file="data/entities/animals/boss_limbs/boss_limbs.xml",wait=true,trait="LimbBossComponent"}) -ewext.module_on_world_update() -ComponentSetValue2(d,"hp",0) -for i=1,120 do ewext.module_on_world_update() end -assert(tracked["120"]==120 and not deleted["120"],"delayed death animation was cut short") -ComponentSetValue2(d,"kill_now",true) -ewext.module_on_world_update() -assert(deleted["120"]==1 and EntityGetIsAlive(120),"final flag did not retire only the network record") -assert(EntityGetFirstComponentIncludingDisabled(120,"ItemChestComponent"),"original loot was stripped") -EntityKill(120) - --- Unknown mod: HP=0 and a death-like callback can be a phase transition. Wait-for-kill --- is explicit; initialized=false has no Kolmi-specific meaning for this custom boss. -d=spawn(130,"130",{wait=true,trait=false,file="mods/multi_phase/a.xml"}) -component(130,"VariableStorageComponent",{name="initialized",value_bool=false}) -ewext.module_on_world_update() -ComponentSetValue2(d,"hp",0) -stock_death(130,88); flush_stock() -ewext.module_on_world_update() -assert(not deleted["130"] and tracked["130"],"phase transition treated as final death") -ComponentSetValue2(d,"hp",100) -ewext.module_on_world_update() -assert(not deleted["130"],"regenerated phase was killed") -tracked["130"]=nil; EntityKill(130) -- healthy transfer to a different owner -ewext.module_on_world_update() -assert(not deleted["130"],"healthy transfer was tombstoned by an earlier phase event") -spawn(131,"130",{wait=true,trait=false}) -- returned alive, same GID -ewext.module_on_world_update() -assert(tracked["130"]==131,"healthy authority reconstruction was blocked") - --- New boss and next phase at the same location and same filename, with adjacent u64 --- GID, remain independent. Only a confirmed dead exact GID is a stale reconstruction. -spawn(140,"9007199254740993") -ewext.module_on_world_update() -assert(tracked["9007199254740993"]==140,"fresh summon/phase confused with dead GID") -spawn(150,"9007199254740992") -entities[151]={alive=true,tags={},components={},children={},parent=150,x=100,y=200} -max_id=151 -entities[150].children={151} -local child_death=component(151,"LuaComponent",{script_death="duplicate_reward.lua"}) -component(151,"ItemChestComponent",{}) -hold_update=true -ewext.module_on_world_update() -assert(EntityGetIsAlive(150) and storage["9007199254740992"],"copy killed before DES acknowledgement") -hold_update=false -ewext.module_on_world_update() -assert(not EntityGetIsAlive(150) and not storage["9007199254740992"],"same-GID copy survived") -assert(ComponentGetValue2(child_death,"script_death")=="" and not EntityGetFirstComponentIncludingDisabled(151,"ItemChestComponent"), - "copy's child can duplicate death loot") - --- Boss with a custom HUD and only keepalive+damage; no vanilla name, tag or healthbar. -d=spawn(160,"160",{trait="StreamingKeepAliveComponent",file="mods/custom_hud/creature.xml"}) -ewext.module_on_world_update() -ComponentSetValue2(d,"kill_now",true) -ewext.module_on_world_update() -assert(deleted["160"]==1,"custom HUD/global boss was missed") -EntityKill(160) - --- Discovery inside native update, including a boss whose healthbar was temporarily --- hidden by the preexisting demotion wrapper at the initial discovery event. -d=spawn(170,"170") -ewext.module_on_new_entity({170},1) -local bar=EntityGetFirstComponentIncludingDisabled(170,"BossHealthBarComponent") -EntityRemoveComponent(170,bar) -ewext.module_on_world_update() -EntityKill(170) -ewext.module_on_world_update() -assert(deleted["170"]==1,"first-track demotion hid a known boss from lifecycle") -during_native=function() spawn(180,"180",{trait="BossDragonComponent"}) end -ewext.module_on_world_update() -EntityKill(180) -ewext.module_on_world_update() -assert(deleted["180"]==1,"native-created boss not observed after native update") - --- Callback while native DES is on the stack only writes a mailbox; it must not call --- find_by_gid/des_death_notify reentrantly. Main-VM flush follows the real EW order. -during_native=function() - spawn(190,"190") - stock_death(190,91) - EntityKill(190) -end -ewext.module_on_world_update() -flush_stock() -ewext.module_on_world_update() -assert(deleted["190"]==1 and packets[#packets].responsible==91,"native-stack callback was lost") - --- Native had already released before a late event could be read. Preserve the proven --- dead GID, then reject its later authority replay; do not mistake release for commit. -spawn(200,"200") -stock_death(200,92) -EntityKill(200); tracked["200"]=nil; released["200"]=true -ewext.module_on_world_update() -spawn(201,"200") -ewext.module_on_world_update() -assert(not EntityGetIsAlive(201) and not storage["200"],"late event did not reject retained authority") -flush_stock(); ewext.module_on_world_update() - --- Foreign authority, polymorph/player avatars, EW death-replay corpses, ordinary --- mobs and children keep their stock protocol. Kolmi still uses the unchanged guard. -spawn(210,"210",{owner=false}) -spawn(211,"211",{tags={boss=true,player_unit=true}}) -spawn(212,"212",{tags={boss=true,polymorphed_player=true}}) -spawn(213,"213",{tags={boss=true,ew_peer=true}}) -spawn(214,"214",{tags={boss=true,ew_no_enemy_sync=true}}) -spawn(215,"215",{trait=false,tags={ew_des=true,enemy=true}}) -for i=210,215 do - ewext.module_on_world_update() - local damage=EntityGetFirstComponentIncludingDisabled(i,"DamageModelComponent") - ComponentSetValue2(damage,"kill_now",true) -end -ewext.module_on_world_update() -for i=210,215 do assert(not deleted[tostring(i)],"excluded entity modified: "..i) end -local prior=#queue -ewext.des_death_notify(215,false,100,200,"data/entities/animals/sheep.xml",33) -assert(#queue==prior+1 and queue[#queue].file=="data/entities/animals/sheep.xml", "ordinary death replay was changed") -queue={} -spawn(216,"216",{trait="LimbBossComponent",tags={ew_des=true,enemy=true}}) -ewext.module_on_world_update() -prior=#queue -ewext.des_death_notify(216,false,100,200,"data/entities/animals/lukki/lukki.xml",33) -assert(#queue==prior+1 and queue[#queue].file=="data/entities/animals/lukki/lukki.xml", - "ordinary lukki treated as a boss merely because of LimbBossComponent") -queue={} --- Becoming a player after discovery must also release ownership of the callback. -spawn(220,"220") -ewext.module_on_world_update() -entities[220].tags.player_unit=true -prior=#queue -ewext.des_death_notify(220,false,100,200,"form.xml",33) -assert(#queue==prior+1,"cached boss record swallowed a player-form callback") -queue={} - -d=spawn(230,"230",{tags={boss_centipede=true,boss=true},wait=true, - file="data/entities/animals/boss_centipede/boss_centipede.xml"}) -ewext.module_on_world_update() -ComponentSetValue2(d,"hp",0) -ewext.module_on_world_update() -assert(deleted["230"]==1 and EntityGetIsAlive(230),"confirmed Kolmi lethal-state fix regressed") -EntityKill(230) - --- Inherited vanilla tags do not make an unknown multi-phase mod boss vanilla Kolmi. -d=spawn(231,"231",{tags={boss_centipede=true,boss=true},wait=true}) -ewext.module_on_world_update() -ComponentSetValue2(d,"hp",0) -ewext.module_on_world_update() -assert(not deleted["231"] and tracked["231"],"modded centipede phase used vanilla Kolmi's early-death rule") -ComponentSetValue2(d,"kill_now",true) -ewext.module_on_world_update() -assert(deleted["231"]==1,"modded centipede did not use generic final-death handling") -EntityKill(231) - --- Notification failure and failed acknowledgement never authorize a quiet kill. -d=spawn(240,"240") -ewext.module_on_world_update() -ComponentSetValue2(d,"kill_now",true) -reject_notify=true -ewext.module_on_world_update() -assert(EntityGetIsAlive(240) and tracked["240"],"failed notification discarded a boss") -reject_notify=false -ewext.module_on_world_update() -assert(deleted["240"]==1,"notification failure was not retried") -EntityKill(240) -spawn(241,"240") -hold_update=true -ewext.module_on_world_update() -hold_update=false; fail_resolve=true -ewext.module_on_world_update() -assert(EntityGetIsAlive(241),"lookup error was treated as an acknowledgement") -fail_resolve=false -ewext.module_on_world_update() -assert(not EntityGetIsAlive(241),"acknowledgement failure was not retried") - --- Custom boss HUD lives on a child; the parent is the actual EW synchronization root. -d=spawn(250,"250",{trait=false}) -entities[251]={alive=true,tags={},components={},children={},parent=250,x=100,y=200} -max_id=251; entities[250].children={251} -component(251,"BossHealthBarComponent",{}) -ewext.module_on_world_update() -EntityKill(250) -ewext.module_on_world_update() -assert(deleted["250"]==1,"boss HUD on a child did not protect the actual DES root") - --- An acknowledged old boss ID does not authorize deleting a player who took over --- the local entity while the request was pending. -spawn(260,"250") -hold_update=true -ewext.module_on_world_update() -entities[260].tags.player_unit=true -hold_update=false -ewext.module_on_world_update() -assert(EntityGetIsAlive(260),"acknowledged reconstruction cleanup deleted a newly possessed player") - --- Inventory/living-entity population is not scanned each frame by the generic guard. --- The only per-frame tag query here is from the unchanged Kolmi guard (twice/frame). -local queries=tag_queries -for i=1,20 do ewext.module_on_world_update() end -assert(tag_queries-queries<=44,"general guard performs a full tag sweep every frame") - --- Optional integration with the user's actual Noita data directory. The whole --- authored Koipi script is loaded unchanged; its coroutine drives the final flag. -if arg[2] then - local data_root=arg[2] - d=spawn(300,"300",{wait=true,trait="BossHealthBarComponent", - file="data/entities/animals/boss_limbs/boss_limbs.xml"}) - component(300,"LimbBossComponent",{}) - ewext.module_on_world_update() - local flags, loot, actions, stats={},{},0,0 - local env=setmetatable({}, {__index=_G}) - env.dofile=function() end - env.async_loop=function() end - env.get_herd_id=function() return 1 end - env.component_get_value_float=function() return 10 end - env.GetUpdatedEntityID=function() return 300 end - env.EntityGetFirstComponent=EntityGetFirstComponentIncludingDisabled - env.EntitySetComponentsWithTagEnabled=function() end - env.for_comps=function() end - env.ComponentGetValueFloat=ComponentGetValue2 - env.ComponentSetValue=function(c,k,v) - if k=="kill_now" then v=v=="1" else v=tonumber(v) or v end - ComponentSetValue2(c,k,v) - end - env.SetRandomSeed=function() end - env.Random=function(a,b) return a end - env.GameScreenshake=function() end - env.GameCreateParticle=function() end - env.StatsLogPlayerKill=function(e) assert(e==300); stats=stats+1 end - env.AddFlagPersistent=function(f) flags[f]=true end - env.wait=function(n) coroutine.yield(n) end - assert(loadfile(data_root.."/entities/animals/boss_limbs/boss_limbs_update.lua","t",env))() - ComponentSetValue2(d,"hp",0) - local co=coroutine.create(env.check_death) - local waited=0 - while coroutine.status(co)~="dead" do - local ok,n=coroutine.resume(co); assert(ok,n) - if n then - assert(n==3,"authored death changed its wait interval") - for i=1,n do - ewext.module_on_world_update(); waited=waited+1 - assert(tracked["300"]==300 and not deleted["300"],"actual Koipi coroutine lost its DES record mid-animation") - end - end - end - assert(waited==120 and stats==1 and flags.miniboss_limbs,"authored death sequence did not finish normally") - assert(ComponentGetValue2(d,"kill_now"),"actual coroutine did not set kill_now") - -- Its real death callback must still produce the original drops/unlock once. - env.HasFlagPersistent=function(f) return flags[f] or false end - env.check_parallel_pos=function() return 0 end - env.EntityLoad=function(file) loot[#loot+1]=file; return 999 end - env.CreateItemActionEntity=function() actions=actions+1 end - assert(loadfile(data_root.."/entities/animals/boss_limbs/boss_limbs_death.lua","t",env))() - env.death(1,"lethal hit",77,true) - stock_death(300,77); EntityKill(300); flush_stock() - ewext.module_on_world_update() - assert(deleted["300"]==1 and #loot==3 and actions==4 and flags.card_unlocked_pyramid, - "actual Koipi loot/unlock or final DES retirement changed") - print("ew_boss_lifecycle_vanilla_koipi=PASS authored_wait_frames=120 authored_loot_entities=3 authored_actions=4 unlock=true") -end -print("ew_boss_lifecycle=PASS generic_traits=true phase_safe=true delayed_death=true native_callbacks=true u64=true ack=true mod_bosses=true kolmi_preserved=true") diff --git a/metamorph_creative_menu/tests/ew_boss_network_pressure_mock.lua b/metamorph_creative_menu/tests/ew_boss_network_pressure_mock.lua deleted file mode 100644 index 0082927..0000000 --- a/metamorph_creative_menu/tests/ew_boss_network_pressure_mock.lua +++ /dev/null @@ -1,25 +0,0 @@ -local root=assert(arg[1],'root required') -local patches=assert(loadfile(root..'/files/integrations/ew/resilience_patches.lua'))() -local fixture=[[function death(damage_type_bit_field, damage_message, entity_thats_responsible, drop_items) - local ent = GetUpdatedEntityID() - local x, y = EntityGetTransform(ent) - local wait_on_kill = false - local damage = EntityGetFirstComponentIncludingDisabled(ent, "DamageModelComponent") - if damage ~= nil then - wait_on_kill = ComponentGetValue2(damage, "wait_for_kill_flag_on_death") - end - CrossCall("ew_death_notify", ent, wait_on_kill, x, y, EntityGetFilename(ent), entity_thats_responsible) -end -]] -local patched,count=patches.patch_boss_death_pressure_source(fixture) -assert(count==1,'boss death pressure patch did not apply') -assert(string.find(patched,'mcm_boss_death_pressure_v1',1,true),'marker missing') -assert(string.find(patched,'BossHealthBarComponent',1,true),'boss healthbar detection missing') -assert(string.find(patched,'StreamingKeepAliveComponent',1,true),'global keepalive detection missing') -assert(string.find(patched,'mcm_ew_boss_death_pressure_until_v1',1,true),'pressure global missing') -assert(string.find(patched,'CrossCall("ew_death_notify"',1,true),'stock EW death notify was replaced') -local compiled,err=load(patched,'@patched_death_notify.lua') -assert(compiled~=nil,'patched death notifier invalid: '..tostring(err)) -local again,n2=patches.patch_boss_death_pressure_source(patched) -assert(n2==0 and again==patched,'patch is not idempotent') -print('ew_boss_network_pressure=PASS stock_death_semantics=true pressure_only=true') diff --git a/metamorph_creative_menu/tests/ew_creature_track_bridge_mock.lua b/metamorph_creative_menu/tests/ew_creature_track_bridge_mock.lua deleted file mode 100644 index 50046b6..0000000 --- a/metamorph_creative_menu/tests/ew_creature_track_bridge_mock.lua +++ /dev/null @@ -1,39 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile -local runtime={enabled=function() return true end} -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/integrations/ew/runtime.lua' then return runtime end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -local alive={[77]=true,[78]=true,[79]=true} -local tags={ - [77]={enemy=true}, - [78]={helpless_animal=true}, - [79]={}, -} -local added={} -EntityGetIsAlive=function(e) return alive[e]==true end -EntityGetRootEntity=function(e) return e end -EntityHasTag=function(e,t) return tags[e] and tags[e][t]==true or false end -EntityAddTag=function(e,t) tags[e]=tags[e] or {}; tags[e][t]=true; added[#added+1]={e,t} end -EntityGetComponentIncludingDisabled=function() return {} end - --- ewext must never be consulted: native DES discovers enemy/helpless_animal itself. -ewext=setmetatable({}, {__index=function(_,key) error('manual ewext access: '..tostring(key)) end}) -GlobalsGetValue=function() error('tracking mailbox must not be used') end -GlobalsSetValue=function() error('tracking mailbox must not be used') end - -local helper=assert(native_dofile(root..'/files/integrations/ew/world_entities.lua')) -local ok,reason=helper.track(77) -assert(ok==true and reason=='native_auto','ordinary enemy did not stay on native DES discovery path') -ok,reason=helper.track(78) -assert(ok==true and reason=='native_auto','native DES helpless-animal discovery path changed') -ok,reason=helper.track(79) -assert(ok==true and reason=='native_forced','nonstandard entity did not receive documented ew_synced opt-in') -assert(tags[79].ew_synced==true and #added==1,'unexpected tags were mutated by native DES adapter') -local seq,ack,status=helper.outbox_state() -assert(seq==0 and ack==0 and status=='native_des_stock_only_v1','ordinary tracking unexpectedly entered creative registry') -print('ew_creature_track_bridge=PASS native_enemy_discovery=true manual_gid=false manual_death_hook=false ew_synced_fallback=true') diff --git a/metamorph_creative_menu/tests/ew_dev_mode_bridge_gate_mock.lua b/metamorph_creative_menu/tests/ew_dev_mode_bridge_gate_mock.lua index 49a54e7..fbac395 100644 --- a/metamorph_creative_menu/tests/ew_dev_mode_bridge_gate_mock.lua +++ b/metamorph_creative_menu/tests/ew_dev_mode_bridge_gate_mock.lua @@ -1,91 +1,7 @@ local root=assert(arg[1],"root required") -local native_dofile=dofile - -local function run(mode) - local loaded={} - local registrations={} - local updates={} - local reported={} - local rpc={opts_reliable=function() end,opts_everywhere=function() end} - local modules={} - local function bridge(name) - return {register=function() registrations[#registrations+1]=name end,update=function() - updates[name]=(updates[name] or 0)+1 - if name=="world_rules" then error("isolated update failure") end - end} - end - modules["mods/metamorph_creative_menu/files/integrations/ew/bridge/protocol.lua"]={NAMESPACE="test"} - modules["mods/metamorph_creative_menu/files/integrations/ew/bridge/common.lua"]={clean=tostring,report_error=function(scope) reported[#reported+1]=scope end} - modules["mods/metamorph_creative_menu/files/integrations/ew/form_death_intercept.lua"]={install=function() - updates.death_intercept=(updates.death_intercept or 0)+1 - return true,"installed" - end} - modules["mods/metamorph_creative_menu/files/integrations/ew/boss_global_demote.lua"]={install=function() - updates.boss_global_demote=(updates.boss_global_demote or 0)+1 - return true,"installed" - end} - modules["mods/metamorph_creative_menu/files/integrations/ew/kolmi_lifecycle.lua"]={install=function() - updates.kolmi_lifecycle=(updates.kolmi_lifecycle or 0)+1 - return true - end} - modules["mods/metamorph_creative_menu/files/integrations/ew/boss_lifecycle.lua"]={install=function() - updates.boss_lifecycle=(updates.boss_lifecycle or 0)+1 - return true - end} - modules["mods/metamorph_creative_menu/files/integrations/ew/perk_runtime_guard.lua"]={install=function() return true,"ok" end} - modules["mods/metamorph_creative_menu/files/integrations/ew/bridge/world_rules.lua"]=bridge("world_rules") - modules["mods/metamorph_creative_menu/files/integrations/ew/bridge/qa.lua"]=bridge("qa_full") - modules["mods/metamorph_creative_menu/files/integrations/ew/bridge/qa_reserved.lua"]=bridge("qa_reserved") - modules["mods/metamorph_creative_menu/files/integrations/ew/bridge/companion.lua"]=bridge("companion") - modules["mods/metamorph_creative_menu/files/integrations/ew/bridge/perks.lua"]=bridge("perks") - modules["mods/metamorph_creative_menu/files/integrations/ew/bridge/weather.lua"]=bridge("weather") - modules["mods/metamorph_creative_menu/files/integrations/ew/bridge/possession.lua"]=bridge("possession") - local material_bridge=bridge("materials") - material_bridge.set_metrics_enabled=function(value) updates.material_metrics_enabled=value end - modules["mods/metamorph_creative_menu/files/integrations/ew/bridge/materials.lua"]=material_bridge - modules["mods/metamorph_creative_menu/files/integrations/ew/bridge/teleport.lua"]=bridge("teleport") - modules["mods/metamorph_creative_menu/files/integrations/ew/bridge/items.lua"]={init=function() end,update=function() updates.items=(updates.items or 0)+1 end} - modules["mods/metamorph_creative_menu/files/integrations/ew/bridge/creative_perks.lua"]={init=function() end,update=function() updates.creative_perks=(updates.creative_perks or 0)+1 end} - modules["mods/metamorph_creative_menu/files/integrations/ew/bridge/forms.lua"]={register_pose=function() registrations[#registrations+1]="forms_pose" end,register_reserved=function() registrations[#registrations+1]="forms_reserved" end,update=function() updates.forms=(updates.forms or 0)+1 end,metrics=function() return 0,0 end,set_profiling_enabled=function(value) updates.form_profiling_enabled=value end} - local old_dofile,old_once=dofile,dofile_once - dofile_once=function(path) - if path=="mods/quant.ew/files/api/ew_api.lua" then return {new_rpc_namespace=function() return rpc end} end - error("unexpected dofile_once "..tostring(path)) - end - dofile=function(path) - loaded[path]=(loaded[path] or 0)+1 - if path=="mods/metamorph_creative_menu/dev_mode.lua" then return mode end - if modules[path] then return modules[path] end - error("unexpected EW bootstrap dependency: "..tostring(path)) - end - GlobalsSetValue=function() end; GameGetFrameNum=function() return 10 end - ctx={my_id="me",host_id="host"} - local module=assert(loadfile(root.."/files/integrations/ew/bootstrap.lua"))() - dofile,dofile_once=old_dofile,old_once - return loaded,registrations,module,updates,reported -end - -local release_loaded,release_reg,release_module,release_updates,release_reported=run(0) -assert(release_loaded["mods/metamorph_creative_menu/files/integrations/ew/bridge/qa.lua"]==nil,"release EW bootstrap loaded full QA telemetry bridge") -assert(release_loaded["mods/metamorph_creative_menu/files/integrations/ew/bridge/qa_reserved.lua"]==1,"release EW bootstrap did not preserve reserved QA slot") -assert(release_reg[2]=="qa_reserved","release QA slot moved from RPC position 3 registration group") -release_module.on_world_update() -assert(release_updates.perks == nil, "reserved perk RPC mailbox is still polled every EW frame") -assert(release_updates.qa_reserved == nil, "release placeholder QA bridge was still polled every frame") -assert(release_updates.material_metrics_enabled==false and release_updates.form_profiling_enabled==false, - "release EW telemetry was not disabled") -assert(release_updates.materials == 1, "material bridge was not updated in release mode") -assert(release_updates.teleport == 1, "teleport bridge was not updated in release mode") -assert(release_updates.death_intercept == 1, "runtime form-death intercept was not installed by EW bootstrap") -assert(release_updates.boss_global_demote == 1, "boss global demotion wrapper was not installed by EW bootstrap") -assert(release_updates.kolmi_lifecycle == 1, "Kolmi lifecycle guard was not installed in release mode") -assert(release_updates.boss_lifecycle == 1, "generic boss lifecycle guard was not installed in release mode") -assert(release_reported[1]=="world_rules.update" and release_updates.items==1 and release_updates.creative_perks==1 and release_updates.materials==1 and release_updates.teleport==1 and release_updates.forms==1, - "one failing EW bridge stopped later network updates") -local dev_loaded,dev_reg,_,dev_updates=run(1) -assert(dev_loaded["mods/metamorph_creative_menu/files/integrations/ew/bridge/qa.lua"]==1,"developer EW bootstrap did not load full QA bridge") -assert(dev_loaded["mods/metamorph_creative_menu/files/integrations/ew/bridge/qa_reserved.lua"]==nil,"developer EW bootstrap loaded release placeholder") -assert(dev_reg[2]=="qa_full","developer QA registration order changed") -assert(dev_updates.material_metrics_enabled==true and dev_updates.form_profiling_enabled==true, - "developer EW telemetry was not enabled") -io.write("ew_dev_mode_bridge_gate=PASS release_qa_unloaded=true reserved_slot=true release_telemetry=false developer_qa=true developer_telemetry=true\n") +local text=assert(io.open(root.."/files/integrations/ew/bootstrap.lua","rb")):read("*a") +assert(not string.find(text,"dev_mode.lua",1,true),"EW bootstrap still loads dev_mode.lua") +assert(not string.find(text,"bridge/qa.lua",1,true),"EW bootstrap still loads full QA bridge") +assert(not string.find(text,"bridge/qa_reserved.lua",1,true),"EW bootstrap still loads QA placeholder module") +assert(string.find(text,"function rpc.reserved_protocol_slot_3",1,true),"EW bootstrap no longer preserves reserved RPC slot 3") +io.write("workshop_ew_composition=PASS qa_unloaded=true reserved_slot_3=true\n") diff --git a/metamorph_creative_menu/tests/ew_form_death_intercept_patch_mock.lua b/metamorph_creative_menu/tests/ew_form_death_intercept_patch_mock.lua deleted file mode 100644 index 65ab128..0000000 --- a/metamorph_creative_menu/tests/ew_form_death_intercept_patch_mock.lua +++ /dev/null @@ -1,161 +0,0 @@ -local root=assert(arg[1], 'root required') -local patches=assert(dofile(root..'/files/integrations/ew/resilience_patches.lua')) -local source=[[ -local rpc={change_entity=function() end} -local module = {} -local gameover_requested=false -ctx={my_player={entity=7,currently_polymorphed=true},cap={health={on_poly_death=function() HEALTH_DEATHS=HEALTH_DEATHS+1 end}}} -util={serialize_entity=function() return 'x' end,deserialize_entity=function() return 8 end,get_ent_health=function() return 0,1,true end} -function module.on_world_update() - if ctx.my_player.currently_polymorphed then - local hp, _, has_hp_component = util.get_ent_health(ctx.my_player.entity) - if has_hp_component and hp <= 0 and not gameover_requested then - ctx.cap.health.on_poly_death() - gameover_requested = true - end - else - gameover_requested = false - end -end -local function send_poly() - rpc.change_entity({ data = util.serialize_entity(ctx.my_player.entity) }) -end -local function apply_seri_ent(player_data, seri_ent) - if seri_ent ~= nil then - local ent = util.deserialize_entity(seri_ent.data) - end -end -return module -]] -local death_patched,death_count=patches.patch_polymorph_death_source(source) -assert(death_count==1 and death_patched:find('mcm_poly_death_intercept_v1',1,true), - 'EW polymorph death intercept was not independently patched') -local patched,count=patches.patch_polymorph_profile_source(death_patched) -assert(count==1 and patched:find('mcm_poly_profile_v1',1,true), - 'optional polymorph profiler did not compose with the critical death patch') -HEALTH_DEATHS=0 -local globals={} -GlobalsSetValue=function(k,v) globals[k]=tostring(v) end -GlobalsGetValue=function(k,d) return globals[k] or d end -EntityHasTag=function(entity,tag) return entity==7 and tag=='metamorph_creative_menu_network_form' end -EntityGetComponentIncludingDisabled=function() return {} end -EntityGetFilename=function() return '' end -EntityGetFirstComponentIncludingDisabled=function() return nil end -GameGetFrameNum=function() return 5 end -GameGetRealWorldTimeSinceStarted=function() return 1 end -CrossCall=function() error('EW-local CrossCall table must not handle an MCM callback') end -np={CrossCall=function(name,entity) - assert(name=='metamorph_creative_menu_form_died' and entity==7,'wrong MCM death interception call') - GlobalsSetValue('mcm_form_death_intercept_ack_v1','7:5') -end} -local chunk,err=load(patched,'patched_poly_death','t',_G); assert(chunk,err) -local module=assert(chunk()) -module.on_world_update() -assert(HEALTH_DEATHS==0,'EW player-death path ran after MCM committed human restore') - --- If the entity death() callback committed the restore earlier in the same frame, the --- form tag may already be removed from the corpse. The recent entity+frame ack must still --- suppress EW's notplayer/game-over path. -HEALTH_DEATHS=0; globals={mcm_form_death_intercept_ack_v1='7:5'} -EntityHasTag=function() return false end -np.CrossCall=function() error('pre-ack path should not need a second CrossCall') end -local chunk_pre=assert(load(patched,'patched_poly_death_pre','t',_G)); local module_pre=assert(chunk_pre()) -module_pre.on_world_update() -assert(HEALTH_DEATHS==0,'same-frame pre-ack was ignored after corpse tag removal') - --- Without an acknowledgement, stock EW death semantics must remain untouched. -HEALTH_DEATHS=0; globals={} -np.CrossCall=function() end -local chunk2=assert(load(patched,'patched_poly_death2','t',_G)); local module2=assert(chunk2()) -module2.on_world_update() -assert(HEALTH_DEATHS==1,'ordinary/unhandled polymorph death was swallowed') - --- Source patches can lose an OnModPreInit ordering race after EW has already loaded the --- original polymorph module. The extra-module health wrapper installs after all EW --- systems and must therefore enforce the same transaction without relying on rewritten --- source being the code that actually runs. -HEALTH_DEATHS=0; globals={}; local active_player=7 -ctx={my_player={entity=7,currently_polymorphed=true},cap={health={ - on_poly_death=function(message) - assert(message=='fatal','stock health arguments were not preserved') - HEALTH_DEATHS=HEALTH_DEATHS+1 - end, -}}} -EntityGetIsAlive=function(entity) return entity==7 or entity==8 or entity==9 end -EntityHasTag=function(entity,tag) - if entity==7 then return tag=='metamorph_creative_menu_network_form' end - if entity==8 then return tag=='player_unit' end - return false -end -np={ - GetPlayerEntity=function() return active_player end, - CrossCall=function(name,entity) - assert(name=='metamorph_creative_menu_form_died' and entity==7,'runtime intercept used wrong native channel') - active_player=8 - GlobalsSetValue('mcm_form_death_intercept_ack_v1','7:5') - end, -} -local runtime_intercept=assert(dofile(root..'/files/integrations/ew/form_death_intercept.lua')) -local installed,install_reason=runtime_intercept.install() -assert(installed and install_reason=='installed','EW health capability intercept was not installed') -ctx.cap.health.on_poly_death('fatal') -assert(HEALTH_DEATHS==0,'runtime health intercept allowed notplayer after committed restore') -assert(globals.mcm_form_death_runtime_rescues_v2=='1' - and globals.mcm_form_death_runtime_intercept_v2:find('rescued:7:',1,true)==1, - 'runtime rescue was not positively recorded') - --- Untagged/ordinary polymorph deaths keep the exact EW handler and arguments. -ctx.my_player.entity=9; active_player=9 -ctx.cap.health.on_poly_death('fatal') -assert(HEALTH_DEATHS==1,'runtime wrapper swallowed an ordinary EW polymorph death') - --- Two mods can bundle separate NoitaPatcher instances. If their native CrossCall --- registries do not meet, the interceptor must still restore the human from the exact --- serialized backup which EW itself stores on the polymorph effect. -local alive={[10]=true,[11]=true,[12]=true}; local killed={}; local damage_values={invincibility_frames=2} -ctx.my_player.entity=10; active_player=10; globals.mcm_form_death_intercept_ack_v1='' -EntityGetIsAlive=function(entity) return alive[entity]==true end -EntityHasTag=function(entity,tag) - if entity==10 then return tag=='metamorph_creative_menu_network_form' end - if entity==12 then return tag=='player_unit' end - return false -end -EntityGetAllChildren=function(entity) return entity==10 and {11} or {} end -EntityGetFirstComponentIncludingDisabled=function(entity,kind) - if entity==11 and kind=='GameEffectComponent' then return 501 end - if entity==12 and kind=='DamageModelComponent' then return 601 end - return nil -end -ComponentGetValue2=function(component,key) - if component==501 and key=='effect' then return 'POLYMORPH' end - if component==501 and key=='mSerializedData' then return 'encoded-human' end - if component==601 then return damage_values[key] end -end -ComponentSetValue2=function(component,key,value) - assert(component==601,'native fallback mutated an unexpected component') - damage_values[key]=value -end -EntityGetTransform=function(entity) assert(entity==10); return 30,40 end -EntityKill=function(entity) killed[entity]=true; alive[entity]=false end -dofile_once=function(path) - assert(path=='mods/quant.ew/files/resource/base64.lua','native fallback loaded the wrong EW codec') - return {decode=function(value) assert(value=='encoded-human'); return 'serialized-human' end} -end -util={deserialize_entity=function(value,x,y) - assert(value=='serialized-human' and x==30 and y==40,'native human restore lost its payload or position') - return 12 -end} -np={ - GetPlayerEntity=function() return active_player end, - CrossCall=function() end, -- deliberately no callback and therefore no acknowledgement - SetPlayerEntity=function(entity) active_player=entity end, -} -ctx.cap.health.on_poly_death('fatal') -assert(HEALTH_DEATHS==1,'stock notplayer path ran after EW-native human restoration') -assert(active_player==12 and killed[10]==true,'EW-native fallback did not commit human before retiring the form') -assert(damage_values.invincibility_frames==12 and damage_values.kill_now==false, - 'EW-native restored human did not receive the handoff protection window') -assert(globals.mcm_form_death_runtime_rescues_v2=='2' - and globals.mcm_form_death_runtime_intercept_v2:find('rescued_native:10:',1,true)==1, - 'EW-native fallback was not positively recorded') -print('ew_form_death_intercept_patch=PASS source_patch=true runtime_capability=true cross_vm_fallback=true stock_fallback=true') diff --git a/metamorph_creative_menu/tests/ew_kolmi_crosscall_failopen_mock.lua b/metamorph_creative_menu/tests/ew_kolmi_crosscall_failopen_mock.lua deleted file mode 100644 index 5bc8592..0000000 --- a/metamorph_creative_menu/tests/ew_kolmi_crosscall_failopen_mock.lua +++ /dev/null @@ -1,39 +0,0 @@ -local root=assert(arg[1],"root required") -local patches=assert(loadfile(root.."/files/integrations/ew/resilience_patches.lua"))() -local source=[[ -local old_entity_load = EntityLoad -function EntityLoad(filename,x,y) - if filename == "portal" then CrossCall("ew_kolmi_spawn_portal",x,y) end - return old_entity_load(filename,x,y) -end -local old_main_anim=set_main_animation -function set_main_animation(a,b) - old_main_anim(a,b) - CrossCall("ew_kolmi_anim",a,b,false) -end -local old_shield_on=shield_on -function shield_on() - CrossCall("ew_kolmi_shield",true,0) - return old_shield_on() -end -]] -local patched,count=patches.patch_kolmi_boss_update_source(source) -assert(count==3,"not every Kolmi transport CrossCall was guarded") -assert(string.find(patched,"mcm_kolmi_crosscall_failopen_v1",1,true),"fail-open marker missing") -assert(not string.find(patched,'CrossCall("ew_kolmi_',1,true),"raw Kolmi CrossCall survived patch") -local old_load_calls,anim_calls,shield_calls=0,0,0 -EntityLoad=function() old_load_calls=old_load_calls+1; return 9 end -set_main_animation=function() anim_calls=anim_calls+1 end -shield_on=function() shield_calls=shield_calls+1; return "shield-ok" end -CrossCall=function() error("split registry") end -np=nil -GlobalsSetValue=function() end -local chunk=assert(load(patched)) -chunk() -local ok,res=pcall(shield_on) -assert(ok and res=="shield-ok" and shield_calls==1,"CrossCall failure aborted vanilla shield_on") -local ok2=pcall(set_main_animation,"a","b") -assert(ok2 and anim_calls==1,"CrossCall failure aborted vanilla animation") -local ok3=pcall(EntityLoad,"portal",1,2) -assert(ok3 and old_load_calls==1,"CrossCall failure aborted vanilla EntityLoad") -print("ew_kolmi_crosscall_failopen=PASS transport_failure_does_not_abort_vanilla_coroutine=true") diff --git a/metamorph_creative_menu/tests/ew_kolmi_lifecycle_mock.lua b/metamorph_creative_menu/tests/ew_kolmi_lifecycle_mock.lua deleted file mode 100644 index 8295792..0000000 --- a/metamorph_creative_menu/tests/ew_kolmi_lifecycle_mock.lua +++ /dev/null @@ -1,218 +0,0 @@ -local root=assert(arg[1],"root required") -local BOSS="data/entities/animals/boss_centipede/boss_centipede.xml" -local globals, entities, components = {}, {}, {} -local tracked, storage, queue, released, deleted, packets = {}, {}, {}, {}, {}, {} -local next_component=0 -local reject_notify, hold_update, fail_resolve=false,false,false -local inside_native=false -local load_inside_native -local function add_component(entity, kind, values) - next_component=next_component+1 - components[next_component]={kind=kind, values=values, enabled=true, entity=entity} - entities[entity].components[#entities[entity].components+1]=next_component - return next_component -end -local function spawn(entity,gid,owner,hp,tags) - entities[entity]={alive=true,tags=tags or {boss_centipede=true},components={},x=100,y=200} - add_component(entity,"VariableStorageComponent",{name="ew_gid_lid",value_string=gid,value_bool=owner}) - local damage=add_component(entity,"DamageModelComponent",{hp=hp,wait_for_kill_flag_on_death=true,kill_now=false}) - add_component(entity,"LuaComponent",{script_source_file="authored_combat.lua",script_death="death.lua"}) - add_component(entity,"ItemChestComponent",{}) - tracked[gid]=entity - storage[gid]={filename=BOSS,hp=hp,global=true,owner=owner} - return damage -end -function EntityGetIsAlive(entity) return entities[entity] and entities[entity].alive or false end -function EntityGetFilename(entity) return BOSS end -function EntityHasTag(entity,tag) return entities[entity] and entities[entity].tags[tag] or false end -function EntityGetRootEntity(entity) return entity end -function EntityGetWithTag(tag) - local found={} - for entity in pairs(entities) do - if EntityGetIsAlive(entity) and EntityHasTag(entity,tag) then found[#found+1]=entity end - end - return found -end -function EntityGetTransform(entity) return entities[entity].x,entities[entity].y end -function EntityGetComponentIncludingDisabled(entity,kind) - local found={} - for _,id in ipairs(entities[entity].components) do - if components[id] and components[id].kind==kind then found[#found+1]=id end - end - return found -end -function EntityGetFirstComponentIncludingDisabled(entity,kind) - return EntityGetComponentIncludingDisabled(entity,kind)[1] -end -function ComponentGetValue2(id,key) return assert(components[id]).values[key] end -function ComponentSetValue2(id,key,value) assert(components[id]).values[key]=value end -function EntitySetComponentIsEnabled(entity,id,enabled) components[id].enabled=enabled end -function EntityRemoveComponent(entity,id) components[id]=nil end -function EntityKill(entity) entities[entity].alive=false end -function GlobalsGetValue(key,default) return globals[key] or default end -function GlobalsSetValue(key,value) globals[key]=value end -ewext={} -function ewext.find_by_gid(gid) - assert(not inside_native,"ewext called reentrantly from the native update") - assert(type(gid)=="string","u64 GID converted to a lossy Lua number") - if fail_resolve then error("find_by_gid temporarily unavailable") end - return tracked[gid] -end -function ewext.des_death_notify(entity,wait,x,y,file,responsible) - assert(not inside_native,"death notify called reentrantly") - if reject_notify then error("notify temporarily unavailable") end - queue[#queue+1]={entity=entity,wait=wait,file=file,responsible=responsible} -end --- Model the relevant v1.6.3 Rust ordering, not a mock where notify immediately deletes: --- diff_model.rs update_tracked_entities drains pending_death_notify, then update_entity --- releases a global !alive body. ReleaseAuthority retains proxy FullEntityData; a later --- RequestAuthority can reload it. DeleteEntity removes it. Remote KillEntity is the --- stock protocol and requires no MCM on the recipient. -function ewext.module_on_world_update() - if hold_update then return end - inside_native=true - for _,notification in ipairs(queue) do - for gid,entity in pairs(tracked) do - if entity==notification.entity and storage[gid].owner then - tracked[gid]=nil - storage[gid]=nil - deleted[gid]=(deleted[gid] or 0)+1 - packets[#packets+1]={kind="KillEntity",gid=gid,wait=notification.wait,file=notification.file} - end - end - end - queue={} - for gid,entity in pairs(tracked) do - if storage[gid].owner and (not EntityGetIsAlive(entity) or entities[entity].beyond_authority) then - tracked[gid]=nil - released[gid]=true -- FullEntityData remains in storage! - EntityKill(entity) - end - end - if load_inside_native then - local f=load_inside_native; load_inside_native=nil; f() - end - inside_native=false -end - --- Establish the native failure without a guard: a previously tracked global Kolmi --- disappears before EW's post-update callback. The proxy retains her spawn data. -spawn(1,"100",true,0) -EntityKill(1) -ewext.module_on_world_update() -assert(released["100"] and storage["100"],"fixture failed to reproduce retained authority") -storage["100"]=nil; released["100"]=nil - -local module=assert(loadfile(root.."/files/integrations/ew/kolmi_lifecycle.lua"))() -assert(module.install()) -assert(module.install(),"installation must be idempotent") - --- Lethal state must beat transfer. Preserve the original delayed-death body/coroutine --- and chest; only its network record is retired. A peer with stock EW gets KillEntity. -local damage=spawn(10,"9007199254740992",true,46) -ewext.module_on_world_update() -ComponentSetValue2(damage,"hp",0) -entities[10].beyond_authority=true -ewext.module_on_world_update() -assert(not storage["9007199254740992"] and not released["9007199254740992"],"dying Kolmi was released instead of deleted") -assert(EntityGetIsAlive(10),"guard skipped the original authored death animation") -assert(EntityGetFirstComponentIncludingDisabled(10,"ItemChestComponent"),"original loot was stripped") -assert(not ComponentGetValue2(damage,"kill_now"),"guard forced the original death animation to finish") -assert(packets[#packets].kind=="KillEntity" and packets[#packets].wait and packets[#packets].file=="", - "native replica death or distant replay suppression is missing") --- Late stock callback must not create another packet or resurrect an untracked root. -ewext.des_death_notify(10,true,100,200,BOSS,2) -EntityKill(10) -ewext.module_on_world_update() -assert(deleted["9007199254740992"]==1,"late callback retired a boss twice") - --- A different u64 GID, including the adjacent value above 2^53, is a new legitimate --- boss at the exact same position. No spatial tombstone may delete it. -spawn(11,"9007199254740993",true,46) -ewext.module_on_world_update() -assert(storage["9007199254740993"] and EntityGetIsAlive(11),"new nearby boss was mistaken for a replay") - --- A real same-GID authority reconstruction is retired through DES, then quietly --- disposed of, with no second script/loot/death animation. No MCM marker is required. -spawn(12,"9007199254740992",true,46) -hold_update=true -ewext.module_on_world_update() -assert(EntityGetIsAlive(12),"reconstructed body killed before native acknowledgement") -hold_update=false -ewext.module_on_world_update() -assert(not EntityGetIsAlive(12) and not storage["9007199254740992"],"same-GID reconstruction survived") -assert(not EntityGetFirstComponentIncludingDisabled(12,"ItemChestComponent"),"reconstruction can duplicate loot") - --- Body removed between frames: use the last authoritative snapshot BEFORE native --- !alive handling, rather than waiting for a late stock death callback. -spawn(20,"200",true,46) -ewext.module_on_world_update() -EntityKill(20) -ewext.module_on_world_update() -assert(deleted["200"]==1 and not released["200"],"late-callback resurrection window remains") - --- Discover a boss first tracked inside native update, then killed before the next one. -load_inside_native=function() spawn(21,"201",true,46) end -ewext.module_on_world_update() -EntityKill(21) -ewext.module_on_world_update() -assert(deleted["201"]==1 and not released["201"],"native-created root was not observed after update") - --- EW can load a Filename with saved HP=0 and is_charmed=false. There is no combat --- coroutine on that passive copy to finish kill_now. It must not remain as an inert, --- immortal body after its network record was removed. -load_inside_native=function() - spawn(22,"202",true,0) - add_component(22,"VariableStorageComponent",{name="initialized",value_bool=false}) -end -ewext.module_on_world_update() -ewext.module_on_world_update() -assert(deleted["202"]==1 and not EntityGetIsAlive(22),"zero-HP passive reconstruction remained after DES retirement") - --- Arena request from the separate menu VM: notification acceptance is not commit. -spawn(30,"300",true,46) -globals.mcm31_kolmi_retire_request_v1_30="300" -hold_update=true -ewext.module_on_world_update() -assert(EntityGetIsAlive(30) and storage["300"],"arena root killed before DES processed its queue") -hold_update=false -ewext.module_on_world_update() -assert(not EntityGetIsAlive(30) and deleted["300"]==1,"arena GID/body did not retire together") -assert(globals.mcm31_kolmi_retire_request_v1_30=="","arena request was not consumed") - --- Remote authority, player forms, ordinary bosses, and healthy ownership transfer --- must keep the stock lifecycle. Merely seeing zero HP on a replica is not authority. -spawn(40,"400",false,0) -spawn(41,"401",true,0,{boss_centipede=true,player_unit=true}) -spawn(42,"402",true,0,{boss_centipede=true,polymorphed_player=true}) -spawn(43,"403",true,0,{boss_centipede=true,ew_no_enemy_sync=true}) -spawn(44,"404",true,0,{boss_alchemist=true}) -ewext.module_on_world_update() -for _,gid in ipairs({"400","401","402","403","404"}) do - assert(not deleted[gid] and storage[gid],"guard modified excluded entity "..gid) -end -spawn(45,"405",true,46) -ewext.module_on_world_update() -tracked["405"]=nil; EntityKill(45) -- stock healthy transfer has already relinquished it -ewext.module_on_world_update() -assert(not deleted["405"],"healthy transfer was interpreted as a death") - --- Transient API failures neither authorize a quiet kill nor discard a pending death. -spawn(50,"500",true,0) -reject_notify=true -ewext.module_on_world_update() -assert(EntityGetIsAlive(50) and storage["500"],"failed notify discarded a body") -reject_notify=false -ewext.module_on_world_update() -assert(deleted["500"]==1,"failed notify was not retried") -spawn(51,"501",true,46) -globals.mcm31_kolmi_retire_request_v1_51="501" -hold_update=true -ewext.module_on_world_update() -fail_resolve=true; hold_update=false -ewext.module_on_world_update() -assert(EntityGetIsAlive(51),"find_by_gid failure treated as acknowledgement") -fail_resolve=false -ewext.module_on_world_update() -assert(not EntityGetIsAlive(51),"acknowledgement was not retried") -print("ew_kolmi_lifecycle=PASS native_queue_order=true separate_vm_ack=true delayed_death=true u64=true transfer=true replay=true exclusions=true retries=true") diff --git a/metamorph_creative_menu/tests/ew_kolmi_spawn_failopen_mock.lua b/metamorph_creative_menu/tests/ew_kolmi_spawn_failopen_mock.lua deleted file mode 100644 index f29be67..0000000 --- a/metamorph_creative_menu/tests/ew_kolmi_spawn_failopen_mock.lua +++ /dev/null @@ -1,36 +0,0 @@ -local root=assert(arg[1],"root required") -local patches=assert(loadfile(root.."/files/integrations/ew/resilience_patches.lua"))() -local source=[[ -local old = item_pickup -function item_pickup(ent, _, _, run) - if run == nil then - local gid - for _, v in ipairs(EntityGetComponent(ent, "VariableStorageComponent") or {}) do - if ComponentGetValue2(v, "name") == "ew_gid_lid" then gid = v break end - end - if gid ~= nil and not ComponentGetValue2(gid, "value_bool") then - CrossCall("ew_spawn_kolmi", ComponentGetValue2(gid, "value_string")) - else - old(ent) - end - else - old(ent) - end -end -]] -local patched,count=patches.patch_kolmi_spawn_source(source) -assert(count==1 and string.find(patched,"mcm_kolmi_spawn_crosscall_failopen_v1",1,true),"Sampo fail-open patch missing") -local old_calls=0 -item_pickup=function() old_calls=old_calls+1 end -EntityGetComponent=function() return {11} end -ComponentGetValue2=function(id,key) - if key=="name" then return "ew_gid_lid" end - if key=="value_bool" then return false end - if key=="value_string" then return "123" end -end -CrossCall=function() error("split registry") end -assert(load(patched))() -local ok=pcall(item_pickup,5,nil,nil,nil) -assert(ok,"remote Sampo CrossCall failure escaped wrapper") -assert(old_calls==1,"remote Sampo fail-open did not fall back to vanilla pickup") -print("ew_kolmi_spawn_failopen=PASS split_registry_falls_back_to_vanilla=true") diff --git a/metamorph_creative_menu/tests/ew_mailbox_drain_mock.lua b/metamorph_creative_menu/tests/ew_mailbox_drain_mock.lua deleted file mode 100644 index 5e701cc..0000000 --- a/metamorph_creative_menu/tests/ew_mailbox_drain_mock.lua +++ /dev/null @@ -1,254 +0,0 @@ -local root = assert(arg[1], "root required") -local native_dofile = dofile - -local function finite_number(value) - return type(value) == "number" and value == value and value ~= math.huge and value ~= -math.huge -end - - -local function load_common(errors) - local previous_dofile = dofile - dofile = function(path) - if path == "mods/metamorph_creative_menu/files/core/global_text.lua" then - return native_dofile(root .. "/files/core/global_text.lua") - end - return previous_dofile(path) - end - local common = assert(native_dofile(root .. "/files/integrations/ew/bridge/common.lua")) - dofile = previous_dofile - local real_report = common.report_error - common.report_error = function(scope, detail) - errors[#errors + 1] = {scope=tostring(scope), detail=tostring(detail)} - return real_report(scope, detail) - end - return common -end - -local function assert_safe_diagnostic(globals, label) - local value = tostring(globals.mcm_world_rules_rpc_error_v1 or "") - assert(#value <= 512, label .. " diagnostic exceeded 512 bytes") - assert(not string.find(value, '"', 1, true), label .. " diagnostic contained quote") - for index=1,#value do - local byte=string.byte(value,index) - assert(byte >= 32 and byte <= 126, label .. " diagnostic contained non-ASCII/control byte") - end -end - -local function make_rpc_proxy(send_name, sends, state) - local receiver - local proxy = {} - setmetatable(proxy, { - __newindex=function(t, key, value) - if key == send_name then - receiver = value - rawset(t, key, function(...) - if state.fail_send then error('simulated rpc failure "<&%\\ Ж🔥') end - sends[#sends+1] = {...} - end) - else - rawset(t, key, value) - end - end, - }) - function proxy.opts_reliable() end - function proxy.opts_everywhere() end - return proxy, function() return receiver end -end - -local function run_teleport() - local globals, sends, errors, state = {}, {}, {}, {fail_send=false, guarded=false, reads=0} - local loaded = true - local transforms = {[10]={0,0}, [20]={100,100}} - ctx={my_id='peer-target',my_player={entity=10},players={['peer-target']={entity=10},['peer-source']={entity=20}},rpc_player_data={entity=20}} - function GlobalsGetValue(key, fallback) - if state.guarded and string.find(key, '_v1_', 1, true) then - state.reads = state.reads + 1 - assert(state.reads < 200, 'teleport attempted unbounded huge-gap mailbox reads') - end - return globals[key] or fallback - end - function GlobalsSetValue(key, value) globals[key]=tostring(value) end - function GameGetFrameNum() return 20 end - function DoesWorldExistAt() return loaded end - function FindFreePositionForBody(x,y) return x,y end - function EntityGetIsAlive(entity) return transforms[entity] ~= nil end - function EntityGetTransform(entity) return transforms[entity][1], transforms[entity][2] end - function EntitySetTransform(entity,x,y) transforms[entity]={x,y} end - function EntityGetFirstComponentIncludingDisabled() return 70 end - function ComponentSetValue2() end - function GameSetCameraPos() end - - local rpc_proxy, get_receiver = make_rpc_proxy('request_bring', sends, state) - local ew_api={new_rpc_namespace=function(namespace) - assert(namespace=='metamorph_creative_menu:teleport:v1:', 'teleport v1 namespace changed') - return rpc_proxy - end} - local common=load_common(errors) - local bridge=assert(loadfile(root..'/files/integrations/ew/bridge/teleport.lua'))() - bridge.register(ew_api,common) - local receiver=assert(get_receiver(),'teleport receiver missing') - - bridge.update() - assert(#sends==0 and globals.mcm_teleport_bring_outbox_ack_v1==nil, 'empty teleport v1 mailbox changed state') - - for i=1,40 do - local suffix='_'..i - globals['mcm_teleport_bring_outbox_peer_v1'..suffix]='peer-source' - globals['mcm_teleport_bring_outbox_target_x_v1'..suffix]='100' - globals['mcm_teleport_bring_outbox_target_y_v1'..suffix]='100' - globals['mcm_teleport_bring_outbox_dest_x_v1'..suffix]=tostring(i) - globals['mcm_teleport_bring_outbox_dest_y_v1'..suffix]='0' - end - globals.mcm_teleport_bring_outbox_seq_v1='40' - - receiver('peer-target',100,100) - bridge.update() - assert(#sends==16, 'teleport did not bound first drain to 16: '..tostring(#sends)) - assert(globals.mcm_teleport_bring_outbox_ack_v1=='16','teleport first ACK was not 16') - assert(globals.mcm_teleport_bring_outbox_peer_v1_1=='' and globals.mcm_teleport_bring_outbox_peer_v1_16=='', 'processed teleport keys were not cleared') - assert(globals.mcm_teleport_bring_outbox_peer_v1_17=='peer-source','unprocessed teleport key was cleared early') - assert(transforms[10][1]==100 and transforms[10][2]==100,'apply_pending did not run while mailbox backlog remained') - bridge.update(); assert(#sends==32 and globals.mcm_teleport_bring_outbox_ack_v1=='32','teleport second drain was not 16') - bridge.update(); assert(#sends==40 and globals.mcm_teleport_bring_outbox_ack_v1=='40','teleport final drain was not 8') - - globals.mcm_teleport_bring_outbox_seq_v1='41' - globals.mcm_teleport_bring_outbox_peer_v1_41='peer-source'; globals.mcm_teleport_bring_outbox_target_x_v1_41='100'; globals.mcm_teleport_bring_outbox_target_y_v1_41='100'; globals.mcm_teleport_bring_outbox_dest_x_v1_41='41'; globals.mcm_teleport_bring_outbox_dest_y_v1_41='0' - state.fail_send=true; bridge.update() - assert(globals.mcm_teleport_bring_outbox_ack_v1=='40','failed teleport RPC was acknowledged') - assert(globals.mcm_teleport_bring_outbox_peer_v1_41=='peer-source','failed teleport RPC payload was cleared') - assert_safe_diagnostic(globals, 'teleport rpc failure') - assert(string.find(globals.mcm_world_rules_rpc_error_v1 or '', '%22', 1, true), 'teleport RPC diagnostic was not encoded') - state.fail_send=false; bridge.update(); assert(globals.mcm_teleport_bring_outbox_ack_v1=='41','teleport retry did not acknowledge success') - - globals.mcm_teleport_bring_outbox_seq_v1='43' - globals.mcm_teleport_bring_outbox_peer_v1_42='peer-source'; globals.mcm_teleport_bring_outbox_target_x_v1_42='nan'; globals.mcm_teleport_bring_outbox_target_y_v1_42='100'; globals.mcm_teleport_bring_outbox_dest_x_v1_42='42'; globals.mcm_teleport_bring_outbox_dest_y_v1_42='0' - globals.mcm_teleport_bring_outbox_peer_v1_43='peer-source'; globals.mcm_teleport_bring_outbox_target_x_v1_43='100'; globals.mcm_teleport_bring_outbox_target_y_v1_43='100'; globals.mcm_teleport_bring_outbox_dest_x_v1_43='43'; globals.mcm_teleport_bring_outbox_dest_y_v1_43='0' - local before=#sends; bridge.update() - assert(globals.mcm_teleport_bring_outbox_ack_v1=='43' and #sends==before+1,'invalid teleport middle record blocked next message') - - state.guarded=true; state.reads=0; local error_before=#errors - globals.mcm_teleport_bring_outbox_seq_v1='1000000'; bridge.update() - assert(globals.mcm_teleport_bring_outbox_ack_v1=='1000000','teleport huge gap did not resync ACK') - assert(#errors==error_before+1,'teleport huge gap did not report exactly once') - assert_safe_diagnostic(globals, 'teleport huge gap') - local gap_error_seq=globals.mcm_world_rules_rpc_error_seq_v1 - local reads_after=state.reads; bridge.update() - assert(#errors==error_before+1 and state.reads==reads_after,'teleport huge gap repeated diagnostic/work') - assert(globals.mcm_world_rules_rpc_error_seq_v1==gap_error_seq,'teleport huge gap diagnostic was written more than once') - state.guarded=false - - globals.mcm_teleport_bring_outbox_seq_v1='1000001' - globals.mcm_teleport_bring_outbox_peer_v1='peer-source'; globals.mcm_teleport_bring_outbox_target_x_v1='100'; globals.mcm_teleport_bring_outbox_target_y_v1='100'; globals.mcm_teleport_bring_outbox_dest_x_v1='77'; globals.mcm_teleport_bring_outbox_dest_y_v1='0' - before=#sends; bridge.update() - assert(#sends==before+1 and sends[#sends][2]==77 and globals.mcm_teleport_bring_outbox_ack_v1=='1000001','teleport v1 base-key peer compatibility regressed') - local stable_sends=#sends - for _, invalid in ipairs({'-1','1000001.5','nan','inf'}) do - local invalid_errors=#errors - globals.mcm_teleport_bring_outbox_seq_v1=invalid - bridge.update() - assert(globals.mcm_teleport_bring_outbox_ack_v1=='1000001' and #sends==stable_sends, - 'invalid teleport sequence advanced mailbox: '..invalid) - bridge.update() - assert(#errors==invalid_errors+1, - 'unchanged invalid teleport sequence emitted a diagnostic every frame: '..invalid) - end - return errors -end - -local function run_possession() - local globals, sends, errors, state = {}, {}, {}, {fail_send=false, guarded=false, reads=0} - local alive, paths, transforms = {[1]=true}, {}, {[1]={100,100}} - ctx={my_player={entity=1}} - function GlobalsGetValue(key, fallback) - if state.guarded and string.find(key, '_v1_', 1, true) then - state.reads=state.reads+1 - assert(state.reads < 150, 'possession attempted unbounded huge-gap mailbox reads') - end - return globals[key] or fallback - end - function GlobalsSetValue(key,value) globals[key]=tostring(value) end - function EntityGetIsAlive(entity) return alive[entity]==true end - function EntityGetTransform(entity) local p=transforms[entity] or {0,0}; return p[1],p[2] end - function EntityGetInRadius(x,y,r) - local out={} - for entity,is_alive in pairs(alive) do - if is_alive and entity~=1 then out[#out+1]=entity end - end - return out - end - function EntityGetParent() return 0 end - function EntityGetFilename(entity) return paths[entity] or '' end - function EntityHasTag(entity,tag) return tag=='ew_replicated' and entity~=1 and alive[entity]==true end - function EntityGetAllChildren() return {} end - function EntityGetAllComponents() return {} end - function EntitySetTransform(entity,x,y) transforms[entity]={x,y} end - function EntityKill(entity) alive[entity]=false end - function EntityRemoveComponent() end - function EntitySetComponentIsEnabled() end - function ComponentGetTypeName() return '' end - - CrossCall=function(name,entity,wait,x,y,path,responsible) - assert(name=='ew_death_notify','possession bridge used non-stock EW channel') - if state.fail_send then error('simulated EW death notify failure "<&%\\ Ж🔥') end - sends[#sends+1]={entity,wait,x,y,path,responsible} - end - local rpc={opts_reliable=function() end,opts_everywhere=function() end} - local common=load_common(errors) - local bridge=assert(loadfile(root..'/files/integrations/ew/bridge/possession.lua'))() - bridge.register(rpc,common) - - bridge.update() - assert(#sends==0 and globals.mcm_possession_retire_outbox_ack_v1==nil, 'empty possession v1 mailbox changed state') - - for i=1,40 do - local entity=1000+i; alive[entity]=true; paths[entity]='mods/example/entities/mob.xml'; transforms[entity]={i,0} - local suffix='_'..i - globals['mcm_possession_retire_outbox_entity_v1'..suffix]=tostring(entity) - globals['mcm_possession_retire_outbox_path_v1'..suffix]='mods/example/entities/mob.xml' - globals['mcm_possession_retire_outbox_x_v1'..suffix]=tostring(i) - globals['mcm_possession_retire_outbox_y_v1'..suffix]='0' - end - globals.mcm_possession_retire_outbox_seq_v1='40' - bridge.update(); assert(#sends==16 and globals.mcm_possession_retire_outbox_ack_v1=='16','possession first drain was not 16/ACK16') - bridge.update(); assert(#sends==32 and globals.mcm_possession_retire_outbox_ack_v1=='32','possession second drain was not 16/ACK32') - bridge.update(); assert(#sends==40 and globals.mcm_possession_retire_outbox_ack_v1=='40','possession final drain was not 8/ACK40') - assert(sends[1][2]==false and sends[1][5]=='mods/example/entities/mob.xml','possession did not use stock EW death-notify shape') - - local e41=1041; alive[e41]=true; paths[e41]='mods/example/entities/mob.xml'; transforms[e41]={41,0} - globals.mcm_possession_retire_outbox_seq_v1='41'; globals.mcm_possession_retire_outbox_entity_v1_41=tostring(e41); globals.mcm_possession_retire_outbox_path_v1_41='mods/example/entities/mob.xml'; globals.mcm_possession_retire_outbox_x_v1_41='41'; globals.mcm_possession_retire_outbox_y_v1_41='0' - state.fail_send=true; bridge.update(); assert(globals.mcm_possession_retire_outbox_ack_v1=='40','failed possession EW death-notify was acknowledged') - assert_safe_diagnostic(globals, 'possession EW death-notify failure') - state.fail_send=false; bridge.update(); assert(globals.mcm_possession_retire_outbox_ack_v1=='41','possession EW death-notify retry did not acknowledge success') - - local e43=1043; alive[e43]=true; paths[e43]='mods/example/entities/mob.xml'; transforms[e43]={43,0} - globals.mcm_possession_retire_outbox_seq_v1='43'; globals.mcm_possession_retire_outbox_entity_v1_42='0'; globals.mcm_possession_retire_outbox_path_v1_42=''; globals.mcm_possession_retire_outbox_x_v1_42='bad'; globals.mcm_possession_retire_outbox_y_v1_42='0'; globals.mcm_possession_retire_outbox_entity_v1_43=tostring(e43); globals.mcm_possession_retire_outbox_path_v1_43='mods/example/entities/mob.xml'; globals.mcm_possession_retire_outbox_x_v1_43='43'; globals.mcm_possession_retire_outbox_y_v1_43='0' - local before=#sends; bridge.update(); assert(#sends==before+1 and globals.mcm_possession_retire_outbox_ack_v1=='43','invalid possession middle record blocked next message') - - state.guarded=true; state.reads=0; local error_before=#errors; globals.mcm_possession_retire_outbox_seq_v1='1000000'; bridge.update() - assert(globals.mcm_possession_retire_outbox_ack_v1=='1000000','possession huge gap did not resync ACK') - assert(#errors==error_before+1,'possession huge gap did not report once') - local reads_after=state.reads; bridge.update(); assert(#errors==error_before+1 and state.reads==reads_after,'possession huge gap repeated diagnostic/work') - state.guarded=false - - local ebase=2001; alive[ebase]=true; paths[ebase]='mods/example/entities/old_peer.xml'; transforms[ebase]={9,10} - globals.mcm_possession_retire_outbox_seq_v1='1000001'; globals.mcm_possession_retire_outbox_entity_v1=tostring(ebase); globals.mcm_possession_retire_outbox_path_v1='mods/example/entities/old_peer.xml'; globals.mcm_possession_retire_outbox_x_v1='9'; globals.mcm_possession_retire_outbox_y_v1='10' - before=#sends; bridge.update(); assert(#sends==before+1 and sends[#sends][5]=='mods/example/entities/old_peer.xml' and globals.mcm_possession_retire_outbox_ack_v1=='1000001','possession v1 base-key compatibility regressed') - local stable_sends=#sends - for _, invalid in ipairs({'-1','1000001.5','nan','inf'}) do - local invalid_errors=#errors - globals.mcm_possession_retire_outbox_seq_v1=invalid - bridge.update() - assert(globals.mcm_possession_retire_outbox_ack_v1=='1000001' and #sends==stable_sends, - 'invalid possession sequence advanced mailbox: '..invalid) - bridge.update() - assert(#errors==invalid_errors+1, - 'unchanged invalid possession sequence emitted a diagnostic every frame: '..invalid) - end - dofile=native_dofile - ewext=nil - return errors -end - -local teleport_errors=run_teleport() -local possession_errors=run_possession() -print('ew_mailbox_drain=PASS teleport_16_16_8=true possession_16_16_8=true atomic_ack=true invalid_continue=true huge_gap=true v1_compatible=true errors='..tostring(#teleport_errors+#possession_errors)) diff --git a/metamorph_creative_menu/tests/ew_material_backpressure_mock.lua b/metamorph_creative_menu/tests/ew_material_backpressure_mock.lua deleted file mode 100644 index 20b4de7..0000000 --- a/metamorph_creative_menu/tests/ew_material_backpressure_mock.lua +++ /dev/null @@ -1,93 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local native_require=require -local globals={} -local frame=1 -local newest=100 -local ack=0 -local slots={} -for i=1,newest do slots[i]="solid" end - -function GlobalsGetValue(key,default) return globals[key] or default end -function GlobalsSetValue(key,value) globals[key]=tostring(value) end -function GameGetFrameNum() return frame end -function CellFactory_GetType(name) return name=="rock_static" and 7 or -1 end -function DoesWorldExistAt() return true end - -local points={} -for i=1,24 do points[i]={i,20} end -local transport={ - ack_sequence=function() return ack end, - first_available_sequence=function(after) return after+1,newest end, - read_outbox=function(sequence) return slots[sequence] end, - clear_outbox=function(sequence) slots[sequence]=nil end, - mark_ack=function(sequence) if sequence>ack then ack=sequence end end, - decode_batch=function(payload) - if payload~="solid" then return nil end - return {material_name="rock_static",solid=true,brush_index=3,points=points} - end, -} - -local area={payload="solid-seed-frame"} -local ffi_stub={string=function(buffer,size) - assert(buffer==area and size==#area.payload,"bad encoded solid seed") - return area.payload -end} -local world={ - EncodedArea=function() return area end, - encode_area=function(_,_,_,_,buffer) assert(buffer==area); return area end, - encoded_size=function(buffer) assert(buffer==area); return #buffer.payload end, -} -require=function(name) - if name=="ffi" then return ffi_stub end - return native_require(name) -end -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/integrations/ew/material_paint_sync.lua" then return transport end - return native_dofile(path) -end -dofile_once=function(path) - if path=="mods/quant.ew/files/system/world_sync/world.lua" then return world end - error("unexpected dofile_once "..tostring(path)) -end - -local network_fails=true -net={ - proxy_send=function() if network_fails then error("proxy offline") end end, - proxy_bin_send=function() if network_fails then error("proxy offline") end end, -} -ctx={proxy_opt={world_num=0}} -local next_entity=0 -function EntityCreateNew() next_entity=next_entity+1; return next_entity end -function EntitySetTransform() end -function EntityAddTag() end -function EntityAddChild() end -function EntityAddComponent2() return 1 end -function EntityKill() end -ewext={des_item_thrown=function() if network_fails then error("entity transport offline") end end} - -local errors=0 -local rpc={opts_reliable=function() end,opts_everywhere=function() end} -local common={clean=tostring,report_error=function() errors=errors+1 end} -local bridge=assert(loadfile(root.."/files/integrations/ew/bridge/materials.lua"))() -bridge.set_metrics_enabled(true) -bridge.register(rpc,common) - -for i=1,5 do frame=i; bridge.update() end -assert(ack==16,"bounded bridge did not stop acknowledging at relay capacity: "..tostring(ack)) -assert(slots[17]=="solid","backpressured batch was cleared instead of retained") -frame=60; bridge.update() -assert(globals.mcm_material_sync_backlog_v1=="2:16", - "bounded backlog changed: "..tostring(globals.mcm_material_sync_backlog_v1)) -assert(errors>0,"network failures were not surfaced") - --- Remove work that the mailbox has not accepted, restore both stock EW transports and --- prove the fixed queues can drain without a restart. -newest=ack -network_fails=false -for i=61,120 do frame=i; bridge.update() end -assert(globals.mcm_material_sync_backlog_v1=="0:0","bridge did not recover after transport returned") -assert(ack==16,"recovery corrupted mailbox acknowledgement") - -require=native_require; dofile=native_dofile -io.write("ew_material_backpressure=PASS bounded=true atomic_ack=true recovery=true\n") diff --git a/metamorph_creative_menu/tests/ew_material_pixel_scene_patch_mock.lua b/metamorph_creative_menu/tests/ew_material_pixel_scene_patch_mock.lua deleted file mode 100644 index 64eb58c..0000000 --- a/metamorph_creative_menu/tests/ew_material_pixel_scene_patch_mock.lua +++ /dev/null @@ -1,49 +0,0 @@ -local root=assert(arg[1], 'root required') -local patches=assert(dofile(root..'/files/integrations/ew/resilience_patches.lua')) -local source=[[ -local old_LoadPixelScene = LoadPixelScene -function LoadPixelScene(materials_filename,colors_filename,x,y,background_file,skip_biome_checks,skip_edge_textures,color_to_material_table,background_z_index,load_even_if_duplicate) - old_LoadPixelScene(materials_filename,colors_filename,x,y,background_file,skip_biome_checks,skip_edge_textures,color_to_material_table,background_z_index,load_even_if_duplicate) - -- TODO there are a couple more parameters, tho they don't seem to be used in vanilla - CrossCall( - "ew_sync_pixel_scene", - materials_filename, - colors_filename, - x, - y, - background_file, - skip_biome_checks, - skip_edge_textures - ) -end -return true -]] -local patched,count=patches.patch_material_pixel_scene_source(source) -assert(count==1 and patched:find('mcm_material_brush_pixel_scene_v1',1,true),'material scene patch did not apply') -local source_without_upstream_comment=source:gsub(" %-%- TODO there are a couple more parameters, tho they don't seem to be used in vanilla\n",'') -local patched_without_comment,count_without_comment=patches.patch_material_pixel_scene_source(source_without_upstream_comment) -assert(count_without_comment==1 and patched_without_comment:find('mcm_material_brush_pixel_scene_v1',1,true),'material scene patch depends on upstream comment text') -local again,again_count=patches.patch_material_pixel_scene_source(patched) -assert(again_count==0 and again==patched,'material scene patch not idempotent') -local unchanged,missing=patches.patch_material_pixel_scene_source('return true') -assert(missing==0 and unchanged=='return true','anchor mismatch modified unrelated EW source') - -local local_loads,network_calls={},{} -LoadPixelScene=function(...) end -- captured as old_LoadPixelScene by patched chunk -CrossCall=function(name,...) - network_calls[#network_calls+1]={name=name,args={...}} -end -local chunk,err=load(patched,'patched_synced_pixel_scenes','t',_G); assert(chunk,err); chunk() --- The patched wrapper replaced the global function. MCM brush must still be applied --- locally but must not enter EW's filename-only pixel-scene RPC. --- Rebuild with a recorder because source captured the global that existed at load time. -LoadPixelScene=function(...) local_loads[#local_loads+1]={...} end -local chunk2=assert(load(patched,'patched_synced_pixel_scenes2','t',_G)); chunk2() -LoadPixelScene('mods/metamorph_creative_menu/files/features/materials/brushes/brush_r2.png','',10,20,'',true,false,{ff5ac75a='rock_static'},50,true) -assert(#local_loads==1,'MCM brush scene was not applied locally') -assert(#network_calls==0,'MCM dynamic brush leaked into EW filename-only pixel-scene RPC') - -LoadPixelScene('data/biome_impl/some_scene.png','data/biome_impl/some_colors.png',30,40,'',false,false,nil,50,false) -assert(#local_loads==2,'ordinary EW pixel scene stopped loading locally') -assert(#network_calls==1 and network_calls[1].name=='ew_sync_pixel_scene','ordinary EW pixel-scene synchronization was changed') -print('ew_material_pixel_scene_patch=PASS mcm_scene_local_only=true upstream_scene_sync_preserved=true') diff --git a/metamorph_creative_menu/tests/ew_material_stock_transport_mock.lua b/metamorph_creative_menu/tests/ew_material_stock_transport_mock.lua deleted file mode 100644 index 2cd6cc4..0000000 --- a/metamorph_creative_menu/tests/ew_material_stock_transport_mock.lua +++ /dev/null @@ -1,152 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile,native_require=dofile,require -local frame,globals,newest,ack=1,{},0,0 -local slots={} -local unavailable_chunk_x=nil - -function GlobalsGetValue(key,default) return globals[key] or default end -function GlobalsSetValue(key,value) globals[key]=tostring(value) end -function GameGetFrameNum() return frame end -function DoesWorldExistAt(x1) return x1~=unavailable_chunk_x end -function CellFactory_GetType(name) return name=="water" and 1 or (name=="rock_static" and 7 or -1) end - -local decoded={ - dynamic={material_name="water",solid=false,brush_index=3,points={{130,10},{132,10}}}, - solid={material_name="rock_static",solid=true,brush_index=2,points={{10,20},{14,20}}}, - repeat_solid={material_name="rock_static",solid=true,brush_index=2,points={{18,20}}}, - far={material_name="rock_static",solid=true,brush_index=2,points={{385,20}}}, - other={material_name="rock_static",solid=true,brush_index=2,points={{650,20}}}, -} -local transport={ - ack_sequence=function() return ack end, - first_available_sequence=function(after) return after+1,newest end, - read_outbox=function(sequence) return slots[sequence] end, - clear_outbox=function(sequence) slots[sequence]=nil end, - mark_ack=function(sequence) ack=math.max(ack,sequence) end, - decode_batch=function(payload) return decoded[payload] end, -} - -local encoded_calls={} -local area={payload="native-ew-world-frame"} -local world={ - EncodedArea=function() return area end, - encode_area=function(x1,y1,x2,y2,buffer) - assert(buffer==area and x1%128==0 and y1%128==0 and x2-x1==128 and y2-y1==128, - "unsafe native world frame") - encoded_calls[#encoded_calls+1]={x1,y1,x2,y2}; return area - end, - encoded_size=function(buffer) return #buffer.payload end, -} -local ffi_stub={string=function(buffer,size) assert(size==#buffer.payload); return buffer.payload end} -require=function(name) if name=="ffi" then return ffi_stub end return native_require(name) end -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/integrations/ew/material_paint_sync.lua" then return transport end - return native_dofile(path) -end -dofile_once=function(path) - if path=="mods/quant.ew/files/system/world_sync/world.lua" then return world end - error("unexpected dofile_once "..tostring(path)) -end - -local binary,proxy={},{} -local fail_world_end_once=true -net={ - proxy_bin_send=function(key,payload) - if key==1 and fail_world_end_once then fail_world_end_once=false; error("temporary world-end failure") end - binary[#binary+1]={key=key,payload=payload} - end, - proxy_send=function(command,payload) proxy[#proxy+1]={command=command,payload=payload} end, -} -ctx={proxy_opt={world_num=3}} - -local next_entity,entities,uploaded=100,{},{} -function EntityCreateNew(name) next_entity=next_entity+1; entities[next_entity]={name=name,components={},children={},alive=true}; return next_entity end -function EntitySetTransform(entity,x,y) entities[entity].x=x; entities[entity].y=y end -function EntityAddTag(entity,tag) entities[entity].tag=tag end -function EntityAddChild(parent,child) - entities[child].parent=parent - entities[child].relative_x=(entities[child].x or 0)-(entities[parent].x or 0) - entities[child].relative_y=(entities[child].y or 0)-(entities[parent].y or 0) - entities[parent].children[#entities[parent].children+1]=child -end -function EntityAddComponent2(entity,name,values) - entities[entity].components[#entities[entity].components+1]={name=name,values=values} - return #entities[entity].components -end -function EntityKill(entity) if entities[entity] then entities[entity].alive=false end end -ewext={des_item_thrown=function(entity) uploaded[#uploaded+1]=entity end} - -local registered={} -local rpc=setmetatable({opts_reliable=function() end,opts_everywhere=function() end},{ - __newindex=function(t,key,value) rawset(t,key,value); if type(value)=="function" then registered[#registered+1]=key end end, -}) -local errors={} -local common={clean=tostring,report_error=function(scope,detail) errors[#errors+1]=scope..":"..tostring(detail) end} -local bridge=assert(loadfile(root.."/files/integrations/ew/bridge/materials.lua"))() -bridge.register(rpc,common) -assert(registered[1]=="sync_material_paint","reserved material RPC slot moved") - -slots[1]="dynamic"; newest=1; bridge.update() -assert(ack==1 and #encoded_calls==1 and #binary==1 and #uploaded==0, - "batch did not enter an ordered native chunk transaction") -frame=2; bridge.update() -assert(#binary==2 and binary[2].key==1 and #uploaded==1, - "failed world end was not retried before releasing relay") -assert(#proxy==0,"painting used EW's destructive cut-through-world command") - -local dynamic=entities[uploaded[1]] -assert(dynamic.name=="mcm_material_relay" and #dynamic.children==2,"dynamic relay lost brush points") -for _,child in ipairs(dynamic.children) do - local converter,emitter=false,false - for _,component in ipairs(entities[child].components) do - if component.name=="MagicConvertMaterialComponent" then - converter=component.values.to_material==1 and component.values.min_radius==0 - elseif component.name=="ParticleEmitterComponent" then - emitter=component.values.emitted_material_name=="water" and component.values.create_real_particles==true - end - end - assert(converter and emitter,"stock relay lacks converter or real material seed") -end - -frame=3; slots[2]="solid"; newest=2; bridge.update() -assert(ack==2 and #encoded_calls==2 and encoded_calls[2][1]==0 and #uploaded==2, - "solid paint did not seed and relay its exact chunk") - --- The same chunk is no longer cached forever. It is coalesced during a short cooldown --- and then re-sent, which keeps continuous moving strokes authoritative without 60 --- full chunk frames per second. -frame=4; slots[3]="repeat_solid"; newest=3; bridge.update() -assert(ack==3 and #encoded_calls==2 and #uploaded==2,"chunk resend cooldown was ignored") -for next_frame=5,8 do frame=next_frame; bridge.update() end -assert(#encoded_calls==2 and #uploaded==2,"cooldown released too early") -frame=9; bridge.update() -assert(#encoded_calls==3 and encoded_calls[3][1]==0 and #uploaded==3, - "painted chunk was permanently cached instead of being refreshed") - --- An unloaded fringe cannot block a later loaded chunk while the player moves. -unavailable_chunk_x=384 -frame=10; slots[4]="far"; newest=4; bridge.update() -assert(ack==4 and #uploaded==3,"unloaded paint escaped before its chunk existed") -frame=11; slots[5]="other"; newest=5; bridge.update() -assert(ack==5 and encoded_calls[#encoded_calls][1]==640 and #uploaded==4, - "unloaded fringe blocked a loaded moving stroke") -unavailable_chunk_x=nil -frame=12; bridge.update() -assert(encoded_calls[#encoded_calls][1]==384 and #uploaded==5, - "fringe paint was not released after streaming completed") - -local converters,emitters=0,0 -for _,entity in pairs(entities) do - for _,component in ipairs(entity.components) do - assert(component.name~="LuaComponent","relay introduced a receiver-side MCM script") - if component.name=="MagicConvertMaterialComponent" then converters=converters+1 end - if component.name=="ParticleEmitterComponent" then emitters=emitters+1 end - end -end -assert(converters==emitters and converters>0,"material classes do not share the stock relay path") -assert(#proxy==0,"destructive proxy terrain transport returned") -assert(#errors==1 and string.find(errors[1],"material_paint_world_end",1,true), - "temporary native transport failure was not isolated") - -require=native_require; dofile=native_dofile -io.write("ew_material_stock_transport=PASS refreshed_chunks=true stock_relay=true no_cut_command=true\n") diff --git a/metamorph_creative_menu/tests/ew_perk_sync_patch_mock.lua b/metamorph_creative_menu/tests/ew_perk_sync_patch_mock.lua index abe57a8..4519bf9 100644 --- a/metamorph_creative_menu/tests/ew_perk_sync_patch_mock.lua +++ b/metamorph_creative_menu/tests/ew_perk_sync_patch_mock.lua @@ -1,7 +1,6 @@ local root=assert(arg[1]) local patches=assert(dofile(root..'/files/integrations/ew/resilience_patches.lua')) local fixture=[[ -local perk_fns = {} local perks_to_ignore = { HOMUNCULUS = true, } @@ -9,22 +8,6 @@ local global_perks = { GOLD_IS_FOREVER = true, PEACE_WITH_GODS = true, } -local perk_list = { - { id = "GOLD_IS_FOREVER" }, - { id = "TEST" }, -} -function perk_fns.get_my_perks() - local perks = {} - for i = 1, #perk_list do - local perk_id = perk_list[i].id - local perk_flag_name = get_perk_picked_flag_name(perk_id) - local perk_count = tonumber(GlobalsGetValue(perk_flag_name .. "_PICKUP_COUNT", "0")) - if perk_count > 0 then - perks[perk_id] = perk_count - end - end - return perks -end function perk_fns.update_perks(perk_data, player_data) local entity = player_data.entity local current_counts = util.get_ent_variable(entity, "ew_current_perks") or {} @@ -35,28 +18,19 @@ function perk_fns.update_perks(perk_data, player_data) end util.set_ent_variable(entity, "ew_current_perks", perk_data) end -return perk_fns ]] local patched,count=patches.patch_peer_perk_isolation_source(fixture) assert(count==1,'peer perk patch did not apply atomically') -assert(string.find(patched,'GOLD_IS_FOREVER = true',1,true),'upstream global-perk policy was destroyed') -assert(not string.find(patched,'local global_perks = {}',1,true),'MCM still replaces EW global-perk policy') -assert(string.find(patched,'mcm_peer_perk_sync_v4',1,true),'critical peer-perk marker missing') -assert(string.find(patched,'mcm_peer_perk_sender_filter_v1',1,true),'sender-side creative filter missing') +assert(string.find(patched,'local global_perks = {}',1,true),'global ownership table was not isolated') +assert(string.find(patched,'mcm_peer_perk_sync_v3',1,true),'critical peer-perk marker missing') assert(string.find(patched,'mcm_peer_perk_removal_v1',1,true),'removal normalization missing') assert(string.find(patched,'mcm_peer_perk_remote_safety_v1',1,true),'remote safety policy missing') for _,perk_id in ipairs({'CORDYCEPS','ABILITY_ACTIONS_MATERIALIZED','RESPAWN','SAVING_GRACE'}) do assert(string.find(patched,perk_id..' = true',1,true),'unsafe remote mechanic not isolated: '..perk_id) end -local globals={ - PERK_PICKED_GOLD_IS_FOREVER_PICKUP_COUNT='2', - PERK_PICKED_TEST_PICKUP_COUNT='2', - ['mcm_creative_perk_hidden_count_v1:GOLD_IS_FOREVER']='1', - ['mcm_creative_perk_hidden_count_v1:TEST']='1', -} -function GlobalsGetValue(name,default) return globals[name] or default end -function get_perk_picked_flag_name(id) return 'PERK_PICKED_'..id end +-- Execute the relevant patched update function in a tiny EW-like environment: omitting +-- TEST from the next packet must become TEST=0 and enter EW's existing negative-diff path. local current={TEST=1,KEEP=1} local killed=0 util={ @@ -64,13 +38,13 @@ util={ set_ent_variable=function(_,_,value) current=value end, } EntityKill=function() killed=killed+1 end -local module=assert(load(patched))() -local advertised=module.get_my_perks() -assert(advertised.GOLD_IS_FOREVER==1,'tracked creative global perk was still advertised to stock EW peer') -assert(advertised.TEST==2,'non-global creative perk was incorrectly hidden from remote replica') -module.update_perks({KEEP=1},{entity=77}) +perk_fns={} +local update_source=string.match(patched,'function perk_fns%.update_perks.-\nend') +assert(type(update_source)=='string','patched update function not found') +assert(load(update_source))() +perk_fns.update_perks({KEEP=1},{entity=77}) assert(current.TEST==0,'removed perk id was not preserved as explicit zero') assert(killed==1,'EW negative-diff cleanup path did not run for removal') local patched_again,count_again=patches.patch_peer_perk_isolation_source(patched) assert(count_again==0 and patched_again==patched,'peer perk patch is not idempotent') -print('ew_perk_sync_patch=PASS upstream_globals_preserved=true sender_filter=true stock_peer_safe=true removals_existing_path=true') +print('ew_perk_sync_patch=PASS peer_local=true removals_propagate_via_existing_ew_path=true') diff --git a/metamorph_creative_menu/tests/ew_perk_visibility_mock.lua b/metamorph_creative_menu/tests/ew_perk_visibility_mock.lua deleted file mode 100644 index 2d16120..0000000 --- a/metamorph_creative_menu/tests/ew_perk_visibility_mock.lua +++ /dev/null @@ -1,17 +0,0 @@ -local root=assert(arg[1], 'root required') -local native_dofile=dofile -local globals={} -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/integrations/ew/runtime.lua' then return {enabled=function() return true end} end - return native_dofile(path) -end -function GlobalsSetValue(k,v) globals[k]=tostring(v) end -function GlobalsGetValue(k,d) return globals[k] or d end -METAMORPH_CREATIVE_MENU_EW_PERK_VISIBILITY=nil -local visibility=assert(native_dofile(root..'/files/integrations/ew/perk_visibility.lua')) -local tx={source_count=function(perk_id,source,player) assert(perk_id=='GOLD_IS_FOREVER' and source=='mcm_creative' and player==7); return 2 end} -assert(visibility.refresh('GOLD_IS_FOREVER',7,tx)==true,'visibility refresh failed') -assert(globals['mcm_creative_perk_hidden_count_v1:GOLD_IS_FOREVER']=='2','creative hidden count not published') -assert(visibility.hidden_count('GOLD_IS_FOREVER')==2,'published hidden count did not roundtrip') -assert(visibility.publish('GOLD_IS_FOREVER',-4)==true and visibility.hidden_count('GOLD_IS_FOREVER')==0,'hidden count was not clamped') -print('ew_perk_visibility=PASS source_count_published=true bounded_nonnegative=true') diff --git a/metamorph_creative_menu/tests/ew_polymorph_profile_patch_mock.lua b/metamorph_creative_menu/tests/ew_polymorph_profile_patch_mock.lua deleted file mode 100644 index 9ec9cf6..0000000 --- a/metamorph_creative_menu/tests/ew_polymorph_profile_patch_mock.lua +++ /dev/null @@ -1,89 +0,0 @@ -local root = assert(arg[1], "root required") -local patches = assert(dofile(root .. "/files/integrations/ew/resilience_patches.lua")) - -local source = [[ -local rpc = { change_entity = function(value) LAST_SENT = value end } -local module = {} -ctx = { my_player = { entity = 7 } } -util = { - serialize_entity = function(entity) assert(entity == 7); return "payload123" end, - deserialize_entity = function(data) assert(data == "payload123"); return 8 end, - get_ent_health = function() return 1,1,true end, -} -local gameover_requested = false -function module.on_world_update() - if ctx.my_player.currently_polymorphed then - local hp, _, has_hp_component = util.get_ent_health(ctx.my_player.entity) - if has_hp_component and hp <= 0 and not gameover_requested then - ctx.cap.health.on_poly_death() - gameover_requested = true - end - else - gameover_requested = false - end -end -local function send_poly() - rpc.change_entity({ data = util.serialize_entity(ctx.my_player.entity) }) -end -local function apply_seri_ent(player_data, seri_ent) - if seri_ent ~= nil then - local ent = util.deserialize_entity(seri_ent.data) - LAST_RECEIVED = ent - end -end -module.send_poly = send_poly -module.apply_seri_ent = apply_seri_ent -return module -]] - -local patched, count = patches.patch_polymorph_profile_source(source) -assert(count == 1, "polymorph profiler patch did not apply") -assert(string.find(patched, "mcm_poly_profile_v1", 1, true), "profiler marker missing") -assert(string.find(patched, "mcm_ew_poly_serialize", 1, true), "serialize metric missing") -assert(string.find(patched, "mcm_ew_poly_deserialize", 1, true), "deserialize metric missing") -local again, again_count = patches.patch_polymorph_profile_source(patched) -assert(again_count == 0 and again == patched, "profiler patch was not idempotent") -local unchanged, missing_count = patches.patch_polymorph_profile_source("local module = {}\nreturn module") -assert(missing_count == 0 and unchanged == "local module = {}\nreturn module", "partial anchor match modified source") - -local globals = {} -local clock = {1.000, 1.025, 2.000, 2.150} -local clock_i = 0 -GameGetRealWorldTimeSinceStarted = function() - clock_i = clock_i + 1 - return clock[clock_i] -end -GameGetFrameNum = function() return 77 end -GlobalsSetValue = function(key, value) globals[key] = tostring(value) end -EntityGetComponentIncludingDisabled = function(entity, component_type) - if component_type == "VariableStorageComponent" then return {91} end - return {} -end -ComponentGetValue2 = function(component, field) - assert(component == 91) - if field == "name" then return "metamorph_creative_menu_network_source" end - if field == "value_string" then return "data/entities/animals/boss_dragon.xml" end -end -EntityGetFilename = function(entity) return entity == 7 and "sender.xml" or "receiver.xml" end -EntityHasTag = function(entity, tag) return tag == "metamorph_creative_menu_network_form" end -EntityGetFirstComponentIncludingDisabled = function(entity, component_type) - if component_type == "BossDragonComponent" then return 123 end - return nil -end - -local chunk, err = load(patched, "patched_polymorph", "t", _G) -assert(chunk, err) -local module = assert(chunk()) -module.send_poly() -assert(LAST_SENT and LAST_SENT.data == "payload123", "serialized payload changed") -module.apply_seri_ent({}, {data="payload123"}) -assert(LAST_RECEIVED == 8, "deserialized entity changed") -assert(globals.mcm_ew_poly_serialize_bytes_v1 == "10", "serialize bytes metric wrong") -assert(globals.mcm_ew_poly_deserialize_bytes_v1 == "10", "deserialize bytes metric wrong") -assert(tonumber(globals.mcm_ew_poly_serialize_ms_v1) == 25, "serialize timing metric wrong") -assert(tonumber(globals.mcm_ew_poly_deserialize_ms_v1) == 150, "deserialize timing metric wrong") -assert(globals.mcm_ew_poly_serialize_kind_v1 == "boss_dragon", "serialize kind missing") -assert(globals.mcm_ew_poly_deserialize_kind_v1 == "boss_dragon", "deserialize kind missing") -assert(globals.mcm_ew_poly_deserialize_source_v1 == "data/entities/animals/boss_dragon.xml", "source identity missing") -assert(globals.mcm_ew_poly_deserialize_frame_v1 == "77", "profile frame missing") -print("ew_polymorph_profile_patch=PASS payload_unchanged=true isolated_poly_path=true") diff --git a/metamorph_creative_menu/tests/ew_protocol_mock.lua b/metamorph_creative_menu/tests/ew_protocol_mock.lua index ec17ce0..5707f56 100644 --- a/metamorph_creative_menu/tests/ew_protocol_mock.lua +++ b/metamorph_creative_menu/tests/ew_protocol_mock.lua @@ -1,8 +1,8 @@ local root=assert(arg[1]) local expected={ - "apply_world_rules","request_world_rules","sync_qa_state","request_player_companion", + "apply_world_rules","request_world_rules","reserved_protocol_slot_3","request_player_companion", "sync_form_pose","remove_global_perk","apply_weather_state","request_weather_state", - "retire_possession_target","announce_light_form_protocol","sync_material_paint" + "retire_possession_target","announce_light_form_protocol" } local recorded={} local rpc=setmetatable({ @@ -13,9 +13,7 @@ local rpc=setmetatable({ if type(v)=="function" then recorded[#recorded+1]=k end end}) local ew_api={new_rpc_namespace=function(ns) - if ns=="metamorph_creative_menu:world_rules:v4:" then return rpc end - assert(ns=="metamorph_creative_menu:teleport:v1:","namespace="..tostring(ns)) - return setmetatable({opts_reliable=function() end,opts_everywhere=function() end},{__newindex=rawset}) + assert(ns=="metamorph_creative_menu:world_rules:v3:","namespace="..tostring(ns)); return rpc end} GlobalsGetValue=function(_,default) return default end GlobalsSetValue=function() end @@ -38,4 +36,4 @@ local mod=assert(loadfile(root.."/files/integrations/ew/bootstrap.lua"))() assert(type(mod)=="table" and type(mod.on_world_update)=="function") assert(#recorded==#expected,"rpc count="..#recorded) for i,name in ipairs(expected) do assert(recorded[i]==name,string.format("slot %d got %s expected %s",i,tostring(recorded[i]),name)) end -print("ew_protocol_order=PASS namespace=v4 slots="..table.concat(recorded,",")) +print("ew_protocol_order=PASS namespace=v3 slots="..table.concat(recorded,",")) diff --git a/metamorph_creative_menu/tests/ew_resilience_status_mock.lua b/metamorph_creative_menu/tests/ew_resilience_status_mock.lua index e7cd562..f3e8a33 100644 --- a/metamorph_creative_menu/tests/ew_resilience_status_mock.lua +++ b/metamorph_creative_menu/tests/ew_resilience_status_mock.lua @@ -1,79 +1,54 @@ local root = assert(arg[1], "root required") local native_dofile = dofile dofile = function(path) - if path == "mods/metamorph_creative_menu/files/core/global_text.lua" then - return native_dofile(root .. "/files/core/global_text.lua") - end if path == "mods/metamorph_creative_menu/files/integrations/ew/resilience_patches.lua" then return native_dofile(root .. "/files/integrations/ew/resilience_patches.lua") end - if path == "mods/metamorph_creative_menu/dev_mode.lua" then return 1 end return native_dofile(path) end local world_path = "mods/quant.ew/files/system/world_sync/world_sync.lua" -local material_scene_path = "mods/quant.ew/files/system/wang_hooks/synced_pixel_scenes.lua" local perk_path = "mods/quant.ew/files/core/perk_fns.lua" local helper_path = "mods/quant.ew/files/system/homunculus/homunculus.lua" local mutation_path = "mods/quant.ew/files/system/perk_patches/perk_patches.lua" -local polymorph_path = "mods/quant.ew/files/system/polymorph/polymorph.lua" local contents = { - [world_path] = "-- mcm_poly_world_sync_v5\nreturn {}", - [material_scene_path] = "-- mcm_material_brush_pixel_scene_v1\nreturn {}", - [perk_path] = "local global_perks = {} -- mcm_peer_perk_sync_v4\n-- mcm_peer_perk_removal_v1\nreturn {}", + [world_path] = "-- mcm_poly_world_sync_v3\nreturn {}", + [perk_path] = "local global_perks = {} -- mcm_peer_perk_isolation_v1 mcm_peer_perk_sync_v3\n-- mcm_peer_perk_removal_v1\nreturn {}", [helper_path] = "-- mcm_perk_helper_sync_v1\nreturn {}", [mutation_path] = "-- mcm_perk_mutation_sync_v1\nreturn {}", - [polymorph_path] = "-- mcm_poly_profile_v1\n-- mcm_poly_death_intercept_v1\nreturn {}", } local globals = {} -local game_messages = {} local ew_enabled = true ModIsEnabled = function(id) return id == "quant.ew" and ew_enabled end ModTextFileGetContent = function(path) return contents[path] or "-- unrelated file" end ModTextFileSetContent = function(path, content) contents[path] = content end GlobalsSetValue = function(key, value) globals[key] = tostring(value) end -GamePrint = function(message) game_messages[#game_messages + 1] = tostring(message) end local resilience = assert(native_dofile(root .. "/files/integrations/ew/resilience.lua")) resilience.pre_init() local status = resilience.critical_patch_status() -assert(status.form_death == "already_present", "existing form-death patch was not recognized") assert(status.world_sync == "already_present", "existing world-sync patch was not recognized") -assert(status.material_scenes == "already_present", "existing material-scene patch was not recognized") assert(status.peer_perks == "already_present", "existing peer-perk patch was not recognized") assert(status.perk_helpers == "already_present", "existing perk-helper patch was not recognized") assert(status.perk_mutations == "already_present", "existing perk-mutation patch was not recognized") assert(globals.mcm_compat_world_sync_patch_v1 == "already_present", "world-sync patch status was not published") -assert(globals.mcm_compat_form_death_patch_v1 == "already_present", "form-death patch status was not published") -assert(globals.mcm_compat_material_scene_patch_v1 == "already_present", "material-scene patch status was not published") assert(globals.mcm_compat_peer_perk_patch_v1 == "already_present", "peer-perk patch status was not published") assert(globals.mcm_compat_perk_helper_patch_v1 == "already_present", "perk-helper patch status was not published") assert(globals.mcm_compat_perk_mutation_patch_v1 == "already_present", "perk-mutation patch status was not published") -assert(globals.mcm_compat_poly_profile_patch_v1 == "already_present", "polymorph profiler patch status was not published") contents[world_path] = "-- incompatible world sync source" -contents[material_scene_path] = "-- incompatible material scene source" contents[perk_path] = "-- incompatible perk source" contents[helper_path] = "-- incompatible helper source" contents[mutation_path] = "-- incompatible mutation source" -contents[polymorph_path] = "-- incompatible polymorph source" resilience.pre_init() status = resilience.critical_patch_status() -assert(status.form_death == "anchor_mismatch", "form-death anchor mismatch was not surfaced") assert(status.world_sync == "anchor_mismatch", "world-sync anchor mismatch was not surfaced") -assert(status.material_scenes == "anchor_mismatch", "material-scene anchor mismatch was not surfaced") assert(status.peer_perks == "anchor_mismatch", "peer-perk anchor mismatch was not surfaced") assert(status.perk_helpers == "anchor_mismatch", "perk-helper anchor mismatch was not surfaced") assert(status.perk_mutations == "anchor_mismatch", "perk-mutation anchor mismatch was not surfaced") -resilience.post_init() -assert(#game_messages >= 6, "critical EW compatibility failures remained hidden from the player") -local visible = table.concat(game_messages, "\n") -assert(string.find(visible, "world_sync", 1, true), "world-sync failure was not shown to the player") -assert(string.find(visible, "form_death", 1, true), "form-death failure was not shown to the player") ew_enabled = false resilience.pre_init() status = resilience.critical_patch_status() -assert(status.form_death == "disabled" and status.world_sync == "disabled" and status.material_scenes == "disabled" and status.peer_perks == "disabled" and status.perk_helpers == "disabled" and status.perk_mutations == "disabled", "disabled EW state was not explicit") -assert(globals.mcm_compat_poly_profile_patch_v1 == "disabled", "disabled profiler patch state was not explicit") +assert(status.world_sync == "disabled" and status.peer_perks == "disabled" and status.perk_helpers == "disabled" and status.perk_mutations == "disabled", "disabled EW state was not explicit") print("ew_resilience_status=PASS verified_critical_patches=true") diff --git a/metamorph_creative_menu/tests/ew_teleport_bring_mock.lua b/metamorph_creative_menu/tests/ew_teleport_bring_mock.lua deleted file mode 100644 index 25955a9..0000000 --- a/metamorph_creative_menu/tests/ew_teleport_bring_mock.lua +++ /dev/null @@ -1,99 +0,0 @@ -local root = assert(arg[1], "root required") - -local globals = {} -local sends = {} -local errors = {} -local receiver = nil -local camera = {} -local loaded = true -local frame = 20 -local transforms = { [10]={0,0}, [20]={100,100}, [30]={220,100} } - -ctx = { - my_id="peer-target", - my_player={entity=10}, - players={ - ["peer-target"]={entity=10}, - ["peer-source"]={entity=20}, - ["peer-other"]={entity=30}, - }, - rpc_player_data={entity=20}, -} - -function GlobalsGetValue(key, fallback) return globals[key] or fallback end -function GlobalsSetValue(key, value) globals[key]=tostring(value) end -function GameGetFrameNum() return frame end -function DoesWorldExistAt() return loaded end -function FindFreePositionForBody(x, y, _, _, radius) return x + 2, y - radius end -function EntityGetIsAlive(entity) return transforms[entity] ~= nil end -function EntityGetTransform(entity) return transforms[entity][1], transforms[entity][2] end -function EntitySetTransform(entity, x, y) transforms[entity]={x,y} end -function EntityGetFirstComponentIncludingDisabled() return 70 end -function ComponentSetValue2() end -function GameSetCameraPos(x, y) camera[#camera+1]={x,y} end - -local rpc_proxy = {} -setmetatable(rpc_proxy, { - __newindex=function(table_value, key, value) - if key == "request_bring" then - receiver = value - rawset(table_value, key, function(...) - sends[#sends+1]={...} - end) - else - rawset(table_value, key, value) - end - end, -}) -function rpc_proxy.opts_reliable() end -function rpc_proxy.opts_everywhere() end - -local ew_api = { - new_rpc_namespace=function(namespace) - assert(namespace == "metamorph_creative_menu:teleport:v1:", "teleport RPC namespace changed") - return rpc_proxy - end, -} -local common = { - finite_number=function(value) - value=tonumber(value) - return value ~= nil and value == value and value ~= math.huge and value ~= -math.huge - end, - report_error=function(scope, reason) errors[#errors+1]={scope,reason} end, -} - -local bridge = assert(loadfile(root .. "/files/integrations/ew/bridge/teleport.lua"))() -bridge.register(ew_api, common) -assert(type(receiver) == "function", "bring receiver was not registered") - -globals.mcm_teleport_bring_outbox_seq_v1="1" -globals.mcm_teleport_bring_outbox_peer_v1_1="peer-other" -globals.mcm_teleport_bring_outbox_target_x_v1_1="220" -globals.mcm_teleport_bring_outbox_target_y_v1_1="100" -globals.mcm_teleport_bring_outbox_dest_x_v1_1="18" -globals.mcm_teleport_bring_outbox_dest_y_v1_1="0" -bridge.update() -assert(#sends == 1 and sends[1][1] == "peer-other" and sends[1][2] == 18 and sends[1][3] == 0, - "bring outbox was not routed to the selected peer") -assert(globals.mcm_teleport_bring_outbox_ack_v1 == "1" and #errors == 0, - "bring outbox was not acknowledged atomically") - -local camera_before = #camera -receiver("peer-target", 500, 500) -assert(#camera == camera_before, "untrusted destination far from sender was accepted") -receiver("another-peer", 118, 100) -assert(#camera == camera_before, "bring request was accepted by a non-target peer") - -loaded = false -receiver("peer-target", 118, 100) -assert(#camera == camera_before + 1, "valid bring request did not begin streaming") -bridge.update() -assert(transforms[10][1] == 0 and transforms[10][2] == 0, - "player moved before the destination was loaded") -loaded = true -frame = frame + 1 -bridge.update() -assert(transforms[10][1] == 120 and transforms[10][2] == 91, - "target peer did not use the engine-provided free position") - -print("ew_teleport_bring=PASS targeted=true sender_bounded=true streaming=true safe_position=true") diff --git a/metamorph_creative_menu/tests/ew_wand_pickup_guard_mock.lua b/metamorph_creative_menu/tests/ew_wand_pickup_guard_mock.lua deleted file mode 100644 index 5777f19..0000000 --- a/metamorph_creative_menu/tests/ew_wand_pickup_guard_mock.lua +++ /dev/null @@ -1,27 +0,0 @@ -local root = assert(arg[1], "root required") -local patches = assert(dofile(root.."/files/integrations/ew/resilience_patches.lua")) -local source = [[if not CrossCall("ew_is_wand_pickup") then - EntityKill(GetUpdatedEntityID()) -end]] -local changed, count = patches.patch_wand_pickup_killself_source(source) -assert(count==1 and string.find(changed,"mcm_wand_pickup_crosscall_guard_v1",1,true),"guard patch was not applied") - -local killed=0 -local env={ - CrossCall=function() error("Requested function does not exist") end, - EntityKill=function(entity) assert(entity==77); killed=killed+1 end, - GetUpdatedEntityID=function() return 77 end, - pcall=pcall, -} -assert(load(changed,"guard","t",env))() -assert(killed==1,"missing EW callback left the helper alive") - -env.CrossCall=function() return true end -assert(load(changed,"guard","t",env))() -assert(killed==1,"real wand pickup was incorrectly retired") - -env.CrossCall=function() return false end -assert(load(changed,"guard","t",env))() -assert(killed==2,"normal false result no longer retires the helper") - -io.write("ew_wand_pickup_guard=PASS missing_callback_retired=true registered_semantics_preserved=true\n") diff --git a/metamorph_creative_menu/tests/ew_world_sync_patch_mock.lua b/metamorph_creative_menu/tests/ew_world_sync_patch_mock.lua deleted file mode 100644 index f045c72..0000000 --- a/metamorph_creative_menu/tests/ew_world_sync_patch_mock.lua +++ /dev/null @@ -1,74 +0,0 @@ -local root = assert(arg[1], "root required") -local patches = assert(loadfile(root .. "/files/integrations/ew/resilience_patches.lua"))() - --- Minimal, syntactically valid fixture containing the current Entangled Worlds --- world_sync.lua anchors. Real Windows installs can expose this source through Noita's --- VFS with CRLF even though the repository uses LF; both forms must patch identically. -local fixture = [[local world_sync = {} -local iter_slow_2 = 0 -local function send_chunks(cx, cy) - local str = "chunk" - if cx ~= nil then - if str ~= nil then - net.proxy_bin_send(KEY_WORLD_FRAME, str) - end - end -end -local int = 4 -- ctx.proxy_opt.world_sync_interval -local function get_all_chunks() end -function world_sync.on_world_update() - local px, py = EntityGetTransform(ctx.my_player.entity) - local cx, cy = 0, 0 - local pos_data = "0" - local ocx, ocy = math.floor(px / CHUNK_SIZE), math.floor(py / CHUNK_SIZE) - local n = 0 - if true then - if ctx.spectating_over_peer_id ~= nil and ctx.spectating_over_peer_id ~= ctx.my_id then - if GameGetFrameNum() % 3 ~= 2 then - get_all_chunks(cx, cy, pos_data, 16, false) - else - get_all_chunks(ocx, ocy, pos_data, 16, true) - end - else - wait = GameGetFrameNum() + 30 - end - end -end - -local PixelRun_const_ptr = ffi.typeof("struct PixelRun const*") -function world_sync.handle_world_data(datum) - local grid_world = world_ffi.get_grid_world() -end -return world_sync -]] - -local function verify(source, label) - local patched, count = patches.patch_world_sync_source(source, true) - assert(count == 6, label .. " source did not apply all world-sync edits") - assert(string.find(patched, "mcm_poly_world_sync_v5", 1, true), label .. " marker missing") - assert(string.find(patched, "mcm_boss_death_pressure_active", 1, true), label .. " death backpressure missing") - assert(string.find(patched, "mcm_heavy_polymorph_active", 1, true), label .. " heavy-form backpressure missing") - assert(string.find(patched, 'EntityGetFirstComponentIncludingDisabled(entity, "BossDragonComponent")', 1, true), label .. " BossDragon heavy-form detection missing") - assert(string.find(patched, 'EntityGetFirstComponentIncludingDisabled(entity, "WormComponent")', 1, true), label .. " Worm heavy-form detection missing") - assert(string.find(patched, "mcm_poly_scheduler_paused", 1, true), label .. " scheduler pause metric missing") - assert(string.find(patched, "mcm_trail_queue", 1, true) == nil, label .. " legacy trail queue survived") - assert(string.find(patched, "mcm_drain_trail", 1, true) == nil, label .. " legacy trail drain survived") - assert(string.find(patched, "mcm_world_sync_trail_backlog_v1", 1, true), label .. " compatibility backlog metric missing") - assert(string.find(patched, "mcm_recv_chunks = mcm_recv_chunks + 1", 1, true), label .. " receive metrics missing") - assert(string.find(patched, "\r", 1, true) == nil, label .. " patch did not canonicalize line endings") - local compiled, failure = load(patched, "@patched_world_sync.lua") - assert(compiled ~= nil, label .. " patched source is invalid: " .. tostring(failure)) -end - -verify(fixture, "LF") -verify((string.gsub(fixture, "\n", "\r\n")), "CRLF") - -local incompatible = "local iter_slow_2 = 0\r\n-- incompatible EW source\r\n" -local unchanged, count = patches.patch_world_sync_source(incompatible) -assert(count == 0 and unchanged == incompatible, "partial patch modified incompatible source") - -local release_patched, release_count = patches.patch_world_sync_source(fixture, false) -assert(release_count == 6 and string.find(release_patched, "local EWCM_METRICS_ENABLED = false", 1, true), - "release world-sync patch did not disable telemetry") - -print("ew_world_sync_patch=PASS lf=true crlf=true all_or_nothing=true") diff --git a/metamorph_creative_menu/tests/false_recovery_mock.lua b/metamorph_creative_menu/tests/false_recovery_mock.lua deleted file mode 100644 index 5d0175c..0000000 --- a/metamorph_creative_menu/tests/false_recovery_mock.lua +++ /dev/null @@ -1,14 +0,0 @@ -local root=assert(arg[1]) -local globals={} -function GlobalsGetValue(k,f) local v=globals[k]; if v==nil then return f end; return v end -function GlobalsSetValue(k,v) globals[k]=tostring(v) end -METAMORPH_CREATIVE_MENU_WORLD_RULE_RECOVERY=nil -local recovery=assert(loadfile(root..'/files/features/world_rules/recovery.lua'))() -assert(recovery.capture('world','flag',true,'meta')==true) -assert(recovery.update_last('world','flag',false)==true) -local record=assert(recovery.read('world','flag')) -assert(record.original==true and record.last==false and record.phase=='owned','saved false was lost from recovery journal') -recovery.mark_partial('world','flag',false) -record=assert(recovery.read('world','flag')) -assert(record.last==false and record.phase=='partial','false was lost in partial recovery record') -print('false_recovery=PASS false_roundtrip=true') diff --git a/metamorph_creative_menu/tests/fixed_numeric_editor_bounds_mock.lua b/metamorph_creative_menu/tests/fixed_numeric_editor_bounds_mock.lua deleted file mode 100644 index 29e17ef..0000000 --- a/metamorph_creative_menu/tests/fixed_numeric_editor_bounds_mock.lua +++ /dev/null @@ -1,50 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile -local typed='1000' -local applied=nil -local focused=true -local ui={ - gui=function() return 1 end, - white_text=function() end, - wrapped_text=function() end, - text_width=function(text) return #tostring(text)*5 end, - button=function() return false end, - text_input=function(before,width,max_chars,key,options) - return typed,focused - end, -} -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/ui/runtime.lua' then return ui end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -GuiLayoutBeginHorizontal=function() end -GuiLayoutAddHorizontalSpacing=function() end -GuiLayoutEnd=function() end - -METAMORPH_CREATIVE_MENU_FIXED_NUMERIC_EDITOR=nil -local numeric=assert(native_dofile(root..'/files/ui/components/fixed_numeric_editor.lua')) -local changed,reason,draft=numeric.draw('slots','SLOTS',10,{ - integer=true,min=1,max=64,value_width=58,value_chars=8,max_width=300, - on_apply=function(value) applied=value; return true,'ok' end, -}) -assert(changed==true and reason==nil,'bounded typed value was not applied') -assert(applied==64,'typed value was not clamped to 64: '..tostring(applied)) -assert(draft=='64','visible draft did not normalize to 64: '..tostring(draft)) - --- A button-side apply must not be overwritten afterwards by the unchanged text draft. -typed='64'; focused=false; applied=nil -local click_once=true -ui.button=function(_,_,label) - if click_once and string.find(label,'>') then click_once=false; return true end - return false -end -numeric.reset('slots2') -local changed2,reason2,draft2=numeric.draw('slots2','SLOTS',64,{ - integer=true,min=1,max=64,value_width=58,value_chars=8,max_width=300, - on_apply=function(value) applied=value; return true,'ok' end, -}) -assert(changed2==true and reason2==nil and applied==64,'increment at cap did not stay at 64') -assert(draft2=='64','button apply was overwritten by stale text draft') -print('fixed_numeric_editor_bounds=PASS typed_clamp=64 button_clamp=64 draft_sync=true') diff --git a/metamorph_creative_menu/tests/fixed_numeric_focus_draft_mock.lua b/metamorph_creative_menu/tests/fixed_numeric_focus_draft_mock.lua deleted file mode 100644 index ed514ec..0000000 --- a/metamorph_creative_menu/tests/fixed_numeric_focus_draft_mock.lua +++ /dev/null @@ -1,77 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile -local typed='-' -local focused=true -local last_blur=nil -local focus_key='numeric.focus' -local applied=nil -local blur_calls=0 -local allowed_seen=nil - -local ui={ - gui=function() return 1 end, - white_text=function() end, - wrapped_text=function() end, - text_width=function(text) return #tostring(text)*5 end, - button=function() return false end, - text_input=function(before,width,max_chars,key,options) - focus_key=key - last_blur=options.on_blur - allowed_seen=options.allowed_characters - return typed,focused - end, - text_input_focus_key=function() return focus_key end, - blur_text_input=function(reason) - blur_calls=blur_calls+1 - if last_blur then last_blur(reason,focus_key) end - focus_key=nil - return true - end, -} -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/ui/runtime.lua' then return ui end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -GuiLayoutBeginHorizontal=function() end -GuiLayoutAddHorizontalSpacing=function() end -GuiLayoutEnd=function() end - -METAMORPH_CREATIVE_MENU_FIXED_NUMERIC_EDITOR=nil -local numeric=assert(native_dofile(root..'/files/ui/components/fixed_numeric_editor.lua')) -local opts={integer=false,value_width=58,value_chars=8,max_width=300, - on_apply=function(value) applied=value; return true end} - --- Intermediate text remains a draft for as long as logical focus remains, regardless of hover. -local changed,reason,draft,is_focused=numeric.draw('numeric.focus','VALUE',10,opts) -assert(not changed and reason==nil and draft=='-' and is_focused,'unfinished draft did not remain while focused') -assert(allowed_seen=='0123456789.-','numeric field lost its restricted native character set') -typed='-'; focused=true -changed,reason,draft,is_focused=numeric.draw('numeric.focus','VALUE',10,opts) -assert(draft=='-' and is_focused,'unfinished numeric draft reset while logical focus remained') - --- Context blur cancels only the unfinished draft back to the model's canonical value. -assert(type(last_blur)=='function','numeric field did not register blur resolution') -last_blur('tab_changed','numeric.focus') -typed='10'; focused=false -changed,reason,draft,is_focused=numeric.draw('numeric.focus','VALUE',10,opts) -assert(draft=='10' and not is_focused and applied==nil,'tab/context blur did not cancel unfinished draft safely') - --- A valid draft commits immediately and remains canonical after a later blur. -typed='12'; focused=true -changed,reason,draft,is_focused=numeric.draw('numeric.focus','VALUE',10,opts) -assert(changed and applied==12 and draft=='12','valid numeric draft did not commit immediately') -typed='12'; focused=true -numeric.draw('numeric.focus','VALUE',12,opts) -- refresh canonical/blur callback from committed model -assert(type(last_blur)=='function'); last_blur('pointer','numeric.focus') -typed='12'; focused=false -local _,_,draft_after=numeric.draw('numeric.focus','VALUE',12,opts) -assert(draft_after=='12','blur rolled back an already committed numeric value') - --- Wand-context reset blurs a matching active stat field before discarding its draft. -focus_key='wand.stat.7.mana'; last_blur=function() end -numeric.reset('wand.stat.') -assert(blur_calls==1,'wand numeric reset did not release matching focus before discarding drafts') - -print('fixed_numeric_focus_draft=PASS hover_safe=true unfinished_cancel=true committed_preserved=true wand_reset_blurs=true') diff --git a/metamorph_creative_menu/tests/fixtures/ew163_death_notify.lua b/metamorph_creative_menu/tests/fixtures/ew163_death_notify.lua deleted file mode 100644 index f437b1f..0000000 --- a/metamorph_creative_menu/tests/fixtures/ew163_death_notify.lua +++ /dev/null @@ -1,12 +0,0 @@ --- Stock callback from the user-supplied EW 1.6.3, for compatibility regression tests. --- https://github.com/IntQuant/noita_entangled_worlds/blob/v1.6.3/quant.ew/files/system/entity_sync_helper/death_notify.lua -function death(damage_type_bit_field, damage_message, entity_thats_responsible, drop_items) - local ent = GetUpdatedEntityID() - local x, y = EntityGetTransform(ent) - local wait_on_kill = false - local damage = EntityGetFirstComponentIncludingDisabled(ent, "DamageModelComponent") - if damage ~= nil then - wait_on_kill = ComponentGetValue2(damage, "wait_for_kill_flag_on_death") - end - CrossCall("ew_death_notify", ent, wait_on_kill, x, y, EntityGetFilename(ent), entity_thats_responsible) -end diff --git a/metamorph_creative_menu/tests/form_attack_profile_mock.lua b/metamorph_creative_menu/tests/form_attack_profile_mock.lua deleted file mode 100644 index 80a4c52..0000000 --- a/metamorph_creative_menu/tests/form_attack_profile_mock.lua +++ /dev/null @@ -1,79 +0,0 @@ -local root=assert(arg[1],"root required") -local profile=dofile(root.."/files/features/forms/attack_profile.lua") - -local shotgun=profile.normalize({ - source_kind="animal", component=20, - path="data/entities/projectiles/buckshot.xml", - frames=100, global_frames=0, - count_min=3, count_max=4, - action_frame=4, animation="attack_ranged", - min_distance=0, max_distance=180, - offset_x=0, offset_y=-7, -}) -assert(shotgun.path=="data/entities/projectiles/buckshot.xml","projectile path changed") -assert(shotgun.count_min==3 and shotgun.count_max==4,"shotgun count range lost") -assert(shotgun.frames==100 and shotgun.global_frames==0,"cooldowns changed") -assert(shotgun.action_frame==4 and shotgun.animation=="attack_ranged","authored action timing lost") - -local count=profile.projectile_count(shotgun,function(a,b) assert(a==3 and b==4); return 4 end) -assert(count==4,"projectile count did not use authored random range") -assert(profile.animation_delay_frames(shotgun,0.15,60)==36,"animation frame timing was treated as game frames") - -local close=profile.normalize({source_kind="ai_attack",component=31,path="same.xml",min_distance=0,max_distance=40,use_probability=100,frames=80,global_frames=0,count_min=1,count_max=1,root_offset_x=2,root_offset_y=3}) -local far=profile.normalize({source_kind="ai_attack",component=32,path="same.xml",min_distance=100,max_distance=300,use_probability=100,frames=50,global_frames=50,count_min=2,count_max=2,root_offset_x=7,root_offset_y=9}) -assert(close.component~=far.component and close.path==far.path,"profiles with same projectile must remain distinct") -assert(close.root_offset_x==2 and far.root_offset_x==7,"root offsets lost") -local selected_close=profile.select_primary({close,far},20,function() return 1 end) -local selected_far=profile.select_primary({close,far},180,function() return 1 end) -assert(selected_close==close,"close-range authored attack not selected") -assert(selected_far==far,"far-range authored attack not selected") - --- Player input is not target acquisition: outside every authored AI interval, use the --- nearest authored attack profile instead of dropping the click. The actual shot target --- remains the cursor and is not clamped to the profile range. -local fallback=profile.select_primary({close,far},70,function() return 1 end) -assert(fallback==close,"gap-range player input did not choose a nearest authored attack") -local very_far=profile.select_primary({close,far},1000,function() return 1 end) -assert(very_far==far,"far cursor was still blocked by the AI max-distance radius") -local min_only=profile.normalize({source_kind="ai_attack",component=39,path="minimum.xml",min_distance=30,max_distance=220,use_probability=100}) -assert(profile.select_primary({min_only},0,function() return 1 end)==min_only,"near cursor was blocked by the AI min-distance radius") - - -local probabilistic=profile.normalize({source_kind="ai_attack",component=34,path="chance.xml",min_distance=0,max_distance=100,use_probability=70}) -local calls={} -local declined=profile.select_primary({probabilistic},10,function(a,b) calls[#calls+1]={a,b}; return 71 end) -assert(declined==nil,"use_probability was treated as a weight instead of a probability gate") -assert(#calls==1 and calls[1][1]==1 and calls[1][2]==100,"single probabilistic attack used the wrong probability roll") -local accepted=profile.select_primary({probabilistic},10,function(a,b) return 70 end) -assert(accepted==probabilistic,"authored probability gate rejected its inclusive boundary") - -local always_a=profile.normalize({source_kind="ai_attack",component=35,path="a.xml",min_distance=0,max_distance=100,use_probability=100}) -local always_b=profile.normalize({source_kind="ai_attack",component=36,path="b.xml",min_distance=0,max_distance=100,use_probability=100}) -local picked_second=profile.select_primary({always_a,always_b},10,function(a,b) assert(a==1 and b==2); return 2 end) -assert(picked_second==always_b,"100-percent attacks no longer use a normal candidate choice") - -local guaranteed=profile.normalize({source_kind="ai_attack",component=37,path="guaranteed.xml",min_distance=0,max_distance=100,use_probability=100}) -local optional=profile.normalize({source_kind="ai_attack",component=38,path="optional.xml",min_distance=0,max_distance=100,use_probability=70}) -local rolls={71} -local idx=0 -local keep_guaranteed=profile.select_primary({guaranteed,optional},10,function(a,b) - idx=idx+1 - local v=rolls[idx] - assert(v~=nil,"unexpected RNG call while gating mixed probabilities") - return v -end) -assert(keep_guaranteed==guaranteed,"declined optional attack suppressed an otherwise guaranteed attack") - -local rolls2={70,2} -idx=0 -local optional_selected=profile.select_primary({guaranteed,optional},10,function(a,b) - idx=idx+1 - local v=rolls2[idx] - assert(v~=nil,"unexpected RNG call while selecting passed mixed probabilities") - return v -end) -assert(optional_selected==optional,"passed optional attack did not participate in candidate selection") - -local zero_prob=profile.normalize({source_kind="ai_attack",component=33,path="never.xml",min_distance=0,max_distance=100,use_probability=0}) -assert(profile.select_primary({zero_prob},10,function() return 1 end)==nil,"zero-probability attack became primary") -print("form_attack_profile=PASS count=true timing=true multi=true same_path_distinct=true range_select=true probability_gate=true") diff --git a/metamorph_creative_menu/tests/form_chained_transform_human_frame_mock.lua b/metamorph_creative_menu/tests/form_chained_transform_human_frame_mock.lua deleted file mode 100644 index 0f181d3..0000000 --- a/metamorph_creative_menu/tests/form_chained_transform_human_frame_mock.lua +++ /dev/null @@ -1,162 +0,0 @@ -local root = assert(arg[1], "root required") -local native_dofile = dofile - -METAMORPH_CREATIVE_MENU_FORM_MANAGER = nil -METAMORPH_CREATIVE_MENU_FORM_TRANSITION_QUEUE = nil - -local frame = 100 -local current_player = 1 -local alive = {[1]=true,[10]=true,[11]=true,[90]=true,[91]=true} -local polymorphed = {[10]=true,[11]=true} -local active_poly_component = {[10]=201,[11]=202} -local effect_frames = {[201]=600,[202]=600,[301]=nil,[302]=nil} -local load_calls = {} -local human_seen_frames = {} -local runtime_resets = 0 - -local bridge = { - SerializeEntity = function(entity) - assert(entity == 1, "only the human may be serialized for a new MCM form") - return "human-backup-" .. tostring(frame) - end, - CrossCallAdd = function() return true end, -} - -local human_restore = { - protect_player=function() end, - restore_controls=function(entity) - assert(entity == 1, "controls restored on non-human entity") - return true - end, - polymorph_effect_components=function(entity) - local component = active_poly_component[entity] - return component ~= nil and {component} or {} - end, - serialized_backup_from_effects=function() return "serialized-human" end, - deserialize_backup=function() return 0 end, -} - -local stubs = { - ["mods/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua"]={get=function() return bridge end}, - ["mods/metamorph_creative_menu/files/platform/noita/player_locator.lua"]={get=function() return current_player end}, - ["mods/metamorph_creative_menu/files/platform/noita/keycodes.lua"]={resolve=function() return 15 end}, - ["mods/metamorph_creative_menu/files/features/forms/profile.lua"]={get=function(target) return {target=target} end}, - ["mods/metamorph_creative_menu/files/features/forms/runtime.lua"]={ - update=function() end, - reset=function() runtime_resets=runtime_resets+1 end, - family=function() return "character" end, - draw_health=function() end, - }, - ["mods/metamorph_creative_menu/files/features/forms/exact_effects.lua"]={ - effect_path=function(path) - if path == "data/entities/animals/a.xml" then return "mods/metamorph_creative_menu/files/generated/a.xml" end - if path == "data/entities/animals/b.xml" then return "mods/metamorph_creative_menu/files/generated/b.xml" end - return nil - end, - invalidate_failed_target=function() end, - prepare=function() return 0 end, - prepare_from_catalog=function() return 2 end, - runtime_target=function(path) return path end, - prepared_count=function() return 2 end, - default_duration_frames=function() return 2147480000 end, - }, - ["mods/metamorph_creative_menu/files/features/forms/player_authority.lua"]={switch=function() return true,"confirmed" end}, - ["mods/metamorph_creative_menu/files/features/forms/transform_flash.lua"]={suppress=function() end,restore=function() end}, - ["mods/metamorph_creative_menu/files/features/forms/corpse_service.lua"]={detach=function() return true end,update=function() end}, - ["mods/metamorph_creative_menu/files/features/forms/human_restore.lua"]=human_restore, - ["mods/metamorph_creative_menu/files/features/forms/external_polymorph.lua"]={is_active=function(entity) return polymorphed[entity]==true end,describe=function() return nil end}, - ["mods/metamorph_creative_menu/files/integrations/ew/form_death_channel.lua"]={register=function() return true end}, -} - -dofile=function(path) - if stubs[path] ~= nil then return stubs[path] end - local prefix="mods/metamorph_creative_menu/" - if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -function GameGetFrameNum() return frame end -function EntityGetIsAlive(entity) return alive[entity]==true end -function EntityHasTag(entity, tag) - if tag=="polymorphed_player" then return polymorphed[entity]==true end - if tag=="player_unit" then return entity==1 end - return false -end -function EntityGetTransform(entity) return 100,200 end -function EntityGetComponentIncludingDisabled() return {} end -function EntityGetFirstComponentIncludingDisabled(entity, kind) - if entity==1 and kind=="Inventory2Component" then return 77 end - if entity==90 and kind=="GameEffectComponent" then return 301 end - if entity==91 and kind=="GameEffectComponent" then return 302 end - return 0 -end -function EntityAddTag() end -function EntityAddComponent2() return 401 end -function ComponentGetValue2() return nil end -function ComponentSetValue2(component, field, value) - if field=="frames" then effect_frames[component]=value end -end -function ModDoesFileExist(path) - return path=="data/entities/animals/a.xml" or path=="data/entities/animals/b.xml" -end -function LoadGameEffectEntityTo(player, effect_file) - load_calls[#load_calls+1]={frame=frame,player=player,effect=effect_file} - assert(player==1, "new polymorph effect must only be attached to the human") - if effect_file:match("/a%.xml$") then return 90 end - if effect_file:match("/b%.xml$") then return 91 end - return 0 -end -function print() end - -local manager=assert(native_dofile(root.."/files/features/forms/manager.lua")) -local transition_queue=assert(native_dofile(root.."/files/features/forms/transition_queue.lua")) - --- Any newer direct human selection supersedes an older queued target. -transition_queue.schedule("data/entities/animals/b.xml",nil,{requested_target="data/entities/animals/b.xml"},90) -assert(transition_queue.has_pending()==true,"queue fixture missing") - --- Human -> A remains immediate and clears that stale intent. -local ok,reason=manager.transform_creature(1,"data/entities/animals/a.xml",nil,false,{requested_target="data/entities/animals/a.xml"}) -assert(ok==true, "initial transform failed: "..tostring(reason)) -assert(#load_calls==1 and load_calls[1].frame==100, "initial human transform was unexpectedly deferred") -assert(transition_queue.has_pending()==false,"direct human transform did not clear older queued intent") - --- Engine resolves A on the next frame. -frame=101 -current_player=10 -manager.update() -assert(manager.session_phase()=="active", "first form did not become active") - --- F4 selection of B while A is active must queue B and expire A, not apply B to A. -local switch_ok,switch_reason=manager.transform_creature(10,"data/entities/animals/b.xml",nil,false,{requested_target="data/entities/animals/b.xml"}) -assert(switch_ok==true and switch_reason=="queued_after_human", "mob->mob selection was not accepted as a queued switch") -assert(manager.has_pending_transform()==true, "queued switch was not retained") -assert(effect_frames[201]==1, "old form polymorph was not expired") -assert(#load_calls==1, "second polymorph was applied directly to the old mob") - --- Engine restores the actual human. This frame is intentionally human-only. -frame=102 -current_player=1 -manager.update() -human_seen_frames[#human_seen_frames+1]=frame -assert(manager.session_phase()=="human", "old form session survived human restoration") -assert(manager.has_pending_transform()==true, "queued target was lost on human restoration") -assert(#load_calls==1, "new form started on the same frame the human reappeared") - --- Only the following pre-update may start B. Therefore frame 102 was a complete --- inter-form human frame from one OnWorldPreUpdate boundary to the next. -frame=103 -manager.update() -assert(#load_calls==2, "queued second form did not start after the human frame") -assert(load_calls[2].frame==103 and load_calls[2].player==1, "second form did not start from the restored human") -assert(load_calls[2].effect:match("/b%.xml$"), "queued target changed") -assert(manager.session_phase()=="transforming", "second form did not create the normal MCM session") -assert(manager.has_pending_transform()==false, "queue survived after committing second transform") -assert(load_calls[2].frame-human_seen_frames[1]>=1, "human was not preserved for at least one frame between forms") - --- Stale requests are bounded even if an engine/mod transition never reaches human. -transition_queue.schedule("data/entities/animals/a.xml",nil,{},103) -assert(transition_queue.expire(133)==false and transition_queue.has_pending()==true,"transition request expired too early") -assert(transition_queue.expire(134)==true and transition_queue.has_pending()==false,"stale transition request survived TTL") - -io.write("form_chained_transform_human_frame=PASS queued=true human_frame=1 direct_mob_to_mob=false direct_intent_replaces=true ttl=30\n") diff --git a/metamorph_creative_menu/tests/form_combat_laser_mock.lua b/metamorph_creative_menu/tests/form_combat_laser_mock.lua index 67905a6..ccc78a9 100644 --- a/metamorph_creative_menu/tests/form_combat_laser_mock.lua +++ b/metamorph_creative_menu/tests/form_combat_laser_mock.lua @@ -50,7 +50,6 @@ function EntityGetTransform(entity) return 0, 0, math.pi * 0.75, 1, 1 end function DEBUG_GetMouseWorld() return 100, 0 end function GameGetFrameNum() return 50 end function ComponentSetValue2(component, field, value) -function ComponentGetIsEnabled() return true end writes[field] = value component_values[component] = component_values[component] or {} component_values[component][field] = value diff --git a/metamorph_creative_menu/tests/form_combat_singleton_cleanup_mock.lua b/metamorph_creative_menu/tests/form_combat_singleton_cleanup_mock.lua deleted file mode 100644 index edbb9ab..0000000 --- a/metamorph_creative_menu/tests/form_combat_singleton_cleanup_mock.lua +++ /dev/null @@ -1,60 +0,0 @@ -local root = assert(arg[1], "root required") -local native_dofile = dofile -local alive = {[1]=true} -local killed = {} -local next_entity = 100 - -local entity_tree = { walk=function(entity, callback) callback(entity) end } -local component_ops = { - valid=function(value) return value ~= nil and value ~= 0 end, - first=function() return nil end, - get=function(component, field, default) - if component == 10 and field == "image_file" then return "data/entities/gun.xml" end - return default - end, - boolean=function(value) return value == true end, - ensure_controls=function() return nil end, - set_type_enabled=function() end, - set_type_enabled_tree=function() end, -} -local tree_cache = { components=function() return {} end } - -dofile = function(path) - if path == "mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua" then return entity_tree end - if path == "mods/metamorph_creative_menu/files/features/forms/component_ops.lua" then return component_ops end - if path == "mods/metamorph_creative_menu/files/features/forms/entity_tree_cache.lua" then return tree_cache end - local prefix = "mods/metamorph_creative_menu/" - if string.sub(path, 1, #prefix) == prefix then return native_dofile(root .. "/" .. string.sub(path, #prefix + 1)) end - return native_dofile(path) -end - -EntityGetComponentIncludingDisabled = function(entity, component_type) - if entity == 1 and component_type == "SpriteComponent" then return {10} end - return {} -end -EntityCreateNew = function() - local entity = next_entity - next_entity = next_entity + 1 - alive[entity] = true - return entity -end -EntityAddTag = function() end -EntityAddComponent2 = function() return 20 end -EntityGetTransform = function() return 5, 6 end -EntitySetTransform = function() end -EntitySetComponentIsEnabled = function() end -EntityGetIsAlive = function(entity) return alive[entity] == true end -EntityKill = function(entity) alive[entity] = false; killed[entity] = true end - -METAMORPH_CREATIVE_MENU_FORM_COMBAT = nil -local first = assert(native_dofile(root .. "/files/features/forms/combat.lua")) -local second = assert(native_dofile(root .. "/files/features/forms/combat.lua")) -assert(first == second, "combat service was duplicated inside one Lua context") -first.setup_manual_barrels(1) -assert(alive[100] == true, "test pivot was not created") -second.reset() -assert(killed[100] == true and alive[100] == false, - "reset through the other combat consumer did not clean the created runtime pivot") - -dofile = native_dofile -print("form_combat_singleton_cleanup=PASS shared_lifecycle=true runtime_pivot_cleaned=true") diff --git a/metamorph_creative_menu/tests/form_contextual_attack_fallback_mock.lua b/metamorph_creative_menu/tests/form_contextual_attack_fallback_mock.lua deleted file mode 100644 index 9b6f3d6..0000000 --- a/metamorph_creative_menu/tests/form_contextual_attack_fallback_mock.lua +++ /dev/null @@ -1,47 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local controls,animal=10,20 -local values={ - [controls]={mButtonDownFire=true,mButtonDownFire2=false,polymorph_hax=true}, - [animal]={attack_ranged_entity_file="data/entities/items/contextual_weapon.xml",attack_ranged_enabled=true,attack_ranged_use_message=true,attack_ranged_frames_between=30}, -} -local enabled_changes=0 -local component_ops={ - valid=function(v)return v~=nil and v~=0 end, - first=function(_,k) if k=="ControlsComponent" then return controls elseif k=="AnimalAIComponent" then return animal end end, - get=function(c,f,d)local v=values[c] and values[c][f]; if v==nil then return d end return v end, - boolean=function(v)return v==true or v==1 end, - ensure_controls=function()return controls end, - set_type_enabled=function()end,set_type_enabled_tree=function()end, -} -local tree={components=function(_,kind) if kind=="AnimalAIComponent" then return {animal} end return {} end,reset=function()end} -local entity_tree={walk=function(e,fn)fn(e)end} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/features/forms/component_ops.lua" then return component_ops end - if path=="mods/metamorph_creative_menu/files/features/forms/entity_tree_cache.lua" then return tree end - if path=="mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua" then return entity_tree end - local prefix="mods/metamorph_creative_menu/"; if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -function ComponentGetEntity() return 1 end -function ComponentSetValue2(c,f,v) values[c]=values[c] or {}; values[c][f]=v end -function EntitySetComponentIsEnabled() enabled_changes=enabled_changes+1 end -function EntityGetComponentIncludingDisabled(_,kind) if kind=="AnimalAIComponent" then return {animal} end return {} end -function EntityGetRootEntity() return 1 end -function EntityGetTransform() return 0,0,0,1,1 end -function EntityGetIsAlive() return true end -function DEBUG_GetMouseWorld() return 100,0 end -function GameGetFrameNum() return 100 end -local loaded=0 -function EntityLoad() loaded=loaded+1; return 99 end -function GameShootProjectile() error("manual replay must not own contextual Message_UseItem attack") end -function EntityKill() end -function EntityCreateNew() return 0 end -function EntityAddTag() end -function EntityAddComponent2() return 0 end -local combat=assert(native_dofile(root.."/files/features/forms/combat.lua")) -assert(combat.configure_ranged_player(1)==false,"contextual attack was incorrectly claimed for direct projectile replay") -assert(values[animal].attack_ranged_enabled==true,"contextual native attack was disabled") -assert(combat.manual_ranged_owned()==false,"contextual fallback reported manual ownership") -assert(combat.update_ranged_attacks(1)==false and loaded==0,"contextual attack unexpectedly created a projectile") -print("form_contextual_attack_fallback=PASS native_preserved=true no_partial_replay=true") diff --git a/metamorph_creative_menu/tests/form_corpse_tree_freeze_mock.lua b/metamorph_creative_menu/tests/form_corpse_tree_freeze_mock.lua deleted file mode 100644 index 3898621..0000000 --- a/metamorph_creative_menu/tests/form_corpse_tree_freeze_mock.lua +++ /dev/null @@ -1,61 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local children={[1]={2},[2]={3},[3]={}} -local alive={[1]=true,[2]=true,[3]=true} -local enabled={[11]=true,[21]=true,[22]=true,[23]=true,[31]=true} -local values={ - [11]={}, - [21]={script_source_file="data/entities/animals/boss_robot/state.lua",script_death=""}, - [22]={script_source_file="",script_death="data/entities/animals/test/death.lua"}, - [23]={is_emitting=true,emit_until_frame=999}, - [31]={attack_ranged_entity_file="danger.xml"}, - [40]={hp=0,max_hp=10,wait_for_kill_flag_on_death=false,kill_now=false}, - [50]={mVelocity={5,6}}, -} -local entity_tree={walk=function(e,fn) - local function rec(x) if fn(x)==false then return false end; for _,c in ipairs(children[x] or {}) do if rec(c)==false then return false end end end - rec(e) -end} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua" then return entity_tree end - if path=="mods/metamorph_creative_menu/files/integrations/ew/runtime.lua" then return {enabled=function() return false end} end - local prefix="mods/metamorph_creative_menu/" - if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -function EntityGetIsAlive(e)return alive[e]==true end -function EntityGetAllChildren(e)return children[e] or {} end -function EntityGetComponentIncludingDisabled(e,k,tag) - if tag~=nil then return {} end - if e==1 and k=="AnimalAIComponent" then return {11} end - if e==1 and k=="LuaComponent" then return {22} end - if e==2 and k=="LuaComponent" then return {21} end - if e==2 and k=="LaserEmitterComponent" then return {23} end - if e==2 and k=="VelocityComponent" then return {50} end - if e==3 and k=="AIAttackComponent" then return {31} end - if e==1 and k=="DamageModelComponent" then return {40} end - return {} -end -function EntityGetFirstComponentIncludingDisabled(e,k)local t=EntityGetComponentIncludingDisabled(e,k);return t[1] or 0 end -function EntitySetComponentIsEnabled(_,c,v) enabled[c]=v==true end -function ComponentGetValue2(c,f)local v=values[c] and values[c][f];if type(v)=="table" then return v[1],v[2] end;return v end -function ComponentSetValue2(c,f,...)values[c]=values[c] or {};local a={...};if #a==1 then values[c][f]=a[1] else values[c][f]=a end end -function EntityRemoveTag()end -function EntityAddTag()end -function EntityRemoveComponent()end -function EntityGetTransform()return 10,20 end -function EntityGetFilename()return "data/entities/animals/test.xml" end -function EntityHasTag()return false end -function EntityKill(e)alive[e]=false end -function GameGetFrameNum()return 100 end -function ModDoesFileExist()return false end -local corpse=assert(native_dofile(root.."/files/features/forms/corpse_service.lua")) -assert(corpse.detach(1,"data/entities/animals/test.xml","death",0)==true,"corpse detach failed") -assert(enabled[11]==false,"root AI remained active on pending corpse") -assert(enabled[31]==false,"child-tree AIAttack remained active on pending corpse") -assert(enabled[21]==false,"child attack Lua remained active on pending corpse") -assert(enabled[23]==false and values[23].is_emitting==false and values[23].emit_until_frame==100,"laser was not hard-stopped before corpse handoff") -assert(enabled[22]==true,"pure native script_death hook was unnecessarily frozen") -assert(values[50].mVelocity[1]==0 and values[50].mVelocity[2]==0,"child velocity was not frozen") -assert(values[40].wait_for_kill_flag_on_death==true and values[40].kill_now==false,"corpse one-frame death hold changed") -print("form_corpse_tree_freeze=PASS recursive=true lua=true laser=true pure_death_preserved=true") diff --git a/metamorph_creative_menu/tests/form_death_channel_ack_mock.lua b/metamorph_creative_menu/tests/form_death_channel_ack_mock.lua deleted file mode 100644 index 2fcd9ac..0000000 --- a/metamorph_creative_menu/tests/form_death_channel_ack_mock.lua +++ /dev/null @@ -1,16 +0,0 @@ -local root=assert(arg[1], 'root required') -local registered=nil -local globals={} -GlobalsSetValue=function(k,v) globals[k]=tostring(v) end -GameGetFrameNum=function() return 123 end -METAMORPH_CREATIVE_MENU_EW_FORM_DEATH_CHANNEL=nil -local channel=assert(dofile(root..'/files/integrations/ew/form_death_channel.lua')) -local bridge={CrossCallAdd=function(name,fn) assert(name=='metamorph_creative_menu_form_died'); registered=fn end} -assert(channel.register(bridge,function(entity) return entity==44 end)==true,'death channel did not register') -assert(type(registered)=='function','wrapped death handler missing') -assert(globals.mcm_form_death_channel_registration_v1=='native_cross_vm','native registration mode was not published') -assert(registered(43,'death',0,nil,0)==false,'failed handoff reported success') -assert(globals[channel.ack_key()]==nil,'failed handoff published acknowledgement') -assert(registered(44,'death',0,nil,0)==true,'successful handoff was lost') -assert(globals[channel.ack_key()]=='44:123','successful handoff did not publish exact entity acknowledgement') -print('form_death_channel_ack=PASS commit_only=true entity_scoped=true native_cross_vm=true') diff --git a/metamorph_creative_menu/tests/form_death_handoff_integration_mock.lua b/metamorph_creative_menu/tests/form_death_handoff_integration_mock.lua index db96b78..415b073 100644 --- a/metamorph_creative_menu/tests/form_death_handoff_integration_mock.lua +++ b/metamorph_creative_menu/tests/form_death_handoff_integration_mock.lua @@ -7,7 +7,6 @@ local runtime_resets = 0 local corpse_detaches = 0 local restored_position = nil local protected_entity = nil -local resets_at_death = 0 local switched_from, switched_to = nil, nil METAMORPH_CREATIVE_MENU_FORM_MANAGER = nil @@ -55,7 +54,6 @@ local stubs = { ["mods/metamorph_creative_menu/files/features/forms/transform_flash.lua"] = {suppress=function() end, restore=function() end}, ["mods/metamorph_creative_menu/files/features/forms/corpse_service.lua"] = { detach=function(entity, source, reason) - assert(runtime_resets > resets_at_death, "runtime reset must restore ownership before corpse final freeze") assert(entity == 2, "wrong transformed body detached as corpse") assert(source == "data/entities/animals/test.xml", "corpse lost source creature path") assert(reason == "death", "death reason changed") @@ -131,7 +129,6 @@ manager.update() assert(manager.session_phase() == "active", "form session did not become active") assert(runtime_updates == 1, "form runtime was not activated before death") -resets_at_death = runtime_resets local handed_off = manager.handle_form_death(2, "death", 9, 100, 0) assert(handed_off == true, "native form death did not restore human") assert(current_player == 3, "restored human did not become authoritative player") diff --git a/metamorph_creative_menu/tests/form_dormant_attack_mock.lua b/metamorph_creative_menu/tests/form_dormant_attack_mock.lua deleted file mode 100644 index d08342e..0000000 --- a/metamorph_creative_menu/tests/form_dormant_attack_mock.lua +++ /dev/null @@ -1,40 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local controls,animal=10,20 -local values={ - [controls]={mButtonDownFire=true,mButtonDownFire2=false}, - [animal]={attack_ranged_entity_file="data/entities/projectiles/coward_bullet.xml",attack_ranged_enabled=false,attack_ranged_use_message=false}, -} -local component_ops={valid=function(v)return v~=nil and v~=0 end,first=function(_,k)if k=="ControlsComponent" then return controls elseif k=="AnimalAIComponent" then return animal end end,get=function(c,f,d)local v=values[c] and values[c][f];if v==nil then return d end return v end,boolean=function(v)return v==true or v==1 end,ensure_controls=function()return controls end,set_type_enabled=function()end,set_type_enabled_tree=function()end} -local tree={components=function(_,k)if k=="AnimalAIComponent" then return {animal} end return {} end,reset=function()end} -local entity_tree={walk=function(e,fn)fn(e)end} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/features/forms/component_ops.lua" then return component_ops end - if path=="mods/metamorph_creative_menu/files/features/forms/entity_tree_cache.lua" then return tree end - if path=="mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua" then return entity_tree end - local prefix="mods/metamorph_creative_menu/";if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -function ComponentGetEntity()return 1 end -function ComponentSetValue2(c,f,v)values[c][f]=v end -function EntitySetComponentIsEnabled()end -function EntityGetComponentIncludingDisabled(_,k)if k=="AnimalAIComponent" then return {animal} end return {} end -function EntityGetRootEntity()return 1 end -function EntityGetTransform()return 0,0,0,1,1 end -function EntityGetIsAlive()return true end -function DEBUG_GetMouseWorld()return 100,0 end -function GameGetFrameNum()return 10 end -local loads=0 -function EntityLoad()loads=loads+1;return 99 end -function GameShootProjectile()end -function EntityKill()end -function EntityCreateNew()return 0 end -function EntityAddTag()end -function EntityAddComponent2()return 0 end -local combat=assert(native_dofile(root.."/files/features/forms/combat.lua")) -assert(combat.configure_ranged_player(1)==true,"dormant direct descriptor was not retained for future authored enable") -assert(values[animal].attack_ranged_entity_file=="data/entities/projectiles/coward_bullet.xml","manual ownership mutated the authored projectile descriptor") -assert(combat.update_ranged_attacks(1)==false and loads==0,"dormant attack fired") -assert(values[controls].polymorph_hax==false,"manual ownership did not disable native polymorph attack replay") -assert(values[animal].attack_ranged_enabled==false,"manual ownership changed authored enabled state") -print("form_dormant_attack=PASS authored_disabled_preserved=true dynamic_capability=true") diff --git a/metamorph_creative_menu/tests/form_dynamic_attack_state_mock.lua b/metamorph_creative_menu/tests/form_dynamic_attack_state_mock.lua deleted file mode 100644 index 5bf6b60..0000000 --- a/metamorph_creative_menu/tests/form_dynamic_attack_state_mock.lua +++ /dev/null @@ -1,145 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local controls,animal,dynamic_attack=10,20,30 -local values={ - [controls]={mButtonDownFire=true,mButtonDownFire2=false}, - [animal]={ - attack_ranged_entity_file="data/entities/projectiles/coward_bullet.xml", - attack_ranged_enabled=false, - attack_ranged_use_message=false, - attack_ranged_frames_between=60, - attack_ranged_entity_count_min=1,attack_ranged_entity_count_max=1, - attack_ranged_action_frame=0, - attack_ranged_min_distance=0,attack_ranged_max_distance=300, - attack_ranged_offset_x=0,attack_ranged_offset_y=0, - attack_ranged_state_duration_frames=45, - mRangedAttackNextFrame=0, - }, -} -local component_ops={ - valid=function(v)return v~=nil and v~=0 end, - first=function(_,k)if k=="ControlsComponent" then return controls elseif k=="AnimalAIComponent" then return animal end end, - get=function(c,f,d)local v=values[c] and values[c][f];if v==nil then return d end return v end, - boolean=function(v)return v==true or v==1 end, - ensure_controls=function()return controls end, - set_type_enabled=function()end, - set_type_enabled_tree=function()end, -} -local dynamic_attacks={} -local tree={components=function(_,k) - if k=="AnimalAIComponent" then return {animal} end - if k=="AIAttackComponent" then return dynamic_attacks end - return {} -end,reset=function()end} -local entity_tree={walk=function(e,fn)fn(e)end} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/features/forms/component_ops.lua" then return component_ops end - if path=="mods/metamorph_creative_menu/files/features/forms/entity_tree_cache.lua" then return tree end - if path=="mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua" then return entity_tree end - local prefix="mods/metamorph_creative_menu/";if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -local frame=10 -local dynamic_enabled=false -local loaded={} -function ComponentGetEntity()return 1 end -function ComponentSetValue2(c,f,v) values[c][f]=v end -function EntitySetComponentIsEnabled()end -function ComponentGetIsEnabled(c) if c==dynamic_attack then return dynamic_enabled end return true end -function EntityGetComponentIncludingDisabled(_,k) - if k=="AnimalAIComponent" then return {animal} end - if k=="AIAttackComponent" then return dynamic_attacks end - return {} -end -function EntityGetRootEntity()return 1 end -function EntityGetTransform()return 0,0,0,1,1 end -function EntityGetIsAlive()return true end -function DEBUG_GetMouseWorld()return 100,0 end -function GameGetFrameNum()return frame end -function EntityLoad(path) loaded[#loaded+1]=path; return 100+#loaded end -function GameShootProjectile() return true end -function EntityKill()end -function EntityCreateNew()return 0 end -function EntityAddTag()end -function EntityAddComponent2()return 0 end -function Random(a,b)return b or a end -function ModTextFileGetContent() return '' end -function GamePlayAnimation()end - -local combat=assert(native_dofile(root.."/files/features/forms/combat.lua")) -assert(combat.configure_ranged_player(1)==true,"dormant direct descriptor was not claimed for dynamic authored state") -assert(values[animal].attack_ranged_entity_file=="data/entities/projectiles/coward_bullet.xml","manual ownership mutated initial descriptor") -assert(combat.update_ranged_attacks(1)==false and #loaded==0,"disabled coward attack fired") - --- Simulate coward_check.lua enabling its already-authored ranged descriptor. -values[animal].attack_ranged_enabled=true -frame=11 -assert(combat.update_ranged_attacks(1)==true,"dynamically enabled AnimalAI attack did not fire") -assert(loaded[1]=="data/entities/projectiles/coward_bullet.xml","dynamic enable lost the original projectile") -assert(values[animal].attack_ranged_entity_file=="data/entities/projectiles/coward_bullet.xml","dynamic enable mutated live descriptor") -assert(values[controls].polymorph_hax==false,"manual replay left polymorph_hax enabled") - --- Simulate basebot_sentry_check.lua rewriting projectile + cadence between updates. -values[animal].attack_ranged_entity_file="data/entities/projectiles/machinegun_bullet_roboguard_big.xml" -values[animal].attack_ranged_frames_between=20 -values[animal].mRangedAttackNextFrame=0 -frame=100 -assert(combat.update_ranged_attacks(1)==true,"dynamic sentry projectile rewrite was not replayed") -assert(loaded[2]=="data/entities/projectiles/machinegun_bullet_roboguard_big.xml","dynamic projectile rewrite was ignored") -assert(values[animal].attack_ranged_entity_file=="data/entities/projectiles/machinegun_bullet_roboguard_big.xml","dynamic rewrite was not preserved on live descriptor") -assert(values[animal].mRangedAttackNextFrame==120,"dynamic cadence was not mirrored into cooldown") - --- Simulate wizard_returner_memory.lua installing an arbitrary remembered projectile. -values[animal].attack_ranged_entity_file="mods/example/projectiles/remembered.xml" -values[animal].attack_ranged_frames_between=30 -values[animal].mRangedAttackNextFrame=0 -frame=200 -assert(combat.update_ranged_attacks(1)==true,"dynamic remembered projectile was not replayed") -assert(loaded[3]=="mods/example/projectiles/remembered.xml","dynamic mod projectile path was not preserved exactly") -assert(values[animal].attack_ranged_entity_file=="mods/example/projectiles/remembered.xml","remembered projectile was not preserved on live descriptor") - --- Dynamic disable must immediately stop subsequent shots without destroying descriptor state. -values[animal].attack_ranged_enabled=false -values[animal].mRangedAttackNextFrame=0 -frame=300 -assert(combat.update_ranged_attacks(1)==false and #loaded==3,"dynamic disable did not suppress firing") - - --- A vanilla/mod LuaComponent may add a new AIAttackComponent after the form was configured. --- Exact replay must discover and claim it instead of keeping the one-time snapshot forever. -values[dynamic_attack]={ - attack_ranged_entity_file="data/entities/projectiles/grenade_scavenger.xml", - frames_between=45,frames_between_global=0, - attack_ranged_entity_count_min=2,attack_ranged_entity_count_max=2, - attack_ranged_action_frame=0,animation_name="attack_ranged", - min_distance=0,max_distance=300,use_probability=100,state_duration_frames=45, - attack_ranged_offset_x=4,attack_ranged_offset_y=-2, - attack_ranged_root_offset_x=0,attack_ranged_root_offset_y=0, - attack_ranged_use_message=false,attack_landing_ranged_enabled=false, - attack_ranged_predict=false,attack_ranged_aim_rotation_enabled=false, - attack_ranged_use_laser_sight=false,mNextFrameUsable=0, -} -dynamic_attacks[1]=dynamic_attack -values[animal].attack_ranged_enabled=false -frame=400 -assert(combat.update_ranged_attacks(1)==false and #loaded==3,"disabled late-added AIAttackComponent became player-usable") - --- Phase/mod enabling the real component makes it available without MCM forcing state. -dynamic_enabled=true -frame=401 -assert(combat.update_ranged_attacks(1)==true,"enabled late-added AIAttackComponent was not discovered") -assert(loaded[4]=="data/entities/projectiles/grenade_scavenger.xml" and loaded[5]=="data/entities/projectiles/grenade_scavenger.xml","late-added attack lost exact projectile/count") -assert(values[dynamic_attack].attack_ranged_entity_file=="data/entities/projectiles/grenade_scavenger.xml","late-added descriptor was mutated by ownership") - --- An intentional empty projectile path from another mod/phase must immediately retire the --- remembered shadow projectile instead of firing the stale saved path. -values[dynamic_attack].attack_ranged_entity_file="" -values[dynamic_attack].mNextFrameUsable=0 -frame=500 -assert(combat.update_ranged_attacks(1)==false and #loaded==5,"intentional empty AIAttack path fired stale projectile") -values[dynamic_attack].attack_ranged_entity_file="mods/example/projectiles/re_enabled.xml" -values[dynamic_attack].mNextFrameUsable=0 -frame=501 -assert(combat.update_ranged_attacks(1)==true and loaded[6]=="mods/example/projectiles/re_enabled.xml","re-enabled live descriptor did not replace empty state") - -print("form_dynamic_attack_state=PASS dormant_claim=true enable=true projectile_rewrite=true cadence=true remembered=true disable=true late_component=true component_enabled=true empty_path_authoritative=true") diff --git a/metamorph_creative_menu/tests/form_external_polymorph_adoption_mock.lua b/metamorph_creative_menu/tests/form_external_polymorph_adoption_mock.lua deleted file mode 100644 index dc1b12a..0000000 --- a/metamorph_creative_menu/tests/form_external_polymorph_adoption_mock.lua +++ /dev/null @@ -1,170 +0,0 @@ -local root = assert(arg[1], "root required") -local native_dofile = dofile - -local current_player = 10 -local alive = {[10]=true,[20]=true} -local frame = 300 -local network_tagged = false -local effect_frames = 600 -local runtime_updates = 0 -local runtime_resets = 0 -local runtime_session = nil -local profile_target = nil -local switched_from, switched_to = nil, nil -local restored_position = nil -local protected_entity = nil - -METAMORPH_CREATIVE_MENU_FORM_MANAGER = nil - -local bridge = { - DeserializeEntity=function() end, - SetPlayerEntity=function(entity) current_player=entity end, - GetPlayerEntity=function() return current_player end, - CrossCallAdd=function() return true end, -} - -local human_restore = { - restore_controls=function() return true end, - protect_player=function(entity, frames) - protected_entity = entity - assert(frames == 12, "external death restore protection changed") - end, - polymorph_effect_components=function(entity) - return entity == 10 and {201} or {} - end, - serialized_backup_from_effects=function(components) - assert(components[1] == 201, "external polymorph backup read from wrong effect") - return "external-serialized-human" - end, - deserialize_backup=function(_, backup, x, y, name) - assert(backup == "external-serialized-human", "external human backup was not preserved") - assert(name == "metamorph_creative_menu_death_handoff", "external death used wrong restore transaction") - restored_position = {x,y} - return 20 - end, -} - -local stubs = { - ["mods/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua"]={get=function() return bridge end}, - ["mods/metamorph_creative_menu/files/platform/noita/player_locator.lua"]={get=function() return current_player end}, - ["mods/metamorph_creative_menu/files/platform/noita/keycodes.lua"]={resolve=function() return 15 end}, - ["mods/metamorph_creative_menu/files/features/forms/profile.lua"]={get=function(target) - profile_target=target - return {target=target} - end}, - ["mods/metamorph_creative_menu/files/features/forms/runtime.lua"]={ - update=function(entity, session) - assert(entity==10, "external runtime applied to wrong entity") - assert(session.phase=="active", "external form was not adopted as active") - assert(session.compatibility_mode=="external_polymorph", "external form lost adoption mode") - assert(session.form_strategy=="external_native_polymorph", "external form lost native strategy") - assert(session.allow_death_handoff==true, "external serialized human was not death-rescuable") - runtime_session=session - runtime_updates=runtime_updates+1 - end, - reset=function() runtime_resets=runtime_resets+1 end, - family=function() return "character" end, - draw_health=function() end, - }, - ["mods/metamorph_creative_menu/files/features/forms/exact_effects.lua"]={}, - ["mods/metamorph_creative_menu/files/features/forms/player_authority.lua"]={switch=function(_, old_entity, new_entity) - switched_from, switched_to=old_entity,new_entity - current_player=new_entity - return true,"confirmed" - end}, - ["mods/metamorph_creative_menu/files/features/forms/transform_flash.lua"]={restore=function() end,suppress=function() end}, - ["mods/metamorph_creative_menu/files/features/forms/corpse_service.lua"]={detach=function(entity, source, reason) - assert(entity==10,"wrong external form detached") - assert(source=="data/entities/animals/shotgunner.xml","external corpse lost exact body path") - assert(reason=="death","external corpse reason changed") - alive[entity]=false - return true - end,update=function() end}, - ["mods/metamorph_creative_menu/files/features/forms/human_restore.lua"]=human_restore, - ["mods/metamorph_creative_menu/files/integrations/ew/form_death_channel.lua"]={register=function(_, callback) - return type(callback)=="function" - end}, -} - -dofile=function(path) - if stubs[path] ~= nil then return stubs[path] end - local prefix="mods/metamorph_creative_menu/" - if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -function GameGetFrameNum() return frame end -function GameGetRealWorldTimeSinceStarted() return frame/60 end -function EntityGetIsAlive(entity) return alive[entity]==true end -function EntityHasTag(entity, tag) - if entity~=10 then return tag=="player_unit" and entity==20 end - if tag=="polymorphed_player" then return true end - if tag=="metamorph_creative_menu_network_form" then return network_tagged end - return false -end -function EntityGetFilename(entity) - return entity==10 and "data/entities/animals/shotgunner.xml" or "data/entities/player.xml" -end -function EntityGetTransform(entity) - if entity==10 then return 123,456 end - if entity==20 then return 123,456 end - return 0,0 -end -function EntityGetComponentIncludingDisabled(entity, kind) - if entity==10 and kind=="VariableStorageComponent" then return {} end - return {} -end -function EntityGetFirstComponentIncludingDisabled(entity, kind) - if entity==20 and kind=="Inventory2Component" then return 66 end - return 0 -end -function EntityAddTag(entity, tag) - assert(entity==10 and tag=="metamorph_creative_menu_network_form","external form marked with wrong network tag") - network_tagged=true -end -function EntityAddComponent2(entity, kind, values) - assert(entity==10 and kind=="VariableStorageComponent","external source marker used wrong component") - assert(values.name=="metamorph_creative_menu_network_source","external source marker name changed") - assert(values.value_string=="data/entities/animals/shotgunner.xml","external source marker lost exact path") - return 301 -end -function ComponentGetValue2(component, field) - if component==201 and field=="polymorph_target" then return "[RANDOM]" end - if component==201 and field=="frames" then return effect_frames end - return nil -end -function ComponentSetValue2(component, field, value) - if component==201 and field=="frames" then effect_frames=value end -end -function EntityKill(entity) alive[entity]=false end -function print() end - -local manager=assert(native_dofile(root.."/files/features/forms/manager.lua")) - --- No MCM marker exists yet: the live local polymorph must still be visible to return-HUMAN --- input before the adoption update runs. -assert(manager.has_active_form()==true,"unowned local polymorph was invisible to MCM") -assert(network_tagged==false,"has_active_form must not mutate the external form") - -manager.update() -assert(manager.session_phase()=="active","external local polymorph was not adopted") -assert(manager.session_target()=="data/entities/animals/shotgunner.xml","random polymorph did not use exact live creature path") -assert(profile_target=="data/entities/animals/shotgunner.xml","external form profile did not use exact live creature path") -assert(runtime_updates==1 and runtime_session~=nil,"external form did not enter normal MCM runtime") -assert(network_tagged==true,"adopted external form was not published to the existing network/death path") -assert(manager.original_player_backup()=="external-serialized-human","external form did not expose native human backup") - -local returning=manager.handle_tab_return(false,true) -assert(returning==true and effect_frames==1,"return-human binding did not expire adopted external polymorph") - --- A lethal hit can land on the same frame as the requested return. Death handoff must --- still win synchronously instead of letting Noita/EW enter the game-over path. -local handed_off=manager.handle_form_death(10,"death",9,50,0) -assert(handed_off==true,"external polymorph death did not restore human") -assert(current_player==20 and switched_from==10 and switched_to==20,"external death did not transfer player authority") -assert(restored_position[1]==123 and restored_position[2]==456,"external death restore changed position") -assert(protected_entity==20,"external death restore did not protect human") -assert(manager.session_phase()=="human","external form session survived death handoff") -assert(runtime_resets>=1,"external form runtime was not reset after death") - -io.write("form_external_polymorph_adoption=PASS exact_body_profile=true runtime_adopted=true network_published=true death_restored=true\n") diff --git a/metamorph_creative_menu/tests/form_fish_breathing_mock.lua b/metamorph_creative_menu/tests/form_fish_breathing_mock.lua deleted file mode 100644 index 937944a..0000000 --- a/metamorph_creative_menu/tests/form_fish_breathing_mock.lua +++ /dev/null @@ -1,39 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local fish_ai,damage=10,20 -local values={ - [fish_ai]={flock=true,avoid_predators=true}, - [damage]={air_needed=true,air_in_lungs_max=7,air_in_lungs=2,air_lack_of_damage=0.6}, -} -local component_ops={ - valid=function(v)return v~=nil and v~=0 end, - first=function(_,kind)if kind=="AdvancedFishAIComponent" then return fish_ai elseif kind=="DamageModelComponent" then return damage end end, - ensure_controls=function()return 0 end, - set_type_enabled=function()end, - set_typed_scalar=function(c,f,v) - local cur=values[c][f] - if type(cur)=="boolean" then v=(v==true or v==1 or v=="1" or v=="true") - elseif type(cur)=="number" then v=tonumber(v) end - values[c][f]=v - return true - end, -} -local controls={direction=function()return 0,0,false end} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/features/forms/component_ops.lua" then return component_ops end - if path=="mods/metamorph_creative_menu/files/features/forms/controls.lua" then return controls end - local prefix="mods/metamorph_creative_menu/" - if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -function ComponentSetValue2(c,f,a,b) values[c][f]=(b~=nil) and {a,b} or a end -function EntityGetTransform()return 0,0 end - -local adapter=assert(native_dofile(root.."/files/features/forms/adapters/fish.lua")) -adapter.configure(1,{damage={air_needed="0",air_in_lungs_max="5",air_in_lungs="5",air_lack_of_damage="0.2"}}) -assert(values[damage].air_needed==false,"fish retained player air_needed") -assert(values[damage].air_in_lungs_max==5,"fish max air was not restored") -assert(values[damage].air_in_lungs==5,"fish current air was not restored") -assert(math.abs(values[damage].air_lack_of_damage-0.2)<0.0001,"fish air damage was not restored") -assert(values[fish_ai].flock==false and values[fish_ai].avoid_predators==false,"fish AI playerization regressed") -print("form_fish_breathing=PASS authored_respiratory_profile=true") diff --git a/metamorph_creative_menu/tests/form_late_first_attack_mock.lua b/metamorph_creative_menu/tests/form_late_first_attack_mock.lua deleted file mode 100644 index 0fabed3..0000000 --- a/metamorph_creative_menu/tests/form_late_first_attack_mock.lua +++ /dev/null @@ -1,59 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local controls,animal=10,20 -local frame=100 -local loads=0 -local values={ - [controls]={mButtonDownFire=true,mButtonDownFire2=false}, - [animal]={ - attack_ranged_entity_file="",attack_ranged_enabled=false,attack_ranged_use_message=false, - attack_ranged_frames_between=30,attack_ranged_entity_count_min=1,attack_ranged_entity_count_max=1, - attack_ranged_action_frame=0,attack_ranged_offset_x=4,attack_ranged_offset_y=0, - attack_ranged_min_distance=0,attack_ranged_max_distance=400, - attack_ranged_aim_rotation_enabled=false,mRangedAttackNextFrame=0, - }, -} -local component_ops={ - valid=function(v)return v~=nil and v~=0 end, - first=function(_,k)if k=="ControlsComponent" then return controls end end, - get=function(c,f,d)local v=values[c] and values[c][f]; if v==nil then return d end return v end, - boolean=function(v)return v==true or v==1 end, - ensure_controls=function()return controls end, - set_type_enabled=function()end,set_type_enabled_tree=function()end, -} -local tree={components=function(_,kind) if kind=="AnimalAIComponent" then return {animal} end return {} end,reset=function()end} -local entity_tree={walk=function(e,fn)fn(e)end} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/features/forms/component_ops.lua" then return component_ops end - if path=="mods/metamorph_creative_menu/files/features/forms/entity_tree_cache.lua" then return tree end - if path=="mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua" then return entity_tree end - local prefix="mods/metamorph_creative_menu/"; if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -function ComponentGetEntity() return 1 end -function ComponentSetValue2(c,f,v) values[c]=values[c] or {}; values[c][f]=v end -function EntitySetComponentIsEnabled() end -function EntityGetComponentIncludingDisabled(_,kind) if kind=="AnimalAIComponent" then return {animal} end return {} end -function EntityGetRootEntity() return 1 end -function EntityGetTransform() return 0,0,0,1,1 end -function EntityGetIsAlive() return true end -function DEBUG_GetMouseWorld() return 100,0 end -function GameGetFrameNum() return frame end -function Random(a,b) return a end -function EntityLoad(path,x,y) loads=loads+1; assert(path=="mods/example/files/late_bolt.xml"); return 100+loads end -function GameShootProjectile() end -function EntityKill() end -function GamePlayAnimation() end -local combat=assert(native_dofile(root.."/files/features/forms/combat.lua")) -assert(combat.configure_ranged_player(1)==false,"empty initial descriptor unexpectedly acquired ownership") -assert(combat.manual_ranged_owned()==false,"empty initial descriptor marked manual ownership") --- Simulate a later Lua/mod callback authoring the first real direct attack. -values[animal].attack_ranged_entity_file="mods/example/files/late_bolt.xml" -values[animal].attack_ranged_enabled=true -frame=101 -combat.update_ranged_attacks(1) -assert(combat.manual_ranged_owned()==true,"late first direct attack was never acquired") -assert(values[animal].attack_ranged_entity_file=="mods/example/files/late_bolt.xml","late first descriptor was mutated by ownership") -assert(values[controls].polymorph_hax==false,"late ownership did not disable polymorph_hax") -assert(loads==1,"late first direct attack was not replayed exactly") -print("form_late_first_attack=PASS retry_acquisition=true polymorph_hax_suppressed=true descriptor_preserved=true exact_replay=true") diff --git a/metamorph_creative_menu/tests/form_manual_actions_mock.lua b/metamorph_creative_menu/tests/form_manual_actions_mock.lua deleted file mode 100644 index b5dd313..0000000 --- a/metamorph_creative_menu/tests/form_manual_actions_mock.lua +++ /dev/null @@ -1,240 +0,0 @@ -local root = assert(arg[1], "root required") -local native_dofile = dofile - -local next_component = 1000 -local next_entity = 5000 -local components_by_entity = {} -local values = {} -local enabled = {} -local alive = {} -local transforms = {} -local frame = 90 -local mouse_x, mouse_y = 100, 0 -local raw_mouse = {[1]=false,[2]=false} -local animation_calls = {} -local loaded = {} -local shot = {} -local tags = {} -local owners = {} - -local function add(entity, kind, id, data) - components_by_entity[entity] = components_by_entity[entity] or {} - components_by_entity[entity][kind] = components_by_entity[entity][kind] or {} - table.insert(components_by_entity[entity][kind], id) - values[id] = data or {} - owners[id] = entity - enabled[id] = values[id]._enabled ~= false - alive[entity] = true - transforms[entity] = transforms[entity] or {0,0,0,1,1} - return id -end - -local entity_tree = { walk=function(entity, fn) return fn(entity) end } -local component_ops = {} -function component_ops.valid(v) return v ~= nil and v ~= 0 end -function component_ops.get(c, f, fallback) - local t=values[c] - if t==nil or t[f]==nil then return fallback end - return t[f] -end -function component_ops.boolean(v) - if type(v)=="number" then return v~=0 end - if type(v)=="string" then return v=="1" or v=="true" end - return v==true -end -function component_ops.ensure_controls(entity) - local list=components_by_entity[entity] and components_by_entity[entity].ControlsComponent or nil - if list and list[1] then return list[1] end - next_component=next_component+1 - return add(entity,"ControlsComponent",next_component,{mButtonDownFire=false,mButtonDownFire2=false,polymorph_hax=true}) -end - -local stubs = { - ["mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua"] = entity_tree, - ["mods/metamorph_creative_menu/files/features/forms/component_ops.lua"] = component_ops, -} -dofile=function(path) - if stubs[path] then return stubs[path] end - local prefix="mods/metamorph_creative_menu/" - if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -function GameGetFrameNum() return frame end -function GameIsInventoryOpen() return false end -function InputIsMouseButtonDown(code) return raw_mouse[code] == true end -function ComponentGetIsEnabled(c) return enabled[c] ~= false end -function ComponentGetValue2(c,f) - local v=values[c] and values[c][f] - if type(v)=="table" and #v>=2 then return v[1],v[2] end - return v -end -function ComponentGetEntity(c) return owners[c] or 0 end -function ComponentSetValue2(c,f,a,b) - values[c]=values[c] or {} - if b~=nil then values[c][f]={a,b} else values[c][f]=a end -end -function EntitySetComponentIsEnabled(_,c,v) enabled[c]=v==true end -function EntityGetComponentIncludingDisabled(entity,kind) - return (components_by_entity[entity] and components_by_entity[entity][kind]) or {} -end -function EntityGetFirstComponentIncludingDisabled(entity,kind) - local list=EntityGetComponentIncludingDisabled(entity,kind); return list[1] -end -function EntityGetTransform(entity) - local t=transforms[entity] or {0,0,0,1,1}; return t[1],t[2],t[3],t[4],t[5] -end -function EntitySetTransform(entity,x,y,r,sx,sy) transforms[entity]={x,y,r or 0,sx or 1,sy or 1} end -function EntityGetRootEntity(entity) return entity end -function EntityGetIsAlive(entity) return alive[entity] ~= false end -function EntityGetInRadiusWithTag() return {} end -function DEBUG_GetMouseWorld() return mouse_x,mouse_y end -function ModTextFileGetContent(path) - if path=="data/enemies_gfx/chest_mimic.xml" then - return '' - end - return "" -end -function GamePlayAnimation(entity,name) animation_calls[#animation_calls+1]={entity,name} end -function EntityInflictDamage(...) return true end -function SetRandomSeed() end -function Random(a,b) return a end -function EntityLoad(path,x,y) - local id=2000+#loaded+1; loaded[#loaded+1]={id=id,path=path,x=x,y=y}; alive[id]=true; transforms[id]={x,y,0,1,1} - add(id,"VelocityComponent",id*10+1,{mVelocity={0,0}}) - add(id,"ProjectileComponent",id*10+2,{damage=0.1}) - return id -end -function GameShootProjectile(shooter,sx,sy,tx,ty,projectile,network) - shot[#shot+1]={shooter=shooter,projectile=projectile,tx=tx,ty=ty,network=network}; return true -end -function EntityKill(entity) alive[entity]=false end -function EntityCreateNew() next_entity=next_entity+1; alive[next_entity]=true; transforms[next_entity]={0,0,0,1,1}; return next_entity end -function EntityAddTag(entity,tag) tags[entity]=tags[entity] or {}; tags[entity][tag]=true end -function EntityAddComponent2(entity,kind,data) - next_component=next_component+1 - return add(entity,kind,next_component,data or {}) -end -function EntityRefreshSprite() end - --- Dash support is intentionally absent. Player-controlled forms keep secondary fire --- available to the normal combat system and no manual action module owns RMB. -local wolf=1 -add(wolf,"ControlsComponent",11,{mButtonDownFire=false,mButtonDownFire2=false,polymorph_hax=true}) -add(wolf,"AnimalAIComponent",12,{_enabled=false,attack_dash_enabled=true,attack_dash_speed=800,attack_dash_distance=50}) -transforms[wolf]={0,0,0,1,1} -local actions=assert(native_dofile(root.."/files/features/forms/adapters/manual_actions.lua")) -actions.configure(wolf,"data/entities/animals/wolf.xml",{can_fly=false,platforming={run_velocity=80}}) -assert(actions.owns_secondary()==false and actions.owns_primary()==false,"removed dash still owns an input surface") -frame=100; raw_mouse[2]=true; actions.update(wolf) -assert(enabled[12]==false,"removed dash feature re-enabled AnimalAI") -raw_mouse[2]=false - --- All four vanilla tooth-mimic bodies expose the same melee contract. -local mimic_paths={ - "data/entities/animals/chest_mimic.xml", - "data/entities/animals/chest_leggy.xml", - "data/entities/animals/illusions/dark_alchemist.xml", - "data/entities/animals/illusions/shaman_wind.xml", -} -for index,path in ipairs(mimic_paths) do - local entity=10+index - local controls=entity*10+1 - add(entity,"ControlsComponent",controls,{mButtonDownFire=false,mButtonDownFire2=false,polymorph_hax=true}) - add(entity,"AnimalAIComponent",entity*10+2,{_enabled=false,attack_melee_enabled=true,attack_melee_damage_min=0.6,attack_melee_damage_max=1.0,attack_melee_max_distance=15,attack_melee_action_frame=5,attack_melee_frames_between=10}) - add(entity,"CharacterDataComponent",entity*10+3,{mVelocity={0,0}}) - local sprite=add(entity,"SpriteComponent",entity*10+4,{image_file="data/enemies_gfx/chest_mimic.xml",rect_animation="stand",next_rect_animation="stand"}) - local animator=add(entity,"SpriteAnimatorComponent",entity*10+5,{_enabled=true}) - actions.configure(entity,path,{can_fly=false,platforming={run_velocity=0}}) - assert(actions.owns_primary()==true,"mimic bite did not own primary input: "..path) - frame=200+index*10; values[controls].mButtonDownFire=false; raw_mouse[1]=false; actions.update(entity) - values[controls].mButtonDownFire=true; frame=frame+1; actions.update(entity) - local last=animation_calls[#animation_calls] - assert(last and last[1]==entity and last[2]=="attack","mimic did not play tooth attack animation: "..path) - assert(values[sprite].rect_animation=="attack" and values[sprite].next_rect_animation=="stand","mimic root sprite did not enter authored bite animation: "..path) - assert(enabled[animator]==false,"mimic SpriteAnimator was allowed to overwrite bite animation: "..path) - frame=frame+40; values[controls].mButtonDownFire=false; actions.update(entity) - assert(enabled[animator]==true,"mimic SpriteAnimator was not restored after bite: "..path) -end -raw_mouse[1]=false - --- Physics crystals use their authored AnimalAI projectile even though PhysicsAI owns locomotion. -local crystal=30 -add(crystal,"ControlsComponent",301,{mButtonDownFire=false,mButtonDownFire2=false,polymorph_hax=true}) -add(crystal,"AnimalAIComponent",302,{_enabled=false,attack_ranged_enabled=true,attack_ranged_entity_file="data/entities/projectiles/orb_pink.xml",attack_ranged_frames_between=10,attack_ranged_action_frame=3,attack_ranged_offset_x=0,attack_ranged_offset_y=-5}) -add(crystal,"PhysicsAIComponent",303,{}) -transforms[crystal]={20,30,0,1,1} -actions.configure(crystal,"data/entities/animals/crystal_physics.xml",{}) -assert(actions.owns_primary()==true,"crystal manual projectile did not own primary") -frame=300; values[301].mButtonDownFire=false; actions.update(crystal) -frame=301; values[301].mButtonDownFire=true; actions.update(crystal) -frame=304; actions.update(crystal) -assert(loaded[#loaded] and loaded[#loaded].path=="data/entities/projectiles/orb_pink.xml","crystal did not fire authored orb_pink projectile") -assert(shot[#shot] and shot[#shot].shooter==crystal,"crystal projectile ownership wrong") - --- Blood crystal does not use AnimalAI ranged fire at all. Its vanilla collision script --- emits twelve orb_pink projectiles around the body; player primary fire must replay the --- same radial attack while suppressing the automatic collision script. -local blood=35 -add(blood,"ControlsComponent",351,{mButtonDownFire=false,mButtonDownFire2=false,polymorph_hax=true}) -local blood_lua=add(blood,"LuaComponent",352,{_enabled=true,script_collision_trigger_hit="data/scripts/animals/bloodcrystal_explosion.lua"}) -transforms[blood]={50,60,0,1,1} -actions.configure(blood,"data/entities/animals/the_end/bloodcrystal_physics.xml",{}) -assert(actions.owns_primary()==true and enabled[blood_lua]==false,"blood crystal collision attack was not manually owned/suppressed") -local before=#loaded -frame=350; values[351].mButtonDownFire=false; raw_mouse[1]=false; actions.update(blood) -frame=351; values[351].mButtonDownFire=true; actions.update(blood) -assert(#loaded-before==12,"blood crystal did not reproduce the vanilla 12-orb radial attack") -for i=before+1,#loaded do assert(loaded[i].path=="data/entities/projectiles/orb_pink.xml","blood crystal radial attack used wrong projectile") end --- Vanilla bloodcrystal_explosion.lua uses script_wait_frames(entity, 10). Holding fire --- therefore repeats quickly but must never emit another volley before ten frames. -frame=359; actions.update(blood) -assert(#loaded-before==12,"blood crystal fired before its vanilla 10-frame gate") -frame=361; actions.update(blood) -assert(#loaded-before==24,"blood crystal did not repeat at the vanilla 10-frame cadence") -actions.reset() -assert(enabled[blood_lua]==true,"blood crystal collision script enable state was not restored on reset") -values[351].mButtonDownFire=false; raw_mouse[1]=false - --- Boss Pit wand is a projectile-form, not a manual creature action. It renders through a --- dedicated presentation entity and chooses a non-stale vanilla Boss Pit payload. -local wand=40 -add(wand,"ControlsComponent",401,{mButtonDownFire=false,mButtonDownFire2=false,mButtonDownRight=false,mButtonDownLeft=false,mButtonDownUp=false,mButtonDownDown=false,mButtonDownFly=false,polymorph_hax=true}) -add(wand,"VelocityComponent",402,{mVelocity={0,0}}) -add(wand,"ProjectileComponent",403,{on_death_explode=true,on_lifetime_out_explode=true}) -add(wand,"SpriteComponent",404,{image_file="data/entities/animals/boss_pit/wand_0$[1-9].png",alpha=1}) -add(wand,"LuaComponent",405,{script_source_file="data/entities/animals/boss_pit/wand.lua"}) -add(wand,"LuaComponent",406,{script_source_file="data/entities/animals/boss_pit/wand_rotate.lua"}) -add(wand,"VariableStorageComponent",407,{name="memory",value_string="data/entities/projectiles/enlightened_laser_darkbeam.xml"}) -transforms[wand]={10,20,0,1,1} -local projectile_forms=assert(native_dofile(root.."/files/features/forms/projectile_forms/controller.lua")) -assert(projectile_forms.configure(wand,"data/entities/animals/boss_pit/wand.xml",{})==true,"wand projectile-form not claimed") -assert(enabled[403]==false and enabled[404]==true and enabled[405]==false and enabled[406]==false,"wand lifecycle suppression/root presentation state wrong") -assert(values[404].image_file=="data/entities/animals/boss_pit/wand_01.png" and values[404].alpha==1,"wand root did not retain a concrete peer-visible sprite") -assert(values[407].value_string=="data/entities/projectiles/deck/rocket.xml","wand did not choose vanilla Boss Pit payload") -local visual1=nil -for e,kinds in pairs(components_by_entity) do - for _,sprite in ipairs(kinds.SpriteComponent or {}) do - if values[sprite] and values[sprite].image_file=="data/entities/animals/boss_pit/wand_01.png" and e~=wand then visual1=sprite end - end -end -assert(visual1,"wand did not create a concrete visible presentation sprite") -frame=400; values[401].mButtonDownFire=false; projectile_forms.update(wand) -frame=401; values[401].mButtonDownFire=true; mouse_x=110; mouse_y=20; projectile_forms.update(wand) -assert(loaded[#loaded] and loaded[#loaded].path=="data/entities/projectiles/deck/rocket.xml","wand fired wrong projectile") -local wand_projectile=loaded[#loaded].id -local pv=values[wand_projectile*10+1].mVelocity -assert(math.abs(pv[1]-250)<0.001 and math.abs(pv[2])<0.001,"rocket wand did not preserve vanilla 500*0.5 launch speed") -assert(math.abs(values[wand_projectile*10+2].damage-0.3)<0.0001,"wand.lua +0.2 projectile damage was not preserved") - -local wand2=41 -add(wand2,"ControlsComponent",411,{mButtonDownFire=false,mButtonDownFire2=false,polymorph_hax=true}) -add(wand2,"VelocityComponent",412,{mVelocity={0,0}}) -add(wand2,"ProjectileComponent",413,{}) -add(wand2,"SpriteComponent",414,{image_file="data/entities/animals/boss_pit/wand_0$[1-9].png"}) -add(wand2,"VariableStorageComponent",417,{name="memory",value_string="data/entities/projectiles/enlightened_laser_darkbeam.xml"}) -transforms[wand2]={11,21,0,1,1} -projectile_forms.configure(wand2,"data/entities/animals/boss_pit/wand.xml",{}) -assert(values[417].value_string=="data/entities/projectiles/deck/rocket_tier_2.xml","consecutive wand transform reused one deterministic attack variant") - -print("form_manual_actions=PASS dash_removed=true mimic_bites=4 chest_leggy_sprite=true crystals=true blood_crystal_radial=true blood_crystal_cooldown=10 projectile_wand_visual=true wand_variant_nonrepeat=true") diff --git a/metamorph_creative_menu/tests/form_multiattack_replay_mock.lua b/metamorph_creative_menu/tests/form_multiattack_replay_mock.lua deleted file mode 100644 index df6a42a..0000000 --- a/metamorph_creative_menu/tests/form_multiattack_replay_mock.lua +++ /dev/null @@ -1,75 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local controls=10 -local attack_close,attack_far=31,32 -local frame=200 -local mouse_x=20 -local loads={} -local random_roll=nil -local values={ - [controls]={mButtonDownFire=true,mButtonDownFire2=false}, - [attack_close]={attack_ranged_entity_file="same.xml",attack_ranged_use_message=false,frames_between=80,frames_between_global=0,attack_ranged_entity_count_min=1,attack_ranged_entity_count_max=1,attack_ranged_action_frame=0,animation_name="",min_distance=0,max_distance=40,use_probability=100,state_duration_frames=45,angular_range_deg=90,mNextFrameUsable=0,attack_ranged_offset_x=0,attack_ranged_offset_y=0,attack_ranged_root_offset_x=0,attack_ranged_root_offset_y=0}, - [attack_far]={attack_ranged_entity_file="same.xml",attack_ranged_use_message=false,frames_between=50,frames_between_global=50,attack_ranged_entity_count_min=2,attack_ranged_entity_count_max=2,attack_ranged_action_frame=0,animation_name="",min_distance=100,max_distance=300,use_probability=100,state_duration_frames=45,angular_range_deg=90,mNextFrameUsable=0,attack_ranged_offset_x=5,attack_ranged_offset_y=0,attack_ranged_root_offset_x=7,attack_ranged_root_offset_y=0}, -} -local component_ops={valid=function(v)return v~=nil and v~=0 end,first=function(_,k)if k=="ControlsComponent" then return controls end end,get=function(c,f,d)local v=values[c] and values[c][f]; if v==nil then return d end return v end,boolean=function(v)return v==true or v==1 end,ensure_controls=function()return controls end,set_type_enabled=function()end,set_type_enabled_tree=function()end} -local tree={components=function(_,kind) if kind=="AIAttackComponent" then return {attack_close,attack_far} elseif kind=="AnimalAIComponent" then return {} else return {} end end, reset=function() end} -local entity_tree={walk=function(e,fn)fn(e)end} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/features/forms/component_ops.lua" then return component_ops end - if path=="mods/metamorph_creative_menu/files/features/forms/entity_tree_cache.lua" then return tree end - if path=="mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua" then return entity_tree end - local prefix="mods/metamorph_creative_menu/"; if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -function ComponentGetEntity(c) return 1 end -function ComponentSetValue2(c,f,v) values[c][f]=v end -function EntitySetComponentIsEnabled() end -function ComponentGetIsEnabled() return true end -function EntityGetComponentIncludingDisabled(_,kind) if kind=="AIAttackComponent" then return {attack_close,attack_far} else return {} end end -function EntityGetRootEntity() return 1 end -function EntityGetTransform() return 0,0,0,1,1 end -function EntityGetIsAlive() return true end -function DEBUG_GetMouseWorld() return mouse_x,0 end -function GameGetFrameNum() return frame end -function Random(a,b) if random_roll~=nil then return math.max(a,math.min(b,random_roll)) end return a end -function EntityLoad(path,x,y) loads[#loads+1]={path=path,x=x,y=y}; return 100+#loads end -function GameShootProjectile() end -function EntityKill() end -function GamePlayAnimation() end -function EntityAddTag() end -function EntityCreateNew() return 0 end -local combat=assert(native_dofile(root.."/files/features/forms/combat.lua")) -assert(combat.configure_ranged_player(1)==true,"AIAttack-only creature did not acquire manual ranged ownership") -assert(values[attack_close].attack_ranged_entity_file=="same.xml" and values[attack_far].attack_ranged_entity_file=="same.xml","manual ownership mutated multi-attack descriptors") -combat.update_ranged_attacks(1) -assert(#loads==1 and loads[1].x==0,"close attack was not selected at close range") --- Reset state and use the far authored profile. Both components deliberately use same.xml. -combat.reset(); frame=300; mouse_x=180; loads={} -combat.configure_ranged_player(1) -combat.update_ranged_attacks(1) -assert(#loads==2,"same-path far profile was deduplicated or lost its count") -assert(loads[1].x==12 and loads[1].y==0,"root+muzzle offset was not preserved") -assert(values[attack_far].mNextFrameUsable==350,"authored local cooldown was not mirrored") - --- A cursor beyond every AI acquisition radius must still use the nearest (far) authored --- profile under player authority. Only attack selection uses the ranges; the click itself --- must never be rejected. -combat.reset(); frame=360; mouse_x=1000; loads={} -values[attack_close].mNextFrameUsable=0; values[attack_far].mNextFrameUsable=0 -combat.configure_ranged_player(1) -combat.update_ranged_attacks(1) -assert(#loads==2 and loads[1].x==12,"far cursor was rejected instead of selecting the far authored profile") - --- Overlapping attack ranges must preserve the authored AIAttack state. A tank-like --- machinegun state may fire repeatedly during its 45f state instead of rerolling into --- a grenade state on every 3f shot. -combat.reset(); loads={}; frame=400; mouse_x=35; random_roll=1 -values[attack_close].frames_between=3; values[attack_close].frames_between_global=0; values[attack_close].min_distance=0; values[attack_close].max_distance=200; values[attack_close].mNextFrameUsable=0 -values[attack_far].frames_between=40; values[attack_far].frames_between_global=0; values[attack_far].min_distance=0; values[attack_far].max_distance=200; values[attack_far].mNextFrameUsable=0 -combat.configure_ranged_player(1); combat.update_ranged_attacks(1) -assert(#loads==1 and loads[1].x==0,"overlap state did not start with selected profile") -random_roll=200; mouse_x=1000; frame=403; combat.update_ranged_attacks(1) -assert(#loads==2 and loads[2].x==0,"locked player attack state was cancelled when cursor left the AI radius") -frame=446; combat.update_ranged_attacks(1) -assert(#loads==4 and loads[3].x==12 and loads[4].x==12,"attack state did not become selectable again after authored duration") -print("form_multiattack_replay=PASS same_path_distinct=true distance_select=true root_offset=true count=true state_lock=true") diff --git a/metamorph_creative_menu/tests/form_network_body_retire_mock.lua b/metamorph_creative_menu/tests/form_network_body_retire_mock.lua deleted file mode 100644 index 93a2578..0000000 --- a/metamorph_creative_menu/tests/form_network_body_retire_mock.lua +++ /dev/null @@ -1,61 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local alive={[1]=true,[2]=true} -local children={[1]={2},[2]={}} -local components={[1]={11,12},[2]={21}} -local types={[11]="LuaComponent",[12]="DamageModelComponent",[21]="LuaComponent"} -local removed_components={} -local disabled={} -local removed_tags={} -local added_tags={} -local killed={} -local loaded=0 -local tree={walk=function(entity,fn) - local function rec(e) fn(e); for _,c in ipairs(children[e] or {}) do rec(c) end end - rec(entity) -end} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua" then return tree end - if path=="mods/metamorph_creative_menu/files/integrations/ew/runtime.lua" then return {enabled=function() return true end} end - return native_dofile(path) -end -EntityGetIsAlive=function(e) return alive[e]==true end -EntityGetAllChildren=function(e) return children[e] or {} end -EntityGetComponentIncludingDisabled=function(e,kind,tag) - if tag~=nil then return {} end - if kind=="LuaComponent" then - local out={}; for _,c in ipairs(components[e] or {}) do if types[c]=="LuaComponent" then out[#out+1]=c end end; return out - end - return {} -end -EntityGetFirstComponentIncludingDisabled=function(e,kind) - if kind=="GameEffectComponent" then return 0 end - if kind=="DamageModelComponent" and e==1 then return 12 end - return 0 -end -EntityGetAllComponents=function(e) return components[e] or {} end -ComponentGetTypeName=function(c) return types[c] end -ComponentGetValue2=function() return nil end -ComponentSetValue2=function() end -EntityRemoveComponent=function(e,c) removed_components[c]=true end -EntitySetComponentIsEnabled=function(e,c,v) disabled[c]=(v==true) end -EntityRemoveTag=function(e,t) removed_tags[t]=true end -EntityAddTag=function(e,t) added_tags[t]=true end -EntitySetTransform=function(e,x,y) assert(x==10000000 and y==10000000) end -EntityKill=function(e) killed[e]=true; alive[e]=false end -EntityGetFilename=function() return "data/entities/animals/boss_centipede/boss_centipede.xml" end -EntityGetTransform=function() return 10,20 end -EntityHasTag=function() return false end -GameGetFrameNum=function() return 10 end -ModDoesFileExist=function() return false end -EntityLoad=function() loaded=loaded+1; return 99 end -local corpse=assert(native_dofile(root.."/files/features/forms/corpse_service.lua")) -assert(corpse.detach(1,"data/entities/animals/boss_centipede/boss_centipede.xml","death",0)==true) -assert(killed[1]==true,"network player-form body remained alive") -assert(removed_components[11] and removed_components[21],"network body death Lua hooks were not stripped") -assert(not added_tags.ew_synced and not added_tags.metamorph_creative_menu_form_corpse, - "network player-form was converted into a second DES corpse") -assert(removed_tags.ew_synced==true and removed_tags.ew_des==true,"network DES tags were not cleared during retirement") -corpse.update() -assert(loaded==0,"network boss retirement spawned a fallback/resurrection body") -print("form_network_body_retire=PASS quiet=true no_ew_synced_clone=true no_boss_respawn_fallback=true") diff --git a/metamorph_creative_menu/tests/form_network_marker_hotpath_mock.lua b/metamorph_creative_menu/tests/form_network_marker_hotpath_mock.lua deleted file mode 100644 index 9cd6cb5..0000000 --- a/metamorph_creative_menu/tests/form_network_marker_hotpath_mock.lua +++ /dev/null @@ -1,73 +0,0 @@ -local root=assert(arg[1], 'root required') -local native_dofile=dofile -local current_player=1 -local frame=100 -local add_tag_calls=0 -local ew_exclusion_calls=0 -local add_storage_calls=0 -local metadata_writes=0 -local runtime_updates=0 -local storage_by_entity={} -local fail_storage_once_entity3=true - -METAMORPH_CREATIVE_MENU_FORM_MANAGER=nil -local stubs={ - ['mods/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua']={get=function() return {SerializeEntity=function() return 'human' end,CrossCallAdd=function() end} end}, - ['mods/metamorph_creative_menu/files/platform/noita/player_locator.lua']={get=function() return current_player end}, - ['mods/metamorph_creative_menu/files/platform/noita/keycodes.lua']={resolve=function() return 43 end}, - ['mods/metamorph_creative_menu/files/features/forms/profile.lua']={get=function() return {} end}, - ['mods/metamorph_creative_menu/files/features/forms/runtime.lua']={reset=function() end,update=function() runtime_updates=runtime_updates+1 end,family=function() return '' end,draw_health=function() end}, - ['mods/metamorph_creative_menu/files/features/forms/exact_effects.lua']={effect_path=function() return 'effect.xml' end,prepare=function() return 1 end,invalidate_failed_target=function() end,default_duration_frames=function() return 1000 end,prepare_from_catalog=function() return 1 end,runtime_target=function(path) return path end,prepared_count=function() return 1 end}, - ['mods/metamorph_creative_menu/files/features/forms/player_authority.lua']={switch=function() return true end}, - ['mods/metamorph_creative_menu/files/features/forms/transform_flash.lua']={suppress=function() end,restore=function() end}, - ['mods/metamorph_creative_menu/files/features/forms/corpse_service.lua']={detach=function() return true end,update=function() end}, - ['mods/metamorph_creative_menu/files/features/forms/human_restore.lua']={protect_player=function() end,polymorph_effect_components=function() return {} end,serialized_backup_from_effects=function() end,deserialize_backup=function() return 0 end}, - ['mods/metamorph_creative_menu/files/integrations/ew/form_death_channel.lua']={register=function() return true end}, -} -dofile=function(path) - if stubs[path]~=nil then return stubs[path] end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -function ModDoesFileExist() return true end -function EntityGetIsAlive(id) return id==1 or id==2 or id==3 or id==99 end -function EntityHasTag(id,tag) return (id==2 or id==3) and tag=='polymorphed_player' end -function EntityGetTransform() return 10,20 end -function GameGetFrameNum() return frame end -function LoadGameEffectEntityTo() current_player=2; return 99 end -function EntityAddTag(id,tag) if tag=='metamorph_creative_menu_network_form' then add_tag_calls=add_tag_calls+1 elseif tag=='ew_no_enemy_sync' then ew_exclusion_calls=ew_exclusion_calls+1 end end -function EntityAddComponent2(id,typ,data) - if typ=='VariableStorageComponent' then - add_storage_calls=add_storage_calls+1 - if id==3 and fail_storage_once_entity3 then fail_storage_once_entity3=false; return 0 end - storage_by_entity[id]=800+id; return 800+id - end - return 0 -end -function EntityGetFirstComponentIncludingDisabled(id,typ,tag) - if id==99 and typ=='GameEffectComponent' then return 77 end - if (id==2 or id==3) and typ=='VariableStorageComponent' and tag=='metamorph_creative_menu_network_form' then return storage_by_entity[id] or 0 end - return 0 -end -function ComponentSetValue2(component,field,value) - if (component==802 or component==803) and (field=='name' or field=='value_string') then metadata_writes=metadata_writes+1 end -end -function ComponentGetValue2() return 0 end -function print() end - -local manager=assert(native_dofile(root..'/files/features/forms/manager.lua')) -assert(manager.transform_creature(1,'data/entities/animals/test.xml',nil,false,{})==true,'transform failed') -assert(add_tag_calls==1 and ew_exclusion_calls==1 and add_storage_calls==1 and metadata_writes==2,'initial network marker/DES exclusion was not written exactly once') -frame=101; manager.update() -frame=102; manager.update() -assert(runtime_updates==2,'form runtime was not updated') -assert(add_tag_calls==1 and add_storage_calls==1 and metadata_writes==2,'network metadata was rewritten on steady-state frames') -current_player=3 -frame=103; manager.update() -assert(add_tag_calls==2 and add_storage_calls==2 and metadata_writes==2,'failed replacement marker attempt was incorrectly treated as committed') -frame=104; manager.update() -assert(add_tag_calls==3 and add_storage_calls==3 and metadata_writes==4,'replacement form metadata did not retry after transient creation failure') -frame=105; manager.update() -assert(add_tag_calls==3 and add_storage_calls==3 and metadata_writes==4,'replacement form metadata was rewritten after successful retry') -io.write('form_network_marker_hotpath=PASS event_driven=true replacement_remarked=true transient_retry=true\n') diff --git a/metamorph_creative_menu/tests/form_network_pressure_mock.lua b/metamorph_creative_menu/tests/form_network_pressure_mock.lua deleted file mode 100644 index cbe4f42..0000000 --- a/metamorph_creative_menu/tests/form_network_pressure_mock.lua +++ /dev/null @@ -1,46 +0,0 @@ -local root=assert(arg[1],'root required') -local globals={} -local tags={[50]={polymorphed_player=true,metamorph_creative_menu_network_form=true}} -local pose_calls=0 -ctx={my_player={entity=50},my_id='me'} -ewext=setmetatable({}, {__index=function(_,key) error('unexpected ewext access: '..tostring(key)) end}) -function EntityGetIsAlive(e) return e==50 end -function EntityHasTag(e,t) return tags[e] and tags[e][t] or false end -function EntityAddTag(e,t) tags[e]=tags[e] or {}; tags[e][t]=true end -function EntityGetTransform() return 100,200,0,1,1 end -function EntityGetComponentIncludingDisabled(e,typ) - if typ=='VariableStorageComponent' then return {} end - return {} -end -function EntityGetFirstComponentIncludingDisabled(e,typ) - if typ=='WormPlayerComponent' then return 701 end - if typ=='WormComponent' then return 702 end - return nil -end -function ComponentGetValue2(c,f) - if c==701 and f=='mDirection' then return 1,0 end - if c==702 and (f=='mTargetSpeed' or f=='speed') then return 20 end - return 0 -end -function ComponentSetValue2() end -function GlobalsGetValue(k,d) return globals[k] or d end -function GlobalsSetValue(k,v) globals[k]=tostring(v) end -function GameGetFrameNum() return tonumber(globals.frame or '0') end -function EntityGetAllChildren() return {} end -util={} -local rpc={opts_reliable=function() end,opts_everywhere=function() end} -local forms=assert(dofile(root..'/files/integrations/ew/bridge/forms.lua')) -forms.register_pose(rpc,{}) -rpc.sync_form_pose=function(...) pose_calls=pose_calls+1 end - -globals.frame='0'; forms.update(0) -assert(tags[50].ew_no_enemy_sync==true,'MCM player-form was not explicitly excluded from DES') -assert(pose_calls==1,'first articulated pose was not sent') -globals.frame='3'; forms.update(3) -assert(pose_calls==1,'articulated pose still uses old 20Hz cadence') -globals.frame='6'; forms.update(6) -assert(pose_calls==2,'articulated 10Hz correction did not send at six frames') -globals.mcm_ew_boss_death_pressure_until_v1='20' -globals.frame='12'; forms.update(12) -assert(pose_calls==2,'pose traffic ignored boss death pressure window') -print('form_network_pressure=PASS early_des_exclusion=true native_poly_exclusion=true articulated_10hz=true death_priority=true') diff --git a/metamorph_creative_menu/tests/form_physics_flying_ik_mock.lua b/metamorph_creative_menu/tests/form_physics_flying_ik_mock.lua deleted file mode 100644 index 4700bed..0000000 --- a/metamorph_creative_menu/tests/form_physics_flying_ik_mock.lua +++ /dev/null @@ -1,46 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local gravity_calls={} -local forces={} -local values={} -local components={ - [10]={type="PhysicsAIComponent"}, - [11]={type="PhysicsBodyComponent"}, - [12]={type="LimbBossComponent"}, -} -local entity_components={[1]={10,11,12}} -local stubs={ - ["mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua"]={walk=function(entity,fn) fn(entity) end}, - ["mods/metamorph_creative_menu/files/features/forms/component_ops.lua"]={ - valid=function(v) return v~=nil and v~=0 end, - first=function(entity,ctype) for _,id in ipairs(entity_components[entity] or {}) do if components[id].type==ctype then return id end end end, - get=function(id,key,default) return values[id] and values[id][key] or default end, - boolean=function(v) return v==true or v==1 end, - ensure_controls=function() return 20 end, - set_type_enabled=function() end, - }, - ["mods/metamorph_creative_menu/files/features/forms/entity_tree_cache.lua"]={components=function(entity,ctype) - local out={};for _,id in ipairs(entity_components[entity] or {}) do if components[id].type==ctype then out[#out+1]=id end end;return out end}, - ["mods/metamorph_creative_menu/files/features/forms/controls.lua"]={direction=function() return 0,-1,true end}, -} -dofile=function(path) if stubs[path] then return stubs[path] end return native_dofile(path) end -function PhysicsSetStatic() end -function PhysicsBodyIDGetFromEntity() return {99} end -function PhysicsBodyIDSetGravityScale(body,scale) gravity_calls[#gravity_calls+1]={body,scale} end -function EntityGetComponentIncludingDisabled() return {} end -function EntityGetTransform() return 100,200,0 end -function ComponentSetValue2(id,key,value) values[id]=values[id] or {};values[id][key]=value end -function PhysicsGetComponentVelocity() return 0,0 end -function PhysicsGetComponentAngularVelocity() return 0 end -function PhysicsApplyForce(entity,x,y) forces[#forces+1]={x,y} end -function PhysicsApplyTorque() end -local adapter=assert(native_dofile(root.."/files/features/forms/adapters/physics.lua")) -local profile={pure_flyer=true,physics_ai={target_vec_max_len=15,force_coeff=10,force_balancing_coeff=0.8,force_max=100}} -adapter.configure(1,profile,true) -assert(#gravity_calls==1 and gravity_calls[1][2]==0,'flying IK boss had gravity forced on') -adapter.update(1,profile,true) -assert(#forces==1 and forces[1][2]<0,'UP input did not produce upward force for flying IK boss') -assert(values[12] and values[12].state==4 and values[12].mMoveToPositionY==104, - 'LimbBoss flight target did not accept upward player input') -dofile=native_dofile -print('form_physics_flying_ik=PASS gravity_zero=true upward_force=true limbboss_up=true') diff --git a/metamorph_creative_menu/tests/form_physics_static_ik_mock.lua b/metamorph_creative_menu/tests/form_physics_static_ik_mock.lua deleted file mode 100644 index 3e4edfe..0000000 --- a/metamorph_creative_menu/tests/form_physics_static_ik_mock.lua +++ /dev/null @@ -1,42 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local static_calls={} -local values={} -local components={ - [10]={type="PhysicsAIComponent"}, - [11]={type="PhysicsBodyComponent"}, - [12]={type="LimbBossComponent"}, -} -local entity_components={[1]={10,11,12}} -local stubs={ - ["mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua"]={walk=function(entity,fn) fn(entity) end}, - ["mods/metamorph_creative_menu/files/features/forms/component_ops.lua"]={ - valid=function(v) return v~=nil and v~=0 end, - first=function(entity,ctype) for _,id in ipairs(entity_components[entity] or {}) do if components[id].type==ctype then return id end end end, - get=function(id,key,default) return values[id] and values[id][key] or default end, - boolean=function(v) return v==true end, - ensure_controls=function() return 20 end, - set_type_enabled=function() end, - }, - ["mods/metamorph_creative_menu/files/features/forms/entity_tree_cache.lua"]={components=function(entity,ctype) - local out={};for _,id in ipairs(entity_components[entity] or {}) do if components[id].type==ctype then out[#out+1]=id end end;return out end}, - ["mods/metamorph_creative_menu/files/features/forms/controls.lua"]={direction=function() return 1,0,true end}, -} -dofile=function(path) if stubs[path] then return stubs[path] end return native_dofile(path) end -function PhysicsSetStatic(e,v) static_calls[#static_calls+1]={e,v} end -function PhysicsBodyIDGetFromEntity() return {} end -function EntityGetComponentIncludingDisabled() return {} end -function EntityGetTransform() return 100,200,0 end -function ComponentSetValue2(id,key,value) values[id]=values[id] or {};values[id][key]=value end -function PhysicsGetComponentVelocity() return 0,0 end -function PhysicsGetComponentAngularVelocity() return 0 end -function PhysicsApplyForce() end -function PhysicsApplyTorque() end -local adapter=assert(native_dofile(root.."/files/features/forms/adapters/physics.lua")) -adapter.configure(1,{physics_ai={}},true) -assert(#static_calls==1 and static_calls[1][1]==1 and static_calls[1][2]==false,"physics/IK form root remained static") -adapter.update(1,{physics_ai={}},true) -assert(values[12] and values[12].state==4,"LimbBoss player form was not placed into MoveTo state") -assert(values[12].mMoveToPositionX==196 and values[12].mMoveToPositionY==200,"LimbBoss MoveTo target not driven by player input") -dofile=native_dofile -print("form_physics_static_ik=PASS root_unstatic=true limbboss_move_to=true") diff --git a/metamorph_creative_menu/tests/form_pose_entity_generation_mock.lua b/metamorph_creative_menu/tests/form_pose_entity_generation_mock.lua deleted file mode 100644 index d74000d..0000000 --- a/metamorph_creative_menu/tests/form_pose_entity_generation_mock.lua +++ /dev/null @@ -1,38 +0,0 @@ -local root = assert(arg[1], "root required") -local applied = {} -ctx = { my_id="me", rpc_peer_id="peer", rpc_player_data={entity=42, currently_polymorphed=true, fps=60} } -EntityGetIsAlive = function(entity) return entity == 42 or entity == 43 end -EntityHasTag = function(entity, tag) return tag == "ew_client" and (entity == 42 or entity == 43) end -EntityGetTransform = function(entity) return entity * 1.0, 0, 0, 1, 1 end -EntitySetTransform = function(entity, x, y) applied[#applied + 1] = {entity=entity, x=x, y=y} end -EntityGetFirstComponentIncludingDisabled = function(entity, component_type) - if component_type == "CharacterDataComponent" then return entity + 100 end - return nil -end -ComponentSetValue2 = function(component, field, x, y) - if field == "mVelocity" then applied[#applied + 1] = {component=component, x=x, y=y} end -end -EntityGetComponentIncludingDisabled = function() return {} end -GameGetRealWorldTimeSinceStarted = function() return 1 end -GlobalsSetValue = function() end -util = {} -local rpc = { opts_reliable=function() end, opts_everywhere=function() end } -local forms = assert(dofile(root .. "/files/integrations/ew/bridge/forms.lua")) -forms.register_pose(rpc, {}) - -rpc.sync_form_pose(100, 10, 20, 0, 1, 1, false, 1, 3, 4, 0) -local sent, received = forms.metrics() -assert(received == 1, "first generation pose was not accepted") - --- Simulate EW replacing the remote player's entity after a reconnect/restart. The new --- sender process can legitimately have a much lower GameGetFrameNum(). -ctx.rpc_player_data.entity = 43 -rpc.sync_form_pose(5, 30, 40, 0, 1, 1, false, 1, 5, 6, 0) -sent, received = forms.metrics() -assert(received == 2, "new remote entity inherited stale source-frame watermark") - --- Reordering protection must still work inside the new generation. -rpc.sync_form_pose(4, 50, 60, 0, 1, 1, false, 1, 7, 8, 0) -sent, received = forms.metrics() -assert(received == 2, "older pose was accepted inside the same remote generation") -print("form_pose_entity_generation=PASS reconnect_frame_reset=true reorder_guard_preserved=true") diff --git a/metamorph_creative_menu/tests/form_primary_projectile_replay_mock.lua b/metamorph_creative_menu/tests/form_primary_projectile_replay_mock.lua deleted file mode 100644 index 5ed27d7..0000000 --- a/metamorph_creative_menu/tests/form_primary_projectile_replay_mock.lua +++ /dev/null @@ -1,132 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local controls,animal,sprite_animator,sprite=10,20,30,40 -local frame=100 -local loads,shots=0,0 -local animations={} -local refreshed=0 -local dynamic_next=false -local loaded_paths={} -local enabled={} -local values={ - [controls]={mButtonDownFire=true,mButtonDownFire2=false,polymorph_hax=true}, - [animal]={attack_ranged_entity_file="data/entities/projectiles/buckshot.xml",attack_ranged_enabled=true,attack_ranged_use_message=false,attack_ranged_frames_between=100,attack_ranged_entity_count_min=3,attack_ranged_entity_count_max=4,attack_ranged_action_frame=4,attack_ranged_offset_x=2,attack_ranged_offset_y=-7,attack_ranged_min_distance=0,attack_ranged_max_distance=180,attack_ranged_aim_rotation_enabled=false,mRangedAttackNextFrame=0}, - [sprite]={image_file="data/enemies_gfx/shotgunner.xml"}, -} -local component_ops={ - valid=function(v) return v~=nil and v~=0 end, - first=function(_,kind) - if kind=="ControlsComponent" then return controls end - if kind=="SpriteAnimatorComponent" then return sprite_animator end - end, - get=function(c,f,d) local v=values[c] and values[c][f]; if v==nil then return d end return v end, - boolean=function(v) return v==true or v==1 end, - ensure_controls=function() return controls end, - set_type_enabled=function() end,set_type_enabled_tree=function() end, -} -local tree={components=function(_,kind) - if kind=="AnimalAIComponent" then return {animal} end - if kind=="AIAttackComponent" then return {} end - return {} -end, reset=function() end} -local entity_tree={walk=function(e,fn) fn(e) end} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/features/forms/component_ops.lua" then return component_ops end - if path=="mods/metamorph_creative_menu/files/features/forms/entity_tree_cache.lua" then return tree end - if path=="mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua" then return entity_tree end - local prefix="mods/metamorph_creative_menu/"; if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -function ComponentGetEntity(c) return c==animal and 1 or 1 end -function ComponentSetValue2(c,f,v) values[c]=values[c] or {}; values[c][f]=v end -function EntitySetComponentIsEnabled(_,c,v) enabled[c]=v==true end -function ComponentGetIsEnabled(c) if enabled[c]==nil then return true end return enabled[c] end -function EntityRefreshSprite() refreshed=refreshed+1 end -function EntityGetComponentIncludingDisabled(_,kind) - if kind=="AnimalAIComponent" then return {animal} end - if kind=="AIAttackComponent" then return {} end - if kind=="SpriteComponent" then return {sprite} end - if kind=="SpriteAnimatorComponent" then return {sprite_animator} end - return {} -end -function EntityGetRootEntity() return 1 end -function EntityGetTransform() return 10,20,0,1,1 end -function EntityGetIsAlive() return true end -function DEBUG_GetMouseWorld() return 110,20 end -function GameGetFrameNum() return frame end -function ModTextFileGetContent(path) - assert(path=="data/enemies_gfx/shotgunner.xml","unexpected sprite path") - return [[]] -end -function Random(a,b) if dynamic_next then return a end; assert(a==3 and b==4); return 4 end -function EntityLoad(path,x,y) loads=loads+1; loaded_paths[#loaded_paths+1]=path; return 100+loads end -function EntityKill() end -function GameShootProjectile(shooter,sx,sy,tx,ty,projectile,send_message) - shots=shots+1 - assert(shooter==1 and sx==12 and sy==13,"authored muzzle offset not used") - assert(tx==110 and ty==20,"cursor target changed") - assert(send_message==true,"projectile message semantics changed") - if dynamic_next then - -- Simulate a synchronous vanilla LuaComponent.script_shot callback (Boss Wizard): - -- Message_Shot authors the next projectile and its interval on AnimalAIComponent. - values[animal].attack_ranged_entity_file="data/entities/animals/boss_wizard/meteor.xml" - values[animal].attack_ranged_frames_between=220 - end -end -function GamePlayAnimation(_,name,priority) animations[#animations+1]={name=name,priority=priority} end -function EntityAddComponent2() return 0 end -function EntityAddTag() end -function EntityCreateNew() return 0 end -local combat=assert(native_dofile(root.."/files/features/forms/combat.lua")) -assert(combat.configure_ranged_player(1)==true,"direct ranged ownership was not acquired") -assert(values[animal].attack_ranged_enabled==true,"manual replay destroyed the authored logical attack-enabled state") -assert(values[animal].attack_ranged_entity_file=="data/entities/projectiles/buckshot.xml","manual ownership mutated native projectile descriptor") - -assert(combat.update_ranged_attacks(1)==true,"primary attack was not reserved") -assert(values[animal].mRangedAttackNextFrame==200,"AnimalAI native ranged cooldown was not mirrored") -assert(values[controls].polymorph_hax==false,"manual replay did not disable polymorph_hax") -assert(loads==0,"shot fired before authored animation action frame") -frame=135; combat.update_ranged_attacks(1); assert(loads==0,"shot fired one frame too early") -frame=136; combat.update_ranged_attacks(1) -assert(loads==4 and shots==4,"shotgun did not fire exact authored projectile count") -for _,p in ipairs(loaded_paths) do assert(p=="data/entities/projectiles/buckshot.xml","fake/wrong projectile entity used") end -assert(values[sprite].rect_animation=="attack_ranged","ranged replay did not drive the authored attack sprite") -assert(enabled[sprite_animator]==false,"locomotion animator was not suspended during the attack window") -assert(refreshed>0,"attack sprite was not refreshed") -local animation_count=#animations -assert(animation_count==0,"supported sprite unexpectedly used sticky GamePlayAnimation fallback") -frame=150; combat.update_ranged_attacks(1); assert(loads==4,"local authored cooldown ignored") -assert(values[sprite].rect_animation=="","attack override did not restore previous locomotion sprite state") -assert(enabled[sprite_animator]==true,"locomotion animator was not restored after attack") -assert(#animations==animation_count,"ranged state exit injected a synthetic locomotion animation") - --- A manual action adapter can reserve either mouse button. The common ranged layer must --- not observe that same press, otherwise dash/melee/wand actions double-fire a projectile. -combat.reset(); loads=0; shots=0; loaded_paths={}; dynamic_next=false; frame=250 -values[animal].mRangedAttackNextFrame=0 -values[controls].mButtonDownFire=false; values[controls].mButtonDownFire2=true -assert(combat.configure_ranged_player(1)==true,"ownership missing for input reservation test") -combat.update_ranged_attacks(1,false,true) -assert(loads==0 and shots==0,"reserved secondary input leaked into ranged replay") -combat.reset(); frame=251; values[animal].mRangedAttackNextFrame=0 -values[controls].mButtonDownFire=true; values[controls].mButtonDownFire2=false -assert(combat.configure_ranged_player(1)==true,"ownership missing for primary reservation test") -combat.update_ranged_attacks(1,true,false) -assert(loads==0 and shots==0,"reserved primary input leaked into ranged replay") - --- Message_Shot-driven attack mutation must survive manual ownership on the live descriptor. --- polymorph_hax remains disabled, so the next shot uses the callback-authored projectile once. -combat.reset(); loads=0; shots=0; loaded_paths={}; dynamic_next=true; frame=300 -values[animal].attack_ranged_entity_file="data/entities/animals/boss_wizard/debuff_init.xml" -values[animal].attack_ranged_enabled=true; values[animal].attack_ranged_frames_between=240 -values[animal].attack_ranged_entity_count_min=1; values[animal].attack_ranged_entity_count_max=1 -values[animal].attack_ranged_action_frame=0; values[animal].mRangedAttackNextFrame=0 -assert(combat.configure_ranged_player(1)==true,"dynamic attack did not acquire ownership") -combat.update_ranged_attacks(1) -assert(loads==1 and loaded_paths[1]=="data/entities/animals/boss_wizard/debuff_init.xml","dynamic attack initial projectile changed") -assert(values[animal].attack_ranged_entity_file=="data/entities/animals/boss_wizard/meteor.xml","script_shot-authored live descriptor was overwritten") -assert(values[animal].mRangedAttackNextFrame==520,"script_shot-authored cooldown did not replace stale interval") -frame=519; combat.update_ranged_attacks(1); assert(loads==1,"dynamic cooldown fired early") -frame=520; combat.update_ranged_attacks(1) -assert(loads==2 and loaded_paths[2]=="data/entities/animals/boss_wizard/meteor.xml","next script_shot-authored projectile was not replayed") -print("form_primary_projectile_replay=PASS attack_sprite_override=true locomotion_restored=true no_forced_stand=true polymorph_hax_suppressed=true descriptor_preserved=true projectile_exact=true count=4 action_delay=36 cooldown=true dynamic_shot=true") diff --git a/metamorph_creative_menu/tests/form_projectile_failure_mock.lua b/metamorph_creative_menu/tests/form_projectile_failure_mock.lua index c58b92e..af41ecf 100644 --- a/metamorph_creative_menu/tests/form_projectile_failure_mock.lua +++ b/metamorph_creative_menu/tests/form_projectile_failure_mock.lua @@ -31,7 +31,6 @@ dofile=function(path) end function ComponentGetEntity() return 1 end function ComponentSetValue2(c,f,v) values[c]=values[c] or {}; values[c][f]=v end -function ComponentGetIsEnabled() return true end function EntityGetRootEntity() return 1 end function EntityGetTransform() return 0,0,0,1,1 end function DEBUG_GetMouseWorld() return 100,0 end @@ -40,11 +39,7 @@ function ModDoesFileExist(path) return path=="secondary.xml" end function EntityLoad() loads=loads+1; return 100+loads end function EntityKill(e) killed[e]=true end function GameShootProjectile() error("native shoot failed") end -function EntityGetComponentIncludingDisabled(_,kind) - if kind=="AnimalAIComponent" then return {animal} end - if kind=="AIAttackComponent" then return {attack} end - return {} -end +function EntityGetComponentIncludingDisabled() return {} end function GamePlayAnimation() end local combat=assert(native_dofile(root.."/files/features/forms/combat.lua")) local first=combat.update_secondary_attacks(1) diff --git a/metamorph_creative_menu/tests/form_ranged_aim_replay_mock.lua b/metamorph_creative_menu/tests/form_ranged_aim_replay_mock.lua deleted file mode 100644 index ed8aa65..0000000 --- a/metamorph_creative_menu/tests/form_ranged_aim_replay_mock.lua +++ /dev/null @@ -1,72 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local controls,animal=10,20 -local frame=100 -local mouse_x,mouse_y=0,100 -local loads,shots=0,{} -local values={ - [controls]={mButtonDownFire=true,mButtonDownFire2=false}, - [animal]={ - attack_ranged_entity_file="data/entities/projectiles/rocket_tiny_roll.xml", - attack_ranged_enabled=true,attack_ranged_use_message=false, - attack_ranged_frames_between=90,attack_ranged_entity_count_min=1,attack_ranged_entity_count_max=1, - attack_ranged_action_frame=0,attack_ranged_offset_x=0,attack_ranged_offset_y=0, - attack_ranged_min_distance=0,attack_ranged_max_distance=400, - attack_ranged_aim_rotation_enabled=true,attack_ranged_aim_rotation_speed=0.1, - attack_ranged_aim_rotation_shooting_ok_angle_deg=5, - mRangedAttackCurrentAimAngle=0, - }, -} -local component_ops={ - valid=function(v)return v~=nil and v~=0 end, - first=function(_,k)if k=="ControlsComponent" then return controls end end, - get=function(c,f,d)local v=values[c] and values[c][f]; if v==nil then return d end return v end, - boolean=function(v)return v==true or v==1 end, - ensure_controls=function()return controls end, - set_type_enabled=function()end,set_type_enabled_tree=function()end, -} -local tree={components=function(_,kind) if kind=="AnimalAIComponent" then return {animal} end return {} end,reset=function()end} -local entity_tree={walk=function(e,fn)fn(e)end} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/features/forms/component_ops.lua" then return component_ops end - if path=="mods/metamorph_creative_menu/files/features/forms/entity_tree_cache.lua" then return tree end - if path=="mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua" then return entity_tree end - local prefix="mods/metamorph_creative_menu/"; if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -function ComponentGetEntity() return 1 end -function ComponentSetValue2(c,f,v) values[c]=values[c] or {}; values[c][f]=v end -function EntitySetComponentIsEnabled() end -function EntityGetComponentIncludingDisabled(_,kind) if kind=="AnimalAIComponent" then return {animal} end return {} end -function EntityGetRootEntity() return 1 end -function EntityGetTransform() return 0,0,0,1,1 end -function EntitySetTransform() end -function EntityGetIsAlive() return true end -function DEBUG_GetMouseWorld() return mouse_x,mouse_y end -function GameGetFrameNum() return frame end -function Random(a,b) return a end -function EntityLoad(path,x,y) loads=loads+1; return 100+loads end -function GameShootProjectile(_,sx,sy,tx,ty) shots[#shots+1]={sx=sx,sy=sy,tx=tx,ty=ty,angle=values[animal].mRangedAttackCurrentAimAngle} end -function EntityKill() end -function EntityCreateNew() return 0 end -function EntityAddTag() end -function GamePlayAnimation() end -local combat=assert(native_dofile(root.."/files/features/forms/combat.lua")) -assert(combat.configure_ranged_player(1)==true,"aimed creature did not acquire manual ranged ownership") --- Cursor starts at +90 degrees while the authored barrel starts at 0 and can only turn 0.1 rad/frame. -for i=1,14 do - frame=99+i - combat.update_manual_aim(1) - combat.update_ranged_attacks(1) - assert(loads==0,"aimed creature fired before authored barrel entered shooting tolerance") -end -frame=114 -combat.update_manual_aim(1) -combat.update_ranged_attacks(1) -assert(loads==1 and #shots==1,"aimed creature did not fire once barrel reached authored tolerance") -local a=shots[1].angle -assert(math.abs(a-1.5)<0.0001,"authored aim rotation speed was not preserved") -assert(math.abs(shots[1].tx - math.cos(a)*1000)<0.001 and math.abs(shots[1].ty - math.sin(a)*1000)<0.001, - "projectile target snapped to cursor instead of using physical barrel direction") -assert(not (math.abs(shots[1].tx-mouse_x)<0.001 and math.abs(shots[1].ty-mouse_y)<0.001),"aimed shot ignored barrel direction") -print("form_ranged_aim_replay=PASS authored_turn_rate=true shooting_tolerance=true barrel_direction=true") diff --git a/metamorph_creative_menu/tests/form_remote_prepare_profile_mock.lua b/metamorph_creative_menu/tests/form_remote_prepare_profile_mock.lua deleted file mode 100644 index f4be4f3..0000000 --- a/metamorph_creative_menu/tests/form_remote_prepare_profile_mock.lua +++ /dev/null @@ -1,89 +0,0 @@ -local root = assert(arg[1], "root required") -local globals = {} -local created = {} -local time_calls = 0 -local enable_calls = 0 - -ctx = { - my_id = "me", - rpc_peer_id = "peer", - rpc_player_data = { entity=42, currently_polymorphed=true, fps=60 }, -} -GameGetRealWorldTimeSinceStarted = function() - time_calls = time_calls + 1 - return time_calls == 1 and 10.000 or 10.012 -end -GameGetFrameNum = function() return 100 end -GlobalsSetValue = function(key, value) globals[key] = tostring(value) end -EntityGetIsAlive = function(entity) return entity == 42 or entity == 43 or entity == 44 end -EntityHasTag = function(entity, tag) return entity == 42 and tag == "ew_client" end -EntityGetAllChildren = function(entity) - if entity == 42 then return {43,44} end - return {} -end -EntityGetTransform = function() return 0, 0, 0, 1, 1 end -EntitySetTransform = function() end -EntitySetComponentIsEnabled = function(entity, component, enabled) - assert(enabled == false or enabled == true) - enable_calls = enable_calls + 1 -end -EntityGetComponentIncludingDisabled = function(entity, component_type) - if component_type == "VariableStorageComponent" and entity == 42 then return {501} end - if component_type == "WormAIComponent" and entity == 42 then return {301} end - if component_type == "CellEaterComponent" and entity == 43 then return {302} end - if component_type == "AIAttackComponent" and entity == 44 then return {303} end - return {} -end -ComponentGetValue2 = function(component, field) - if component == 501 then - if field == "name" then return "metamorph_creative_menu_network_source" end - if field == "value_string" then return "data/entities/animals/boss_dragon.xml" end - end - if component == 101 then - local values = { - speed=2, acceleration=3, part_distance=10, ground_check_offset=0, - hitbox_radius=1, bite_damage=2, target_kill_radius=1, - target_kill_ragdoll_force=1, eat_anim_wait_mult=0.05, jump_cam_shake=20, - } - return values[field] or 0 - end - if component == 202 and field == "mDirection" then return 0, 0 end - if component == 201 and (field == "mTargetSpeed" or field == "speed") then return 2 end - return 0 -end -ComponentSetValue2 = function() end -EntityGetFirstComponentIncludingDisabled = function(entity, component_type) - if entity ~= 42 then return nil end - if component_type == "BossDragonComponent" then return 101 end - if component_type == "WormComponent" then return created.WormComponent end - if component_type == "WormPlayerComponent" then return created.WormPlayerComponent end - return nil -end -EntityAddComponent2 = function(entity, component_type, values) - assert(entity == 42) - local id = component_type == "WormComponent" and 201 or 202 - created[component_type] = id - return id -end -util = {} - -local rpc = { - opts_reliable=function() end, - opts_everywhere=function() end, -} -local forms = assert(dofile(root .. "/files/integrations/ew/bridge/forms.lua")) -forms.set_profiling_enabled(true) -forms.register_pose(rpc, {}) -assert(type(rpc.sync_form_pose) == "function", "pose rpc was not registered") -rpc.sync_form_pose(10, 100, 200, 0, 1, 1, false, 5, 1, 0, 2) -assert(globals.mcm_form_remote_prepare_ms_v1 == "12.000", "remote prepare timing wrong") -assert(globals.mcm_form_remote_prepare_entities_v1 == "3", "remote tree count wrong") -assert(globals.mcm_form_remote_prepare_components_v1 == "3", "remote component count wrong") -assert(globals.mcm_form_remote_prepare_kind_v1 == "5", "remote kind missing") -assert(globals.mcm_form_remote_prepare_source_v1 == "data/entities/animals/boss_dragon.xml", "remote source missing") -assert(created.WormComponent == 201 and created.WormPlayerComponent == 202, "profiling changed articulated preparation") -local first_time_calls = time_calls -rpc.sync_form_pose(11, 101, 200, 0, 1, 1, false, 5, 1, 0, 2) -assert(time_calls == first_time_calls, "cached pose path still pays profiling timer cost") -print("form_remote_prepare_profile=PASS first_prepare_only=true cached_hotpath_unchanged=true") -function ComponentGetIsEnabled() return true end diff --git a/metamorph_creative_menu/tests/form_return_controls_mock.lua b/metamorph_creative_menu/tests/form_return_controls_mock.lua deleted file mode 100644 index ff8cf80..0000000 --- a/metamorph_creative_menu/tests/form_return_controls_mock.lua +++ /dev/null @@ -1,109 +0,0 @@ -local root = assert(arg[1], "root required") -local native_dofile = dofile - -local current_player = 1 -local frame = 50 -local alive = {[1]=true,[2]=true,[3]=true,[99]=true} -local control_fields = {[303]={enabled=false,polymorph_hax=true}} -local component_enabled = {[303]=false} -local effect_frames = 2147480000 - -METAMORPH_CREATIVE_MENU_FORM_MANAGER = nil - -local bridge = { - SerializeEntity=function(entity) - assert(entity==1,"wrong human serialized before polymorph") - return "disabled-controls-human" - end, - CrossCallAdd=function() return true end, -} - -local stubs = { - ["mods/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua"]={get=function() return bridge end}, - ["mods/metamorph_creative_menu/files/platform/noita/player_locator.lua"]={get=function() return current_player end}, - ["mods/metamorph_creative_menu/files/platform/noita/keycodes.lua"]={resolve=function() return 15 end}, - ["mods/metamorph_creative_menu/files/features/forms/profile.lua"]={get=function() return {} end}, - ["mods/metamorph_creative_menu/files/features/forms/runtime.lua"]={ - update=function() end, reset=function() end, family=function() return "character" end, - }, - ["mods/metamorph_creative_menu/files/features/forms/exact_effects.lua"]={ - effect_path=function() return "mods/metamorph_creative_menu/files/generated/test_effect.xml" end, - invalidate_failed_target=function() end, prepare=function() return 1 end, - prepare_from_catalog=function() return 1 end, runtime_target=function(path) return path end, - default_duration_frames=function() return 2147480000 end, - }, - ["mods/metamorph_creative_menu/files/features/forms/player_authority.lua"]={switch=function() return true,"committed" end}, - ["mods/metamorph_creative_menu/files/features/forms/transform_flash.lua"]={restore=function() end,suppress=function() end}, - ["mods/metamorph_creative_menu/files/features/forms/corpse_service.lua"]={detach=function() return true end,update=function() end}, - ["mods/metamorph_creative_menu/files/integrations/ew/form_death_channel.lua"]={register=function() return true end}, -} - -dofile=function(path) - if stubs[path] ~= nil then return stubs[path] end - local prefix="mods/metamorph_creative_menu/" - if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -function GameGetFrameNum() return frame end -function GameGetGameEffect(entity,effect) - if entity==2 and effect=="POLYMORPH" then return 202 end - return 0 -end -function EntityGetIsAlive(entity) return alive[entity]==true end -function EntityHasTag(entity,tag) return entity==2 and tag=="polymorphed_player" end -function EntityGetTransform() return 12,34 end -function EntityGetAllChildren() return {} end -function EntityGetComponentIncludingDisabled(entity,kind) - if entity==3 and kind=="ControlsComponent" then return {303} end - return {} -end -function EntityGetFirstComponentIncludingDisabled(entity,kind) - if entity==99 and kind=="GameEffectComponent" then return 77 end - if entity==3 and kind=="ControlsComponent" then return 303 end - return 0 -end -function EntityGetComponentIsEnabled(entity,component) return component_enabled[component]==true end -function EntitySetComponentIsEnabled(entity,component,enabled) - assert(entity==3 and component==303,"wrong restored control component") - component_enabled[component]=enabled==true -end -function EntityAddTag() end -function EntityAddComponent2() return 250 end -function ComponentGetValue2(component,field) - local fields=control_fields[component] - return fields and fields[field] or nil -end -function ComponentSetValue2(component,field,value) - if component==202 and field=="frames" then effect_frames=value end - local fields=control_fields[component] - if fields then fields[field]=value end -end -function ModDoesFileExist() return true end -function LoadGameEffectEntityTo() return 99 end -function print() end - -local manager=assert(native_dofile(root.."/files/features/forms/manager.lua")) -local transformed,reason=manager.transform_creature(1,"data/entities/animals/test.xml") -assert(transformed==true,"transform setup failed: "..tostring(reason)) - -current_player=2 -frame=51 -manager.update() -assert(manager.session_phase()=="active","creature form did not become active") -local returning,return_reason=manager.return_to_human() -assert(returning==true and return_reason=="expire" and effect_frames==1,"native return was not requested") - --- Native polymorph restores the serialized human on its own. Reproduce the real failure: --- that snapshot was taken while a menu click had ControlsComponent.enabled=false. -alive[2]=false -current_player=3 -frame=52 -manager.update() - -assert(control_fields[303].enabled==true,"restored human ControlsComponent.enabled stayed false") -assert(control_fields[303].polymorph_hax==false,"restored human kept the form-only polymorph_hax flag") -assert(component_enabled[303]==true,"restored human ControlsComponent stayed engine-disabled") -assert(manager.session_phase()=="human","form session survived native human restoration") - -io.write("form_return_controls=PASS native_return=true controls_reactivated=true polymorph_flag_cleared=true\n") diff --git a/metamorph_creative_menu/tests/form_rotating_muzzle_origin_mock.lua b/metamorph_creative_menu/tests/form_rotating_muzzle_origin_mock.lua deleted file mode 100644 index cfbf9e2..0000000 --- a/metamorph_creative_menu/tests/form_rotating_muzzle_origin_mock.lua +++ /dev/null @@ -1,71 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local controls,attack=10,30 -local frame=100 -local loaded,shots={},{} -local values={ - [controls]={mButtonDownFire=true,mButtonDownFire2=false}, - [attack]={ - attack_ranged_entity_file="data/entities/projectiles/rocket_tank.xml", - frames_between=50,frames_between_global=50, - attack_ranged_entity_count_min=1,attack_ranged_entity_count_max=1, - attack_ranged_action_frame=0,animation_name="", - min_distance=0,max_distance=300,use_probability=100,state_duration_frames=45, - attack_ranged_root_offset_x=0,attack_ranged_root_offset_y=-5, - attack_ranged_offset_x=10,attack_ranged_offset_y=-3, - attack_ranged_use_message=false,attack_landing_ranged_enabled=false, - attack_ranged_aim_rotation_enabled=true,attack_ranged_aim_rotation_speed=10, - attack_ranged_aim_rotation_shooting_ok_angle_deg=5, - attack_ranged_use_laser_sight=true, - mRangedAttackCurrentAimAngle=0,mNextFrameUsable=0, - }, -} -local component_ops={ - valid=function(v)return v~=nil and v~=0 end, - first=function(_,kind)if kind=="ControlsComponent" then return controls end end, - get=function(c,f,d)local v=values[c] and values[c][f];if v==nil then return d end return v end, - boolean=function(v)return v==true or v==1 end, - ensure_controls=function()return controls end, - set_type_enabled=function()end,set_type_enabled_tree=function()end, -} -local tree={components=function(_,kind)if kind=="AIAttackComponent" then return {attack} end return {} end,reset=function()end} -local entity_tree={walk=function(e,fn)fn(e)end} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/features/forms/component_ops.lua" then return component_ops end - if path=="mods/metamorph_creative_menu/files/features/forms/entity_tree_cache.lua" then return tree end - if path=="mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua" then return entity_tree end - local prefix="mods/metamorph_creative_menu/";if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -function ComponentGetEntity()return 1 end -function ComponentSetValue2(c,f,v)values[c]=values[c] or {};values[c][f]=v end -function EntitySetComponentIsEnabled()end -function ComponentGetIsEnabled() return true end -function EntityGetComponentIncludingDisabled(_,kind)if kind=="AIAttackComponent" then return {attack} end return {} end -function EntityGetRootEntity()return 1 end -function EntityGetTransform()return 100,200,0,1,1 end -function EntitySetTransform()end -function EntityGetIsAlive()return true end -function DEBUG_GetMouseWorld()return 100,300 end -function GameGetFrameNum()return frame end -function Random(a,b)return a end -function EntityLoad(path,x,y)loaded[#loaded+1]={path=path,x=x,y=y};return 100+#loaded end -function GameShootProjectile(_,sx,sy,tx,ty)shots[#shots+1]={sx=sx,sy=sy,tx=tx,ty=ty} return true end -function EntityKill()end -function EntityCreateNew()return 0 end -function EntityAddTag()end -function GamePlayAnimation()end -local combat=assert(native_dofile(root.."/files/features/forms/combat.lua")) -assert(combat.configure_ranged_player(1)==true,"tank-like AIAttack did not acquire manual ownership") -combat.update_manual_aim(1) -assert(math.abs((values[attack].mRangedAttackCurrentAimAngle or 0)-math.pi*0.5)<0.0001,"aim did not rotate around authored pivot toward cursor") -assert(combat.update_ranged_attacks(1)==true,"tank-like attack did not fire") -assert(#loaded==1 and #shots==1,"tank-like attack did not create exactly one projectile") --- tank.xml proves the geometry relationship: gun Sprite transform_offset.y=-5 exactly --- matches attack_ranged_root_offset_y=-5. The muzzle vector (10,-3) is therefore --- relative to that pivot and rotates with the physical aim. At +90deg it becomes (3,10). -assert(math.abs(loaded[1].x-103)<0.001 and math.abs(loaded[1].y-205)<0.001, - "rotating AIAttack muzzle was treated as a fixed world-space offset") -assert(math.abs(shots[1].sx-loaded[1].x)<0.001 and math.abs(shots[1].sy-loaded[1].y)<0.001,"shoot origin disagreed with loaded projectile origin") -assert(math.abs(shots[1].tx-103)<0.01 and shots[1].ty>1200,"projectile direction did not follow physical barrel angle") -print("form_rotating_muzzle_origin=PASS pivot=true rotated_muzzle=true barrel_direction=true") diff --git a/metamorph_creative_menu/tests/form_save_recovery_mock.lua b/metamorph_creative_menu/tests/form_save_recovery_mock.lua deleted file mode 100644 index 5a3cdcc..0000000 --- a/metamorph_creative_menu/tests/form_save_recovery_mock.lua +++ /dev/null @@ -1,92 +0,0 @@ -local root = assert(arg[1], "root required") -local native_dofile = dofile - -local active_player = 10 -local alive = {[10]=true,[20]=true} -local frame = 100 -local effect_frames = 2147478898 -local runtime_updates = 0 -local runtime_resets = 0 -local switched_to = 0 - -function GameGetFrameNum() return frame end -function EntityGetIsAlive(entity) return alive[entity] == true end -function EntityHasTag(entity, tag) - return entity == 10 and (tag == "polymorphed_player" or tag == "metamorph_creative_menu_network_form") -end -function EntityGetTransform(entity) return 40, 80 end -function EntityGetComponentIncludingDisabled(entity, kind) - if entity == 10 and kind == "VariableStorageComponent" then return {101} end - return {} -end -function EntityGetFirstComponentIncludingDisabled() return nil end -function ComponentGetValue2(component, field) - if component == 101 and field == "name" then return "metamorph_creative_menu_network_source" end - if component == 101 and field == "value_string" then return "data/entities/animals/coward.xml" end - if component == 201 and field == "polymorph_target" then return "data/entities/animals/coward.xml" end - if component == 201 and field == "frames" then return effect_frames end -end -function ComponentSetValue2(component, field, value) - if component == 201 and field == "frames" then effect_frames = value end -end -function EntityKill(entity) alive[entity] = false end - -local bridge = { - DeserializeEntity=function() end, - SetPlayerEntity=function(entity) active_player=entity end, - GetPlayerEntity=function() return active_player end, - CrossCallAdd=function() return true end, -} -local stubs = { - ["mods/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua"]={get=function() return bridge end}, - ["mods/metamorph_creative_menu/files/platform/noita/player_locator.lua"]={get=function() return active_player end}, - ["mods/metamorph_creative_menu/files/platform/noita/keycodes.lua"]={resolve=function() return 15 end}, - ["mods/metamorph_creative_menu/files/features/forms/profile.lua"]={get=function(target) return {target=target} end}, - ["mods/metamorph_creative_menu/files/features/forms/runtime.lua"]={ - update=function() runtime_updates=runtime_updates+1 end, - reset=function() runtime_resets=runtime_resets+1 end, - }, - ["mods/metamorph_creative_menu/files/features/forms/exact_effects.lua"]={}, - ["mods/metamorph_creative_menu/files/features/forms/player_authority.lua"]={ - switch=function(_, previous, replacement) - assert(previous==10 and replacement==20, "wrong recovery authority handoff") - active_player=replacement; switched_to=replacement; return true,"confirmed" - end, - }, - ["mods/metamorph_creative_menu/files/features/forms/transform_flash.lua"]={restore=function() end,suppress=function() end}, - ["mods/metamorph_creative_menu/files/features/forms/corpse_service.lua"]={detach=function() end,update=function() end}, - ["mods/metamorph_creative_menu/files/features/forms/human_restore.lua"]={ - protect_player=function() end, - polymorph_effect_components=function(entity) return entity==10 and {201} or {} end, - serialized_backup_from_effects=function() return "serialized-human-from-save" end, - deserialize_backup=function(_, backup, x, y) - assert(backup=="serialized-human-from-save" and x==40 and y==80,"saved human backup was not used") - return 20 - end, - }, - ["mods/metamorph_creative_menu/files/integrations/ew/form_death_channel.lua"]={register=function(_, callback) return type(callback)=="function" end}, -} -dofile=function(path) - if stubs[path] ~= nil then return stubs[path] end - local prefix="mods/metamorph_creative_menu/" - if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -METAMORPH_CREATIVE_MENU_FORM_MANAGER=nil -local manager=assert(native_dofile(root.."/files/features/forms/manager.lua")) - -assert(manager.has_active_form()==true,"saved marked form was invisible before session recovery") -assert(manager.update()==false,"ordinary recovery update should not complete a return") -assert(manager.has_active_form()==true and manager.session_phase()=="active","saved form session was not rehydrated") -assert(manager.session_target()=="data/entities/animals/coward.xml","saved form target was not recovered") -assert(runtime_updates==1,"recovered form runtime did not resume") - -local started, reason=manager.return_to_human() -assert(started==true and reason=="expire" and effect_frames==1,"return did not expire saved polymorph") -frame=113 -assert(manager.update()==true,"saved-form fallback restore did not complete") -assert(switched_to==20 and active_player==20 and alive[10]==false,"human authority was not restored transactionally") -assert(manager.has_active_form()==false and runtime_resets>=1,"recovered session was not cleared") - -io.write("form_save_recovery=PASS detected_before_update=true session_rehydrated=true serialized_human_restored=true\n") diff --git a/metamorph_creative_menu/tests/form_scripted_attack_math_mock.lua b/metamorph_creative_menu/tests/form_scripted_attack_math_mock.lua deleted file mode 100644 index a5da3f5..0000000 --- a/metamorph_creative_menu/tests/form_scripted_attack_math_mock.lua +++ /dev/null @@ -1,14 +0,0 @@ -local root=assert(arg[1],"root required") -local mathx=assert(dofile(root.."/files/features/forms/scripted_attack_math.lua")) -local function near(a,b) return math.abs(a-b)<0.00001 end -local dragon=mathx.radial(10,100,0,false) -assert(#dragon==10 and near(dragon[1].x,100) and near(dragon[1].y,0),"boss dragon radial geometry changed") -local limbs=mathx.radial_degrees(8,230,0) -assert(#limbs==8 and near(limbs[1].x,230) and near(limbs[3].y,230),"boss limbs 8-way geometry changed") -local fish=mathx.radial(8,80,math.pi*0.25,true) -assert(#fish==8 and fish[1].y<0,"fish giga y-axis convention changed") -local fire=mathx.firepillar(10) -assert(#fire==10 and fire[1].y<0 and fire[#fire].y<0,"centipede firepillar fan changed") -local vx,vy=mathx.aimed(0,0,3,4,20) -assert(near(vx,12) and near(vy,16),"aimed normalization changed") -print("form_scripted_attack_math=PASS dragon10=true limbs8=true fish8=true firepillar=true aimed=true") diff --git a/metamorph_creative_menu/tests/form_scripted_attack_replay_mock.lua b/metamorph_creative_menu/tests/form_scripted_attack_replay_mock.lua deleted file mode 100644 index 33fe164..0000000 --- a/metamorph_creative_menu/tests/form_scripted_attack_replay_mock.lua +++ /dev/null @@ -1,83 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local controls=10 -local lua_dragon=20 -local genome=30 -local frame=0 -local next_entity=100 -local children={[1]={2},[2]={}} -local alive={[1]=true,[2]=true} -local enabled={[lua_dragon]=true} -local owner_of={[lua_dragon]=2,[genome]=1,[controls]=1} -local values={ - [controls]={mButtonDownFire=true,mButtonDownFire2=false}, - [lua_dragon]={script_source_file="data/scripts/projectiles/orb_green_dragon.lua",_enabled=true}, - [genome]={herd_id=7}, -} -local transforms={[1]={10,20,0,1,1},[2]={12,18,0,1,1}} -local projectile_components={} -local velocity_components={} -local shots={} -local component_ops={ - valid=function(v)return v~=nil and v~=0 end, - get=function(c,f,d)local v=values[c] and values[c][f];if v==nil then return d end;return v end, - boolean=function(v)return v==true or v==1 end, - ensure_controls=function()return controls end, -} -local entity_tree={walk=function(e,fn) - local function rec(id) if fn(id)==false then return false end; for _,c in ipairs(children[id] or {}) do if rec(c)==false then return false end end end - rec(e) -end} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua" then return entity_tree end - if path=="mods/metamorph_creative_menu/files/features/forms/component_ops.lua" then return component_ops end - local prefix="mods/metamorph_creative_menu/";if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -function EntityGetComponentIncludingDisabled(e,kind) - if kind=="LuaComponent" and e==2 then return {lua_dragon} end - if kind=="GenomeDataComponent" and e==1 then return {genome} end - if kind=="ProjectileComponent" then return projectile_components[e] and {projectile_components[e]} or {} end - if kind=="VelocityComponent" then return velocity_components[e] and {velocity_components[e]} or {} end - return {} -end -function EntityGetFirstComponentIncludingDisabled(e,kind) - local t=EntityGetComponentIncludingDisabled(e,kind);return t[1] -end -function ComponentGetIsEnabled(c)return enabled[c]==true end -function EntitySetComponentIsEnabled(_,c,v) enabled[c]=v==true end -function ComponentGetValue2(c,f)local v=values[c] and values[c][f];return v end -function ComponentSetValue2(c,f,...) - values[c]=values[c] or {};local args={...};values[c][f]=#args==1 and args[1] or args -end -function ComponentGetEntity(c)return owner_of[c] or 0 end -function EntityGetAllChildren(e)return children[e] or {} end -function EntityGetIsAlive(e)return alive[e]==true end -function EntityGetTransform(e)local t=transforms[e];if not t then return nil end;return t[1],t[2],t[3],t[4],t[5] end -function EntitySetTransform(e,x,y) transforms[e]={x,y,0,1,1} end -function EntityCreateNew() next_entity=next_entity+1;alive[next_entity]=true;transforms[next_entity]={0,0,0,1,1};return next_entity end -function EntityAddTag() end -function EntityKill(e) alive[e]=false end -function EntityLoad(path,x,y) - next_entity=next_entity+1;alive[next_entity]=true;transforms[next_entity]={x,y,0,1,1} - local pc=next_entity+1000;local vc=next_entity+2000 - projectile_components[next_entity]=pc;velocity_components[next_entity]=vc;owner_of[pc]=next_entity;owner_of[vc]=next_entity - values[pc]={};values[vc]={};shots[#shots+1]={path=path,entity=next_entity,x=x,y=y};return next_entity -end -function GameShootProjectile(who,sx,sy,tx,ty,p,send) local s=shots[#shots];s.who=who;s.tx=tx;s.ty=ty;s.send=send end -function GameGetFrameNum()return frame end -function DEBUG_GetMouseWorld()return 110,20 end -function GameEntityPlaySound() end -function GamePlaySound() end -function Random(...) return 0 end -local scripted=assert(native_dofile(root.."/files/features/forms/scripted_attacks.lua")) -assert(scripted.configure(1,"data/entities/animals/boss_dragon.xml")==true,"boss dragon scripted attack not discovered") -assert(enabled[lua_dragon]==false,"native scripted attack left autonomous") -frame=80 -scripted.update(1) -assert(#shots==10,"boss dragon did not replay exact ten-projectile ring") -for _,s in ipairs(shots) do assert(s.path=="data/entities/projectiles/orb_green_boss_dragon.xml","wrong boss dragon projectile") end -assert(math.abs((values[velocity_components[shots[1].entity]].mVelocity or {})[1]-100)<0.001,"script velocity was not stamped like utilities.shoot_projectile") -scripted.reset() -assert(enabled[lua_dragon]==true,"script component enabled state was not restored") -print("form_scripted_attack_replay=PASS script_disabled=true dragon_ring=10 exact_entity=true velocity_stamp=true restore=true") diff --git a/metamorph_creative_menu/tests/form_scripted_attack_state_mock.lua b/metamorph_creative_menu/tests/form_scripted_attack_state_mock.lua deleted file mode 100644 index ad72d09..0000000 --- a/metamorph_creative_menu/tests/form_scripted_attack_state_mock.lua +++ /dev/null @@ -1,285 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local controls=10 -local frame=0 -local next_entity=1000 -local scenario={} -local values={} -local owners={} -local enabled={} -local alive={} -local transforms={} -local children={} -local shots={} -local projectile_components={} -local velocity_components={} -local object_values={} -local random_queue={} -local mouse_x,mouse_y=110,20 -local ray_blocked=false -local function pop_random(a,b) - local v=table.remove(random_queue,1) - if v~=nil then return v end - if a==nil then return 0 end - if b==nil then return a end - return a -end -local component_ops={ - valid=function(v)return v~=nil and v~=0 end, - get=function(c,f,d)local v=values[c] and values[c][f];if v==nil then return d end;return v end, - boolean=function(v)return v==true or v==1 end, - ensure_controls=function()return controls end, -} -local entity_tree={walk=function(e,fn) - local function rec(id) if fn(id)==false then return false end; for _,c in ipairs(children[id] or {}) do if rec(c)==false then return false end end end - rec(e) -end} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua" then return entity_tree end - if path=="mods/metamorph_creative_menu/files/features/forms/component_ops.lua" then return component_ops end - local prefix="mods/metamorph_creative_menu/"; if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -local function reset_world(script_specs, vars, extra_children) - values={[controls]={mButtonDownFire=true,mButtonDownFire2=false}} - owners={[controls]=1} - enabled={};alive={[1]=true};transforms={[1]={10,20,0,1,1}};children={[1]={}} - shots={};projectile_components={};velocity_components={};object_values={};random_queue={};frame=0;next_entity=1000;mouse_x=110;mouse_y=20;ray_blocked=false - local id=20 - for _,spec in ipairs(script_specs or {}) do - id=id+1; local is_enabled=spec.enabled~=false; values[id]={script_source_file=spec.path,execute_every_n_frame=spec.interval or -1,mLastExecutionFrame=spec.last or -1,_enabled=spec.serialized_enabled~=nil and spec.serialized_enabled or is_enabled};owners[id]=spec.owner or 1;enabled[id]=is_enabled - local owner=spec.owner or 1;alive[owner]=true;transforms[owner]=transforms[owner] or {12,18,0,1,1};scenario.lua_by_owner=scenario.lua_by_owner or {};scenario.lua_by_owner[owner]=scenario.lua_by_owner[owner] or {};table.insert(scenario.lua_by_owner[owner],id) - if owner~=1 then local found=false;for _,c in ipairs(children[1])do if c==owner then found=true end end;if not found then table.insert(children[1],owner) end end - end - for _,v in ipairs(vars or {}) do - id=id+1;values[id]={name=v.name,value_int=v.value_int,value_string=v.value_string or "",value_bool=v.value_bool or false};owners[id]=v.owner or 1 - scenario.vars=scenario.vars or {};scenario.vars[v.name]=id - end - for _,c in ipairs(extra_children or {}) do - alive[c.id]=true;transforms[c.id]={c.x or 0,c.y or 0,0,1,1};table.insert(children[1],c.id);children[c.id]={};scenario.names=scenario.names or {};scenario.names[c.id]=c.name or "" - if c.genome then id=id+1; values[id]={herd_id=c.herd_id or 5};owners[id]=c.id;scenario.genomes=scenario.genomes or {};scenario.genomes[c.id]=id end - if c.sprite then id=id+1;values[id]={rect_animation=c.sprite,image_file="sprite.xml"};owners[id]=c.id;scenario.sprites=scenario.sprites or {};scenario.sprites[c.id]=id end - end - id=id+1;values[id]={herd_id=7};owners[id]=1;scenario.root_genome=id - scenario.root=1 -end -scenario={lua_by_owner={},vars={},genomes={},sprites={}} -function EntityGetComponentIncludingDisabled(e,kind) - if kind=="LuaComponent" then return (scenario.lua_by_owner and scenario.lua_by_owner[e]) or {} end - if kind=="VariableStorageComponent" then local out={};for name,c in pairs(scenario.vars or {}) do if owners[c]==e then out[#out+1]=c end end;return out end - if kind=="GenomeDataComponent" then local out={};if e==1 and scenario.root_genome then out[#out+1]=scenario.root_genome end;if scenario.genomes and scenario.genomes[e] then out[#out+1]=scenario.genomes[e] end;return out end - if kind=="SpriteComponent" then - if scenario.sprite_lists and scenario.sprite_lists[e] then return scenario.sprite_lists[e] end - local c=scenario.sprites and scenario.sprites[e];return c and {c} or {} - end - if kind=="ProjectileComponent" then local c=projectile_components[e];return c and {c} or {} end - if kind=="VelocityComponent" then local c=velocity_components[e];return c and {c} or {} end - if kind=="LaserEmitterComponent" then return scenario.lasers or {} end - if kind=="HitboxComponent" then return (scenario.hitbox_owner==e and scenario.hitbox) and {scenario.hitbox} or {} end - if kind=="DamageModelComponent" then return (scenario.damage_owner==e and scenario.damage_component) and {scenario.damage_component} or {} end - return {} -end -function EntityGetFirstComponentIncludingDisabled(e,kind)local t=EntityGetComponentIncludingDisabled(e,kind);return t[1] end -function EntitySetComponentsWithTagEnabled(e,tag,v) scenario.tag_enabled=scenario.tag_enabled or {};scenario.tag_enabled[tag]=v==true;scenario.tag_enabled_by_owner=scenario.tag_enabled_by_owner or {};scenario.tag_enabled_by_owner[e]=scenario.tag_enabled_by_owner[e] or {};scenario.tag_enabled_by_owner[e][tag]=v==true end -function EntityGetName(e)return scenario.names and scenario.names[e] or "" end -function ComponentGetIsEnabled(c)return enabled[c]==true end -function EntitySetComponentIsEnabled(_,c,v)enabled[c]=v==true end -function ComponentGetValue2(c,f)local v=values[c] and values[c][f];if type(v)=="table" then return v[1],v[2] end;return v end -function ComponentSetValue2(c,f,...)values[c]=values[c] or {};local a={...};values[c][f]=#a==1 and a[1] or a end -function ComponentObjectSetValue2(c,obj,f,v)object_values[c]=object_values[c] or {};object_values[c][obj.."."..f]=v end -function ComponentGetEntity(c)return owners[c] or 0 end -function EntityGetAllChildren(e)return children[e] or {} end -function EntityGetRootEntity(e)return e==2 and 1 or e end -function EntityGetIsAlive(e)return alive[e]==true end -function EntityGetTransform(e)local t=transforms[e];if not t then return nil end;return t[1],t[2],t[3],t[4],t[5] end -function EntitySetTransform(e,x,y)transforms[e]={x,y,0,1,1} end -function EntityCreateNew()next_entity=next_entity+1;alive[next_entity]=true;transforms[next_entity]={0,0,0,1,1};children[next_entity]={};return next_entity end -function EntityAddTag(e,tag) scenario.tags=scenario.tags or {};scenario.tags[e]=scenario.tags[e] or {};scenario.tags[e][tag]=true end -function EntityKill(e)alive[e]=false end -function EntityAddChild(parent, child) children[parent]=children[parent] or {};table.insert(children[parent],child);scenario.names=scenario.names or {};if scenario.loaded_paths and scenario.loaded_paths[child] and string.find(scenario.loaded_paths[child],"boss_centipede_shield",1,true) then scenario.names[child]="shield_entity" end end -function EntitySetDamageFromMaterial(e,mat,v) scenario.material_damage=scenario.material_damage or {};scenario.material_damage[mat]=v end -function EntityAddComponent2(e,kind,fields)next_entity=next_entity+1;values[next_entity]=fields or {};owners[next_entity]=e;return next_entity end -function EntityLoad(path,x,y) - next_entity=next_entity+1;local e=next_entity;alive[e]=true;transforms[e]={x,y,0,1,1};children[e]={};scenario.loaded_paths=scenario.loaded_paths or {};scenario.loaded_paths[e]=path - local pc=e+10000;local vc=e+20000;values[pc]={};values[vc]={};owners[pc]=e;owners[vc]=e;projectile_components[e]=pc;velocity_components[e]=vc - shots[#shots+1]={path=path,e=e,x=x,y=y};return e -end -function GameShootProjectile(who,sx,sy,tx,ty,p,send)local s=shots[#shots];s.who=who;s.tx=tx;s.ty=ty;s.send=send end -function GameGetFrameNum()return frame end -function DEBUG_GetMouseWorld()return mouse_x,mouse_y end -function RaytraceSurfacesAndLiquiform()return ray_blocked end -function EntityGetWithTag(tag) if tag=="healer" then return scenario.healers or {} end return {} end -function EntityGetFirstHitboxCenter(e)local t=transforms[e];return t[1],t[2] end -function Random(a,b)return pop_random(a,b) end -function SetRandomSeed()end -function ProceduralRandomf(a,b,c,d)if d~=nil then return c end;return 0.25 end -function GamePlayAnimation(e,name)scenario.animations=scenario.animations or {};scenario.animations[#scenario.animations+1]={e,name} end -function GameEntityPlaySound()end -function GamePlaySound()end -function GameCreateParticle()end -function GameGetOrbCountThisRun()return scenario.orbs or 0 end -function SessionNumbersGetValue(key) assert(key=="NEW_GAME_PLUS_COUNT"); return tostring(scenario.ngplus or 0) end -local scripted=assert(native_dofile(root.."/files/features/forms/scripted_attacks.lua")) -local function fresh(specs,vars,extra) - scripted.reset();scenario={lua_by_owner={},vars={},genomes={},sprites={}};reset_world(specs,vars,extra) -end -local function count_path(path)local n=0;for _,s in ipairs(shots)do if s.path==path then n=n+1 end end;return n end --- Scripted replay treats explicit player fire as target acquisition. Vanilla search --- radii must not reject a distant cursor, while attack-internal conditions such as the --- monk's line-of-sight check remain authored behavior. -fresh({{path="data/scripts/animals/fungus_giga_pollen.lua",interval=10}},nil,nil) -assert(scripted.configure(1,"fungus_giga"));frame=10;mouse_x=1000;scripted.update(1);assert(count_path("data/entities/projectiles/pollen.xml")==1,"fungus giga player fire was blocked by the vanilla 64px acquisition radius") - -fresh({{path="data/scripts/animals/monk_hand_shoot.lua",interval=60,owner=2}},nil,{{id=2,x=10,y=20,sprite="stand"}}) -assert(scripted.configure(1,"monk"));frame=60;mouse_x=1000;scripted.update(1);assert(count_path("data/entities/projectiles/orb_green_accelerating.xml")==1,"monk player fire was blocked by the vanilla 200px acquisition radius") --- A separate far-cursor attempt still respects the authored line-of-sight condition. -fresh({{path="data/scripts/animals/monk_hand_shoot.lua",interval=60,owner=2}},nil,{{id=2,x=10,y=20,sprite="stand"}}) -assert(scripted.configure(1,"monk"));frame=60;mouse_x=1000;ray_blocked=true;scripted.update(1);assert(count_path("data/entities/projectiles/orb_green_accelerating.xml")==0,"monk ignored vanilla line-of-sight check") - --- boss meat: 20f acid + 80f status-driven orb -fresh({{path="data/entities/animals/boss_meat/shot.lua",interval=20},{path="data/entities/animals/boss_meat/eye.lua",interval=80}},{{name="status",value_int=3}}) -assert(scripted.configure(1,"boss_meat"));frame=20;scripted.update(1);assert(count_path("data/entities/animals/boss_meat/acidshot_slow.xml")==1,"boss meat acid cadence changed") -frame=80;scripted.update(1);assert(count_path("data/entities/animals/boss_meat/orb_big.xml")==1,"boss meat eye orb state was lost") -assert(scenario.tag_enabled==nil or scenario.tag_enabled.vacuum_NOT~=true,"boss meat replay re-enabled native shot.lua through vacuum_NOT") --- fish giga: opened eye + timer 360 fires exact eight orb ring and mirrors the --- vanilla vulnerable-hitbox lifecycle while the eye opens/closes. -fresh({{path="data/entities/animals/boss_fish/eye.lua",interval=1,owner=2}},{{name="phase_timer",value_int=360,owner=2}},{{id=2,x=30,y=40,sprite="opened"}}) -scenario.hitbox=90;scenario.hitbox_owner=1;values[90]={};owners[90]=1;enabled[90]=true -assert(scripted.configure(1,"fish_giga"));frame=1;scripted.update(1);assert(count_path("data/entities/animals/boss_fish/orb_big.xml")==8,"fish giga did not fire eight authored orbs") --- Losing the target from an opened eye must begin close and immediately disable the --- same root hitbox, exactly as boss_fish/eye.lua. -values[controls].mButtonDownFire=false;frame=2;scripted.update(1);assert(enabled[90]==false,"fish giga close did not disable vulnerable hitbox") --- Re-open through the same authored `closed -> open -> opened` 36-frame sequence. -values[controls].mButtonDownFire=true;values[scenario.sprites[2]].rect_animation="closed";frame=3;scripted.update(1) -assert(values[scenario.sprites[2]].rect_animation=="open","fish giga did not enter authored open animation") -for f=4,40 do frame=f;scripted.update(1) end -assert(enabled[90]==true and values[scenario.sprites[2]].rect_animation=="opened","fish giga open did not enable vulnerable hitbox") -fresh({{path="data/entities/animals/boss_fish/eye.lua",interval=1,owner=2}},{{name="phase_timer",value_int=360,owner=2}},{{id=2,x=30,y=40,sprite="opened"}}) -assert(scripted.configure(1,"fish_giga"));mouse_x=1000 -for f=1,361 do frame=f;scripted.update(1) end -assert(count_path("data/entities/animals/boss_fish/orb_big.xml")==8,"fish giga player fire was blocked by the vanilla 160px acquisition radius") --- maggot: shooter_part points at genome-bearing segment and advances state -fresh({{path="data/entities/animals/maggot_tiny/shot.lua",interval=20}},{{name="shooter_part",value_int=1}},{{id=2,x=50,y=60,genome=true}}) -assert(scripted.configure(1,"maggot_tiny"));frame=20;scripted.update(1);assert(count_path("data/entities/animals/maggot_tiny/orb.xml")==1,"maggot segment shot missing");assert(values[scenario.vars.shooter_part].value_int==2,"maggot shooter_part did not advance") --- wizard supplemental volley: two tentacles per 16f -fresh({{path="data/entities/animals/boss_wizard/bloodtentacle.lua",interval=16}},nil,nil) -assert(scripted.configure(1,"boss_wizard"));frame=16;scripted.update(1);assert(count_path("data/entities/animals/boss_wizard/bloodtentacle.xml")==2,"boss wizard tentacle volley count changed") -fresh({{path="data/entities/animals/boss_wizard/bloodtentacle.lua",interval=16,enabled=false,serialized_enabled=true}},nil,nil) -assert(scripted.configure(1,"boss_wizard"));frame=16;scripted.update(1);assert(count_path("data/entities/animals/boss_wizard/bloodtentacle.xml")==0,"runtime-disabled boss wizard phase was invented from stale _enabled data") --- boss ghost laser cross is already a safe native self-contained state machine; leave it enabled and prevent generic cursor-laser ownership. -fresh({{path="data/entities/animals/boss_ghost/lasers.lua",interval=1,enabled=true}},nil,nil) -scripted.configure(1,"boss_ghost");assert(scripted.manages_lasers()==true,"boss ghost native laser state machine was not protected");local ghost_lua=scenario.lua_by_owner[1][1];assert(enabled[ghost_lua]==true,"boss ghost native laser script was disabled") --- robot preserves the entire 40f state cycle, including spell-eater gates and healer spawn. -fresh({{path="data/entities/animals/boss_robot/state.lua",interval=40}},{{name="state",value_int=1},{name="spell_eater",value_int=1}},nil) -assert(scripted.configure(1,"boss_robot"));mouse_x=1000;frame=40;scripted.update(1);assert(count_path("data/entities/animals/boss_robot/rocket_roll.xml")==10,"boss robot rocket phase was blocked by the vanilla 300px acquisition radius");assert(values[scenario.vars.spell_eater].value_int==0,"boss robot state2 did not disable spell eater") -frame=80;scripted.update(1);frame=120;scripted.update(1);assert(values[scenario.vars.spell_eater].value_int==1,"boss robot state4 did not enable spell eater") -for f=160,480,40 do frame=f;scripted.update(1) end -assert(count_path("data/entities/animals/robobase/healerdrone_physics.xml")==1,"boss robot state13 healer spawn was lost") -assert(values[scenario.vars.state].value_int==0,"boss robot state13 did not reset authored state cycle") --- Once the robot reaches laser state 6, releasing fire must not freeze an emitting --- laser. The replay finishes only the in-progress laser phase through authored state10. -fresh({{path="data/entities/animals/boss_robot/state.lua",interval=40}},{{name="state",value_int=5},{name="spell_eater",value_int=1}},nil) -scenario.lasers={91,92};values[91]={is_emitting=false};values[92]={is_emitting=false};owners[91]=1;owners[92]=1;enabled[91]=true;enabled[92]=true -assert(scripted.configure(1,"boss_robot"));frame=40;scripted.update(1) -assert(values[91].is_emitting==true and values[92].is_emitting==true,"boss robot state6 did not start lasers") -values[controls].mButtonDownFire=false -for _,f in ipairs({80,120,160,200}) do frame=f;scripted.update(1) end -assert(ComponentGetValue2(91,"is_emitting")==false and ComponentGetValue2(92,"is_emitting")==false,"boss robot laser stayed active after primary fire release") -assert(values[scenario.vars.state].value_int==10,"boss robot did not finish the in-progress laser phase through state10") --- reset is also a hard safety boundary for manually-owned emitters. -fresh({{path="data/entities/animals/boss_robot/state.lua",interval=40}},{{name="state",value_int=5},{name="spell_eater",value_int=1}},nil) -scenario.lasers={93};values[93]={is_emitting=false};owners[93]=1;enabled[93]=true -assert(scripted.configure(1,"boss_robot"));frame=40;scripted.update(1);assert(values[93].is_emitting==true,"boss robot reset fixture did not start laser") -scripted.reset();assert(ComponentGetValue2(93,"is_emitting")==false,"boss robot reset left a managed laser emitting") --- pit state0 -> state1 = exact wand carrier -fresh({{path="data/entities/animals/boss_pit/boss_pit_logic.lua",interval=40}},{{name="state",value_int=0},{name="memory",value_string="memory.xml"},{name="pathfinding_frames_stuck",value_int=0}},nil) -random_queue={1,1};assert(scripted.configure(1,"boss_pit"));frame=40;scripted.update(1);assert(count_path("data/entities/animals/boss_pit/wand.xml")==1,"boss pit wand carrier missing") --- Passive boss-pit recovery belongs to the disabled vanilla logic too. It must run --- every authored 40f even when the player is not firing, without advancing attack state. -fresh({{path="data/entities/animals/boss_pit/boss_pit_logic.lua",interval=40}},{{name="state",value_int=0},{name="memory",value_string="memory.xml"},{name="pathfinding_frames_stuck",value_int=0}},nil) -scenario.hitbox=94;scenario.hitbox_owner=1;values[94]={damage_multiplier=0};owners[94]=1;enabled[94]=true;values[controls].mButtonDownFire=false -assert(scripted.configure(1,"boss_pit")) -frame=40;scripted.update(1);assert(math.abs(values[94].damage_multiplier-0.35)<0.000001,"boss pit damage multiplier did not recover by vanilla +0.35") -assert(scenario.tag_enabled and scenario.tag_enabled.invincible==false,"boss pit invincible tag was not disabled during passive recovery") -assert(values[scenario.vars.state].value_int==0 and count_path("data/entities/animals/boss_pit/wand.xml")==0,"boss pit passive recovery advanced the manual firing state") -frame=80;scripted.update(1);assert(math.abs(values[94].damage_multiplier-0.70)<0.000001,"boss pit second recovery tick changed") -frame=120;scripted.update(1);assert(values[94].damage_multiplier==1,"boss pit recovery did not clamp at 1.0") --- limbs primary mirrors expose/weak-hitbox/details lifecycle as well as the exact rings. -fresh({{path="data/entities/animals/boss_limbs/boss_limbs_update.lua",interval=-1}},nil,nil) -scenario.sprite_lists={[1]={91,92,93,94}} -for _,c in ipairs(scenario.sprite_lists[1]) do values[c]={rect_animation="stand"};owners[c]=1 end -assert(scripted.configure(1,"boss_limbs"));frame=0;scripted.update(1) -assert(values[91].rect_animation=="stand" and values[92].rect_animation=="invisible" and values[94].rect_animation=="invisible","boss limbs expose did not hide only detail sprites") -frame=9;scripted.update(1);assert(scenario.tag_enabled==nil or scenario.tag_enabled.hitbox_weak_spot~=true,"boss limbs weak spot exposed before authored 10f delay") -frame=10;scripted.update(1);assert(scenario.tag_enabled.hitbox_weak_spot==true and scenario.tag_enabled.hitbox_default==false,"boss limbs weak hitbox timing changed") -frame=45;scripted.update(1);assert(count_path("data/entities/animals/boss_limbs/orb_boss_limbs.xml")==8,"boss limbs first ring changed") -frame=110;scripted.update(1);frame=175;scripted.update(1);assert(count_path("data/entities/animals/boss_limbs/orb_boss_limbs.xml")==24,"boss limbs three-ring phase changed") -frame=414;scripted.update(1);assert(scenario.tag_enabled.hitbox_weak_spot==true,"boss limbs weak spot closed before authored 240f exposure") -frame=415;scripted.update(1);assert(scenario.tag_enabled.hitbox_weak_spot==true,"boss limbs close animation disabled weak spot too early") -frame=425;scripted.update(1);assert(scenario.tag_enabled.hitbox_weak_spot==false and scenario.tag_enabled.hitbox_default==true,"boss limbs default hitbox restore timing changed") -assert(values[92].rect_animation=="invisible","boss limbs details returned before close finished") -frame=455;scripted.update(1);assert(values[92].rect_animation=="stand" and values[94].rect_animation=="stand","boss limbs details restore timing changed") -frame=464;scripted.update(1);assert(count_path("data/entities/animals/boss_limbs/orb_boss_limbs.xml")==24,"boss limbs phase cooldown was skipped") --- Boss Centipede player form must suppress the pre-battle limb coroutine and replay --- vanilla init_boss() state before the manual attack controller reads orbcount/shield. -fresh({ - {path="data/entities/animals/boss_centipede/boss_centipede_before_fight.lua",interval=-1,enabled=true}, - {path="data/entities/animals/boss_centipede/boss_centipede_update.lua",interval=-1,enabled=false}, -},{{name="initialized",value_bool=false},{name="orbcount",value_int=0}},{{id=2,x=10,y=20,name="limb"}}) -scenario.orbs=5;scenario.ngplus=2 -scenario.damage_component=90;scenario.damage_owner=1;values[90]={hp=1,max_hp=1};owners[90]=1 -assert(scripted.configure(1,"boss_centipede"),"centipede configure failed") -local before_comp,update_comp=nil,nil -for _,c in ipairs(scenario.lua_by_owner[1] or {}) do - local src=values[c].script_source_file - if src=="data/entities/animals/boss_centipede/boss_centipede_before_fight.lua" then before_comp=c end - if src=="data/entities/animals/boss_centipede/boss_centipede_update.lua" then update_comp=c end -end -assert(before_comp and enabled[before_comp]==false,"centipede pre-battle coroutine remained enabled") -assert(update_comp and enabled[update_comp]==false,"centipede vanilla combat coroutine escaped manual ownership") -assert(values[scenario.vars.initialized].value_bool==true,"centipede init flag was not committed") -assert(values[scenario.vars.orbcount].value_int==7,"centipede real run+NG orbcount was not stored") -local expected_hp=46.0+(2.0^(7+1.3))+(7*15.5) -assert(math.abs(values[90].hp-expected_hp)<0.001 and math.abs(values[90].max_hp-expected_hp)<0.001,"centipede orb-scaled HP init changed") -assert(object_values[90]["damage_multipliers.projectile"]==0.2 and object_values[90]["damage_multipliers.drill"]==0.01,"centipede orb resistance init missing") -assert(scenario.material_damage.acid==0.0,"centipede material resistance init missing") -local shield_found=false -for child,path in pairs(scenario.loaded_paths or {}) do if path=="data/entities/animals/boss_centipede/boss_centipede_shield_strong.xml" and scenario.names[child]=="shield_entity" then shield_found=true end end -assert(shield_found,"centipede strong shield entity was not initialized") -assert(scenario.tag_enabled_by_owner[2] and scenario.tag_enabled_by_owner[2].disabled_at_start==true,"centipede initial child components were not enabled") - --- centipede circle phase reproduces its authored prep, repeat and branch growth. --- orbcount0: repeat_left=1 means first phase has 5 branches, repeated phase has 6. -fresh({{path="data/entities/animals/boss_centipede/boss_centipede_update.lua",interval=-1,enabled=false}},{{name="orbcount",value_int=0}},{{id=2,x=10,y=20,name="shield_entity"}}) -random_queue={1};assert(scripted.configure(1,"boss_centipede"));mouse_x=1000;frame=0;scripted.update(1) -assert(scenario.animations==nil or #scenario.animations==0,"centipede opened eye before vanilla 50+10f shield prep") -frame=49;scripted.update(1);assert(scenario.tag_enabled_by_owner==nil or scenario.tag_enabled_by_owner[2]==nil,"centipede shield enabled before 50f") -frame=50;scripted.update(1);assert(scenario.tag_enabled_by_owner[2].shield==true,"centipede shield_on timing changed") -frame=59;scripted.update(1);assert(scenario.animations==nil or #scenario.animations==0,"centipede eye opened before shield wait completed") -frame=60;scripted.update(1);assert(scenario.animations[#scenario.animations][2]=="open","centipede eye-open timing changed") -frame=114;scripted.update(1);assert(count_path("data/entities/animals/boss_centipede/orb_circleshot.xml")==0,"centipede ignored 115f authored prep") -frame=115;scripted.update(1);assert(count_path("data/entities/animals/boss_centipede/orb_circleshot.xml")==5,"centipede first repeated circle branch count changed");assert(count_path("data/entities/particles/muzzle_flashes/muzzle_flash_circular_pink.xml")==1,"centipede circle muzzle flash missing") -for i=1,9 do frame=115+i*12;scripted.update(1) end -assert(count_path("data/entities/animals/boss_centipede/orb_circleshot.xml")==50,"centipede first ten-ring spiral changed") -frame=235;scripted.update(1);assert(scenario.tag_enabled_by_owner[2].shield==false,"centipede repeated circle did not disable shield after loop wait") -frame=345;scripted.update(1);assert(scenario.tag_enabled_by_owner[2].shield==true,"centipede repeated circle did not re-enable shield after move wait") -frame=355;scripted.update(1);assert(count_path("data/entities/animals/boss_centipede/orb_circleshot.xml")==56,"centipede repeated phase did not grow to six branches") --- firepillar exact repeat amounts 2/1/0 => 6,8,10 projectiles at orbcount0. -fresh({{path="data/entities/animals/boss_centipede/boss_centipede_update.lua",interval=-1,enabled=false}},{{name="orbcount",value_int=0}},nil) -random_queue={2};assert(scripted.configure(1,"boss_centipede"));frame=0;scripted.update(1) -frame=115;scripted.update(1);assert(count_path("data/entities/animals/boss_centipede/firepillar.xml")==6,"centipede first firepillar repeat amount changed") -frame=215;scripted.update(1);assert(count_path("data/entities/animals/boss_centipede/firepillar.xml")==14,"centipede second firepillar repeat amount changed") -frame=315;scripted.update(1);assert(count_path("data/entities/animals/boss_centipede/firepillar.xml")==24,"centipede final firepillar repeat amount changed") --- orbcount2 unlocks homing: 4 + floor(2*0.5) = five shots, 20f apart after open-eye prep. -fresh({{path="data/entities/animals/boss_centipede/boss_centipede_update.lua",interval=-1,enabled=false}},{{name="orbcount",value_int=2}},nil) -random_queue={3};assert(scripted.configure(1,"boss_centipede"));frame=0;scripted.update(1) -for _,f in ipairs({55,75,95,115,135}) do frame=f;scripted.update(1) end -assert(count_path("data/entities/animals/boss_centipede/orb_homing.xml")==5,"centipede authored homing count/cadence changed") --- orbcount11 unlocks the exact three-projectile polymorph triangle. -fresh({{path="data/entities/animals/boss_centipede/boss_centipede_update.lua",interval=-1,enabled=false}},{{name="orbcount",value_int=11}},nil) -random_queue={4};assert(scripted.configure(1,"boss_centipede"));frame=0;scripted.update(1);frame=85;scripted.update(1) -assert(count_path("data/entities/animals/boss_centipede/orb_polymorph.xml")==3,"centipede polymorph volley count changed") -local poly={};for _,shot in ipairs(shots)do if shot.path=="data/entities/animals/boss_centipede/orb_polymorph.xml" then poly[#poly+1]=shot end end -assert(poly[1].x==10 and poly[1].y==10 and values[velocity_components[poly[1].e]].mVelocity[1]==0 and values[velocity_components[poly[1].e]].mVelocity[2]==-50,"centipede first polymorph vector changed") -assert(poly[2].x==5 and poly[2].y==20 and poly[3].x==5 and poly[3].y==20,"centipede authored duplicate side vectors changed") -print("form_scripted_attack_state=PASS meat=true fish=true maggot=true wizard=true robot=true pit=true limbs=true centipede=true") diff --git a/metamorph_creative_menu/tests/form_static_tank_aim_mock.lua b/metamorph_creative_menu/tests/form_static_tank_aim_mock.lua deleted file mode 100644 index 003b24c..0000000 --- a/metamorph_creative_menu/tests/form_static_tank_aim_mock.lua +++ /dev/null @@ -1,78 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local controls,attack,gun=10,30,40 -local frame=100 -local values={ - [controls]={mButtonDownFire=true,mButtonDownFire2=false}, - [attack]={ - attack_ranged_entity_file="data/entities/projectiles/machinegun_bullet_tank.xml", - frames_between=10,frames_between_global=0, - attack_ranged_entity_count_min=1,attack_ranged_entity_count_max=1, - attack_ranged_action_frame=0,animation_name="", - min_distance=0,max_distance=500,use_probability=100,state_duration_frames=10, - attack_ranged_root_offset_x=0,attack_ranged_root_offset_y=-5, - attack_ranged_offset_x=10,attack_ranged_offset_y=-3, - attack_ranged_use_message=false,attack_landing_ranged_enabled=false, - attack_ranged_aim_rotation_enabled=true,attack_ranged_aim_rotation_speed=0.05, - attack_ranged_aim_rotation_shooting_ok_angle_deg=10, - mRangedAttackCurrentAimAngle=-1.2,mNextFrameUsable=0, - }, - [gun]={image_file="data/enemies_gfx/tank_gun.xml",update_transform_rotation=false,alpha=1}, -} -local enabled={[controls]=true,[attack]=true,[gun]=true} -local loaded,shots={},{} -local created=0 -local component_ops={ - valid=function(v)return v~=nil and v~=0 end, - first=function(_,kind)if kind=="ControlsComponent" then return controls end end, - get=function(c,f,d)local v=values[c] and values[c][f];if v==nil then return d end return v end, - boolean=function(v)if type(v)=="string" then return v=="1" or v=="true" end return v==true or v==1 end, - ensure_controls=function()return controls end, - set_type_enabled=function()end,set_type_enabled_tree=function()end, -} -local tree={components=function(_,kind)if kind=="AIAttackComponent" then return {attack} end return {} end,reset=function()end} -local entity_tree={walk=function(e,fn)fn(e)end} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/features/forms/component_ops.lua" then return component_ops end - if path=="mods/metamorph_creative_menu/files/features/forms/entity_tree_cache.lua" then return tree end - if path=="mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua" then return entity_tree end - local prefix="mods/metamorph_creative_menu/";if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -function ComponentGetEntity(c) if c==attack or c==gun then return 1 end return 1 end -function ComponentGetIsEnabled(c)return enabled[c]~=false end -function ComponentSetValue2(c,f,v)values[c]=values[c] or {};values[c][f]=v end -function EntitySetComponentIsEnabled(_,c,v)enabled[c]=v==true end -function EntityGetComponentIncludingDisabled(_,kind) - if kind=="AIAttackComponent" then return {attack} end - if kind=="SpriteComponent" then return {gun} end - return {} -end -function EntityGetRootEntity()return 1 end -function EntityGetTransform()return 100,200,0,1,1 end -function EntitySetTransform()end -function EntityGetIsAlive()return true end -function DEBUG_GetMouseWorld()return 100,300 end -function GameGetFrameNum()return frame end -function Random(a,b)return a end -function EntityLoad(path,x,y)loaded[#loaded+1]={path=path,x=x,y=y};return 100+#loaded end -function GameShootProjectile(_,sx,sy,tx,ty)shots[#shots+1]={sx=sx,sy=sy,tx=tx,ty=ty};return true end -function EntityKill()end -function EntityCreateNew()created=created+1;return 900+created end -function EntityAddTag()end -function EntityAddComponent2()return 0 end -function GamePlayAnimation()end - -local combat=assert(native_dofile(root.."/files/features/forms/combat.lua")) -assert(combat.configure_ranged_player(1)==true,"tank attack was not manually owned") -combat.setup_manual_barrels(1) -assert(created==0 and enabled[gun]==true,"static tank gun sprite was replaced by a fake rotating pivot") -local stale=values[attack].mRangedAttackCurrentAimAngle -combat.update_manual_aim(1) -assert(values[attack].mRangedAttackCurrentAimAngle==stale,"static tank gun still tried to rotate an unsupported visual layer") -assert(combat.update_ranged_attacks(1)==true,"static tank attack did not fire") -assert(#loaded==1 and #shots==1,"static tank attack did not emit exactly one projectile") --- No rotation of the authored muzzle vector: pivot=(100,195), muzzle=(110,192). -assert(math.abs(loaded[1].x-110)<0.001 and math.abs(loaded[1].y-192)<0.001,"static tank muzzle was rotated away from its authored sprite position") -assert(math.abs(shots[1].tx-100)<0.001 and math.abs(shots[1].ty-300)<0.001,"static tank projectile did not target the real cursor") -print("form_static_tank_aim=PASS no_fake_turret_rotation=true authored_muzzle=true direct_cursor=true") diff --git a/metamorph_creative_menu/tests/form_static_turret_arc_mock.lua b/metamorph_creative_menu/tests/form_static_turret_arc_mock.lua deleted file mode 100644 index b75cf3b..0000000 --- a/metamorph_creative_menu/tests/form_static_turret_arc_mock.lua +++ /dev/null @@ -1,55 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local controls,animal=10,20 -local mouse_angle=math.rad(80) -local values={ - [controls]={mButtonDownFire=false,mButtonDownFire2=false}, - [animal]={ - attack_ranged_entity_file="data/entities/projectiles/laser_turret.xml", - attack_ranged_enabled=true,attack_ranged_use_message=false, - attack_ranged_frames_between=100,attack_ranged_entity_count_min=1,attack_ranged_entity_count_max=1, - attack_ranged_action_frame=0,attack_ranged_offset_x=10,attack_ranged_offset_y=-6, - attack_ranged_min_distance=10,attack_ranged_max_distance=740, - attack_ranged_aim_rotation_enabled=true,attack_ranged_aim_rotation_speed=0.1, - attack_ranged_aim_rotation_shooting_ok_angle_deg=2, - creature_detection_angular_range_deg=90,is_static_turret=true, - mRangedAttackCurrentAimAngle=0, - }, -} -local component_ops={ - valid=function(v)return v~=nil and v~=0 end, - first=function(_,k)if k=="ControlsComponent" then return controls end end, - get=function(c,f,d)local v=values[c] and values[c][f]; if v==nil then return d end return v end, - boolean=function(v)return v==true or v==1 end, - ensure_controls=function()return controls end, - set_type_enabled=function()end,set_type_enabled_tree=function()end, -} -local tree={components=function(_,kind) if kind=="AnimalAIComponent" then return {animal} end return {} end,reset=function()end} -local entity_tree={walk=function(e,fn)fn(e)end} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/features/forms/component_ops.lua" then return component_ops end - if path=="mods/metamorph_creative_menu/files/features/forms/entity_tree_cache.lua" then return tree end - if path=="mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua" then return entity_tree end - local prefix="mods/metamorph_creative_menu/"; if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -function ComponentGetEntity() return 1 end -function ComponentSetValue2(c,f,v) values[c]=values[c] or {}; values[c][f]=v end -function EntitySetComponentIsEnabled() end -function EntityGetComponentIncludingDisabled(_,kind) if kind=="AnimalAIComponent" then return {animal} end return {} end -function EntityGetRootEntity() return 1 end -function EntityGetTransform() return 0,0,0,1,1 end -function EntitySetTransform() end -function EntityGetIsAlive() return true end -function DEBUG_GetMouseWorld() return math.cos(mouse_angle)*100,math.sin(mouse_angle)*100 end -function GameGetFrameNum() return 100 end -function EntityCreateNew() return 999 end -function EntityAddTag() end -function GamePlayAnimation() end -local combat=assert(native_dofile(root.."/files/features/forms/combat.lua")) -assert(combat.configure_ranged_player(1)==true,"static turret did not acquire ranged ownership") -for _=1,20 do combat.update_manual_aim(1) end -local actual=values[animal].mRangedAttackCurrentAimAngle -assert(math.abs(actual-mouse_angle)<0.0001, - "creature_detection_angular_range_deg=90 was incorrectly treated as a +/-45 degree sector: "..tostring(actual)) -print("form_static_turret_arc=PASS authored_half_arc=true full_sector_180=true") diff --git a/metamorph_creative_menu/tests/form_static_weapon_presentation_mock.lua b/metamorph_creative_menu/tests/form_static_weapon_presentation_mock.lua deleted file mode 100644 index 7d57ced..0000000 --- a/metamorph_creative_menu/tests/form_static_weapon_presentation_mock.lua +++ /dev/null @@ -1,37 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local gun,controls=20,21 -local values={ - [gun]={image_file="data/enemies_gfx/tank_gun.xml",update_transform_rotation=false,has_special_scale=true,special_scale_x=1,special_scale_y=1}, - [controls]={mButtonDownLeft=false,mButtonDownRight=false}, -} -local enabled={[gun]=true,[controls]=true} -local sx=-1 -local component_ops={ - valid=function(v)return v~=nil and v~=0 end, - get=function(c,f,d)local v=values[c] and values[c][f];if v==nil then return d end return v end, - boolean=function(v)if type(v)=="string" then return v=="1" or v=="true" end return v==true or v==1 end, -} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/features/forms/component_ops.lua" then return component_ops end - local prefix="mods/metamorph_creative_menu/" - if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -function EntityGetComponentIncludingDisabled(_,kind) if kind=="SpriteComponent" then return {gun} end return {} end -function EntityGetFirstComponentIncludingDisabled(_,kind) if kind=="ControlsComponent" then return controls end return nil end -function EntityGetTransform() return 0,0,0,sx,1 end -function EntityGetIsAlive() return true end -function ComponentSetValue2(c,f,v) values[c]=values[c] or {}; values[c][f]=v end - -local adapter=assert(native_dofile(root.."/files/features/forms/adapters/static_weapon_presentation.lua")) -assert(adapter.configure(1)==true,"tank static gun layer was not claimed") -adapter.update(1) -assert(values[gun].has_special_scale==false,"static tank gun did not inherit the root/body mirror") --- Switching the root/body side must require no fake turret rotation or per-sprite negative scale. -sx=1 -adapter.update(1) -assert(values[gun].has_special_scale==false and values[gun].special_scale_x==1,"tank gun presentation invented a separate flip channel") -adapter.reset() -assert(values[gun].has_special_scale==true and values[gun].special_scale_x==1 and values[gun].special_scale_y==1,"tank gun authored scale state was not restored") -print("form_static_weapon_presentation=PASS inherits_body_flip=true reversible=true") diff --git a/metamorph_creative_menu/tests/form_tab_return_mock.lua b/metamorph_creative_menu/tests/form_tab_return_mock.lua index d90ae9b..2a68b20 100644 --- a/metamorph_creative_menu/tests/form_tab_return_mock.lua +++ b/metamorph_creative_menu/tests/form_tab_return_mock.lua @@ -1,7 +1,5 @@ local root = assert(arg[1], "root required") local native_dofile = dofile - --- First keep the existing manager-level regression for input quarantine and polymorph expiry. local player_entity_id = 1 local polymorph_component_id = 77 local component_frames = 120 @@ -48,142 +46,4 @@ assert(form_manager.handle_tab_return(true) == false, "Alt-Tab/input quarantine assert(component_frames == 120, "blocked TAB modified polymorph effect") assert(form_manager.handle_tab_return(false) == true, "TAB did not return active polymorph form") assert(component_frames == 1, "TAB did not expire polymorph effect") - --- Then load the real init.lua with orchestration dependencies stubbed. This catches the --- F4/open-menu regression that a manager-only test cannot observe. -local state = { - menu_open = false, - has_form = false, - input_blocked = false, - return_pressed = false, - consume_counts = {}, - handle_calls = {}, - draw_calls = 0, - painter_disable_calls = 0, -} - -local function reset(overrides) - state.menu_open = false - state.has_form = false - state.input_blocked = false - state.return_pressed = false - state.consume_counts = {} - state.handle_calls = {} - state.draw_calls = 0 - state.painter_disable_calls = 0 - for key, value in pairs(overrides or {}) do state[key] = value end -end - -local function noop() end -local localization = {translate = function(_, fallback) return fallback end} -local input_guard = {update = noop, blocked = function() return state.input_blocked end} -local action_bindings = { - update = noop, - consume = function(action) - state.consume_counts[action] = (state.consume_counts[action] or 0) + 1 - if action == "return_human" then return state.return_pressed end - return false - end, -} -local init_form_manager = { - current_player = function() return 1 end, - update = noop, - has_active_form = function() return state.has_form end, - handle_tab_return = function(blocked, pressed) - state.handle_calls[#state.handle_calls + 1] = {blocked = blocked, pressed = pressed} - return blocked ~= true and pressed == true - end, -} -local weather = {update = noop, release = noop} -local world_rules = {update = noop, reset = noop, post_update = noop} -local menu_controller = { - is_open = function() return state.menu_open end, - draw = function() state.draw_calls = state.draw_calls + 1 end, -} -local keybinds = {update = noop} -local ew_resilience = {pre_init = noop, post_init = function() return 0, 0, 0 end} -local perk_service = {update = noop} -local effect_service = {update = noop, remove_all = noop} -local player_avatar = {update = noop} -local material_painter = { - update = noop, - adjust_brush = noop, - is_enabled = function() return false end, - set_enabled = function(enabled) - if enabled == false then state.painter_disable_calls = state.painter_disable_calls + 1 end - return true - end, -} -local player_tools = {update = noop, visible_players = function() return {} end, teleport_to = noop} - -local init_modules = { - ["mods/metamorph_creative_menu/dev_mode.lua"] = 0, - ["mods/metamorph_creative_menu/files/platform/noita/localization.lua"] = localization, - ["mods/metamorph_creative_menu/files/platform/noita/input_guard.lua"] = input_guard, - ["mods/metamorph_creative_menu/files/platform/noita/action_bindings.lua"] = action_bindings, - ["mods/metamorph_creative_menu/files/features/forms/manager.lua"] = init_form_manager, - ["mods/metamorph_creative_menu/files/features/weather/service.lua"] = weather, - ["mods/metamorph_creative_menu/files/features/world_rules/service.lua"] = world_rules, - ["mods/metamorph_creative_menu/files/ui/menu_controller.lua"] = menu_controller, - ["mods/metamorph_creative_menu/files/features/possession/keybinds.lua"] = keybinds, - ["mods/metamorph_creative_menu/files/integrations/ew/resilience.lua"] = ew_resilience, - ["mods/metamorph_creative_menu/files/integrations/ew/perk_sync.lua"] = {}, - ["mods/metamorph_creative_menu/files/features/perks/service.lua"] = perk_service, - ["mods/metamorph_creative_menu/files/features/effects/service.lua"] = effect_service, - ["mods/metamorph_creative_menu/files/features/companion/player_avatar.lua"] = player_avatar, - ["mods/metamorph_creative_menu/files/features/materials/painter.lua"] = material_painter, - ["mods/metamorph_creative_menu/files/features/player_tools/service.lua"] = player_tools, -} - -dofile_once = function() end -dofile = function(path) - if init_modules[path] ~= nil then return init_modules[path] end - local prefix = "mods/metamorph_creative_menu/" - if string.sub(path, 1, #prefix) == prefix then - return native_dofile(root .. "/" .. string.sub(path, #prefix + 1)) - end - return native_dofile(path) -end -function GameIsInventoryOpen() return false end - -native_dofile(root .. "/init.lua") -assert(type(OnWorldPreUpdate) == "function", "real init.lua did not expose OnWorldPreUpdate") - -reset({menu_open = true, has_form = true, return_pressed = true, input_blocked = false}) -OnWorldPreUpdate() -assert(state.consume_counts.return_human == 1, "open menu suppressed return_human action consumption") -assert(#state.handle_calls == 1, "form return handler was not called exactly once") -assert(state.handle_calls[1].blocked == false and state.handle_calls[1].pressed == true, - "form return handler received wrong state while menu was open") -assert(state.draw_calls == 0, "menu drew after successful form return") -assert(state.painter_disable_calls == 1, "successful form return did not disable the material painter") - -reset({menu_open = false, has_form = true, return_pressed = true, input_blocked = false}) -OnWorldPreUpdate() -assert(state.consume_counts.return_human == 1, "closed menu no longer consumes return_human exactly once") -assert(#state.handle_calls == 1 - and state.handle_calls[1].blocked == false - and state.handle_calls[1].pressed == true, - "closed-menu form return behavior changed") -assert(state.draw_calls == 0, "closed-menu successful form return did not short-circuit drawing") - -reset({menu_open = true, has_form = false, return_pressed = true, input_blocked = false}) -OnWorldPreUpdate() -assert((state.consume_counts.return_human or 0) == 0, "return_human was consumed without an active form") -assert(#state.handle_calls == 1 - and state.handle_calls[1].blocked == false - and state.handle_calls[1].pressed == false, - "inactive-form frame passed a return request to the handler") -assert(state.draw_calls == 1, "menu did not continue drawing without an active form") - -reset({menu_open = true, has_form = true, return_pressed = true, input_blocked = true}) -OnWorldPreUpdate() -assert(state.consume_counts.return_human == 1, "quarantined frame did not query return_human exactly once") -assert(#state.handle_calls == 1 - and state.handle_calls[1].blocked == true - and state.handle_calls[1].pressed == true, - "input quarantine state was not forwarded to handle_tab_return") -assert(state.draw_calls == 1, "blocked return incorrectly short-circuited menu drawing") -assert(state.painter_disable_calls == 0, "blocked return incorrectly disabled the material painter") - -io.write("form_tab_return=PASS blocked_alt_tab=true active_tab_return=true open_menu=true closed_menu=true inactive_form=true quarantine=true\n") +print("form_tab_return=PASS blocked_alt_tab=true active_tab_return=true") diff --git a/metamorph_creative_menu/tests/form_tank_special_case_removal_mock.lua b/metamorph_creative_menu/tests/form_tank_special_case_removal_mock.lua deleted file mode 100644 index 84d9cf6..0000000 --- a/metamorph_creative_menu/tests/form_tank_special_case_removal_mock.lua +++ /dev/null @@ -1,9 +0,0 @@ -local root=assert(arg[1],"root required") -local function read(path)local f=assert(io.open(root.."/"..path,"rb"));local s=f:read("*a");f:close();return s end -local runtime=read("files/features/forms/runtime.lua") -local family=read("files/features/forms/family.lua") -assert(not string.find(runtime,"tank_native",1,true),"legacy tank-native special runtime still present") -assert(not string.find(runtime,"native_tank_mode",1,true),"legacy tank animation mode flag still present") -assert(not string.find(family,"is_native_tank_path",1,true),"tank path special-case classifier still present") -assert(string.find(runtime,"configure_character_player",1,true) and string.find(runtime,"setup_manual_barrels(entity)",1,true),"tanks no longer flow through the ordinary character attack presentation path") -print("form_tank_special_case_removal=PASS special_runtime_removed=true generic_family=true") diff --git a/metamorph_creative_menu/tests/form_wand_ghost_controller_mock.lua b/metamorph_creative_menu/tests/form_wand_ghost_controller_mock.lua deleted file mode 100644 index d505249..0000000 --- a/metamorph_creative_menu/tests/form_wand_ghost_controller_mock.lua +++ /dev/null @@ -1,126 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local values,enabled,owners,components,alive,transforms,tags,inventories={},{},{},{},{},{},{},{} -local next_component,next_entity=1000,2000 -local frame=100 -local mouse_x,mouse_y=150,40 -local loads,use_calls={},{ } - -local function add(entity,kind,id,data) - components[entity]=components[entity] or {}; components[entity][kind]=components[entity][kind] or {} - table.insert(components[entity][kind],id); values[id]=data or {}; enabled[id]=values[id]._enabled~=false; owners[id]=entity - alive[entity]=true; transforms[entity]=transforms[entity] or {0,0,0,1,1}; return id -end -local component_ops={} -function component_ops.valid(v)return v~=nil and v~=0 end -function component_ops.get(c,f,d)local v=values[c] and values[c][f];if v==nil then return d end return v end -function component_ops.ensure_controls(entity)local x=components[entity] and components[entity].ControlsComponent;return x and x[1] or nil end -local gameplay_input={} -function gameplay_input.require_release() end -function gameplay_input.down(controls,surface) return surface=="primary" and values[controls].mButtonDownFire==true end -local bridge={} -function bridge.UseItem(entity,wand,a,b,c,x,y,tx,ty) - use_calls[#use_calls+1]={entity=entity,wand=wand,x=x,y=y,tx=tx,ty=ty} - return true -end -local patcher_bridge={} -function patcher_bridge.get(options) - assert(options and options.capability=="UseItem","wand controller requested wrong patcher capability") - return bridge -end -local stubs={ - ["mods/metamorph_creative_menu/files/features/forms/component_ops.lua"]=component_ops, - ["mods/metamorph_creative_menu/files/platform/noita/gameplay_input.lua"]=gameplay_input, - ["mods/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua"]=patcher_bridge, -} -dofile=function(path) - if stubs[path] then return stubs[path] end - local prefix="mods/metamorph_creative_menu/" - if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -function ComponentGetValue2(c,f)local v=values[c] and values[c][f];if type(v)=="table" and #v>=2 then return v[1],v[2] end return v end -function ComponentSetValue2(c,f,a,b) values[c]=values[c] or {};if b~=nil then values[c][f]={a,b}else values[c][f]=a end end -function ComponentGetEntity(c)return owners[c] or 0 end -function EntitySetComponentIsEnabled(_,c,v)enabled[c]=v==true end -function EntityGetComponentIncludingDisabled(entity,kind)return (components[entity] and components[entity][kind]) or {} end -function EntityGetFirstComponentIncludingDisabled(entity,kind)local x=EntityGetComponentIncludingDisabled(entity,kind);return x[1] end -function EntityGetTransform(entity)local t=transforms[entity] or {0,0,0,1,1};return t[1],t[2],t[3],t[4],t[5] end -function EntitySetTransform(entity,x,y,r,sx,sy)transforms[entity]={x,y,r or 0,sx or 1,sy or 1} end -function EntityGetIsAlive(entity)return alive[entity]~=false end -function EntityKill(entity)alive[entity]=false end -function EntityAddTag(entity,tag)tags[entity]=tags[entity] or {};tags[entity][tag]=true end -function EntityRemoveTag(entity,tag)if tags[entity] then tags[entity][tag]=nil end end -function EntityHasTag(entity,tag)return tags[entity] and tags[entity][tag]==true or false end -function DEBUG_GetMouseWorld()return mouse_x,mouse_y end -function GameGetFrameNum()return frame end -function GameGetAllInventoryItems(entity)return inventories[entity] end - -local helper_count=0 -function EntityLoad(path,x,y) - next_entity=next_entity+1;local e=next_entity;alive[e]=true;transforms[e]={x,y,0,1,1};loads[#loads+1]={entity=e,path=path,x=x,y=y} - if path=="data/entities/animals/wand_ghost.xml" then - helper_count=helper_count+1 - tags[e]={mortal=true,hittable=true,homing_target=true,enemy=true,wand_ghost=true} - add(e,"AnimalAIComponent",e*10+1,{_enabled=true,ai_state=10,attack_ranged_enabled=true,attack_ranged_entity_file="data/entities/projectiles/orb_pink.xml"}) - add(e,"ItemPickUpperComponent",e*10+2,{_enabled=true,is_in_npc=true,drop_items_on_death=true}) - add(e,"DamageModelComponent",e*10+3,{_enabled=true}) - add(e,"HitboxComponent",e*10+4,{_enabled=true}) - add(e,"PhysicsAIComponent",e*10+5,{_enabled=true}) - -- Simulate the result of the untouched vanilla one-shot wand_ghost.lua. Different - -- proxy seed positions produce different actual item sprites/decks. - next_entity=next_entity+1;local wand=next_entity;alive[wand]=true;transforms[wand]={x,y,0,1,1};tags[wand]={wand=true,item=true} - local sprite_path=helper_count==1 and "data/items_gfx/wands/wand_0240.png" or (helper_count==2 and "data/items_gfx/wands/wand_0777.png" or "data/items_gfx/wands/wand_0111.png") - add(wand,"SpriteComponent",wand*10+1,{_enabled=true,_tags="item,enabled_in_hand",image_file=sprite_path,visible=true}) - add(wand,"AbilityComponent",wand*10+2,{use_gun_script=true,mReloadFramesLeft=0,mNextFrameUsable=0}) - inventories[e]={wand} - end - return e -end - -local ghost=1 -add(ghost,"ControlsComponent",12,{mButtonDownFire=false,polymorph_hax=true}) -transforms[ghost]={10,20,0,1,1} -local controller=assert(native_dofile(root.."/files/features/forms/held_wand_forms/controller.lua")) -local paths={"data/entities/animals/wand_ghost.xml","data/entities/animals/wand_ghost_charmed.xml","data/entities/animals/wand_ghost_with_sampo.xml"} -local seed_positions={} -for i,path in ipairs(paths) do - assert(controller.configure(ghost,path)==true,"held-wand ghost path was not claimed: "..path) - assert(controller.owns_primary()==true,"wand ghost did not own primary fire") - local helper=loads[#loads].entity - assert(loads[#loads].path=="data/entities/animals/wand_ghost.xml","held-wand form did not use a vanilla proxy ghost") - seed_positions[#seed_positions+1]={loads[#loads].x,loads[#loads].y} - local ai=components[helper].AnimalAIComponent[1] - local pickup=components[helper].ItemPickUpperComponent[1] - assert(enabled[ai]==false and enabled[components[helper].PhysicsAIComponent[1]]==false,"proxy retained autonomous AI") - assert(enabled[components[helper].DamageModelComponent[1]]==false and enabled[components[helper].HitboxComponent[1]]==false,"proxy remained damageable") - assert(values[pickup].drop_items_on_death==false,"proxy could drop implementation wand") - assert(not EntityHasTag(helper,"hittable") and not EntityHasTag(helper,"enemy"),"proxy retained world-enemy tags") - - -- Update discovers the real vanilla-held item and attaches the invisible proxy to the - -- player body. The item's own SpriteComponent remains enabled; no MCM sprite copy can - -- independently become invisible. - values[12].mButtonDownFire=false;frame=frame+1;controller.update(ghost) - local wand=GameGetAllInventoryItems(helper)[1] - local wand_sprite=components[wand].SpriteComponent[1] - assert(enabled[wand_sprite]==true and values[wand_sprite].image_file~="","real procedural wand renderer was disabled") - assert(math.abs(transforms[helper][1]-10)<0.001 and math.abs(transforms[helper][2]-20)<0.001,"ready proxy was not attached to player form") - - values[12].mButtonDownFire=true;frame=frame+1;controller.update(ghost) - local use=use_calls[#use_calls] - assert(use and use.entity==helper and use.wand==wand,"primary fire did not cast the actual proxy-held wand") - assert(use.tx==mouse_x and use.ty==mouse_y,"real wand cast did not use cursor target") - assert(enabled[ai]==false and values[ai].attack_ranged_entity_file=="data/entities/projectiles/orb_pink.xml","MCM fell back to AnimalAI orb_pink ownership") - assert(not EntityHasTag(helper,"player_unit"),"temporary UseItem player tag leaked after cast") - - -- Ability cooldown is authoritative: an unavailable wand must not be forcibly used. - local ability=components[wand].AbilityComponent[1] - values[ability].mNextFrameUsable=frame+10 - local uses_before=#use_calls;frame=frame+1;controller.update(ghost) - assert(#use_calls==uses_before,"wand controller bypassed real AbilityComponent cooldown") - values[12].mButtonDownFire=false - controller.reset() - assert(alive[helper]==false and alive[wand]==false,"wand proxy/helper leaked after controller reset") -end -assert(seed_positions[1][1]~=seed_positions[2][1] or seed_positions[1][2]~=seed_positions[2][2],"proxy generation reused identical procedural seed coordinates") -print("form_wand_ghost_controller=PASS paths=3 vanilla_proxy=true native_item_renderer=true procedural_seed_varies=true patcher_use_item=true ability_cooldown=true no_ai_fallback=true") diff --git a/metamorph_creative_menu/tests/global_text_mock.lua b/metamorph_creative_menu/tests/global_text_mock.lua deleted file mode 100644 index 99771e7..0000000 --- a/metamorph_creative_menu/tests/global_text_mock.lua +++ /dev/null @@ -1,64 +0,0 @@ -local root = assert(arg[1], "root required") -local native_dofile = dofile -local global_text = assert(native_dofile(root .. "/files/core/global_text.lua")) - -local function valid_encoded(value) - assert(type(value) == "string", "diagnostic encoder did not return string") - local index = 1 - while index <= #value do - local byte = string.byte(value, index) - assert(byte >= 32 and byte <= 126, "diagnostic output contained non-printable/non-ASCII byte") - assert(byte ~= 34 and byte ~= 38 and byte ~= 60 and byte ~= 62 and byte ~= 92, - "diagnostic output contained unsafe literal ASCII") - if byte == 37 then - local hex = string.sub(value, index + 1, index + 2) - assert(#hex == 2 and string.match(hex, "^[0-9A-F][0-9A-F]$"), "diagnostic output ended in partial %HH") - index = index + 3 - else - index = index + 1 - end - end -end - -assert(global_text.encode_diagnostic(nil) == "", "nil diagnostic was not empty") -assert(global_text.encode_diagnostic("plain ASCII: / [] () _-+=!?,.;") == "plain ASCII: / [] () _-+=!?,.;", - "safe ASCII changed") - -local dangerous = "quote\" nul\0 cr\r lf\n tab\t <>&\\% Ж🔥" -local encoded = global_text.encode_diagnostic(dangerous) -valid_encoded(encoded) -for _, token in ipairs({"%22","%00","%0D","%0A","%09","%3C","%3E","%26","%5C","%25","%D0%96","%F0%9F%94%A5"}) do - assert(string.find(encoded, token, 1, true), "missing diagnostic escape " .. token) -end -assert(global_text.encode_diagnostic('%', 2) == '', 'partial percent escape crossed byte limit') -assert(global_text.encode_diagnostic('%', 3) == '%25', 'exact percent escape limit failed') -assert(global_text.encode_diagnostic('%A', 4) == '%25A', 'escape plus ASCII boundary failed') -assert(global_text.encode_diagnostic(string.char(0xC0,0xAF,0xFF)) == '%C0%AF%FF', 'malformed bytes were not deterministic') -local huge = global_text.encode_diagnostic(string.rep('x', 10001)) -assert(#huge == 512 and huge == string.rep('x',512), 'default 512-byte diagnostic bound failed') - -local globals = {} -function GlobalsGetValue(key, fallback) return globals[key] or fallback end -function GlobalsSetValue(key, value) globals[key] = tostring(value) end -dofile = function(path) - if path == "mods/metamorph_creative_menu/files/core/global_text.lua" then return global_text end - return native_dofile(path) -end -local common = assert(native_dofile(root .. "/files/integrations/ew/bridge/common.lua")) -common.report_error('rpc"<&%', 'bad\\\nЖ🔥' .. string.rep('z', 10000)) -local stored = assert(globals.mcm_world_rules_rpc_error_v1, 'bridge diagnostic Global missing') -valid_encoded(stored) -assert(#stored <= 512 and string.find(stored, '%22', 1, true) and string.find(stored, '%0A', 1, true), - 'bridge diagnostic Global did not use bounded encoder') -dofile = native_dofile - -local function source(path) - local file = assert(io.open(root .. '/' .. path, 'rb')) - local text = file:read('*a'); file:close(); return text -end -assert(string.find(source('init.lua'), 'global_text.encode_diagnostic', 1, true), 'init runtime diagnostic not wired to encoder') -assert(string.find(source('files/integrations/ew/bridge/materials.lua'), 'common.encode_diagnostic', 1, true), 'material diagnostic not wired to encoder') -assert(string.find(source('files/integrations/ew/bootstrap.lua'), 'common.encode_diagnostic', 1, true), 'perk runtime guard failure not wired to encoder') -assert(string.find(source('files/integrations/ew/resilience.lua'), 'global_text.encode_diagnostic', 1, true), 'EW resilience status not wired to encoder') - -print('global_text=PASS ascii_only=true escapes=true malformed=true bounded=true bridge=true sites=true') diff --git a/metamorph_creative_menu/tests/gravity_mock.lua b/metamorph_creative_menu/tests/gravity_mock.lua index 4aa3741..6e0933d 100644 --- a/metamorph_creative_menu/tests/gravity_mock.lua +++ b/metamorph_creative_menu/tests/gravity_mock.lua @@ -57,7 +57,7 @@ assert(math.abs(comps[101].gravity - (-400))<1e-6,"player gravity="..tostring(co comps[102].pixel_gravity=-700 api.post_update() assert(math.abs(comps[102].pixel_gravity - (-1400))<1e-6,"post-update reassert="..tostring(comps[102].pixel_gravity)) -local dbg=api.local_gravity_debug(); assert(dbg.factor==-4) +local dbg=api.local_gravity_state(); assert(dbg.factor==-4) local found=false; for _,r in ipairs(dbg.rows) do if r.field=="pixel_gravity" then found=true; assert(r.native==350); assert(r.expected==-1400); assert(r.current==-1400) end end; assert(found) -- RESET returns ownership snapshot (perk-modified 0), not the clean scale used for creative multiplication. frame=3 diff --git a/metamorph_creative_menu/tests/history_external_mock.lua b/metamorph_creative_menu/tests/history_external_mock.lua deleted file mode 100644 index 60fb0f7..0000000 --- a/metamorph_creative_menu/tests/history_external_mock.lua +++ /dev/null @@ -1,33 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local state={version=2,mana=10,spells={{entity=101,id='A'}}} -local applies=0 -local function clone(v) if type(v)~='table' then return v end local o={} for k,x in pairs(v) do o[k]=clone(x) end return o end -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/features/wands/blueprints.lua' then return { - capture=function() return clone(state),'ok' end, - apply=function(_,_,bp) applies=applies+1; state=clone(bp); return true,'loaded' end, - } end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -GameGetFrameNum=function() return 10 end -METAMORPH_CREATIVE_MENU_WAND_HISTORY=nil -local history=assert(native_dofile(root..'/files/features/wands/history.lua')) -assert(history.perform(1,2,'edit',function() state.mana=20; return true,'ok' end)==true) --- Simulate an external card transfer after the history record was created. -state.spells={}; state.external_owner=999 -local ok,why=history.undo(1,2) -assert(ok==false and why=='stale_history','stale undo was not rejected') -assert(applies==0 and #state.spells==0 and state.external_owner==999,'stale undo recreated/destroyed externally moved card state') -assert(not history.can_undo(2) and not history.can_redo(2),'stale history chain was not invalidated') --- Redo has the same precondition in the opposite direction. -state={version=2,mana=10,spells={{entity=201,id='B'}}}; applies=0 -assert(history.perform(1,3,'edit2',function() state.mana=30; return true end)==true) -assert(history.undo(1,3)==true and state.mana==10) -local after_normal_undo=applies -state.spells={}; state.external_owner=777 -ok,why=history.redo(1,3) -assert(ok==false and why=='stale_history' and applies==after_normal_undo,'stale redo mutated wand') -print('history_external=PASS undo_guard=true redo_guard=true no_external_card_mutation=true') diff --git a/metamorph_creative_menu/tests/horizontal_strip_drag_ownership_mock.lua b/metamorph_creative_menu/tests/horizontal_strip_drag_ownership_mock.lua deleted file mode 100644 index cf2a39e..0000000 --- a/metamorph_creative_menu/tests/horizontal_strip_drag_ownership_mock.lua +++ /dev/null @@ -1,84 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local mouse_x,mouse_y=10,10 -local down=true -local just_down=true -local clicked_image=nil -local scroll_model_loaded=false - -METAMORPH_CREATIVE_MENU_POINTER={ - gui_position=function() return mouse_x,mouse_y end, - left_down=function() return down end, - left_just_down=function() return just_down end, - inside=function(x,y,w,h,px,py) return px>=x and px<=x+w and py>=y and py<=y+h end, -} -local prefix='mods/metamorph_creative_menu/' -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/ui/widgets/scroll_model.lua' then - scroll_model_loaded=true - error('horizontal strip must not load the wheel scroll model') - end - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -GUI_OPTION={Layout_NoLayouting=1} -local last={x=0,y=0,w=0,h=0} -local row_y=4 -local nav_x=0 -local in_layout=false -GuiOptionsAddForNextWidget=function() end -local z_calls={} -GuiColorSetForNextWidget=function() end -GuiZSetForNextWidget=function(_,z) z_calls[#z_calls+1]=z end -GuiText=function() end -GuiLayoutBeginHorizontal=function() in_layout=true; nav_x=0 end -GuiLayoutEnd=function() if in_layout then row_y=row_y+22 end; in_layout=false end -GuiButton=function(_,_,_,_,_) - last={x=nav_x,y=row_y,w=10,h=9}; nav_x=nav_x+12 - return false -end -GuiImageButton=function(_,_,_,_,_,image) - last={x=nav_x,y=row_y,w=9,h=17}; nav_x=nav_x+11 - return image==clicked_image -end -GuiImage=function(_,_,x,y,_,_,sx,sy) - if in_layout then last={x=nav_x,y=row_y,w=tonumber(sx) or 1,h=tonumber(sy) or 1}; nav_x=nav_x+(tonumber(sx) or 1)+2 - else last={x=x,y=y,w=tonumber(sx) or 1,h=tonumber(sy) or 1} end -end -GuiGetPreviousWidgetInfo=function() return 0,0,false,last.x,last.y,last.w,last.h end - -METAMORPH_CREATIVE_MENU_HORIZONTAL_STRIP=nil -local strip=assert(native_dofile(root..'/files/ui/widgets/horizontal_strip.lua')) -local function draw_item(index) - return false,false,true,4+index*20,4,18,18 -end -local next_id_value=0 -local function next_id() next_id_value=next_id_value+1; return next_id_value end -local options={gui=1,screen_width=320,screen_height=240,next_id=next_id} - -local first=strip.draw('no_pan',8,80,20,draw_item,options) -assert(first.offset==0,'initial horizontal offset changed without wheel input') -just_down=false; mouse_x=50 -local second=strip.draw('no_pan',8,80,20,draw_item,options) -assert(second.offset==0,'LMB movement unexpectedly panned the horizontal strip') -down=false -strip.draw('no_pan',8,80,20,draw_item,options) - --- The inline right arrow advances exactly one visible page. -clicked_image='mods/metamorph_creative_menu/files/ui/assets/page_right.png' -local third=strip.draw('no_pan',8,80,20,draw_item,options) -assert(third.offset==2,'right arrow did not advance one visible page') -clicked_image=nil -local fourth=strip.draw('no_pan',8,80,20,draw_item,options) -assert(fourth.first==2,'advanced page was not rendered on the next frame') - --- The inline left arrow returns one page without a wheel or drag recognizer. -clicked_image='mods/metamorph_creative_menu/files/ui/assets/page_left.png' -local fifth=strip.draw('no_pan',8,80,20,draw_item,options) -assert(fifth.offset==0 and fifth.first==0,'left arrow did not return one visible page') -assert(not scroll_model_loaded,'horizontal strip unexpectedly loaded wheel scrolling') -local foreground=false -for _,z in ipairs(z_calls) do if tonumber(z) and tonumber(z)<=-110 then foreground=true end end -assert(foreground,'navigation arrows were not forced in front of the menu background') - -print('horizontal_strip_drag_ownership=PASS lmb_pan=false wheel=false arrows=true page_step=2 single_tall_arrows=true foreground_z=true') diff --git a/metamorph_creative_menu/tests/input_guard_mock.lua b/metamorph_creative_menu/tests/input_guard_mock.lua index 085a2fb..3b7f6dd 100644 --- a/metamorph_creative_menu/tests/input_guard_mock.lua +++ b/metamorph_creative_menu/tests/input_guard_mock.lua @@ -13,7 +13,7 @@ local keycode_stub = { } dofile = function(path) if path == "mods/metamorph_creative_menu/files/platform/noita/keycodes.lua" then return keycode_stub end - return native_dofile(path:gsub("^mods/metamorph_creative_menu/", root.."/")) + return native_dofile(path) end function GameGetFrameNum() return frame_number end @@ -55,15 +55,3 @@ guard.update() assert(guard.resume_serial() == 2, "real-time focus gap did not mark resume") assert(guard.blocked() == true, "real-time focus gap did not quarantine actions") print("input_guard=PASS alt_tab=true focus_gap=true") - --- A slow first search is not an Alt-Tab. Keyboard focus must survive a long frame. -local text_guard=native_dofile(root.."/files/platform/noita/text_entry_guard.lua") -frame_number=100; real_time_seconds=1.11; guard.update() -local serial=guard.resume_serial() -text_guard.focus("materials") -frame_number=101; real_time_seconds=2.2; guard.update() -assert(guard.resume_serial()==serial and not guard.blocked(),"first-search stall treated as focus loss") -alt_down=true; frame_number=102; guard.update() -assert(guard.blocked(),"text focus disabled explicit Alt protection") -text_guard.clear() -print("input_guard_search_stall=PASS timing_gap_keeps_focus=true explicit_alt_guard=true") diff --git a/metamorph_creative_menu/tests/item_all_liquid_catalog_mock.lua b/metamorph_creative_menu/tests/item_all_liquid_catalog_mock.lua deleted file mode 100644 index 595e80a..0000000 --- a/metamorph_creative_menu/tests/item_all_liquid_catalog_mock.lua +++ /dev/null @@ -1,42 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local prefix="mods/metamorph_creative_menu/" -local catalog_path=prefix.."files/features/items/catalog.lua" -local outliers_path=prefix.."files/features/items/vanilla_outliers.lua" -local registry_path=prefix.."files/item_registry.lua" - -dofile=function(path) - if path==catalog_path then - return {{path="data/entities/items/pickup/test_item.xml",name="$item_test",description="$item_test_desc",category="OTHER"}} - end - if path==outliers_path then return {} end - if path==registry_path then METAMORPH_CREATIVE_MENU_EXTERNAL_ITEMS={}; return METAMORPH_CREATIVE_MENU_EXTERNAL_ITEMS end - return native_dofile(path) -end -function ModDoesFileExist(path) return path=="data/entities/items/pickup/test_item.xml" or path==registry_path end -function ModGetActiveModIDs() return {"metamorph_creative_menu"} end -function CellFactory_GetAllLiquids() return {"water","oil"} end -function CellFactory_GetType(name) if name=="water" then return 1 elseif name=="oil" then return 2 end return -1 end -function CellFactory_GetUIName(id) if id==1 then return "$mat_water" elseif id==2 then return "$mat_oil" end return "" end -local translations={ - ["$item_test"]="Тестовый предмет", ["$item_test_desc"]="Описание", - ["$mat_water"]="Вода", ["$mat_oil"]="Масло", -} -local function tr(key) return translations[key] or key end -local function trf(key,fallback) local value=tr(key); return value==key and fallback or value end - -local item_catalog=assert(native_dofile(root.."/files/features/items/ui_catalog.lua")) -local all=item_catalog.entries_for(1,tr,trf) -local water=nil -local item=nil -for _,entry in ipairs(all) do - if entry.id=="water" then water=entry end - if entry.path=="data/entities/items/pickup/test_item.xml" then item=entry end -end -assert(item and item.kind=="item","ALL lost ordinary items") -assert(water and water.kind=="liquid","ALL does not include dynamic liquids") -assert(water.display_name=="Вода","liquid current-language display name was not preserved") -assert(water.ui_name_key=="$mat_water","liquid localization key was not retained for multilingual search aliases") -local liquids=item_catalog.liquids(tr) -assert(#liquids==2 and liquids[1].id=="water","dedicated liquid catalogue regressed") -print("item_all_liquid_catalog=PASS all_union=true localized_name=true localization_key=true") diff --git a/metamorph_creative_menu/tests/item_drag_transaction_mock.lua b/metamorph_creative_menu/tests/item_drag_transaction_mock.lua deleted file mode 100644 index ddd8271..0000000 --- a/metamorph_creative_menu/tests/item_drag_transaction_mock.lua +++ /dev/null @@ -1,198 +0,0 @@ -local root = assert(arg[1], "root required") -local native_dofile = dofile -local prefix = "mods/metamorph_creative_menu/" -dofile = function(path) - if string.sub(path, 1, #prefix) == prefix then - return native_dofile(root .. "/" .. string.sub(path, #prefix + 1)) - end - return native_dofile(path) -end - -local alive = {[1]=true,[2]=true,[3]=true} -local names = {[1]="player",[2]="inventory_quick",[3]="inventory_full"} -local parent = {[2]=1,[3]=1} -local children = {[1]={2,3},[2]={},[3]={}} -local comps = {[1]={101}} -local ctype = {[101]="Inventory2Component"} -local fields = {[101]={quick_inventory_slots=4,full_inventory_slots_x=16,full_inventory_slots_y=1}} -local transform = {[1]={100,200}} -local material = {} -local next_entity = 10 -local loads = {} -local ew_calls = 0 -local ew_enabled = true -local ew_fail = false -local pickup_calls = 0 -local material_fail = false -local component_enable = {} - -local function add_child(p, e) - if parent[e] and children[parent[e]] then - for i,v in ipairs(children[parent[e]]) do if v==e then table.remove(children[parent[e]],i); break end end - end - parent[e]=p - children[p]=children[p] or {} - children[p][#children[p]+1]=e -end -local function new_item(path,x,y) - local e=next_entity; next_entity=next_entity+1 - alive[e]=true; names[e]="item"; transform[e]={x,y}; children[e]={} - local c=10000+e - comps[e]={c}; ctype[c]="ItemComponent" - fields[c]={inventory_slot={0,0},is_pickable=true,auto_pickup=false,has_been_picked_by_player=false} - loads[#loads+1]={entity=e,path=path,x=x,y=y} - -- Deliberately make a child that EntityKill(root) alone would not clean up. - local child=e*100 - alive[child]=true; names[child]="child"; children[child]={}; add_child(e,child) - return e,child -end - -function EntityGetIsAlive(e) return alive[e]==true end -function EntityGetAllChildren(e) local out={}; for _,v in ipairs(children[e] or {}) do if alive[v] then out[#out+1]=v end end; return out end -function EntityGetFirstComponentIncludingDisabled(e,t) - for _,c in ipairs(comps[e] or {}) do if ctype[c]==t then return c end end - return 0 -end -function EntityGetComponentIncludingDisabled(e,t) - local out={}; for _,c in ipairs(comps[e] or {}) do if ctype[c]==t then out[#out+1]=c end end; return out -end -function ComponentGetValue2(c,k) - local v=fields[c] and fields[c][k] - if type(v)=="table" then return unpack(v) end - return v -end -function ComponentSetValue2(c,k,...) - local a={...}; fields[c]=fields[c] or {}; fields[c][k]=#a>1 and a or a[1]; return true -end -function EntityGetName(e) return names[e] or "" end -function EntityGetParent(e) return parent[e] or 0 end -function EntityGetRootEntity(e) local cur=e; while parent[cur] do cur=parent[cur] end; return cur end -function EntityHasTag() return false end -function EntityGetTransform(e) local t=transform[e]; if not t then return nil,nil end; return t[1],t[2] end -function EntitySetTransform(e,x,y) transform[e]={x,y} end -function EntityRemoveFromParent(e) - local p=parent[e]; if p and children[p] then for i,v in ipairs(children[p]) do if v==e then table.remove(children[p],i); break end end end - parent[e]=nil -end -function EntityAddChild(p,e) EntityRemoveFromParent(e); add_child(p,e) end -function EntityKill(e) alive[e]=false; EntityRemoveFromParent(e) end -function EntitySetComponentsWithTagEnabled(e,tag,value) - component_enable[e]=component_enable[e] or {}; component_enable[e][tag]=value -end -function EntityLoad(path,x,y) - if path=="data/entities/items/bad_load.xml" then return 0 end - return (new_item(path,x,y)) -end -function ModDoesFileExist(path) - return path=="data/entities/items/test.xml" or path=="data/entities/items/bad_load.xml" - or path=="data/entities/items/pickup/potion_empty.xml" -end -function GamePickUpInventoryItem(player,e) - pickup_calls=pickup_calls+1 - add_child(2,e) -end -function RemoveMaterialInventoryMaterial(e) material[e]=nil end -function AddMaterialInventoryMaterial(e,id,amount) - if material_fail then error("material failure") end - assert(amount==1000,"wrong liquid amount"); material[e]=id; return true -end -function ModIsEnabled(id) return id=="quant.ew" and ew_enabled end -function CrossCall(name,e) - assert(name=="ew_thrown","wrong EW crosscall") - ew_calls=ew_calls+1 - if ew_fail then error("EW down") end - return true -end -local globals={} -function GlobalsGetValue(k,d) return globals[k] or d end -function GlobalsSetValue(k,v) if ew_fail then error("EW mailbox down") end; globals[k]=v end -function GameGetFrameNum() return 1 end -function GameHasFlagRun() return false end - -for _,name in ipairs({"METAMORPH_CREATIVE_MENU_ITEM_SERVICE","METAMORPH_CREATIVE_MENU_EW_RUNTIME"}) do _G[name]=nil end -local service=assert(native_dofile(root.."/files/features/items/service.lua")) - --- Exact ordinary world drop: requested coordinates, world component state, one EW handoff. -local entity,reason=service.spawn_at("data/entities/items/test.xml",321.5,-77.25) -assert(entity~=0 and reason=="spawned_direct","exact item world spawn failed: "..tostring(reason)) -local last=loads[#loads] -assert(last.entity==entity and last.x==321.5 and last.y==-77.25,"world coordinates were changed") -assert(component_enable[entity].enabled_in_world==true and component_enable[entity].enabled_in_inventory==false,"world component state not committed") -assert(ew_calls==1,"successful world item must perform exactly one EW handoff") - --- Exact filled bottle uses the same world transaction and keeps the requested liquid. -local before_ew=ew_calls -local ok,liquid_reason,flask=service.spawn_filled_flask_at("water",-12.5,44.75) -assert(ok and flask~=0 and liquid_reason=="spawned_direct","filled flask world drop failed") -last=loads[#loads] -assert(last.entity==flask and last.x==-12.5 and last.y==44.75,"filled flask ignored exact world coordinates") -assert(material[flask]=="water","filled flask lost selected material") -assert(ew_calls==before_ew+1,"filled flask did not perform exactly one EW handoff") - --- Invalid coordinates must not load anything. -local load_count=#loads -local invalid,invalid_reason=service.spawn_at("data/entities/items/test.xml",nil,10) -assert(invalid==0 and invalid_reason=="invalid" and #loads==load_count,"missing coordinates created an entity") -local bad_liquid,bad_liquid_reason=service.spawn_filled_flask_at("water",0,nil) -assert(not bad_liquid and bad_liquid_reason=="invalid" and #loads==load_count,"missing liquid coordinates created an entity") - --- XML load failure creates nothing. -local bad_load,bad_load_reason=service.spawn_at("data/entities/items/bad_load.xml",1,2) -assert(bad_load==0 and bad_load_reason=="load","XML load failure was not transactional") - --- Material fill failure recursively removes root and descendants. -material_fail=true -local material_ok,material_reason=service.spawn_filled_flask_at("oil",5,6) -material_fail=false -assert(not material_ok and material_reason=="material","material failure reason changed") -local failed_root=loads[#loads].entity -assert(not alive[failed_root] and not alive[failed_root*100],"material failure left a partial flask tree") - --- EW failure after a successful load/world-enable also rolls back the full tree. -ew_fail=true -local ew_entity,ew_reason=service.spawn_at("data/entities/items/test.xml",9,10) -ew_fail=false -assert(ew_entity==0 and ew_reason=="world_sync","EW failure was not reported") -failed_root=loads[#loads].entity -assert(not alive[failed_root] and not alive[failed_root*100],"EW failure left a world item or child behind") - --- The legacy short-click world path shares the same strict world commit. -ew_fail=true -local near_entity,near_reason=service.spawn_near(1,"data/entities/items/test.xml") -ew_fail=false -assert(near_entity==0 and near_reason=="world_sync","short-click EW failure left a nominal spawn") -failed_root=loads[#loads].entity -assert(not alive[failed_root] and not alive[failed_root*100],"short-click EW failure left partial entities") - --- Strict inventory release creates only on commit and rolls back when quick inventory is full. -for slot=0,3 do - local e=40+slot; alive[e]=true; names[e]="occupied"; children[e]={}; add_child(2,e) - local c=10000+e; comps[e]={c}; ctype[c]="ItemComponent"; fields[c]={inventory_slot={slot,0},is_pickable=true,auto_pickup=false} -end -local ew_before_inventory=ew_calls -local inv_ok,inv_reason,inv_entity=service.give_strict(1,"data/entities/items/test.xml",true) -assert(not inv_ok and inv_reason=="full" and inv_entity==0,"full inventory did not reject strict drop: ok="..tostring(inv_ok).." reason="..tostring(inv_reason).." entity="..tostring(inv_entity)) -failed_root=loads[#loads].entity -assert(not alive[failed_root] and not alive[failed_root*100],"failed inventory drop left partial entities") -assert(ew_calls==ew_before_inventory,"failed inventory drop must not perform an EW world handoff") -local liquid_inv_ok,liquid_inv_reason,liquid_inv_entity=service.give_filled_flask_strict(1,"water") -assert(not liquid_inv_ok and liquid_inv_reason=="full" and liquid_inv_entity==0,"full inventory accepted strict liquid drop") -failed_root=loads[#loads].entity -assert(not alive[failed_root] and not alive[failed_root*100],"failed liquid inventory drop left partial entities") -assert(ew_calls==ew_before_inventory,"failed liquid inventory drop incorrectly used EW") - --- Free one slot and verify a strict inventory commit succeeds without a world handoff. -EntityKill(43) -local inv_ok2,inv_reason2,inv_entity2=service.give_strict(1,"data/entities/items/test.xml",true) -assert(inv_ok2 and inv_reason2=="picked" and inv_entity2~=0,"strict inventory commit failed") -assert(EntityGetParent(inv_entity2)==2,"strict inventory item did not end in quick inventory") -assert(ew_calls==ew_before_inventory,"inventory commit incorrectly used EW world handoff") - --- Single-player world spawning does not require EW/CrossCall at all. -ew_enabled=false -local ew_before_single=ew_calls -local solo,solo_reason=service.spawn_at("data/entities/items/test.xml",7,8) -assert(solo~=0 and solo_reason=="spawned_singleplayer","single-player world spawn became EW-dependent") -assert(ew_calls==ew_before_single,"single-player world spawn called EW") - -print("item_drag_transaction=PASS exact_world=true liquid=true strict_inventory=true rollback=true ew_once=true singleplayer=true") diff --git a/metamorph_creative_menu/tests/item_icon_descriptor_mock.lua b/metamorph_creative_menu/tests/item_icon_descriptor_mock.lua deleted file mode 100644 index e3a05d6..0000000 --- a/metamorph_creative_menu/tests/item_icon_descriptor_mock.lua +++ /dev/null @@ -1,243 +0,0 @@ -local root = assert(arg[1], "root") -local prefix = "mods/metamorph_creative_menu/" -local real_dofile = dofile - -local files = {} -local dimensions = {} -local writes = {} -local existing = {} - -local function add(path, content, w, h) - existing[path] = true - if content ~= nil then files[path] = content end - if w ~= nil and h ~= nil then dimensions[path] = {w, h} end -end - -local function sprite_xml(png, fw, fh, count, per_row, pos_x, pos_y, default_name, animation_name) - default_name = default_name == false and nil or (default_name or "default") - animation_name = animation_name or "default" - return '' - .. '' -end - -local HEART_XML = "data/items_gfx/heart_extrahp.xml" -local EVIL_XML = "data/items_gfx/heart_extrahp_evil.xml" -local FULL_XML = "data/items_gfx/heart.xml" -local REFRESH_XML = "data/items_gfx/spell_refresh.xml" -local CHAINSAW_XML = "data/items_gfx/wands/custom/chainsaw.xml" -add(HEART_XML, sprite_xml("data/items_gfx/heart_extrahp.png", 20, 20, 4, 4, 0, 0), nil, nil) -add("data/items_gfx/heart_extrahp.png", nil, 80, 20) -add(EVIL_XML, sprite_xml("data/items_gfx/heart_extrahp_evil.png", 20, 20, 4, 4, 0, 0), nil, nil) -add("data/items_gfx/heart_extrahp_evil.png", nil, 80, 40) -add(FULL_XML, sprite_xml("data/items_gfx/heart.png", 20, 20, 4, 4, 0, 0), nil, nil) -add("data/items_gfx/heart.png", nil, 80, 40) -add(REFRESH_XML, sprite_xml("data/items_gfx/spell_refresh.png", 20, 20, 4, 4, 0, 0), nil, nil) -add("data/items_gfx/spell_refresh.png", nil, 80, 20) -add(CHAINSAW_XML, sprite_xml("data/items_gfx/wands/custom/chainsaw.png", 14, 6, 2, 10, 0, 1), nil, nil) -add("data/items_gfx/wands/custom/chainsaw.png", nil, 28, 8) - -for i = 0, 11 do - local suffix = string.format("%02d", i) - local xml = "data/items_gfx/orbs/orb_" .. suffix .. ".xml" - local png = (i == 11) and "data/items_gfx/orbs/orb.png" or ("data/items_gfx/orbs/orb_" .. suffix .. ".png") - add(xml, sprite_xml(png, 40, 50, 7, 7, 0, 0), nil, nil) - add(png, nil, 280, 50) -end -add("data/items_gfx/orbs/orb.xml", sprite_xml("data/items_gfx/orbs/orb.png", 40, 50, 7, 7, 0, 0), nil, nil) -add("data/items_gfx/orbs/orb.png", nil, 280, 50) - --- Only the relevant real catalog entities are marked as present. ui_catalog's prewarm --- therefore exercises the production catalog/outlier/registry collection without a Noita runtime. -for _, path in ipairs({ - "data/entities/items/pickup/heart.xml", - "data/entities/items/pickup/heart_better.xml", - "data/entities/items/pickup/heart_evil.xml", - "data/entities/items/pickup/heart_fullhp.xml", - "data/entities/items/pickup/heart_fullhp_temple.xml", - "data/entities/items/pickup/spell_refresh.xml", - "data/entities/items/wands/experimental/experimental_wand_4.xml", -}) do add(path, "") end -for i = 0, 11 do add("data/entities/items/orbs/orb_" .. string.format("%02d", i) .. ".xml", "") end -add("data/entities/items/orbs/orb_13.xml", "") - -local FIRST_XML = "data/test/no_default.xml" -add(FIRST_XML, - '') -add("data/test/no_default.png", nil, 18, 11) -local SIMPLE_PNG = "data/ui_gfx/items/simple_test.png" -add(SIMPLE_PNG, nil, 13, 17) -add("data/ui_gfx/inventory/inventory_box.png", nil, 18, 18) -add("data/ui_gfx/progress_menu/icon_unknown.png", nil, 16, 16) - -local LATE_ENTITY = "mods/example/files/late_item.xml" -local LATE_XML = "mods/example/files/late_item_sprite.xml" -add(LATE_XML, sprite_xml("mods/example/files/late_item_sprite.png", 12, 10, 3, 3, 0, 0, false, "idle")) -add("mods/example/files/late_item_sprite.png", nil, 36, 10) -add(LATE_ENTITY, '') -local BAD_XML = "mods/example/files/bad_sprite.xml" -add(BAD_XML, '') -add("mods/example/files/bad_sprite.png", nil, 64, 16) - -function ModTextFileGetContent(path) return files[path] end -function ModTextFileSetContent(path, content) - writes[#writes + 1] = {path=path, content=content} - files[path] = content - existing[path] = true -end -function ModDoesFileExist(path) return existing[path] == true end -function ModGetActiveModIDs() return {} end -function GuiGetImageDimensions(_, path) - local d = dimensions[path] - if d then return d[1], d[2] end - return 0, 0 -end - -function dofile(path) - if string.sub(path, 1, #prefix) == prefix then - return real_dofile(root .. "/" .. string.sub(path, #prefix + 1)) - end - return real_dofile(path) -end - -METAMORPH_CREATIVE_MENU_ASSETS = nil -local assets = dofile(prefix .. "files/platform/noita/assets.lua") -local item_catalog = dofile(prefix .. "files/features/items/ui_catalog.lua") - -local generated_catalog = assert(real_dofile(root .. "/files/features/items/catalog.lua")) -local by_path = {} -for _, entry in ipairs(generated_catalog) do by_path[entry.path] = entry end -local expected_gold_icons = { - ["data/entities/items/pickup/goldnugget.xml"] = "data/ui_gfx/items/goldnugget.png", - ["data/entities/items/pickup/goldnugget_10.xml"] = "data/ui_gfx/items/goldnugget.png", - ["data/entities/items/pickup/goldnugget_50.xml"] = "data/ui_gfx/items/goldnugget.png", - ["data/entities/items/pickup/goldnugget_200.xml"] = "data/ui_gfx/items/goldnugget.png", - ["data/entities/items/pickup/goldnugget_1000.xml"] = "data/ui_gfx/items/goldnugget.png", - ["data/entities/items/pickup/goldnugget_10000.xml"] = "data/items_gfx/easter/golden_idol.png", - ["data/entities/items/pickup/goldnugget_200000.xml"] = "data/items_gfx/easter/golden_idol_big.png", - ["data/entities/items/pickup/goldnugget_x.xml"] = "data/entities/animals/boss_centipede/rewards/gold_reward_sprite.png", - ["data/entities/items/pickup/bloodmoney_10.xml"] = "data/ui_gfx/items/goldnugget.png", - ["data/entities/items/pickup/bloodmoney_10000.xml"] = "data/items_gfx/easter/golden_idol.png", - ["data/entities/items/pickup/bloodmoney_200000.xml"] = "data/items_gfx/easter/golden_idol_big.png", - ["data/entities/items/pickup/bloodmoney_x.xml"] = "data/entities/animals/boss_centipede/rewards/gold_reward_sprite.png", -} -for entity_path, icon_path in pairs(expected_gold_icons) do - assert(by_path[entity_path] and by_path[entity_path].icon == icon_path, - "gold/catalog presentation still points at generic icon: " .. entity_path) -end - --- Post-init collection must cover the real built-in catalog sources and deduplicate shared icons. -local prepared, failed = item_catalog.prewarm_icons(assets) -assert(prepared > 0 and failed == 0, "catalog icon prewarm failed") -local writes_after_catalog = #writes -assert(writes_after_catalog > 0, "catalog prewarm did not publish proxies") - -local heart = assert(assets.resolve(HEART_XML), "heart descriptor missing") -assert(heart.kind == "sprite_proxy" and heart.single_frame == true, "heart did not use prewarmed one-frame proxy") -assert(heart.width == 20 and heart.height == 20, "heart frame dimensions wrong") -assert(heart.animation_name == "default", "heart default animation not selected") -assert(heart.rect_animation.pos_x == 0 and heart.rect_animation.pos_y == 0, "heart crop origin wrong") - -local evil = assert(assets.resolve(EVIL_XML), "evil heart descriptor missing") -assert(evil.width == 20 and evil.height == 20 and evil.single_frame == true, "evil heart frame descriptor wrong") -local refresh = assert(assets.resolve(REFRESH_XML), "spell refresh descriptor missing") -assert(refresh.width == 20 and refresh.height == 20 and refresh.single_frame == true, "spell refresh frame descriptor wrong") -local orb = assert(assets.resolve("data/items_gfx/orbs/orb_00.xml"), "orb descriptor missing") -assert(orb.width == 40 and orb.height == 50 and orb.single_frame == true, "orb frame descriptor wrong") -local orb13 = assert(assets.resolve("data/items_gfx/orbs/orb.xml"), "orb 13 descriptor missing") -assert(orb13.width == 40 and orb13.height == 50, "orb 13 frame descriptor wrong") -local chainsaw = assert(assets.resolve(CHAINSAW_XML), "chainsaw descriptor missing") -assert(chainsaw.width == 14 and chainsaw.height == 6, "chainsaw frame dimensions wrong") -assert(chainsaw.rect_animation.pos_y == 1, "chainsaw non-zero pos_y was lost") - --- A source atlas PNG with a sibling Sprite XML must never escape as a successful raster icon. -local atlas_request = assert(assets.resolve("data/items_gfx/heart_extrahp.png"), "atlas request missing descriptor") -assert(atlas_request.path ~= "data/items_gfx/heart_extrahp.png", "animated source atlas returned as icon") -assert(atlas_request.single_frame == true, "atlas request did not route to one-frame descriptor") - --- A genuine single-frame PNG remains a raster and is not forced through XML machinery. -assets.bind_gui({}) -local simple = assert(assets.resolve(SIMPLE_PNG), "single-frame PNG missing") -assert(simple.kind == "raster" and simple.path == SIMPLE_PNG and simple.single_frame == true, "single-frame PNG was rewritten") -assert(assets.dimensions(simple) == 13, "single-frame PNG dimensions unavailable") - --- Default animation fallback: when Sprite.default_animation is absent, first RectAnimation wins. -local before_first = #writes -local first_prepared, first_failed = assets.prewarm({FIRST_XML, FIRST_XML}) -assert(first_prepared == 1 and first_failed == 0, "deduplicated prewarm count wrong") -assert(#writes == before_first + 1, "duplicate XML path wrote more than one proxy") -local first = assert(assets.resolve(FIRST_XML), "first-animation descriptor missing") -assert(first.animation_name == "idle", "first RectAnimation was not selected") -assert(first.rect_animation.pos_x == 3 and first.rect_animation.pos_y == 4, "first animation crop metadata wrong") -local writes_before_repeat = #writes -assets.resolve(FIRST_XML); assets.resolve(FIRST_XML); assets.prewarm({FIRST_XML}) -assert(#writes == writes_before_repeat, "prewarmed proxy was written again during resolve/prewarm") - --- A third-party XML appearing after prewarm cannot receive an init proxy. It must still --- resolve to its original Sprite XML descriptor with explicit animation metadata, never raw atlas. -local writes_before_late = #writes -local late = assert(assets.resolve_entity(LATE_ENTITY, "item"), "late mod item descriptor missing") -assert(#writes == writes_before_late, "runtime-discovered mod icon attempted late VFS proxy write") -assert(late.kind == "sprite_xml" and late.path == LATE_XML, "late mod item did not keep original Sprite XML") -assert(late.animation_name == "idle" and late.width == 12 and late.height == 10, "late mod animation descriptor incomplete") -assert(late.path ~= late.source_png and late.source_is_atlas == true, "late mod atlas escaped as raster") - --- Malformed mod XML fails closed to a placeholder and is not negative-cached forever. -local bad = assert(assets.resolve(BAD_XML), "malformed XML did not produce descriptor") -assert(bad.kind == "placeholder" and bad.reason == "sprite_xml_missing_animation", "malformed XML failure reason wrong") -local still_good = assert(assets.resolve(HEART_XML), "malformed XML poisoned unrelated asset") -assert(still_good.kind == "sprite_proxy", "malformed XML broke existing proxy") -files[BAD_XML] = sprite_xml("mods/example/files/bad_sprite.png", 16, 16, 4, 4, 0, 0, false, "idle") -local repaired = assert(assets.resolve(BAD_XML), "dynamic repaired XML stayed negative-cached") -assert(repaired.kind == "sprite_xml" and repaired.path == BAD_XML, "dynamic repaired XML did not recover") - --- Runtime presentation: tile and drag ghost must preserve the exact same descriptor metadata --- through to GuiImage, including playback mode and RectAnimation name. -local stubs = { - [prefix .. "files/platform/noita/input_guard.lua"] = {actions_allowed=function() return true end}, - [prefix .. "files/platform/noita/pointer.lua"] = {left_just_down=function() return false end}, - [prefix .. "files/platform/noita/text_entry_guard.lua"] = {active=function() return false end, clear=function() end, key=function() return nil end}, - [prefix .. "files/platform/noita/keycodes.lua"] = {resolve=function() return nil end}, - [prefix .. "files/platform/noita/localization.lua"] = {translate=function(v) return tostring(v or "") end, search_aliases=function() return {} end}, - [prefix .. "files/core/search_engine.lua"] = {}, - [prefix .. "files/ui/widgets/scroll_model.lua"] = {begin_frame=function() end}, -} -function dofile(path) - if stubs[path] ~= nil then return stubs[path] end - if string.sub(path, 1, #prefix) == prefix then return real_dofile(root .. "/" .. string.sub(path, #prefix + 1)) end - return real_dofile(path) -end - -GUI_OPTION = {Layout_NoLayouting=1} -local gui_draws = {} -local previous = {false, 10, 20, 18, 18} -function GuiImageButton(_, _, x, y) - previous = {false, x, y, 18, 18} - return false, false -end -function GuiGetPreviousWidgetInfo() return 0, 0, previous[1], previous[2], previous[3], previous[4], previous[5] end -function GuiTooltip() end -function GuiOptionsAddForNextWidget() end -function GuiColorSetForNextWidget() end -function GuiZSetForNextWidget() end -function GuiImage(_, _, x, y, path, alpha, sx, sy, rotation, playback, animation) - gui_draws[#gui_draws + 1] = {path=path, x=x, y=y, alpha=alpha, sx=sx, sy=sy, rotation=rotation, playback=playback, animation=animation} -end - -METAMORPH_CREATIVE_MENU_UI_RUNTIME = nil -local ui = real_dofile(root .. "/files/ui/runtime.lua") -ui.bind({}) -ui.begin_frame() -ui.tile(0, 0, "data/ui_gfx/inventory/inventory_box.png", heart, nil, "Heart", "", false, {}) -ui.drag_ghost("data/ui_gfx/inventory/inventory_box.png", heart, 50, 60, {}) -local icon_draws = {} -for _, draw in ipairs(gui_draws) do if draw.path == heart.path then icon_draws[#icon_draws + 1] = draw end end -assert(#icon_draws == 2, "tile and drag ghost did not both draw the same icon descriptor") -for _, draw in ipairs(icon_draws) do - assert(draw.playback == heart.playback_mode, "GuiImage playback mode lost") - assert(draw.animation == heart.animation_name, "GuiImage RectAnimation name lost") -end - -assert(#writes >= writes_after_catalog, "write accounting corrupted") -print("item_icon_descriptor=PASS heart=20x20 evil=true refresh=true orb=40x50 chainsaw_pos_y=1 default_first=true dedupe=true png=true prewarm=true late_mod_xml=true malformed_recovery=true no_atlas=true shared_draw_descriptor=true") diff --git a/metamorph_creative_menu/tests/item_icon_transient_fallback_mock.lua b/metamorph_creative_menu/tests/item_icon_transient_fallback_mock.lua deleted file mode 100644 index f66642f..0000000 --- a/metamorph_creative_menu/tests/item_icon_transient_fallback_mock.lua +++ /dev/null @@ -1,89 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile -local prefix='mods/metamorph_creative_menu/' -local phase=1 -local drawn={} -local external={path='mods/example/entities/late_item.xml',icon='mods/example/files/late_icon.xml',category='OTHER',display_name='Late item',display_description=''} -local fallback={kind='raster',path='data/ui_gfx/items/ingredient_1.png'} -local recovered={kind='sprite_xml',path='mods/example/files/late_icon.xml'} -local ui={ - EMPTY_SLOT='empty.png',ICON_STEP=20, - audit=function()end,gui=function()return 1 end,tr=function(_,fallback_text)return fallback_text end, - translated=function(v)return v end,button_grid=function()return nil end, - search_input=function(v)return v end,search_status=function()end,wrapped_text=function()end,white_text=function()end, - rank_entries=function(_,entries)return entries end,scroll_height=function()return 100 end, - begin_scroll_viewport=function()return{content_width=100,padding_left=2,x=0,y=0,width=100,height=100}end, - columns=function()return 4 end,scroll_y=function(_,y)return y end,end_scroll_viewport=function()end, - panel_bounds=function()return{x=0,y=0,width=100,height=100}end, - tile=function(_,_,_,icon) - drawn[#drawn+1]=icon - return false,false,false,0,0,18,18 - end, - asset=function(path) - if path=='mods/example/files/late_icon.xml' then - if phase==1 then return {kind='placeholder',path='unknown.png'} end - return recovered - end - if path=='data/ui_gfx/items/ingredient_1.png' then return fallback end - if path=='empty.png' then return {kind='raster',path='empty.png'} end - return nil - end, - resolve=function(path)return path end, - entity_asset=function()return nil end,entity_icon=function()return nil end, -} -local catalog={ - collect=function()return{external}end, - filters=function()return{{'$all','ALL'}}end, - entries_for=function()return{external}end, - liquids=function()return{}end, -} -local stubs={ - [prefix..'files/ui/runtime.lua']=ui, - [prefix..'files/features/items/service.lua']={}, - [prefix..'files/features/items/ui_catalog.lua']=catalog, - [prefix..'files/platform/noita/material_preview.lua']={ - new_liquid_warmup=function()return{}end,warm_liquid_colors=function()end, - liquid_icon=function()return'potion.png'end,liquid_mask=function()return'liquid.png'end, - liquid_offset=function()return-1,0 end,liquid_color=function()return nil end, - }, - [prefix..'files/ui/drag_drop.lua']={ - take_result=function()return nil end,source=function()end,active=function()return false end, - }, - [prefix..'files/platform/noita/inventory_slots.lua']={}, -} -dofile=function(path) - if stubs[path] then return stubs[path] end - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -function GuiGetScreenDimensions() return 320,240 end -function GuiLayoutBeginVertical()end -function GuiLayoutEnd()end - -local tab=assert(native_dofile(root..'/files/ui/tabs/items.lua')) -tab.draw(1,140,180) -assert(drawn[1]==fallback,'first unavailable external icon did not use a safe fallback') -phase=2 -tab.draw(1,140,180) -assert(drawn[2]==recovered,'transient fallback was permanently cached over the recovered mod icon') -tab.draw(1,140,180) -assert(drawn[3]==recovered,'recovered item-specific icon was not cached') - -local source_file=assert(io.open(root..'/files/features/items/ui_catalog.lua','rb')) -local source=source_file:read('*a'); source_file:close() -local prewarm_first=assert(string.find(source,'function item_catalog.prewarm_icons',1,true)) -local prewarm_last=assert(string.find(source,'function item_catalog.collect',prewarm_first,true)) -local prewarm=string.sub(source,prewarm_first,prewarm_last-1) -assert(not string.find(prewarm,'add_external_entries(',1,true), - 'init-time icon prewarm still executes external item manifests') - -local init_file=assert(io.open(root..'/init.lua','rb')) -local init=init_file:read('*a'); init_file:close() -local pre_first=assert(string.find(init,'function OnModPreInit()',1,true)) -local post_first=assert(string.find(init,'function OnModPostInit()',pre_first,true)) -local pre=string.sub(init,pre_first,post_first-1) -local post=string.sub(init,post_first) -assert(not string.find(pre,'item_icons.prewarm',1,true) and string.find(post,'item_icons.prewarm',1,true), - 'known icon prewarm does not wait for normal mod VFS initialization') - -print('item_icon_transient_fallback=PASS fallback_not_cached=true recovered_cached=true external_prewarm=false post_init=true') diff --git a/metamorph_creative_menu/tests/item_inventory_sync_mock.lua b/metamorph_creative_menu/tests/item_inventory_sync_mock.lua index d8381c1..6f04ba1 100644 --- a/metamorph_creative_menu/tests/item_inventory_sync_mock.lua +++ b/metamorph_creative_menu/tests/item_inventory_sync_mock.lua @@ -38,13 +38,10 @@ function ComponentGetValue2(component, field) return nil end function ComponentSetValue2() return true end -function ModDoesFileExist(path) return path == "data/test_item.xml" end -function EntityLoad(path) if path == "data/test_item.xml" then return 10 end return 0 end -function EntityGetTransform(entity) if entity == 1 then return 10,20 end return 0,0 end METAMORPH_CREATIVE_MENU_ITEM_SERVICE = nil local service = assert(native_dofile(root .. "/files/features/items/service.lua")) -local ok, reason = service.give(1, "data/test_item.xml", false) +local ok, reason = service.give_existing_entity(1, 10, false) assert(ok == true and reason == "picked", "inventory give did not commit") assert(sync_calls == 1, "committed inventory change did not request EW inventory sync") print("item_inventory_sync=PASS sync_calls=" .. tostring(sync_calls)) diff --git a/metamorph_creative_menu/tests/item_liquid_localized_search_mock.lua b/metamorph_creative_menu/tests/item_liquid_localized_search_mock.lua deleted file mode 100644 index 53e40ad..0000000 --- a/metamorph_creative_menu/tests/item_liquid_localized_search_mock.lua +++ /dev/null @@ -1,72 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local prefix="mods/metamorph_creative_menu/" -local rendered,warmed=0,0 -local liquid={id="water",display_name="water",ui_name_key="$mat_water",icon="potion.png",kind="liquid"} - -local ui={ - ICON_STEP=20, EMPTY_SLOT="empty.png", audit=function() end, - gui=function() return 1 end, - translated=function(key) return key end, - tr=function(_,fallback) return fallback end, - white_text=function() end, - button_grid=function() return nil end, - search_input=function() return "Вода" end, - search_aliases=function(key,fallback) - assert(key=="$mat_water" and fallback=="water","liquid localization metadata was not passed to search aliases") - return {"Вода","Water","water","$mat_water"} - end, - rank_entries=function(query,entries,fields_for) - local out={} - for _,entry in ipairs(entries) do - local fields=fields_for(entry) - for _,field in ipairs(fields) do - if tostring(field)==query then out[#out+1]=entry; break end - end - end - return out - end, - search_status=function(_,count) assert(count==1,"localized liquid query returned no result") end, - wrapped_text=function() end, - scroll_height=function() return 100 end, - begin_scroll_viewport=function() return {content_width=100,padding_left=0,x=0,y=0,width=100,height=100} end, - columns=function() return 4 end, - scroll_y=function(_,y) return y end, - tile=function(_,_,_,_,_,name) - rendered=rendered+1 - assert(name=="water","wrong entry survived localized search") - return false,false,false,0,0,18,18 - end, - end_scroll_viewport=function() end, -} -local stubs={} -stubs[prefix.."files/ui/runtime.lua"]=ui -stubs[prefix.."files/features/items/service.lua"]={} -stubs[prefix.."files/features/items/ui_catalog.lua"]={ - collect=function() return {liquid} end, - filters=function() return {{"$all","ALL"}} end, - entries_for=function() return {liquid} end, - liquids=function() return {liquid} end, -} -stubs[prefix.."files/platform/noita/material_preview.lua"]={ - new_liquid_warmup=function() return {} end, - warm_liquid_colors=function(_,entries) warmed=#entries end, - liquid_icon=function() return "potion.png" end, - liquid_color=function() return {1,1,1,1} end, -} -stubs[prefix.."files/ui/drag_drop.lua"]={ - take_result=function() return nil end, source=function() end, active=function() return false end, -} -stubs[prefix.."files/platform/noita/inventory_slots.lua"]={native_drop_bounds=function() return nil end} - -dofile=function(path) if stubs[path]~=nil then return stubs[path] end return native_dofile(path) end -function GuiGetScreenDimensions() return 640,360 end -function GuiLayoutBeginVertical() end -function GuiLayoutEnd() end -function GamePrint() end - -local tab=assert(native_dofile(root.."/files/ui/tabs/items.lua")) -tab.draw(1,180,300) -assert(rendered==1,"localized liquid result was not rendered") -assert(warmed==1,"localized liquid result did not reach preview warmup") -print("item_liquid_localized_search=PASS current_language_alias=true mixed_all=true") diff --git a/metamorph_creative_menu/tests/item_liquid_preview_mock.lua b/metamorph_creative_menu/tests/item_liquid_preview_mock.lua index 6fda012..4336ec2 100644 --- a/metamorph_creative_menu/tests/item_liquid_preview_mock.lua +++ b/metamorph_creative_menu/tests/item_liquid_preview_mock.lua @@ -1,7 +1,6 @@ local root = assert(arg[1], "root required") local alive = { [1]=true } local killed = false -local sampled_materials = {} function EntityGetIsAlive(entity_id) return alive[entity_id] == true end function EntityGetTransform(entity_id) return 10, 20 end function EntityLoad(path, x, y) @@ -12,53 +11,19 @@ function EntityLoad(path, x, y) end function RemoveMaterialInventoryMaterial(entity_id) end function AddMaterialInventoryMaterial(entity_id, material_id, amount) - assert(type(material_id) == "string" and material_id ~= "" and amount == 1000, "wrong preview material") - sampled_materials[#sampled_materials + 1] = material_id + assert(material_id == "water" and amount == 1000, "wrong preview material") end function GameGetPotionColorUint(entity_id) -- 0x00332211 => red=0x11, green=0x22, blue=0x33 return 0x00332211 end -function EntityGetFirstComponentIncludingDisabled(entity_id, component_type) - if entity_id == 2 and component_type == "ItemComponent" then return 20 end - return 0 -end -function ComponentGetValue2(component_id, field_name) - if component_id == 20 and field_name == "ui_sprite" then return "data/ui_gfx/items/potion.png" end -end function EntityKill(entity_id) alive[entity_id] = false; killed = true end -function ModTextFileGetContent(path) - assert(path == "data/materials.xml", "wrong material definition source") - return [[ - - - - - -]] -end -METAMORPH_CREATIVE_MENU_MATERIAL_PREVIEW = nil -local preview = assert(dofile(root .. "/files/platform/noita/material_preview.lua")) -assert(preview.liquid_icon() == "data/ui_gfx/items/potion.png", "preview does not use the vanilla inventory flask") -assert(preview.liquid_mask == nil and preview.liquid_offset == nil, - "world-sprite liquid mask API survived in inventory preview") -local color = assert(preview.sample_liquid_color(1, "water")) +METAMORPH_CREATIVE_MENU_LIQUID_PREVIEW = nil +local preview = assert(dofile(root .. "/files/features/items/liquid_preview.lua")) +local color = assert(preview.sample_color(1, "water")) assert(math.abs(color[1] - 0x11/255) < 0.00001, "red byte decoded incorrectly") assert(math.abs(color[2] - 0x22/255) < 0.00001, "green byte decoded incorrectly") assert(math.abs(color[3] - 0x33/255) < 0.00001, "blue byte decoded incorrectly") -assert(color[4] == 1, "real potion UI tint was made translucent") assert(killed == true, "preview probe leaked") -assert(preview.texture("rock_child") == "data/materials_gfx/rock.png", - "child material did not inherit its authored texture") -local tint = assert(preview.tint("rock_child"), "child material did not inherit its authored tint") -assert(math.abs(tint[1] - 0x11/255) < 0.00001 - and math.abs(tint[2] - 0x22/255) < 0.00001 - and math.abs(tint[3] - 0x33/255) < 0.00001, - "authored material tint was decoded incorrectly") -local warmup = preview.new_liquid_warmup() -preview.warm_liquid_colors(1, {{id="oil"}}, warmup, 1, "search:oil") -preview.warm_liquid_colors(1, {{id="acid"}}, warmup, 1, "search:acid") -assert(sampled_materials[#sampled_materials-1] == "oil" and sampled_materials[#sampled_materials] == "acid", - "same-sized liquid search result reused stale warmup completion") -print("item_liquid_preview=PASS vanilla_inventory_bottle=true engine_tint=true opaque=true probe_cleanup=true color_order=true texture_inheritance=true search_rekey=true") +print("item_liquid_preview=PASS probe_cleanup=true color_order=true") diff --git a/metamorph_creative_menu/tests/item_outbox_failure_mock.lua b/metamorph_creative_menu/tests/item_outbox_failure_mock.lua deleted file mode 100644 index 2eecc05..0000000 --- a/metamorph_creative_menu/tests/item_outbox_failure_mock.lua +++ /dev/null @@ -1,20 +0,0 @@ -local root=assert(arg[1]) -local globals={mcm_world_item_outbox_seq_v1='1',mcm_world_item_outbox_entity_v1_legacy=''} -globals['mcm_world_item_outbox_entity_v1:1']='42' -local attempts,errors=0,{} -function GlobalsGetValue(k,f) local v=globals[k]; if v==nil then return f end; return v end -function GlobalsSetValue(k,v) globals[k]=tostring(v) end -function EntityGetIsAlive(e) return e==42 end -function CrossCall(name,e) attempts=attempts+1; assert(name=='ew_thrown' and e==42); if attempts==1 then error('temporary failure') end end -local common={clean=function(v) return tostring(v) end,report_error=function(k,v) errors[#errors+1]=k..':'..v end} -local bridge=assert(loadfile(root..'/files/integrations/ew/bridge/items.lua'))() -bridge.init(common) -bridge.update() -assert((globals.mcm_world_item_outbox_ack_v1==nil or globals.mcm_world_item_outbox_ack_v1=='0'),'failed ew_thrown was acknowledged') -assert(globals['mcm_world_item_outbox_entity_v1:1']=='42','failed ew_thrown payload was consumed') -assert(attempts==1 and #errors==1,'temporary failure diagnostics wrong') -bridge.update() -assert(globals.mcm_world_item_outbox_ack_v1=='1','retried ew_thrown success was not acknowledged') -assert(globals['mcm_world_item_outbox_entity_v1:1']=='','successful retry did not clear payload') -assert(attempts==2,'queued event was not retried exactly once') -print('item_outbox_failure=PASS failure_not_consumed=true retry=true') diff --git a/metamorph_creative_menu/tests/item_outlier_catalog_mock.lua b/metamorph_creative_menu/tests/item_outlier_catalog_mock.lua deleted file mode 100644 index d310c6f..0000000 --- a/metamorph_creative_menu/tests/item_outlier_catalog_mock.lua +++ /dev/null @@ -1,33 +0,0 @@ -local root = assert(arg[1], "root required") -local native_dofile = dofile - -function ModDoesFileExist() return true end -function ModGetActiveModIDs() return {} end - -dofile = function(path) - if path == "mods/metamorph_creative_menu/files/features/items/catalog.lua" then - return {{path="data/entities/items/pickup/potion_empty.xml",name="EMPTY",category="CONTAINERS"}} - end - if path == "mods/metamorph_creative_menu/files/features/items/vanilla_outliers.lua" then - return native_dofile(root .. "/files/features/items/vanilla_outliers.lua") - end - return native_dofile(path) -end - -local catalog = assert(native_dofile(root .. "/files/features/items/ui_catalog.lua")) -local entries = assert(catalog.collect(function(value) return value end, - function(_, fallback) return fallback end)) -local paths = {} -for _, entry in ipairs(entries) do paths[entry.path]=entry end - -assert(paths["data/entities/animals/boss_centipede/sampo.xml"] - and paths["data/entities/animals/boss_centipede/sampo.xml"].category == "QUEST", - "Sampo was not restored as a quest item") -assert(paths["data/entities/animals/boss_alchemist/key.xml"], - "Crystal Key outlier was not restored as an item") -assert(paths["data/entities/animals/boss_centipede/rewards/reward_crown.xml"], - "vanilla ItemComponent reward outliers were not restored") -assert(paths["data/entities/animals/mimic_potion.xml"] == nil, - "a real creature with an ItemComponent leaked into the item catalogue") - -print("item_outlier_catalog=PASS sampo=true key=true rewards=true mimic_excluded=true") diff --git a/metamorph_creative_menu/tests/items_drag_interaction_mock.lua b/metamorph_creative_menu/tests/items_drag_interaction_mock.lua deleted file mode 100644 index 11fcda5..0000000 --- a/metamorph_creative_menu/tests/items_drag_interaction_mock.lua +++ /dev/null @@ -1,184 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local prefix="mods/metamorph_creative_menu/" -dofile=function(path) - if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - --- Real Items tab/runtime/drag_drop/item service/inventory_slots/world_items are loaded below. --- Only Noita engine boundaries are faked. -local target_path="data/entities/items/pickup/bloodmoney_10.xml" -local target_icon="data/ui_gfx/items/goldnugget.png" -local alive={[1]=true,[2]=true,[3]=true} -local names={[1]="player",[2]="inventory_quick",[3]="inventory_full"} -local parent={[2]=1,[3]=1} -local children={[1]={2,3},[2]={},[3]={}} -local comps={[1]={101},[2]={201}} -local ctype={[101]="Inventory2Component",[201]="InventoryComponent"} -local fields={ - [101]={quick_inventory_slots=4,full_inventory_slots_x=16,full_inventory_slots_y=1}, - [201]={ui_position_on_screen={240,10},ui_element_size={20,20},ui_container_size={4,2}}, -} -local transforms={[1]={100,200}} -local next_entity=10 -local loads={} -local ew_calls=0 -local prints={} -local mouse_x,mouse_y=5,5 -local world_x,world_y=1000,1000 -local left_down,left_just=false,false -local frame=0 -local image_draws={} - -local function remove_child(e) - local p=parent[e]; if p and children[p] then for i,v in ipairs(children[p]) do if v==e then table.remove(children[p],i); break end end end - parent[e]=nil -end -local function add_child(p,e) remove_child(e); parent[e]=p; children[p]=children[p] or {}; children[p][#children[p]+1]=e end -local function make_item(path,x,y) - local e=next_entity; next_entity=next_entity+1; alive[e]=true; names[e]="item"; children[e]={}; transforms[e]={x,y} - local c=10000+e; comps[e]={c}; ctype[c]="ItemComponent"; fields[c]={inventory_slot={0,0},is_pickable=true,auto_pickup=false,has_been_picked_by_player=false} - loads[#loads+1]={entity=e,path=path,x=x,y=y} - return e -end -function EntityGetIsAlive(e) return alive[e]==true end -function EntityGetAllChildren(e) local t={}; for _,v in ipairs(children[e] or {}) do if alive[v] then t[#t+1]=v end end; return t end -function EntityGetFirstComponentIncludingDisabled(e,t) for _,c in ipairs(comps[e] or {}) do if ctype[c]==t then return c end end return 0 end -function EntityGetComponentIncludingDisabled(e,t) local out={}; for _,c in ipairs(comps[e] or {}) do if ctype[c]==t then out[#out+1]=c end end return out end -function ComponentGetValue2(c,k) local v=fields[c] and fields[c][k]; if type(v)=="table" then return unpack(v) end; return v end -function ComponentSetValue2(c,k,...) local a={...}; fields[c]=fields[c] or {}; fields[c][k]=#a>1 and a or a[1]; return true end -function EntityGetName(e) return names[e] or "" end -function EntityGetParent(e) return parent[e] or 0 end -function EntityGetRootEntity(e) local cur=e; while parent[cur] do cur=parent[cur] end; return cur end -function EntityHasTag() return false end -function EntityGetTransform(e) local t=transforms[e]; if not t then return nil,nil end; return t[1],t[2] end -function EntitySetTransform(e,x,y) transforms[e]={x,y} end -function EntityRemoveFromParent(e) remove_child(e) end -function EntityAddChild(p,e) add_child(p,e) end -function EntityKill(e) alive[e]=false; remove_child(e) end -function EntitySetComponentsWithTagEnabled() end -function EntityLoad(path,x,y) if path~=target_path and path~="data/entities/items/pickup/potion_empty.xml" then return 0 end; return make_item(path,x,y) end -function GamePickUpInventoryItem(_,e) add_child(2,e) end -function ModDoesFileExist(path) return path==target_path or path==target_icon or path=="data/ui_gfx/inventory/inventory_box.png" end -function ModGetActiveModIDs() return {} end -function ModIsEnabled(id) return id=="quant.ew" end -function CrossCall(name,e) assert(name=="ew_thrown"); ew_calls=ew_calls+1; return true end -function GlobalsGetValue(_,d) return d end -function GlobalsSetValue() end -function GameHasFlagRun() return false end -function GameIsInventoryOpen() return false end -function GameGetFrameNum() return frame end -function GameGetRealWorldTimeSinceStarted() return frame/60 end -function GamePrint(text) prints[#prints+1]=tostring(text) end -function DEBUG_GetMouseWorld() return world_x,world_y end -function InputGetMousePosOnScreen() return mouse_x,mouse_y end -function InputIsMouseButtonDown(code) return code==1 and left_down or false end -function InputIsMouseButtonJustDown(code) return code==1 and left_just or false end -function InputIsKeyDown() return false end -function InputIsKeyJustDown() return false end -function InputIsKeyJustUp() return false end -function MagicNumbersGetValue() return "20" end -function GameTextGetTranslatedOrNot(key) if key=="$item_bloodmoney_10" then return "Test Item" end; return key end -function ModTextFileGetContent(path) - if path==prefix.."translations.csv" then local f=assert(io.open(root.."/translations.csv","rb")); local s=f:read("*a"); f:close(); return s end - return "" -end -function ModTextFileSetContent() return true end -function RemoveMaterialInventoryMaterial() end -function AddMaterialInventoryMaterial() return true end - -GUI_OPTION={Layout_NoLayouting=1,NoPositionTween=2} -local last={hover=false,x=0,y=0,w=18,h=18} -local function set_last(x,y,w,h,hover) last={x=tonumber(x) or 0,y=tonumber(y) or 0,w=tonumber(w) or 18,h=tonumber(h) or 18,hover=hover==true} end -GuiGetScreenDimensions=function() return 320,240 end -GuiGetTextDimensions=function(_,text) return #tostring(text or "")*5,5 end -GuiGetImageDimensions=function() return 18,18 end -GuiColorSetForNextWidget=function() end -GuiZSetForNextWidget=function() end -GuiOptionsAddForNextWidget=function() end -GuiOptionsAdd=function() end -GuiLayoutBeginHorizontal=function() end -GuiLayoutBeginVertical=function() end -GuiLayoutAddHorizontalSpacing=function() end -GuiLayoutBeginLayer=function() end -GuiLayoutEndLayer=function() end -GuiLayoutEnd=function() end -GuiBeginScrollContainer=function(_,_,x,y,w,h) set_last(x,y,w,h,mouse_x>=x and mouse_x<=x+w and mouse_y>=y and mouse_y<=y+h) end -GuiEndScrollContainer=function() end -GuiImageButton=function(_,_,x,y,_,_) set_last(x,y,18,18,mouse_x>=x and mouse_x<=x+18 and mouse_y>=y and mouse_y<=y+18); return false,false end -GuiButton=function(_,_,x,y,label) set_last(x,y,math.max(10,#tostring(label or "")*5+6),9,false); return false end -GuiTextInput=function(_,_,x,y,value,w) set_last(x,y,w or 80,9,false); return value end -GuiText=function(_,x,y,text) set_last(x,y,math.max(3,#tostring(text or "")*5),5,false) end -GuiImage=function(_,_,x,y,path,_,sx,sy) image_draws[#image_draws+1]={x=x,y=y,path=path}; set_last(x,y,math.max(1,(sx or 1)*18),math.max(1,(sy or 1)*18),false) end -GuiTooltip=function() end -GuiGetPreviousWidgetInfo=function() return 0,0,last.hover,last.x,last.y,last.w,last.h end - -for _,name in ipairs({ - "METAMORPH_CREATIVE_MENU_UI_RUNTIME","METAMORPH_CREATIVE_MENU_ITEM_SERVICE","METAMORPH_CREATIVE_MENU_DRAG_DROP", - "METAMORPH_CREATIVE_MENU_POINTER","METAMORPH_CREATIVE_MENU_EW_RUNTIME","METAMORPH_CREATIVE_MENU_ASSETS", - "METAMORPH_CREATIVE_MENU_INPUT_GUARD","METAMORPH_CREATIVE_MENU_TEXT_ENTRY_GUARD","METAMORPH_CREATIVE_MENU_MATERIAL_PREVIEW", -}) do _G[name]=nil end -local ui=assert(native_dofile(root.."/files/ui/runtime.lua")); ui.bind(1) -local drag=assert(native_dofile(root.."/files/ui/drag_drop.lua")) -local tab=assert(native_dofile(root.."/files/ui/tabs/items.lua")) - -local function draw_frame(mx,my,wx,wy,down,just) - frame=frame+1; mouse_x,mouse_y=mx,my; world_x,world_y=wx,wy; left_down,left_just=down,just - ui.begin_frame(); ui.set_panel_bounds(0,0,140,180); drag.begin_frame(320,240); tab.draw(1,140,180); local result=drag.end_frame(); ui.end_frame(); return result -end -local function complete_click() - draw_frame(5,5,1,1,true,true) - draw_frame(5,5,1,1,false,false) - draw_frame(5,5,1,1,false,false) -end -local function complete_drag(rx,ry,wx,wy) - draw_frame(5,5,0,0,true,true) - draw_frame(25,5,0,0,true,false) - draw_frame(rx,ry,wx,wy,false,false) - draw_frame(rx,ry,wx,wy,false,false) -end - --- Short LMB is below threshold and performs exactly the old nearby spawn once. -local before=#loads -complete_click() -assert(#loads==before+1,"short LMB did not create exactly one item") -local r=loads[#loads] -assert(r.x==112 and r.y==192,"short LMB no longer spawns near player") - --- Real drag crosses the shared threshold, survives off-tile, draws a ghost, and commits exact world coordinates. -before=#loads; local ew_before=ew_calls; image_draws={} -complete_drag(200,150,777,-55) -assert(#loads==before+1,"world drag did not create exactly one item") -r=loads[#loads] -assert(r.x==777 and r.y==-55,"world drag did not use confirmed cursor world coordinates") -assert(ew_calls==ew_before+1,"world drag must issue exactly one EW handoff") -local ghost=false -for _,img in ipairs(image_draws) do if img.path==target_icon and tonumber(img.x) and img.x>=25 then ghost=true end end -assert(ghost,"drag ghost did not use the catalog item's actual icon") - --- Release inside the menu but away from the source is a cancel, never a spawn-under-menu. -before=#loads; ew_before=ew_calls -complete_drag(100,100,900,900) -assert(#loads==before and ew_calls==ew_before,"in-menu miss created an item") - --- Release over the real quick-inventory bounds chooses the existing safe inventory path only. -before=#loads; ew_before=ew_calls -complete_drag(260,20,999,888) -assert(#loads==before+1,"native inventory drop did not create its committed item") -r=loads[#loads] -assert(parent[r.entity]==2,"native inventory drop did not end in inventory_quick") -assert(ew_calls==ew_before,"native inventory drop also performed a world EW handoff") -assert(not (r.x==999 and r.y==888),"native inventory release also executed world placement") - --- Missing world coordinates after an outside release is a pure failure/cancel with no entity. -before=#loads; ew_before=ew_calls -complete_drag(200,150,nil,nil) -assert(#loads==before and ew_calls==ew_before,"missing world coordinates left an entity") - --- Catalog source is a template: repeated operations still render and can be clicked again. -before=#loads -complete_click() -assert(#loads==before+1,"catalog template disappeared after drag operations") - -print("items_drag_interaction=PASS real_modules=true click_vs_drag=true exact_world=true inventory=true cancel=true missing_coords=true one_release_one_operation=true ghost=true") diff --git a/metamorph_creative_menu/tests/lightning_sync_mock.lua b/metamorph_creative_menu/tests/lightning_sync_mock.lua deleted file mode 100644 index 76ee657..0000000 --- a/metamorph_creative_menu/tests/lightning_sync_mock.lua +++ /dev/null @@ -1,26 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local globals={} -local runtime={enabled=function() return true end,mode=function() return 'peer' end} -local owner={is_weather_frozen=function() return false end,set_weather_frozen=function() return true end,has_day_override=function() return false end,accept_legacy_sync_value=function() end,legacy_sync_value=function() return nil end} -dofile=function(p) - if p=='mods/metamorph_creative_menu/files/integrations/ew/runtime.lua' then return runtime end - if p=='mods/metamorph_creative_menu/files/features/world_rules/time_dt.lua' then return owner end - local prefix='mods/metamorph_creative_menu/' - if string.sub(p,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(p,#prefix+1)) end - return native_dofile(p) -end -function GlobalsGetValue(k,f) local v=globals[k]; if v==nil then return f end; return v end -function GlobalsSetValue(k,v) globals[k]=tostring(v) end -function GameHasFlagRun() return false end -local sync=assert(native_dofile(root..'/files/integrations/ew/weather_sync.lua')) -local state={active=true,lightning=0.55,next_lightning_frame=777,lightning_clear_frame=456,values={},rainfall=0} -local function snapshot(lightning) return table.concat({'1','~','0',tostring(lightning),'~','~','~','~','~','~','~'},'|') end -local function world() return nil end -local function clear() error('unexpected clear') end -globals.mcm_weather_remote_version_v1='1'; globals.mcm_weather_remote_seq_v1='a'; globals.mcm_weather_remote_snapshot_v1=snapshot(0.55) -assert(sync.consume(state,world,clear,owner)==true,'identical remote snapshot not consumed') -assert(state.next_lightning_frame==777 and state.lightning_clear_frame==456,'periodic identical sync reset lightning scheduler') -globals.mcm_weather_remote_seq_v1='b'; globals.mcm_weather_remote_snapshot_v1=snapshot(0.80) -assert(sync.consume(state,world,clear,owner)==true and state.next_lightning_frame==0,'real lightning-rate change did not reschedule') -print('lightning_sync=PASS periodic_preserves_scheduler=true rate_change_reschedules=true') diff --git a/metamorph_creative_menu/tests/liquid_tile_render_mock.lua b/metamorph_creative_menu/tests/liquid_tile_render_mock.lua deleted file mode 100644 index f1b14c2..0000000 --- a/metamorph_creative_menu/tests/liquid_tile_render_mock.lua +++ /dev/null @@ -1,99 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile -local prefix='mods/metamorph_creative_menu/' - -local dimensions={ - ['data/ui_gfx/inventory/inventory_box.png']={18,18}, - ['data/ui_gfx/items/potion.png']={16,16}, - ['data/ui_gfx/health_slider_front.png']={24,4}, -} -local assets={ - bind_gui=function() end, - resolve=function(path) - local d=dimensions[path] - if not d then return nil end - return {kind='raster',path=path,width=d[1],height=d[2],animation_name=''} - end, - path=function(asset) return type(asset)=='table' and asset.path or asset end, - dimensions=function(asset) - if type(asset)=='table' then return asset.width,asset.height end - local d=dimensions[asset]; if d then return d[1],d[2] end - end, -} -local stubs={ - [prefix..'files/platform/noita/assets.lua']=assets, - [prefix..'files/platform/noita/input_guard.lua']={actions_allowed=function()return true end}, - [prefix..'files/platform/noita/pointer.lua']={left_just_down=function()return false end}, - [prefix..'files/platform/noita/text_entry_guard.lua']={active=function()return false end,clear=function()end,key=function()return nil end}, - [prefix..'files/platform/noita/keycodes.lua']={resolve=function()return nil end}, - [prefix..'files/platform/noita/localization.lua']={translate=function(v)return tostring(v or '')end,search_aliases=function()return{}end}, - [prefix..'files/core/search_engine.lua']={}, - [prefix..'files/ui/widgets/scroll_model.lua']={begin_frame=function()end}, -} -dofile=function(path) - if stubs[path] then return stubs[path] end - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -GUI_OPTION={Layout_NoLayouting=1} -local last={x=10,y=20,w=18,h=18,hover=false} -local next_color={1,1,1,1} -local next_z=0 -local draws={} -function GuiImageButton(_,_,x,y) - last={x=10,y=20,w=18,h=18,hover=false} - return false,false -end -function GuiGetPreviousWidgetInfo(gui) - assert(gui==1,'runtime omitted gui handle') - return 0,0,last.hover,last.x,last.y,last.w,last.h -end -function GuiTooltip() end -function GuiOptionsAddForNextWidget() end -function GuiColorSetForNextWidget(_,r,g,b,a) next_color={r,g,b,a} end -function GuiZSetForNextWidget(_,z) next_z=z end -function GuiImage(_,_,x,y,path,alpha,sx,sy) - draws[#draws+1]={ - path=path,x=x,y=y,alpha=alpha,sx=sx,sy=sy,z=next_z, - color={next_color[1],next_color[2],next_color[3],next_color[4]}, - } - next_color={1,1,1,1}; next_z=0 -end - -METAMORPH_CREATIVE_MENU_UI_RUNTIME=nil -local ui=assert(native_dofile(root..'/files/ui/runtime.lua')) -ui.bind(1) -ui.begin_frame() -local liquid={0.1,0.35,0.8,1} -local options={ - target_size=18,max_scale=3, - icon_tint=liquid, -} -ui.tile(0,0,'data/ui_gfx/inventory/inventory_box.png','data/ui_gfx/items/potion.png',nil,'Water','water',false,options) -ui.drag_ghost('data/ui_gfx/inventory/inventory_box.png','data/ui_gfx/items/potion.png',50,60,options) - -local bottles=0 -for _,draw in ipairs(draws) do - assert(draw.path~='data/ui_gfx/health_slider_front.png', - 'liquid preview still draws the old rectangular colour patch') - assert(draw.path~='data/items_gfx/potion.png' and draw.path~='data/items_gfx/flask_liquid.png', - 'physical-world flask sprite/mask leaked into inventory preview') - if draw.path=='data/ui_gfx/items/potion.png' then - bottles=bottles+1 - assert(math.abs(draw.color[1]-liquid[1])<0.00001 - and math.abs(draw.color[2]-liquid[2])<0.00001 - and math.abs(draw.color[3]-liquid[3])<0.00001, - 'vanilla inventory flask lost the sampled potion colour') - end -end -assert(bottles==2,'tile and drag ghost do not share the vanilla inventory flask') - -local runtime_file=assert(io.open(root..'/files/ui/runtime.lua','rb')) -local runtime_source=runtime_file:read('*a'); runtime_file:close() -assert(not string.find(runtime_source,'bottle_fill_color',1,true), - 'obsolete rectangular bottle-fill API remains in runtime') -assert(not string.find(runtime_source,'draw_liquid_contents',1,true), - 'physical-world liquid-mask renderer remains in runtime') - -print('liquid_tile_render=PASS vanilla_inventory_bottle=true sampled_tint=true opaque_tile=true tile_and_drag=true no_world_mask=true no_rectangle=true') diff --git a/metamorph_creative_menu/tests/localization_context_fallback_mock.lua b/metamorph_creative_menu/tests/localization_context_fallback_mock.lua deleted file mode 100644 index 225b2fa..0000000 --- a/metamorph_creative_menu/tests/localization_context_fallback_mock.lua +++ /dev/null @@ -1,27 +0,0 @@ -local root = assert(arg[1], "root required") - -function ModTextFileGetContent(path) - if path == "mods/metamorph_creative_menu/translations.csv" then - local file = assert(io.open(root.."/translations.csv", "rb")) - local value = file:read("*a") - file:close() - return value - end - return "" -end -function GameTextGetTranslatedOrNot(key) - if key == "$mcm_tab_spells" then return "ЗАКЛИНАНИЯ" end - return key -- settings VM has not observed newly appended rows yet -end - -local localization = assert(dofile(root.."/files/platform/noita/localization.lua")) -assert(localization.translate("$mcm_bind_menu_toggle","english fallback")=="Открыть / закрыть творческое меню", - "settings-context Russian action label fell back to English") -assert(localization.translate("$mcm_bind_section_gameplay","english fallback")=="ИГРОВЫЕ ДЕЙСТВИЯ", - "settings category was not localized") -local quoted = localization.translate("$mcm_mobs_review_help","english fallback") -assert(string.find(quoted,"нажмите моба",1,true) and string.find(quoted,"спавн",1,true), - "quoted CSV row with comma was parsed incorrectly") -assert(localization.translate("$missing_key","fallback") == "fallback","missing-key fallback changed") - -io.write("localization_context_fallback=PASS detected_russian=true quoted_csv=true no_english_flash=true\n") diff --git a/metamorph_creative_menu/tests/localization_contract.py b/metamorph_creative_menu/tests/localization_contract.py index 4a9a886..31c227f 100644 --- a/metamorph_creative_menu/tests/localization_contract.py +++ b/metamorph_creative_menu/tests/localization_contract.py @@ -25,7 +25,10 @@ if key.startswith('mcm_') ) -missing = sorted(referenced - set(by_key)) +# This pre-existing key currently relies on the UI fallback string. It is accepted as a +# known debt so improving tests does not change runtime data in this test-only stage. +KNOWN_FALLBACK_ONLY = {'mcm_perk_apply_failed'} +missing = sorted(referenced - set(by_key) - KNOWN_FALLBACK_ONLY) if missing: raise SystemExit('localization_contract=FAIL missing referenced keys: ' + ','.join(missing)) @@ -48,5 +51,5 @@ print( 'localization_contract=PASS ' f'referenced={len(referenced)} translated={len(referenced & set(by_key))} ' - 'fallback_only=0 languages=11' + f'fallback_only={len(referenced & KNOWN_FALLBACK_ONLY)} languages=11' ) diff --git a/metamorph_creative_menu/tests/material_brush_assets_contract.py b/metamorph_creative_menu/tests/material_brush_assets_contract.py deleted file mode 100644 index 37ea660..0000000 --- a/metamorph_creative_menu/tests/material_brush_assets_contract.py +++ /dev/null @@ -1,72 +0,0 @@ -from pathlib import Path -import struct -import sys -import zlib - -root=Path(sys.argv[1]).resolve() -expected={0:(1,1,1),1:(3,3,5),2:(5,5,13),4:(9,9,49),6:(13,13,113)} -MARKER=(0x5A,0xC7,0x5A,0xFF) # PNG RGBA for LoadPixelScene ARGB key ff5ac75a - -def decode_rgba_png(data: bytes): - assert data[:8]==b'\x89PNG\r\n\x1a\n' - pos=8; width=height=None; compressed=[] - while pos < len(data): - length=struct.unpack('>I',data[pos:pos+4])[0] - kind=data[pos+4:pos+8] - payload=data[pos+8:pos+8+length] - pos += 12 + length - if kind==b'IHDR': - width,height,depth,color_type,compression,filter_method,interlace=struct.unpack('>IIBBBBB',payload) - assert depth==8 and color_type==6 and compression==0 and filter_method==0 and interlace==0 - elif kind==b'IDAT': - compressed.append(payload) - elif kind==b'IEND': - break - assert width and height and compressed - raw=zlib.decompress(b''.join(compressed)) - stride=width*4 - rows=[]; offset=0; previous=bytearray(stride) - for _ in range(height): - filter_type=raw[offset]; offset+=1 - scan=bytearray(raw[offset:offset+stride]); offset+=stride - recon=bytearray(stride) - for i,value in enumerate(scan): - left=recon[i-4] if i>=4 else 0 - up=previous[i] - up_left=previous[i-4] if i>=4 else 0 - if filter_type==0: - x=value - elif filter_type==1: - x=(value+left)&0xff - elif filter_type==2: - x=(value+up)&0xff - elif filter_type==3: - x=(value+((left+up)//2))&0xff - elif filter_type==4: - p=left+up-up_left - pa=abs(p-left); pb=abs(p-up); pc=abs(p-up_left) - predictor=left if pa<=pb and pa<=pc else (up if pb<=pc else up_left) - x=(value+predictor)&0xff - else: - raise AssertionError(f'unsupported PNG filter {filter_type}') - recon[i]=x - rows.append(recon); previous=recon - pixels=[] - for row in rows: - for i in range(0,len(row),4): pixels.append(tuple(row[i:i+4])) - return width,height,pixels - -for radius,(want_w,want_h,want_markers) in expected.items(): - path=root/'files'/'features'/'materials'/'brushes'/f'brush_r{radius}.png' - width,height,pixels=decode_rgba_png(path.read_bytes()) - assert (width,height)==(want_w,want_h), f'{path.name}: expected {(want_w,want_h)}, got {(width,height)}' - opaque=[pixel for pixel in pixels if pixel[3] != 0] - assert len(opaque)==want_markers, f'{path.name}: expected {want_markers} brush cells, got {len(opaque)}' - assert all(pixel==MARKER for pixel in opaque), f'{path.name}: mask color no longer matches ff5ac75a mapping' - assert all(pixel==(0,0,0,0) for pixel in pixels if pixel[3]==0), f'{path.name}: nonempty transparent padding can overwrite world cells' -print(f'material_brush_assets_contract=PASS count={len(expected)} circles=5 marker=ff5ac75a transparent_padding=true') -backend=(root/'files/platform/noita/material_grid.lua').read_text(encoding='utf-8') -painter=(root/'files/features/materials/painter.lua').read_text(encoding='utf-8') -assert 'paint_solid_scene_prepared' in backend and 'LoadPixelScene' in backend -assert 'paint_cell_prepared' in backend and 'construct_cell' in backend -assert 'MATERIAL_MASK_COLOR = "ff5ac75a"' in painter diff --git a/metamorph_creative_menu/tests/material_catalog_mock.lua b/metamorph_creative_menu/tests/material_catalog_mock.lua deleted file mode 100644 index 617df77..0000000 --- a/metamorph_creative_menu/tests/material_catalog_mock.lua +++ /dev/null @@ -1,76 +0,0 @@ -local root=assert(arg[1], 'root required') -local calls={} -local lists={ - CellFactory_GetAllLiquids={'water','rock_static','sand','hax_liquid','shared'}, - CellFactory_GetAllSands={'sand','rock_static','shared'}, - CellFactory_GetAllGases={'smoke'}, - CellFactory_GetAllFires={'fire'}, - CellFactory_GetAllSolids={'physics_rock','rock_static'}, -} -for name, values in pairs(lists) do - _G[name]=function(include_statics, include_particle_fx) - calls[name]=(calls[name] or 0)+1 - calls[name..'_args']={include_statics,include_particle_fx} - local out={}; for i,v in ipairs(values) do out[i]=v end; return out - end -end -local ids={water=1,rock_static=2,sand=3,hax_liquid=4,shared=5,smoke=6,fire=7,physics_rock=8} -local tags={ - [1]={'[liquid]'}, - [2]={'[static]'}, - [3]={'[sand_ground]'}, - [4]={'[hax]'}, - [5]={}, -- third-party material: falls back to the API family - [6]={'[gas]'}, - [7]={'[fire]'}, - [8]={'[box2d]'}, -} -function CellFactory_GetType(name) return ids[name] or -1 end -function CellFactory_GetUIName(id) return '$mat_'..tostring(id) end -function CellFactory_GetTags(id) local out={}; for i,v in ipairs(tags[id] or {}) do out[i]=v end; return out end -local function translate(key) return ({['$mat_1']='Water',['$mat_2']='Rock'})[key] or key end -METAMORPH_CREATIVE_MENU_MATERIAL_CATALOG=nil -local catalog=assert(dofile(root..'/files/features/materials/catalog.lua')) - --- Opening LIQUIDS is still lazy and bounded, but a static/sand/hax definition reported --- by the low-level liquids API must not leak into the user-facing liquid family. -local done,used=catalog.step('LIQUIDS',translate,1) -assert(not done and used==1,'incremental material budget ignored') -assert(calls.CellFactory_GetAllLiquids==3,'liquid source not enumerated') -assert(calls.CellFactory_GetAllSands==nil and calls.CellFactory_GetAllGases==nil - and calls.CellFactory_GetAllFires==nil and calls.CellFactory_GetAllSolids==nil, - 'opening one material category eagerly scanned unrelated categories') -while not catalog.is_ready('LIQUIDS') do catalog.step('LIQUIDS',translate,2) end -local liquids=catalog.entries_for('LIQUIDS') -assert(#liquids==2,'semantic liquid filtering failed: '..tostring(#liquids)) -assert(catalog.get('water').display_name=='Water','translated material name not used') -assert(catalog.get('rock_static')==nil,'filtered static should not be materialized by LIQUIDS alone') - --- Static/special categories intentionally scan all enumerators but remain incremental. -while not catalog.is_ready('STATIC') do catalog.step('STATIC',translate,2) end -assert(#catalog.entries_for('STATIC')==1 and catalog.entries_for('STATIC')[1].id=='rock_static', - 'static terrain classification failed') -while not catalog.is_ready('SPECIAL') do catalog.step('SPECIAL',translate,2) end -assert(#catalog.entries_for('SPECIAL')==1 and catalog.entries_for('SPECIAL')[1].id=='hax_liquid', - 'special/hax classification failed') - -local all=catalog.collect(translate) -assert(#all==8, 'ALL must deduplicate materials reported by multiple classes') -for api,_ in pairs(lists) do - local args=calls[api..'_args'] - assert(calls[api]==3 and args, api..' must cache its full/static/particle enumerations') -end -local categories=catalog.categories() -assert(#categories==8 and categories[1].id=='ALL' and categories[4].id=='STATIC' - and categories[7].id=='SOLIDS' and categories[8].id=='SPECIAL','material category surface wrong') -assert(#catalog.entries_for('SANDS')==0,'SANDS should remain lazy until requested') -while not catalog.is_ready('SANDS') do catalog.step('SANDS',translate,4) end -local sands=catalog.entries_for('SANDS') -assert(#sands==1,'powders must use one deterministic family for untagged duplicate API entries') -local sand_seen=false; for _,entry in ipairs(sands) do if entry.id=='sand' then sand_seen=true end; assert(entry.id~='rock_static','static leaked into powders') end -assert(sand_seen,'tagged powder missing') -while not catalog.is_ready('SOLIDS') do catalog.step('SOLIDS',translate,4) end -assert(#catalog.entries_for('SOLIDS')==1 and catalog.entries_for('SOLIDS')[1].id=='physics_rock','box2d classification failed') -assert(catalog.get('rock_static').categories.STATIC==true,'static membership missing') -assert(catalog.get('physics_rock').categories.SOLIDS==true,'physics-solid membership missing') -io.write('material_catalog=PASS lazy=true semantic_tags=true static=true physics=true special=true deduplicated=true\n') diff --git a/metamorph_creative_menu/tests/material_complete_catalog_mock.lua b/metamorph_creative_menu/tests/material_complete_catalog_mock.lua deleted file mode 100644 index 8d11656..0000000 --- a/metamorph_creative_menu/tests/material_complete_catalog_mock.lua +++ /dev/null @@ -1,526 +0,0 @@ --- All named definitions from the supplied data(10).zip, including CellDataChild. -local root=assert(arg[1]) -local definitions={ - {"air","SPECIAL","",false}, - {"fire","FIRES","[fire],[hot]",false}, - {"fire_blue","FIRES","[fire],[hot]",false}, - {"spark","FIRES","[fire],[hot]",false}, - {"spark_green","FIRES","[fire],[hot]",false}, - {"spark_green_bright","FIRES","[fire],[hot]",false}, - {"spark_blue","FIRES","[fire],[hot]",false}, - {"spark_blue_dark","FIRES","[fire],[hot]",false}, - {"spark_red","FIRES","[fire],[hot]",false}, - {"spark_red_bright","FIRES","[fire],[hot]",false}, - {"spark_white","FIRES","[fire],[hot]",false}, - {"spark_white_bright","FIRES","[fire],[hot]",false}, - {"spark_yellow","FIRES","[fire],[hot]",false}, - {"spark_purple","FIRES","[fire],[hot]",false}, - {"spark_purple_bright","FIRES","[fire],[hot]",false}, - {"spark_player","FIRES","[fire],[hot]",false}, - {"spark_teal","FIRES","[fire],[hot]",false}, - {"spark_electric","FIRES","[fire],[hot]",false}, - {"flame","FIRES","[fire],[hot]",false}, - {"sand_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"sand_static_rainforest","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"sand_static_rainforest_dark","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"bone_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"rust_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"sand_static_bright","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"meat_static","SANDS","[static],[corrodible],[meltable_to_blood],[alchemy],[meat],[food],[earth],[solid]",true}, - {"sand_static_red","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"nest_static","SANDS","[static],[corrodible],[burnable],[alchemy],[solid],[earth]",true}, - {"bluefungi_static","SANDS","[static],[corrodible],[burnable],[alchemy],[solid],[earth],[fungus]",true}, - {"rock_static","SANDS","[static],[corrodible],[meltable_to_lava],[alchemy],[solid],[earth]",true}, - {"rock_static_intro","SANDS","[static],[alchemy],[solid],[earth]",true}, - {"rock_static_trip_secret","SANDS","[static],[alchemy],[solid],[earth]",true}, - {"rock_static_trip_secret2","SANDS","[static],[alchemy],[solid],[earth]",true}, - {"rock_static_cursed","SANDS","[static],[alchemy],[solid],[earth],[matter_eater_ignore_list]",true}, - {"rock_static_purple","SANDS","[static],[alchemy],[solid],[earth]",true}, - {"water_static","LIQUIDS","[static]",true}, - {"endslime_static","LIQUIDS","[static],[alchemy],[solid],[earth]",true}, - {"slime_static","LIQUIDS","[static],[alchemy],[solid],[earth],[food]",true}, - {"spore_pod_stalk","LIQUIDS","[static],[alchemy],[solid],[burnable],[earth],[fungus]",true}, - {"rock_hard","SANDS","[static],[alchemy],[solid],[earth]",true}, - {"rock_static_fungal","SANDS","[static],[alchemy],[solid],[earth],[fungus]",true}, - {"wood_tree","SANDS","[static],[alchemy],[solid],[earth]",true}, - {"rock_static_noedge","SANDS","[static],[alchemy],[solid],[earth]",true}, - {"rock_hard_border","SANDS","[static],[alchemy],[solid],[earth]",true}, - {"rock_magic_gate","SANDS","[static],[corrodible],[meltable_to_lava],[alchemy],[solid],[earth]",true}, - {"rock_magic_bottom","SANDS","[static],[corrodible],[meltable_to_lava],[alchemy],[solid],[earth]",true}, - {"rock_eroding","SOLIDS","[static],[corrodible],[meltable_to_lava],[alchemy],[solid],[earth]",true}, - {"rock_vault","SANDS","[static],[corrodible],[meltable_to_lava],[alchemy],[solid],[earth]",true}, - {"coal_static","SANDS","[static],[corrodible],[meltable_to_lava],[alchemy],[solid],[earth]",true}, - {"rock_static_grey","SANDS","[static],[corrodible],[meltable_to_lava],[alchemy],[solid],[earth]",true}, - {"rock_static_radioactive","SANDS","[static],[alchemy],[meltable_to_lava],[corrodible],[solid],[earth]",true}, - {"rock_static_cursed_green","SANDS","[static],[alchemy],[meltable_to_lava],[corrodible],[solid],[earth]",true}, - {"rock_static_poison","SANDS","[static],[alchemy],[meltable_to_lava],[corrodible],[solid],[earth]",true}, - {"skullrock","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"rock_static_wet","SANDS","[static],[corrodible],[meltable_to_lava],[alchemy],[solid],[earth]",true}, - {"lavarock_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"static_magic_material","SANDS","[static],[corrodible],[alchemy],[solid]",true}, - {"meteorite_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"templerock_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"templebrick_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"templebrick_static_broken","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"templebrick_static_soft","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"templebrick_noedge_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"templerock_soft","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"templebrick_thick_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"templebrick_thick_static_noedge","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"templeslab_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"templeslab_crumbling_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"templebrickdark_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"wizardstone","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"templebrick_golden_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"templebrick_diamond_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"templebrick_static_ruined","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"glowstone","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"glowstone_altar","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"glowstone_altar_hdr","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"glowstone_potion","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"templebrick_red","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"templebrick_moss_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"the_end","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"steel_static","SANDS","[static],[corrodible],[rust],[alchemy],[meltable_metal_generic],[solid],[earth]",true}, - {"steelmoss_static","SANDS","[static],[corrodible],[rust],[alchemy],[meltable_metal_generic],[solid],[earth]",true}, - {"steel_rusted_no_holes","SANDS","[static],[corrodible],[rust],[alchemy],[meltable_metal_generic],[solid],[earth]",true}, - {"steel_grey_static","SANDS","[static],[corrodible],[rust],[alchemy],[meltable_metal_generic],[solid],[earth]",true}, - {"steelfrost_static","SANDS","[static],[corrodible],[rust],[alchemy],[meltable_metal_generic],[solid],[earth]",true}, - {"steelmoss_slanted","SANDS","[static],[corrodible],[rust],[alchemy],[meltable_metal_generic],[solid],[earth]",true}, - {"steelsmoke_static","SANDS","[static],[corrodible],[rust],[alchemy],[meltable_metal_generic],[solid],[earth]",true}, - {"steelpipe_static","SANDS","[static],[corrodible],[rust],[alchemy],[meltable_metal_generic],[solid],[earth]",true}, - {"steel_static_strong","SANDS","[static],[alchemy],[solid],[earth]",true}, - {"steel_static_unmeltable","SANDS","[static],[alchemy],[corrodible],[solid],[earth]",true}, - {"rock_static_glow","SANDS","[static],[corrodible],[meltable_to_lava],[alchemy],[solid],[earth]",true}, - {"snow_static","SANDS","[static],[corrodible],[frozen],[meltable_to_water],[alchemy],[solid]",true}, - {"ice_static","SANDS","[static],[corrodible],[frozen],[meltable_to_water],[alchemy],[solid]",true}, - {"rock_static_intro_breakable","SANDS","[static],[alchemy],[solid],[earth]",true}, - {"ice_blood_static","SANDS","[static],[alchemy],[meltable_to_blood],[solid]",true}, - {"ice_slime_static","SANDS","[static],[alchemy],[meltable_to_slime],[solid]",true}, - {"ice_acid_static","SANDS","[static],[corrodible],[frozen],[meltable_to_acid],[alchemy],[solid]",true}, - {"ice_cold_static","SANDS","[static],[corrodible],[frozen],[meltable_to_cold],[alchemy],[solid]",true}, - {"ice_radioactive_static","SANDS","[static],[corrodible],[frozen],[meltable_to_radioactive],[alchemy],[solid]",true}, - {"ice_poison_static","SANDS","[static],[corrodible],[frozen],[meltable_to_poison],[alchemy],[solid]",true}, - {"ice_meteor_static","SANDS","[static],[corrodible],[frozen],[meltable_to_lava],[alchemy],[solid],[earth]",true}, - {"tubematerial","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"glass_static","SANDS","[static],[alchemy],[solid],[earth]",true}, - {"waterrock","SOLIDS","[static],[alchemy],[solid],[earth]",false}, - {"ice_glass","SOLIDS","[box2d],[static],[meltable_to_water],[alchemy],[solid],[cold]",false}, - {"tube_physics","SOLIDS","[box2d],[static],[meltable_to_lava],[alchemy],[solid],[earth]",false}, - {"ice_acid_glass","SOLIDS","[static],[meltable_to_acid],[alchemy],[solid],[cold]",false}, - {"ice_cold_glass","SOLIDS","[static],[meltable_to_cold],[alchemy],[solid],[cold]",false}, - {"ice_radioactive_glass","SOLIDS","[static],[meltable_to_radioactive],[alchemy],[solid],[cold]",false}, - {"ice_poison_glass","SOLIDS","[static],[meltable_to_poison],[alchemy],[solid],[cold]",false}, - {"ice_blood_glass","SOLIDS","[static],[meltable_to_blood],[alchemy],[solid],[cold]",false}, - {"ice_slime_glass","SOLIDS","[static],[meltable_to_slime],[alchemy],[solid],[cold]",false}, - {"ice_glass_b2","SOLIDS","[static],[meltable_to_water],[alchemy],[solid],[cold]",false}, - {"glass_brittle","SOLIDS","[static],[alchemy],[solid],[earth]",false}, - {"snowrock_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"concrete_static","SANDS","[static],[corrodible],[meltable_to_lava],[alchemy],[solid],[earth]",true}, - {"wood_static","SANDS","[corrodible],[burnable],[alchemy],[solid],[earth]",true}, - {"cheese_static","SANDS","[static],[corrodible],[meltable_to_lava],[alchemy],[solid],[earth]",true}, - {"wood_static_wet","SANDS","[corrodible],[burnable],[alchemy],[solid],[earth]",true}, - {"root_growth","SANDS","[corrodible],[burnable],[alchemy],[solid],[earth]",true}, - {"wood_burns_forever","SANDS","[corrodible],[burnable],[alchemy],[solid],[earth]",true}, - {"creepy_liquid_emitter","SANDS","[corrodible],[burnable],[alchemy],[solid],[earth]",true}, - {"gold_static","SANDS","[corrodible],[alchemy],[gold],[solid],[earth]",true}, - {"gold_static_radioactive","SANDS","[corrodible],[alchemy],[gold],[solid],[earth]",true}, - {"gold_static_dark","SANDS","[corrodible],[alchemy],[gold],[solid],[earth]",true}, - {"wood_static_vertical","SANDS","[corrodible],[burnable],[alchemy],[solid],[earth]",true}, - {"wood_static_gas","SANDS","[corrodible],[burnable],[alchemy],[solid],[earth]",true}, - {"corruption_static","SANDS","[static],[corrodible]",true}, - {"smoke","GASES","[gas]",false}, - {"cloud","GASES","[gas]",true}, - {"cloud_lighter","GASES","[gas]",true}, - {"smoke_magic","GASES","[gas]",false}, - {"smoke_explosion","GASES","[gas]",false}, - {"steam","GASES","[gas]",false}, - {"steam_trailer","GASES","[gas]",false}, - {"acid_gas","GASES","[gas],[burnable_fast]",false}, - {"acid_gas_static","GASES","[gas],[burnable_fast]",true}, - {"smoke_static","GASES","[gas]",true}, - {"poison_gas","GASES","[gas],[burnable_fast]",false}, - {"fungal_gas","GASES","[gas],[burnable_fast],[fungus]",false}, - {"poo_gas","GASES","[gas]",false}, - {"blood_cold_vapour","GASES","[gas],[vapour],[cold]",false}, - {"sand_herb_vapour","GASES","[gas],[vapour]",false}, - {"radioactive_gas","GASES","[gas]",false}, - {"radioactive_gas_static","GASES","[gas]",true}, - {"magic_gas_hp_regeneration","GASES","[gas],[vapour],[regenerative_gas]",false}, - {"magic_gas_midas","GASES","[gas],[vapour]",false}, - {"magic_gas_worm_blood","GASES","[gas],[vapour]",false}, - {"rainbow_gas","GASES","[gas]",false}, - {"magic_gas_polymorph","GASES","[gas],[vapour],[alchemy]",false}, - {"magic_gas_weakness","GASES","[gas],[vapour],[alchemy]",false}, - {"magic_gas_teleport","GASES","[gas],[vapour],[alchemy]",false}, - {"magic_gas_fungus","GASES","[gas],[vapour],[alchemy]",false}, - {"water","LIQUIDS","[liquid],[corrodible],[freezable],[water],[liquid_common],[chaotic_transmutation]",false}, - {"water_fading","LIQUIDS","[liquid],[corrodible],[freezable],[water],[liquid_common],[chaotic_transmutation]",false}, - {"water_salt","LIQUIDS","[liquid],[corrodible],[freezable],[water],[liquid_common]",false}, - {"void_liquid","LIQUIDS","[liquid]",false}, - {"mimic_liquid","LIQUIDS","[liquid],[mimic_liquid]",false}, - {"just_death","LIQUIDS","[liquid],[magic]",false}, - {"water_temp","LIQUIDS","[liquid],[corrodible],[freezable],[water],[liquid_common]",false}, - {"water_ice","LIQUIDS","[liquid],[corrodible],[meltable_to_water],[freezable],[water],[liquid_common],[chaotic_transmutation]",false}, - {"water_swamp","LIQUIDS","[liquid],[corrodible],[meltable_to_water],[water],[impure],[liquid_common]",false}, - {"oil","LIQUIDS","[liquid],[burnable],[impure],[liquid_common],[chaotic_transmutation]",false}, - {"liquid_fire","LIQUIDS","[liquid],[burnable],[impure],[hot],[fire_strong],[liquid_common]",false}, - {"liquid_fire_weak","LIQUIDS","[liquid],[burnable],[impure],[hot],[fire_strong],[liquid_common]",false}, - {"alcohol","LIQUIDS","[liquid],[water],[impure],[liquid_common],[chaotic_transmutation]",false}, - {"beer","LIQUIDS","[liquid],[water],[impure],[liquid_common]",false}, - {"milk","LIQUIDS","[liquid],[water],[impure],[liquid_common]",false}, - {"molut","LIQUIDS","[liquid],[water],[impure],[liquid_common]",false}, - {"sima","LIQUIDS","[liquid],[water],[impure],[liquid_common]",false}, - {"juhannussima","LIQUIDS","[liquid],[water],[impure],[liquid_common]",false}, - {"alcohol_gas","GASES","[gas],[burnable],[evaporable]",false}, - {"midas_precursor","LIQUIDS","[liquid],[burnable],[water]",false}, - {"midas","LIQUIDS","[liquid],[burnable],[water]",false}, - {"magic_liquid","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"material_confusion","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"material_darkness","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"pus","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"material_rainbow","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"magic_liquid_weakness","LIQUIDS","[liquid],[water],[magic_liquid],[impure],[magic_faster]",false}, - {"magic_liquid_movement_faster","LIQUIDS","[liquid],[water],[magic_liquid],[impure],[magic_faster]",false}, - {"magic_liquid_faster_levitation","LIQUIDS","[liquid],[water],[magic_liquid],[impure],[magic_faster]",false}, - {"magic_liquid_faster_levitation_and_movement","LIQUIDS","[liquid],[water],[magic_liquid],[impure],[magic_faster]",false}, - {"magic_liquid_worm_attractor","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"magic_liquid_protection_all","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"magic_liquid_mana_regeneration","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"magic_liquid_unstable_teleportation","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"magic_liquid_teleportation","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"magic_liquid_hp_regeneration","LIQUIDS","[liquid],[water],[magic_liquid],[regenerative]",false}, - {"magic_liquid_hp_regeneration_unstable","LIQUIDS","[liquid],[water],[magic_liquid],[regenerative]",false}, - {"magic_liquid_polymorph","LIQUIDS","[liquid],[water],[magic_liquid],[magic_polymorph],[impure]",false}, - {"magic_liquid_random_polymorph","LIQUIDS","[liquid],[water],[magic_liquid],[magic_polymorph],[impure]",false}, - {"magic_liquid_unstable_polymorph","LIQUIDS","[liquid],[water],[magic_liquid],[magic_polymorph],[impure]",false}, - {"magic_liquid_berserk","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"magic_liquid_charm","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"magic_liquid_invisibility","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"cloud_radioactive","LIQUIDS","[liquid],[radioactive],[impure],[liquid_common]",false}, - {"cloud_blood","LIQUIDS","[liquid],[blood],[impure],[liquid_common]",false}, - {"cloud_slime","LIQUIDS","[liquid],[impure],[slime],[liquid_common]",false}, - {"swamp","LIQUIDS","[liquid],[corrodible],[soluble],[meltable_to_water],[freezable],[water],[impure],[liquid_common],[chaotic_transmutation]",false}, - {"mud","SANDS","[sand_ground],[corrodible],[meltable_to_lava],[impure]",false}, - {"blood","LIQUIDS","[liquid],[corrodible],[soluble],[blood],[impure],[liquid_common],[food],[chaotic_transmutation]",false}, - {"blood_fading","LIQUIDS","[liquid],[corrodible],[soluble],[evaporable_fast],[blood],[impure],[liquid_common],[food]",false}, - {"blood_fading_slow","LIQUIDS","[liquid],[corrodible],[soluble],[blood],[impure],[liquid_common],[food],[chaotic_transmutation]",false}, - {"blood_fungi","LIQUIDS","[liquid],[corrodible],[soluble],[evaporable],[blood],[impure],[liquid_common],[food],[fungus],[chaotic_transmutation]",false}, - {"blood_worm","LIQUIDS","[liquid],[corrodible],[soluble],[blood],[impure],[liquid_common],[food],[chaotic_transmutation]",false}, - {"porridge","LIQUIDS","[liquid],[corrodible],[soluble],[impure],[liquid_common],[evaporable_by_fire],[food]",false}, - {"blood_cold","LIQUIDS","[liquid],[corrodible],[soluble],[evaporable_custom],[blood],[impure],[cold],[liquid_common]",false}, - {"radioactive_liquid","LIQUIDS","[liquid],[corrodible],[soluble],[radioactive],[impure],[liquid_common],[chaotic_transmutation]",false}, - {"poison","LIQUIDS","[liquid],[corrodible],[soluble],[impure],[liquid_common]",false}, - {"cursed_liquid","LIQUIDS","[corrodible],[soluble],[impure]",false}, - {"radioactive_liquid_yellow","LIQUIDS","[liquid],[corrodible],[soluble],[radioactive],[impure],[liquid_common],[chaotic_transmutation]",false}, - {"radioactive_liquid_fading","LIQUIDS","[liquid],[corrodible],[soluble],[evaporable],[radioactive],[impure],[liquid_common]",false}, - {"plasma_fading","LIQUIDS","[liquid],[corrodible],[soluble],[evaporable_fast],[impure],[liquid_common]",false}, - {"plasma_fading_bright","LIQUIDS","[liquid],[corrodible],[soluble],[evaporable_fast],[impure],[liquid_common]",false}, - {"plasma_fading_green","SANDS","[liquid],[corrodible],[soluble],[evaporable_fast],[impure],[liquid_common]",false}, - {"plasma_fading_pink","SANDS","[liquid],[corrodible],[soluble],[evaporable_fast],[impure],[liquid_common]",false}, - {"gold_molten","LIQUIDS","[liquid],[corrodible],[molten],[alchemy],[gold],[solid],[liquid_common],[earth]",false}, - {"steel_static_molten","LIQUIDS","[liquid],[corrodible],[molten_metal],[alchemy],[gold],[solid],[liquid_common],[earth]",false}, - {"steelmoss_slanted_molten","LIQUIDS","[liquid],[corrodible],[molten_metal],[alchemy],[gold],[solid],[liquid_common],[earth]",false}, - {"steelmoss_static_molten","LIQUIDS","[liquid],[corrodible],[molten_metal],[alchemy],[gold],[solid],[liquid_common],[earth]",false}, - {"steelsmoke_static_molten","LIQUIDS","[liquid],[corrodible],[molten_metal],[alchemy],[gold],[solid],[liquid_common],[earth]",false}, - {"metal_sand_molten","LIQUIDS","[liquid],[corrodible],[molten_metal],[alchemy],[gold],[solid],[liquid_common],[earth]",false}, - {"metal_molten","LIQUIDS","[liquid],[corrodible],[molten_metal],[alchemy],[gold],[solid],[liquid_common],[earth]",false}, - {"metal_rust_molten","LIQUIDS","[liquid],[corrodible],[molten_metal],[alchemy],[gold],[solid],[liquid_common],[earth]",false}, - {"metal_nohit_molten","LIQUIDS","[liquid],[corrodible],[molten_metal],[alchemy],[gold],[solid],[liquid_common],[earth]",false}, - {"aluminium_molten","LIQUIDS","[liquid],[corrodible],[molten_metal],[alchemy],[gold],[solid],[liquid_common],[earth]",false}, - {"aluminium_robot_molten","LIQUIDS","[liquid],[corrodible],[molten_metal],[alchemy],[gold],[solid],[liquid_common],[earth]",false}, - {"metal_prop_molten","LIQUIDS","[liquid],[corrodible],[molten_metal],[alchemy],[gold],[solid],[liquid_common],[earth]",false}, - {"steel_rust_molten","LIQUIDS","[liquid],[corrodible],[molten_metal],[alchemy],[gold],[solid],[liquid_common],[earth]",false}, - {"aluminium_oxide_molten","LIQUIDS","[liquid],[corrodible],[molten_metal],[alchemy],[gold],[solid],[liquid_common],[earth]",false}, - {"wax_molten","LIQUIDS","[liquid],[corrodible],[molten],[alchemy],[impure],[solid],[liquid_common],[earth]",false}, - {"silver_molten","LIQUIDS","[liquid],[corrodible],[molten],[alchemy],[solid],[liquid_common],[earth]",false}, - {"copper_molten","LIQUIDS","[liquid],[corrodible],[molten],[alchemy],[solid],[liquid_common],[earth]",false}, - {"brass_molten","LIQUIDS","[liquid],[corrodible],[molten],[alchemy],[solid],[liquid_common],[earth]",false}, - {"glass_molten","LIQUIDS","[liquid],[molten],[meltable_to_lava],[alchemy],[solid],[liquid_common],[earth]",false}, - {"glass_broken_molten","LIQUIDS","[liquid],[molten],[meltable_to_lava],[alchemy],[solid],[liquid_common],[earth]",false}, - {"steel_molten","LIQUIDS","[liquid],[molten],[alchemy],[solid],[liquid_common],[earth]",false}, - {"creepy_liquid","LIQUIDS","[liquid],[impure]",false}, - {"cement","LIQUIDS","[corrodible],[meltable_to_lava],[alchemy],[impure],[solid],[earth]",false}, - {"concrete_sand","SANDS","[corrodible],[meltable_to_lava],[alchemy]",false}, - {"sand","SANDS","[sand_ground],[corrodible],[meltable_to_lava_fast],[alchemy],[chaotic_transmutation]",false}, - {"sand_blue","SANDS","[sand_ground],[corrodible],[meltable_to_lava_fast],[alchemy],[chaotic_transmutation]",false}, - {"sand_surface","SANDS","[sand_ground],[corrodible],[meltable_to_lava_fast],[alchemy],[chaotic_transmutation]",false}, - {"lavasand","SANDS","[sand_ground],[corrodible],[meltable_to_lava_fast],[alchemy],[chaotic_transmutation]",false}, - {"sand_petrify","SANDS","[sand_ground],[corrodible],[meltable_to_lava_fast],[alchemy],[chaotic_transmutation]",false}, - {"bone","SANDS","[sand_ground],[corrodible],[meltable_to_lava],[alchemy],[chaotic_transmutation]",false}, - {"soil","SANDS","[sand_ground],[corrodible],[grows_grass],[meltable_to_lava],[alchemy],[solid]",false}, - {"soil_lush","SANDS","[sand_ground],[corrodible],[grows_grass],[meltable_to_lava],[alchemy],[solid]",false}, - {"soil_lush_dark","SANDS","[sand_ground],[corrodible],[grows_grass],[meltable_to_lava],[alchemy],[solid]",false}, - {"soil_dead","SANDS","[sand_ground],[corrodible],[grows_grass],[meltable_to_lava],[alchemy],[solid]",false}, - {"soil_dark","SANDS","[sand_ground],[corrodible],[grows_grass],[meltable_to_lava],[alchemy],[solid]",false}, - {"sandstone","SANDS","[sand_ground],[corrodible],[meltable_to_lava],[alchemy],[solid],[earth]",false}, - {"sandstone_surface","SANDS","[sand_ground],[corrodible],[meltable_to_lava],[alchemy],[solid],[earth]",false}, - {"fungisoil","SANDS","[sand_ground],[corrodible],[grows_fungus],[meltable_to_lava],[alchemy],[solid],[earth],[fungus]",false}, - {"honey","SANDS","[corrodible],[meltable_to_lava_fast],[alchemy],[meltable_by_fire],[chaotic_transmutation]",false}, - {"glue","SANDS","[liquid],[burnable],[alchemy],[impure]",false}, - {"slime","LIQUIDS","[corrodible],[meltable_to_lava],[alchemy],[slime],[liquid_common],[food],[chaotic_transmutation]",false}, - {"slush","LIQUIDS","[corrodible],[meltable_to_water],[alchemy],[snow],[chaotic_transmutation]",false}, - {"slime_green","LIQUIDS","[corrodible],[meltable_to_lava],[alchemy],[slime],[liquid_common],[food],[chaotic_transmutation]",false}, - {"slime_yellow","LIQUIDS","[corrodible],[meltable_to_lava],[alchemy],[slime],[liquid_common],[food]",false}, - {"pea_soup","LIQUIDS","[corrodible],[meltable_to_lava],[alchemy],[slime],[liquid_common],[food],[chaotic_transmutation]",false}, - {"vomit","LIQUIDS","[food]",false}, - {"endslime","SANDS","[static],[corrodible],[alchemy],[slime],[chaotic_transmutation]",true}, - {"endslime_blood","SANDS","[static],[corrodible],[slime]",false}, - {"explosion_dirt","SANDS","[sand_ground],[corrodible]",false}, - {"vine","SANDS","[static],[sand_ground],[corrodible],[burnable],[alchemy]",true}, - {"root","SANDS","[static],[sand_ground],[corrodible],[burnable],[alchemy],[solid],[earth]",true}, - {"snow","SANDS","[static],[sand_ground],[corrodible],[frozen],[meltable_to_water],[alchemy],[chaotic_transmutation]",false}, - {"snow_sticky","SANDS","[sand_ground],[corrodible],[frozen],[meltable_to_water],[alchemy]",false}, - {"rotten_meat","SANDS","[sand_ground],[corrodible],[meltable_to_lava],[alchemy],[meat],[food]",false}, - {"meat_slime_sand","SANDS","[sand_ground],[corrodible],[meltable_to_lava],[alchemy],[meat],[slime],[food]",false}, - {"meat_slime_green","SOLIDS","[box2d],[corrodible],[meltable_to_lava],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_slime_orange","SOLIDS","[box2d],[corrodible],[meltable_to_lava],[alchemy],[meat],[solid],[earth],[food]",false}, - {"rotten_meat_radioactive","SANDS","[sand_ground],[corrodible],[meltable_to_lava],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_worm","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_helpless","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_trippy","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_frog","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_cursed","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_cursed_dry","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_slime_cursed","SOLIDS","[box2d],[corrodible],[meltable_to_lava],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_teleport","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_fast","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_polymorph","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_polymorph_protection","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_confusion","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"ice","SANDS","[sand_ground],[corrodible],[frozen],[meltable_to_water],[alchemy],[solid]",false}, - {"sand_herb","SANDS","[corrodible],[evaporable_custom],[burnable],[alchemy]",false}, - {"wax","SANDS","[burnable],[corrodible],[meltable],[alchemy],[solid],[earth]",false}, - {"gold","SANDS","[sand_metal],[corrodible],[meltable_metal],[alchemy],[gold],[solid],[earth]",false}, - {"gold_radioactive","SANDS","[sand_metal],[corrodible],[meltable_metal_generic],[alchemy],[gold],[solid],[earth]",false}, - {"silver","SANDS","[sand_metal],[corrodible],[meltable_metal],[alchemy],[solid],[earth]",false}, - {"steel_sand","SANDS","[sand_metal],[corrodible],[alchemy]",false}, - {"metal_sand","SANDS","[sand_metal],[corrodible],[alchemy],[meltable_metal]",false}, - {"copper","SANDS","[sand_metal],[corrodible],[meltable_metal],[alchemy],[solid],[earth]",false}, - {"brass","SANDS","[sand_metal],[corrodible],[meltable_metal],[alchemy],[solid],[earth]",false}, - {"diamond","SANDS","[sand_metal],[alchemy],[solid],[earth]",false}, - {"coal","SANDS","[sand_other],[corrodible],[burnable],[alchemy],[chaotic_transmutation]",false}, - {"sulphur","SANDS","[sand_other],[corrodible],[alchemy]",false}, - {"salt","SANDS","[sand_other],[corrodible],[alchemy],[chaotic_transmutation]",false}, - {"sodium","SANDS","[sand_other],[corrodible],[alchemy],[chaotic_transmutation]",false}, - {"purifying_powder","SANDS","[sand_other],[corrodible],[alchemy],[chaotic_transmutation]",false}, - {"burning_powder","SANDS","[sand_other],[corrodible],[alchemy],[chaotic_transmutation]",false}, - {"sodium_unstable","SANDS","[sand_other],[corrodible],[alchemy]",false}, - {"gunpowder","SANDS","[sand_other],[corrodible],[alchemy],[chaotic_transmutation]",false}, - {"gunpowder_explosive","SANDS","[sand_other],[corrodible],[alchemy],[chaotic_transmutation]",false}, - {"gunpowder_tnt","SANDS","[sand_other],[corrodible],[alchemy]",false}, - {"gunpowder_unstable","SANDS","[sand_other],[corrodible],[burnable],[alchemy],[chaotic_transmutation]",false}, - {"gunpowder_unstable_big","SANDS","[sand_other],[corrodible],[burnable],[alchemy]",false}, - {"monster_powder_test","SANDS","[sand_other],[corrodible],[burnable],[alchemy]",false}, - {"rat_powder","SANDS","[sand_other]",false}, - {"fungus_powder","SANDS","[sand_other],[fungus]",false}, - {"fungus_powder_bad","SANDS","[sand_other],[fungus]",false}, - {"shock_powder","SANDS","[sand_other],[fungus]",false}, - {"orb_powder","SANDS","[NO_FUNGAL_SHIFT]",false}, - {"gunpowder_unstable_boss_limbs","SANDS","[sand_other],[corrodible],[alchemy],[meat]",false}, - {"plastic_red","SANDS","[corrodible],[meltable],[alchemy]",false}, - {"plastic_grey","SANDS","[corrodible],[meltable],[alchemy]",false}, - {"plastic_red_molten","LIQUIDS","[liquid],[corrodible],[molten],[meltable_to_lava],[alchemy],[liquid_common]",false}, - {"plastic_grey_molten","LIQUIDS","[liquid],[corrodible],[molten],[meltable_to_lava],[alchemy],[liquid_common]",false}, - {"plastic_molten","LIQUIDS","[liquid],[corrodible],[molten],[meltable_to_lava],[alchemy],[liquid_common]",false}, - {"plastic_prop_molten","LIQUIDS","[liquid],[corrodible],[molten],[meltable_to_lava],[alchemy],[liquid_common]",false}, - {"grass","SANDS","[plant],[requires_air],[corrodible],[burnable],[alchemy],[impure]",false}, - {"grass_holy","SANDS","[plant],[requires_air],[corrodible],[burnable],[alchemy]",false}, - {"grass_darker","SANDS","[plant],[requires_air],[corrodible],[burnable],[alchemy],[impure]",false}, - {"grass_ice","SANDS","[plant],[corrodible],[burnable],[impure]",false}, - {"grass_dry","SANDS","[plant],[requires_air],[corrodible],[burnable],[alchemy],[impure]",false}, - {"fungi","SANDS","[plant],[corrodible],[burnable],[alchemy],[impure],[fungus]",false}, - {"fungi_green","SANDS","[plant],[corrodible],[burnable],[alchemy],[impure],[fungus]",false}, - {"fungi_yellow","SANDS","[plant],[corrodible],[burnable],[alchemy],[impure],[fungus]",false}, - {"grass_dark","SANDS","[plant],[requires_air],[corrodible],[burnable],[alchemy],[impure]",false}, - {"fungi_creeping","SANDS","[plant],[corrodible],[burnable]",false}, - {"fungi_creeping_secret","SANDS","[plant],[corrodible],[burnable]",false}, - {"spore","SANDS","[plant],[corrodible],[burnable],[alchemy],[impure]",false}, - {"peat","LIQUIDS","[plant],[corrodible],[burnable],[alchemy],[impure]",false}, - {"moss_rust","SANDS","[plant],[corrodible],[burnable],[alchemy],[impure]",false}, - {"moss","SANDS","[plant],[corrodible],[burnable],[alchemy],[impure]",false}, - {"plant_material","SANDS","[plant],[requires_air],[corrodible],[burnable]",false}, - {"plant_material_red","SANDS","[plant],[requires_air],[corrodible],[burnable]",false}, - {"plant_material_dark","SANDS","[plant],[requires_air],[corrodible],[burnable]",false}, - {"ceiling_plant_material","SANDS","[plant],[corrodible],[burnable]",false}, - {"mushroom_seed","SANDS","[plant],[corrodible],[burnable]",false}, - {"plant_seed","SANDS","[plant],[corrodible],[burnable]",false}, - {"mushroom","SANDS","[plant],[corrodible],[burnable]",false}, - {"mushroom_giant_red","SANDS","[plant],[requires_air],[corrodible],[burnable]",false}, - {"mushroom_giant_blue","SANDS","[plant],[requires_air],[corrodible],[burnable]",false}, - {"glowshroom","SANDS","[plant],[requires_air],[corrodible],[burnable]",false}, - {"bush_seed","SANDS","[plant],[requires_air],[corrodible],[burnable]",false}, - {"acid","LIQUIDS","[liquid],[acid],[impure],[liquid_common],[chaotic_transmutation]",false}, - {"lava","LIQUIDS","[fire_lava],[liquid],[lava],[liquid_common],[chaotic_transmutation]",false}, - {"wood_player","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"wood_player_b2","SOLIDS","[static],[box2d],[alchemy],[corrodible],[solid],[earth]",false}, - {"wood_player_b2_vertical","SOLIDS","[static],[box2d],[alchemy],[corrodible],[solid],[earth]",false}, - {"wood","SOLIDS","[box2d],[corrodible],[alchemy],[burnable],[solid],[earth]",false}, - {"wax_b2","SOLIDS","[box2d],[alchemy],[solid],[earth]",false}, - {"fuse","SOLIDS","[box2d]",false}, - {"fuse_bright","SOLIDS","[box2d]",false}, - {"fuse_tnt","SOLIDS","[box2d]",false}, - {"fuse_holy","SOLIDS","[box2d]",false}, - {"templebrick_box2d","SOLIDS","[box2d],[corrodible],[alchemy],[burnable],[solid],[earth]",false}, - {"wood_trailer","SOLIDS","[box2d],[corrodible],[alchemy],[solid],[earth]",false}, - {"wood_wall","SOLIDS","[box2d],[corrodible],[alchemy],[burnable],[solid],[earth]",false}, - {"cactus","SOLIDS","[box2d],[corrodible],[alchemy],[solid],[earth]",false}, - {"grass_loose","SOLIDS","[box2d],[corrodible],[alchemy],[solid],[earth]",false}, - {"fungus_loose","SOLIDS","[box2d],[alchemy],[solid],[earth],[fungus]",false}, - {"fungus_loose_green","SOLIDS","[box2d],[alchemy],[solid],[earth],[fungus]",false}, - {"fungus_loose_trippy","SOLIDS","[box2d],[alchemy],[solid],[earth],[fungus]",false}, - {"wood_prop","SOLIDS","[box2d],[corrodible],[alchemy],[burnable],[solid],[earth]",false}, - {"wood_prop_noplayerhit","SOLIDS","[box2d],[corrodible],[alchemy],[burnable],[solid],[earth]",false}, - {"cloth_box2d","SOLIDS","[box2d],[burnable],[solid]",false}, - {"wood_prop_durable","SOLIDS","[box2d],[corrodible],[alchemy],[burnable],[solid],[earth]",false}, - {"nest_box2d","SOLIDS","[box2d],[corrodible],[alchemy],[burnable],[solid],[earth]",false}, - {"nest_firebug_box2d","SOLIDS","[box2d],[corrodible],[alchemy],[burnable],[solid],[earth]",false}, - {"cocoon_box2d","SOLIDS","[box2d],[corrodible],[alchemy],[burnable],[solid],[earth]",false}, - {"wood_loose","SOLIDS","[box2d],[corrodible],[alchemy],[solid],[earth]",false}, - {"rock_loose","SOLIDS","[box2d],[corrodible],[alchemy],[solid],[earth]",false}, - {"ice_ceiling","SOLIDS","[box2d],[corrodible],[alchemy],[solid]",false}, - {"brick","SOLIDS","[box2d],[corrodible],[alchemy],[solid],[earth]",false}, - {"concrete_collapsed","SOLIDS","[box2d],[corrodible],[alchemy],[solid],[earth]",false}, - {"tnt","SOLIDS","[box2d],[corrodible],[burnable],[alchemy],[solid],[earth]",false}, - {"tnt_static","SOLIDS","[box2d],[corrodible],[burnable],[alchemy],[solid],[earth]",false}, - {"trailer_text","SANDS","",true}, - {"meteorite","SOLIDS","[box2d],[alchemy],[solid],[earth]",false}, - {"sulphur_box2d","SOLIDS","[box2d],[solid],[sunbaby_ignore_list]",false}, - {"meteorite_test","SOLIDS","[box2d],[alchemy],[solid],[earth]",false}, - {"meteorite_crackable","SOLIDS","[box2d],[alchemy],[solid],[earth]",false}, - {"meteorite_green","SOLIDS","[box2d],[alchemy],[solid],[earth]",false}, - {"steel","SOLIDS","[box2d],[rust_box2d],[alchemy],[meltable_metal],[solid],[earth]",false}, - {"steel_rust","SOLIDS","[box2d],[alchemy],[meltable_metal],[solid],[earth]",false}, - {"metal_rust_rust","SOLIDS","[box2d],[alchemy],[corrodible],[solid],[meltable_metal_generic],[earth]",false}, - {"metal_rust_barrel_rust","SOLIDS","[box2d],[alchemy],[corrodible],[solid],[meltable_metal_generic],[earth]",false}, - {"plastic","SOLIDS","[box2d],[corrodible],[meltable_plastic],[alchemy],[solid],[earth]",false}, - {"plastic_prop","SOLIDS","[box2d],[corrodible],[meltable_plastic],[alchemy],[solid],[earth]",false}, - {"aluminium","SOLIDS","[box2d],[corrodible],[rust_oxide],[alchemy],[meltable_metal],[solid],[earth]",false}, - {"aluminium_robot","SOLIDS","[box2d],[corrodible],[alchemy],[meltable_metal],[solid],[earth]",false}, - {"metal_prop","SOLIDS","[box2d],[corrodible],[alchemy],[meltable_metal],[solid],[earth]",false}, - {"metal_prop_low_restitution","SOLIDS","[box2d],[corrodible],[alchemy],[meltable_metal_generic],[solid],[earth]",false}, - {"metal_prop_loose","SOLIDS","[box2d],[corrodible],[alchemy],[meltable_metal_generic],[solid],[earth]",false}, - {"metal","SOLIDS","[box2d],[corrodible],[alchemy],[meltable_metal],[solid],[earth]",false}, - {"metal_hard","SOLIDS","[box2d],[corrodible],[alchemy],[meltable_metal_generic],[solid]",false}, - {"rock_box2d","SOLIDS","[box2d],[static],[alchemy],[solid],[earth]",false}, - {"templebrick_box2d_edgetiles","SOLIDS","[box2d],[solid],[earth]",false}, - {"rock_box2d_hard","SOLIDS","[box2d],[solid],[sunbaby_ignore_list]",false}, - {"poop_box2d_hard","SOLIDS","[box2d],[sunbaby_ignore_list],[matter_eater_ignore_list]",false}, - {"rock_box2d_nohit","SOLIDS","[box2d],[solid],[earth]",false}, - {"rock_box2d_nohit_heavy","SOLIDS","[box2d],[solid],[earth]",false}, - {"rock_box2d_nohit_hard","SOLIDS","[box2d],[solid]",false}, - {"rock_static_box2d","SOLIDS","[box2d],[static],[alchemy],[solid],[earth]",false}, - {"item_box2d","SOLIDS","[box2d]",false}, - {"item_box2d_glass","SOLIDS","[box2d]",false}, - {"item_box2d_meat","SOLIDS","[box2d]",false}, - {"gem_box2d","SOLIDS","[box2d],[sunbaby_ignore_list]",false}, - {"potion_glass_box2d","SOLIDS","[box2d]",false}, - {"glass_box2d","SOLIDS","[box2d]",false}, - {"gem_box2d_yellow_sun","SOLIDS","[box2d],[matter_eater_ignore_list],[indestructible],[sunbaby_ignore_list]",false}, - {"gem_box2d_red_float","SOLIDS","[box2d],[sunbaby_ignore_list]",false}, - {"gem_box2d_yellow_sun_gravity","SOLIDS","[box2d],[matter_eater_ignore_list],[indestructible],[sunbaby_ignore_list]",false}, - {"gem_box2d_darksun","SOLIDS","[box2d],[matter_eater_ignore_list],[indestructible],[sunbaby_ignore_list]",false}, - {"gem_box2d_pink","SOLIDS","[box2d],[sunbaby_ignore_list]",false}, - {"gem_box2d_red","SOLIDS","[box2d],[sunbaby_ignore_list]",false}, - {"gem_box2d_turquoise","SOLIDS","[box2d],[sunbaby_ignore_list]",false}, - {"gem_box2d_opal","SOLIDS","[box2d],[sunbaby_ignore_list]",false}, - {"gem_box2d_white","SOLIDS","[box2d],[sunbaby_ignore_list]",false}, - {"gem_box2d_green","SOLIDS","[box2d],[sunbaby_ignore_list]",false}, - {"gem_box2d_orange","SOLIDS","[box2d],[sunbaby_ignore_list]",false}, - {"gold_box2d","SOLIDS","[box2d],[gold],[alchemy]",false}, - {"bloodgold_box2d","SOLIDS","[box2d],[gold],[alchemy]",false}, - {"metal_nohit","SOLIDS","[box2d],[corrodible],[alchemy],[meltable_metal],[solid],[earth]",false}, - {"metal_chain_nohit","SOLIDS","[box2d],[corrodible],[alchemy],[meltable_metal_generic],[solid],[earth]",false}, - {"metal_wire_nohit","SOLIDS","[box2d],[corrodible],[alchemy],[meltable_metal_generic],[solid],[earth]",false}, - {"metal_rust","SOLIDS","[box2d],[corrodible],[rust_box2d],[alchemy],[meltable_metal],[solid],[earth]",false}, - {"metal_rust_barrel","SOLIDS","[box2d],[corrodible],[rust_box2d],[alchemy],[meltable_metal_generic],[solid],[earth]",false}, - {"bone_box2d","SOLIDS","[box2d],[corrodible],[alchemy],[meltable_to_lava],[solid],[earth]",false}, - {"crystal","SOLIDS","[box2d],[alchemy]",false}, - {"magic_crystal","SOLIDS","[box2d],[alchemy]",false}, - {"magic_crystal_green","SOLIDS","[box2d],[alchemy]",false}, - {"gold_b2","SOLIDS","[box2d],[gold],[alchemy],[solid],[earth]",false}, - {"crystal_purple","SOLIDS","[box2d],[alchemy]",false}, - {"crystal_solid","SOLIDS","[box2d],[alchemy]",false}, - {"crystal_magic","SOLIDS","[box2d],[alchemy]",false}, - {"aluminium_oxide","SOLIDS","[box2d],[corrodible],[alchemy],[meltable_metal],[solid],[earth]",false}, - {"meat","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_fruit","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_pumpkin","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_warm","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_hot","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_done","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_burned","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_slime","SOLIDS","[box2d],[corrodible],[meltable_to_lava],[alchemy],[meat],[solid],[earth],[food]",false}, - {"urine","LIQUIDS","[liquid],[corrodible],[soluble],[liquid_common]",false}, - {"poo","SANDS","[sand_other],[corrodible],[soluble]",false}, - {"mammi","SANDS","[sand_other],[corrodible],[soluble]",false}, - {"physics_throw_material_part2","SOLIDS","[box2d],[alchemy]",false}, - {"rocket_particles","LIQUIDS","[liquid]",false}, - {"ice_melting_perf_killer","SOLIDS","[box2d],[hax],[meltable_to_water],[ice]",false}, - {"ice_b2","SOLIDS","[box2d],[alchemy],[solid]",false}, - {"glass_liquidcave","SOLIDS","[box2d],[alchemy],[solid],[earth]",false}, - {"glass","SOLIDS","[static],[meltable_metal],[alchemy],[solid],[earth]",false}, - {"glass_broken","SANDS","[sand_other],[meltable_metal],[alchemy],[solid],[earth]",false}, - {"neon_tube_purple","SOLIDS","[static],[corrodible],[box2d],[alchemy],[solid],[earth]",false}, - {"neon_tube_cyan","SOLIDS","[static],[corrodible],[box2d],[alchemy],[solid],[earth]",false}, - {"neon_tube_blood_red","SOLIDS","[static],[corrodible],[box2d],[alchemy],[solid],[earth]",false}, - {"blood_thick","SANDS","[static],[corrodible],[blood],[food],[chaotic_transmutation]",true}, - {"snow_b2","SOLIDS","[box2d],[corrodible],[alchemy],[meltable_to_water],[solid]",false}, - {"fungal_shift_particle_fx","SPECIAL","",false}, -} -local names,ids={},{} -local xml={''} -for i,m in ipairs(definitions) do - names[i-1]=m[1]; ids[m[1]]=i-1 - -- Discovery fixture deliberately mixes parent/child elements and commented entries. - xml[#xml+1]='' -end -xml[#xml+1]='' -xml[#xml+1]='' -function ModTextFileGetContent(path) assert(path=='data/materials.xml'); return table.concat(xml,'\n') end -function CellFactory_GetType(name) return ids[name] or (name=='mod_custom_liquid' and 10000 or 0) end -function CellFactory_GetName(id) return names[id] or (id==10000 and 'mod_custom_liquid' or '') end -function CellFactory_GetUIName(id) return names[id] or 'custom' end -function CellFactory_GetTags(id) - local out={} - for tag in (definitions[id+1] and definitions[id+1][3] or '[liquid]'):gmatch('%[[^%]]+%]') do out[#out+1]=tag end - return out -end -local api={LIQUIDS='CellFactory_GetAllLiquids',SANDS='CellFactory_GetAllSands',GASES='CellFactory_GetAllGases',FIRES='CellFactory_GetAllFires',SOLIDS='CellFactory_GetAllSolids'} -for kind,name in pairs(api) do - _G[name]=function(statics) - local out={} - for i,m in ipairs(definitions) do - -- Deliberately omit water_static AND every fifth loaded definition from the API. - if m[1]~='water_static' and i%5~=0 and m[2]==kind and (statics~=false or not m[4]) then out[#out+1]=m[1] end - end - if kind=='LIQUIDS' then out[#out+1]='mod_custom_liquid' end - return out - end -end -METAMORPH_CREATIVE_MENU_MATERIAL_CATALOG=nil -local catalog=dofile(root..'/files/features/materials/catalog.lua') -for _,c in ipairs(catalog.categories()) do - local guard=0 - while not catalog.is_ready(c.id) do - local _,used=catalog.step(c.id,nil,24); assert(used<=24,'budget exceeded') - guard=guard+1;assert(guard<2000,'catalog did not finish') - end -end -local found={} -for _,entry in ipairs(catalog.entries_for('ALL')) do assert(not found[entry.id],'duplicate');found[entry.id]=true end -for _,m in ipairs(definitions) do assert(found[m[1]],'loaded material disappeared: '..m[1]) end -assert(found.mod_custom_liquid,'runtime mod material disappeared') -assert(not found.not_loaded and not found.commented_out,'invalid XML entry offered as air') -assert(#catalog.entries_for('ALL')==#definitions+1,'catalog completeness mismatch') -local water=assert(catalog.get('water_static'),'water_static missing') -assert(water.category=='STATIC' and water.categories.STATIC,'water_static misclassified') -local partition={} -for _,c in ipairs(catalog.categories()) do if c.id~='ALL' then - for _,entry in ipairs(catalog.entries_for(c.id)) do - assert(not partition[entry.id],'material in multiple categories: '..entry.id) - partition[entry.id]=true - end -end end -for name in pairs(found) do assert(partition[name],'material absent from all specific categories: '..name) end -print('material_complete_catalog=PASS vanilla_definitions='..#definitions..' mod_material=true omitted_api_recovered=true water_static=true unloaded_excluded=true') diff --git a/metamorph_creative_menu/tests/material_feature_wiring_mock.lua b/metamorph_creative_menu/tests/material_feature_wiring_mock.lua deleted file mode 100644 index 1169392..0000000 --- a/metamorph_creative_menu/tests/material_feature_wiring_mock.lua +++ /dev/null @@ -1,26 +0,0 @@ -local root=assert(arg[1], 'root required') -local function read(path) - local f=assert(io.open(root..'/'..path,'rb')); local s=f:read('*a'); f:close(); return s -end -local menu=read('files/ui/menu_controller.lua') -local init=read('init.lua') -local tab=read('files/ui/tabs/materials.lua') -local painter=read('files/features/materials/painter.lua') -local backend=read('files/platform/noita/material_grid.lua') -local catalog=read('files/features/materials/catalog.lua') -assert(string.find(menu,'id="materials"',1,true),'MATERIALS tab is not registered') -assert(string.find(init,'material_painter.update',1,true),'material painter lifecycle is not composed') -assert(string.find(tab,'painter.set_enabled(true)',1,true),'MATERIALS tab cannot arm closed-menu painting') -assert(string.find(tab,'bindings.label("paint_draw")',1,true),'MATERIALS tab does not show the configured paint input') -assert(string.find(painter,'action_bindings.is_down("paint_draw")',1,true),'paint stroke input is not assignable') -assert(string.find(tab,'material_catalog.step',1,true) and not string.find(tab,'material_catalog.collect',1,true), - 'MATERIALS tab still performs synchronous full catalog scan') -assert(string.find(catalog,'budget',1,true),'material catalog has no incremental work budget') -assert(string.find(painter,'grid_backend.paint_solid_scene_prepared',1,true),'solid brush is not routed separately') -assert(string.find(painter,'grid_backend.paint_cell_prepared',1,true),'liquid/direct cell path missing') -assert(not string.find(painter,'GameCreateParticle',1,true),'material-particle backend returned') -assert(not string.find(painter,'ComponentSetValue2',1,true),'painter still mutates player fire controls') -assert(string.find(backend,'construct_cell',1,true) and string.find(backend,'LoadPixelScene',1,true), - 'hybrid direct-cell/solid PixelScene backend is incomplete') -assert(not string.find(backend,'quant.ew',1,true),'material grid backend depends on EW') -io.write('material_feature_wiring=PASS lazy_catalog=true hybrid_backend=true assignable_input=true standalone=true\n') diff --git a/metamorph_creative_menu/tests/material_grid_backend_mock.lua b/metamorph_creative_menu/tests/material_grid_backend_mock.lua deleted file mode 100644 index 95597aa..0000000 --- a/metamorph_creative_menu/tests/material_grid_backend_mock.lua +++ /dev/null @@ -1,140 +0,0 @@ -local root=assert(arg[1], 'root required') -local native_dofile=dofile -local native_require=require -local bootstrap_options=nil -local patcher_bridge={get=function(options) bootstrap_options=options; return {GetWorldInfo=function() return {} end} end} -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua' then return patcher_bridge end - return native_dofile(path) -end - -local loaded=true -local fail_construct_id=nil -local remove_calls,construct_calls,events,scene_calls={},{},{},{} -local cells={} -local ids={air=0,water=1,oil=2,rock_static=3,ptrless_valid=4} -local material_ptrs={ - [1]={id=1,cell_type=1,liquid_static=false,solid_static_type=0,platform_type=0,cell_holes_in_texture=false}, - [2]={id=2,cell_type=1,liquid_static=false,solid_static_type=0,platform_type=0,cell_holes_in_texture=false}, - -- Noita's authored *_static terrain may report cell_type=liquid while still being - -- semantically solid/static. This fixture mirrors that shape instead of pretending - -- CellFactory_GetAllSolids implies CELL_TYPE_SOLID. - [3]={id=3,cell_type=1,liquid_static=true,solid_static_type=1,platform_type=1,cell_holes_in_texture=true}, -} -local world_lookup_count=0 -local function make_cell(id) - local cell={material={id=id}} - cell.vtable={get_material=function(self) return self.material end} - return cell -end -local chunk_map={} -local grid_world={vtable={get_chunk_map=function(self) return chunk_map end}} -local world_ffi={} -function world_ffi.get_grid_world() world_lookup_count=world_lookup_count+1; return grid_world end -function world_ffi.chunk_loaded(map,x,y) assert(map==chunk_map); return loaded end -function world_ffi.get_cell(map,x,y) - assert(map==chunk_map); local key=x..','..y - cells[key]=cells[key] or {[0]=nil}; return cells[key] -end -function world_ffi.get_material_id(material) return material.id end -function world_ffi.remove_cell(world,cell,x,y,flag) - assert(world==grid_world and flag==false) - remove_calls[#remove_calls+1]={cell=cell,x=x,y=y}; events[#events+1]='remove:'..x..','..y - cells[x..','..y][0]=nil -end -function world_ffi.get_material_ptr(id) return material_ptrs[id] end -function world_ffi.construct_cell(world,x,y,ptr,memory) - assert(world==grid_world and memory==nil) - construct_calls[#construct_calls+1]={x=x,y=y,id=ptr.id}; events[#events+1]='construct:'..x..','..y..':'..ptr.id - if ptr.id==fail_construct_id then return nil end - return make_cell(ptr.id) -end -require=function(name) if name=='noitapatcher.nsew.world_ffi' then return world_ffi end; return native_require(name) end -local compat_status="disabled" -function GlobalsGetValue(key,default) if key=="mcm_compat_material_scene_patch_v1" then return compat_status end; return default end -function GameGetFrameNum() return 100 end -function CellFactory_GetType(name) return ids[name] or -1 end -function DoesWorldExistAt() return loaded end -function LoadPixelScene(materials,colors,x,y,background,skip_biome,skip_edges,color_map,z,load_duplicate) - scene_calls[#scene_calls+1]={materials=materials,x=x,y=y,map=color_map,skip_biome=skip_biome,skip_edges=skip_edges,duplicate=load_duplicate} -end - -METAMORPH_CREATIVE_MENU_MATERIAL_GRID_BACKEND=nil -local backend=assert(native_dofile(root..'/files/platform/noita/material_grid.lua')) -local ok,reason=backend.status() -assert(ok and reason=='ready','grid backend did not initialize') -assert(type(bootstrap_options)=='table' and bootstrap_options.bootstrap_if_installed==true and bootstrap_options.capability=='GetWorldInfo', - 'grid backend does not bootstrap standalone NoitaPatcher through patcher_bridge') - -local context=assert(backend.begin_paint('water')) -assert(context.mode=='direct_cell','liquid incorrectly routed away from direct cell backend') -assert(world_lookup_count==1,'begin_paint did not acquire one frame-local world context') -ok,reason=backend.paint_cell_prepared(context,10.8,20.9) -assert(ok and reason=='painted','empty liquid cell was not painted') -assert(#construct_calls==1 and construct_calls[1].x==10 and construct_calls[1].y==20 and construct_calls[1].id==1,'wrong constructed cell') -assert(cells['10,20'][0] and cells['10,20'][0].material.id==1,'constructed cell not assigned') - --- Same material does not churn native cells. -local constructs_before=#construct_calls -ok,reason=backend.paint_cell_prepared(context,10,20) -assert(ok and reason=='unchanged' and #construct_calls==constructs_before,'same material reconstructed') - --- Textured/static solid is deliberately routed through PixelScene, not construct_cell. -context=assert(backend.begin_paint('rock_static')) -assert(context.mode=='solid_scene','static terrain material was not detected from CellData semantic flags') -local before_construct=#construct_calls -ok,reason=backend.paint_cell_prepared(context,10,20) -assert(not ok and reason=='solid_scene_required' and #construct_calls==before_construct,'solid attempted unsafe direct construct') -ok,reason=backend.paint_solid_scene_prepared(context,'mods/test/brush.png',8,18,'ff5ac75a') -assert(ok and reason=='painted' and #scene_calls==1,'solid PixelScene placement failed') -assert(scene_calls[1].map.ff5ac75a=='rock_static' and scene_calls[1].skip_biome==true - and scene_calls[1].skip_edges==false and scene_calls[1].duplicate==true,'solid PixelScene options changed') - --- A material known to CellFactory can lack a constructible native pointer. It remains --- paintable through Noita's authored PixelScene path instead of disabling the brush. -local ptrless=assert(backend.begin_paint('ptrless_valid')) -assert(ptrless.mode=='solid_scene' and ptrless.material_ptr==nil, - 'valid pointerless material did not select the safe scene path') -ok,reason=backend.paint_solid_scene_prepared(ptrless,'mods/test/brush.png',8,18,'ff5ac75a') -assert(ok and reason=='painted' and #scene_calls==2 - and scene_calls[2].map.ff5ac75a=='ptrless_valid','pointerless material was not painted') - --- If MCM cannot install its narrow EW PixelScene guard, solid painting fails closed --- instead of sending an invalid MCM-owned mask through stock EW. SP uses disabled. -compat_status='anchor_mismatch' -local scenes_before_failure=#scene_calls -ok,reason=backend.paint_solid_scene_prepared(context,'mods/test/brush.png',8,18,'ff5ac75a') -assert(not ok and reason=='ew_material_scene_patch' and #scene_calls==scenes_before_failure,'unsafe EW PixelScene fallback was not blocked') -compat_status='applied' - --- Failed liquid replacement still restores previous material. -context=assert(backend.begin_paint('oil')) --- Put water back at a direct-cell location. -cells['30,40']={[0]=make_cell(1)} -fail_construct_id=2 -local event_before=#events -ok,reason=backend.paint_cell_prepared(context,30,40) -assert(not ok and reason=='construct','forced liquid construct failure was not reported') -assert(events[event_before+1]=='remove:30,40' and events[event_before+2]=='construct:30,40:2' - and events[event_before+3]=='construct:30,40:1','failed replacement did not rollback previous material') -assert(cells['30,40'][0] and cells['30,40'][0].material.id==1,'failed replacement destroyed previous terrain') -fail_construct_id=nil - --- PixelScene is the general authored-material fallback, not a solid-only primitive. -local generic_scenes_before=#scene_calls -ok,reason=backend.paint_scene_prepared(context,'mods/test/brush.png',28,38,'ff5ac75a') -assert(ok and reason=='painted' and #scene_calls==generic_scenes_before+1 - and scene_calls[#scene_calls].map.ff5ac75a=='oil', - 'generic PixelScene fallback rejected a valid dynamic material') - -loaded=false; context=assert(backend.begin_paint('water')) -local removes_before=#remove_calls; constructs_before=#construct_calls -ok,reason=backend.paint_cell_prepared(context,50,60) -assert(not ok and reason=='unloaded','unloaded direct-cell chunk was not rejected') -assert(#remove_calls==removes_before and #construct_calls==constructs_before,'unloaded world was mutated') -local solid=assert(backend.begin_paint('rock_static')) -ok,reason=backend.paint_solid_scene_prepared(solid,'mods/test/brush.png',50,60,'ff5ac75a') -assert(not ok and reason=='unloaded' and #scene_calls==3,'unloaded solid scene was written') - -assert(backend.begin_paint('missing')==nil,'invalid material was accepted') -io.write('material_grid_backend=PASS direct=true generic_scene_fallback=true rollback=true unloaded_safe=true\n') diff --git a/metamorph_creative_menu/tests/material_paint_transport_mock.lua b/metamorph_creative_menu/tests/material_paint_transport_mock.lua deleted file mode 100644 index 5dcf567..0000000 --- a/metamorph_creative_menu/tests/material_paint_transport_mock.lua +++ /dev/null @@ -1,54 +0,0 @@ -local root=assert(arg[1],"root required") -local values={} - -function GlobalsGetValue(key,default) - local value=values[key] - if value==nil or value=="" then return default end - return value -end -function GlobalsSetValue(key,value) values[key]=tostring(value) end - -local ew_enabled=true -function ModIsEnabled(name) return name=="quant.ew" and ew_enabled end - -METAMORPH_CREATIVE_MENU_EW_MATERIAL_PAINT_SYNC=nil -local sync=assert(loadfile(root.."/files/integrations/ew/material_paint_sync.lua"))() -assert(sync.capacity()==32 and sync.max_points()==24,"bounded transport constants changed") -assert(sync.publish_batch("bad|material",false,1,{{1,2}})==false,"unsafe material identifier accepted") -assert(sync.publish_batch("water",false,1,{})==false,"empty point batch accepted") -assert(sync.publish_batch("water",false,1,{{0/0,2}})==false,"non-finite coordinate accepted") - -assert(sync.publish_batch("water",false,3,{{12,-7},{13,-6}})==true,"valid paint batch rejected") -assert(sync.outbox_sequence()==1,"first sequence was not published") -local payload=assert(sync.read_outbox(1),"first outbox slot missing") -local decoded=assert(sync.decode_batch(payload),"valid payload did not decode") -assert(decoded.material_name=="water" and decoded.solid==false and decoded.brush_index==3,"payload metadata changed") -assert(decoded.points[1][1]==12 and decoded.points[1][2]==-7 and #decoded.points==2,"payload coordinates changed") - -sync.mark_ack(1) -assert(sync.ack_sequence()==1,"acknowledge did not advance") -sync.mark_ack(0) -assert(sync.ack_sequence()==1,"acknowledge moved backwards") - -for i=2,sync.capacity()+6 do - assert(sync.publish_batch("rock_static",true,1,{{i,-i}})==true,"ring publish failed at "..i) -end -local newest=sync.outbox_sequence() -local oldest=select(1,sync.first_available_sequence(0)) -assert(oldest==newest-sync.capacity()+1,"first available ring sequence is wrong") -assert(sync.read_outbox(oldest-1)==nil,"overwritten ring slot was exposed as current") -local newest_payload=assert(sync.read_outbox(newest),"newest ring slot missing") -sync.clear_outbox(newest-sync.capacity()) -assert(sync.read_outbox(newest)==newest_payload,"clearing stale sequence erased aliased current slot") -sync.clear_outbox(newest) -assert(sync.read_outbox(newest)==nil,"current ring slot did not clear") - -local slot_keys=0 -for key in pairs(values) do - if string.find(key,"mcm_material_paint_outbox_slot_v3:",1,true)==1 then slot_keys=slot_keys+1 end -end -assert(slot_keys<=sync.capacity(),"transport created unbounded per-frame Global keys") - -ew_enabled=false -assert(sync.publish_batch("water",false,1,{{1,1}})==false,"standalone mode wrote EW mailbox") -io.write("material_paint_transport=PASS bounded_ring=true validation=true standalone_noop=true\n") diff --git a/metamorph_creative_menu/tests/material_painter_mock.lua b/metamorph_creative_menu/tests/material_painter_mock.lua deleted file mode 100644 index e3e45a5..0000000 --- a/metamorph_creative_menu/tests/material_painter_mock.lua +++ /dev/null @@ -1,184 +0,0 @@ -local root=assert(arg[1], 'root required') -local native_dofile=dofile -local actions_allowed=true -local buttons -local input_guard={actions_allowed=function() return actions_allowed end} -local action_bindings={ - get=function(id) assert(id=='paint_draw'); return 'Mouse:3' end, - is_down=function(id) assert(id=='paint_draw'); return buttons and buttons[3]==true end, -} -local grid_calls={} -local solid_calls={} -local generic_scene_calls={} -local sync_calls={} -local begin_calls=0 -local last_begin_options=nil -local backend_ready=true -local direct_construct_fails=false -local ids={water=1,oil=2,rock_static=3} -local grid_backend={ - status=function() return backend_ready, backend_ready and 'ready' or 'noitapatcher' end, - material_id=function(name) return ids[name] end, - begin_paint=function(material,options) - begin_calls=begin_calls+1 - last_begin_options=options - local force_scene=type(options)=='table' and options.force_scene==true - return {material=material,mode=(force_scene or material=='rock_static') and 'solid_scene' or 'direct_cell'},'ready' - end, - paint_cell_prepared=function(context,x,y) - assert(context.mode=='direct_cell','solid leaked into direct cell painter') - grid_calls[#grid_calls+1]={material=context.material,x=x,y=y} - if direct_construct_fails then return false,'construct' end - return true,'painted' - end, - paint_scene_prepared=function(context,file,x,y,color) - assert(context.mode=='direct_cell','generic fallback did not preserve direct material context') - generic_scene_calls[#generic_scene_calls+1]={material=context.material,file=file,x=x,y=y,color=color} - return true,'painted' - end, - paint_solid_scene_prepared=function(context,file,x,y,color) - assert(context.mode=='solid_scene','non-solid leaked into PixelScene painter') - solid_calls[#solid_calls+1]={material=context.material,file=file,x=x,y=y,color=color} - return true,'painted' - end, -} -local material_sync={ - publish_batch=function(material,solid,brush,points) - sync_calls[#sync_calls+1]={material=material,solid=solid,brush=brush,points=points} - return true - end, -} -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/platform/noita/input_guard.lua' then return input_guard end - if path=='mods/metamorph_creative_menu/files/platform/noita/action_bindings.lua' then return action_bindings end - if path=='mods/metamorph_creative_menu/files/platform/noita/material_grid.lua' then return grid_backend end - if path=='mods/metamorph_creative_menu/files/integrations/ew/material_paint_sync.lua' then return material_sync end - return native_dofile(path) -end - -Mouse_middle=3 -buttons={[1]=false,[3]=false} -local mouse_x,mouse_y=100,200 -local inventory_is_open=true -local game_prints={} -function InputIsMouseButtonDown(button) - assert(button==3,'material painter must consume only middle mouse') - return buttons[button]==true -end -function DEBUG_GetMouseWorld() return mouse_x,mouse_y end -function GameIsInventoryOpen() return inventory_is_open end -function EntityGetIsAlive(id) return id==77 end -function GameGetFrameNum() return 100 end -function GamePrint(text) game_prints[#game_prints+1]=text end -function CellFactory_GetType(name) return ids[name] or -1 end -function EntityGetFirstComponentIncludingDisabled() error('material painter must not mutate ControlsComponent') end -function ComponentSetValue2() error('material painter must not mutate ControlsComponent') end - -METAMORPH_CREATIVE_MENU_MATERIAL_PAINTER=nil -local painter=assert(native_dofile(root..'/files/features/materials/painter.lua')) -assert(painter.paint_button()==3,'middle mouse constant not resolved') -assert(painter.set_material('missing')==false,'invalid material accepted') -assert(painter.set_material('rock_static')==true,'valid solid material rejected') -assert(painter.brush_diameter()==5,'default brush size changed unexpectedly') - -backend_ready=false -local enabled,reason=painter.set_enabled(true) -assert(enabled==false and reason=='noitapatcher' and painter.is_enabled()==false,'unavailable backend armed painter') -backend_ready=true -assert(painter.set_enabled(true)==true and painter.is_enabled()==true,'paint mode did not arm') - -local painted -painted,reason=painter.update(77) -assert(painted==false and reason=='armed_in_menu','menu-open armed state painted world') -inventory_is_open=false -buttons[1]=true -painted,reason=painter.update(77) -assert(painted==false and reason=='idle','LMB incorrectly drives material brush') - --- Solid uses one PixelScene stamp, not per-cell native construction. -buttons[3]=true -painted,reason=painter.update(77) -assert(painted==true and reason=='painted','MMB solid press did not paint') -assert(#solid_calls==1 and #grid_calls==0,'solid brush did not use dedicated PixelScene path') -assert(solid_calls[1].material=='rock_static' and solid_calls[1].color=='ff5ac75a','solid material/mask changed') -assert(solid_calls[1].x==98 and solid_calls[1].y==198,'solid mask was not centered on cursor') -assert(#sync_calls==1 and sync_calls[1].material=='rock_static' and sync_calls[1].solid==true, - 'actual solid backend mode was not published to EW adapter') - --- Continuous solid movement is interpolated by stamp count. -mouse_x=110; mouse_y=200 -painter.update(77) -assert(#solid_calls>1,'solid continuous stroke did not interpolate movement') - --- Liquid retains the direct-cell backend that worked in-game. -buttons[3]=false; painter.update(77) -assert(painter.set_material('water')==true,'liquid selection failed') -buttons[3]=true; mouse_x,mouse_y=120,210 -local before_liquid=#grid_calls -painter.update(77) -assert(#grid_calls-before_liquid==13,'5px circular liquid brush should write 13 direct cells') -for i=before_liquid+1,#grid_calls do assert(grid_calls[i].material=='water','liquid path changed material') end -assert(sync_calls[#sync_calls].material=='water' and sync_calls[#sync_calls].solid==false, - 'liquid backend mode was not published to EW adapter') -assert(begin_calls>=3,'paint context was not acquired per painted frame') - --- A cursor jump larger than the per-frame budget must be continued on later frames, --- never skipped. Radius zero makes every missing interpolation point observable. -buttons[3]=false; painter.update(77) -assert(painter.set_material('water')==true) -painter.set_brush_index(1) -buttons[3]=true; mouse_x,mouse_y=200,220 -painter.update(77) -local moving_sync_start=#sync_calls -mouse_x=240 -for _=1,5 do painter.update(77) end -local expected_x=201 -for i=moving_sync_start+1,#sync_calls do - for _,point in ipairs(sync_calls[i].points) do - assert(point[1]==expected_x and point[2]==220, - 'bounded moving stroke skipped or reordered point '..tostring(expected_x)) - expected_x=expected_x+1 - end -end -assert(expected_x==241,'moving stroke did not eventually reach the live cursor') - --- construct_cell can legitimately refuse a valid dynamic material at authored texture --- coordinates. The painter must fall back to a one-stamp PixelScene placement and still --- publish the operation as dynamic terrain. -buttons[3]=false; painter.update(77) -assert(painter.set_material('oil')==true) -direct_construct_fails=true -buttons[3]=true; mouse_x,mouse_y=300,230 -painted,reason=painter.update(77) -direct_construct_fails=false -assert(painted and reason=='painted' and #generic_scene_calls==1, - 'valid dynamic material was lost when direct construction failed') -assert(generic_scene_calls[1].material=='oil' and sync_calls[#sync_calls].material=='oil' - and sync_calls[#sync_calls].solid==false,'dynamic PixelScene fallback changed sync semantics') - - --- Public CellFactory SOLIDS membership is forwarded as an explicit semantic hint. This --- covers authored terrain that reports an internal liquid cell_type (e.g. *_static). -buttons[3]=false; painter.update(77) -assert(painter.set_material('oil',{solid=true})==true,'semantic solid selection failed') -buttons[3]=true; mouse_x,mouse_y=125,215 -local semantic_solid_before=#solid_calls -painter.update(77) -assert(type(last_begin_options)=='table' and last_begin_options.force_scene==true,'SOLIDS membership was not forwarded to backend') -assert(#solid_calls>semantic_solid_before and solid_calls[#solid_calls].material=='oil','semantic solid did not use PixelScene path') - -actions_allowed=false; buttons[3]=true -local before_block=#grid_calls; local before_solid=#solid_calls -painted,reason=painter.update(77) -assert(not painted and reason=='input_blocked' and #grid_calls==before_block and #solid_calls==before_solid and painter.is_enabled(), - 'input quarantine did not safely pause painter') -actions_allowed=true - -buttons[3]=false; inventory_is_open=true -painted,reason=painter.update(77) -assert(not painted and reason=='menu_reopened' and painter.is_enabled()==false,'reopening inventory did not disarm paint mode') -inventory_is_open=true; assert(painter.set_enabled(true)==true) -painted,reason=painter.update(0) -assert(not painted and reason=='player' and painter.is_enabled()==false,'invalid player left painter armed') - -io.write('material_painter=PASS materials_fallback=true moving_continuous=true bounded=true lifecycle=true\n') diff --git a/metamorph_creative_menu/tests/material_tab_deferred_catalog_mock.lua b/metamorph_creative_menu/tests/material_tab_deferred_catalog_mock.lua deleted file mode 100644 index f81cda2..0000000 --- a/metamorph_creative_menu/tests/material_tab_deferred_catalog_mock.lua +++ /dev/null @@ -1,98 +0,0 @@ -local root=assert(arg[1], 'root required') -local native_dofile=dofile -local step_calls=0 -local catalog={ - categories=function() - return { - {id='ALL',key='all',fallback='ALL'}, - {id='LIQUIDS',key='liquids',fallback='LIQUIDS'}, - {id='SANDS',key='sands',fallback='SANDS'}, - } - end, - is_ready=function() return false end, - step=function(category,translate,budget) - assert(category=='LIQUIDS','first category changed unexpectedly') - assert(type(translate)=='function','translation callback missing') - assert(budget>0 and budget<=24,'catalog per-frame budget is no longer bounded') - step_calls=step_calls+1 - return false,0 - end, - entries_for=function() return {} end, - get=function() return nil end, -} -local painter={ - get_material=function() return 'water' end, - brush_diameter=function() return 5 end, - adjust_brush=function() end, - is_enabled=function() return false end, - set_enabled=function() return true,'enabled' end, - set_material=function() return true end, -} -local material_preview={ - new_liquid_warmup=function() return {} end, - warm_liquid_colors=function() return true end, - texture=function() return nil end, - tint=function() return nil end, - liquid_icon=function() return 'data/items_gfx/potion.png' end, - liquid_mask=function() return 'data/items_gfx/flask_liquid.png' end, - liquid_offset=function() return -1,0 end, - liquid_color=function() return nil end, -} -local ui={ - audit=function() end, gui=function() return {} end, - tr=function(_,fallback) return fallback end, - translated=function(v) return v end, - white_text=function() end, button=function() return false end, - wrapped_text=function() end, - button_grid=function() return nil end, - stepper=function() return 0 end, - search_input=function(v) return v end, - search_status=function() end, - columns=function() return 4 end, scroll_height=function() return 100 end, - mark_hovered=function() end, - begin_scroll_viewport=function(_,_,_,_,width,height) return {content_width=math.max(20,(tonumber(width) or 100)-12),padding_left=2,padding_top=2,state={offset=0},height=height} end, - scroll_y=function(_,y) return y end, end_scroll_viewport=function() end, tile=function() return false end, - matches_search=function() return false end, - EMPTY_SLOT='slot', ICON_STEP=20, -} -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/ui/runtime.lua' then return ui end - if path=='mods/metamorph_creative_menu/files/features/materials/catalog.lua' then return catalog end - if path=='mods/metamorph_creative_menu/files/features/materials/painter.lua' then return painter end - if path=='mods/metamorph_creative_menu/files/platform/noita/action_bindings.lua' then return {label=function() return 'MOUSE MIDDLE' end} end - if path=='mods/metamorph_creative_menu/files/platform/noita/material_preview.lua' then return material_preview end - return native_dofile(path) -end -GuiLayoutBeginVertical=function() end -GuiLayoutEnd=function() end -GuiLayoutBeginHorizontal=function() end -GuiBeginScrollContainer=function() end -GuiEndScrollContainer=function() end -GuiGetPreviousWidgetInfo=function() return 0,0,false end -local tab=assert(native_dofile(root..'/files/ui/tabs/materials.lua')) --- Critical regression: entering the tab must not enumerate CellFactory on that same --- GUI frame. EW networking/inventory work is also concentrated on the open frame. -tab.draw(1,220,720) -assert(step_calls==0,'MATERIALS tab performs catalog work on its opening frame') -tab.draw(1,220,720) -assert(step_calls==1,'MATERIALS catalog did not resume incrementally after deferred frame') -print('material_tab_deferred_catalog=PASS open_frame_work=0 incremental_after=true') - --- Search from the default LIQUIDS category must still find static paint materials. -local requested,selected,selected_solid,rendered=nil,nil,nil,{} -local query="water_static" -local static={id="water_static",display_name="Static water",category="STATIC",categories={STATIC=true}} -catalog.is_ready=function() return true end -catalog.step=function(category,_,budget) requested=category;assert(budget<=24);return true end -catalog.entries_for=function(category) return category=="ALL" and {static} or {} end -ui.search_input=function() return query end -ui.rank_entries=function(_,values) return values end -ui.tile=function(_,_,_,_,_,name) rendered[#rendered+1]=name;return true end -painter.material_color=function() return {1,1,1,1} end -painter.set_material=function(id,options) selected=id;selected_solid=options.solid;return true end -for i=1,3 do tab.draw(1,220,720) end -assert(requested=="ALL" and #rendered>0,"search still restricted to LIQUIDS") -assert(selected=="water_static" and selected_solid,"static water search result cannot be selected for painting") -query=""; for i=1,3 do tab.draw(1,220,720) end -assert(requested=="LIQUIDS","clearing search did not restore chosen category") -print('material_search_all=PASS default_liquids=true water_static_paintable=true category_restored=true') diff --git a/metamorph_creative_menu/tests/material_tab_preview_mock.lua b/metamorph_creative_menu/tests/material_tab_preview_mock.lua deleted file mode 100644 index 1d55700..0000000 --- a/metamorph_creative_menu/tests/material_tab_preview_mock.lua +++ /dev/null @@ -1,128 +0,0 @@ -local root = assert(arg[1], "root required") -local native_dofile = dofile -local selected_category_once = false -local tiles = {} -local liquid_warmups = 0 - -local water = { - id="water", display_name="Water", category="LIQUIDS", - categories={LIQUIDS=true}, -} -local stone = { - id="stone", display_name="Stone", category="SOLIDS", - categories={SOLIDS=true}, -} -local catalog = { - categories=function() - return { - {id="ALL",key="all",fallback="ALL"}, - {id="LIQUIDS",key="liquids",fallback="LIQUIDS"}, - {id="SOLIDS",key="solids",fallback="SOLIDS"}, - } - end, - is_ready=function() return true end, - step=function() return true end, - entries_for=function(category) - if category == "LIQUIDS" then return {water} end - if category == "SOLIDS" then return {stone} end - return {water,stone} - end, - get=function(id) return id == "water" and water or stone end, -} -local painter = { - get_material=function() return "water" end, - brush_diameter=function() return 5 end, - adjust_brush=function() end, - is_enabled=function() return false end, - set_enabled=function() return true end, - set_material=function() return true end, - material_color=function() error("authored texture unexpectedly used a colour fallback") end, -} -local liquid_color = {0.1,0.2,0.3,1} -local texture_tint = {0.4,0.5,0.6,1} -local material_preview = { - new_liquid_warmup=function() return {} end, - warm_liquid_colors=function(_, entries) - if #entries > 0 then - liquid_warmups = liquid_warmups + 1 - assert(#entries == 1 and entries[1].id == "water", - "materials tab did not pass its liquid entries to the shared preview service") - end - return true - end, - texture=function(id) return id == "stone" and "data/materials_gfx/stone.png" or nil end, - tint=function(id) return id == "stone" and texture_tint or nil end, - liquid_icon=function() return "data/ui_gfx/items/potion.png" end, - liquid_color=function(id) return id == "water" and liquid_color or nil end, -} -local ui = { - EMPTY_SLOT="slot", ICON_STEP=20, - audit=function() end, - gui=function() return {} end, - tr=function(_,fallback) return fallback end, - translated=function(value) return value end, - white_text=function() end, - wrapped_text=function() end, - button=function() return false end, - button_grid=function() - if not selected_category_once then selected_category_once=true; return 3 end - return nil - end, - stepper=function() return 0 end, - search_input=function(value) return value end, - search_status=function() end, - columns=function() return 4 end, - scroll_height=function() return 200 end, - mark_hovered=function() end, - begin_scroll_viewport=function(_,_,_,_,width,height) return {content_width=math.max(20,(tonumber(width) or 100)-12),padding_left=2,padding_top=2,state={offset=0},height=height} end, - scroll_y=function(_,y) return y end, end_scroll_viewport=function() end, - matches_search=function() return true end, - tile=function(_,_,background,icon,fallback,title,description,selected,options) - tiles[#tiles+1] = { - background=background, icon=icon, fallback=fallback, title=title, - description=description, selected=selected, options=options, - } - return false,false - end, -} - -dofile = function(path) - if path == "mods/metamorph_creative_menu/files/ui/runtime.lua" then return ui end - if path == "mods/metamorph_creative_menu/files/features/materials/catalog.lua" then return catalog end - if path == "mods/metamorph_creative_menu/files/features/materials/painter.lua" then return painter end - if path == "mods/metamorph_creative_menu/files/platform/noita/action_bindings.lua" then - return {label=function() return "MOUSE MIDDLE" end} - end - if path == "mods/metamorph_creative_menu/files/platform/noita/material_preview.lua" then - return material_preview - end - return native_dofile(path) -end - -GuiLayoutBeginVertical=function() end -GuiLayoutEnd=function() end -GuiLayoutBeginHorizontal=function() end -GuiBeginScrollContainer=function() end -GuiEndScrollContainer=function() end -GuiGetPreviousWidgetInfo=function() return 0,0,false end - -local tab = assert(native_dofile(root .. "/files/ui/tabs/materials.lua")) -tab.draw(1,260,500) -assert(#tiles == 1, "liquid preview tile was not drawn") -assert(tiles[1].icon == "data/ui_gfx/items/potion.png", "liquid did not use the vanilla inventory flask") -assert(tiles[1].options.icon_tint == liquid_color, - "vanilla flask did not use the engine-sampled potion tint") -assert(tiles[1].options.liquid_mask == nil and tiles[1].options.liquid_offset_x == nil, - "physical-world flask mask leaked into inventory presentation") -assert(tiles[1].options.bottle_fill_color == nil, - "liquid retained the old rectangular bottle patch") -assert(tiles[1].options.swatch_color == nil, "liquid leaked the old generic swatch preview") - -tab.draw(1,260,500) -assert(#tiles == 2, "authored solid preview tile was not drawn") -assert(tiles[2].icon == "data/materials_gfx/stone.png", "solid did not use its authored material texture") -assert(tiles[2].options.icon_tint == texture_tint, "solid did not use its authored texture tint") -assert(tiles[2].options.swatch_color == nil, "authored texture was replaced by a random colour") -assert(liquid_warmups >= 1, "shared liquid preview cache was not warmed") - -print("material_tab_preview=PASS vanilla_inventory_bottle=true engine_tint=true shared_liquid_display=true authored_texture=true") diff --git a/metamorph_creative_menu/tests/material_vanilla_data_mock.lua b/metamorph_creative_menu/tests/material_vanilla_data_mock.lua deleted file mode 100644 index 1b2a9ce..0000000 --- a/metamorph_creative_menu/tests/material_vanilla_data_mock.lua +++ /dev/null @@ -1,275 +0,0 @@ --- Fixture: root CellData attributes from the user-supplied data(10).zip. --- Child inheritance is excluded so expectations do not assume engine merge behavior. -local root=assert(arg[1], 'root required') -local materials={ - {"fire","FIRES","[fire],[hot]",false}, - {"spark","FIRES","[fire],[hot]",false}, - {"spark_electric","FIRES","[fire],[hot]",false}, - {"flame","FIRES","[fire],[hot]",false}, - {"sand_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"nest_static","SANDS","[static],[corrodible],[burnable],[alchemy],[solid],[earth]",true}, - {"bluefungi_static","SANDS","[static],[corrodible],[burnable],[alchemy],[solid],[earth],[fungus]",true}, - {"rock_static","SANDS","[static],[corrodible],[meltable_to_lava],[alchemy],[solid],[earth]",true}, - {"water_static","LIQUIDS","[static]",true}, - {"endslime_static","LIQUIDS","[static],[alchemy],[solid],[earth]",true}, - {"slime_static","LIQUIDS","[static],[alchemy],[solid],[earth],[food]",true}, - {"spore_pod_stalk","LIQUIDS","[static],[alchemy],[solid],[burnable],[earth],[fungus]",true}, - {"lavarock_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"static_magic_material","SANDS","[static],[corrodible],[alchemy],[solid]",true}, - {"meteorite_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"templerock_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"steel_static","SANDS","[static],[corrodible],[rust],[alchemy],[meltable_metal_generic],[solid],[earth]",true}, - {"rock_static_glow","SANDS","[static],[corrodible],[meltable_to_lava],[alchemy],[solid],[earth]",true}, - {"snow_static","SANDS","[static],[corrodible],[frozen],[meltable_to_water],[alchemy],[solid]",true}, - {"ice_static","SANDS","[static],[corrodible],[frozen],[meltable_to_water],[alchemy],[solid]",true}, - {"ice_acid_static","SANDS","[static],[corrodible],[frozen],[meltable_to_acid],[alchemy],[solid]",true}, - {"ice_cold_static","SANDS","[static],[corrodible],[frozen],[meltable_to_cold],[alchemy],[solid]",true}, - {"ice_radioactive_static","SANDS","[static],[corrodible],[frozen],[meltable_to_radioactive],[alchemy],[solid]",true}, - {"ice_poison_static","SANDS","[static],[corrodible],[frozen],[meltable_to_poison],[alchemy],[solid]",true}, - {"ice_meteor_static","SANDS","[static],[corrodible],[frozen],[meltable_to_lava],[alchemy],[solid],[earth]",true}, - {"tubematerial","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"glass_static","SANDS","[static],[alchemy],[solid],[earth]",true}, - {"waterrock","SOLIDS","[static],[alchemy],[solid],[earth]",false}, - {"ice_glass","SOLIDS","[box2d],[static],[meltable_to_water],[alchemy],[solid],[cold]",false}, - {"ice_glass_b2","SOLIDS","[static],[meltable_to_water],[alchemy],[solid],[cold]",false}, - {"glass_brittle","SOLIDS","[static],[alchemy],[solid],[earth]",false}, - {"snowrock_static","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"concrete_static","SANDS","[static],[corrodible],[meltable_to_lava],[alchemy],[solid],[earth]",true}, - {"wood_static","SANDS","[corrodible],[burnable],[alchemy],[solid],[earth]",true}, - {"cheese_static","SANDS","[static],[corrodible],[meltable_to_lava],[alchemy],[solid],[earth]",true}, - {"smoke","GASES","[gas]",false}, - {"cloud","GASES","[gas]",true}, - {"cloud_lighter","GASES","[gas]",true}, - {"smoke_explosion","GASES","[gas]",false}, - {"steam","GASES","[gas]",false}, - {"acid_gas","GASES","[gas],[burnable_fast]",false}, - {"acid_gas_static","GASES","[gas],[burnable_fast]",true}, - {"smoke_static","GASES","[gas]",true}, - {"blood_cold_vapour","GASES","[gas],[vapour],[cold]",false}, - {"sand_herb_vapour","GASES","[gas],[vapour]",false}, - {"radioactive_gas","GASES","[gas]",false}, - {"radioactive_gas_static","GASES","[gas]",true}, - {"magic_gas_hp_regeneration","GASES","[gas],[vapour],[regenerative_gas]",false}, - {"magic_gas_midas","GASES","[gas],[vapour]",false}, - {"magic_gas_worm_blood","GASES","[gas],[vapour]",false}, - {"rainbow_gas","GASES","[gas]",false}, - {"magic_gas_polymorph","GASES","[gas],[vapour],[alchemy]",false}, - {"magic_gas_weakness","GASES","[gas],[vapour],[alchemy]",false}, - {"magic_gas_teleport","GASES","[gas],[vapour],[alchemy]",false}, - {"magic_gas_fungus","GASES","[gas],[vapour],[alchemy]",false}, - {"water","LIQUIDS","[liquid],[corrodible],[freezable],[water],[liquid_common],[chaotic_transmutation]",false}, - {"water_temp","LIQUIDS","[liquid],[corrodible],[freezable],[water],[liquid_common]",false}, - {"water_ice","LIQUIDS","[liquid],[corrodible],[meltable_to_water],[freezable],[water],[liquid_common],[chaotic_transmutation]",false}, - {"water_swamp","LIQUIDS","[liquid],[corrodible],[meltable_to_water],[water],[impure],[liquid_common]",false}, - {"oil","LIQUIDS","[liquid],[burnable],[impure],[liquid_common],[chaotic_transmutation]",false}, - {"alcohol","LIQUIDS","[liquid],[water],[impure],[liquid_common],[chaotic_transmutation]",false}, - {"beer","LIQUIDS","[liquid],[water],[impure],[liquid_common]",false}, - {"milk","LIQUIDS","[liquid],[water],[impure],[liquid_common]",false}, - {"molut","LIQUIDS","[liquid],[water],[impure],[liquid_common]",false}, - {"sima","LIQUIDS","[liquid],[water],[impure],[liquid_common]",false}, - {"juhannussima","LIQUIDS","[liquid],[water],[impure],[liquid_common]",false}, - {"alcohol_gas","GASES","[gas],[burnable],[evaporable]",false}, - {"magic_liquid","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"material_confusion","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"material_darkness","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"material_rainbow","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"magic_liquid_weakness","LIQUIDS","[liquid],[water],[magic_liquid],[impure],[magic_faster]",false}, - {"magic_liquid_movement_faster","LIQUIDS","[liquid],[water],[magic_liquid],[impure],[magic_faster]",false}, - {"magic_liquid_faster_levitation","LIQUIDS","[liquid],[water],[magic_liquid],[impure],[magic_faster]",false}, - {"magic_liquid_faster_levitation_and_movement","LIQUIDS","[liquid],[water],[magic_liquid],[impure],[magic_faster]",false}, - {"magic_liquid_worm_attractor","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"magic_liquid_protection_all","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"magic_liquid_mana_regeneration","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"magic_liquid_unstable_teleportation","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"magic_liquid_teleportation","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"magic_liquid_hp_regeneration","LIQUIDS","[liquid],[water],[magic_liquid],[regenerative]",false}, - {"magic_liquid_polymorph","LIQUIDS","[liquid],[water],[magic_liquid],[magic_polymorph],[impure]",false}, - {"magic_liquid_random_polymorph","LIQUIDS","[liquid],[water],[magic_liquid],[magic_polymorph],[impure]",false}, - {"magic_liquid_unstable_polymorph","LIQUIDS","[liquid],[water],[magic_liquid],[magic_polymorph],[impure]",false}, - {"magic_liquid_berserk","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"magic_liquid_charm","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"magic_liquid_invisibility","LIQUIDS","[liquid],[water],[magic_liquid],[impure]",false}, - {"cloud_radioactive","LIQUIDS","[liquid],[radioactive],[impure],[liquid_common]",false}, - {"cloud_blood","LIQUIDS","[liquid],[blood],[impure],[liquid_common]",false}, - {"cloud_slime","LIQUIDS","[liquid],[impure],[slime],[liquid_common]",false}, - {"swamp","LIQUIDS","[liquid],[corrodible],[soluble],[meltable_to_water],[freezable],[water],[impure],[liquid_common],[chaotic_transmutation]",false}, - {"mud","SANDS","[sand_ground],[corrodible],[meltable_to_lava],[impure]",false}, - {"blood","LIQUIDS","[liquid],[corrodible],[soluble],[blood],[impure],[liquid_common],[food],[chaotic_transmutation]",false}, - {"blood_fading","LIQUIDS","[liquid],[corrodible],[soluble],[evaporable_fast],[blood],[impure],[liquid_common],[food]",false}, - {"blood_fungi","LIQUIDS","[liquid],[corrodible],[soluble],[evaporable],[blood],[impure],[liquid_common],[food],[fungus],[chaotic_transmutation]",false}, - {"blood_worm","LIQUIDS","[liquid],[corrodible],[soluble],[blood],[impure],[liquid_common],[food],[chaotic_transmutation]",false}, - {"porridge","LIQUIDS","[liquid],[corrodible],[soluble],[impure],[liquid_common],[evaporable_by_fire],[food]",false}, - {"blood_cold","LIQUIDS","[liquid],[corrodible],[soluble],[evaporable_custom],[blood],[impure],[cold],[liquid_common]",false}, - {"radioactive_liquid","LIQUIDS","[liquid],[corrodible],[soluble],[radioactive],[impure],[liquid_common],[chaotic_transmutation]",false}, - {"radioactive_liquid_fading","LIQUIDS","[liquid],[corrodible],[soluble],[evaporable],[radioactive],[impure],[liquid_common]",false}, - {"plasma_fading","LIQUIDS","[liquid],[corrodible],[soluble],[evaporable_fast],[impure],[liquid_common]",false}, - {"gold_molten","LIQUIDS","[liquid],[corrodible],[molten],[alchemy],[gold],[solid],[liquid_common],[earth]",false}, - {"wax_molten","LIQUIDS","[liquid],[corrodible],[molten],[alchemy],[impure],[solid],[liquid_common],[earth]",false}, - {"silver_molten","LIQUIDS","[liquid],[corrodible],[molten],[alchemy],[solid],[liquid_common],[earth]",false}, - {"copper_molten","LIQUIDS","[liquid],[corrodible],[molten],[alchemy],[solid],[liquid_common],[earth]",false}, - {"brass_molten","LIQUIDS","[liquid],[corrodible],[molten],[alchemy],[solid],[liquid_common],[earth]",false}, - {"glass_molten","LIQUIDS","[liquid],[molten],[meltable_to_lava],[alchemy],[solid],[liquid_common],[earth]",false}, - {"glass_broken_molten","LIQUIDS","[liquid],[molten],[meltable_to_lava],[alchemy],[solid],[liquid_common],[earth]",false}, - {"steel_molten","LIQUIDS","[liquid],[molten],[alchemy],[solid],[liquid_common],[earth]",false}, - {"creepy_liquid","LIQUIDS","[liquid],[impure]",false}, - {"cement","LIQUIDS","[corrodible],[meltable_to_lava],[alchemy],[impure],[solid],[earth]",false}, - {"concrete_sand","SANDS","[corrodible],[meltable_to_lava],[alchemy]",false}, - {"sand","SANDS","[sand_ground],[corrodible],[meltable_to_lava_fast],[alchemy],[chaotic_transmutation]",false}, - {"bone","SANDS","[sand_ground],[corrodible],[meltable_to_lava],[alchemy],[chaotic_transmutation]",false}, - {"soil","SANDS","[sand_ground],[corrodible],[grows_grass],[meltable_to_lava],[alchemy],[solid]",false}, - {"sandstone","SANDS","[sand_ground],[corrodible],[meltable_to_lava],[alchemy],[solid],[earth]",false}, - {"fungisoil","SANDS","[sand_ground],[corrodible],[grows_fungus],[meltable_to_lava],[alchemy],[solid],[earth],[fungus]",false}, - {"honey","SANDS","[corrodible],[meltable_to_lava_fast],[alchemy],[meltable_by_fire],[chaotic_transmutation]",false}, - {"glue","SANDS","[liquid],[burnable],[alchemy],[impure]",false}, - {"slime","LIQUIDS","[corrodible],[meltable_to_lava],[alchemy],[slime],[liquid_common],[food],[chaotic_transmutation]",false}, - {"slush","LIQUIDS","[corrodible],[meltable_to_water],[alchemy],[snow],[chaotic_transmutation]",false}, - {"explosion_dirt","SANDS","[sand_ground],[corrodible]",false}, - {"vine","SANDS","[static],[sand_ground],[corrodible],[burnable],[alchemy]",true}, - {"root","SANDS","[static],[sand_ground],[corrodible],[burnable],[alchemy],[solid],[earth]",true}, - {"snow","SANDS","[static],[sand_ground],[corrodible],[frozen],[meltable_to_water],[alchemy],[chaotic_transmutation]",false}, - {"snow_sticky","SANDS","[sand_ground],[corrodible],[frozen],[meltable_to_water],[alchemy]",false}, - {"rotten_meat","SANDS","[sand_ground],[corrodible],[meltable_to_lava],[alchemy],[meat],[food]",false}, - {"meat_slime_sand","SANDS","[sand_ground],[corrodible],[meltable_to_lava],[alchemy],[meat],[slime],[food]",false}, - {"rotten_meat_radioactive","SANDS","[sand_ground],[corrodible],[meltable_to_lava],[alchemy],[meat],[solid],[earth],[food]",false}, - {"ice","SANDS","[sand_ground],[corrodible],[frozen],[meltable_to_water],[alchemy],[solid]",false}, - {"sand_herb","SANDS","[corrodible],[evaporable_custom],[burnable],[alchemy]",false}, - {"wax","SANDS","[burnable],[corrodible],[meltable],[alchemy],[solid],[earth]",false}, - {"gold","SANDS","[sand_metal],[corrodible],[meltable_metal],[alchemy],[gold],[solid],[earth]",false}, - {"silver","SANDS","[sand_metal],[corrodible],[meltable_metal],[alchemy],[solid],[earth]",false}, - {"copper","SANDS","[sand_metal],[corrodible],[meltable_metal],[alchemy],[solid],[earth]",false}, - {"brass","SANDS","[sand_metal],[corrodible],[meltable_metal],[alchemy],[solid],[earth]",false}, - {"diamond","SANDS","[sand_metal],[alchemy],[solid],[earth]",false}, - {"coal","SANDS","[sand_other],[corrodible],[burnable],[alchemy],[chaotic_transmutation]",false}, - {"sulphur","SANDS","[sand_other],[corrodible],[alchemy]",false}, - {"salt","SANDS","[sand_other],[corrodible],[alchemy],[chaotic_transmutation]",false}, - {"sodium_unstable","SANDS","[sand_other],[corrodible],[alchemy]",false}, - {"gunpowder","SANDS","[sand_other],[corrodible],[alchemy],[chaotic_transmutation]",false}, - {"gunpowder_explosive","SANDS","[sand_other],[corrodible],[alchemy],[chaotic_transmutation]",false}, - {"gunpowder_tnt","SANDS","[sand_other],[corrodible],[alchemy]",false}, - {"gunpowder_unstable","SANDS","[sand_other],[corrodible],[burnable],[alchemy],[chaotic_transmutation]",false}, - {"gunpowder_unstable_big","SANDS","[sand_other],[corrodible],[burnable],[alchemy]",false}, - {"monster_powder_test","SANDS","[sand_other],[corrodible],[burnable],[alchemy]",false}, - {"rat_powder","SANDS","[sand_other]",false}, - {"fungus_powder","SANDS","[sand_other],[fungus]",false}, - {"orb_powder","SANDS","[NO_FUNGAL_SHIFT]",false}, - {"gunpowder_unstable_boss_limbs","SANDS","[sand_other],[corrodible],[alchemy],[meat]",false}, - {"plastic_red","SANDS","[corrodible],[meltable],[alchemy]",false}, - {"plastic_red_molten","LIQUIDS","[liquid],[corrodible],[molten],[meltable_to_lava],[alchemy],[liquid_common]",false}, - {"grass","SANDS","[plant],[requires_air],[corrodible],[burnable],[alchemy],[impure]",false}, - {"grass_holy","SANDS","[plant],[requires_air],[corrodible],[burnable],[alchemy]",false}, - {"grass_darker","SANDS","[plant],[requires_air],[corrodible],[burnable],[alchemy],[impure]",false}, - {"fungi","SANDS","[plant],[corrodible],[burnable],[alchemy],[impure],[fungus]",false}, - {"spore","SANDS","[plant],[corrodible],[burnable],[alchemy],[impure]",false}, - {"moss","SANDS","[plant],[corrodible],[burnable],[alchemy],[impure]",false}, - {"plant_material","SANDS","[plant],[requires_air],[corrodible],[burnable]",false}, - {"plant_material_red","SANDS","[plant],[requires_air],[corrodible],[burnable]",false}, - {"plant_material_dark","SANDS","[plant],[requires_air],[corrodible],[burnable]",false}, - {"ceiling_plant_material","SANDS","[plant],[corrodible],[burnable]",false}, - {"mushroom_seed","SANDS","[plant],[corrodible],[burnable]",false}, - {"plant_seed","SANDS","[plant],[corrodible],[burnable]",false}, - {"mushroom","SANDS","[plant],[corrodible],[burnable]",false}, - {"mushroom_giant_red","SANDS","[plant],[requires_air],[corrodible],[burnable]",false}, - {"mushroom_giant_blue","SANDS","[plant],[requires_air],[corrodible],[burnable]",false}, - {"glowshroom","SANDS","[plant],[requires_air],[corrodible],[burnable]",false}, - {"bush_seed","SANDS","[plant],[requires_air],[corrodible],[burnable]",false}, - {"acid","LIQUIDS","[liquid],[acid],[impure],[liquid_common],[chaotic_transmutation]",false}, - {"lava","LIQUIDS","[fire_lava],[liquid],[lava],[liquid_common],[chaotic_transmutation]",false}, - {"wood_player","SANDS","[static],[corrodible],[alchemy],[solid],[earth]",true}, - {"wood_player_b2","SOLIDS","[static],[box2d],[alchemy],[corrodible],[solid],[earth]",false}, - {"wood","SOLIDS","[box2d],[corrodible],[alchemy],[burnable],[solid],[earth]",false}, - {"wax_b2","SOLIDS","[box2d],[alchemy],[solid],[earth]",false}, - {"fuse","SOLIDS","[box2d]",false}, - {"wood_loose","SOLIDS","[box2d],[corrodible],[alchemy],[solid],[earth]",false}, - {"rock_loose","SOLIDS","[box2d],[corrodible],[alchemy],[solid],[earth]",false}, - {"ice_ceiling","SOLIDS","[box2d],[corrodible],[alchemy],[solid]",false}, - {"brick","SOLIDS","[box2d],[corrodible],[alchemy],[solid],[earth]",false}, - {"concrete_collapsed","SOLIDS","[box2d],[corrodible],[alchemy],[solid],[earth]",false}, - {"tnt","SOLIDS","[box2d],[corrodible],[burnable],[alchemy],[solid],[earth]",false}, - {"tnt_static","SOLIDS","[box2d],[corrodible],[burnable],[alchemy],[solid],[earth]",false}, - {"trailer_text","SANDS","",true}, - {"meteorite","SOLIDS","[box2d],[alchemy],[solid],[earth]",false}, - {"sulphur_box2d","SOLIDS","[box2d],[solid],[sunbaby_ignore_list]",false}, - {"meteorite_test","SOLIDS","[box2d],[alchemy],[solid],[earth]",false}, - {"meteorite_green","SOLIDS","[box2d],[alchemy],[solid],[earth]",false}, - {"steel","SOLIDS","[box2d],[rust_box2d],[alchemy],[meltable_metal],[solid],[earth]",false}, - {"steel_rust","SOLIDS","[box2d],[alchemy],[meltable_metal],[solid],[earth]",false}, - {"metal_rust_rust","SOLIDS","[box2d],[alchemy],[corrodible],[solid],[meltable_metal_generic],[earth]",false}, - {"metal_rust_barrel_rust","SOLIDS","[box2d],[alchemy],[corrodible],[solid],[meltable_metal_generic],[earth]",false}, - {"plastic","SOLIDS","[box2d],[corrodible],[meltable_plastic],[alchemy],[solid],[earth]",false}, - {"aluminium","SOLIDS","[box2d],[corrodible],[rust_oxide],[alchemy],[meltable_metal],[solid],[earth]",false}, - {"rock_static_box2d","SOLIDS","[box2d],[static],[alchemy],[solid],[earth]",false}, - {"rock_box2d","SOLIDS","[box2d],[static],[alchemy],[solid],[earth]",false}, - {"crystal","SOLIDS","[box2d],[alchemy]",false}, - {"magic_crystal","SOLIDS","[box2d],[alchemy]",false}, - {"crystal_magic","SOLIDS","[box2d],[alchemy]",false}, - {"aluminium_oxide","SOLIDS","[box2d],[corrodible],[alchemy],[meltable_metal],[solid],[earth]",false}, - {"meat","SOLIDS","[box2d],[corrodible],[alchemy],[meat],[solid],[earth],[food]",false}, - {"meat_slime","SOLIDS","[box2d],[corrodible],[meltable_to_lava],[alchemy],[meat],[solid],[earth],[food]",false}, - {"urine","LIQUIDS","[liquid],[corrodible],[soluble],[liquid_common]",false}, - {"poo","SANDS","[sand_other],[corrodible],[soluble]",false}, - {"mammi","SANDS","[sand_other],[corrodible],[soluble]",false}, - {"physics_throw_material_part2","SOLIDS","[box2d],[alchemy]",false}, - {"rocket_particles","LIQUIDS","[liquid]",false}, - {"ice_melting_perf_killer","SOLIDS","[box2d],[hax],[meltable_to_water],[ice]",false}, - {"ice_b2","SOLIDS","[box2d],[alchemy],[solid]",false}, - {"glass_liquidcave","SOLIDS","[box2d],[alchemy],[solid],[earth]",false}, - {"glass","SOLIDS","[static],[meltable_metal],[alchemy],[solid],[earth]",false}, - {"glass_broken","SANDS","[sand_other],[meltable_metal],[alchemy],[solid],[earth]",false}, - {"neon_tube_purple","SOLIDS","[static],[corrodible],[box2d],[alchemy],[solid],[earth]",false}, - {"blood_thick","SANDS","[static],[corrodible],[blood],[food],[chaotic_transmutation]",true}, - {"snow_b2","SOLIDS","[box2d],[corrodible],[alchemy],[meltable_to_water],[solid]",false}, -} -local by_name={} -for i,m in ipairs(materials) do by_name[m[1]]=i end -local apis={LIQUIDS='CellFactory_GetAllLiquids',SANDS='CellFactory_GetAllSands', - GASES='CellFactory_GetAllGases',FIRES='CellFactory_GetAllFires',SOLIDS='CellFactory_GetAllSolids'} -for kind,api in pairs(apis) do - _G[api]=function(statics,particles) - local out={} - for _,m in ipairs(materials) do - if m[2]==kind and (statics~=false or not m[4]) then out[#out+1]=m[1] end - end - if kind=='LIQUIDS' and particles then out[#out+1]='mod_particle_fx' end - return out - end -end -function CellFactory_GetType(name) return by_name[name] or (name=='mod_particle_fx' and 10000 or -1) end -function CellFactory_GetUIName(id) return tostring(id) end -function CellFactory_GetTags(id) - local out={}; for tag in (materials[id] and materials[id][3] or ''):gmatch('%[[^%]]+%]') do out[#out+1]=tag end; return out -end -METAMORPH_CREATIVE_MENU_MATERIAL_CATALOG=nil -local catalog=dofile(root..'/files/features/materials/catalog.lua') -for _,category in ipairs(catalog.categories()) do - local guard=0 - while not catalog.is_ready(category.id) do - local _,used=catalog.step(category.id,nil,7); assert(used<=7,'budget exceeded') - guard=guard+1; assert(guard<2000,'catalog never completed') - end -end -local function contains(category,name) - for _,e in ipairs(catalog.entries_for(category)) do if e.id==name then return true end end - return false -end -for _,m in ipairs(materials) do - local tags=m[3] - if tags:find('[hax]',1,true) then - assert(contains('SPECIAL',m[1]),'missing hax: '..m[1]) - elseif m[4] or tags:find('[static]',1,true) then - assert(contains('STATIC',m[1]),'missing terrain: '..m[1]) - assert(not contains('SANDS',m[1]),'terrain in powders: '..m[1]) - end -end -for _,name in ipairs({'rock_static','wood_static','steel_static'}) do - assert(contains('STATIC',name),'missing representative terrain: '..name) -end -assert(contains('SANDS','sand'),'sand missing') -assert(contains('LIQUIDS','water'),'water missing') -assert(contains('LIQUIDS','lava'),'lava missing') -assert(contains('SPECIAL','ice_melting_perf_killer'),'vanilla hax missing') -assert(contains('SPECIAL','mod_particle_fx'),'particle FX fallback missing') -assert(#catalog.entries_for('ALL')==#materials+1,'ALL lost or duplicated material') -local total=0 -for _,c in ipairs(catalog.categories()) do if c.id~='ALL' then total=total+#catalog.entries_for(c.id) end end -assert(total==#catalog.entries_for('ALL'),'material missing from category or duplicated between categories') -print('material_vanilla_data=PASS materials='..#materials..' static='..#catalog.entries_for('STATIC')..' special='..#catalog.entries_for('SPECIAL')) diff --git a/metamorph_creative_menu/tests/menu_error_isolation_mock.lua b/metamorph_creative_menu/tests/menu_error_isolation_mock.lua index 40b68e4..1baed79 100644 --- a/metamorph_creative_menu/tests/menu_error_isolation_mock.lua +++ b/metamorph_creative_menu/tests/menu_error_isolation_mock.lua @@ -2,7 +2,7 @@ local root = assert(arg[1], "root required") local native_dofile = dofile local tile_calls = 0 local error_labels = 0 -local diagnostic_calls = 0 +local error_prints = 0 local tab_draw_calls = 0 local ui = { @@ -14,9 +14,6 @@ local ui = { gui=function() return 1 end, tr=function(_, fallback) return fallback end, tile=function() tile_calls=tile_calls+1; return false,false end, - button=function() return false end, - pointer_handle=function() return false,false end, - colored_text=function() end, white_text=function(_,_,text) if text == "This section could not be drawn" then error_labels=error_labels+1 end end, finish_auto_box=function() end, hovered=function() return false end, @@ -27,18 +24,16 @@ local stubs = { ["mods/metamorph_creative_menu/files/ui/runtime.lua"]=ui, ["mods/metamorph_creative_menu/files/platform/noita/player_locator.lua"]={get=function() return 1 end}, ["mods/metamorph_creative_menu/files/platform/noita/input_guard.lua"]={actions_allowed=function() return true end, blocked=function() return false end, resume_serial=function() return 0 end}, - ["mods/metamorph_creative_menu/files/platform/noita/action_bindings.lua"]={update=function() end,consume=function() return false end,label=function() return '—' end}, + ["mods/metamorph_creative_menu/files/platform/noita/keycodes.lua"]={resolve=function() return 9 end}, ["mods/metamorph_creative_menu/files/platform/noita/menu_inventory_guard.lua"]={ controls_disabled=function() return false end, inventory_open=function() return true end, capture_scroll_selection=function() return nil end, restore_scroll_selection=function() end, - acquire_manual_controls=function() return true end, - release_manual_controls=function() return true end, }, } local tab_paths = { - "spells.lua","items.lua","materials.lua","perks.lua","creatures.lua","effects.lua","weather.lua","world_rules.lua","players.lua","controls.lua" + "spells.lua","items.lua","perks.lua","creatures.lua","effects.lua","weather.lua","world_rules.lua" } for index, name in ipairs(tab_paths) do stubs["mods/metamorph_creative_menu/files/ui/tabs/"..name] = index == 1 and failing_tab or harmless_tab @@ -61,8 +56,7 @@ function GuiLayoutBeginHorizontal() end function GuiLayoutEnd() end function GuiBeginAutoBox() end function InputIsMouseButtonJustDown() return false end -function print() end -METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE=function() diagnostic_calls=diagnostic_calls+1 end +function print(message) if string.find(tostring(message), "UI tab", 1, true) then error_prints=error_prints+1 end end METAMORPH_CREATIVE_MENU_MENU_CONTROLLER=nil local controller = assert(native_dofile(root.."/files/ui/menu_controller.lua")) @@ -70,8 +64,8 @@ local ok1, err1 = pcall(controller.draw) local ok2, err2 = pcall(controller.draw) assert(ok1 and ok2, "tab runtime error escaped menu controller: "..tostring(err1 or err2)) assert(tab_draw_calls == 2, "active tab was not attempted on both frames") -assert(tile_calls == 20, "top tab bar stopped rendering after a tab error") +assert(tile_calls == 14, "top tab bar stopped rendering after a tab error") assert(error_labels == 2, "menu did not show bounded tab error fallback") -assert(diagnostic_calls == 1, "same persistent tab error should be reported once, not every frame") +assert(error_prints == 1, "same persistent tab error should be printed once, not every frame") print("menu_error_isolation=PASS header_survives=true error_contained=true") diff --git a/metamorph_creative_menu/tests/menu_inventory_guard_mock.lua b/metamorph_creative_menu/tests/menu_inventory_guard_mock.lua index 622ca58..bd1c965 100644 --- a/metamorph_creative_menu/tests/menu_inventory_guard_mock.lua +++ b/metamorph_creative_menu/tests/menu_inventory_guard_mock.lua @@ -2,7 +2,6 @@ local root = assert(arg[1], "root required") local native_dofile = dofile local bridge_calls = 0 local component_writes = {} -local controls_enabled = true local patcher_bridge = { get = function(request) @@ -28,9 +27,9 @@ function EntityGetFirstComponentIncludingDisabled(entity_id, component_type) if entity_id == 1 and component_type == "Inventory2Component" then return 22 end return nil end -function ComponentGetIsEnabled(component_id) return true end +function EntityGetComponentIsEnabled(player_entity_id, component_id) return true end function ComponentGetValue2(component_id, field_name) - if component_id == 20 and field_name == "enabled" then return controls_enabled end + if component_id == 20 and field_name == "enabled" then return true end if component_id == 21 and field_name == "mActive" then return true end if component_id == 22 and field_name == "mActiveItem" then return 10 end if component_id == 22 and field_name == "mActualActiveItem" then return 11 end @@ -38,7 +37,6 @@ function ComponentGetValue2(component_id, field_name) end function ComponentSetValue2(component_id, field_name, value) component_writes[field_name] = value - if component_id == 20 and field_name == "enabled" then controls_enabled = value end end function EntityGetAllChildren() return {} end function EntityGetName() return "" end @@ -48,28 +46,10 @@ METAMORPH_CREATIVE_MENU_MENU_INVENTORY_GUARD = nil local guard = assert(native_dofile(root .. "/files/platform/noita/menu_inventory_guard.lua")) assert(guard.controls_disabled(1) == false, "enabled controls reported disabled") assert(guard.inventory_open(1) == true, "InventoryGuiComponent open state was ignored") -assert(guard.suppress_text_controls(1)==true and controls_enabled==true, - 'text entry disabled ControlsComponent instead of suppressing transient actions') -assert(component_writes.mButtonDownDown==false and component_writes.mButtonFrameDown==-1 - and component_writes.mButtonDownInventory==false and component_writes.mButtonFrameInventory==-1 - and component_writes.mButtonDownFire==false and component_writes.mButtonFrameFire==-1, - 'text entry did not suppress movement/inventory/fire action fields') local snapshot = assert(guard.capture_scroll_selection(1)) assert(snapshot.active_item_entity_id == 10 and snapshot.actual_active_item_entity_id == 11, "selection snapshot incorrect") assert(component_writes.mButtonFrameChangeItemR == -1 and component_writes.mButtonCountChangeItemL == 0, "wheel input was not suppressed") assert(guard.restore_scroll_selection(snapshot) == true, "selection restore failed") assert(bridge_calls == 1, "NoitaPatcher held-item restore was not used") assert(component_writes.mActiveItem == 10 and component_writes.mActualActiveItem == 11 and component_writes.mForceRefresh == true, "inventory ids/refresh not restored") -assert(guard.acquire_manual_controls(1)==true and controls_enabled==false and guard.manual_controls_owned(), - 'manual menu did not suppress gameplay controls') -assert(component_writes.mButtonDownLeftClick==false and component_writes.mButtonDownRightClick==false - and component_writes.mButtonFrameLeftClick==-1 and component_writes.mButtonFrameRightClick==-1, - 'manual menu did not suppress the vanilla click fields behind MCM') -assert(guard.release_manual_controls()==true and controls_enabled==true and not guard.manual_controls_owned(), - 'manual menu did not restore its controls baseline') -controls_enabled=false -assert(guard.acquire_manual_controls(1)==true) -controls_enabled=true -- a later external owner re-enabled controls -assert(guard.release_manual_controls()==true and controls_enabled==true, - 'manual menu overwrote a later external controls decision') -print("menu_inventory_guard=PASS capture_restore=true manual_controls=true text_controls=true pointer_fields=true compare_and_swap=true") +print("menu_inventory_guard=PASS capture_restore=true") diff --git a/metamorph_creative_menu/tests/menu_inventory_policy_controller_mock.lua b/metamorph_creative_menu/tests/menu_inventory_policy_controller_mock.lua deleted file mode 100644 index 4a17c74..0000000 --- a/metamorph_creative_menu/tests/menu_inventory_policy_controller_mock.lua +++ /dev/null @@ -1,263 +0,0 @@ -local root=assert(arg[1], 'root required') -local native_dofile=dofile - -local saved={ - ['metamorph_creative_menu.ui_panel_x']=61, - ['metamorph_creative_menu.ui_panel_y']=23, - ['metamorph_creative_menu.ui_panel_width']=244, - ['metamorph_creative_menu.ui_panel_height']=151, -} -local inventory_open=false -local queued={} -local drawn=0 -local frame=1 -local blocked=false -local text_active=false -local capture_active=false -local left_just=false -local left_down=false -local drag_pending=false -local acquire_calls=0 -local mouse_x,mouse_y=8,8 -local screen_width,screen_height=320,200 - -local ui={ - ICON_STEP=20, EMPTY_SLOT='slot', - audit=function() end, bind=function() end, begin_frame=function() end, end_frame=function() end, - gui=function() return 1 end, tr=function(_,fallback) return fallback end, - tile=function() return false,false end, white_text=function() end, colored_text=function() end, - text_width=function(text) return #tostring(text)*5 end, - button=function() return false end, - drag_handle=function(x,y) return false, tonumber(x) or 0, tonumber(y) or 0, 16, 10 end, - finish_auto_box=function() return 57,19,252,159 end, - set_panel_bounds=function() end, resize_affordances=function() end, - hovered=function() return false end, - text_input_active=function() return text_active end, - reset_text_inputs=function() text_active=false end, - blur_text_input=function() text_active=false; return true end, -} - -local action_bindings={ - update=function() end, - consume=function(id) - if blocked or text_active or capture_active then return false end - if queued[id] then queued[id]=nil; return true end - return false - end, - label=function() return '—' end, -} -local input_guard={blocked=function() return blocked end,resume_serial=function() return 0 end} -local pointer={ - left_just_down=function() return left_just end, - left_down=function() return left_down end, - right_down=function() return false end, - gui_position=function() return mouse_x,mouse_y end, - inside=function(x,y,w,h,mx,my) return mx>=x and mx<=x+w and my>=y and my<=y+h end, -} -local guard={ - inventory_open=function() return inventory_open end, - acquire_manual_controls=function() acquire_calls=acquire_calls+1; return true end, - release_manual_controls=function() return true end, - capture_scroll_selection=function() return nil end, - restore_scroll_selection=function() end, -} - -local stubs={ - ['mods/metamorph_creative_menu/files/ui/runtime.lua']=ui, - ['mods/metamorph_creative_menu/files/platform/noita/player_locator.lua']={get=function() return 1 end}, - ['mods/metamorph_creative_menu/files/platform/noita/input_guard.lua']=input_guard, - ['mods/metamorph_creative_menu/files/platform/noita/menu_inventory_guard.lua']=guard, - ['mods/metamorph_creative_menu/files/platform/noita/action_bindings.lua']=action_bindings, - ['mods/metamorph_creative_menu/files/platform/noita/pointer.lua']=pointer, - ['mods/metamorph_creative_menu/files/ui/drag_drop.lua']={begin_frame=function() end,end_frame=function() end,cancel=function() end,pending=function() return drag_pending end}, -} -for _,name in ipairs({'spells','items','materials','perks','creatures','effects','weather','world_rules','players','controls'}) do - stubs['mods/metamorph_creative_menu/files/ui/tabs/'..name..'.lua']={draw=function() drawn=drawn+1 end,warmup_step=function() return true end} -end - -dofile=function(path) - if stubs[path] then return stubs[path] end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -GUI_OPTION={NoPositionTween=1} -GuiCreate=function() return 1 end -GuiStartFrame=function() end -GuiOptionsAdd=function() end -GuiGetScreenDimensions=function() return screen_width,screen_height end -GuiLayoutBeginVertical=function() end -GuiLayoutBeginHorizontal=function() end -GuiLayoutEnd=function() end -GuiBeginAutoBox=function() end -InputIsMouseButtonJustDown=function() return false end -InputIsMouseButtonDown=function() return false end -GameGetFrameNum=function() return frame end -ModSettingGet=function(id) return saved[id] end -ModSettingSet=function(id,value) saved[id]=value end - -local function reset_inputs() - queued={}; blocked=false; text_active=false; capture_active=false - left_just=false; left_down=false; drag_pending=false; mouse_x,mouse_y=8,8 -end -local function load_controller(mode, remembered) - reset_inputs() - inventory_open=false - saved['metamorph_creative_menu.inventory_open_policy']=mode - saved['metamorph_creative_menu.ui_inventory_remembered_open']=remembered - METAMORPH_CREATIVE_MENU_MENU_CONTROLLER=nil - return assert(native_dofile(root..'/files/ui/menu_controller.lua')) -end -local function draw(controller) - frame=frame+1 - controller.draw() -end - --- Legacy layout values survive loading the new policy unchanged. -do - local c=load_controller('always_closed', true) - queued.menu_toggle=true; draw(c) - local l=assert(c.layout()) - assert(l.x==61 and l.y==23 and l.width==244 and l.height==151, - 'new inventory policy lost legacy panel layout settings') -end - --- Always Open: temporary F4 close, then reopen on next inventory edge. -do - local c=load_controller('always_open', true) - inventory_open=true; draw(c) - assert(c.is_open(), 'Always Open did not auto-open with native inventory') - queued.menu_toggle=true; draw(c) - assert(not c.is_open(), 'Always Open F4 did not temporarily close') - inventory_open=false; draw(c) - inventory_open=true; draw(c) - assert(c.is_open(), 'Always Open did not reopen on next inventory cycle') -end - --- Always Closed: F4 opens only this inventory session; no hidden auxiliary surface is required. -do - local c=load_controller('always_closed', true) - inventory_open=true; draw(c) - assert(not c.is_open(), 'Always Closed auto-opened') - queued.menu_toggle=true; draw(c) - assert(c.is_open(), 'Always Closed F4 did not manually open') - inventory_open=false; draw(c) - assert(not c.is_open(), 'in-inventory manual open leaked after inventory close') - inventory_open=true; draw(c) - assert(not c.is_open(), 'next Always Closed inventory session did not start hidden') -end - --- Remember Last State persists explicit choices but not automatic inventory close. -do - local c=load_controller('remember', false) - inventory_open=true; draw(c) - assert(not c.is_open(), 'remember=false did not restore closed state') - queued.menu_toggle=true; draw(c) - assert(c.is_open() and saved['metamorph_creative_menu.ui_inventory_remembered_open']==true, - 'explicit Remember open was not persisted') - inventory_open=false; draw(c) - assert(not c.is_open() and saved['metamorph_creative_menu.ui_inventory_remembered_open']==true, - 'automatic inventory close corrupted remembered preference') - inventory_open=true; draw(c) - assert(c.is_open(), 'remember=true did not restore after inventory cycle') - queued.menu_toggle=true; draw(c) - assert(not c.is_open() and saved['metamorph_creative_menu.ui_inventory_remembered_open']==false, - 'explicit Remember close was not persisted') - - -- Simulated game reload uses the saved preference. - inventory_open=false - c=load_controller('remember', saved['metamorph_creative_menu.ui_inventory_remembered_open']) - inventory_open=true; draw(c) - assert(not c.is_open(), 'Remember preference did not survive controller reload') -end - --- Focused text/binding capture and quarantine must not leak F4 into visibility state. -do - local c=load_controller('always_closed', true) - inventory_open=true; draw(c) - text_active=true; queued.menu_toggle=true; draw(c) - assert(not c.is_open(), 'focused text input leaked F4 into menu toggle') - text_active=false; capture_active=true; queued.menu_toggle=true; draw(c) - assert(not c.is_open(), 'binding capture leaked F4 into menu toggle') - capture_active=false; blocked=true; queued.menu_toggle=true; draw(c) - assert(not c.is_open(), 'input quarantine leaked F4 into menu toggle') -end - --- Always Closed remains reachable by F4 with no auxiliary inventory handle. -do - local c=load_controller('always_closed', true) - inventory_open=true; draw(c) - assert(not c.is_open(), 'Always Closed fixture auto-opened') - queued.menu_toggle=true; draw(c) - assert(c.is_open(), 'removing inventory handle disabled F4 menu access') -end - --- An MCM drag still owns controls on the release frame (left_down is already false). --- Otherwise vanilla InventoryGui can complete the same release and overwrite its slot. -do - local c=load_controller('always_closed', true) - inventory_open=true; queued.menu_toggle=true; draw(c) - assert(c.is_open(), 'release-ownership fixture did not open') - mouse_x,mouse_y=319,199 - left_down=false; drag_pending=true - local before=acquire_calls - draw(c) - assert(acquire_calls==before+1,'drag release frame returned controls to vanilla InventoryGui early') - drag_pending=false -end - -print('menu_inventory_policy_controller=PASS modes=true f4=true remember_restart=true auto_close=true focus=true quarantine=true release_owned=true legacy_layout=true') - --- Exercise the real guard across native engine updates, not just API call counts. -stubs['mods/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua']={get=function() return nil end} -local controls_enabled=true -function EntityGetIsAlive(e) return e==1 end -function EntityGetFirstComponentIncludingDisabled(e,kind) - if e~=1 then return nil end - if kind=="ControlsComponent" then return 20 end - if kind=="InventoryGuiComponent" then return 21 end -end -function ComponentGetValue2(c,field) - if c==20 and field=="enabled" then return controls_enabled end - if c==21 and field=="mActive" then return inventory_open end -end -function ComponentSetValue2(c,field,value) - if c==20 and field=="enabled" then controls_enabled=value end - if c==21 and field=="mActive" then inventory_open=value end -end -function GameIsInventoryOpen() return inventory_open end -METAMORPH_CREATIVE_MENU_MENU_INVENTORY_GUARD=nil -local real_guard=native_dofile(root..'/files/platform/noita/menu_inventory_guard.lua') -for k,v in pairs(real_guard) do guard[k]=v end -local c=load_controller('always_open',true) -inventory_open=true; draw(c) -local focus_on_draw=true -stubs['mods/metamorph_creative_menu/files/ui/tabs/spells.lua'].draw=function() - if focus_on_draw then text_active=true end -end -mouse_x,mouse_y=100,60; left_just=true; left_down=true -draw(c) -assert(controls_enabled and not real_guard.manual_controls_owned(),"focus click left controls disabled through first typing frame") -left_just=false; left_down=false; focus_on_draw=false --- I processed after MCM's pre-update. -inventory_open=false; c.post_update() -assert(inventory_open and text_active,"native inventory key closed focused editor in post-update") -draw(c); assert(c.is_open() and text_active,"next frame reset the editor") --- A native edge already visible before MCM draws must also be neutralized. -inventory_open=false; draw(c) -assert(c.is_open() and inventory_open and text_active,"pre-draw native edge closed focused editor") --- A click out of the field releases ownership; normal inventory closing works again. -text_active=false; draw(c) -inventory_open=false; draw(c) -assert(not c.is_open(),"inventory guard remained stuck after blur") --- F4-only menu must not open the native inventory when typing I either. -c=load_controller('always_closed',false) -queued.menu_toggle=true; draw(c) -text_active=true; draw(c) -inventory_open=true; c.post_update() -assert(not inventory_open and c.is_open(),"I opened native inventory behind a manual text editor") -text_active=false; draw(c) -inventory_open=true; draw(c) -assert(inventory_open,"normal inventory opening remained blocked after blur") -print("menu_text_native_edges=PASS same_frame_release=true native_i=true pre_sync=true post_update=true manual_mode=true blur_releases=true") diff --git a/metamorph_creative_menu/tests/menu_inventory_visibility_policy_mock.lua b/metamorph_creative_menu/tests/menu_inventory_visibility_policy_mock.lua deleted file mode 100644 index 287497d..0000000 --- a/metamorph_creative_menu/tests/menu_inventory_visibility_policy_mock.lua +++ /dev/null @@ -1,72 +0,0 @@ -local root=assert(arg[1], 'root required') -local policy=assert(dofile(root..'/files/core/menu_visibility_policy.lua')) - -local function fresh() return policy.new() end - --- Always Open: each new native inventory session starts visible, but an explicit close --- is respected for the remainder of the current session only. -do - local state=fresh() - policy.sync_inventory(state, true, 'always_open', true) - assert(policy.visible(state)==true, 'always_open did not open on inventory edge') - local remembered=policy.set_user_visible(state, false) - assert(remembered==nil and policy.visible(state)==false, 'always_open explicit close was not temporary') - policy.sync_inventory(state, false, 'always_open', true) - assert(policy.visible(state)==false, 'always_open stayed visible after inventory close') - policy.sync_inventory(state, true, 'always_open', true) - assert(policy.visible(state)==true, 'always_open did not reopen on next inventory cycle') -end - --- Always Closed: inventory edges never auto-open. A manual open inside an inventory --- session is session-scoped, while a manual open outside inventory remains a real manual window. -do - local state=fresh() - policy.sync_inventory(state, true, 'always_closed', true) - assert(policy.visible(state)==false, 'always_closed auto-opened') - assert(policy.set_user_visible(state, true)==nil and policy.visible(state)==true, - 'always_closed manual in-inventory open failed') - policy.sync_inventory(state, false, 'always_closed', true) - assert(policy.visible(state)==false, 'always_closed in-inventory manual window leaked past inventory close') - policy.sync_inventory(state, true, 'always_closed', true) - assert(policy.visible(state)==false, 'always_closed next inventory session did not start closed') - policy.sync_inventory(state, false, 'always_closed', true) - policy.set_user_visible(state, true) - assert(policy.visible(state)==true, 'manual F4-style open outside inventory failed') - policy.sync_inventory(state, true, 'always_closed', true) - assert(policy.visible(state)==true, 'pre-existing manual window was discarded by inventory opening') - policy.sync_inventory(state, false, 'always_closed', true) - assert(policy.visible(state)==true, 'pre-existing manual window was discarded by inventory closing') -end - --- Remember Last State: only explicit user changes return a preference to persist. Native --- inventory closing itself is automatic and must never overwrite the preference. -do - local state=fresh() - policy.sync_inventory(state, true, 'remember', true) - assert(policy.visible(state)==true, 'remember=true was not restored') - local remembered=policy.set_user_visible(state, false) - assert(remembered==false and policy.visible(state)==false, 'explicit close did not persist remembered=false') - local automatic=policy.sync_inventory(state, false, 'remember', false) - assert(automatic==nil, 'automatic inventory close attempted to persist preference') - policy.sync_inventory(state, true, 'remember', false) - assert(policy.visible(state)==false, 'remember=false was not restored on next inventory open') - remembered=policy.set_user_visible(state, true) - assert(remembered==true and policy.visible(state)==true, 'explicit open did not persist remembered=true') - policy.sync_inventory(state, false, 'remember', true) - assert(policy.visible(state)==false, 'remembered in-inventory window did not auto-hide with inventory') - - -- Simulated restart: a new state consumes the saved setting and gets the same result. - local restarted=fresh() - policy.sync_inventory(restarted, true, 'remember', remembered) - assert(policy.visible(restarted)==true, 'remember preference did not survive recreated state') -end - --- Unknown/legacy setting values must fail compatibly to the old behavior. -do - local state=fresh() - policy.sync_inventory(state, true, 'bogus_old_value', false) - assert(policy.visible(state)==true and policy.normalize_mode('bogus_old_value')=='always_open', - 'invalid policy did not fail back to Always Open') -end - -print('menu_inventory_visibility_policy=PASS always_open=true always_closed=true remember=true restart=true automatic_close=true') diff --git a/metamorph_creative_menu/tests/menu_navigation_bindings_mock.lua b/metamorph_creative_menu/tests/menu_navigation_bindings_mock.lua deleted file mode 100644 index 8a2c104..0000000 --- a/metamorph_creative_menu/tests/menu_navigation_bindings_mock.lua +++ /dev/null @@ -1,290 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile -local queued={} -local drawn={} -local acquired,released,text_suppressed=0,0,0 -local frame=1 -local saved={ - ['metamorph_creative_menu.ui_panel_x']=100, - ['metamorph_creative_menu.ui_panel_y']=20, - ['metamorph_creative_menu.ui_panel_width']=210, - ['metamorph_creative_menu.ui_panel_height']=170, -} -local left_just_down=false -local left_down=false -local mouse_gui_x,mouse_gui_y=80,30 -local click_label=nil -local resize_affordance_calls=0 -local highlighted_resize_edges=nil -local text_active=false -local focus_blurs=0 -local panel_bounds={x=0,y=0,width=0,height=0} -local affordance_bounds=nil -local button_labels={} -local drag_handle_calls={} -local tab_tiles={} -local auto_box_right_extra=0 - -local ui={ - ICON_STEP=20,EMPTY_SLOT='slot',audit=function() end,bind=function() end,begin_frame=function() end, - gui=function() return 1 end,tr=function(_,fallback) return fallback end, - tile=function(_,_,_,icon,fallback,title,description,selected,options) - tab_tiles[title]={icon=icon,fallback=fallback,description=description,selected=selected,options=options} - return false,false - end, - button=function(_,_,text) - button_labels[#button_labels+1]=text - if click_label~=nil and text==click_label then click_label=nil; return true end - return false - end, - drag_handle=function(_,_,layout_text,title,tooltip_title,tooltip_description) - drag_handle_calls[#drag_handle_calls+1]={layout_text=layout_text,title=title,tooltip_title=tooltip_title,tooltip_description=tooltip_description} - return false - end, - text_width=function(text) return #tostring(text)*5 end, - white_text=function() end,colored_text=function() end, - finish_auto_box=function() - return panel_bounds.x-4,panel_bounds.y-4,panel_bounds.width+8+auto_box_right_extra,panel_bounds.height+8 - end, - set_panel_bounds=function(x,y,width,height) - panel_bounds={x=x,y=y,width=width,height=height} - end, - resize_affordances=function(x,y,width,height,edges) - resize_affordance_calls=resize_affordance_calls+1 - highlighted_resize_edges=edges - affordance_bounds={x=x,y=y,width=width,height=height} - end, - hovered=function() return false end, - text_input_active=function() return text_active end, - blur_text_input=function() - if text_active then focus_blurs=focus_blurs+1; text_active=false; return true end - return false - end, -} -local action_bindings={ - update=function() end, - consume=function(id) if queued[id] then queued[id]=nil; return true end return false end, - label=function() return '—' end, -} -local guard={ - inventory_open=function() return false end, - acquire_manual_controls=function(player) assert(player==1); acquired=acquired+1; return true end, - suppress_text_controls=function(player) assert(player==1); text_suppressed=text_suppressed+1; return true end, - release_manual_controls=function() released=released+1; return true end, - capture_scroll_selection=function() return nil end, - restore_scroll_selection=function() end, -} -local stubs={ - ['mods/metamorph_creative_menu/files/ui/runtime.lua']=ui, - ['mods/metamorph_creative_menu/files/platform/noita/player_locator.lua']={get=function() return 1 end}, - ['mods/metamorph_creative_menu/files/platform/noita/input_guard.lua']={blocked=function() return false end,resume_serial=function() return 0 end}, - ['mods/metamorph_creative_menu/files/platform/noita/menu_inventory_guard.lua']=guard, - ['mods/metamorph_creative_menu/files/platform/noita/action_bindings.lua']=action_bindings, - ['mods/metamorph_creative_menu/files/ui/drag_drop.lua']={ - begin_frame=function() end, end_frame=function() end, cancel=function() end, - }, -} -for _,name in ipairs({'spells','items','materials','perks','creatures','effects','weather','world_rules','players','controls'}) do - stubs['mods/metamorph_creative_menu/files/ui/tabs/'..name..'.lua']={ - draw=function() drawn[#drawn+1]=name end, - warmup_step=function() return true end, - } -end -dofile=function(path) - if stubs[path] then return stubs[path] end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -GUI_OPTION={NoPositionTween=1} -GuiCreate=function() return 1 end -GuiStartFrame=function() end -GuiOptionsAdd=function() end -GuiGetScreenDimensions=function() return 320,200 end -GuiLayoutBeginVertical=function() end -GuiLayoutBeginHorizontal=function() end -GuiLayoutEnd=function() end -GuiBeginAutoBox=function() end -InputIsMouseButtonJustDown=function(code) return code==1 and left_just_down end -InputIsMouseButtonDown=function(code) return code==1 and left_down end -DEBUG_GetMouseWorld=function() return mouse_gui_x-160,mouse_gui_y-100 end -GameGetCameraPos=function() return 0,0 end -MagicNumbersGetValue=function(name) - if name=='VIRTUAL_RESOLUTION_X' then return '320' end - if name=='VIRTUAL_RESOLUTION_OFFSET_X' then return '0' end - return '0' -end -GameGetFrameNum=function() return frame end -ModSettingGet=function(id) return saved[id] end -ModSettingSet=function(id,value) saved[id]=value end - -METAMORPH_CREATIVE_MENU_MENU_CONTROLLER=nil -local controller=assert(native_dofile(root..'/files/ui/menu_controller.lua')) -queued.menu_toggle=true -controller.draw() -assert(controller.is_open() and controller.active_tab()=='spells' and drawn[#drawn]=='spells', - 'assignable menu key did not open the first useful section') -assert(acquired==0,'opening the menu alone suppressed gameplay controls') -assert(#drag_handle_calls>0 and drag_handle_calls[#drag_handle_calls].title=='SPELLS', - 'header did not expose the active section through the dedicated drag handle') -for _,label in ipairs(button_labels) do - assert(label~='<' and label~='>','legacy previous/next section button remained in the header') -end -for title,tile in pairs(tab_tiles) do - assert(tile.description==nil,'tab tooltip leaked an empty/service binding description for '..tostring(title)) - assert(not string.find(tostring(title),'\n',1,true) and tostring(title)~='—' and not string.find(tostring(title),'open_',1,true), - 'tab tooltip title leaked a second line/dash/technical id for '..tostring(title)) -end -assert(tab_tiles.PERKS.icon=='data/ui_gfx/perk_icons/respawn.png' - and tab_tiles.PERKS.fallback=='data/ui_gfx/perk_icons/extra_hp.png' - and tab_tiles.PERKS.options.fill>=1.5 and tab_tiles.PERKS.options.fill<=1.6 - and tab_tiles.PERKS.options.max_scale==3.5, - 'PERKS Extra Life icon did not keep the corrected moderate scale') -assert(tab_tiles.EFFECTS.icon=='data/ui_gfx/status_indicators/hp_regeneration.png', - 'EFFECTS did not use the healing/regeneration icon') -assert(tab_tiles.WEATHER.icon=='data/ui_gfx/status_indicators/wet.png', - 'WEATHER did not use the wet status icon') -assert(tab_tiles.TELEPORTATION.icon=='data/ui_gfx/gun_actions/teleport_projectile_static.png', - 'TELEPORTATION did not use the Return spell icon') -assert(tab_tiles.CONTROLS.icon=='data/ui_gfx/gun_actions/divide_2.png', - 'CONTROLS did not use the Divide by 2 spell icon') - -frame=2; text_active=true; controller.draw() -assert(acquired==0 and text_suppressed>0,'focused text input did not suppress transient gameplay actions without disabling controls') - -local suppressed_while_focused=text_suppressed - --- A context/tab switch releases the real focus owner before the next tab draws, so --- ControlsComponent is not kept disabled by a stale text-entry flag. -frame=2; queued.open_materials=true; controller.draw() -assert(controller.active_tab()=='materials' and drawn[#drawn]=='materials', - 'direct section shortcut did not navigate while menu was open') -assert(focus_blurs==1,'tab switch did not release focused text input') -assert(acquired==0 and text_suppressed==suppressed_while_focused,'stale text focus kept suppressing gameplay actions after tab switch') -assert(saved['metamorph_creative_menu.ui_last_tab']=='materials','last section was not persisted') - -frame=3; queued.tab_next=true; controller.draw() -assert(controller.active_tab()=='perks' and drawn[#drawn]=='perks','next-section action used wrong ordering') - -frame=4; queued.menu_toggle=true; local before=#drawn; controller.draw() -assert(not controller.is_open() and #drawn==before,'menu toggle did not close the manual panel') -assert(released>0,'manual menu did not release controls on close') - -frame=5; queued.open_controls=true; controller.draw() -assert(controller.is_open() and controller.active_tab()=='controls' and drawn[#drawn]=='controls', - 'direct section shortcut did not open a closed menu') - --- Only the click itself is modal: a panel click cannot also fire the held wand, while --- opening or hovering the menu leaves gameplay controls untouched. -local acquired_before_click=acquired -frame=51; left_just_down=true; left_down=true; mouse_gui_x=120; mouse_gui_y=90 -controller.draw() -assert(acquired==acquired_before_click+1,'panel click was not isolated from gameplay input') -frame=52; left_just_down=false; left_down=false; controller.draw() - --- The full title bar (except window controls) moves the panel, even outside the title --- glyphs themselves, and persists its position on release. -local drag_origin=assert(controller.layout()) -frame=6; left_just_down=true; left_down=true; mouse_gui_x=drag_origin.x+30; mouse_gui_y=drag_origin.y+6 -controller.draw() -frame=7; left_just_down=false; left_down=true; mouse_gui_x=drag_origin.x-10; mouse_gui_y=drag_origin.y+16 -controller.draw() -frame=8; left_down=false; controller.draw() -local moved=assert(controller.layout()) -assert(moved.x==60 and moved.y==20,'title bar did not move/clamp the panel by the mouse delta') -assert(saved['metamorph_creative_menu.ui_panel_x']==60 and saved['metamorph_creative_menu.ui_panel_y']==20, - 'dragged panel position was not persisted') - --- The right border changes width without losing position and persists on release. -frame=9; left_just_down=true; left_down=true; mouse_gui_x=275; mouse_gui_y=100 -controller.draw() -assert(type(highlighted_resize_edges)=='table' and highlighted_resize_edges.right, - 'right resize border did not expose a visible hover/drag affordance') -assert(affordance_bounds.x==56 and affordance_bounds.width==218, - 'custom frame did not overlay the measured outer AutoBox bounds') -frame=10; left_just_down=false; left_down=true; mouse_gui_x=295 -controller.draw() -frame=11; left_down=false; controller.draw() -local resized=assert(controller.layout()) -assert(resized.width==230 and resized.x==60,'resize grip did not widen from its drag origin') -assert(saved['metamorph_creative_menu.ui_panel_width']==230,'resized panel width was not persisted') - --- Hovering the border advertises resize without changing layout. -highlighted_resize_edges=nil -frame=111; left_just_down=false; left_down=false; mouse_gui_x=295; mouse_gui_y=100 -controller.draw() -assert(type(highlighted_resize_edges)=='table' and highlighted_resize_edges.right, - 'menu border did not advertise resize before the press') -assert(controller.layout().width==230,'border hover changed menu width') - --- A press that began somewhere else stays owned by whatever interaction started it. --- Crossing the frame while the button is held must not highlight or arm resize. This --- rule is intentionally independent of spell/item drag-drop payload types. -frame=112; left_just_down=true; left_down=true; mouse_gui_x=310; mouse_gui_y=100 -controller.draw() -highlighted_resize_edges=nil -frame=113; left_just_down=false; left_down=true; mouse_gui_x=295; mouse_gui_y=100 -controller.draw() -assert(highlighted_resize_edges==nil, - 'held pointer crossing the menu border incorrectly armed resize') -frame=114; left_down=false; controller.draw() -assert(controller.layout().width==230,'cross-border held pointer changed menu width') - --- The two-pixel outward part of the thicker visible frame is a real resize target, --- but only when the fresh press itself begins there. -highlighted_resize_edges=nil -frame=115; left_just_down=true; left_down=true; mouse_gui_x=295; mouse_gui_y=100 -controller.draw() -assert(type(highlighted_resize_edges)=='table' and highlighted_resize_edges.right, - 'fresh press on the expanded visible frame did not arm resize') -frame=116; left_just_down=false; left_down=false; controller.draw() - --- The bottom border changes height independently. -frame=12; left_just_down=true; left_down=true; mouse_gui_x=150; mouse_gui_y=196 -controller.draw() -frame=13; left_just_down=false; left_down=true; mouse_gui_y=166 -controller.draw() -frame=14; left_down=false; controller.draw() -assert(controller.layout().height==140,'bottom border did not resize panel height') -assert(saved['metamorph_creative_menu.ui_panel_height']==140,'resized panel height was not persisted') - --- A wild drag cannot strand the menu outside the viewport. -local before_wild=assert(controller.layout()) -frame=15; left_just_down=true; left_down=true; mouse_gui_x=before_wild.x+25; mouse_gui_y=before_wild.y+6 -controller.draw() -frame=16; left_just_down=false; left_down=true; mouse_gui_x=-500; mouse_gui_y=-500 -controller.draw() -frame=17; left_down=false; controller.draw() -local clamped=assert(controller.layout()) -assert(clamped.x==10 and clamped.y==10,'panel drag did not keep the visible resize frame inside the viewport') - --- Close is a real visibility transition: no minimized title-bar-only state remains. -frame=18; click_label='-'; controller.draw() -assert(not controller.is_open(),'hide button did not fully hide the menu') -frame=19; queued.menu_toggle=true; controller.draw() -assert(controller.is_open() and controller.layout().minimized==false,'F4 did not reopen fully closed menu') - --- Recovery button must ignore stale measured insets from the current/narrow layout. --- A content-driven AutoBox can temporarily report a large right inset; one reset click --- must still land at the true default instead of requiring a second click next frame. -auto_box_right_extra=80 -frame=191; controller.draw() -auto_box_right_extra=0 -frame=20; click_label='R'; controller.draw() -local reset=assert(controller.layout()) -assert(reset.x==114 and reset.y==20 and reset.width==196 and reset.height==170, - 'layout reset reused stale frame insets and required a second click') -assert(resize_affordance_calls>0,'resizable window corners were never drawn') - --- The reset/close control end of the title row is deliberately not a move target. -frame=21; left_just_down=true; left_down=true; mouse_gui_x=305; mouse_gui_y=22 -controller.draw() -frame=22; left_just_down=false; left_down=true; mouse_gui_x=250; mouse_gui_y=50 -controller.draw() -frame=23; left_down=false; controller.draw() -local controls_excluded=assert(controller.layout()) -assert(controls_excluded.x==114 and controls_excluded.y==20, - 'window-control area incorrectly started title-bar movement') - -print('menu_navigation_bindings=PASS useful_default=true toggle=true direct_tabs=true cycling=true persistence=true controls_guard=true focus_tab_blur=true styled_drag_handle=true no_header_arrows=true tab_tooltips_clean=true exact_tab_icons=true full_titlebar_drag=true controls_excluded=true border_hover=true press_origin=true expanded_frame_target=true noita_frame_affordance=true full_close=true viewport_clamp=true reset=true') diff --git a/metamorph_creative_menu/tests/menu_overlay_revert_contract.py b/metamorph_creative_menu/tests/menu_overlay_revert_contract.py deleted file mode 100644 index 4c24ed8..0000000 --- a/metamorph_creative_menu/tests/menu_overlay_revert_contract.py +++ /dev/null @@ -1,10 +0,0 @@ -from pathlib import Path -import sys -root=Path(sys.argv[1]).resolve() -menu=(root/'files/ui/menu_controller.lua').read_text() -runtime=(root/'files/ui/runtime.lua').read_text() -assert 'inventory_cursor_guard' not in menu, 'failed native tooltip shield is still wired into menu runtime' -assert not (root/'files/platform/noita/inventory_cursor_guard.lua').exists(), 'dead tooltip-shield module still ships in release' -assert 'fully opaque dark backing' not in runtime, 'opaque tooltip-occlusion backing still changes menu appearance' -assert '{0.018, 0.018, 0.012, 1.0}' not in runtime, 'opaque panel backing still present' -print('menu_overlay_revert_contract=PASS stock_transparency=true tooltip_shield_removed=true') diff --git a/metamorph_creative_menu/tests/native_gameover_ew_gate_mock.lua b/metamorph_creative_menu/tests/native_gameover_ew_gate_mock.lua deleted file mode 100644 index 3e08a19..0000000 --- a/metamorph_creative_menu/tests/native_gameover_ew_gate_mock.lua +++ /dev/null @@ -1,14 +0,0 @@ -local root=assert(arg[1],"root required") -local native_require=require -local require_calls=0 -ModIsEnabled=function(id) return id=="quant.ew" end -require=function(name) require_calls=require_calls+1; error("native require must not run in EW: "..tostring(name)) end -package.cpath="?.dll" -local patch=assert(loadfile(root.."/files/platform/noita/native_gameover_patch.lua"))() -local ok,reason=patch.install() -require=native_require -assert(ok==false and reason=="disabled_in_entangled_worlds","native Game Over patch was not disabled in EW") -assert(require_calls==0,"EW gate happened after native DLL require") -local status=patch.status() -assert(status.network_disabled==true and status.installed==false,"native EW-disabled status changed") -print("native_gameover_ew_gate=PASS require_skipped=true network_disabled=true") diff --git a/metamorph_creative_menu/tests/native_inventory_drop_bounds_mock.lua b/metamorph_creative_menu/tests/native_inventory_drop_bounds_mock.lua deleted file mode 100644 index 2ee3f3c..0000000 --- a/metamorph_creative_menu/tests/native_inventory_drop_bounds_mock.lua +++ /dev/null @@ -1,36 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -dofile=function(path) - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -local inventory_open=true -function GameIsInventoryOpen() return inventory_open end -function MagicNumbersGetValue(name) - if name=='INVENTORY_ICON_SIZE' then return '20' end - if name=='UI_BARS_POS_X' then return '20' end - if name=='UI_BARS_POS_Y' then return '20' end - return nil -end -function EntityGetAllChildren(entity) - if entity==1 then return {20} end - return {} -end -function EntityGetName(entity) if entity==20 then return 'inventory_full' end return '' end -function EntityGetFirstComponentIncludingDisabled(entity,kind) - if entity==1 and kind=='Inventory2Component' then return 11 end - return 0 -end -function ComponentGetValue2(component,field) - if component==11 and field=='full_inventory_slots_x' then return 16 end - if component==11 and field=='full_inventory_slots_y' then return 1 end - if component==11 and field=='quick_inventory_slots' then return 4 end - return nil -end -local slots=assert(native_dofile(root..'/files/platform/noita/inventory_slots.lua')) -local bounds=assert(slots.native_drop_bounds(1,'inventory_full',427,242)) -assert(bounds.x==87 and bounds.y==16 and bounds.width==320 and bounds.height==28,'native inventory fallback geometry changed') -inventory_open=false -assert(slots.native_drop_bounds(1,'inventory_full',427,242)==nil,'closed inventory remained a drop target') -print('native_inventory_drop_bounds=PASS open_only=true grid_geometry=true') diff --git a/metamorph_creative_menu/tests/negative_wand_mock.lua b/metamorph_creative_menu/tests/negative_wand_mock.lua deleted file mode 100644 index e29984b..0000000 --- a/metamorph_creative_menu/tests/negative_wand_mock.lua +++ /dev/null @@ -1,26 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local objects={gun_config={deck_capacity=6,actions_per_round=1,reload_time=-1,shuffle_deck_when_empty=false},gunaction_config={fire_rate_wait=-3,spread_degrees=0,speed_multiplier=1}} -local scalars={mana=50,mana_max=100,mana_charge_speed=20,item_recoil_recovery_speed=0,gun_level=1,never_reload=false,sprite_file='wand.png'} -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/platform/noita/wand.lua' then return { - ability=function() return 21 end, - get_object=function(_,o,f) local v=(objects[o] or {})[f]; return v,v~=nil end, - set_object=function(_,o,f,v) objects[o][f]=v; return true end, - get_scalar=function(_,f) return scalars[f],scalars[f]~=nil end, - set_scalar=function(_,f,v) scalars[f]=v; return true end, - } end - if path=='mods/metamorph_creative_menu/files/features/wands/sync.lua' then return {inventory=function() end} end - if path=='mods/metamorph_creative_menu/files/features/wands/appearance.lua' then return {set_visual=function() return true end} end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -EntityGetAllChildren=function() return {} end -METAMORPH_CREATIVE_MENU_WAND_SERVICE=nil -local service=assert(native_dofile(root..'/files/features/wands/service.lua')) -assert(service.set_stat(1,2,'reload_time',-1)==true and objects.gun_config.reload_time==-1,'negative integer -1 changed during rounding') -assert(service.set_stat(1,2,'fire_rate_wait',-3)==true and objects.gunaction_config.fire_rate_wait==-3,'negative fire-rate integer changed during rounding') -assert(service.set_stat(1,2,'reload_time',-1.4)==true and objects.gun_config.reload_time==-1,'negative nearest rounding biased downward') -assert(service.set_stat(1,2,'reload_time',-1.6)==true and objects.gun_config.reload_time==-2,'negative nearest rounding failed below half') -print('negative_wand=PASS exact_negative=true symmetric_rounding=true') diff --git a/metamorph_creative_menu/tests/noita_gameover_profile_contract.py b/metamorph_creative_menu/tests/noita_gameover_profile_contract.py deleted file mode 100644 index f8c6326..0000000 --- a/metamorph_creative_menu/tests/noita_gameover_profile_contract.py +++ /dev/null @@ -1,302 +0,0 @@ -from pathlib import Path -import os -import struct -import sys - -root = Path(sys.argv[1]).resolve() -exe = Path(os.environ.get("NOITA_EXE_UNDER_TEST", "/mnt/data/noita.exe")) -if not exe.is_file(): - print("noita_gameover_profile_contract=PASS binary_validation=SKIPPED adaptive_profile=true") - raise SystemExit(0) - -data = exe.read_bytes() - - -def u16(off: int) -> int: - return struct.unpack_from(" int: - return struct.unpack_from(" int: - return struct.unpack_from(" 0, "missing .text" -text_va = text["va"] -text_end = text_va + min(text["virtual_size"] or text["raw_size"], text["raw_size"]) - - -def va_to_off(va: int, size: int = 1) -> int: - rva = va - image_base - for s in sections: - span = max(s["virtual_size"], s["raw_size"]) - if s["rva"] <= rva and rva + size <= s["rva"] + span: - within = rva - s["rva"] - assert within + size <= s["raw_size"], f"VA {va:#x} has no file-backed bytes" - return s["raw_off"] + within - raise AssertionError(f"VA {va:#x} is outside mapped sections") - - -def read_va(va: int, size: int) -> bytes: - off = va_to_off(va, size) - return data[off:off + size] - - -def byte(va: int) -> int: - return data[va_to_off(va)] - - -def u32va(va: int) -> int: - return struct.unpack_from(" int: - return struct.unpack_from(" bool: - return text_va <= va < text_end - - -def is_writable_image(va: int) -> bool: - rva = va - image_base - for s in sections: - span = max(s["virtual_size"], s["raw_size"]) - if s["rva"] <= rva < s["rva"] + span: - return bool(s["characteristics"] & 0x80000000) - return False - - -def rel32_target(insn: int) -> int: - return insn + 5 + s32va(insn + 1) - - -def branch_target(branch: int) -> int: - op = byte(branch) - if op == 0x74: - rel = struct.unpack("b", bytes([byte(branch + 1)]))[0] - return branch + 2 + rel - if op == 0x0F and byte(branch + 1) == 0x84: - return branch + 6 + s32va(branch + 2) - return 0 - - -def find_ascii(needle: bytes) -> int: - target = needle + b"\0" - for s in sections: - if not (s["characteristics"] & 0x40000000): # readable - continue - if s["characteristics"] & 0x20000000: # executable - continue - blob = data[s["raw_off"]:s["raw_off"] + s["raw_size"]] - at = blob.find(target) - if at >= 0: - return s["va"] + at - return 0 - - -def find_function_start(inside: int, max_back: int = 256) -> int: - low = max(text_va, inside - max_back) - p = inside - while p >= low + 3: - p -= 1 - if read_va(p, 3) == b"\x55\x8b\xec": - return p - return 0 - - -def most_repeated_call_target(start: int, end: int, excluded: int) -> int: - counts = {} - start = max(start, text_va) - end = min(end, text_end) - for p in range(start, max(start, end - 4)): - if byte(p) != 0xE8: - continue - target = rel32_target(p) - if target == excluded or not is_text(target): - continue - counts[target] = counts.get(target, 0) + 1 - if not counts: - return 0 - target, count = max(counts.items(), key=lambda item: item[1]) - return target if count >= 2 else 0 - - -def locate_replay_sites(): - save_string = find_ascii(b"$menugameover_savereplay") - assert save_string, "Save Replay localization string not found" - for p in range(text_va, text_end - 5): - if byte(p) != 0x68 or u32va(p + 1) != save_string: - continue - label = p - begin = max(text_va, label - 128) - gate = skip = 0 - for q in range(begin, label - 36): - if byte(q) != 0xA1 or byte(q + 5) != 0xB9: - continue - if u32va(q + 1) != u32va(q + 6): - continue - if read_va(q + 10, 2) != b"\xff\x90": - continue - if read_va(q + 16, 4) != b"\x85\xc0\x0f\x84": - continue - if byte(q + 24) != 0x80 or byte(q + 25) != 0xB8 or byte(q + 30) != 0: - continue - if read_va(q + 31, 2) != b"\x0f\x84": - continue - target1 = q + 24 + s32va(q + 20) - target2 = q + 37 + s32va(q + 33) - if target1 != target2 or target1 <= label or target1 - label > 1024: - continue - gate, skip = q, target1 - break - if not gate: - continue - click = click_size = 0 - for q in range(label + 5, skip - 2): - if byte(q) != 0x84: - continue - if byte(q + 2) == 0x74: - target = branch_target(q + 2) - action = q + 4 - elif read_va(q + 2, 2) == b"\x0f\x84": - target = branch_target(q + 2) - action = q + 8 - else: - continue - if target == skip and 7 <= skip - action <= 32: - click, click_size = action, skip - action - break - if click: - return { - "string": save_string, - "gate": gate, - "label": label, - "click": click, - "click_size": click_size, - "skip": skip, - } - raise AssertionError("adaptive Save Replay builder signature not found") - - -def locate_gameover_core(): - event_string = find_ascii(b"event_cues/game_over/create") - assert event_string, "stock Game Over event string not found" - for xref in range(text_va, text_end - 5): - if byte(xref) != 0x68 or u32va(xref + 1) != event_string: - continue - cmp_site = set_flag = flag_offset = 0 - for p in range(max(text_va, xref - 192), xref - 7): - if byte(p) != 0x80 or byte(p + 1) != 0xB9 or byte(p + 6) != 0: - continue - candidate_offset = u32va(p + 2) - if candidate_offset > 0x10000: - continue - for q in range(p + 7, min(xref - 6, p + 96)): - if (byte(q) == 0xC6 and byte(q + 1) == 0x81 - and u32va(q + 2) == candidate_offset and byte(q + 6) == 1): - cmp_site, set_flag, flag_offset = p, q, candidate_offset - break - if cmp_site: - break - if not cmp_site: - continue - trigger_entry = find_function_start(cmp_site) - if not trigger_entry: - continue - init_call = 0 - for p in range(set_flag + 7, min(text_end - 5, set_flag + 32)): - if byte(p) == 0xE8: - init_call = rel32_target(p) - break - if not init_call or not is_text(init_call): - continue - get_global = most_repeated_call_target(set_flag + 7, xref + 160, init_call) - if not get_global: - continue - game_mode_pointer = 0 - for p in range(text_va + 10, text_end - 5): - if byte(p) != 0xE8 or rel32_target(p) != trigger_entry: - continue - s = p - 10 - if (read_va(s, 2) == b"\x8b\x0d" and read_va(s + 6, 2) == b"\x85\xc9" - and byte(s + 8) in (0x74, 0x75)): - candidate = u32va(s + 2) - if is_writable_image(candidate): - game_mode_pointer = candidate - break - if not game_mode_pointer: - continue - return { - "event_string": event_string, - "xref": xref, - "cmp": cmp_site, - "set": set_flag, - "flag_offset": flag_offset, - "trigger": trigger_entry, - "init": init_call, - "get_global": get_global, - "game_mode_pointer": game_mode_pointer, - } - raise AssertionError("adaptive stock Game Over trigger signature not found") - - -replay = locate_replay_sites() -core = locate_gameover_core() - -# Contract-level source checks: the production native module must stay address-independent. -native = (root / "native_src/mcm_native_gameover.c").read_text(encoding="utf-8") -facade = (root / "files/platform/noita/native_gameover_patch.lua").read_text(encoding="utf-8") -for old_va in ("0x006e62a7", "0x006e62e1", "0x006e6393", "0x01204bc0", "0x00439bb0", "0x0047d820"): - assert old_va.lower() not in native.lower(), f"fixed historical VA leaked into native source: {old_va}" -assert "fixed_exe_hash = false" in facade -assert "fixed_virtual_addresses = false" in facade -assert "$menugameover_savereplay" in native -assert "event_cues/game_over/create" in native -assert "resolve_import" in native and "parse_image_layout" in native - -print( - "noita_gameover_profile_contract=PASS " - "supplied_exe=true adaptive_profile=true fixed_addresses=false " - f"image_base={image_base:#x} image_size={size_of_image:#x} " - f"replay_gate={replay['gate']:#x} replay_label={replay['label']:#x} " - f"replay_click={replay['click']:#x} replay_click_size={replay['click_size']} " - f"gameover_trigger={core['trigger']:#x} gameover_flag_offset={core['flag_offset']:#x} " - f"game_mode_pointer={core['game_mode_pointer']:#x}" -) diff --git a/metamorph_creative_menu/tests/non_perk_states_mock.lua b/metamorph_creative_menu/tests/non_perk_states_mock.lua deleted file mode 100644 index 3d9b2f7..0000000 --- a/metamorph_creative_menu/tests/non_perk_states_mock.lua +++ /dev/null @@ -1,79 +0,0 @@ -local root=tostring(arg[1] or ".") -local old_dofile=dofile -local prefix="mods/metamorph_creative_menu/" -dofile=function(path) if string.sub(path,1,#prefix)==prefix then return old_dofile(root.."/"..string.sub(path,#prefix+1)) end return old_dofile(path) end - -local player=1 -local alive={[1]=true,[10]=true,[11]=true,[12]=true,[13]=true} -local parent={[10]=1,[11]=1,[12]=1,[13]=1} -local tags={[10]={essence_effect=true},[11]={essence_effect=true},[12]={essence_effect=true},[13]={greed_curse=true}} -local filenames={[10]="data/entities/misc/essences/fire.xml",[12]="data/entities/misc/essences/air.xml"} -local icons={[11]="$item_essence_fire"} -local globals={ESSENCE_FIRE_PICKUP_COUNT="2",ESSENCE_AIR_PICKUP_COUNT="1"} -local flags={essence_fire=true,essence_air=true,greed_curse=true} -local damage={projectile=1.69,explosion=3.38} - -function EntityGetIsAlive(e) return alive[e]==true end -function EntityGetAllChildren(e) local r={} for child,p in pairs(parent) do if p==e and alive[child] then r[#r+1]=child end end return r end -function EntityHasTag(e,t) return tags[e] and tags[e][t] or false end -function EntityGetFilename(e) return filenames[e] or "" end -function EntityGetFirstComponentIncludingDisabled(e,t) if t=="UIIconComponent" and icons[e] then return e+100 end end -function ComponentGetValue2(c,f) if f=="name" then return icons[c-100] end end -function EntityRemoveFromParent(e) parent[e]=nil end -function EntityKill(e) alive[e]=false end -function GlobalsGetValue(k,d) return globals[k] or d end -function GlobalsSetValue(k,v) globals[k]=v end -function GameHasFlagRun(k) return flags[k]==true end -function GameRemoveFlagRun(k) flags[k]=nil end -function GameAddFlagRun(k) flags[k]=true end -function EntityGetComponent(e,t) if e==player and t=="DamageModelComponent" then return {200} end return {} end -function ComponentObjectGetValue(c,o,f) return tostring(damage[f]) end -function ComponentObjectSetValue(c,o,f,v) damage[f]=tonumber(v) end - -local service=dofile("mods/metamorph_creative_menu/files/features/perks/non_perk_states.lua") -local listed=service.list(player) -assert(#listed==3,"expected fire, air and greed") -local ok,reason=service.remove(player,"essence_fire"); assert(ok,tostring(reason)) -assert(globals.ESSENCE_FIRE_PICKUP_COUNT=="0" and not flags.essence_fire,"fire state remained") -assert(not alive[10] and not alive[11],"fire children remained") -assert(math.abs(damage.projectile-1.0)<0.000001,"fire projectile multiplier not reversed") -assert(math.abs(damage.explosion-2.0)<0.000001,"fire explosion multiplier not reversed") -assert(alive[12],"removing fire killed another essence") -ok,reason=service.remove(player,"greed_curse"); assert(ok,tostring(reason)) -assert(not alive[13] and not flags.greed_curse and flags.greed_curse_gone,"greed runtime state remained") -assert(globals.ESSENCE_AIR_PICKUP_COUNT=="1" and flags.essence_air,"unrelated essence changed") -print("non_perk_states=PASS selective=true fire_inverse=true greed=true") - --- Standard grid semantics: one copy with LMB, all remaining copies with RMB, --- for every vanilla essence (including the earth essence with internal id "air"). -function RemoveFlagPersistent() error("active-state removal must preserve progression") end -for index,id in ipairs({"laser","fire","water","air","alcohol"}) do - service.remove(player,"essence_"..id) - local key="ESSENCE_"..string.upper(id).."_PICKUP_COUNT" - globals[key]="2"; flags["essence_"..id]=true - if id=="fire" then damage.projectile=1.69; damage.explosion=3.38 end - local owned={} - for copy=1,2 do - local effect=1000+index*10+copy*2 - local icon=effect+1 - owned[#owned+1]=effect; owned[#owned+1]=icon - for _,child in ipairs({effect,icon}) do - alive[child]=true; parent[child]=player; tags[child]={essence_effect=true} - end - filenames[effect]="data/entities/misc/essences/"..id..".xml" - icons[icon]="$item_essence_"..id - end - assert(service.remove_one(player,"essence_"..id)) - assert(globals[key]=="1" and flags["essence_"..id],"single copy did not preserve remaining state: "..id) - local remaining=0 - for _,child in ipairs(owned) do if alive[child] then remaining=remaining+1 end end - assert(remaining==2,"single copy did not remove one effect and one icon") - if id=="fire" then assert(math.abs(damage.projectile-1.3)<0.000001,"single fire inverse wrong") end - assert(service.remove(player,"essence_"..id)) - assert(globals[key]=="0" and not flags["essence_"..id],"remove-all state remained: "..id) - for _,child in ipairs(owned) do assert(not alive[child],"remove-all child remained") end - if id=="fire" then - assert(math.abs(damage.projectile-1)<0.000001 and math.abs(damage.explosion-2)<0.000001,"stacked fire inverse wrong") - end -end -print("non_perk_states_stacks=PASS essences=5 one=true all=true fire_inverse=true progression_preserved=true") diff --git a/metamorph_creative_menu/tests/patcher_bridge_ew_shared_path_mock.lua b/metamorph_creative_menu/tests/patcher_bridge_ew_shared_path_mock.lua deleted file mode 100644 index 14c0673..0000000 --- a/metamorph_creative_menu/tests/patcher_bridge_ew_shared_path_mock.lua +++ /dev/null @@ -1,19 +0,0 @@ -local root=assert(arg[1],"root required") -METAMORPH_CREATIVE_MENU_BRIDGE_API=nil -METAMORPH_CREATIVE_MENU_NP=nil -np=nil -local local_loader="mods/metamorph_creative_menu/NoitaPatcher/load.lua" -local ew_loader="mods/quant.ew/NoitaPatcher/load.lua" -local loaded={} -GameGetFrameNum=function() return 400 end -ModIsEnabled=function(id) return id=="quant.ew" end -ModDoesFileExist=function(path) return path==local_loader or path==ew_loader end -dofile_once=function(path) - loaded[#loaded+1]=path - assert(path==ew_loader,"MCM bootstrapped a second physical NoitaPatcher while EW is active: "..tostring(path)) - np={SerializeEntity=function() return "shared" end} -end -local bridge=assert(loadfile(root.."/files/platform/noita/patcher_bridge.lua"))() -local got=bridge.get({bootstrap_if_installed=true,capability="SerializeEntity"}) -assert(got==np and #loaded==1 and loaded[1]==ew_loader,"EW shared NoitaPatcher path was not reused") -print("patcher_bridge_ew_shared_path=PASS same_physical_loader=true split_crosscall_registry_avoided=true") diff --git a/metamorph_creative_menu/tests/perk_batch_queue_mock.lua b/metamorph_creative_menu/tests/perk_batch_queue_mock.lua deleted file mode 100644 index 768ed0b..0000000 --- a/metamorph_creative_menu/tests/perk_batch_queue_mock.lua +++ /dev/null @@ -1,226 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local frame=100 -local globals={} -local alive={[1]=true,[2]=true} -local next_entity=1000 -local entity_perk={} -local begin_count=0 -local commit_count=0 -local inverse_remove_count=0 -local visibility_syncs=0 -local inventory_syncs=0 -local pickup_attempts={} -local fail_at={} -local untracked={} -local scopes={} - -local transactions={ - begin=function(player,perk_id) - begin_count=begin_count+1 - return {player=player,perk_id=perk_id,transaction_id=begin_count} - end, - start_capture=function() return true end, - stop_capture=function() end, - commit=function(token) - commit_count=commit_count+1 - if untracked[tostring(token and token.perk_id or "")] then return false,"removed_component" end - return true,"tracked" - end, - revert_transaction=function() return true,"reverted" end, - has=function() return false end, - active_count=function() return 0 end, - source_count=function(perk_id) return tonumber(globals["PERK_PICKED_"..tostring(perk_id).."_PICKUP_COUNT"]) or 0 end, -} -local inverses={ - capture_pre_pickup=function() end, - has=function(perk_id) return tostring(perk_id)~="UNSAFE" end, - remove=function() inverse_remove_count=inverse_remove_count+1 return true,"inverse" end, - zero_cleanup=function() end, - maintenance_cleanup=function() end, - post_tracked_cleanup=function() end, -} -local root_companions={ - supports=function() return false end,capture_before=function() return nil end,commit=function() end, - abort_pickup=function() end,update=function() end,on_count_zero=function() end,debug=function() return {} end, -} -local nested={ - open_gamble_scope=function(player,tx) scopes[tx]={player=player,open=true}; return true end, - scope_open=function(player,tx) local s=scopes[tx]; return s~=nil and s.player==player and s.open==true end, - update=function() end,children=function() return {} end,clear_parent=function() end,register_child=function() end, - state_snapshot=function() return {scopes=0,children=0} end, -} -local presentation={ - expire_one_game_effect=function() end,on_count_zero=function() end,update=function() end, - rebind_player=function() end, -} -local locomotion={capture_if_idle=function() end,repair_if_idle=function() end,baseline_count=function() return 0 end} -local ew_visibility={refresh=function() visibility_syncs=visibility_syncs+1 return true end} -local ew_runtime={force_inventory_sync=function() inventory_syncs=inventory_syncs+1 return true end} -local ew_world={notify_world_item=function() return true end} - -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/features/perks/inverse_registry.lua" then return inverses end - if path=="mods/metamorph_creative_menu/files/features/perks/transactions.lua" then return transactions end - if path=="mods/metamorph_creative_menu/files/features/perks/root_companions.lua" then return root_companions end - if path=="mods/metamorph_creative_menu/files/features/perks/nested_pickups.lua" then return nested end - if path=="mods/metamorph_creative_menu/files/features/perks/presentation.lua" then return presentation end - if path=="mods/metamorph_creative_menu/files/features/perks/locomotion_guard.lua" then return locomotion end - if path=="mods/metamorph_creative_menu/files/integrations/ew/perk_visibility.lua" then return ew_visibility end - if path=="mods/metamorph_creative_menu/files/integrations/ew/runtime.lua" then return ew_runtime end - if path=="mods/metamorph_creative_menu/files/integrations/ew/world_items.lua" then return ew_world end - local prefix="mods/metamorph_creative_menu/" - if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -EntityGetIsAlive=function(id) return alive[id]==true end -EntityHasTag=function() return false end -EntityGetTransform=function(id) if alive[id] then return 10,20 end end -EntityGetName=function(id) return entity_perk[id] or "mock" end -EntityGetRootEntity=function(id) return id end -EntityGetComponentIncludingDisabled=function() return {} end -EntitySetComponentIsEnabled=function() end -EntityKill=function(id) alive[id]=false end -GameGetFrameNum=function() return frame end -GlobalsGetValue=function(k,d) return globals[k] or d end -GlobalsSetValue=function(k,v) globals[k]=tostring(v) end -GameRemoveFlagRun=function() end -GameAddFlagRun=function() end -get_perk_picked_flag_name=function(id) return "PERK_PICKED_"..tostring(id) end -perk_spawn=function(_,_,perk_id) - next_entity=next_entity+1; alive[next_entity]=true; entity_perk[next_entity]=perk_id; return next_entity -end -perk_pickup=function(entity,player) - local id=assert(entity_perk[entity],"missing perk id") - pickup_attempts[id]=(pickup_attempts[id] or 0)+1 - if fail_at[id]~=nil and pickup_attempts[id]==fail_at[id] then error("forced_"..id) end - local key="PERK_PICKED_"..id.."_PICKUP_COUNT" - globals[key]=tostring((tonumber(globals[key]) or 0)+1) -end - -local service=assert(native_dofile(root.."/files/features/perks/service.lua")) -local function perk(id) return {id=id,func=function() end,ui_name=id} end -local function count(id) return tonumber(globals["PERK_PICKED_"..id.."_PICKUP_COUNT"]) or 0 end -local function pump(job_id,max_frames) - local max_ops=0 - for _=1,(max_frames or 200) do - if service.job_status()==nil then break end - local before=begin_count - frame=frame+1 - service.update(1) - max_ops=math.max(max_ops,begin_count-before) - end - assert(service.job_status()==nil,"job did not finish: "..tostring(job_id)) - return max_ops -end - --- Queue supports exactly the selectable 1/10/100 sizes, with a fixed four-op budget. -for _,amount in ipairs({1,10,100}) do - local id="TAKE"..amount - local sync_before=inventory_syncs - local begin_before=begin_count - local ok,reason,job_id=service.start_take_job(1,perk(id),amount) - assert(ok,reason) - local max_ops=pump(job_id,80) - assert(count(id)==amount,"wrong queue count for "..amount) - assert(begin_count-begin_before==amount,"each copy must begin its own transaction") - assert(max_ops<=4,"batch exceeded per-frame budget") - local expected_groups=math.ceil(amount/4) - assert(inventory_syncs-sync_before==expected_groups,"deferred inventory sync was not grouped") -end -assert(commit_count==111,"every queued copy must commit independently") - --- Vanilla may successfully apply a perk whose destructive mutation cannot be safely --- inverted. The queue must stop, but the already-applied copy still counts and must be --- included in the grouped sync before the terminal notice is published. -untracked.UNTRACKED=true -local untracked_sync_before=inventory_syncs -assert(service.start_take_job(1,perk("UNTRACKED"),10)) -frame=450; service.update(1) -assert(service.job_status()==nil and count("UNTRACKED")==1,"untracked successful pickup was lost or queue continued") -assert(inventory_syncs-untracked_sync_before==1,"untracked successful pickup was not synchronized") -local untracked_notice=assert(service.consume_job_notice(),"missing untracked terminal notice") -assert(untracked_notice.completed==1 and untracked_notice.reason=="transaction:removed_component", - "untracked terminal status did not describe the applied prefix") - --- Normal separate clicks are no longer suppressed for 15 frames; only same-frame repeat is. -local direct=perk("DIRECT") -frame=500 -local ok1,r1=service.apply(1,direct) -frame=501 -local ok2,r2=service.apply(1,direct) -local ok3,r3=service.apply(1,direct) -assert(ok1 and ok2 and r1=="applied" and r2=="applied","separate-frame clicks were debounced") -assert(ok3==false and r3=="debounced","same-frame duplicate command was not blocked") -assert(count("DIRECT")==2,"direct click count mismatch") - --- Stop on first error, retain already committed copies, and report the exact canonical reason once. -local err=perk("ERROR") -fail_at.ERROR=4 -local sync_before_error=inventory_syncs -assert(service.start_take_job(1,err,10)) -frame=600; service.update(1) -assert(service.job_status()==nil,"failed job remained active") -assert(count("ERROR")==3,"failed batch rolled back previously confirmed copies") -assert(inventory_syncs-sync_before_error==1,"successful prefix was not flushed once") -local notice=assert(service.consume_job_notice(),"missing failure notice") -assert(notice.state=="error" and notice.reason=="pickup_failed","wrong failure reason: "..tostring(notice.reason)) -assert(service.consume_job_notice()==nil,"failure notice repeated") - --- Cancellation preserves the completed prefix and prevents further application. -local cancel=perk("CANCEL") -assert(service.start_take_job(1,cancel,100)) -frame=700; service.update(1) -assert(count("CANCEL")==4,"first cancel batch budget mismatch") -assert(service.cancel_job()) -for _=1,3 do frame=frame+1; service.update(1) end -assert(count("CANCEL")==4 and service.job_status()==nil,"cancelled job continued") -local cancel_notice=assert(service.consume_job_notice()); assert(cancel_notice.state=="cancelled") - --- A vanished or changed local player stops the job before it can touch another entity. -local vanish=perk("VANISH") -assert(service.start_take_job(1,vanish,10)); alive[1]=false; frame=800; service.update(1) -assert(count("VANISH")==0 and service.job_status()==nil,"vanished player job applied a perk") -assert(service.consume_job_notice().reason=="player_changed"); alive[1]=true -local changed=perk("CHANGED") -assert(service.start_take_job(1,changed,10)); frame=801; service.update(2) -assert(count("CHANGED")==0 and service.job_status()==nil,"job migrated to another player") -assert(service.consume_job_notice().reason=="player_changed") - --- GAMBLE opens a nested async scope. No second instance may start until that exact scope closes. -local gamble=perk("GAMBLE") -assert(service.start_take_job(1,gamble,10)); frame=900; service.update(1) -local g=assert(service.job_status()); assert(g.completed==1 and g.waiting_async==true,"GAMBLE did not enter async wait") -local attempts_after_first=pickup_attempts.GAMBLE -frame=901; service.update(1) -assert(pickup_attempts.GAMBLE==attempts_after_first,"next GAMBLE started before nested transaction settled") -local waiting_tx=nil -for tx,s in pairs(scopes) do if s.open then waiting_tx=tx end end -assert(waiting_tx~=nil); scopes[waiting_tx].open=false -frame=902; service.update(1) -g=assert(service.job_status()); assert(g.completed==2 and g.waiting_async==true,"GAMBLE did not resume exactly one instance") -assert(service.cancel_job()); service.consume_job_notice() - --- REMOVE ALL is likewise budgeted; it never performs the old synchronous UI loop. -local rem=perk("REMOVE") -globals.PERK_PICKED_REMOVE_PICKUP_COUNT="11" -local inv_before=inverse_remove_count -assert(service.start_remove_all_job(1,rem)) -local max_remove=0 -for _=1,10 do - if service.job_status()==nil then break end - local before=inverse_remove_count - frame=frame+1; service.update(1) - max_remove=math.max(max_remove,inverse_remove_count-before) -end -assert(service.job_status()==nil and count("REMOVE")==0,"REMOVE ALL did not finish") -assert(inverse_remove_count-inv_before==11,"REMOVE ALL operation count mismatch") -assert(max_remove<=4,"REMOVE ALL exceeded per-frame budget") - -local unsafe={id="UNSAFE",func=function() end} -globals.PERK_PICKED_UNSAFE_PICKUP_COUNT="3" -local unsafe_ok,unsafe_reason=service.start_remove_all_job(1,unsafe) -assert(unsafe_ok==false and unsafe_reason=="requires_inverse","unsafe perk entered removal queue") - -print("perk_batch_queue=PASS amounts=1,10,100 budget=4 transaction_per_copy=true debounce=same_frame sync_grouped=true error_stop=true cancel=true player_guard=true gamble_wait=true remove_all_bounded=true") diff --git a/metamorph_creative_menu/tests/perk_drag_interaction_mock.lua b/metamorph_creative_menu/tests/perk_drag_interaction_mock.lua deleted file mode 100644 index f98c498..0000000 --- a/metamorph_creative_menu/tests/perk_drag_interaction_mock.lua +++ /dev/null @@ -1,82 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local perk={id="TEST_PERK",ui_name="Test Perk",ui_description="Desc",ui_icon="perk.png",func=function() end} -local source_calls,ghosts,audits,prints={},{},{},{} -local nearby_calls,world_calls,apply_calls={},{},{} -local queued_result=nil -local drag_active=false -local active_payload=nil -local tile_action={} -local panel={x=0,y=0,width=140,height=180} -local ui={ - ICON_STEP=20,EMPTY_SLOT="slot",audit=function(e,d) audits[#audits+1]={e,d} end,gui=function() return 1 end, - tr=function(_,fallback) return fallback end,translated=function(v) return v end, - white_text=function() end,wrapped_text=function() end,search_input=function(v) return v end,search_status=function() end, - scroll_height=function() return 90 end,columns=function() return 4 end,rank_entries=function(_,entries) return entries end, - resolve=function(p) return p end,dimensions=function() return 18,18 end, - button_grid=function() return nil end, - begin_scroll_viewport=function() return {padding_left=0,content_width=100,x=10,y=20,width=100,height=80} end, - scroll_y=function(_,y) return y end,end_scroll_viewport=function() end, - tile=function(x,y) - local a=tile_action; return a.clicked==true,a.right==true,a.hovered==true,x,y,18,18 - end, - panel_bounds=function() return panel end, - drag_ghost=function(bg,icon,x,y) ghosts[#ghosts+1]={bg=bg,icon=icon,x=x,y=y} end, -} -local service={ - count=function() return 0 end,can_remove=function() return true end,job_status=function() return nil end,consume_job_notice=function() return nil end, - spawn=function(player,data) nearby_calls[#nearby_calls+1]={player=player,id=data.id}; return true,"spawned",71 end, - spawn_at=function(data,x,y) world_calls[#world_calls+1]={id=data.id,x=x,y=y}; return true,"spawned",72 end, - apply=function(player,data) apply_calls[#apply_calls+1]={player=player,id=data.id}; return true,"applied" end, -} -local drag_drop={ - take_result=function() local r=queued_result; queued_result=nil; return r end, - source=function(id,payload,bounds,clip) source_calls[#source_calls+1]={id=id,payload=payload,bounds=bounds,clip=clip}; return true end, - active=function() return drag_active end, - payload=function() return active_payload end, - mouse_position=function() return 211,133 end, -} -local catalog={all=function() return {perk} end} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/ui/runtime.lua" then return ui end - if path=="mods/metamorph_creative_menu/files/ui/drag_drop.lua" then return drag_drop end - if path=="mods/metamorph_creative_menu/files/features/perks/service.lua" then return service end - if path=="mods/metamorph_creative_menu/files/features/perks/catalog.lua" then return catalog end - return native_dofile(path) -end -GuiLayoutBeginVertical=function() end; GuiLayoutBeginHorizontal=function() end; GuiLayoutEnd=function() end -InputIsMouseButtonDown=function() return false end -GamePrint=function(text) prints[#prints+1]=tostring(text) end -local tab=assert(native_dofile(root.."/files/ui/tabs/perks.lua")) -local function payload() return {kind="catalog_perk",id=perk.id,data=perk,display_name="Test Perk",background="slot",icon="perk.png"} end -local function draw() tab.draw(1,140,180); tile_action={} end - -draw() -assert(#source_calls==1 and source_calls[1].payload.kind=="catalog_perk" and source_calls[1].payload.id==perk.id, - "perk catalog did not register drag source") -assert(source_calls[1].bounds.width==18 and source_calls[1].clip.x==10,"perk drag source bounds/clip changed") - -queued_result={click=true,payload=payload(),release_x=5,release_y=5,world_x=1,world_y=2} -tile_action.clicked=true -draw() -assert(#nearby_calls==1 and #world_calls==0,"short perk click did not remain exactly one nearby spawn") - -queued_result={click=false,target=nil,payload=payload(),release_x=200,release_y=150,world_x=777,world_y=-55} -draw() -assert(#world_calls==1 and world_calls[1].x==777 and world_calls[1].y==-55,"perk drag lost exact world coordinates") - -queued_result={click=false,target=nil,payload=payload(),release_x=100,release_y=100,world_x=999,world_y=999} -draw(); assert(#world_calls==1,"in-menu perk drag created a world perk") -local before_prints=#prints -queued_result={click=false,target=nil,payload=payload(),release_x=200,release_y=150,world_x=nil,world_y=nil} -draw(); assert(#world_calls==1 and #prints==before_prints+1,"missing perk world position was not a visible failure") - -queued_result={click=false,target="future.target",payload=payload(),release_x=200,release_y=150,world_x=5,world_y=6} -draw(); assert(#world_calls==1,"perk drag executed after another target accepted release") - -tile_action.right=true -draw(); assert(#apply_calls==1 and apply_calls[1].id==perk.id,"RMB TAKE regressed after perk drag support") - -drag_active=true; active_payload=payload(); draw(); drag_active=false; active_payload=nil -assert(#ghosts==1 and ghosts[1].x==211 and ghosts[1].y==133,"perk drag ghost missing") -print("perk_drag_interaction=PASS source=true click_once=true exact_world=true in_menu_cancel=true missing_coords=true rmb_take=true ghost=true") diff --git a/metamorph_creative_menu/tests/perk_failed_pickup_rollback_backoff_mock.lua b/metamorph_creative_menu/tests/perk_failed_pickup_rollback_backoff_mock.lua deleted file mode 100644 index be5562c..0000000 --- a/metamorph_creative_menu/tests/perk_failed_pickup_rollback_backoff_mock.lua +++ /dev/null @@ -1,66 +0,0 @@ -local root=assert(arg[1], 'root required') -local native_dofile=dofile -local frame=0 -local revert_calls=0 -local revert_should_succeed=false - -local transactions={} -function transactions.has() return false end -function transactions.begin(player,perk_id) return {player=player,perk_id=perk_id,transaction_id=99} end -function transactions.start_capture() return true end -function transactions.stop_capture() end -function transactions.commit() return true,'tracked' end -function transactions.revert_transaction() - revert_calls=revert_calls+1 - return revert_should_succeed,'stuck' -end -function transactions.update() end -function transactions.active_count() return 0 end - -local stubs={ - ['mods/metamorph_creative_menu/files/features/perks/inverse_registry.lua']={has=function() return false end,capture_pre_pickup=function() end}, - ['mods/metamorph_creative_menu/files/features/perks/transactions.lua']=transactions, - ['mods/metamorph_creative_menu/files/features/perks/root_companions.lua']={supports=function() return false end,abort_pickup=function() end,update=function() end,debug=function() return {} end}, - ['mods/metamorph_creative_menu/files/features/perks/nested_pickups.lua']={update=function() end,state_snapshot=function() return {scopes=0,children=0} end}, - ['mods/metamorph_creative_menu/files/features/perks/locomotion_guard.lua']={capture_if_idle=function() end,repair_if_idle=function() end,baseline_count=function() return 0 end}, - ['mods/metamorph_creative_menu/files/features/perks/presentation.lua']={update=function() end}, - ['mods/metamorph_creative_menu/files/integrations/ew/world_items.lua']={notify_world_item=function() return true,'singleplayer' end}, - ['mods/metamorph_creative_menu/files/integrations/ew/perk_visibility.lua']={refresh=function() return true,'singleplayer' end}, - ['mods/metamorph_creative_menu/files/integrations/ew/runtime.lua']={force_inventory_sync=function() return true end}, -} -dofile=function(path) if stubs[path]~=nil then return stubs[path] end return native_dofile(path) end - -local alive={[1]=true,[77]=true} -function EntityGetIsAlive(id) return alive[id]==true end -function EntityHasTag() return false end -function EntityGetTransform(id) if id==1 then return 10,20 end end -function GameGetFrameNum() return frame end -function GlobalsGetValue(_,default) return default or '0' end -function GlobalsSetValue() end -function GameRemoveFlagRun() end -function GameAddFlagRun() end -function EntityGetRootEntity(id) return id end -function EntityGetComponentIncludingDisabled() return {} end -function EntitySetComponentIsEnabled() end -function EntityKill(id) alive[id]=false end -function EntityGetName() return 'perk' end -function perk_spawn() alive[77]=true; return 77 end -function perk_pickup() error('intentional pickup failure') end - -METAMORPH_CREATIVE_MENU_PERK_SERVICE=nil -local service=assert(native_dofile(root..'/files/features/perks/service.lua')) -local ok,reason=service.apply(1,{id='TEST_PERK'},{ignore_debounce=true}) -assert(ok==false and string.find(reason,'pickup_failed_partial',1,true),'test did not enter failed rollback path') -assert(revert_calls==1,'initial rollback attempt count changed') -for _=1,29 do frame=frame+1; service.update(0) end -assert(revert_calls==1,'failed pickup rollback retried every frame') -frame=30; service.update(0) -assert(revert_calls==2,'failed pickup rollback did not retry after initial backoff') -for f=31,89 do frame=f; service.update(0) end -assert(revert_calls==2,'failed pickup rollback ignored doubled backoff') -revert_should_succeed=true -frame=90; service.update(0) -assert(revert_calls==3,'failed pickup rollback did not retry after doubled backoff') -frame=91; service.update(0) -assert(revert_calls==3,'successful rollback remained queued') -print('perk_failed_pickup_rollback_backoff=PASS bounded_retry=true clears_on_success=true') diff --git a/metamorph_creative_menu/tests/perk_pending_cleanup_timeout_mock.lua b/metamorph_creative_menu/tests/perk_pending_cleanup_timeout_mock.lua index 0c4f4f9..5032862 100644 --- a/metamorph_creative_menu/tests/perk_pending_cleanup_timeout_mock.lua +++ b/metamorph_creative_menu/tests/perk_pending_cleanup_timeout_mock.lua @@ -1,22 +1,12 @@ -local root=assert(arg[1]); local frame=0; local alive={[101]=true}; local kill_calls=0 +local root=assert(arg[1]); local frame=0; local alive={[101]=true} function GameGetFrameNum() return frame end function EntityGetIsAlive(id) return alive[id]==true end function EntityGetComponentIncludingDisabled() return {} end function EntitySetComponentIsEnabled() end -function EntityKill(_) kill_calls=kill_calls+1 end +function EntityKill(_) end local pending=assert(dofile(root..'/files/features/perks/transactions/pending_cleanup.lua')) assert(select(1,pending.retire_entity(101,'stuck-test'))==true) for _=1,31 do frame=frame+1; pending.update() end local state=pending.state_snapshot(); assert(state.pending==0 and state.failed==1,'true residue not detected') assert(string.find(table.concat(state.failed_items,','),'entity:101',1,true),'exact id missing') -local calls_at_timeout=kill_calls -for _=1,28 do frame=frame+1; pending.update() end -assert(kill_calls==calls_at_timeout,'failed residue was retried every frame instead of backing off') -frame=frame+1; pending.update() -assert(kill_calls==calls_at_timeout+1,'failed residue was not retried after initial backoff') -local calls_after_retry=kill_calls -for _=1,59 do frame=frame+1; pending.update() end -assert(kill_calls==calls_after_retry,'failed residue ignored exponential retry delay') -frame=frame+1; pending.update() -assert(kill_calls==calls_after_retry+1,'second failed retry did not occur after doubled backoff') -print('perk_pending_cleanup_timeout=PASS persistent_residue_detected_async=true bounded_retry_backoff=true') +print('perk_pending_cleanup_timeout=PASS persistent_residue_detected_async=true') diff --git a/metamorph_creative_menu/tests/perk_scroll_surface_mock.lua b/metamorph_creative_menu/tests/perk_scroll_surface_mock.lua deleted file mode 100644 index be27cc6..0000000 --- a/metamorph_creative_menu/tests/perk_scroll_surface_mock.lua +++ /dev/null @@ -1,35 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile -local buttons={} -local tiles=0 -local ui={ - ICON_STEP=20,EMPTY_SLOT='slot',audit=function() end,gui=function() return 1 end, - tr=function(_,fallback) return fallback end,translated=function(v) return v end, - white_text=function() end,wrapped_text=function() end,search_input=function(v) return v end, - scroll_height=function() return 90 end,columns=function() return 4 end,search_status=function() end, - rank_entries=function(_,entries) return entries end,resolve=function(p) return p end,dimensions=function() return 18,18 end, - button=function(_,_,label) buttons[#buttons+1]=tostring(label); return false end, - button_grid=function(items) for _,item in ipairs(items or {}) do buttons[#buttons+1]=tostring(item.label or '') end; return nil end, - begin_scroll_viewport=function(key,id,x,y,w,h) return {padding_left=2,content_width=math.max(20,w-12)} end, - scroll_y=function(_,y) return y end,end_scroll_viewport=function(_,height) _G.__perk_content_height=height end, - tile=function() tiles=tiles+1; return false,false,false end, -} -local service={count=function() return 0 end,can_remove=function() return true end,job_status=function() return nil end,consume_job_notice=function() return nil end} -local list={} -for i=1,60 do list[i]={id='P'..i,ui_name='Perk '..i,ui_description='Desc',ui_icon='perk.png'} end -local catalog={all=function() return list end} -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/ui/runtime.lua' then return ui end - if path=='mods/metamorph_creative_menu/files/ui/drag_drop.lua' then return {take_result=function() return nil end,source=function() end,active=function() return false end} end - if path=='mods/metamorph_creative_menu/files/features/perks/service.lua' then return service end - if path=='mods/metamorph_creative_menu/files/features/perks/catalog.lua' then return catalog end - return native_dofile(path) -end -GuiLayoutBeginVertical=function() end; GuiLayoutBeginHorizontal=function() end; GuiLayoutEnd=function() end -InputIsMouseButtonDown=function() return false end; GamePrint=function() end -local tab=assert(native_dofile(root..'/files/ui/tabs/perks.lua')) -tab.draw(1,220,180) -for _,label in ipairs(buttons) do assert(label~='<' and label~='>','perk catalogue still exposes page arrows') end -assert(tiles==60,'perk catalogue did not render one continuous scroll surface: '..tostring(tiles)) -assert(tonumber(_G.__perk_content_height or 0)>0,'perk scroll content height missing') -print('perk_scroll_surface=PASS arrows=false continuous=true tiles='..tiles) diff --git a/metamorph_creative_menu/tests/perk_spawn_mock.lua b/metamorph_creative_menu/tests/perk_spawn_mock.lua index d7295a4..5f3a864 100644 --- a/metamorph_creative_menu/tests/perk_spawn_mock.lua +++ b/metamorph_creative_menu/tests/perk_spawn_mock.lua @@ -1,8 +1,6 @@ local root = assert(arg[1], "root required") local native_dofile = dofile local spawn_call = nil -local notified_entity = nil -local registered_entity, registered_perk = nil, nil local stubs = { ["mods/metamorph_creative_menu/files/features/perks/inverse_registry.lua"]={has=function() return false end}, @@ -11,11 +9,6 @@ local stubs = { ["mods/metamorph_creative_menu/files/features/perks/nested_pickups.lua"]={update=function() end,state_snapshot=function() return {scopes=0,children=0} end}, ["mods/metamorph_creative_menu/files/features/perks/locomotion_guard.lua"]={capture_if_idle=function() end,repair_if_idle=function() end,baseline_count=function() return 0 end}, ["mods/metamorph_creative_menu/files/features/perks/presentation.lua"]={update=function() end}, - ["mods/metamorph_creative_menu/files/integrations/ew/world_items.lua"]={ - notify_world_item=function(entity) notified_entity=entity; return true,"direct" end, - register_creative_perk=function(entity,perk_id) registered_entity,registered_perk=entity,perk_id; return true,"registered" end, - }, - ["mods/metamorph_creative_menu/files/integrations/ew/perk_visibility.lua"]={refresh=function() return true,"singleplayer" end}, } dofile=function(path) if stubs[path]~=nil then return stubs[path] end @@ -29,6 +22,4 @@ local service=assert(native_dofile(root.."/files/features/perks/service.lua")) local ok,reason,entity=service.spawn(1,{id="EXTRA_HP"}) assert(ok==true and reason=="spawned" and entity==77,"LMB perk spawn failed") assert(spawn_call.x==112 and spawn_call.y==192 and spawn_call.id=="EXTRA_HP","perk world spawn placement/id changed") -assert(notified_entity==77,"spawned vanilla perk was not handed to the optional EW world-item adapter") -assert(registered_entity==77 and registered_perk=="EXTRA_HP","creative perk was not registered for stock-peer consume correlation") -io.write("perk_spawn=PASS lmb=true world_item_handoff=true stock_peer_watch=true\n") +io.write("perk_spawn=PASS lmb=true\n") diff --git a/metamorph_creative_menu/tests/perk_states_grid_mock.lua b/metamorph_creative_menu/tests/perk_states_grid_mock.lua deleted file mode 100644 index 601cae3..0000000 --- a/metamorph_creative_menu/tests/perk_states_grid_mock.lua +++ /dev/null @@ -1,64 +0,0 @@ -local root=assert(arg[1], 'root required') -local original_dofile=dofile -local mode_click, clicked_name, right_name=nil,nil,nil -local rendered, labels, events={}, {}, {} -local active=true -local ui={ICON_STEP=20,EMPTY_SLOT='slot',audit=function() end,gui=function() return 1 end, - tr=function(_,fallback) return fallback end,translated=function(v) return ({['$ordinary']='Ordinary',['$earth']='Earth essence',['$greed']='Greed'})[v] or v or '' end, - white_text=function(_,_,v) labels[#labels+1]=v end,wrapped_text=function() end, - search_input=function(v) return v end,scroll_height=function() return 90 end, - columns=function() return 4 end,search_status=function() end, - rank_entries=function(_,entries) return entries end,resolve=function(p) return p end, - dimensions=function() return 18,18 end, - button_grid=function(items) - for _,item in ipairs(items) do labels[#labels+1]=item.label end - if items[1].label=='ADD' then local result=mode_click; mode_click=nil; return result end - end, - begin_scroll_viewport=function() return {padding_left=2,content_width=200} end, - scroll_y=function(_,y) return y end,end_scroll_viewport=function() end, - tile=function(_,_,_,icon,_,name) - rendered[#rendered+1]={name=name,icon=icon} - return name==clicked_name,name==right_name,true - end, -} -local states={} -function states.list() - if not active then return {} end - return {{id='essence_air',name_key='$earth',description_key='Earth description', - icon='earth.png',count=2},{id='greed_curse',name_key='$greed',description_key='Curse',icon='greed.png',count=1}} -end -function states.remove_one(_,id) events[#events+1]='one:'..id; return true end -function states.remove(_,id) events[#events+1]='all:'..id; active=false; return true end -local service={count=function() return 1 end,can_remove=function(perk) - assert(perk.id=='ORDINARY','essence sent to perk inverse system'); return true end, - job_status=function() return nil end,consume_job_notice=function() return nil end, - remove_one=function(_,perk) events[#events+1]='perk:'..perk.id; return true end} -dofile=function(path) - if path:match('/ui/runtime.lua$') then return ui end - if path:match('/ui/drag_drop.lua$') then return {take_result=function() end,source=function() end,active=function() return false end} end - if path:match('/perks/service.lua$') then return service end - if path:match('/perks/non_perk_states.lua$') then return states end - if path:match('/perks/catalog.lua$') then return {all=function() return {{id='ORDINARY',ui_name='$ordinary',ui_description='Description',ui_icon='perk.png'}} end} end - return original_dofile(path) -end -GuiLayoutBeginVertical=function() end; GuiLayoutEnd=function() end -InputIsMouseButtonDown=function() return false end; GamePrint=function() end -local tab=original_dofile(root..'/files/ui/tabs/perks.lua') -local function draw() - rendered,labels={},{}; tab.draw(1,220,180) - for _,label in ipairs(labels) do - assert(label~='ESSENCES / CURSES' and not label:find('REMOVE:',1,true),'separate removal UI remained') - end -end -draw(); assert(#rendered==1 and rendered[1].name=='Ordinary','state leaked into ADD: '..#rendered..' '..tostring(rendered[1] and rendered[1].name)) -mode_click=2; draw(); assert(#rendered==3,'active states missing from REMOVE grid') -assert(rendered[2].icon=='earth.png','essence icon requires a hover to load') -clicked_name='Earth essence'; draw(); clicked_name=nil -assert(events[#events]=='one:essence_air','LMB did not remove one essence') -clicked_name='Ordinary'; draw(); clicked_name=nil -assert(events[#events]=='perk:ORDINARY','normal perk removal changed') -right_name='Earth essence'; draw(); right_name=nil -assert(events[#events]=='all:essence_air','RMB did not remove all essence copies') -draw(); assert(#rendered==1,'removed state remained in grid') -mode_click=1; draw(); assert(#rendered==1,'ADD catalogue changed after removal') -print('perk_states_grid=PASS add_clean=true standard_grid=true left_one=true right_all=true normal_perks=true') diff --git a/metamorph_creative_menu/tests/perk_tab_batch_ui_mock.lua b/metamorph_creative_menu/tests/perk_tab_batch_ui_mock.lua deleted file mode 100644 index 0633a43..0000000 --- a/metamorph_creative_menu/tests/perk_tab_batch_ui_mock.lua +++ /dev/null @@ -1,84 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local draw_index=0 -local tile_mode="left" -local select_100=true -local right_down=false -local spawn_calls=0 -local apply_calls=0 -local queue_calls={} -local cancel_calls=0 -local active=nil -local texts={} -local ui={ - ICON_STEP=20,EMPTY_SLOT="slot",audit=function() end,gui=function() return 1 end, - tr=function(_,fallback) return fallback end,translated=function(v) return v end, - white_text=function(_,_,text) texts[#texts+1]=tostring(text) end, - wrapped_text=function(_,_,text) texts[#texts+1]=tostring(text) end, - search_input=function(v) return v end,columns=function() return 4 end,scroll_height=function() return 90 end, - begin_scroll_viewport=function(_,_,_,_,w,h) return {padding_left=2,state={offset=0},height=h,content_width=math.max(20,w-12)} end, - scroll_y=function(_,y) return y end,end_scroll_viewport=function() end,search_status=function() end, - rank_entries=function(_,entries) return entries end,resolve=function(p) return p end,dimensions=function() return 18,18 end, - button=function(_,_,label) - if label=="100" and select_100 then select_100=false; return true end - if label=="CANCEL" and active~=nil and draw_index>=4 then return true end - return false - end, - button_grid=function(items) - for index,item in ipairs(items or {}) do - if item.label=="100" and select_100 then select_100=false; return index end - if item.label=="CANCEL" and active~=nil and draw_index>=4 then return index end - end - return nil - end, - tile=function() - if tile_mode=="left" then return true,false,false end - if tile_mode=="right" then return false,true,false end - return false,false,false - end, -} -local service={ - count=function() return 0 end,can_remove=function() return true end, - spawn=function(_,perk) spawn_calls=spawn_calls+1; return true,"spawned",123 end, - apply=function() apply_calls=apply_calls+1; return true,"applied" end, - start_take_job=function(_,perk,amount) queue_calls[#queue_calls+1]={id=perk.id,amount=amount}; active={kind="take",perk_id=perk.id,total=amount,completed=0}; return true,"queued" end, - job_status=function() return active end, - consume_job_notice=function() return nil end, - cancel_job=function() cancel_calls=cancel_calls+1; active=nil; return true end, -} -local catalog={all=function() return {{id="TEST",ui_name="Test",ui_description="Desc",ui_icon="perk.png",func=function() end}} end} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/ui/runtime.lua" then return ui end - if path=="mods/metamorph_creative_menu/files/ui/drag_drop.lua" then return { - take_result=function() - if draw_index==1 then return {click=true,payload={kind="catalog_perk",id="TEST",data={id="TEST",func=function() end},display_name="Test"}} end - return nil - end, - source=function() end, active=function() return false end, - } end - if path=="mods/metamorph_creative_menu/files/features/perks/service.lua" then return service end - if path=="mods/metamorph_creative_menu/files/features/perks/catalog.lua" then return catalog end - return native_dofile(path) -end -GuiLayoutBeginVertical=function() end; GuiLayoutBeginHorizontal=function() end; GuiLayoutEnd=function() end -InputIsMouseButtonDown=function(code) return code==2 and right_down end -GamePrint=function() end - -local tab=assert(native_dofile(root.."/files/ui/tabs/perks.lua")) --- Select TAKE 100, but LMB must still spawn exactly one physical pickup. -draw_index=1; tile_mode="left"; tab.draw(1,220,180) -assert(spawn_calls==1 and apply_calls==0 and #queue_calls==0,"LMB did not remain a single physical spawn") --- RMB uses the selected amount and queues rather than applying synchronously. -draw_index=2; tile_mode="right"; right_down=true; tab.draw(1,220,180) -assert(#queue_calls==1 and queue_calls[1].amount==100,"RMB did not queue selected TAKE amount") -assert(apply_calls==0 and spawn_calls==1,"mass TAKE created physical pickups or immediate apply") --- A held RMB does not enqueue the same command again. -draw_index=3; tile_mode="right"; right_down=true; tab.draw(1,220,180) -assert(#queue_calls==1,"held RMB repeated batch command") --- Progress is rendered and CANCEL terminates the current job. -draw_index=4; tile_mode="none"; right_down=false; tab.draw(1,220,180) -assert(cancel_calls==1 and active==nil,"CANCEL did not stop active job") -local saw_progress=false -for _,text in ipairs(texts) do if string.find(text,"0/100",1,true) then saw_progress=true end end -assert(saw_progress,"active batch progress was not rendered") -print("perk_tab_batch_ui=PASS take_selector=true lmb_single_pickup=true rmb_queue=true held_guard=true progress=true cancel=true") diff --git a/metamorph_creative_menu/tests/perk_tab_unsafe_removal_mock.lua b/metamorph_creative_menu/tests/perk_tab_unsafe_removal_mock.lua deleted file mode 100644 index a739aed..0000000 --- a/metamorph_creative_menu/tests/perk_tab_unsafe_removal_mock.lua +++ /dev/null @@ -1,57 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local remove_mode_clicked=false -local remove_calls=0 - -local ui={ - ICON_STEP=20,EMPTY_SLOT="slot",audit=function() end,gui=function() return 1 end, - tr=function(_,fallback) return fallback end,translated=function(value) return value end, - white_text=function() end,wrapped_text=function() end,search_input=function(value) return value end, - button_grid=function(items) - for index,item in ipairs(items or {}) do - if item.label=="REMOVE" and not remove_mode_clicked then remove_mode_clicked=true; return index end - end - return nil - end, - columns=function() return 4 end,scroll_height=function() return 100 end, - mark_hovered=function() end, - begin_scroll_viewport=function(_,_,_,_,width,height) return {content_width=math.max(20,(tonumber(width) or 100)-12),padding_left=2,padding_top=2,state={offset=0},height=height} end, - scroll_y=function(_,y) return y end, end_scroll_viewport=function() end,search_status=function() end, - rank_entries=function(_,entries) return entries end, - resolve=function(path) return path end,dimensions=function() return 18,18 end, - button=function(_,_,label) - if label=="REMOVE" and not remove_mode_clicked then remove_mode_clicked=true; return true end - return false - end, - tile=function() return true,false,false end, -} -local service={ - count=function() return 1 end, - can_remove=function() return false,"no_safe_inverse" end, - remove_one=function() remove_calls=remove_calls+1; return false,"unsafe" end, - remove_all=function() remove_calls=remove_calls+1; return 0,"unsafe" end, -} -local catalog={all=function() return {{ - id="UNSAFE_TEST",ui_name="Unsafe test",ui_description="No inverse",ui_icon="perk.png", -}} end} - -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/ui/runtime.lua" then return ui end - if path=="mods/metamorph_creative_menu/files/ui/drag_drop.lua" then return {take_result=function() return nil end,source=function() end,active=function() return false end} end - if path=="mods/metamorph_creative_menu/files/features/perks/service.lua" then return service end - if path=="mods/metamorph_creative_menu/files/features/perks/catalog.lua" then return catalog end - return native_dofile(path) -end -function GuiLayoutBeginVertical() end -function GuiLayoutBeginHorizontal() end -function GuiLayoutEnd() end -function GuiBeginScrollContainer() end -function GuiEndScrollContainer() end -function GuiGetPreviousWidgetInfo() return false,false,false end -function GamePrint() error("unsafe removal should not reach user-error fallback") end - -local tab=assert(native_dofile(root.."/files/ui/tabs/perks.lua")) -tab.draw(1,220,180) -assert(remove_mode_clicked,"test did not enter perk removal mode") -assert(remove_calls==0,"unsafe perk removal service was invoked despite can_remove=false") -print("perk_tab_unsafe_removal=PASS warning_only=true mutation_blocked=true") diff --git a/metamorph_creative_menu/tests/perk_transaction_source_mock.lua b/metamorph_creative_menu/tests/perk_transaction_source_mock.lua deleted file mode 100644 index 7a95f31..0000000 --- a/metamorph_creative_menu/tests/perk_transaction_source_mock.lua +++ /dev/null @@ -1,35 +0,0 @@ -local root=assert(arg[1], 'root required') -local native_dofile=dofile -local next_id=0 -local stubs={} -stubs['mods/metamorph_creative_menu/files/features/perks/transactions/global_journal.lua']={ - start_capture=function() return false end,stop_capture=function() end,attach_delta=function(delta,token) delta.transaction_id=token.transaction_id end, - preflight_delta=function() return true end,revert_delta=function() end,discard_delta=function() end,active_owner_counts=function() return 0,0 end, -} -stubs['mods/metamorph_creative_menu/files/features/perks/transactions/mutation_journal.lua']={ - prepare=function(token) next_id=next_id+1; token.transaction_id=next_id end,start_capture=function() return false end,stop_capture=function() end, - attach_delta=function(delta,token) delta.transaction_id=token.transaction_id end,cleanup_owned_objects=function() return true end,revert_properties=function() return true end, - discard_delta=function() end,rebind_delta=function() end,rebuild_ownership=function() end,active_property_count=function() return 0 end, -} -stubs['mods/metamorph_creative_menu/files/features/perks/transactions/player_rebind.lua']={capture=function() return {} end,resolve=function() return {} end,remap_delta=function() end} -stubs['mods/metamorph_creative_menu/files/features/perks/transactions/pending_cleanup.lua']={update=function() end,state_snapshot=function() return {pending=0,failed=0} end} -stubs['mods/metamorph_creative_menu/files/features/perks/transactions/structural_snapshot.lua']={ - valid_entity=function(id) return id==1 end,parent_of=function() return 0 end,snapshot=function() return {} end, - build_delta=function() return {reversible=true,fields={},enabled={},reparents={},added_entities={},added_components={}} end, - component_alive=function() return false end,preflight_reparents=function() return true end,cleanup_structural_additions=function() return true end, -} -stubs['mods/metamorph_creative_menu/files/features/perks/transactions/wand_special_states.lua']={ - object_get=function() end,object_set=function() return true end,extra_mana_state=function() return nil end,same_scalar=function(a,b) return a==b end, - no_more_shuffle_state=function() return nil end,no_more_shuffle_delta=function() return {} end, -} -dofile=function(path) if stubs[path]~=nil then return stubs[path] end return native_dofile(path) end -METAMORPH_CREATIVE_MENU_PERK_TRANSACTIONS=nil -local tx=assert(native_dofile(root..'/files/features/perks/transactions.lua')) -local a=assert(tx.begin(1,'TEST')); a.source='mcm_creative'; assert(tx.commit(a)) -local b=assert(tx.begin(1,'TEST')); assert(tx.commit(b)) -local c=assert(tx.begin(1,'TEST')); c.source='mcm_creative'; assert(tx.commit(c)) -assert(tx.source_count('TEST','mcm_creative',1)==2,'creative transaction source count incorrect') -assert(tx.source_count('TEST','',1)==1,'ordinary transaction source count incorrect') -assert(tx.revert('TEST',1)==true,'top creative transaction did not revert') -assert(tx.source_count('TEST','mcm_creative',1)==1,'creative source count did not follow rollback stack') -print('perk_transaction_source=PASS source_preserved=true rollback_count=true') diff --git a/metamorph_creative_menu/tests/physics_reentry_mock.lua b/metamorph_creative_menu/tests/physics_reentry_mock.lua deleted file mode 100644 index 703160a..0000000 --- a/metamorph_creative_menu/tests/physics_reentry_mock.lua +++ /dev/null @@ -1,43 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local player,frame=1,1 -local active,shell=true,true -local body={gravity=1,linear=0.2,angular=0.3} -local getter_calls=0 -local globals={} -function GlobalsGetValue(k,f) return globals[k] or f end -function GlobalsSetValue(k,v) globals[k]=tostring(v) end -function GameGetFrameNum() return frame end -function EntityGetIsAlive(e) return e==player end -function EntityGetTransform() return 0,0 end -function EntityGetInRadius() return {} end -function EntityGetComponentIncludingDisabled() return {} end -function EntityGetFilename() return 'data/entities/player.xml' end -function ComponentGetTypeName() return nil end -function ComponentGetEntity() return nil end -function PhysicsBodyIDQueryBodies(minx,_,maxx) local hw=(maxx-minx)/2; if hw<=1024 then return active and {77} or {} end; return shell and {77} or {} end -function PhysicsBodyIDGetGravityScale(id) assert(id==77); getter_calls=getter_calls+1; return body.gravity end -function PhysicsBodyIDSetGravityScale(id,v) assert(id==77); body.gravity=v end -function PhysicsBodyIDGetDamping(id) assert(id==77); getter_calls=getter_calls+1; return body.linear,body.angular end -function PhysicsBodyIDSetDamping(id,l,a) assert(id==77); body.linear,body.angular=l,a end -function PhysicsBodyIDApplyForce() end -local stubs={ - ['mods/metamorph_creative_menu/files/platform/noita/player_locator.lua']={get=function() return player end}, - ['mods/metamorph_creative_menu/files/core/rule_math.lua']={same=function(a,b) return math.abs(a-b)<1e-9 end,scaled=function(a,b) return a*b end}, -} -dofile=function(p) if stubs[p] then return stubs[p] end local prefix='mods/metamorph_creative_menu/' if string.sub(p,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(p,#prefix+1)) end return native_dofile(p) end -METAMORPH_CREATIVE_MENU_WORLD_RULE_RECOVERY=nil; METAMORPH_CREATIVE_MENU_WORLD_RULE_PHYSICS=nil -local physics=assert(native_dofile(root..'/files/features/world_rules/physics.lua')) -physics.scan(player,2,2,frame) -assert(body.gravity==2 and math.abs(body.linear-0.4)<1e-9) -frame=2; active=false; shell=false; local before=getter_calls -physics.scan(player,2,2,frame) -assert(getter_calls==before,'body outside restore shell was queried by stale ID') -assert(body.gravity==2 and math.abs(body.linear-0.4)<1e-9,'out-of-area body was unexpectedly touched') -frame=3; active=true; shell=true -physics.scan(player,2,2,frame) -assert(body.gravity==2,'returning body compounded gravity to '..tostring(body.gravity)) -assert(math.abs(body.linear-0.4)<1e-9 and math.abs(body.angular-0.6)<1e-9,'returning body compounded damping') -assert(physics.restore_rule('physics_gravity')==true and body.gravity==1,'original gravity was not retained across far excursion') -assert(physics.restore_rule('physics_damping')==true and math.abs(body.linear-0.2)<1e-9 and math.abs(body.angular-0.3)<1e-9,'original damping was not retained across far excursion') -print('physics_reentry=PASS far_exit_safe=true original_retained=true no_compound=true') diff --git a/metamorph_creative_menu/tests/player_tools_safe_teleport_mock.lua b/metamorph_creative_menu/tests/player_tools_safe_teleport_mock.lua deleted file mode 100644 index 34e5a77..0000000 --- a/metamorph_creative_menu/tests/player_tools_safe_teleport_mock.lua +++ /dev/null @@ -1,81 +0,0 @@ -local root = assert(arg[1], "root required") - -METAMORPH_CREATIVE_MENU_PLAYER_TOOLS = nil - -local frame = 10 -local loaded = true -local scans = 0 -local camera = {} -local transforms = { [1]={0,0}, [2]={100,200} } -local globals = {} -local velocity = nil -local free_request = nil - -function GameGetFrameNum() return frame end -function EntityGetIsAlive(entity) return transforms[entity] ~= nil end -function EntityGetWithTag(tag) - scans = scans + 1 - if tag == "ew_client" or tag == "ew_peer" then return {2} end - if tag == "player_unit" then return {1} end - return {} -end -function EntityGetTransform(entity) - local value = assert(transforms[entity], "unexpected entity") - return value[1], value[2] -end -function EntitySetTransform(entity, x, y) transforms[entity] = {x,y} end -function EntityGetName(entity) return entity == 2 and "peer-two" or "" end -function DoesWorldExistAt() return loaded end -function FindFreePositionForBody(x, y, vx, vy, radius) - free_request = {x=x,y=y,vx=vx,vy=vy,radius=radius} - return x + 3, y - 10 -end -function EntityGetFirstComponentIncludingDisabled() return 77 end -function ComponentSetValue2(component, field, x, y) velocity={component,field,x,y} end -function EntityLoad() return 90 end -function GameSetCameraPos(x, y) camera[#camera+1]={x,y} end -function GlobalsGetValue(key, fallback) return globals[key] or fallback end -function GlobalsSetValue(key, value) globals[key]=tostring(value) end - -local service = assert(loadfile(root .. "/files/features/player_tools/service.lua"))() - -assert(scans == 0, "player list was scanned while the service loaded") -local idle, idle_reason = service.update() -assert(idle == false and idle_reason == "idle" and scans == 0, - "idle per-frame update scanned network players") - -local visible = service.visible_players() -assert(#visible == 2 and scans == 4, "demand-driven player enumeration lost or duplicated peers") - -local ok, reason = service.teleport_to(2, 1) -assert(ok == true and reason == "teleported", "loaded player teleport did not complete") -assert(free_request.x == 118 and free_request.y == 200 and free_request.radius == 9, - "teleport did not request free space beside the target") -assert(transforms[1][1] == 121 and transforms[1][2] == 190, - "safe position returned by the engine was not used") -assert(velocity and velocity[3] == 0 and velocity[4] == 0, - "velocity was not cleared before teleport") - -loaded = false -ok, reason = service.teleport_location("tree", 1) -assert(ok == true and reason == "streaming" and service.has_pending_teleport(), - "unloaded location was not queued for streaming") -assert(#camera == 1 and camera[1][1] == -1902 and camera[1][2] == -1405, - "camera did not start destination streaming") -frame = frame + 1 -local waiting, waiting_reason = service.update() -assert(waiting == false and waiting_reason == "streaming" and #camera == 2, - "pending destination was not kept alive while unloaded") -loaded = true -frame = frame + 1 -ok, reason = service.update() -assert(ok == true and reason == "teleported" and not service.has_pending_teleport(), - "streamed location did not finish at a free position") - -ok, reason = service.bring_to_me(2, 1) -assert(ok == true and reason == "queued", "bring request was not queued") -assert(globals.mcm_teleport_bring_outbox_seq_v1 == "1" - and globals.mcm_teleport_bring_outbox_peer_v1_1 == "peer-two", - "bring request did not preserve the authoritative peer identity") - -print("player_tools_safe_teleport=PASS demand_driven=true free_space=true streaming=true bring_outbox=true") diff --git a/metamorph_creative_menu/tests/possession_client_fallback_mock.lua b/metamorph_creative_menu/tests/possession_client_fallback_mock.lua index dc31ee2..189f08e 100644 --- a/metamorph_creative_menu/tests/possession_client_fallback_mock.lua +++ b/metamorph_creative_menu/tests/possession_client_fallback_mock.lua @@ -4,7 +4,7 @@ local radii={} local target_module={ target_under_cursor=function(player,radius) radii[#radii+1]=radius - if radius==96 then return 99 end + if radius==64 then return 99 end return 0 end, is_creature=function() return true end, @@ -47,5 +47,5 @@ EntityHasTag=function() return false end local possession=assert(native_dofile(root..'/files/features/possession/service.lua')) local ok,reason=possession.possess_under_cursor(1) assert(ok and reason=='pending',tostring(reason)) -assert(#radii==2 and radii[1]==48 and radii[2]==96,'network-safe fallback radii changed') -print('possession_client_fallback=PASS primary=48 fallback=96') +assert(#radii==2 and radii[1]==32 and radii[2]==64,'client fallback radii changed') +print('possession_client_fallback=PASS primary=32 fallback=64') diff --git a/metamorph_creative_menu/tests/possession_creature_policy_mock.lua b/metamorph_creative_menu/tests/possession_creature_policy_mock.lua index 614d174..b08d0ae 100644 --- a/metamorph_creative_menu/tests/possession_creature_policy_mock.lua +++ b/metamorph_creative_menu/tests/possession_creature_policy_mock.lua @@ -1,24 +1,18 @@ local root=assert(arg[1]) local native_dofile=dofile local current_filename="data/entities/animals/sheep.xml" -local alive={ [1]=true, [2]=true, [3]=true } +local alive={ [1]=true, [2]=true } local components={ DamageModelComponent=true, AnimalAIComponent=true } local creature_service={ is_internal_helper_path=function(path) return false end, - -- Deliberately false: live world targeting must not require catalogue membership. - is_transformable_creature_path=function() return false end, - unsafe_reason=function(path) - if path=="data/entities/animals/boss_sky/boss_sky_damage.xml" then return "unsafe" end - return nil + is_transformable_creature_path=function(path) + return path~="data/entities/animals/boss_sky/boss_sky_damage.xml" end, canonical_transform_path=function(path) return path end, } local entity_tree={ - walk=function(root_entity, visitor) - if visitor(root_entity)==false then return end - if root_entity==3 then visitor(2) end - end, - root=function(entity) return entity==2 and 3 or entity end, + walk=function(root_entity, visitor) visitor(root_entity) end, + root=function(entity) return entity end, } local function map(path) local prefix="mods/metamorph_creative_menu/" @@ -33,21 +27,14 @@ end EntityGetIsAlive=function(entity) return alive[entity]==true end EntityHasTag=function() return false end EntityGetFilename=function(entity) return entity==2 and current_filename or "data/entities/player.xml" end -EntityGetParent=function(entity) return entity==2 and 3 or 0 end EntityGetFirstComponentIncludingDisabled=function(entity, component_type) - return entity==2 and components[component_type] and 100 or nil + return components[component_type] and 100 or nil end -DEBUG_GetMouseWorld=function() return 20,30 end -EntityGetInRadius=function() return {3} end -EntityGetTransform=function(entity) return entity==2 and 20 or 0, entity==2 and 30 or 0 end local targeting=assert(native_dofile(root.."/files/features/possession/targeting.lua")) assert(targeting.is_creature(2,1)==true,"ordinary transformable creature should be targetable") -current_filename="mods/example/entities/natural_uncatalogued_mob.xml" -assert(targeting.is_creature(2,1)==true,"structurally valid uncatalogued natural mob was rejected") -assert(targeting.target_under_cursor(1,48)==2,"controller wrapper hid the natural creature from G") current_filename="data/entities/animals/boss_sky/boss_sky_damage.xml" assert(targeting.is_creature(2,1)==false,"confirmed unsafe exact path must not be targetable through G") current_filename="data/entities/animals/boss_sky/boss_sky_damage_clone.xml" assert(targeting.is_creature(2,1)==true,"similar filename must not inherit exact unsafe verdict") -print("possession_creature_policy=PASS exact_unsafe=true uncatalogued=true wrapped_target=true") +print("possession_creature_policy=PASS exact_unsafe_shared=true no_substring_ban=true") diff --git a/metamorph_creative_menu/tests/possession_des_ack_retire_mock.lua b/metamorph_creative_menu/tests/possession_des_ack_retire_mock.lua deleted file mode 100644 index d8bb976..0000000 --- a/metamorph_creative_menu/tests/possession_des_ack_retire_mock.lua +++ /dev/null @@ -1,87 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile -local frame=10 -local transformed=false -local target_alive=true -local ack=false -local queued=0 -local retired=0 -local seq=7 - -METAMORPH_CREATIVE_MENU_POSSESSION=nil - -local form_manager={ - current_player=function() return transformed and 3 or 1 end, - is_human_ready=function(entity) return not transformed and entity==1 end, - return_to_human=function() transformed=false return true,'expire' end, - prepare_exact_effect_paths=function() return 1 end, - transform_creature=function() transformed=true return true,'ok' end, - session_target=function() return transformed and 'data/entities/animals/maggot_tiny/maggot_tiny.xml' or nil end, - session_actual_target=function() return transformed and 'data/entities/animals/maggot_tiny/maggot_tiny.xml' or nil end, -} -local targeting={ - target_under_cursor=function() return 2 end, - is_creature=function(entity,player) return entity==2 and player==1 end, - transform_plan=function(path) return path,nil end, -} -local retirement={ - retire_without_death_side_effects=function(entity) - assert(entity==2,'wrong local multipart root retired') - retired=retired+1 - target_alive=false - return true - end, -} -local ew_retirement={ - network_entity=function(entity) return entity end, - network_kind=function(entity) return entity==2 and 'des' or 'local',entity end, - queue_remote=function(entity,path,x,y,responsible) - assert(entity==2 and target_alive==true,'DES retirement was queued after local entity destruction') - queued=queued+1 - return true,'queued',entity,seq - end, - acknowledged=function(request) return request==seq and ack end, -} -local stubs={ - ['mods/metamorph_creative_menu/files/features/forms/manager.lua']=form_manager, - ['mods/metamorph_creative_menu/files/features/possession/targeting.lua']=targeting, - ['mods/metamorph_creative_menu/files/features/possession/retirement.lua']=retirement, - ['mods/metamorph_creative_menu/files/integrations/ew/possession_retire.lua']=ew_retirement, -} -dofile=function(path) - if stubs[path] then return stubs[path] end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -EntityGetIsAlive=function(entity) - if entity==1 or entity==3 then return true end - if entity==2 then return target_alive end - return false -end -EntityGetFilename=function(entity) return entity==2 and 'data/entities/animals/maggot_tiny/maggot_tiny.xml' or '' end -ModDoesFileExist=function() return true end -EntityGetName=function(entity) return entity==2 and '$animal_maggot_tiny' or '' end -EntityGetTransform=function(entity) if entity==2 then return 100,200 else return 0,0 end end -EntitySetTransform=function() end -EntityGetFirstComponentIncludingDisabled=function() return 0 end -ComponentGetValue2=function() return nil end -ComponentSetValue2=function() end -GameGetFrameNum=function() return frame end -EntityHasTag=function(entity,tag) return transformed and entity==3 and tag=='polymorphed_player' end - -local possession=assert(native_dofile(root..'/files/features/possession/service.lua')) -local ok=possession.possess_entity(1,2) -assert(ok==true,'possession did not start') -frame=11; possession.update() -- human frame -> transform -frame=12; possession.update() -- confirmed form -> queue DES retirement -assert(queued==1 and retired==0 and target_alive==true, - 'DES target was destroyed in the same update as its death request') -frame=13; possession.update() -- bridge not ACKed yet -assert(retired==0 and target_alive==true,'DES target was destroyed before EW ACK') -ack=true -frame=14; possession.update() -assert(retired==1 and target_alive==false,'DES target was not removed after EW ACK') - -print('possession_des_ack_retire=PASS maggot_tiny=true live_gid_until_ack=true retire_after_ack=true') diff --git a/metamorph_creative_menu/tests/possession_fast_reject_mock.lua b/metamorph_creative_menu/tests/possession_fast_reject_mock.lua deleted file mode 100644 index 94554cb..0000000 --- a/metamorph_creative_menu/tests/possession_fast_reject_mock.lua +++ /dev/null @@ -1,54 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local service_calls={helper=0,unsafe=0,loads=0} -local creature_service={ - is_internal_helper_path=function() service_calls.helper=service_calls.helper+1; return false end, - unsafe_reason=function() service_calls.unsafe=service_calls.unsafe+1; return nil end, -} -local children_calls=0 -local entity_tree={ - root=function(entity) return entity end, - walk=function(entity,visitor) - if visitor(entity)==false then return end - children_calls=children_calls+1 - for _,child in ipairs(EntityGetAllChildren(entity) or {}) do visitor(child) end - end, -} -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua' then return entity_tree end - if path=='mods/metamorph_creative_menu/files/features/creatures/service.lua' then - service_calls.loads=service_calls.loads+1 - return creature_service - end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -local alive={[1]=true,[2]=true,[3]=true} -local tags={ [2]={item_pickup=true}, [3]={} } -EntityGetIsAlive=function(e) return alive[e]==true end -EntityHasTag=function(e,t) return tags[e] and tags[e][t] or false end -EntityGetFilename=function(e) - if e==2 then return 'data/entities/items/pickup/heart_fullhp.xml' end - if e==3 then return 'data/entities/props/physics_rock.xml' end - return 'data/entities/player.xml' -end -EntityGetFirstComponentIncludingDisabled=function(e,kind) - if (e==2 or e==3) and kind=='DamageModelComponent' then return 100+e end - if e==3 and (kind=='PhysicsBodyComponent' or kind=='PhysicsBody2Component') then return 200+e end - return nil -end -EntityGetParent=function() return 0 end -EntityGetAllChildren=function() return {} end -EntityGetTransform=function(e) return e==1 and 0 or 10, e==1 and 0 or 10 end -DEBUG_GetMouseWorld=function() return 10,10 end -EntityGetInRadius=function() return {2,3} end - -local targeting=assert(native_dofile(root..'/files/features/possession/targeting.lua')) -assert(service_calls.loads==1,'creature service should be cached once at module load') -assert(targeting.target_under_cursor(1,48)==0,'pickup/physics prop should not be considered possessable') -assert(service_calls.helper==0 and service_calls.unsafe==0, - 'obvious non-creature miss reached XML/catalogue classification') -assert(children_calls==0,'obvious non-creature miss recursively traversed entity children') -print('possession_fast_reject=PASS cached_service=true xml_scans=0 descendant_walks=0') diff --git a/metamorph_creative_menu/tests/possession_keybind_routing_mock.lua b/metamorph_creative_menu/tests/possession_keybind_routing_mock.lua index 8548bd8..1d60266 100644 --- a/metamorph_creative_menu/tests/possession_keybind_routing_mock.lua +++ b/metamorph_creative_menu/tests/possession_keybind_routing_mock.lua @@ -10,17 +10,11 @@ METAMORPH_CREATIVE_MENU_KEYBINDS = nil local stubs = { ["mods/metamorph_creative_menu/files/platform/noita/input_guard.lua"] = {actions_allowed=function() return true end}, ["mods/metamorph_creative_menu/files/platform/noita/keycodes.lua"] = { - resolve=function(name) return name=='Key_g' and 10 or nil end, resolve_binding=function(binding_name, fallback_name) if binding_name == "Key_g" then return 10, "Key_g" end return 10, fallback_name end, }, - ["mods/metamorph_creative_menu/files/platform/noita/action_bindings.lua"] = { - update=function() end, - consume=function(id) return id=='possession' and pressed_key==10 end, - get=function(id) assert(id=='possession'); return 'Key_g' end, - }, ["mods/metamorph_creative_menu/files/platform/noita/player_locator.lua"] = {get=function() return 1 end}, ["mods/metamorph_creative_menu/files/features/possession/service.lua"] = { update=function() updates=updates+1 end, diff --git a/metamorph_creative_menu/tests/possession_outbox_retention_mock.lua b/metamorph_creative_menu/tests/possession_outbox_retention_mock.lua deleted file mode 100644 index 23080e1..0000000 --- a/metamorph_creative_menu/tests/possession_outbox_retention_mock.lua +++ /dev/null @@ -1,25 +0,0 @@ -local root=assert(arg[1]) -local globals={} -local bases={ - 'mcm_possession_retire_outbox_entity_v1','mcm_possession_retire_outbox_path_v1','mcm_possession_retire_outbox_x_v1', - 'mcm_possession_retire_outbox_y_v1','mcm_possession_retire_outbox_wait_v1','mcm_possession_retire_outbox_responsible_v1'} -local values={'42','mods/example/entities/mob.xml','10','20','0','7'} -for i,b in ipairs(bases) do globals[b]=values[i]; globals[b..'_1']=values[i] end -globals.mcm_possession_retire_outbox_seq_v1='1' -function GlobalsGetValue(k,f) local v=globals[k]; if v==nil then return f end; return v end -function GlobalsSetValue(k,v) globals[k]=tostring(v) end -function EntityGetIsAlive(e) return e==42 end -local sends=0 -function CrossCall(name,entity,wait,x,y,path,responsible) - assert(name=='ew_death_notify' and entity==42 and path=='mods/example/entities/mob.xml'); sends=sends+1 -end -local common={finite_number=function(v) return type(v)=='number' and v==v and math.abs(v) EW-extra-module mailbox path. It must preserve the canonical --- EW root, not the clicked child, and the EW side must emit the stock death CrossCall. -CrossCall=nil -local queued,reason,target=retire.queue_remote(10,'mods/example/entities/network_mob.xml',105,200,1) -assert(queued==true and reason=='queued' and target==20 - and globals.mcm_possession_retire_outbox_entity_v1=='20', - 'remote possession did not queue the canonical EW network root') - -local rpc={opts_reliable=function() end,opts_everywhere=function() end} -local common={ - finite_number=function(value) return type(value)=='number' and value==value end, - report_error=function(scope,detail) error('unexpected bridge error: '..tostring(scope)..':'..tostring(detail)) end, -} -ctx={my_player={entity=1}} -local des_calls={} -ewext={des_death_notify=function(entity,wait,x,y,path,responsible) - des_calls[#des_calls+1]={entity=entity,wait=wait,x=x,y=y,path=path,responsible=responsible} -end} -CrossCall=function() error('live DES possession should not defer through CrossCall') end -local bridge=assert(native_dofile(root..'/files/integrations/ew/bridge/possession.lua')) -bridge.register(rpc,common) --- Protocol slot 9 remains registered for old MCM peers, but is deliberately inert. -rpc.retire_possession_target('mods/example/entities/network_mob.xml',105,200) -assert(alive[20]==true,'reserved MCM RPC still mutated the target') -bridge.update() -assert(#des_calls==1 and des_calls[1].entity==20 and des_calls[1].wait==false - and des_calls[1].path=='mods/example/entities/network_mob.xml', - 'EW bridge did not resolve DES retirement while the tracked entity/GID was alive') -assert(alive[20]==true,'EW bridge directly killed the entity instead of delegating death to DES') -assert(globals.mcm_possession_retire_outbox_ack_v1=='1','EW retirement mailbox was not acknowledged') - -print('possession_remote_replica=PASS canonical_root=true des_live_gid=true ack=true enemy_sync_channel_separate=true') diff --git a/metamorph_creative_menu/tests/possession_tree_retirement_mock.lua b/metamorph_creative_menu/tests/possession_tree_retirement_mock.lua deleted file mode 100644 index bb289a6..0000000 --- a/metamorph_creative_menu/tests/possession_tree_retirement_mock.lua +++ /dev/null @@ -1,44 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile - -local entity_tree={ - root=function(entity) return entity==11 and 10 or entity end, - walk=function(entity,visitor) - if entity==10 then visitor(10); visitor(11); visitor(12) else visitor(entity) end - end, -} -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/platform/noita/entity_tree.lua' then return entity_tree end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -local alive={[10]=true,[11]=true,[12]=true} -local moved=nil -local removed={} -local disabled={} -local killed={} -local comps={[10]={101,102},[11]={111},[12]={121}} -local types={[101]='LuaComponent',[102]='DamageModelComponent',[111]='LuaComponent',[121]='SpriteComponent'} -EntityGetIsAlive=function(entity) return alive[entity]==true end -EntitySetTransform=function(entity,x,y) moved={entity,x,y} end -EntityGetAllComponents=function(entity) return comps[entity] or {} end -ComponentGetTypeName=function(component) return types[component] or '' end -EntityRemoveComponent=function(entity,component) removed[#removed+1]={entity,component} end -EntitySetComponentIsEnabled=function(entity,component,value) disabled[#disabled+1]={entity,component,value} end -EntityKill=function(entity) killed[#killed+1]=entity; alive[entity]=false end - -local retirement=assert(native_dofile(root..'/files/features/possession/retirement.lua')) -assert(retirement.retire_without_death_side_effects(11)==true,'multipart child retirement failed') -assert(moved and moved[1]==10,'multipart possession did not move the creature root out of play') -assert(#killed==1 and killed[1]==10,'multipart possession killed clicked child instead of full local root') -local removed_seen={} -for _,entry in ipairs(removed) do removed_seen[entry[1]..':'..entry[2]]=true end -assert(removed_seen['10:101'] and removed_seen['11:111'], 'multipart possession did not suppress Lua death scripts across the tree') -local disabled_seen={} -for _,entry in ipairs(disabled) do disabled_seen[entry[1]..':'..entry[2]]=entry[3] end -assert(disabled_seen['10:102']==false and disabled_seen['12:121']==false, - 'multipart possession did not disable non-Lua components across the tree') - -print('possession_tree_retirement=PASS clicked_child_to_root=true whole_tree_suppressed=true') diff --git a/metamorph_creative_menu/tests/qa_baselines_scope_mock.lua b/metamorph_creative_menu/tests/qa_baselines_scope_mock.lua index ede7845..b3179cd 100644 --- a/metamorph_creative_menu/tests/qa_baselines_scope_mock.lua +++ b/metamorph_creative_menu/tests/qa_baselines_scope_mock.lua @@ -1,8 +1,8 @@ local root = assert(arg[1], "project root required") local original_dofile = dofile --- The QA baseline module owns the perk introspection modules it reads; it must not --- borrow a service table from runner.lua globals. +-- This test intentionally does NOT define a global perk_service. The QA baseline +-- module must own its dependency instead of accidentally borrowing runner.lua locals. perk_service = nil local world_rules_stub = { @@ -18,24 +18,19 @@ local weather_stub = { local root_companions_stub = { owned_counts = function() return {} end, } -local transaction_calls = 0 -local transactions_stub = { - active_count = function() transaction_calls = transaction_calls + 1; return 0 end, - active_mutation_count = function() return 0 end, - active_global_owner_counts = function() return 0, 0 end, - cleanup_state = function() return {pending=0, failed=0} end, +local service_calls = 0 +local perk_service_stub = { + ownership_state = function() + service_calls = service_calls + 1 + return { transactions = 0, mutations = 0, global_owners = 0, run_flag_owners = 0, cleanup = {pending=0, failed=0} } + end, } -local nested_stub = {state_snapshot=function() return {scopes=0, children=0} end} -local locomotion_stub = {baseline_count=function() return 0 end} - dofile = function(path) if path == "mods/metamorph_creative_menu/files/features/world_rules/service.lua" then return world_rules_stub end if path == "mods/metamorph_creative_menu/files/features/weather/service.lua" then return weather_stub end if path == "mods/metamorph_creative_menu/files/features/perks/root_companions.lua" then return root_companions_stub end - if path == "mods/metamorph_creative_menu/files/features/perks/transactions.lua" then return transactions_stub end - if path == "mods/metamorph_creative_menu/files/features/perks/nested_pickups.lua" then return nested_stub end - if path == "mods/metamorph_creative_menu/files/features/perks/locomotion_guard.lua" then return locomotion_stub end + if path == "mods/metamorph_creative_menu/files/features/perks/service.lua" then return perk_service_stub end return original_dofile(path) end @@ -57,6 +52,6 @@ assert(type(baselines) == "table", "baseline module did not load") local ok, snapshot = pcall(baselines.perk_guard_snapshot, 1) assert(ok, "perk_guard_snapshot must not depend on a global perk_service: " .. tostring(snapshot)) assert(type(snapshot) == "table", "snapshot missing") -assert(transaction_calls == 1, "owned transaction state was not queried") +assert(service_calls == 1, "owned perk service was not queried") assert(type(snapshot.ownership) == "table" and snapshot.ownership.transactions == 0, "ownership snapshot missing") -print("qa_baselines_scope=PASS explicit_perk_ownership_dependencies=true") +print("qa_baselines_scope=PASS explicit_perk_service_dependency=true") diff --git a/metamorph_creative_menu/tests/release_wand_ui_contract.py b/metamorph_creative_menu/tests/release_wand_ui_contract.py deleted file mode 100644 index ca126bb..0000000 --- a/metamorph_creative_menu/tests/release_wand_ui_contract.py +++ /dev/null @@ -1,12 +0,0 @@ -from pathlib import Path -import sys -root=Path(sys.argv[1]).resolve() -editor=(root/'files/ui/components/wand_editor.lua').read_text() -presets=(root/'files/ui/components/wand_presets.lua').read_text() -translations=(root/'translations.csv').read_text(encoding='utf-8-sig') -assert 'METAMORPH_CREATIVE_MENU_DEV_MODE == true and last_error ~= nil' in editor, 'wand mutation diagnostic leaks into release UI' -assert 'ui.tr("$mcm_delete", "DELETE")' in presets, 'wand preset delete still uses an unlabeled X' -assert 'ui.tr("$mcm_cancel", "CANCEL")' in presets, 'wand preset deletion has no explicit cancel action' -assert 'clicked == 4 and confirm_delete == index' in presets, 'wand preset cancel button is not handled' -assert 'mcm_cancel,CANCEL,' in translations, 'cancel localization missing' -print('release_wand_ui_contract=PASS diagnostics_gated=true delete_label=true confirm_cancel=true') diff --git a/metamorph_creative_menu/tests/runtime_scroll_viewport_mock.lua b/metamorph_creative_menu/tests/runtime_scroll_viewport_mock.lua deleted file mode 100644 index 59db58c..0000000 --- a/metamorph_creative_menu/tests/runtime_scroll_viewport_mock.lua +++ /dev/null @@ -1,150 +0,0 @@ -local root = assert(arg[1], "root required") -local native_dofile = dofile -local prefix = "mods/metamorph_creative_menu/" -dofile = function(path) - if string.sub(path, 1, #prefix) == prefix then - return native_dofile(root .. "/" .. string.sub(path, #prefix + 1)) - end - return native_dofile(path) -end - --- Noita boundary stubs only. The real runtime.lua, pointer.lua, scroll_model.lua and --- asset/localization adapters are loaded below. -GUI_OPTION = {Layout_NoLayouting=1} -local wheel = 0 -local calls = {} -local stack = {} -local last_info = {false, 0, 0, 1, 1} -local content_by_id = {} - -local function note(name, ...) - calls[#calls + 1] = {name=name,args={...}} -end -local function info(hovered, x, y, w, h) - last_info = {hovered == true, tonumber(x) or 0, tonumber(y) or 0, tonumber(w) or 1, tonumber(h) or 1} -end - -GuiBeginScrollContainer = function(_, id, x, y, w, h) - note("begin_scroll", id, x, y, w, h) - stack[#stack + 1] = {id=id,x=x or 0,y=y or 0,w=w,h=h,layout_y=0} - info(true, x, y, w, h) -end -GuiEndScrollContainer = function() - note("end_scroll") - table.remove(stack) -end -GuiLayoutBeginLayer = function() note("begin_layer") end -GuiLayoutEndLayer = function() note("end_layer") end -GuiLayoutBeginVertical = function(_, x, y) - note("begin_vertical", x, y) - local top = stack[#stack] - if top then top.layout_y = tonumber(y) or 0 end -end -GuiLayoutEnd = function() note("layout_end") end -GuiColorSetForNextWidget = function() end -GuiZSetForNextWidget = function() end -GuiOptionsAddForNextWidget = function() end -GuiImage = function(_, _, x, y, _, _, sx, sy) - note("image", x, y, sx, sy) - info(false, x, y, math.max(1, (tonumber(sx) or 1) * 24), math.max(1, (tonumber(sy) or 1) * 4)) -end -GuiText = function(_, x, y, text) - note("text", x, y, text) - local top = stack[#stack] - if top then - local h = 5 - local desired = tonumber(content_by_id[top.id]) or 0 - -- Runtime computes content height relative to container_y + padding_top - offset. - local sentinel_y = top.y + top.layout_y + desired - h - info(false, 0, sentinel_y, 3, h) - else - info(false, x, y, 3, 5) - end -end -GuiGetPreviousWidgetInfo = function() - return 0, 0, last_info[1], last_info[2], last_info[3], last_info[4], last_info[5] -end -GuiGetImageDimensions = function() return 24, 4 end -GuiGetTextDimensions = function(_, text) return #tostring(text or "") * 5, 5 end -GuiGetScreenDimensions = function() return 320, 240 end -InputIsMouseButtonJustDown = function(code) - if code == 4 then return wheel < 0 end - if code == 5 then return wheel > 0 end - return false -end -InputIsMouseButtonDown = function() return false end -InputIsKeyDown = function() return false end -InputIsKeyJustDown = function() return false end -InputIsKeyJustUp = function() return false end -GameGetFrameNum = function() return 1 end -GameGetRealWorldTimeSinceStarted = function() return 1 end -ModTextFileGetContent = function(path) - if path == prefix .. "translations.csv" then - local h = assert(io.open(root .. "/translations.csv", "rb")); local s=h:read("*a"); h:close(); return s - end - return "" -end -ModDoesFileExist = function() return false end -GameTextGetTranslatedOrNot = function(key) return key end - -METAMORPH_CREATIVE_MENU_UI_RUNTIME = nil -METAMORPH_CREATIVE_MENU_SCROLL_MODEL = nil -METAMORPH_CREATIVE_MENU_POINTER = nil -local ui = assert(native_dofile(root .. "/files/ui/runtime.lua")) -ui.bind(1) - -local function index_of(name, start_at) - for i = start_at or 1, #calls do if calls[i].name == name then return i end end - return nil -end - --- Real begin/end sequence, measured content geometry and actual content width. The --- production regression here is engine-specific: a layout layer opened after the scroll --- container resets its children to the screen origin. The wrapper must therefore leave --- contents in the native container coordinate space. -calls = {}; content_by_id[501] = 180; wheel = 1; ui.begin_frame() -local ctx = ui.begin_scroll_viewport("probe.long", 501, 7, 9, 120, 64, - {padding_left=3,padding_right=4,padding_top=5,padding_bottom=6}) -assert(ctx.content_width == 120 - 3 - 4 - 8, "content width ignored padding/scrollbar") -assert(ctx.viewport_height == 64 - 5 - 6, "viewport height ignored vertical padding") -local offset, maximum, content_height = ui.end_scroll_viewport(ctx) -assert(content_height == 180, "runtime did not measure logical content height from sentinel") -assert(maximum == 180 - (64 - 5 - 6), "runtime maximum offset is wrong") -assert(offset == 0, "native viewport retained a detached manual offset") -local a=index_of("begin_scroll"); local c=index_of("begin_vertical") -local d=index_of("layout_end"); local f=index_of("end_scroll") -assert(a and c and d and f and a < c and c < d and d < f, - "runtime native scroll GUI call order changed") -assert(index_of("begin_layer") == nil and index_of("end_layer") == nil, - "scroll contents were detached from the native container coordinate space") -assert(ui.scroll_y(ctx, 17) == 22, "free-layout content did not stay relative to container padding") - --- Responsive viewport geometry is derived from actual width at all supported menu sizes. -for _, width in ipairs({96,160,280}) do - local id=600+width; content_by_id[id]=240; wheel=0; ui.begin_frame() - local responsive=ui.begin_scroll_viewport("responsive."..tostring(width),id,0,0,width,70) - assert(responsive.content_width==width-2-2-8,"responsive content width escaped padding/scrollbar at "..tostring(width)) - ui.end_scroll_viewport(responsive) -end - --- Native scrolling owns the wheel and the wrapper never applies a second offset. -content_by_id[502] = 90; wheel = 1; ui.begin_frame() -local short = ui.begin_scroll_viewport("probe.short", 502, 0, 0, 100, 50) -local short_offset, short_maximum = ui.end_scroll_viewport(short) -content_by_id[503] = 900; wheel = 1; ui.begin_frame() -local long = ui.begin_scroll_viewport("probe.very_long", 503, 0, 0, 100, 50) -local long_offset, long_maximum = ui.end_scroll_viewport(long) -assert(short_offset == 0 and long_offset == 0 and short_maximum < long_maximum, - "runtime applied a second manual scroll offset over native scrolling") - --- Nested viewports keep balanced native container calls without introducing a layout layer. -content_by_id[510] = 300; content_by_id[511] = 200; wheel = 1; ui.begin_frame() -local parent = ui.begin_scroll_viewport("nested.parent", 510, 0, 0, 140, 80) -local child = ui.begin_scroll_viewport("nested.child", 511, 0, 0, 100, 40) -local child_offset = select(1, ui.end_scroll_viewport(child)) -local parent_offset = select(1, ui.end_scroll_viewport(parent)) -assert(child_offset == 0 and parent_offset == 0, "nested native viewport applied a manual wheel offset") -assert(index_of("begin_layer") == nil and index_of("end_layer") == nil, - "nested viewport detached contents from its native container") - -print("runtime_scroll_viewport=PASS gui_order=true native_coordinates=true no_detached_layer=true geometry=true widths=96_160_280 nested_balanced=true") diff --git a/metamorph_creative_menu/tests/runtime_update_isolation_mock.lua b/metamorph_creative_menu/tests/runtime_update_isolation_mock.lua deleted file mode 100644 index 74826d0..0000000 --- a/metamorph_creative_menu/tests/runtime_update_isolation_mock.lua +++ /dev/null @@ -1,122 +0,0 @@ -local root = assert(arg[1], "root required") -local calls = {} -local messages = {} -local printed = {} -local captured = {} -local native_dofile = dofile -local effect_attempt = 0 -local function hit(name) calls[name] = (calls[name] or 0) + 1 end - -local stubs = { - ["mods/metamorph_creative_menu/files/platform/noita/localization.lua"]={register=function() end}, - ["mods/metamorph_creative_menu/files/platform/noita/input_guard.lua"]={ - update=function() hit("input") end, - blocked=function() return false end, - }, - ["mods/metamorph_creative_menu/files/platform/noita/action_bindings.lua"]={ - update=function() hit("action_bindings") end, - consume=function() return false end, - }, - ["mods/metamorph_creative_menu/files/features/forms/manager.lua"]={ - current_player=function() hit("player"); return 7 end, - update=function() hit("forms") end, - draw_form_health=function() hit("health") end, - post_update=function() hit("forms_post") end, - handle_tab_return=function() hit("tab"); return false end, - has_active_form=function() return false end, - prepare_exact_effect_paths_from_catalog=function() return 0 end, - }, - ["mods/metamorph_creative_menu/files/features/weather/service.lua"]={update=function() hit("weather") end}, - ["mods/metamorph_creative_menu/files/features/world_rules/service.lua"]={ - update=function() hit("rules") end, - post_update=function() hit("rules_post") end, - }, - ["mods/metamorph_creative_menu/files/ui/menu_controller.lua"]={ - draw=function() hit("menu") end, - post_update=function() hit("menu_post") end, - is_open=function() return false end, - active_tab=function() return "spells" end, - is_hovered=function() return false end, - }, - ["mods/metamorph_creative_menu/files/features/possession/keybinds.lua"]={update=function() hit("keys") end}, - ["mods/metamorph_creative_menu/files/integrations/ew/resilience.lua"]={ - pre_init=function() end, - post_init=function() return 0,0,0 end, - }, - ["mods/metamorph_creative_menu/files/integrations/ew/world_entities.lua"]={update=function() hit("ew_world_entities") end}, - ["mods/metamorph_creative_menu/files/integrations/ew/perk_sync.lua"]={update=function() hit("perk_sync") end}, - ["mods/metamorph_creative_menu/files/features/perks/service.lua"]={ - update=function(player) assert(player == 7); hit("perks") end, - }, - ["mods/metamorph_creative_menu/files/features/effects/service.lua"]={ - update=function() - hit("effects") - effect_attempt = effect_attempt + 1 - error("intentional \"update\" <&%\\ \nЖ🔥 failure " .. tostring(effect_attempt)) - end, - }, - ["mods/metamorph_creative_menu/files/features/companion/player_avatar.lua"]={update=function() hit("companion") end}, - ["mods/metamorph_creative_menu/files/features/materials/painter.lua"]={update=function(player) assert(player==7); hit("materials") end, set_enabled=function() end}, - ["mods/metamorph_creative_menu/files/platform/noita/assets.lua"]={prewarm=function() return 0,0 end}, - ["mods/metamorph_creative_menu/files/features/items/ui_catalog.lua"]={prewarm_icons=function() return 0,0 end}, - ["mods/metamorph_creative_menu/files/features/spells/slot_settler.lua"]={update=function() hit("spell_slots") end}, - ["mods/metamorph_creative_menu/files/features/death_recovery/service.lua"]={install=function() return true end,update=function() hit("death_recovery") end,pause_update=function() hit("death_pause") end}, - ["mods/metamorph_creative_menu/files/features/player_tools/service.lua"]={ - update=function() hit("player_tools") end, - visible_players=function() return {} end, - teleport_to=function() end, - }, -} - -dofile_once = function() end -dofile = function(path) - if path == "mods/metamorph_creative_menu/dev_mode.lua" then return 0 end - if path == "mods/metamorph_creative_menu/files/core/global_text.lua" then - return native_dofile(root .. "/files/core/global_text.lua") - end - if stubs[path] ~= nil then return stubs[path] end - error("unexpected init dependency: " .. tostring(path)) -end -ModIsEnabled=function() return false end -ModDoesFileExist=function() return false end -ModLuaFileAppend=function() end -GlobalsSetValue=function(key, value) calls[key]=tostring(value) end -GamePrint=function(message) messages[#messages+1]=tostring(message) end -GameIsInventoryOpen=function() return false end -print=function(message) printed[#printed+1]=tostring(message) end -METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE=function(scope, detail) - captured[#captured+1]={scope=tostring(scope), detail=tostring(detail)} -end - -assert(loadfile(root .. "/init.lua"))() -OnWorldPreUpdate() -OnWorldPostUpdate() -OnWorldPreUpdate() -OnWorldPostUpdate() - -assert(calls.effects == 2, "failing subsystem was not retried on the next frame") -for _, name in ipairs({"spell_slots","weather","rules","materials","perks","companion","ew_world_entities","forms","keys","tab","menu","rules_post","forms_post","menu_post"}) do - assert((calls[name] or 0) > 0, "later subsystem was skipped after runtime error: " .. name) -end -assert(calls.perk_sync == nil, "reserved acknowledge-only perk mailbox was still polled every frame") -assert(type(calls.mcm_runtime_error_last_v1) == "string" - and string.find(calls.mcm_runtime_error_last_v1, "effect_service.update", 1, true), - "last runtime error was not published") -assert(not string.find(calls.mcm_runtime_error_last_v1, '"', 1, true) - and not string.find(calls.mcm_runtime_error_last_v1, "\n", 1, true) - and string.find(calls.mcm_runtime_error_last_v1, "%22", 1, true) - and string.find(calls.mcm_runtime_error_last_v1, "%D0%96", 1, true), - "runtime diagnostic Global was not ASCII-safe encoded") -local raw1 = "intentional \"update\" <&%\\ \nЖ🔥 failure 1" -local raw2 = "intentional \"update\" <&%\\ \nЖ🔥 failure 2" -assert(#messages == 2 and string.find(messages[1], raw1, 1, true) - and string.find(messages[2], raw2, 1, true), - "human-readable GamePrint error was changed by Global encoding") -assert(#printed == 2 and string.find(printed[1], raw1, 1, true) - and string.find(printed[2], raw2, 1, true), - "human-readable print error was changed by Global encoding") -assert(#captured == 2 and string.find(captured[1].detail, raw1, 1, true) - and string.find(captured[2].detail, raw2, 1, true), - "file-log diagnostic detail was changed by Global encoding") - -print("runtime_update_isolation=PASS later_services_continue=true visible_error=true") diff --git a/metamorph_creative_menu/tests/scroll_grid_model_mock.lua b/metamorph_creative_menu/tests/scroll_grid_model_mock.lua deleted file mode 100644 index 05bbad1..0000000 --- a/metamorph_creative_menu/tests/scroll_grid_model_mock.lua +++ /dev/null @@ -1,69 +0,0 @@ -local root = assert(arg[1], "root required") -local native_dofile = dofile -local wheel = 0 - -local stubs = { - ["mods/metamorph_creative_menu/files/platform/noita/pointer.lua"] = { - wheel_delta=function() return wheel end, - }, -} -dofile = function(path) - if stubs[path] ~= nil then return stubs[path] end - local prefix = "mods/metamorph_creative_menu/" - if string.sub(path, 1, #prefix) == prefix then - return native_dofile(root .. "/" .. string.sub(path, #prefix + 1)) - end - return native_dofile(path) -end - -METAMORPH_CREATIVE_MENU_SCROLL_MODEL = nil -local scroll = assert(native_dofile(root .. "/files/ui/widgets/scroll_model.lua")) - -local short_offset = select(1, scroll.scroll_offset(0, 120, 60, 1, scroll.VERTICAL_STEP)) -local long_offset = select(1, scroll.scroll_offset(0, 1200, 60, 1, scroll.VERTICAL_STEP)) -assert(short_offset == 20 and long_offset == 20, - "vertical wheel distance still depends on total content length") - -local near_end = select(1, scroll.scroll_offset(55, 120, 60, 1, scroll.VERTICAL_STEP)) -assert(near_end == 60, "vertical scroll did not clamp to the last real content pixel") - -local widths = {96, 117, 206, 273, 401} -local previous_columns = 0 -for _, width in ipairs(widths) do - local metrics = scroll.grid_metrics(width, 20) - assert(metrics.grid_width <= metrics.content_width, - "grid starts a column that does not fit the viewport at width " .. tostring(width)) - assert((metrics.columns + 1) * metrics.step > metrics.content_width, - "grid left room for another complete column at width " .. tostring(width)) - assert(metrics.columns >= previous_columns, - "grid column count regressed when the panel got wider") - previous_columns = metrics.columns -end - -local narrow = scroll.grid_metrics(206, 20) -local wide = scroll.grid_metrics(273, 20) -assert(narrow.columns ~= wide.columns, "responsive grid did not change column count across panel widths") -local selected_id = "spell_37" -local selected_index = 37 -local narrow_row = math.floor((selected_index - 1) / narrow.columns) -local wide_row = math.floor((selected_index - 1) / wide.columns) -assert(selected_id == "spell_37" and narrow_row ~= wide_row, - "resize probe did not reflow geometry while preserving selected object identity") - -local horizontal_offset = select(1, scroll.horizontal_offset(0, 30, 8, 1)) -assert(horizontal_offset == scroll.HORIZONTAL_STEP and horizontal_offset == 3, - "horizontal strip did not use its shared constant slot step") - -wheel = 1 -scroll.begin_frame() -assert(scroll.consume_wheel("horizontal:wand", true) == 1, - "hovered horizontal strip did not acquire wheel ownership") -assert(scroll.consume_wheel("spells.workspace.wand", true) == 0, - "parent viewport received the same wheel event as the horizontal strip") - -wheel = -1 -scroll.begin_frame() -assert(scroll.consume_wheel("catalog", false) == 0, "non-hovered viewport consumed wheel") -assert(scroll.consume_wheel("catalog", true) == -1, "hovered parent viewport could not consume an unowned tick") - -print("scroll_grid_model=PASS constant_vertical_px=true constant_horizontal_slots=true wheel_owner=true grid_fit=true resize_reflow=true") diff --git a/metamorph_creative_menu/tests/scroll_usage_contract.py b/metamorph_creative_menu/tests/scroll_usage_contract.py deleted file mode 100644 index 1f7edb2..0000000 --- a/metamorph_creative_menu/tests/scroll_usage_contract.py +++ /dev/null @@ -1,59 +0,0 @@ -from pathlib import Path -import sys - -root = Path(sys.argv[1] if len(sys.argv) > 1 else '.').resolve() -failures = [] -def fail(message): failures.append(message) - -def read(rel): return (root / rel).read_text(encoding='utf-8') - -def png_size(rel): - data = (root / rel).read_bytes() - if len(data) < 24 or data[:8] != b'\x89PNG\r\n\x1a\n': - fail(f'invalid PNG asset: {rel}') - return 0, 0 - return int.from_bytes(data[16:20], 'big'), int.from_bytes(data[20:24], 'big') - -runtime = read('files/ui/runtime.lua') -scroll = read('files/ui/widgets/scroll_model.lua') -horizontal = read('files/ui/widgets/horizontal_strip.lua') -spells = read('files/ui/tabs/spells.lua') -wand_presets = read('files/ui/components/wand_presets.lua') - -for dead in ('function ui_runtime.reset_scroll', 'ui_runtime.SCROLL_STEP', 'ui_runtime.HORIZONTAL_SCROLL_STEP', - 'ui_runtime.SCROLLBAR_WIDTH'): - if dead in runtime: fail(f'dead runtime scroll API retained: {dead}') -if 'function scroll_model.wheel_owner' in scroll: - fail('test-only scroll_model.wheel_owner accessor retained') -if 'wheel_enabled' in horizontal or 'wheel_enabled' in spells: - fail('dead wheel_enabled branch/plumbing retained') -if 'scroll_model' in horizontal or 'consume_wheel' in horizontal: - fail('horizontal strip still consumes wheel input') -if 'GuiImageButton' not in horizontal or 'page_left.png' not in horizontal or 'page_right.png' not in horizontal: - fail('horizontal strip is missing its tall high-contrast image navigation arrows') -if '"<<<"' in horizontal or '">>>"' in horizontal: - fail('horizontal strip still wastes slot width on repeated text chevrons') -for arrow in ('files/ui/assets/page_left.png', 'files/ui/assets/page_right.png'): - width, height = png_size(arrow) - if width > 10: - fail(f'navigation arrow is too wide and steals spell-slot space: {arrow}={width}x{height}') - if height < 20: - fail(f'navigation arrow is too short to be visually prominent: {arrow}={width}x{height}') -if 'pointer.left_just_down' in horizontal or 'state.drag' in horizontal: - fail('horizontal strip still has an ambiguous LMB pan recognizer') -if 'ui.begin_scroll_viewport("spells.workspace."' not in spells: - fail('Spells workspace bypasses shared runtime viewport') -if 'ui.columns(workspace_content_width' not in spells: - fail('spell catalogue grid ignores actual viewport content width') -if 'begin_scroll_viewport' in wand_presets or 'end_scroll_viewport' in wand_presets: - fail('wand presets create a nested scroll viewport inside the already-scrollable WAND workspace') - -for name in ('controls','creatures','effects','items','materials','perks','players','spells','weather','world_rules'): - if 'ui.begin_scroll_viewport' not in read(f'files/ui/tabs/{name}.lua'): - fail(f'{name} tab bypasses shared viewport') - -if failures: - print('scroll_usage_contract=FAIL') - for message in failures: print(' -', message) - raise SystemExit(1) -print('scroll_usage_contract=PASS shared_tabs=true horizontal_wheel=false inline_arrows=true') diff --git a/metamorph_creative_menu/tests/search_engine_mock.lua b/metamorph_creative_menu/tests/search_engine_mock.lua deleted file mode 100644 index 59295a3..0000000 --- a/metamorph_creative_menu/tests/search_engine_mock.lua +++ /dev/null @@ -1,19 +0,0 @@ -local root=assert(arg[1]) -local engine=assert(dofile(root..'/files/core/search_engine.lua')) - -local function match(query,...) - local fields={...} - return engine.matches(query,fields) -end - -assert(engine.normalize('ЁЖИК')=='ежик','Cyrillic case/yo folding failed') -assert(engine.normalize('Über Łódź façade')=='uber lodz facade','Latin accent folding failed') -assert(match('огнен','Огненный шар','Fireball'),'Russian substring failed') -assert(match('fireball','Огненный шар','Fireball'),'English alias failed') -assert(match('firball','Fireball'),'one-edit typo tolerance failed') -assert(match('teleport bolt','Teleporting Cast Bolt'),'multi-token search failed') -assert(match('wand_good','data/entities/items/wands/wand_good.xml'),'technical separator search failed') -assert(match('火球','火球术','Fireball'),'CJK literal search failed') -assert(not match('fire -ball','Fireball'),'negative token failed') -assert(not match('xyz','Fireball'),'unrelated query matched') -print('search_engine=PASS multilingual=true english_alias=true accents=true typos=true negatives=true') diff --git a/metamorph_creative_menu/tests/search_field_cache_mock.lua b/metamorph_creative_menu/tests/search_field_cache_mock.lua deleted file mode 100644 index 65d81d4..0000000 --- a/metamorph_creative_menu/tests/search_field_cache_mock.lua +++ /dev/null @@ -1,25 +0,0 @@ -local root=assert(arg[1],'root required') -METAMORPH_CREATIVE_MENU_SEARCH_ENGINE=nil -local engine=assert(dofile(root..'/files/core/search_engine.lua')) -local original=engine.normalize -local calls=0 -engine.normalize=function(value) - calls=calls+1 - return original(value) -end -local fields={'Fireball','$action_fireball','FIREBALL','data/entities/projectiles/deck/fireball.xml'} -local q1=engine.compile_query('fire') -local before_fields=calls -assert(engine.score(q1,fields)~=nil,'baseline cached-field search did not match') -local first=calls -assert(first-before_fields>=#fields,'baseline search did not normalize fields') -local q2=engine.compile_query('ball') -local before_second=calls -assert(engine.score(q2,fields)~=nil,'second cached-field search did not match') -assert(calls==before_second,'changing only the query renormalized the entire catalogue entry') -local changed={'Fire bolt','$action_firebolt','FIREBOLT','data/entities/projectiles/deck/firebolt.xml'} -local q3=engine.compile_query('bolt') -local before_changed=calls -assert(engine.score(q3,changed)~=nil,'changed field set did not match') -assert(calls-before_changed>=#changed,'changed field set incorrectly reused stale normalized fields') -print('search_field_cache=PASS query_changes_reuse_fields=true changed_fields_invalidate=true') diff --git a/metamorph_creative_menu/tests/search_focus_mock.lua b/metamorph_creative_menu/tests/search_focus_mock.lua deleted file mode 100644 index 5696498..0000000 --- a/metamorph_creative_menu/tests/search_focus_mock.lua +++ /dev/null @@ -1,103 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local text_calls=0 -local capture_calls=0 -local text_events={} -local last_hover=false -local left_just=false -local native_focus_id=nil -local first_id=nil -local capture_typed='' - -local function pop() - if #text_events==0 then return {hover=false} end - return table.remove(text_events,1) -end - -dofile=function(path) - local prefix='mods/metamorph_creative_menu/' - if path==prefix..'files/platform/noita/assets.lua' then - return {bind_gui=function() end,resolve=function(p)return p end,path=function(p)return p end,dimensions=function()return 18,18 end,resolve_entity=function()return nil end} - end - if path==prefix..'files/platform/noita/input_guard.lua' then return {actions_allowed=function()return true end} end - if path==prefix..'files/platform/noita/pointer.lua' then return {left_just_down=function() return left_just end,gui_position=function() return 200,100 end} end - if path==prefix..'files/platform/noita/keycodes.lua' then return {resolve=function() return 1 end} end - if path==prefix..'files/platform/noita/localization.lua' then - return {translate=function(v)return v end,english=function(k)return k=='$spell' and 'Fireball' or k end, - search_aliases=function(k) if k=='$spell' then return {'Огненный шар','Fireball',k} end return {k} end} - end - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -GUI_OPTION={Layout_NoLayouting=1,ForceFocusable=2} -GuiCreate=function() return 2 end -GuiStartFrame=function() end -GuiColorSetForNextWidget=function()end -GuiZSetForNextWidget=function()end -GuiText=function()end -GuiImage=function()end -GuiOptionsAddForNextWidget=function()end -GuiLayoutBeginHorizontal=function()end -GuiLayoutEnd=function()end -local after_id=nil -GuiButton=function(_,id,_,_,label) - if label=='AFTER' then - if after_id then assert(id==after_id,'first character shifted following widget IDs') else after_id=id end - end - return false -end -GuiGetPreviousWidgetInfo=function() return false,false,last_hover,0,0,100,10 end -GuiGetScreenDimensions=function() return 640,360 end -GuiTextInput=function(gui_id,id,_,_,value,width) - if gui_id==2 then - capture_calls=capture_calls+1 - local typed=capture_typed; capture_typed='' - return ' '..typed - end - text_calls=text_calls+1 - local e=pop(); last_hover=e.hover==true - if first_id==nil then first_id=id else assert(id==first_id,'search native widget identity changed across frames') end - if left_just then - if last_hover then native_focus_id=id elseif native_focus_id==id then native_focus_id=nil end - end - if native_focus_id==id and last_hover and e.typed then return value..e.typed end - return value -end -GuiGetTextDimensions=function(_,text) return #tostring(text)*5,8 end -GameGetFrameNum=function() return 10 end -InputIsKeyJustDown=function() return false end -InputIsKeyDown=function() return false end - -METAMORPH_CREATIVE_MENU_UI_RUNTIME=nil -METAMORPH_CREATIVE_MENU_TEXT_ENTRY_GUARD=nil -local ui=assert(native_dofile(root..'/files/ui/runtime.lua')) -ui.bind(1) -text_events={{hover=false}} -ui.begin_frame() -local value=ui.search_input('',100,64,'focus_test') -ui.button(0,0,'AFTER') -assert(value=='' and text_calls==1 and capture_calls==0,'inactive search was not a persistent GuiTextInput') -assert(ui.text_input_active()==false,'inactive search incorrectly reserved keyboard input') - -left_just=true; text_events={{hover=true}} -ui.begin_frame(); value=ui.search_input(value,100,64,'focus_test') -assert(text_calls==2 and ui.text_input_active()==true,'native activation click did not focus search in-place') -left_just=false - -text_events={{hover=false}}; capture_typed='x' -ui.begin_frame(); value=ui.search_input(value,100,64,'focus_test'); ui.end_frame() -ui.button(0,0,'AFTER') -assert(text_calls==3 and capture_calls==1 and value=='x','focused search did not accept off-hover input through isolated capture gui') -text_events={{hover=false}}; capture_typed='y' -ui.begin_frame(); value=ui.search_input(value,100,64,'focus_test'); ui.end_frame() -assert(text_calls==4 and capture_calls==2 and value=='xy','hovering another MCM surface interrupted logical text focus') -assert(ui.matches_search('fireball','$spell'),'English localization alias was not searchable') -assert(ui.matches_search('огнен','$spell'),'client-language localization alias was not searchable') - -ui.reset_text_inputs() -assert(ui.text_input_active()==false,'reset left keyboard input reserved') -text_events={{hover=false,typed='z'}} -ui.begin_frame(); value=ui.search_input(value,100,64,'focus_test') -assert(value=='' and text_calls==5,'menu reset did not clear search or leaked text from unowned native focus') -print('search_focus=PASS persistent_native=true explicit_click=true isolated_capture=true hover_independent=true close_reset=true bilingual_alias=true') diff --git a/metamorph_creative_menu/tests/settings_action_registry_mock.lua b/metamorph_creative_menu/tests/settings_action_registry_mock.lua deleted file mode 100644 index 80b052a..0000000 --- a/metamorph_creative_menu/tests/settings_action_registry_mock.lua +++ /dev/null @@ -1,52 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile -local registry=native_dofile(root..'/files/core/action_registry.lua') -local codec=native_dofile(root..'/files/core/binding_codec.lua') -local keycodes={matching_name_fragment=function() return {} end,resolve=function() return nil end,available=function() return {} end,pretty_name=function(v) return v end} -dofile=function(path) - if path=='data/scripts/lib/mod_settings.lua' then return true end - if path=='mods/metamorph_creative_menu/files/core/action_registry.lua' then return registry end - if path=='mods/metamorph_creative_menu/files/core/binding_codec.lua' then return codec end - if path=='mods/metamorph_creative_menu/files/platform/noita/keycodes.lua' then return keycodes end - if path=='mods/metamorph_creative_menu/files/platform/noita/binding_capture.lua' then - return native_dofile(root..'/files/platform/noita/binding_capture.lua') - end - if path=='mods/metamorph_creative_menu/files/platform/noita/localization.lua' then return { - register=function() return true end, - translate=function(key,fallback) return fallback or key end, - } end - return native_dofile(path) -end -MOD_SETTING_SCOPE_RUNTIME=1 -mod_settings_update=function() end -mod_settings_gui_count=function() return 0 end -mod_settings_gui=function() end -assert(loadfile(root..'/settings.lua'))() -assert(#mod_settings==#registry.sections(),'keybindings were not grouped into registry sections') -local flattened={} -for _,category in ipairs(mod_settings) do - assert(category.foldable==true and type(category.settings)=='table','settings category is not foldable') - for _,setting in ipairs(category.settings) do flattened[#flattened+1]=setting end -end -local binding_settings={} -local by_id={} -for _,setting in ipairs(flattened) do - by_id[setting.id]=setting - if string.sub(tostring(setting.id),1,8)=='binding_' then binding_settings[#binding_settings+1]=setting end -end -assert(#binding_settings==#registry.actions(),'Noita keybinding settings and in-game action registry diverged') -local seen={} -for index,setting in ipairs(binding_settings) do - local action=registry.actions()[index] - assert(setting.id=='binding_'..action.id and setting.value_default==action.default, - 'settings ordering/default differs from action registry at '..tostring(index)) - assert(not seen[setting.id],'duplicate generated setting id: '..setting.id) - seen[setting.id]=true -end -local policy=assert(by_id.inventory_open_policy,'inventory opening policy setting missing') -assert(policy.value_default=='always_open' and #policy.values==3 - and policy.values[1][1]=='always_open' and policy.values[2][1]=='always_closed' and policy.values[3][1]=='remember', - 'inventory opening policy defaults/options changed') -assert(by_id.inventory_handle_enabled==nil,'removed inventory handle setting is still exposed') -assert(mod_settings_version==6,'settings schema version was not bumped for inventory policy') -print('settings_action_registry=PASS generated=true actions='..tostring(#binding_settings)..' grouped=true defaults=true unique=true inventory_policy=true handle_removed=true') diff --git a/metamorph_creative_menu/tests/spell_drag_world_transaction_mock.lua b/metamorph_creative_menu/tests/spell_drag_world_transaction_mock.lua deleted file mode 100644 index b38d820..0000000 --- a/metamorph_creative_menu/tests/spell_drag_world_transaction_mock.lua +++ /dev/null @@ -1,119 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local sync_calls=0 -local notified={} -local raw_capacity=3 -local loaded_service=nil - -local alive={[1]=true,[2]=true,[3]=true,[4]=true,[10]=true,[20]=true} -local parent={[1]=0,[2]=1,[3]=2,[4]=2,[10]=1,[20]=10} -local fields={ - [31]={inventory_slot={0,0},permanently_attached=false}, - [41]={inventory_slot={-1,-1},permanently_attached=true}, - [201]={inventory_slot={2,0},permanently_attached=false}, -} -local item_for_entity={[3]=31,[4]=41,[20]=201} -local fail_transform={} - -local inventory_slots={} -function inventory_slots.place_exact(player,entity,name,x,y) - if player~=1 or name~='inventory_full' or not alive[entity] then return false,'invalid' end - parent[entity]=10 - local item=item_for_entity[entity] - if item then fields[item].inventory_slot={x,y}; fields[item].permanently_attached=false end - return true,'ok' -end -function inventory_slots.snapshot() return nil,'unused' end -function inventory_slots.is_inside_player_inventory() return true end - -local wand_api={} -function wand_api.ability(wand) return wand==2 and 99 or 0 end -function wand_api.get_object(component,object,key) - if component==99 and object=='gun_config' and key=='deck_capacity' then return raw_capacity,true end - return nil,false -end -function wand_api.set_object(component,object,key,value) - if component~=99 or object~='gun_config' or key~='deck_capacity' then return false end - raw_capacity=value; return true -end -function wand_api.held() return 2 end - -local prefix='mods/metamorph_creative_menu/' -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/integrations/ew/runtime.lua' then - return {force_inventory_sync=function() sync_calls=sync_calls+1 end} - end - if path=='mods/metamorph_creative_menu/files/integrations/ew/world_items.lua' then - return {notify_world_item=function(entity) notified[#notified+1]=entity; return true,'direct' end} - end - if path=='mods/metamorph_creative_menu/files/platform/noita/inventory_slots.lua' then return inventory_slots end - if path=='mods/metamorph_creative_menu/files/platform/noita/wand.lua' then return wand_api end - if path=='mods/metamorph_creative_menu/files/features/spells/factory.lua' then return {create=function() return 0,'unused' end} end - if path=='mods/metamorph_creative_menu/files/features/spells/service.lua' and loaded_service~=nil then return loaded_service end - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -EntityGetIsAlive=function(entity) return alive[entity]==true end -EntityGetParent=function(entity) return parent[entity] or 0 end -EntityRemoveFromParent=function(entity) parent[entity]=0 end -EntityAddChild=function(new_parent,entity) parent[entity]=new_parent end -EntityGetFirstComponentIncludingDisabled=function(entity,kind) - if kind=='ItemComponent' then return item_for_entity[entity] or 0 end - return 0 -end -EntityGetComponentIncludingDisabled=function() return {} end -ComponentGetValue2=function(component,key) - local value=fields[component] and fields[component][key] - if type(value)=='table' then return value[1],value[2] end - return value -end -ComponentSetValue2=function(component,key,a,b) - fields[component]=fields[component] or {} - if b~=nil then fields[component][key]={a,b} else fields[component][key]=a end -end -EntitySetComponentsWithTagEnabled=function() end -EntityGetTransform=function(entity) if entity==1 then return 100,50 end return 0,0 end -EntitySetTransform=function(entity,x,y) - if fail_transform[entity] then error('simulated transform failure') end -end -GameRegenItemActionsInContainer=function() end -GameRegenItemActionsInPlayer=function() end - -loaded_service=assert(native_dofile(root..'/files/features/spells/service.lua')) -local inventory_service=assert(native_dofile(root..'/files/features/spells/inventory_service.lua')) -local permanent_service=assert(native_dofile(root..'/files/features/spells/permanent_service.lua')) - -local regular={entity=3,item_component=31,slot=0,actual_slot=0,actual_slot_y=0} -local regular_entries={regular} -fail_transform[3]=true -local ok,reason=loaded_service.drop_to_world(1,2,regular,regular_entries,300,50) -assert(ok==false and reason=='world_position_failed','wand world target failure did not propagate') -assert(parent[3]==2 and fields[31].inventory_slot[1]==0 and fields[31].permanently_attached==false, - 'failed wand world drop did not restore exact source') -assert(#notified==0,'failed wand world drop published world entity') - -local inventory={entity=20,item_component=201,action_id='X',x=2,y=0,index=2} -fail_transform[20]=true -ok,reason=inventory_service.drop_to_world(1,inventory,250,50) -assert(ok==false and reason=='world_position_failed','inventory world target failure did not propagate') -assert(parent[20]==10 and fields[201].inventory_slot[1]==2 and fields[201].inventory_slot[2]==0, - 'failed inventory world drop did not rollback source slot') -assert(#notified==0,'failed inventory world drop published world entity') - -local permanent={entity=4,item_component=41,actual_slot=-1,actual_slot_y=-1} -fail_transform[4]=true -local before_capacity=raw_capacity -ok,reason=permanent_service.drop_to_world(1,2,permanent,250,50) -assert(ok==false and reason=='world_position_failed','Always Cast world target failure did not propagate') -assert(parent[4]==2 and fields[41].permanently_attached==true and raw_capacity==before_capacity, - 'failed Always Cast world drop did not restore source/capacity') -assert(#notified==0,'failed Always Cast world drop published world entity') - --- Success is the commit boundary: only now may the source detach and be advertised. -fail_transform[3]=false -ok,reason=loaded_service.drop_to_world(1,2,regular,regular_entries,300,50) -assert(ok==true and reason=='thrown' and parent[3]==0,'successful wand world drop did not commit detach') -assert(#notified==1 and notified[1]==3,'successful world drop did not publish exactly once') - -print('spell_drag_world_transaction=PASS wand_rollback=true inventory_rollback=true always_rollback=true success_commit=true') diff --git a/metamorph_creative_menu/tests/spell_factory_unlock_mock.lua b/metamorph_creative_menu/tests/spell_factory_unlock_mock.lua deleted file mode 100644 index 43e5589..0000000 --- a/metamorph_creative_menu/tests/spell_factory_unlock_mock.lua +++ /dev/null @@ -1,24 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local persistent={} -local removed={} -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/features/spells/catalog.lua' then - return {load=function()return true end,by_id=function()return {LOCKED={spawn_requires_flag='card_unlock_test'},OPEN={}} end} - end - return native_dofile(path) -end -GameHasFlagPersistent=function(flag)return persistent[flag]==true end -RemoveFlagPersistent=function(flag)persistent[flag]=nil;removed[#removed+1]=flag end -CreateItemActionEntity=function(id) - if id=='LOCKED' then persistent.card_unlock_test=true end - return id=='FAIL' and 0 or 42 -end -local factory=assert(native_dofile(root..'/files/features/spells/factory.lua')) -local entity=select(1,factory.create('LOCKED')) -assert(entity==42 and persistent.card_unlock_test~=true and removed[#removed]=='card_unlock_test','new unlock side effect was not reverted') -persistent.card_unlock_test=true -factory.create('LOCKED') -assert(persistent.card_unlock_test==true,'pre-existing unlock was removed') -assert(select(1,factory.create('FAIL'))==0,'failed creation changed result') -print('spell_factory_unlock=PASS new_flag_reverted=true existing_flag_preserved=true') diff --git a/metamorph_creative_menu/tests/spell_inventory_grid_mock.lua b/metamorph_creative_menu/tests/spell_inventory_grid_mock.lua deleted file mode 100644 index 1fb87e2..0000000 --- a/metamorph_creative_menu/tests/spell_inventory_grid_mock.lua +++ /dev/null @@ -1,44 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local prefix="mods/metamorph_creative_menu/" -local tiles,targets,rows,buttons=0,0,0,0 - -local ui={ - ICON_STEP=20, EMPTY_SLOT="empty.png", - gui=function() return 1 end, - white_text=function() end, - tr=function(_,fallback) return fallback end, - tile=function() - tiles=tiles+1 - return false,false,false,tiles*20,10,18,18 - end, -} -local drag={ - source=function() end, - target=function(id,bounds,accept,drop,priority) - targets=targets+1 - assert(id=="spells.inventory_target."..tostring(targets-1),"inventory target index drifted while wrapping") - end, -} -local inventory={contents=function() - return {inventory=10,width=16,height=1,capacity=16,by_index={},entries={}},"ok" -end} - -dofile=function(path) - if path==prefix.."files/ui/runtime.lua" then return ui end - if path==prefix.."files/ui/drag_drop.lua" then return drag end - if path==prefix.."files/features/spells/inventory_service.lua" then return inventory end - return native_dofile(path) -end -function GuiLayoutBeginHorizontal() rows=rows+1 end -function GuiLayoutEnd() end -function GuiButton() buttons=buttons+1; return false end - -METAMORPH_CREATIVE_MENU_SPELL_INVENTORY_UI=nil -local component=assert(native_dofile(root.."/files/ui/components/spell_inventory_strip.lua")) -local layout,reason=component.draw(1,150,640,360,nil,{}) -assert(reason=="ok" and layout.capacity==16,"spell inventory did not expose stock 16-cell capacity") -assert(tiles==16 and targets==16,"narrow menu hid spell inventory cells") -assert(rows==2,"16 cells should use the available narrow-menu width before wrapping") -assert(buttons==0,"spell inventory pagination arrows/buttons survived") -print("spell_inventory_grid=PASS all_16=true arrows=false wrap_rows=2 exact_targets=true") diff --git a/metamorph_creative_menu/tests/spell_inventory_transfer_mock.lua b/metamorph_creative_menu/tests/spell_inventory_transfer_mock.lua deleted file mode 100644 index 913bce3..0000000 --- a/metamorph_creative_menu/tests/spell_inventory_transfer_mock.lua +++ /dev/null @@ -1,291 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local sync_calls=0 -local frame=1 -local regen_calls=0 -local sentinel_create_count=0 -local sentinel_kill_count=0 -local fail_slot_write=nil -local fail_real_pickup_once=false -local bridge=nil - --- The real-game regression only affects already-registered spell entities. The full build --- now serializes those cards, creates a fresh ECS entity, and then sends that fresh card --- through the same exact native pickup transaction that already works for catalog cards. -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/integrations/ew/runtime.lua' then - return {force_inventory_sync=function() sync_calls=sync_calls+1 end} - end - if path=='mods/metamorph_creative_menu/files/integrations/ew/world_items.lua' then - return {notify_world_item=function() return true,'direct' end} - end - if path=='mods/metamorph_creative_menu/files/platform/noita/patcher_bridge.lua' then - return {get=function() return bridge end} - end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -local alive={[1]=true,[2]=true,[10]=true,[3]=true,[4]=true,[20]=true,[21]=true} -local parent={[1]=0,[2]=0,[10]=1,[3]=2,[4]=2,[20]=10,[21]=10} -local children={[1]={10},[2]={3,4},[10]={20,21},[3]={},[4]={},[20]={},[21]={}} -local names={[1]='player',[2]='wand',[10]='inventory_full'} -local tags={} -local component_owner={} -local component_type={} -local fields={} -local objects={} -local next_entity=1000 -local next_component=10000 - -local function copy(value) - if type(value)~='table' then return value end - local out={} - for k,v in pairs(value) do out[k]=copy(v) end - return out -end - -local function add_component(entity,id,kind,data) - component_owner[id]=entity; component_type[id]=kind; fields[id]=data or {} -end -local function new_component(entity,kind,data) - local id=next_component; next_component=next_component+1 - add_component(entity,id,kind,data) - return id -end -add_component(1,11,'Inventory2Component',{full_inventory_slots_x=4,full_inventory_slots_y=1,quick_inventory_slots=4,mForceRefresh=false}) -add_component(2,12,'AbilityComponent',{use_gun_script=true,mana=50,mana_max=100,mana_charge_speed=10}) -objects[12]={deck_capacity=2} -add_component(3,31,'ItemComponent',{permanently_attached=false,inventory_slot={0,0},uses_remaining=7,is_frozen=false,mItemUid=301,mFramePickedUp=31,has_been_picked_by_player=true}) -add_component(3,32,'ItemActionComponent',{action_id='A'}) -add_component(4,41,'ItemComponent',{permanently_attached=false,inventory_slot={1,0},uses_remaining=5,is_frozen=false,mItemUid=401,mFramePickedUp=41,has_been_picked_by_player=true}) -add_component(4,42,'ItemActionComponent',{action_id='B'}) -add_component(20,201,'ItemComponent',{permanently_attached=false,inventory_slot={0,0},uses_remaining=3,is_frozen=true,mItemUid=2001,mFramePickedUp=201,has_been_picked_by_player=true}) -add_component(20,202,'ItemActionComponent',{action_id='X'}) -add_component(21,211,'ItemComponent',{permanently_attached=false,inventory_slot={1,0},uses_remaining=2,is_frozen=false,mItemUid=2101,mFramePickedUp=211,has_been_picked_by_player=true}) -add_component(21,212,'ItemActionComponent',{action_id='Y'}) - -EntityGetIsAlive=function(entity) return alive[entity]==true end -EntityGetAllChildren=function(entity) - local out={} - for _,child in ipairs(children[entity] or {}) do if alive[child] then out[#out+1]=child end end - return out -end -EntityGetFirstComponentIncludingDisabled=function(entity,kind) - for id,owner in pairs(component_owner) do if owner==entity and component_type[id]==kind then return id end end - return 0 -end -ComponentGetValue2=function(component,field) - local value=fields[component] and fields[component][field] - if type(value)=='table' then return value[1],value[2] end - return value -end -ComponentSetValue2=function(component,field,a,b) - fields[component]=fields[component] or {} - if field=='inventory_slot' and fail_slot_write and fail_slot_write.component==component and fail_slot_write.x==a then - fail_slot_write=nil; error('injected slot write failure') - end - if b~=nil then fields[component][field]={a,b} else fields[component][field]=a end -end -ComponentObjectGetValue2=function(component,object,field) return objects[component] and objects[component][field] end -ComponentObjectSetValue2=function(component,object,field,value) objects[component]=objects[component] or {}; objects[component][field]=value end -EntityGetName=function(entity) return names[entity] or '' end -EntityGetParent=function(entity) return parent[entity] or 0 end -EntityGetRootEntity=function(entity) - local current=entity - for _=1,16 do local p=parent[current] or 0; if p==0 then return current end; current=p end - return current -end -EntityAddTag=function(entity,tag) tags[entity]=tags[entity] or {}; tags[entity][tag]=true end -EntityHasTag=function(entity,tag) return tags[entity]~=nil and tags[entity][tag]==true end -EntityRemoveFromParent=function(entity) - local old=parent[entity] or 0 - if old~=0 then for i=#(children[old] or {}),1,-1 do if children[old][i]==entity then table.remove(children[old],i) end end end - parent[entity]=0 -end -EntityAddChild=function(new_parent,entity) - EntityRemoveFromParent(entity) - parent[entity]=new_parent - children[new_parent]=children[new_parent] or {} - children[new_parent][#children[new_parent]+1]=entity -end -EntityKill=function(entity) - EntityRemoveFromParent(entity) - alive[entity]=false -end -EntityCreateNew=function() - local entity=next_entity; next_entity=next_entity+1 - alive[entity]=true; parent[entity]=0; children[entity]={}; names[entity]=''; tags[entity]={} - return entity -end -EntitySetComponentsWithTagEnabled=function() end -EntityGetTransform=function() return 100,200 end -EntitySetTransform=function() end -GameGetFrameNum=function() return frame end -GameRegenItemActionsInContainer=function() regen_calls=regen_calls+1 end -GameRegenItemActionsInPlayer=function() regen_calls=regen_calls+1 end -ModTextFileGetContent=function() return '' end - -bridge={ - SerializeEntity=function(entity) - local item=EntityGetFirstComponentIncludingDisabled(entity,'ItemComponent') - local action=EntityGetFirstComponentIncludingDisabled(entity,'ItemActionComponent') - assert(item~=0 and action~=0,'serialize missing spell components') - return {item=copy(fields[item]), action=copy(fields[action]), tags=copy(tags[entity] or {})} - end, - DeserializeEntity=function(entity,data) - new_component(entity,'ItemComponent',copy(data.item)) - new_component(entity,'ItemActionComponent',copy(data.action)) - tags[entity]=copy(data.tags or {}) - end, -} - -local function make_action(action_id) - local entity=EntityCreateNew() - new_component(entity,'ItemComponent',{permanently_attached=false,inventory_slot={-1,-1},uses_remaining=-1,is_frozen=false,mItemUid=0,mFramePickedUp=0}) - new_component(entity,'ItemActionComponent',{action_id=action_id}) - return entity -end -CreateItemActionEntity=function(action_id) - sentinel_create_count=sentinel_create_count+1 - return make_action(action_id) -end - -local function first_free_spell_slot(inventory) - local occupied={} - for _,child in ipairs(EntityGetAllChildren(inventory) or {}) do - local action=EntityGetFirstComponentIncludingDisabled(child,'ItemActionComponent') - local item=EntityGetFirstComponentIncludingDisabled(child,'ItemComponent') - if action~=0 and item~=0 then - local x,y=ComponentGetValue2(item,'inventory_slot') - x=tonumber(x); y=tonumber(y) or 0 - if x~=nil and x>=0 and x<4 and y==0 then occupied[x]=true end - end - end - for x=0,3 do if not occupied[x] then return x,0 end end - return nil,nil -end - --- Deliberately model the hostile native behavior from the game: preferred inventory_slot is --- ignored for a picked card and the private InventoryComponent registration chooses the first --- free spell cell. Sentinel isolation is therefore meaningful in this test. -GamePickUpInventoryItem=function(player_entity,entity) - local sentinel=EntityHasTag(entity,'metamorph_creative_menu_inventory_sentinel') - if fail_real_pickup_once and not sentinel then fail_real_pickup_once=false; return false end - local x,y=first_free_spell_slot(10) - if x==nil then return false end - local item=EntityGetFirstComponentIncludingDisabled(entity,'ItemComponent') - ComponentSetValue2(item,'inventory_slot',x,y) - -- A real pickup assigns fresh registration history; this is exactly what a rehydrated card - -- needs instead of carrying the wand's stale native identity into inventory_full. - ComponentSetValue2(item,'mItemUid',entity*10+1) - ComponentSetValue2(item,'mFramePickedUp',frame) - EntityAddChild(10,entity) - return true -end -GameKillInventoryItem=function(player_entity,entity) - if EntityHasTag(entity,'metamorph_creative_menu_inventory_sentinel') then sentinel_kill_count=sentinel_kill_count+1 end - EntityKill(entity) - return true -end - -local inventory_service=assert(native_dofile(root..'/files/features/spells/inventory_service.lua')) -local spell_service=assert(native_dofile(root..'/files/features/spells/service.lua')) - -local function inv_entry(action_id) - local inv=assert(inventory_service.contents(1)) - for _,entry in ipairs(inv.entries) do if entry.action_id==action_id then return entry,inv end end - return nil,inv -end -local function wand_entry(action_id) - local slots,_,_,entries=spell_service.contents(2) - for _,entry in pairs(slots or {}) do if entry and entry.action_id==action_id then return entry,slots,entries end end - for _,entry in ipairs(entries or {}) do if entry.action_id==action_id then return entry,slots,entries end end - return nil,slots,entries -end -local function assert_state(entry,uses,frozen,label) - assert(entry and EntityGetIsAlive(entry.entity),label..' missing') - local item=entry.item_component - assert(ComponentGetValue2(item,'uses_remaining')==uses and ComponentGetValue2(item,'is_frozen')==frozen,label..' runtime state changed') -end - -local x,inv=inv_entry('X') -assert(inv.capacity==4 and x.index==0,'spell inventory snapshot changed') -local old_x_entity=x.entity -assert(inventory_service.move(1,x,2)==true,'move to empty inventory slot failed') -x,inv=inv_entry('X') -assert(x.index==2 and inv.by_index[0]==nil,'rehydrated inventory move used wrong slot') -assert(x.entity~=old_x_entity and not EntityGetIsAlive(old_x_entity),'registered card was not rehydrated into a fresh entity') -assert_state(x,3,true,'rehydrated X') -assert(sentinel_create_count>0 and sentinel_kill_count==sentinel_create_count,'fresh exact pickup did not clean sentinels') - --- A delayed native packing pass must rehydrate the current registered card again and return it --- to the requested cell rather than trying another direct inventory_slot write. -local drift_entity=x.entity -ComponentSetValue2(x.item_component,'inventory_slot',3,0) -frame=frame+1 -local sentinels_before=sentinel_create_count -local repaired=select(1,spell_service.settle_slots()) -assert(repaired==1,'next-frame slot settle did not report a repair') -x=assert(inv_entry('X')) -assert(x.index==2 and x.entity~=drift_entity and not EntityGetIsAlive(drift_entity),'next-frame repair did not use fresh native registration') -assert(sentinel_create_count>sentinels_before,'deferred repair did not use exact native pickup') -assert_state(x,3,true,'settled X') - --- Occupied inventory drops are collision-safe swaps. Entity ids may change deliberately, but --- action identity and runtime card state must survive full serialization. -local y=assert(inv_entry('Y')) -assert(inventory_service.move(1,x,1)==true,'inventory swap failed') -x,inv=inv_entry('X'); y=assert(inv_entry('Y')) -assert(x.index==1 and y.index==2,'inventory occupied drop did not swap exact cells') -assert_state(x,3,true,'swapped X'); assert_state(y,2,false,'swapped Y') - --- Inject one failed native registration. Rehydration must restore logical cards and their prior --- exact positions even though rollback necessarily gets new entity ids as well. -fail_real_pickup_once=true -assert(inventory_service.move(1,x,2)==false,'injected native pickup failure reported success') -x,inv=inv_entry('X'); y=assert(inv_entry('Y')) -assert(x.index==1 and y.index==2,'failed exact swap did not restore prior slots') -assert_state(x,3,true,'rollback X'); assert_state(y,2,false,'rollback Y') - --- Cross-surface transfer is the user-reported case: inventory -> wand and then the same card --- back from wand -> a distant exact inventory cell. The latter must discard the wand's native --- registration and return a freshly deserialized card through GamePickUpInventoryItem. -local wand_a,wand_slots,wand_entries=wand_entry('A') -assert(spell_service.adopt_inventory(1,2,x,0,wand_a,wand_entries)==true,'inventory->wand occupied swap failed') -x=assert(wand_entry('X')) -local a=assert(inv_entry('A')) -assert(x.slot==0 and a.index==1,'inventory->wand swap exact positions wrong') -assert_state(x,3,true,'wand X'); assert_state(a,7,false,'inventory A') - -local wand_x,slots_after,entries_after=wand_entry('X') -local wand_x_old=wand_x.entity -assert(spell_service.export_to_inventory_slot(1,2,wand_x,entries_after,3,0)==true,'wand->distant inventory slot failed') -x=assert(inv_entry('X')) -assert(x.index==3,'wand export did not land in requested inventory cell') -assert(x.entity~=wand_x_old and not EntityGetIsAlive(wand_x_old),'wand card retained stale registered entity id') -assert_state(x,3,true,'exported X') - --- Reverse occupied cross-surface swap still works with the replacement ids returned by exact --- inventory placement. -a=assert(inv_entry('A')) -local b,slots_b,entries_b=wand_entry('B') -assert(a and a.index==1 and b and b.slot==1,'reverse swap setup invalid') -assert(spell_service.adopt_inventory(1,2,a,1,b,entries_b)==true,'occupied inventory<->wand reverse swap failed') -a=assert(wand_entry('A')); b=assert(inv_entry('B')) -assert(a.slot==1 and b.index==1,'reverse cross-surface exact positions wrong') -assert_state(a,7,false,'wand A'); assert_state(b,5,false,'inventory B') - --- Wand slot repair stays a simple component transaction; only inventory_full needs rehydration. -spell_service.expect_exact_slots(1,'injected_settle_failure',{{entity=a.entity,parent=2,x=1,y=0}}) -ComponentSetValue2(a.item_component,'inventory_slot',0,0) -fail_slot_write={component=a.item_component,x=1} -frame=frame+1 -local repaired_fault,failed_fault=spell_service.settle_slots() -assert(repaired_fault==0 and failed_fault==1 and ComponentGetValue2(a.item_component,'inventory_slot')==0,'failed deferred wand settle changed last valid coordinate') - -assert(sentinel_kill_count==sentinel_create_count,'transaction leaked sentinel entities') -assert(sync_calls>=5,'transfers did not request inventory synchronization') -assert(regen_calls==0,'ordinary spell drag called gameplay action regeneration APIs') -print('spell_inventory_transfer=PASS rehydrate_registered_cards=true engine_first_free_model=true exact_native_pickup=true state_preserved=true distant_wand_export=true rollback=true swaps=true no_action_regen=true') diff --git a/metamorph_creative_menu/tests/spell_permanent_service_mock.lua b/metamorph_creative_menu/tests/spell_permanent_service_mock.lua deleted file mode 100644 index 9fa2507..0000000 --- a/metamorph_creative_menu/tests/spell_permanent_service_mock.lua +++ /dev/null @@ -1,183 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local sync_calls=0 -local world_notified={} - -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/integrations/ew/runtime.lua' then - return {force_inventory_sync=function() sync_calls=sync_calls+1 end} - end - if path=='mods/metamorph_creative_menu/files/integrations/ew/world_items.lua' then - return {notify_world_item=function(entity) world_notified[#world_notified+1]=entity; return true,'direct' end} - end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -local alive={[1]=true,[2]=true,[10]=true,[3]=true,[4]=true,[20]=true} -local parent={[1]=0,[2]=0,[10]=1,[3]=2,[4]=2,[20]=10} -local children={[1]={10},[2]={3,4},[10]={20},[3]={},[4]={},[20]={}} -local names={[1]='player',[2]='wand',[10]='inventory_full'} -local component_owner={} -local component_type={} -local fields={} -local objects={} -local fail_capacity_write=false - -local function add_component(entity,id,kind,data) - component_owner[id]=entity; component_type[id]=kind; fields[id]=data or {} -end -add_component(1,11,'Inventory2Component',{full_inventory_slots_x=4,full_inventory_slots_y=1,quick_inventory_slots=4,mForceRefresh=false,mActiveItem=2}) -add_component(2,12,'AbilityComponent',{use_gun_script=true,mana=50,mana_max=100,mana_charge_speed=10}) -objects[12]={deck_capacity=3} -add_component(3,31,'ItemComponent',{permanently_attached=false,inventory_slot={0,0},uses_remaining=7,is_frozen=false}) -add_component(3,32,'ItemActionComponent',{action_id='A'}) -add_component(4,41,'ItemComponent',{permanently_attached=true,inventory_slot={-1,-1},uses_remaining=5,is_frozen=true}) -add_component(4,42,'ItemActionComponent',{action_id='P'}) -add_component(20,201,'ItemComponent',{permanently_attached=false,inventory_slot={0,0},uses_remaining=3,is_frozen=true}) -add_component(20,202,'ItemActionComponent',{action_id='X'}) - -EntityGetIsAlive=function(entity) return alive[entity]==true end -EntityGetAllChildren=function(entity) - local out={} - for _,child in ipairs(children[entity] or {}) do if alive[child] then out[#out+1]=child end end - return out -end -EntityGetFirstComponentIncludingDisabled=function(entity,kind) - for id,owner in pairs(component_owner) do if owner==entity and component_type[id]==kind then return id end end - return 0 -end -ComponentGetValue2=function(component,field) - local value=fields[component] and fields[component][field] - if type(value)=='table' then return value[1],value[2] end - return value -end -ComponentSetValue2=function(component,field,a,b) - fields[component]=fields[component] or {} - if b~=nil then fields[component][field]={a,b} else fields[component][field]=a end -end -ComponentObjectGetValue2=function(component,object,field) return objects[component] and objects[component][field] end -ComponentObjectSetValue2=function(component,object,field,value) - if fail_capacity_write and field=='deck_capacity' then return end - objects[component]=objects[component] or {}; objects[component][field]=value -end -EntityGetName=function(entity) return names[entity] or '' end -EntityGetParent=function(entity) return parent[entity] or 0 end -EntityGetRootEntity=function(entity) - local current=entity - for _=1,16 do local p=parent[current] or 0; if p==0 then return current end; current=p end - return current -end -EntityHasTag=function() return false end -EntityRemoveFromParent=function(entity) - local old=parent[entity] or 0 - if old~=0 then for i=#(children[old] or {}),1,-1 do if children[old][i]==entity then table.remove(children[old],i) end end end - parent[entity]=0 -end -EntityAddChild=function(new_parent,entity) - EntityRemoveFromParent(entity); parent[entity]=new_parent; children[new_parent]=children[new_parent] or {}; children[new_parent][#children[new_parent]+1]=entity -end -EntityKill=function(entity) alive[entity]=false end -EntitySetComponentsWithTagEnabled=function() end -EntityGetTransform=function() return 100,200 end -EntitySetTransform=function() end -GameRegenItemActionsInContainer=function() end -GameRegenItemActionsInPlayer=function() end -GamePickUpInventoryItem=function(player,entity) - -- The tested exact-placement path does not depend on this, but generic inventory does. - local inv=10; EntityAddChild(inv,entity) -end -ModTextFileGetContent=function() return '' end - -local next_entity=30 -CreateItemActionEntity=function(action_id) - local entity=next_entity; next_entity=next_entity+1 - alive[entity]=true; parent[entity]=0; children[entity]={} - local item=entity*10+1; local action=entity*10+2 - add_component(entity,item,'ItemComponent',{permanently_attached=false,inventory_slot={-1,-1},uses_remaining=9,is_frozen=false}) - add_component(entity,action,'ItemActionComponent',{action_id=action_id}) - return entity -end - -local spell_service=assert(native_dofile(root..'/files/features/spells/service.lua')) -local permanent_service=assert(native_dofile(root..'/files/features/spells/permanent_service.lua')) -local inventory_service=assert(native_dofile(root..'/files/features/spells/inventory_service.lua')) - -local slots,highest,permanent,entries,permanent_entries=spell_service.contents(2) -assert(permanent==1 and #permanent_entries==1 and permanent_entries[1].action_id=='P','permanent action enumeration failed') -assert(spell_service.capacity(2,highest,permanent)==2,'effective capacity must exclude Always Cast actions') - --- Promotion grows raw capacity by one, preserving the number of ordinary slots. -local normal=slots[0] -assert(permanent_service.promote(1,2,normal)==true,'promote failed') -assert(fields[31].permanently_attached==true and fields[31].inventory_slot[1]==-1,'promote did not mark card permanent') -assert(objects[12].deck_capacity==4,'promote did not preserve effective capacity') -local raw_after_first_promote=objects[12].deck_capacity -local duplicate_ok=permanent_service.promote(1,2,normal) -assert(duplicate_ok==false and objects[12].deck_capacity==raw_after_first_promote, - 'duplicate promotion of the same card grew raw capacity twice') -slots,highest,permanent,entries,permanent_entries=spell_service.contents(2) -assert(permanent==2 and spell_service.capacity(2,highest,permanent)==2,'promotion changed ordinary capacity') - --- Demotion reverses the raw-capacity increment and restores an ordinary slot. -local promoted -for _,entry in ipairs(permanent_entries) do if entry.action_id=='A' then promoted=entry end end -assert(promoted and permanent_service.demote(1,2,promoted,0)==true,'demote failed') -assert(fields[31].permanently_attached==false and fields[31].inventory_slot[1]==0,'demote did not restore card slot') -assert(objects[12].deck_capacity==3,'demote did not restore raw capacity') - --- Swapping a normal card with an Always Cast card keeps raw capacity exactly unchanged. -slots,highest,permanent,entries,permanent_entries=spell_service.contents(2) -local perm_p=permanent_entries[1] -local raw_before_swap=objects[12].deck_capacity -assert(permanent_service.swap_with_slot(1,2,perm_p,slots[0])==true,'Always Cast/slot swap failed') -assert(objects[12].deck_capacity==raw_before_swap,'role swap changed raw capacity') -assert(fields[41].permanently_attached==false and fields[41].inventory_slot[1]==0,'old permanent did not become normal') -assert(fields[31].permanently_attached==true,'old normal did not become permanent') - --- Catalog add and replacement preserve progression-neutral card creation semantics while --- maintaining the raw-capacity invariant. -local raw_before_add=objects[12].deck_capacity -local add_ok,_,added_entity=permanent_service.add(1,2,'Q') -assert(add_ok and alive[added_entity] and objects[12].deck_capacity==raw_before_add+1,'Always Cast add failed') -slots,highest,permanent,entries,permanent_entries=spell_service.contents(2) -local q -for _,entry in ipairs(permanent_entries) do if entry.action_id=='Q' then q=entry end end -local raw_before_replace=objects[12].deck_capacity -assert(q and permanent_service.replace(1,2,q,'R')==true,'Always Cast replacement failed') -assert(objects[12].deck_capacity==raw_before_replace,'Always Cast replacement changed capacity') - --- Inventory -> Always Cast moves the exact entity, preserving mutable/custom-like state. -local inv=assert(inventory_service.contents(1)) -local inv_x=inv.by_index[0] -local raw_before_adopt=objects[12].deck_capacity -assert(permanent_service.adopt_inventory(1,2,inv_x)==true,'inventory->Always Cast failed') -assert(parent[20]==2 and fields[201].permanently_attached==true,'inventory entity was cloned/lost instead of promoted') -assert(fields[201].uses_remaining==3 and fields[201].is_frozen==true,'inventory card runtime state changed') -assert(objects[12].deck_capacity==raw_before_adopt+1,'inventory promotion changed ordinary capacity') - --- Exporting the same permanent entity to an exact empty spell-inventory slot reverses raw capacity. -slots,highest,permanent,entries,permanent_entries=spell_service.contents(2) -local perm_x -for _,entry in ipairs(permanent_entries) do if entry.entity==20 then perm_x=entry end end -local raw_before_export=objects[12].deck_capacity -assert(perm_x and permanent_service.export_to_inventory_slot(1,2,perm_x,2,0)==true,'Always Cast exact inventory export failed') -assert(parent[20]==10 and fields[201].inventory_slot[1]==2 and fields[201].permanently_attached==false,'permanent export did not preserve exact entity/slot') -assert(objects[12].deck_capacity==raw_before_export-1,'permanent export did not restore raw capacity') - --- A failed capacity write must rollback the card itself; no half-promoted state is allowed. -slots,highest,permanent,entries,permanent_entries=spell_service.contents(2) -local regular_p=slots[0] -assert(regular_p and regular_p.action_id=='P','fault setup lost normal spell') -local old_slot=fields[regular_p.item_component].inventory_slot[1] -local raw_fault=objects[12].deck_capacity -fail_capacity_write=true -local fault_ok=permanent_service.promote(1,2,regular_p) -fail_capacity_write=false -assert(fault_ok==false,'failed capacity write reported successful promote') -assert(fields[regular_p.item_component].permanently_attached==false and fields[regular_p.item_component].inventory_slot[1]==old_slot,'failed promote did not rollback card metadata') -assert(objects[12].deck_capacity==raw_fault,'failed promote changed raw capacity') - -assert(sync_calls>=6,'Always Cast operations did not refresh inventory/EW state') -print('spell_permanent_service=PASS enumerate=true capacity_invariant=true promote_demote=true duplicate_idempotent=true role_swap=true exact_entity=true rollback=true') diff --git a/metamorph_creative_menu/tests/spell_service_mock.lua b/metamorph_creative_menu/tests/spell_service_mock.lua index b0efdb6..e7dd1fe 100644 --- a/metamorph_creative_menu/tests/spell_service_mock.lua +++ b/metamorph_creative_menu/tests/spell_service_mock.lua @@ -1,22 +1,15 @@ local root=assert(arg[1]) local native_dofile=dofile local sync_calls=0 -local notified_world_items={} -local regen_calls=0 -local fail_slot_write=nil dofile=function(path) if path=='mods/metamorph_creative_menu/files/integrations/ew/runtime.lua' then return {force_inventory_sync=function() sync_calls=sync_calls+1 end} end - if path=='mods/metamorph_creative_menu/files/integrations/ew/world_items.lua' then - return {notify_world_item=function(entity) notified_world_items[#notified_world_items+1]=entity; return true,'direct' end} - end local prefix='mods/metamorph_creative_menu/' if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end return native_dofile(path) end local alive={[1]=true,[2]=true,[3]=true,[4]=true} -local transforms={[1]={100,50}} local parent={[1]=0,[2]=1,[3]=2,[4]=2} local children={[1]={2},[2]={3,4},[3]={},[4]={}} local component_owner={[11]=1,[21]=2,[31]=3,[32]=3,[41]=4,[42]=4} @@ -34,11 +27,6 @@ EntityGetFirstComponentIncludingDisabled=function(entity,component_name) for component,owner in pairs(component_owner) do if owner==entity and component_type[component]==component_name then return component end end return 0 end -EntityGetComponentIncludingDisabled=function(entity,component_name) - local result={} - for component,owner in pairs(component_owner) do if owner==entity and component_type[component]==component_name then result[#result+1]=component end end - return result -end ComponentGetValue2=function(component,field) local value=fields[component] and fields[component][field] if type(value)=='table' then return value[1],value[2] end @@ -46,11 +34,6 @@ ComponentGetValue2=function(component,field) end ComponentSetValue2=function(component,field,a,b) fields[component]=fields[component] or {} - if field=='inventory_slot' and fail_slot_write - and fail_slot_write.component==component and fail_slot_write.x==a then - fail_slot_write=nil - error('injected wand slot write failure') - end if b~=nil then fields[component][field]={a,b} else fields[component][field]=a end end ComponentObjectGetValue2=function(component,object_name,field) return gun_config[component] and gun_config[component][field] end @@ -63,10 +46,10 @@ EntityKill=function(entity) alive[entity]=false end EntityGetParent=function(entity) return parent[entity] or 0 end EntityAddChild=function(new_parent,entity) EntityRemoveFromParent(entity); parent[entity]=new_parent; children[new_parent]=children[new_parent] or {}; children[new_parent][#children[new_parent]+1]=entity end EntitySetComponentsWithTagEnabled=function() end -EntityGetTransform=function(entity) local t=transforms[entity] or {0,0}; return t[1],t[2] end -EntitySetTransform=function(entity,x,y) transforms[entity]={x,y} end -GameRegenItemActionsInContainer=function() regen_calls=regen_calls+1; fields[21].mana=0; fields[21].mana_max=1; fields[21].mana_charge_speed=1 end -GameRegenItemActionsInPlayer=function() regen_calls=regen_calls+1 end +EntityGetTransform=function() return 0,0 end +EntitySetTransform=function() end +GameRegenItemActionsInContainer=function() fields[21].mana=0; fields[21].mana_max=1; fields[21].mana_charge_speed=1 end +GameRegenItemActionsInPlayer=function() end local next_entity=5 CreateItemActionEntity=function(action_id) local entity=next_entity; next_entity=next_entity+1 @@ -77,9 +60,6 @@ CreateItemActionEntity=function(action_id) component_owner[action_component]=entity; component_type[action_component]='ItemActionComponent' fields[item_component]={permanently_attached=false,inventory_slot={-1,-1}} fields[action_component]={action_id=action_id} - local velocity_component=250+entity - component_owner[velocity_component]=entity; component_type[velocity_component]='VelocityComponent' - fields[velocity_component]={mVelocity={0,0}} return entity end @@ -90,21 +70,10 @@ assert(highest==1 and permanent==0 and by_slot[0].entity==3 and by_slot[1].entit assert(service.capacity(2,highest,permanent)==3,'wand capacity changed') local replaced=service.replace(1,2,1,'C',by_slot[1],entries); assert(replaced) assert(not alive[4],'replaced spell survived') -assert(fields[41].inventory_slot[1]==0,'replaced card was mutated before retirement') +assert(fields[41].inventory_slot[1]==1,'existing duplicate slot was not normalized before replacement') assert(fields[21].mana==50 and fields[21].mana_max==100 and fields[21].mana_charge_speed==10,'wand mana state not preserved') -assert(regen_calls==0,'ordinary spell edit called gameplay action regeneration APIs') assert(sync_calls==1,'spell edit did not request EW inventory sync') -local move_slots,_,_,move_entries=service.contents(2) -local move_source,move_target=move_slots[0],move_slots[1] -fail_slot_write={component=move_source.item_component,x=1} -assert(service.move(1,2,move_source,1,move_target,move_entries)==false, - 'injected wand swap failure reported success') -assert(fields[move_source.item_component].inventory_slot[1]==move_source.actual_slot - and fields[move_target.item_component].inventory_slot[1]==move_target.actual_slot, - 'failed wand swap stranded a card at its temporary coordinate') - --- INSERT preserves the selected card and shifts the suffix into the nearest free slot. -- A failed attach must leave the old spell intact and must not publish inventory sync. local by_slot2,_,_,entries2=service.contents(2) local old_entity=by_slot2[0].entity @@ -113,58 +82,10 @@ EntityAddChild=function(new_parent,entity) if entity>=next_entity-1 then return end return old_add_child(new_parent,entity) end --- Re-introduce a conflicting physical coordinate on the other card. A failed replacement --- must not normalize that unrelated card before its candidate has committed. -local other_entry=by_slot2[1] -assert(other_entry~=nil,'test setup lost replacement card') -ComponentSetValue2(other_entry.item_component,'inventory_slot',0,0) -local by_slot_fault,_,_,entries_fault=service.contents(2) -assert(by_slot_fault[1]~=nil and by_slot_fault[1].actual_slot==0,'fault setup did not create duplicate slot') -local before_other_slot=fields[by_slot_fault[1].item_component].inventory_slot[1] local before_sync=sync_calls -local failed=service.replace(1,2,0,'D',by_slot_fault[0],entries_fault) +local failed=service.replace(1,2,0,'D',by_slot2[0],entries2) assert(failed==false,'failed attach reported success') assert(alive[old_entity]==true and parent[old_entity]==2,'failed replacement destroyed or detached old spell') -assert(fields[by_slot_fault[1].item_component].inventory_slot[1]==before_other_slot,'failed replacement mutated another card before commit') assert(sync_calls==before_sync,'failed replacement published inventory sync') EntityAddChild=old_add_child - --- DROP must commit only after the entity is really detached, then advertise the real --- vanilla action through the same optional EW world-item adapter used by ITEMS. -local by_slot3,_,_,entries3=service.contents(2) -local drop_entry=by_slot3[0] -local drop_entity=drop_entry.entity -local drop_sync_before=sync_calls -assert(service.remove(1,2,drop_entry,entries3,true)==true,'world drop failed') -assert(parent[drop_entity]==0 and alive[drop_entity]==true,'world drop did not preserve a detached live action') -assert(notified_world_items[#notified_world_items]==drop_entity,'world drop was not handed to EW world-item transport') -assert(sync_calls==drop_sync_before+1,'committed world drop did not refresh inventory') - --- Dragging a catalog spell into the world creates a real card near the player and --- gives it velocity toward the release point instead of spawning motionless at the feet. -local throw_notify_before=#notified_world_items -local thrown,throw_reason,throw_entity=service.throw_catalog(1,'THROW',300,50) -assert(thrown==true and throw_reason=='thrown' and alive[throw_entity]==true,'catalog throw failed') -local throw_x,throw_y=EntityGetTransform(throw_entity) -assert(throw_x>100 and math.abs(throw_y-44)<1,'thrown card origin did not move toward cursor') -local velocity_component=EntityGetFirstComponentIncludingDisabled(throw_entity,'VelocityComponent') -local throw_vx,throw_vy=ComponentGetValue2(velocity_component,'mVelocity') -assert(throw_vx>100 and math.abs(throw_vy)<1,'thrown card did not receive directional velocity') -assert(#notified_world_items==throw_notify_before+1 and notified_world_items[#notified_world_items]==throw_entity,'thrown card was not handed to world-item sync') - --- If Noita reports a still-attached entity, DROP must not expose/network it or publish --- an inventory commit. This guards the transaction boundary against partial detach. -local by_slot4,_,_,entries4=service.contents(2) -local stuck_entry=by_slot4[1] -assert(stuck_entry~=nil,'test setup lost second spell') -local real_remove=EntityRemoveFromParent -EntityRemoveFromParent=function(entity) if entity==stuck_entry.entity then return end return real_remove(entity) end -local notify_before=#notified_world_items -local failed_drop_sync=sync_calls -assert(service.remove(1,2,stuck_entry,entries4,true)==false,'failed detach reported committed DROP') -assert(parent[stuck_entry.entity]==2 and alive[stuck_entry.entity]==true,'failed detach mutated the original spell') -assert(#notified_world_items==notify_before,'failed detach published a world item') -assert(sync_calls==failed_drop_sync,'failed detach published inventory sync') -EntityRemoveFromParent=real_remove - -print('spell_service=PASS duplicate_slots=true no_action_regen=true swap_rollback=true mana_preserved=true transactional_replace=true transactional_drop=true directional_throw=true world_handoff=true sync=true') +print('spell_service=PASS duplicate_slots=true mana_preserved=true transactional_replace=true sync=true') diff --git a/metamorph_creative_menu/tests/spells_interaction_surface_mock.lua b/metamorph_creative_menu/tests/spells_interaction_surface_mock.lua deleted file mode 100644 index 5c05901..0000000 --- a/metamorph_creative_menu/tests/spells_interaction_surface_mock.lua +++ /dev/null @@ -1,150 +0,0 @@ -local root = assert(arg[1], "root required") -local native_dofile = dofile -local prefix = "mods/metamorph_creative_menu/" -dofile = function(path) - if string.sub(path, 1, #prefix) == prefix then - return native_dofile(root .. "/" .. string.sub(path, #prefix + 1)) - end - return native_dofile(path) -end - --- This is an executable render scenario for the real Spells tab and its real UI/service --- modules. Only Noita engine boundaries are faked below. -ACTION_TYPE_PROJECTILE=0; ACTION_TYPE_STATIC_PROJECTILE=1; ACTION_TYPE_MODIFIER=2 -ACTION_TYPE_DRAW_MANY=3; ACTION_TYPE_MATERIAL=4; ACTION_TYPE_OTHER=5 -ACTION_TYPE_UTILITY=6; ACTION_TYPE_PASSIVE=7 -actions = { - {id="TEST", type=ACTION_TYPE_PROJECTILE, name="$test_spell", description="$test_desc", - sprite="data/ui_gfx/gun_actions/light_bullet.png"}, -} -dofile_once = function() return true end - -local alive = {[1]=true,[2]=true,[3]=true,[10]=true} -local children = {[1]={3}, [2]={10}, [3]={}, [10]={}} -local parent = {[3]=1,[10]=2} -local names = {[1]="player",[2]="wand",[3]="inventory_full",[10]="spell"} -local entity_components = {[1]={101},[2]={201},[10]={1001,1002}} -local component_type = {[101]="Inventory2Component",[201]="AbilityComponent",[1001]="ItemActionComponent",[1002]="ItemComponent"} -local fields = { - [101]={mActualActiveItem=2,mActiveItem=2,quick_inventory_slots=4,full_inventory_slots_x=4,full_inventory_slots_y=1}, - [201]={use_gun_script=true,mana=50,mana_max=100,mana_charge_speed=10}, - [1001]={action_id="TEST"}, - [1002]={inventory_slot={0,0},permanently_attached=false,has_been_picked_by_player=true,is_pickable=true}, -} -local objects = {[201]={gun_config={deck_capacity=2}}} - -EntityGetIsAlive=function(e) return alive[e]==true end -EntityGetAllChildren=function(e) local out={}; for _,v in ipairs(children[e] or {}) do out[#out+1]=v end; return out end -EntityGetFirstComponentIncludingDisabled=function(e,t) - for _,c in ipairs(entity_components[e] or {}) do if component_type[c]==t then return c end end - return 0 -end -EntityGetComponentIncludingDisabled=function(e,t) - local out={}; for _,c in ipairs(entity_components[e] or {}) do if t==nil or component_type[c]==t then out[#out+1]=c end end; return out -end -ComponentGetValue2=function(c,k) - local v=fields[c] and fields[c][k] - if type(v)=="table" then return unpack(v) end - return v -end -ComponentSetValue2=function(c,k,...) - local a={...}; fields[c]=fields[c] or {}; fields[c][k]=#a>1 and a or a[1] -end -ComponentObjectGetValue2=function(c,obj,k) return objects[c] and objects[c][obj] and objects[c][obj][k] end -ComponentObjectSetValue2=function(c,obj,k,v) objects[c]=objects[c] or {}; objects[c][obj]=objects[c][obj] or {}; objects[c][obj][k]=v end -EntityGetName=function(e) return names[e] or "" end -EntityHasTag=function(e,tag) return e==2 and tag=="wand" end -EntityGetParent=function(e) return parent[e] or 0 end -EntityGetRootEntity=function(e) local p=e; while parent[p] do p=parent[p] end; return p end -EntityGetTransform=function(e) return e==1 and 100 or 0, e==1 and 50 or 0 end -EntitySetComponentsWithTagEnabled=function() end - --- Noita GUI/input/localization boundary. -GUI_OPTION={Layout_NoLayouting=1, NoPositionTween=2} -local texts, tooltips = {}, {} -local widget_counter=0 -local last={hover=false,x=0,y=0,w=18,h=18} -local function set_last(x,y,w,h,hover) last={x=x or 0,y=y or 0,w=w or 18,h=h or 18,hover=hover==true} end -local function next_pos(w,h) - widget_counter=widget_counter+1 - local x=((widget_counter-1)%8)*20+4 - local y=math.floor((widget_counter-1)/8)*20+4 - set_last(x,y,w or 18,h or 18,false) - return x,y -end -GuiGetScreenDimensions=function() return 320,240 end -GuiGetTextDimensions=function(_,text) return #tostring(text or "")*5,5 end -GuiGetImageDimensions=function() return 18,18 end -GuiColorSetForNextWidget=function() end -GuiZSetForNextWidget=function() end -GuiOptionsAddForNextWidget=function() end -GuiOptionsAdd=function() end -GuiLayoutBeginHorizontal=function() end -GuiLayoutBeginVertical=function() end -GuiLayoutAddHorizontalSpacing=function() end -GuiLayoutAddVerticalSpacing=function() end -GuiLayoutBeginLayer=function() end -GuiLayoutEndLayer=function() end -GuiLayoutEnd=function() end -GuiBeginScrollContainer=function(_,_,x,y,w,h) set_last(x,y,w,h,false) end -GuiEndScrollContainer=function() end -GuiImageButton=function(_,_,_,_,_,_) next_pos(18,18); return false,false end -GuiButton=function(_,_,_,_,label) next_pos(math.max(10,#tostring(label or "")*5+6),9); return false end -GuiTextInput=function(_,_,_,_,value,w) next_pos(w or 80,9); return value end -GuiText=function(_,x,y,text) texts[#texts+1]=tostring(text or ""); next_pos(math.max(3,#tostring(text or "")*5),5) end -GuiImage=function(_,_,x,y,_,_,sx,sy) set_last(x,y,math.max(1,(sx or 1)*18),math.max(1,(sy or 1)*18),false) end -GuiTooltip=function(_,title,description) tooltips[#tooltips+1]={title=tostring(title or ""),description=tostring(description or "")} end -GuiGetPreviousWidgetInfo=function() return 0,0,last.hover,last.x,last.y,last.w,last.h end - -InputIsMouseButtonJustDown=function() return false end -InputIsMouseButtonDown=function() return false end -InputIsKeyDown=function() return false end -InputIsKeyJustDown=function() return false end -InputIsKeyJustUp=function() return false end -InputGetMousePosOnScreen=function() return nil,nil end -GameGetFrameNum=function() return 1 end -GameGetRealWorldTimeSinceStarted=function() return 1 end -GameTextGetTranslatedOrNot=function(key) - if key=="$test_spell" then return "Test Spell" end - if key=="$test_desc" then return "Test description" end - return key -end -ModTextFileGetContent=function(path) - if path==prefix.."translations.csv" then local h=assert(io.open(root.."/translations.csv","rb")); local s=h:read("*a"); h:close(); return s end - return "" -end -ModDoesFileExist=function() return false end -MagicNumbersGetValue=function() return "0" end - --- Ensure a clean real-module graph in this Lua process. -for _,name in ipairs({ - "METAMORPH_CREATIVE_MENU_UI_RUNTIME","METAMORPH_CREATIVE_MENU_SPELL_CATALOG", - "METAMORPH_CREATIVE_MENU_SPELL_FACTORY","METAMORPH_CREATIVE_MENU_SPELL_INVENTORY_SERVICE", - "METAMORPH_CREATIVE_MENU_SPELL_SERVICE","METAMORPH_CREATIVE_MENU_PERMANENT_SPELL_SERVICE", - "METAMORPH_CREATIVE_MENU_DRAG_DROP","METAMORPH_CREATIVE_MENU_HORIZONTAL_STRIP", - "METAMORPH_CREATIVE_MENU_WAND_API","METAMORPH_CREATIVE_MENU_INPUT_GUARD", - "METAMORPH_CREATIVE_MENU_TEXT_ENTRY_GUARD","METAMORPH_CREATIVE_MENU_POINTER", -}) do _G[name]=nil end - -local ui=assert(native_dofile(root.."/files/ui/runtime.lua")); ui.bind(1); ui.begin_frame(); ui.set_panel_bounds(0,0,260,220) -local drag=assert(native_dofile(root.."/files/ui/drag_drop.lua")); drag.begin_frame(320,240) -local tab=assert(native_dofile(root.."/files/ui/tabs/spells.lua")) -tab.draw(1,260,220) -drag.end_frame(); ui.end_frame() - -local joined="\n"..table.concat(texts,"\n").."\n" -assert(string.find(joined,"\nWAND SLOTS\n",1,true),"real Spells draw did not render WAND SLOTS in CATALOG") -assert(string.find(joined,"\nSPELL INVENTORY\n",1,true),"real Spells draw did not render SPELL INVENTORY in CATALOG") -local saw_always=false -for _,record in ipairs(tooltips) do - if record.title=="ALWAYS CAST" or string.find(record.description,"Always Cast",1,true) then saw_always=true end -end --- The Always Cast label itself is ordinary GuiText, so accept either recorded text or tooltip. -assert(saw_always or string.find(joined,"ALWAYS CAST",1,true),"real Spells draw did not render Always Cast surface") -local saw_catalog_spell=false -for _,record in ipairs(tooltips) do - if string.find(record.title,"Test Spell [TEST]",1,true) then saw_catalog_spell=true; break end -end -assert(saw_catalog_spell,"real Spells module did not render the catalog spell tile") - -print("spells_interaction_surface=PASS real_module=true catalog_surfaces=true catalog_tile=true no_internal_stubs=true") diff --git a/metamorph_creative_menu/tests/spells_ui_structure_contract.py b/metamorph_creative_menu/tests/spells_ui_structure_contract.py deleted file mode 100644 index 3ab9414..0000000 --- a/metamorph_creative_menu/tests/spells_ui_structure_contract.py +++ /dev/null @@ -1,65 +0,0 @@ -from pathlib import Path -import csv -import sys - -root = Path(sys.argv[1] if len(sys.argv) > 1 else '.').resolve() -spells = (root / 'files/ui/tabs/spells.lua').read_text(encoding='utf-8') - -failures = [] -def require(condition: bool, message: str) -> None: - if not condition: - failures.append(message) - -def section(first_marker: str, next_marker: str) -> str: - first = spells.find(first_marker) - last = spells.find(next_marker, first + len(first_marker)) if first >= 0 else -1 - require(first >= 0, f'missing marker: {first_marker}') - require(last >= 0, f'missing next marker: {next_marker}') - return spells[first:last] if first >= 0 and last >= 0 else '' - -require('placement_mode' not in spells, 'hidden placement_mode state still exists') -require('workspace == "loadout"' not in spells and 'draw_loadout' not in spells, - 'removed LOADOUT workspace still exists') -for key in ('mcm_spell_workspace_loadout', 'mcm_spell_mode_replace', 'mcm_spell_mode_insert', - 'mcm_spell_mode_append', 'mcm_spell_mode_always'): - require(key not in spells, f'removed workspace/mode key still referenced: {key}') - -catalog = section(' local function draw_catalog()', ' local function draw_wand_tools()') -for call, label in (('draw_slot_strip()', 'wand slots'), ('draw_always_cast()', 'Always Cast'), - ('draw_inventory()', 'spell inventory')): - require(catalog.count(call) == 1, f'CATALOG must render {label} exactly once') -positions = [catalog.find('$mcm_spell_controls_hint'), catalog.find('draw_slot_strip()'), - catalog.find('draw_always_cast()'), catalog.find('draw_inventory()')] -require(all(p >= 0 for p in positions) and positions == sorted(positions), - 'CATALOG controls/surfaces are not in the documented order') - -wand = section(' local function draw_wand_tools()', ' GuiLayoutBeginVertical(ui.gui(), 0, 2, true)') -require('draw_slot_strip()' not in wand and 'draw_always_cast()' not in wand and 'draw_inventory()' not in wand, - 'WAND duplicates a CATALOG placement strip') -require('wand_editor.draw' in wand and 'wand_presets.draw' in wand, - 'WAND must contain editor + presets') - -workspace = section(' local workspace_items = {', ' -- The entire active workspace') -require(workspace.count('$mcm_spell_workspace_catalog') == 1, 'CATALOG workspace button missing/duplicated') -require(workspace.count('$mcm_spell_workspace_wand') == 1, 'WAND workspace button missing/duplicated') -require('LOADOUT' not in workspace, 'LOADOUT button still rendered') - -with (root / 'translations.csv').open('r', encoding='utf-8-sig', newline='') as handle: - rows = list(csv.reader(handle)) -by_key = {row[0]: row for row in rows[1:] if row} -for key in ('mcm_spell_workspace_loadout', 'mcm_spell_mode_replace', 'mcm_spell_mode_insert', - 'mcm_spell_mode_append', 'mcm_spell_mode_always', 'mcm_wand_strip_drag_hint'): - require(key not in by_key, f'dead translation retained: {key}') -hint = by_key.get('mcm_spell_controls_hint') -require(hint is not None, 'localized spell controls hint missing') -if hint is not None: - require(len(hint) == 15, f'spell controls hint has {len(hint)} columns, expected 15') - require(all(hint[i].strip() for i in range(1, 12)), 'spell controls hint has blank game-language column') -require('mcm_wand_strip_hint' not in by_key, 'obsolete horizontal wheel hint translation remains') - -if failures: - print('spells_ui_structure_contract=FAIL') - for failure in failures: - print(' -', failure) - raise SystemExit(1) -print('spells_ui_structure_contract=PASS workspaces=2 catalog_surfaces=3 wand_strips=0 wheel_hint=false') diff --git a/metamorph_creative_menu/tests/standalone_patcher_contract.py b/metamorph_creative_menu/tests/standalone_patcher_contract.py index 7b90b97..6f516eb 100644 --- a/metamorph_creative_menu/tests/standalone_patcher_contract.py +++ b/metamorph_creative_menu/tests/standalone_patcher_contract.py @@ -7,12 +7,9 @@ local_loader = 'mods/metamorph_creative_menu/NoitaPatcher/load.lua' assert local_loader in bridge, 'patcher bridge is not bootstrapping MCM-owned NoitaPatcher' -assert 'mods/quant.ew/NoitaPatcher/load.lua' in bridge, 'EW-active bridge does not reuse EW physical NoitaPatcher loader' -assert 'ModIsEnabled("quant.ew")' in bridge, 'EW loader reuse is not gated on EW enablement' -assert 'bootstrap_path = BUNDLED_PATCHER_PATH' in bridge, 'standalone local NoitaPatcher fallback was removed' +assert 'mods/quant.ew/NoitaPatcher/load.lua' not in bridge, 'core patcher bridge still depends on quant.ew' assert (root / 'NoitaPatcher/load.lua').is_file(), 'bundled NoitaPatcher load.lua missing' assert (root / 'NoitaPatcher/noitapatcher.dll').is_file(), 'bundled NoitaPatcher DLL missing' -assert (root / 'NoitaPatcher/noitapatcher/nsew/world_ffi.lua').is_file(), 'bundled standalone world_ffi missing' assert 'mods/metamorph_creative_menu/files/lib/base64.lua' in serialization, 'form serialization is not using local base64' assert 'mods/quant.ew/files/resource/base64.lua' not in serialization, 'form serialization still depends on EW base64' assert (root / 'files/lib/base64.lua').is_file(), 'local base64 codec missing' @@ -22,12 +19,7 @@ rel = path.relative_to(root).as_posix() if rel.startswith('files/integrations/ew/') or rel.startswith('files/qa/'): continue - if rel == 'files/platform/noita/patcher_bridge.lua': - # This bridge may select EW's byte-identical physical DLL only while EW is active, - # specifically to share its native CrossCall registry. Standalone still falls back - # to MCM's bundled loader. - continue text = path.read_text(encoding='utf-8', errors='replace') assert 'mods/quant.ew/' not in text, f'standalone runtime leaked an EW filesystem dependency: {rel}' -print('standalone_patcher_contract=PASS local_noitapatcher=true ew_shared_physical_loader=true local_base64=true core_quant_ew_refs=1_guarded') +print('standalone_patcher_contract=PASS local_noitapatcher=true local_base64=true core_quant_ew_refs=0') diff --git a/metamorph_creative_menu/tests/startup_no_world_globals_mock.lua b/metamorph_creative_menu/tests/startup_no_world_globals_mock.lua deleted file mode 100644 index c01cb45..0000000 --- a/metamorph_creative_menu/tests/startup_no_world_globals_mock.lua +++ /dev/null @@ -1,31 +0,0 @@ -local root = assert(arg[1], "root required") -local native_dofile = dofile -local global_reads = 0 - -function GlobalsGetValue() - global_reads=global_reads+1 - error("WorldState doesn't exist at the moment") -end - -local runtime={enabled=function() return false end,mode=function() return "off" end} -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/integrations/ew/runtime.lua" then return runtime end - local prefix="mods/metamorph_creative_menu/" - if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -METAMORPH_CREATIVE_MENU_EW_MATERIAL_PAINT_SYNC=nil -METAMORPH_CREATIVE_MENU_WORLD_RULE_SYNC=nil -for _, relative in ipairs({ - "files/integrations/ew/material_paint_sync.lua", - "files/integrations/ew/weather_sync.lua", - "files/integrations/ew/world_items.lua", - "files/integrations/ew/possession_retire.lua", - "files/integrations/ew/world_rules_sync.lua", -}) do - assert(native_dofile(root.."/"..relative), relative.." failed to load without WorldState") -end -assert(global_reads==0,"network module read Globals before WorldState existed") - -io.write("startup_no_world_globals=PASS modules=5 global_reads=0\n") diff --git a/metamorph_creative_menu/tests/teleport_current_coordinates_ui_mock.lua b/metamorph_creative_menu/tests/teleport_current_coordinates_ui_mock.lua deleted file mode 100644 index eaa568c..0000000 --- a/metamorph_creative_menu/tests/teleport_current_coordinates_ui_mock.lua +++ /dev/null @@ -1,43 +0,0 @@ -local root=tostring(arg[1] or ".") -local old_dofile=dofile -local rendered={} -local ui={} -function ui.gui() return 1 end -function ui.tr(key,fallback) if key=="$mcm_teleport_current_coordinates" then return "CURRENT" end return fallback or key end -function ui.wrapped_text(_,_,text,_) rendered[#rendered+1]=tostring(text) end -function ui.colored_text() end -function ui.white_text() end -function ui.scroll_height() return 300 end -function ui.begin_scroll_viewport() return {content_width=200} end -function ui.end_scroll_viewport() end -function ui.button_grid() return nil end -function ui.text_input(value) return value end -function ui.button() return false end - -local tools={} -function tools.has_pending_teleport() return false end -function tools.visible_players() return {} end -function tools.position(entity) if entity==1 then return 12.34,-56.78 end return 0,0 end -function tools.locations() return {} end -function tools.teleport_position() error("teleport should not fire") end -function tools.teleport_location() error("location should not fire") end -function tools.teleport_to() error("remote should not fire") end -function tools.bring_to_me() error("bring should not fire") end - -dofile=function(path) - if path=="mods/metamorph_creative_menu/files/ui/runtime.lua" then return ui end - if path=="mods/metamorph_creative_menu/files/features/player_tools/service.lua" then return tools end - local prefix="mods/metamorph_creative_menu/" - if string.sub(path,1,#prefix)==prefix then return old_dofile(root.."/"..string.sub(path,#prefix+1)) end - return old_dofile(path) -end -function GuiLayoutBeginVertical() end -function GuiLayoutEnd() end -function GuiLayoutBeginHorizontal() end - -local tab=dofile("mods/metamorph_creative_menu/files/ui/tabs/players.lua") -tab.draw(1,220,400) -local joined=table.concat(rendered,"\n") -assert(string.find(joined,"X 12.3 Y %-56.8"),"current coordinate line missing: "..joined) -assert(not string.find(joined,"CURRENT",1,true),"redundant coordinate label remained") -print("teleport_current_coordinates_ui=PASS live_position=true formatted=true") diff --git a/metamorph_creative_menu/tests/teleport_pending_mock.lua b/metamorph_creative_menu/tests/teleport_pending_mock.lua deleted file mode 100644 index 224e939..0000000 --- a/metamorph_creative_menu/tests/teleport_pending_mock.lua +++ /dev/null @@ -1,26 +0,0 @@ -local root=assert(arg[1]) -local frame=1 -local pos={0,0} -local old_loaded=false -function GameGetFrameNum() return frame end -function EntityGetIsAlive(e) return e==1 end -function EntityGetTransform() return pos[1],pos[2] end -function EntitySetTransform(_,x,y) pos={x,y} end -function EntityGetFirstComponentIncludingDisabled() return 7 end -function ComponentSetValue2() end -function DoesWorldExistAt(minx,_,maxx) local x=(minx+maxx)/2; if math.abs(x-1000)<100 then return old_loaded end; return true end -function FindFreePositionForBody(x,y) return x,y end -function EntityLoad() return 2 end -function GameSetCameraPos() end -function GlobalsGetValue(_,f) return f end -function GlobalsSetValue() end -METAMORPH_CREATIVE_MENU_PLAYER_TOOLS=nil -local service=assert(loadfile(root..'/files/features/player_tools/service.lua'))() -local ok,why=service.teleport_position(1,1000,0) -assert(ok and why=='streaming' and service.has_pending_teleport(),'old unloaded teleport was not queued') -ok,why=service.teleport_position(1,200,0) -assert(ok and why=='teleported' and pos[1]==200 and not service.has_pending_teleport(),'new immediate teleport did not supersede old pending request') -old_loaded=true; frame=2 -ok,why=service.update() -assert(ok==false and why=='idle' and pos[1]==200,'older pending teleport fired after newer teleport') -print('teleport_pending=PASS newer_supersedes_old=true') diff --git a/metamorph_creative_menu/tests/text_entry_menu_guard_contract.py b/metamorph_creative_menu/tests/text_entry_menu_guard_contract.py deleted file mode 100644 index da09d75..0000000 --- a/metamorph_creative_menu/tests/text_entry_menu_guard_contract.py +++ /dev/null @@ -1,23 +0,0 @@ -from pathlib import Path -import sys -root=Path(sys.argv[1]).resolve() -menu=(root/'files/ui/menu_controller.lua').read_text() -runtime=(root/'files/ui/runtime.lua').read_text() -bindings=(root/'files/platform/noita/action_bindings.lua').read_text() -guard=(root/'files/platform/noita/menu_inventory_guard.lua').read_text() -translations=(root/'translations.csv').read_text(encoding='utf-8-sig') -assert 'ui.text_input_active() == true' in menu, 'menu does not observe focused text input' -assert 'menu_inventory_guard.suppress_text_controls(player)' in menu, 'focused text still relies on disabling the whole ControlsComponent' -assert 'mButtonDownInventory' not in guard or '"Inventory"' in guard, 'text guard does not suppress inventory-toggle action' -assert 'pending_selection_restore = menu_inventory_guard.capture_scroll_selection(player)' in menu, 'numeric typing does not protect active held-item selection' -assert 'text_entry_guard.active()' in bindings, 'hotkey dispatcher ignores text-entry focus' -assert 'placeholder=ui_runtime.tr("$mcm_search_placeholder", "Search...")' in runtime, 'search has no real placeholder' -assert 'stable_text_input_id' in runtime, 'editable fields do not derive native identity from focus_key' -assert 'stable_text_capture_id' in runtime and 'capture_text_away_from_field' in runtime, 'click-once off-hover keyboard capture missing' -assert 'local text_capture_gui = nil' in runtime and 'ensure_text_capture_gui' in runtime, 'off-hover capture does not use an isolated Gui context' -assert 'pending_text_capture' not in runtime and 'flush_text_capture' not in runtime, 'legacy deferred root capture can still scroll/stall the main Gui' -assert 'GUI_OPTION.ForceFocusable' in runtime, 'editable widgets do not request stable native focus' -assert 'GuiButton(gui, ui_runtime.next_id(), 0, 0' not in runtime, 'editable fields still swap to GuiButton while inactive' -assert 'prominent=true' not in runtime, 'search still has the old accent outline' -assert 'mcm_search_placeholder' in translations, 'search placeholder localization missing' -print('text_entry_menu_guard_contract=PASS hotkeys=true text_actions=true held_item=true placeholder=true persistent_native=true isolated_off_hover_capture=true') diff --git a/metamorph_creative_menu/tests/text_focus_owner_mock.lua b/metamorph_creative_menu/tests/text_focus_owner_mock.lua deleted file mode 100644 index 5b15176..0000000 --- a/metamorph_creative_menu/tests/text_focus_owner_mock.lua +++ /dev/null @@ -1,136 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile -local visible_events={} -local last_info={hover=false,x=0,y=0,w=96,h=10} -local left_just=false -local input_calls={} -local button_calls=0 -local native_focus_id=nil -local escape_down=false -local capture_typed='' - -local function pop(events, fallback) - if #events==0 then return fallback end - return table.remove(events,1) -end - -dofile=function(path) - local prefix='mods/metamorph_creative_menu/' - if path==prefix..'files/platform/noita/assets.lua' then - return {bind_gui=function() end,resolve=function(p)return p end,path=function(p)return p end, - dimensions=function()return 24,4 end,resolve_entity=function()return nil end} - end - if path==prefix..'files/platform/noita/input_guard.lua' then return {actions_allowed=function()return true end} end - if path==prefix..'files/platform/noita/pointer.lua' then - return { - left_just_down=function() return left_just end, - gui_position=function() return 300,200 end, - } - end - if path==prefix..'files/platform/noita/keycodes.lua' then - return {resolve=function(name) if name=='Key_ESCAPE' then return 27 elseif name=='Key_BACKSPACE' then return 8 end return nil end} - end - if path==prefix..'files/platform/noita/localization.lua' then return {translate=function(v)return v end} end - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -GUI_OPTION={Layout_NoLayouting=1,ForceFocusable=2} -GuiCreate=function() return 2 end -GuiStartFrame=function() end -GuiColorSetForNextWidget=function()end -GuiZSetForNextWidget=function()end -GuiOptionsAddForNextWidget=function()end -GuiText=function()end -GuiTooltip=function()end -GuiLayoutBeginHorizontal=function()end -GuiLayoutEnd=function()end -GuiGetTextDimensions=function(_,text)return #tostring(text)*5,8 end -GuiGetScreenDimensions=function() return 640,360 end -GuiButton=function() button_calls=button_calls+1; return false,false end -GuiTextInput=function(gui_id,id,_,_,value,width,_,allowed) - if gui_id==2 then - input_calls[#input_calls+1]={id=id,width=0,capture=true,gui=gui_id} - local typed=capture_typed - capture_typed='' - return ' '..typed - end - local e=pop(visible_events,{hover=false,w=width}) - input_calls[#input_calls+1]={id=id,width=width,allowed=allowed,hover=e.hover==true,capture=false,gui=gui_id} - if left_just then - if e.hover==true then native_focus_id=id - elseif native_focus_id==id then native_focus_id=nil end - end - local result=value - if native_focus_id==id and e.hover==true and e.typed~=nil then result=value..e.typed end - last_info={hover=e.hover==true,x=10,y=10,w=e.w or width,h=10} - return result -end -GuiGetPreviousWidgetInfo=function() - return false,false,last_info.hover,last_info.x,last_info.y,last_info.w,last_info.h -end -GameGetFrameNum=function() return 20 end -InputIsKeyJustDown=function(code) return code==27 and escape_down end -InputIsKeyDown=function() return false end - -METAMORPH_CREATIVE_MENU_UI_RUNTIME=nil -METAMORPH_CREATIVE_MENU_TEXT_ENTRY_GUARD=nil -local ui=assert(native_dofile(root..'/files/ui/runtime.lua')) -ui.bind(1) - -visible_events={{hover=false,w=96}} -ui.begin_frame() -local a,af=ui.text_input('α',96,20,'field.a') -ui.end_frame() -local a_id=input_calls[#input_calls].id -assert(a=='α' and af==false,'inactive field unexpectedly owned logical focus') -assert(#input_calls==1 and button_calls==0,'inactive editable field was not a native GuiTextInput') - -left_just=true; visible_events={{hover=true,w=96}} -ui.begin_frame(); a,af=ui.text_input(a,96,20,'field.a'); ui.end_frame() -local click_call=input_calls[#input_calls] -assert(af==true and ui.text_input_focus_key()=='field.a','native click did not establish logical focus') -assert(click_call.id==a_id and click_call.width==96,'field native identity or width changed in click frame') -left_just=false - --- Native focus intentionally stops producing characters off-hover. MCM uses a separate --- capture Gui, so hovering another MCM widget cannot steal or scroll the visible editor. -visible_events={{hover=false,w=96}} -capture_typed='β' -local before_calls=#input_calls -ui.begin_frame(); a,af=ui.text_input(a,96,20,'field.a'); ui.end_frame() -assert(a=='αβ' and af==true,'off-hover input did not update the logically focused field immediately') -assert(#input_calls==before_calls+2,'focused off-hover field did not draw visible+isolated capture inputs') -local visible_call=input_calls[#input_calls-1] -local capture_call=input_calls[#input_calls] -assert(visible_call.id==a_id and visible_call.width==96 and visible_call.gui==1,'visible field identity changed after pointer left') -assert(capture_call.capture==true and capture_call.width==0 and capture_call.gui==2 and capture_call.id~=a_id, - 'off-hover capture did not use a separate Gui context') - --- Numeric-style whitelists are enforced on captured text too. -left_just=true; visible_events={{hover=false,w=96}} -ui.begin_frame(); a,af=ui.text_input(a,96,20,'field.a'); ui.end_frame(); left_just=false -assert(af==false,'outside click did not release field focus') -left_just=true; visible_events={{hover=true,w=80}} -ui.begin_frame(); local n,nf=ui.text_input('1',80,20,'field.n',{allowed_characters='0123456789.-'}); ui.end_frame(); left_just=false -assert(nf==true,'numeric field did not focus') -visible_events={{hover=false,w=80}}; capture_typed='2s.5' -ui.begin_frame(); n,nf=ui.text_input(n,80,20,'field.n',{allowed_characters='0123456789.-'}); ui.end_frame() -visible_events={{hover=false,w=80}} -ui.begin_frame(); n,nf=ui.text_input(n,80,20,'field.n',{allowed_characters='0123456789.-'}); ui.end_frame() -assert(n=='12.5','off-hover capture ignored numeric whitelist: '..tostring(n)) - --- Clicking another field transfers the single logical owner. -left_just=true; visible_events={{hover=false,w=80},{hover=true,w=72}} -ui.begin_frame() -n,nf=ui.text_input(n,80,20,'field.n',{allowed_characters='0123456789.-'}) -local b,bf=ui.text_input('B',72,20,'field.b') -ui.end_frame(); left_just=false -assert(nf==false and bf==true and ui.text_input_focus_key()=='field.b','clicking another field did not transfer exclusive focus') - -ui.begin_frame(); ui.end_frame() -assert(ui.text_input_active()==false,'unmounted focused field survived a tab/context switch') - -ui.reset_text_inputs() -assert(ui.text_input_active()==false,'reset did not clear logical ownership') -print('text_focus_owner=PASS native_id_stable=true click_once=true isolated_capture=true hover_independent=true whitelist=true outside_blur=true exclusive=true unmounted_blur=true') diff --git a/metamorph_creative_menu/tests/ui_adaptive_sections_mock.lua b/metamorph_creative_menu/tests/ui_adaptive_sections_mock.lua deleted file mode 100644 index 79425d0..0000000 --- a/metamorph_creative_menu/tests/ui_adaptive_sections_mock.lua +++ /dev/null @@ -1,342 +0,0 @@ -local root=assert(arg[1],"root required") -local native_dofile=dofile -local prefix="mods/metamorph_creative_menu/" - --- Feature/service boundaries. UI modules and runtime/layout/scroll/focus modules are real. -local stubs={} -stubs[prefix.."files/platform/noita/assets.lua"]={ - bind_gui=function() end, resolve=function(p) if p==nil or p=="" then return nil end return p end, - path=function(p)return p end, dimensions=function()return 18,18 end, - resolve_entity=function() return "icon.png" end, -} -stubs[prefix.."files/platform/noita/input_guard.lua"]={actions_allowed=function() return true end} -local left_just=false -local wheel=0 -stubs[prefix.."files/platform/noita/pointer.lua"]={ - left_just_down=function() return left_just end,left_down=function() return false end, - wheel_delta=function() return wheel end,inside=function(x,y,w,h,mx,my) - return mx~=nil and my~=nil and mx>=x and my>=y and mx<=x+w and my<=y+h - end,gui_position=function() return nil,nil end, -} -stubs[prefix.."files/platform/noita/keycodes.lua"]={resolve=function() return nil end} -local long={ - ["$mcm_teleport_bring_player"]="ПЕРЕМЕСТИТЬ ИГРОКА СЮДА", - ["$mcm_teleport_to_player"]="ПЕРЕЙТИ К ИГРОКУ", - ["$mcm_controls_reset_all"]="СБРОСИТЬ ВСЕ НАЗНАЧЕНИЯ", - ["$mcm_controls_reset_confirm"]="ПОДТВЕРДИТЬ ПОЛНЫЙ СБРОС НАЗНАЧЕНИЙ", - ["$mcm_weather_release"]="ОТПУСТИТЬ УПРАВЛЕНИЕ ПОГОДОЙ", - ["$mcm_weather_advanced"]="РАСШИРЕННЫЕ НАСТРОЙКИ", - ["$mcm_weather_back"]="ВЕРНУТЬСЯ К ПРЕСЕТАМ", - ["$mcm_wand_appearance"]="ВНЕШНИЙ ВИД И БЛОКИРОВКИ", - ["$mcm_wand_skins"]="СОХРАНЁННЫЕ ВАРИАНТЫ ВНЕШНЕГО ВИДА", - ["$mcm_wand_presets"]="СОХРАНЁННЫЕ ЖЕЗЛЫ", - ["$action_long"]="ОЧЕНЬ ДЛИННОЕ ЛОКАЛИЗОВАННОЕ ДЕЙСТВИЕ УПРАВЛЕНИЯ", - ["$section_long"]="ДЛИННЫЙ РАЗДЕЛ УПРАВЛЕНИЯ", - ["$weather_long"]="ОЧЕНЬ ДЛИННЫЙ ПАРАМЕТР ПОГОДЫ", - ["$location_long"]="ОЧЕНЬ ДЛИННОЕ НАЗВАНИЕ МИРОВОЙ ТОЧКИ", -} -stubs[prefix.."files/platform/noita/localization.lua"]={ - translate=function(key,fallback) return long[key] or key end, - search_aliases=function(key,fallback) return {long[key] or key,fallback or key,key} end, -} - -local item_entry={path="item.xml",category="OTHER",display_name="Item",display_description="desc",icon="item.png"} -stubs[prefix.."files/features/items/ui_catalog.lua"]={ - collect=function() return {item_entry} end, - filters=function() return {{"$f1","ALL"},{"$f2","OTHER","OTHER"}} end, - entries_for=function() return {item_entry} end,liquids=function() return {} end, -} -stubs[prefix.."files/features/items/service.lua"]={spawn=function()return true end,give=function()return true end,spawn_filled_flask=function()return true end} -stubs[prefix.."files/platform/noita/material_preview.lua"]={new_liquid_warmup=function()return{}end,warm_liquid_colors=function()end,liquid_icon=function()return"potion.png"end,liquid_mask=function()return"liquid.png"end,liquid_offset=function()return-1,0 end,liquid_color=function()return{1,1,1,1}end} -stubs[prefix.."files/ui/drag_drop.lua"]={take_result=function()return nil end,source=function()end,active=function()return false end} -stubs[prefix.."files/platform/noita/inventory_slots.lua"]={native_drop_bounds=function()return nil end} - -local perk={id="P",ui_name="Perk",ui_description="Desc",ui_icon="perk.png"} -stubs[prefix.."files/features/perks/catalog.lua"]={all=function()return{perk}end} -stubs[prefix.."files/features/perks/service.lua"]={ - count=function()return 0 end,can_remove=function()return true end,job_status=function()return nil end, - consume_job_notice=function()return nil end,spawn=function()return true end,apply=function()return true end, - start_take_job=function()return true end,start_remove_all_job=function()return true end,remove_one=function()return true end, -} -local effect={id="E",kind="game_effect",display_name="Effect",display_description="desc",icon="effect.png"} -stubs[prefix.."files/features/effects/service.lua"]={catalog=function()return{effect}end,active_snapshot=function()return{}end,is_active=function()return false end,add=function()return true end,remove=function()return 1 end,remove_all=function()return 0 end} -local weather_field={id="rain",label="$weather_long",min=0,max=1,step=.1,decimals=2} -stubs[prefix.."files/features/weather/service.lua"]={ - can_edit=function()return true,"single" end,is_locked=function()return true end,set_time_preset=function()return true end, - apply_preset=function()return true end,release=function()return true end,fields=function()return{weather_field}end, - get=function()return .5 end,set=function()return true end,debug_state=function()return{}end, -} -local rule={id="relations",label="Rule",description="Description",choices={{native=true},{value=1}}} -stubs[prefix.."files/features/world_rules/service.lua"]={can_edit=function()return true,"single"end,has_overrides=function()return false end,rules=function()return{rule}end,choice_label=function()return"NATIVE"end,is_overridden=function()return false end,supported=function()return true end,step=function()return true end,reset=function()return true end} -stubs[prefix.."files/features/player_tools/service.lua"]={ - has_pending_teleport=function()return false end,visible_players=function()return{2}end,position=function()return 123,456 end, - teleport_to=function()return true end,bring_to_me=function()return true end, - locations=function()return{{id="loc",key="$location_long",fallback="Long location",x=10,y=20}}end, - teleport_location=function()return true end, -} -local registry={ - sections=function()return{{id="s",key="$section_long",fallback="SECTION"}}end, - actions=function()return{{id="long",section="s",key="$action_long",fallback="ACTION",default="CTRL+SHIFT+ALT+MOUSE4"}}end, - get=function(id) if id=="long" then return registry.actions()[1] end end, -} -stubs[prefix.."files/platform/noita/action_bindings.lua"]={ - update=function()end,label=function()return"CTRL+SHIFT+ALT+MOUSE4"end,capture_action=function()return nil end, - registry=function()return registry end,conflicts=function()return{}end,get=function()return"CTRL+SHIFT+ALT+MOUSE4"end, - start_capture=function()end,set=function()end,reset=function()end,reset_all=function()end, -} -local visual={name="Wand",show_name_in_ui=true,wand_frozen=false,sprite_file="very/long/path/to/a/wand/sprite.xml",image_file="wand.png",offset_x=1,offset_y=2,tip_x=3,tip_y=4} -stubs[prefix.."files/features/wands/appearance.lua"]={ - snapshot=function()return visual,"ok"end,spell_freeze_state=function()return false,false,1 end, - set_name=function()return true end,set_wand_frozen=function()return true end,set_spells_frozen=function()return true end,set_visual=function()return true end, -} -stubs[prefix.."files/features/wands/history.lua"]={perform=function(_,_,_,cb)return cb()end} -stubs[prefix.."files/features/wands/skins.lua"]={entries=function()return{{name="Long skin",sprite_file="skin.xml",image_file="skin.png",icon="skin.png",source_path="source"}}end} -stubs[prefix.."files/features/wands/presets.lua"]={ - all=function()return{{name="Очень длинное имя сохранённого жезла",blueprint={meta={image_file="wand.png"},sprite_file="wand.xml"}}}end, - save=function()return false,"intentional_preset_failure"end,load=function()return true end,give=function()return true end,delete=function()return true end, -} - --- Runtime/module path routing. -dofile=function(path) - if stubs[path]~=nil then return stubs[path] end - if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - --- Minimal Noita GUI layout simulator. It records real widget bounds generated by the --- production runtime and validates them against the active viewport content width. -GUI_OPTION={Layout_NoLayouting=1,NoPositionTween=2} -local panel_width=96 -local layouts,scrolls,layers={},{},{} -local last={hover=false,x=0,y=0,w=1,h=1} -local no_layout=false -local overflow={} -local max_right=0 -local horizontal_rows=0 -local texts={} -local tooltips={} -local input_ids={} -local hover_input_id=nil -local native_focus_id=nil -local click_next_button=false -local click_label=nil -local prints={} -local diagnostics={} -local frame=1 - -local function current_layout() return layouts[#layouts] end -local function current_scroll() return scrolls[#scrolls] end -local function base_position(x,y) - local l=current_layout() - if no_layout then - local s=current_scroll(); return (s and s.x or 0)+(tonumber(x)or 0),(s and s.y or 0)+(tonumber(y)or 0),false - end - -- Native Noita scroll children are relative to the active container even while the - -- outer window layout remains on the stack. Do not require a synthetic layout layer: - -- that was the bad mock assumption which hid the real top-left-origin regression. - local s=current_scroll() - if s and l==s.parent_layout then - return s.x+(tonumber(x)or 0),s.y+(tonumber(y)or 0),false - end - if l then return l.x+l.cx+(tonumber(x)or 0),l.y+l.cy+(tonumber(y)or 0),true end - if s then return s.x+(tonumber(x)or 0),s.y+(tonumber(y)or 0),false end - return tonumber(x)or 0,tonumber(y)or 0,false -end -local function advance(w,h) - local l=current_layout(); if not l then return end - if l.mode=="h" then l.cx=l.cx+w+2; l.h=math.max(l.h,h); l.w=math.max(l.w,l.cx) - else l.cy=l.cy+h+1; l.w=math.max(l.w,w); l.h=math.max(l.h,l.cy) end -end -local function place(x,y,w,h,interactive,label) - w,h=math.max(1,tonumber(w)or 1),math.max(1,tonumber(h)or 1) - local ax,ay,participates=base_position(x,y) - last={hover=false,x=ax,y=ay,w=w,h=h} - if interactive then - local limit=panel_width - local s=current_scroll(); if s then limit=s.content_right end - local right=ax+w; max_right=math.max(max_right,right) - if right>limit+0.01 then overflow[#overflow+1]=tostring(label or "widget").." right="..right.." limit="..limit end - end - if participates then advance(w,h) end - no_layout=false - return ax,ay -end -local function begin_layout(mode,x,y) - local base_x,base_y - local layer_scroll=layers[#layers] - local native_scroll=current_scroll() - if native_scroll~=nil and current_layout()==native_scroll.parent_layout then - base_x=native_scroll.x+(tonumber(x)or 0); base_y=native_scroll.y+(tonumber(y)or 0) - layouts[#layouts+1]={mode=mode,x=base_x,y=base_y,cx=0,cy=0,w=0,h=0,detached=true} - elseif layer_scroll~=nil and layer_scroll==native_scroll then - base_x=layer_scroll.x+(tonumber(x)or 0); base_y=layer_scroll.y+(tonumber(y)or 0) - layouts[#layouts+1]={mode=mode,x=base_x,y=base_y,cx=0,cy=0,w=0,h=0,detached=true} - else - local ax,ay=base_position(x,y); layouts[#layouts+1]={mode=mode,x=ax,y=ay,cx=0,cy=0,w=0,h=0,detached=false} - end -end -function GuiLayoutBeginVertical(_,x,y) begin_layout("v",x,y) end -function GuiLayoutBeginHorizontal(_,x,y) horizontal_rows=horizontal_rows+1; begin_layout("h",x,y) end -function GuiLayoutEnd() - local l=table.remove(layouts); if not l then return end - if not l.detached then advance(math.max(1,l.w),math.max(1,l.h)) end -end -function GuiLayoutAddHorizontalSpacing(_,v) local l=current_layout(); if l and l.mode=="h" then l.cx=l.cx+(tonumber(v)or 0) end end -function GuiLayoutAddVerticalSpacing(_,v) local l=current_layout(); if l and l.mode=="v" then l.cy=l.cy+(tonumber(v)or 0) end end -function GuiLayoutBeginLayer() layers[#layers+1]=current_scroll() end -function GuiLayoutEndLayer() table.remove(layers) end -function GuiBeginScrollContainer(_,id,x,y,w,h) - local ax,ay=base_position(x,y) - local s={id=id,x=ax,y=ay,w=w,h=h,content_right=ax+w-10,parent_layout=current_layout()} - scrolls[#scrolls+1]=s; last={hover=true,x=ax,y=ay,w=w,h=h} -end -function GuiEndScrollContainer() - local s=table.remove(scrolls); if s and s.parent_layout then advance(s.w,s.h) end -end -function GuiColorSetForNextWidget() end -function GuiZSetForNextWidget() end -function GuiOptionsAddForNextWidget(_,option) if option==GUI_OPTION.Layout_NoLayouting then no_layout=true end end -function GuiOptionsAdd() end -local function glyph_count(text) - local count=0; for _ in string.gmatch(tostring(text or ""),"[\1-\127\194-\244][\128-\191]*") do count=count+1 end; return count -end -function GuiGetTextDimensions(_,text) return glyph_count(text)*5,5 end -function GuiGetImageDimensions() return 18,18 end -function GuiButton(_,_,x,y,label) - local w=math.max(10,glyph_count(label)*5+6); place(x,y,w,9,true,"button:"..tostring(label)) - local clicked=false - if click_label~=nil and tostring(label)==click_label then clicked=true; click_label=nil - elseif click_next_button then clicked=true; click_next_button=false end - return clicked -end -function GuiImageButton(_,_,x,y) - place(x,y,18,18,true,"tile") - return false,false -end -function GuiTextInput(_,id,x,y,value,w) - local hovered=hover_input_id==id - local ax,ay=place(x,y,w or 68,9,true,"input:"..tostring(id)); last.hover=hovered - input_ids[#input_ids+1]=id - if left_just then - if hovered then native_focus_id=id elseif native_focus_id==id then native_focus_id=nil end - end - return value -end -function GuiText(_,x,y,text) - texts[#texts+1]=tostring(text or "") - place(x,y,math.max(1,glyph_count(text)*5),5,false,"text") -end -function GuiImage(_,_,x,y,_,_,sx,sy) place(x,y,18*(tonumber(sx)or 1),18*(tonumber(sy)or 1),false,"image") end -function GuiTooltip(_,title,description) tooltips[#tooltips+1]={title=tostring(title or ""),description=description~=nil and tostring(description) or nil} end -function GuiGetPreviousWidgetInfo() return 0,0,last.hover,last.x,last.y,last.w,last.h end -function GuiGetScreenDimensions() return 320,240 end -function GameGetFrameNum() return frame end -function InputIsMouseButtonJustDown() return false end -function InputIsMouseButtonDown() return false end -function InputIsKeyDown() return false end -function InputIsKeyJustDown() return false end -function InputIsKeyJustUp() return false end -function ModDoesFileExist() return true end -function GamePrint(message) prints[#prints+1]=tostring(message) end -METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE=function(scope,reason) diagnostics[#diagnostics+1]={scope,reason} end - -local function reset_geometry(width) - panel_width=width; layouts={};scrolls={};layers={};last={hover=false,x=0,y=0,w=1,h=1};no_layout=false - overflow={};max_right=0;horizontal_rows=0;texts={};tooltips={};input_ids={};click_next_button=false;click_label=nil -end - -for _,name in ipairs({"METAMORPH_CREATIVE_MENU_UI_RUNTIME","METAMORPH_CREATIVE_MENU_SCROLL_MODEL","METAMORPH_CREATIVE_MENU_TEXT_ENTRY_GUARD"}) do _G[name]=nil end -local ui=assert(native_dofile(root.."/files/ui/runtime.lua")); ui.bind(1) - --- A non-empty shared search field also renders its right-side clear button; both must fit --- the narrowest supported panel instead of only proving the empty-input case. -reset_geometry(96); ui.begin_frame(); ui.search_input("query",68,64,"adaptive.search.clear"); ui.end_frame() -assert(#overflow==0,"shared search input/clear button overflowed width 96: "..table.concat(overflow," | ")) -ui.blur_text_input("adaptive_probe_done") - -local modules={ - items=assert(native_dofile(root.."/files/ui/tabs/items.lua")), - perks=assert(native_dofile(root.."/files/ui/tabs/perks.lua")), - effects=assert(native_dofile(root.."/files/ui/tabs/effects.lua")), - weather=assert(native_dofile(root.."/files/ui/tabs/weather.lua")), - rules=assert(native_dofile(root.."/files/ui/tabs/world_rules.lua")), - players=assert(native_dofile(root.."/files/ui/tabs/players.lua")), - controls=assert(native_dofile(root.."/files/ui/tabs/controls.lua")), -} - -local function render(tab,width) - reset_geometry(width); ui.begin_frame(); tab.draw(1,width,190); ui.end_frame() - assert(#overflow==0,"UI overflow at width "..width..": "..table.concat(overflow," | ")) - return horizontal_rows,texts,input_ids -end - --- Basic weather first: its four icon actions must wrap by shared grid and it must not --- repeat the WEATHER tab title as a content heading. -local weather_rows={} -for _,width in ipairs({96,160,280}) do - local rows,drawn=render(modules.weather,width); weather_rows[width]=rows - for _,text in ipairs(drawn) do assert(text~="WEATHER","WEATHER tab repeated its own title inside content") end -end -assert(weather_rows[96]>=weather_rows[280],"weather rows did not adapt to width") - --- Enter advanced weather once, then verify the real shared stepper at all widths. -reset_geometry(96); click_next_button=true; ui.begin_frame(); modules.weather.draw(1,96,190); ui.end_frame() -for _,width in ipairs({96,160,280}) do render(modules.weather,width) end - -local reflow={} -for name,tab in pairs(modules) do - if name~="weather" then - reflow[name]={} - for _,width in ipairs({96,160,280}) do - local rows=render(tab,width); reflow[name][width]=rows - end - end -end -assert(reflow.players[96]>reflow.players[280],"teleport action row did not reflow on narrow menu") -assert(reflow.controls[96]>reflow.controls[280],"controls action row did not reflow on narrow menu") - --- Stable native focus survives width changes in a real CONTROLS render. -reset_geometry(96); ui.begin_frame(); modules.controls.draw(1,96,190); ui.end_frame() -local control_input=assert(input_ids[1],"controls search input was not rendered") -reset_geometry(96); hover_input_id=control_input; left_just=true; ui.begin_frame(); modules.controls.draw(1,96,190); ui.end_frame() -assert(ui.text_input_focus_key()=="controls","controls search did not acquire existing native input") -reset_geometry(280); hover_input_id=nil; left_just=false; ui.begin_frame(); modules.controls.draw(1,280,190); ui.end_frame() -assert(input_ids[1]==control_input and ui.text_input_focus_key()=="controls","responsive redraw changed native input identity or focus") -ui.blur_text_input("test_done") - --- WAND appearance receives the inner workspace content width (80 at a 96-wide panel), --- not the outer window width. Its path/name/lock/numeric controls must stay inside it. -_G.METAMORPH_CREATIVE_MENU_WAND_APPEARANCE_EDITOR_UI=nil -_G.METAMORPH_CREATIVE_MENU_WAND_SKIN_PICKER_UI=nil -local appearance_editor=assert(native_dofile(root.."/files/ui/components/wand_appearance_editor.lua")) -reset_geometry(80); click_next_button=true; ui.begin_frame(); appearance_editor.draw(1,2,80); ui.end_frame() -for _,width in ipairs({80,144,264}) do - reset_geometry(width); ui.begin_frame(); appearance_editor.draw(1,2,width); ui.end_frame() - assert(#overflow==0,"wand appearance overflow at inner width "..width..": "..table.concat(overflow," | ")) -end - --- Skin picker is a nested long list and must share the same viewport path. -_G.METAMORPH_CREATIVE_MENU_WAND_SKIN_PICKER_UI=nil -local skin_picker=assert(native_dofile(root.."/files/ui/components/wand_skin_picker.lua")) -reset_geometry(80); click_next_button=true; ui.begin_frame(); skin_picker.draw(1,2,visual,80); ui.end_frame() -for _,width in ipairs({80,144,264}) do - reset_geometry(width); ui.begin_frame(); skin_picker.draw(1,2,visual,width); ui.end_frame() - assert(#overflow==0,"wand skin picker overflow at inner width "..width..": "..table.concat(overflow," | ")) -end - --- Presets: narrow name/actions fit; a failed SAVE reports once and never becomes a --- permanent bottom row on following frames. -_G.METAMORPH_CREATIVE_MENU_WAND_PRESETS_UI=nil -local presets_ui=assert(native_dofile(root.."/files/ui/components/wand_presets.lua")) -reset_geometry(80); click_next_button=true; ui.begin_frame(); presets_ui.draw(1,2,80); ui.end_frame() -local before_prints=#prints -reset_geometry(80); click_label="SAVE"; ui.begin_frame(); presets_ui.draw(1,2,80); ui.end_frame() -assert(#overflow==0,"wand presets overflowed narrow workspace") -assert(#prints==before_prints+1,"preset failure was not reported once") -local failure_text=prints[#prints] -reset_geometry(80); ui.begin_frame(); presets_ui.draw(1,2,80); ui.end_frame() -assert(#prints==before_prints+1,"stale preset failure repeated on next frame") -for _,text in ipairs(texts) do assert(not string.find(text,"intentional_preset_failure",1,true),"raw stale preset reason remained in layout") end -assert(failure_text~="","preset failure user message was empty") - -print("ui_adaptive_sections=PASS widths=96_160_280 bounds=true rows_reflow=true weather_no_duplicate=true focus_stable=true wand_inner_width=true transient_errors=true") diff --git a/metamorph_creative_menu/tests/ui_responsive_layout_mock.lua b/metamorph_creative_menu/tests/ui_responsive_layout_mock.lua deleted file mode 100644 index 64a0ad7..0000000 --- a/metamorph_creative_menu/tests/ui_responsive_layout_mock.lua +++ /dev/null @@ -1,137 +0,0 @@ -local root = assert(arg[1], "root required") -local native_dofile = dofile -local horizontal_rows = 0 -local largest_spacing = 0 -local widget_y = 0 -local widget_h = 0 -local click_label = nil -local rendered_text = {} -local tooltip_calls = {} - -local stubs={ - ["mods/metamorph_creative_menu/files/platform/noita/assets.lua"]={bind_gui=function() end}, - ["mods/metamorph_creative_menu/files/platform/noita/input_guard.lua"]={actions_allowed=function() return true end}, - ["mods/metamorph_creative_menu/files/platform/noita/keycodes.lua"]={}, - ["mods/metamorph_creative_menu/files/platform/noita/localization.lua"]={ - translate=function(key,fallback) return key=="$new_label" and "ДЛИННАЯ РУССКАЯ НАДПИСЬ" or (fallback or key) end, - }, -} -dofile=function(path) - if stubs[path] then return stubs[path] end - local prefix="mods/metamorph_creative_menu/" - if string.sub(path,1,#prefix)==prefix then return native_dofile(root.."/"..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -function GuiGetTextDimensions(_,text) return #tostring(text)*5,9 end -function GuiLayoutBeginHorizontal() horizontal_rows=horizontal_rows+1 end -function GuiLayoutEnd() end -function GuiLayoutAddHorizontalSpacing(_,value) largest_spacing=math.max(largest_spacing,tonumber(value) or 0) end -function GuiColorSetForNextWidget() end -function GuiZSetForNextWidget() end -function GuiButton(_,_,_,_,text) - if click_label ~= nil and text == click_label then click_label=nil; return true end - return false -end -function GuiGetPreviousWidgetInfo() return false,false,false,0,widget_y,100,widget_h end -function GuiTooltip(...) - tooltip_calls[#tooltip_calls+1]={ - argc=select('#', ...), title=select(2, ...), description=select(3, ...), - } -end -function GuiText(_,_,_,text) rendered_text[#rendered_text+1]=tostring(text or "") end -function GameGetFrameNum() return 10 end - -METAMORPH_CREATIVE_MENU_UI_RUNTIME=nil -local ui=assert(native_dofile(root.."/files/ui/runtime.lua")) -ui.bind(1) -assert(ui.tr("$new_label","English")=="ДЛИННАЯ РУССКАЯ НАДПИСЬ","runtime did not use localized fallback service") -ui.button(0,0,"TAB",false,"PERKS",nil) -local tooltip=assert(tooltip_calls[#tooltip_calls]) -assert(tooltip.argc==3 and tooltip.title=="PERKS" and tooltip.description=="", - "title-only tooltip did not call Noita with an explicit empty description") - -rendered_text={} -local line_count=ui.wrapped_text(0,0,"one two three four five",45) -assert(line_count>=3 and #rendered_text==line_count,"narrow translated/help text was not wrapped") -for _,line in ipairs(rendered_text) do assert(ui.text_width(line)<=45,"wrapped line exceeded panel width") end -rendered_text={} -local utf8_line_count=ui.wrapped_text(0,0,"界界界界界界",30) -assert(utf8_line_count>=3 and #rendered_text==utf8_line_count,"unspaced UTF-8 text was not wrapped") -for _,line in ipairs(rendered_text) do assert(ui.text_width(line)<=30,"UTF-8 wrapped line exceeded panel width") end -local ranked=ui.rank_entries("alpha", { - {id="later",name="beta alpha"}, {id="first",name="alpha"}, -}, function(entry) return {entry.name,entry.id} end, function(entry) return entry.id end) -assert(#ranked==2 and ranked[1].id=="first","shared catalogue search did not rank exact matches first") -click_label="RESET" -assert(ui.confirm_button("reset","RESET","CONFIRM")==false,"destructive action skipped its first confirmation click") -click_label="CONFIRM" -assert(ui.confirm_button("reset","RESET","CONFIRM")==true,"destructive action did not accept its second confirmation click") -click_label="+" -assert(ui.stepper("VALUE","1")==1,"shared stepper did not report increment") - -local before=horizontal_rows -ui.button_grid({ - {label="12345678"},{label="abcdefgh"},{label="ABCDEFGH"}, -},55) -assert(horizontal_rows-before==3,"button grid did not adapt rows to narrow width") -ui.panel_width_anchor(240) -assert(largest_spacing>=230,"sparse tab did not honor resized panel width") -assert(ui.grid_height(600,160)==440,"large window list kept an obsolete fixed height cap") -assert(ui.grid_height(120,160)==64,"small window list dropped below its usable minimum") -widget_y,widget_h=120,10 -ui.set_panel_bounds(10,20,240,300) -assert(ui.scroll_height(258,160)==181, - "catalogue did not consume the measured space down to the panel border") - -for _,name in ipairs({"controls","creatures","effects","items","materials","perks","players","spells","weather","world_rules"}) do - local handle=assert(io.open(root.."/files/ui/tabs/"..name..".lua","rb")) - local source=handle:read("*a"); handle:close() - assert(string.find(source,"ui.scroll_height(",1,true), - name.." tab bypassed the shared adaptive scroll-height system") -end - --- The real menu frame is a fixed nine-piece rectangle. Tab contents must never be able --- to resize it the way GuiEndAutoBoxNinePiece's minimum-only sizing could. -GUI_OPTION={Layout_NoLayouting=1} -GuiOptionsAddForNextWidget=function() end -local fixed_geom=nil -GuiImageNinePiece=function(_,_,x,y,w,h) fixed_geom={x=x,y=y,w=w,h=h} end -local old_widget_info=GuiGetPreviousWidgetInfo -GuiGetPreviousWidgetInfo=function() - if fixed_geom then return false,false,false,fixed_geom.x,fixed_geom.y,fixed_geom.w,fixed_geom.h end - return old_widget_info() -end -local fx,fy,fw,fh=ui.fixed_panel_frame(434,68,196,282,4) -assert(fx==430 and fy==64 and fw==204 and fh==290, - "fixed panel frame did not honor the requested layout rectangle") -GuiGetPreviousWidgetInfo=old_widget_info -local menu_handle=assert(io.open(root.."/files/ui/menu_controller.lua","rb")) -local menu_source=menu_handle:read("*a"); menu_handle:close() -assert(string.find(menu_source,"ui.fixed_panel_frame",1,true), - "menu controller still sizes its visible frame from active-tab content") - -METAMORPH_CREATIVE_MENU_PANEL_LAYOUT=nil -local panel_layout=assert(native_dofile(root.."/files/core/panel_layout.lua")) -local frame_outsets={left=6,right=6,top=6,bottom=6} -local compact_default=panel_layout.create(320,240,{},frame_outsets) -assert(compact_default.x==114 and compact_default.y==42 - and compact_default.width==196 and compact_default.height==188, - "320x240 default did not preserve the reference panel proportions") -local reference_default=panel_layout.create(640,360,{},frame_outsets) -assert(reference_default.x==434 and reference_default.y==68 - and reference_default.width==196 and reference_default.height==282, - "640x360 default no longer matches the user-selected reference position/size") -assert(compact_default.x-frame_outsets.left>=panel_layout.MARGIN - and compact_default.x+compact_default.width+frame_outsets.right<=320-panel_layout.MARGIN - and compact_default.y-frame_outsets.top>=panel_layout.MARGIN - and compact_default.y+compact_default.height+frame_outsets.bottom<=240-panel_layout.MARGIN, - "default visible resize frame escaped the compact viewport") -local preserved=panel_layout.create(640,360,{x=123,y=67,width=260,height=190},frame_outsets) -assert(preserved.x==123 and preserved.y==67 and preserved.width==260 and preserved.height==190, - "valid saved user layout was changed") -local edge_saved=panel_layout.create(320,240,{x=110,y=80,width=210,height=187},frame_outsets) -assert(edge_saved.x==100 and edge_saved.y==43, - "saved layout was not clamped by the visual frame outset") - -io.write("ui_responsive_layout=PASS wrapping=true utf8=true dynamic_rows=true ranked_search=true confirmations=true steppers=true width_anchor=true measured_fill=true all_scroll_tabs=true localized_runtime=true compact_default=true reference_default=true fixed_frame=true saved_layout_preserved=true frame_outset_clamp=true\n") diff --git a/metamorph_creative_menu/tests/utf8_text_regression_mock.lua b/metamorph_creative_menu/tests/utf8_text_regression_mock.lua deleted file mode 100644 index 63d545e..0000000 --- a/metamorph_creative_menu/tests/utf8_text_regression_mock.lua +++ /dev/null @@ -1,33 +0,0 @@ -local root=assert(arg[1]) -local utf8_text=assert(dofile(root..'/files/core/utf8_text.lua')) - -local function bytes(...) return string.char(...) end -local function assert_eq(actual, expected, message) - if actual~=expected then - error((message or 'mismatch')..': expected '..string.format('%q',expected)..' got '..string.format('%q',actual)) - end -end - -assert_eq(utf8_text.truncate_bytes('abc',4),'abc','short ascii changed') -assert_eq(utf8_text.truncate_bytes('abcd',4),'abcd','boundary ascii changed') -assert_eq(utf8_text.truncate_bytes('abcde',4),'abcd','long ascii truncation failed') - -local ru='абв' -assert_eq(utf8_text.truncate_bytes(ru,5),'аб','cyrillic boundary split') - -local han=string.rep('漢',27) -local han_safe=utf8_text.truncate_bytes(han,80) -assert_eq(han_safe,string.rep('漢',26),'CJK boundary split') -assert(#han_safe==78,'CJK byte limit changed') - -local emoji='abc'..'😀' -assert_eq(utf8_text.truncate_bytes(emoji,7),emoji,'emoji before boundary changed') -assert_eq(utf8_text.truncate_bytes(emoji,6),'abc','emoji crossing boundary split') - -assert_eq(utf8_text.truncate_bytes('ok'..bytes(0xE2,0x82),80),'ok','truncated multibyte sequence leaked') -assert_eq(utf8_text.truncate_bytes('ok'..bytes(0xC0,0xAF)..'x',80),'ok','overlong encoding accepted') -assert_eq(utf8_text.truncate_bytes('ok'..bytes(0xED,0xA0,0x80)..'x',80),'ok','UTF-16 surrogate accepted') -assert_eq(utf8_text.truncate_bytes('ok'..bytes(0xF4,0x90,0x80,0x80)..'x',80),'ok','codepoint above U+10FFFF accepted') -assert_eq(utf8_text.truncate_bytes('ok'..bytes(0xE2,0x28,0xA1)..'x',80),'ok','invalid continuation accepted') - -print('utf8_text=PASS ascii=true cyrillic=true cjk=true emoji=true malformed=true') diff --git a/metamorph_creative_menu/tests/wand_always_cast_drop_click_mock.lua b/metamorph_creative_menu/tests/wand_always_cast_drop_click_mock.lua deleted file mode 100644 index a779615..0000000 --- a/metamorph_creative_menu/tests/wand_always_cast_drop_click_mock.lua +++ /dev/null @@ -1,43 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile -local pending=false -local click_count=0 -local target_count=0 - -local ui={ - gui=function() return 1 end, - tr=function(_,fallback) return fallback end, - white_text=function() end, - wrapped_text=function() end, - button=function() return true,false,10,10,20,10 end, -} -local drag={ - target=function() target_count=target_count+1 end, - pending=function() return pending end, -} -local strip={draw=function() error('empty Always Cast must not draw strip') end} - -dofile=function(path) - local prefix='mods/metamorph_creative_menu/' - if path==prefix..'files/ui/runtime.lua' then return ui end - if path==prefix..'files/ui/drag_drop.lua' then return drag end - if path==prefix..'files/ui/widgets/horizontal_strip.lua' then return strip end - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -GuiLayoutBeginHorizontal=function() end -GuiLayoutEnd=function() end - -METAMORPH_CREATIVE_MENU_WAND_ALWAYS_CAST_UI=nil -local view=assert(native_dofile(root..'/files/ui/components/wand_always_cast_strip.lua')) - -pending=false -view.draw({},180,640,360,nil,{on_add_click=function() click_count=click_count+1 end}) -assert(click_count==1,'ordinary + click no longer promotes selected spell') -assert(target_count==1,'Always Cast + lost its drop target') - -pending=true -view.draw({},180,640,360,nil,{on_add_click=function() click_count=click_count+1 end}) -assert(click_count==1,'drag release over + also fired the ordinary click promotion') -assert(target_count==2,'drag-owned release removed Always Cast drop target') -print('wand_always_cast_drop_click=PASS click=true drag_release_single_mutation=true') diff --git a/metamorph_creative_menu/tests/wand_appearance_editor_mock.lua b/metamorph_creative_menu/tests/wand_appearance_editor_mock.lua deleted file mode 100644 index b821a30..0000000 --- a/metamorph_creative_menu/tests/wand_appearance_editor_mock.lua +++ /dev/null @@ -1,134 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local apply_index=0 -local click_expand=true -local click_name_apply=false -local click_grid=nil -local typed_name='Draft Ω' -local input_calls={} -local game_prints={} -local diagnostics={} -local wrapped_calls=0 -local reset_calls=0 -local fail_name=false -local state={ - wand=2,item=31,sprite=32,hotspot=33,name='Old',show_name_in_ui=true,wand_frozen=false, - sprite_file='old.png',image_file='old.png',offset_x=3,offset_y=4,tip_x=5,tip_y=6, -} -local spell_frozen=false - -local function copy(t) local o={}; for k,v in pairs(t) do o[k]=v end; return o end -local ui={} -function ui.gui() return 1 end -function ui.tr(key,fallback) return fallback end -function ui.white_text() end -function ui.wrapped_text() wrapped_calls=wrapped_calls+1 end -function ui.button(x,y,label) - label=tostring(label or '') - if string.find(label,'APPEARANCE & LOCKS',1,true) then - local v=click_expand; click_expand=false; return v - end - if string.find(label,'APPLY',1,true) then - apply_index=apply_index+1 - return click_name_apply and apply_index==1 - end - return false -end -function ui.button_grid(items) - for index,item in ipairs(items or {}) do - local label=tostring(item.label or '') - if string.find(label,'APPEARANCE & LOCKS',1,true) and click_expand then click_expand=false; return index end - if label=='APPLY' then - apply_index=apply_index+1 - if click_name_apply and apply_index==1 then return index end - end - end - local v=click_grid; click_grid=nil; return v -end -function ui.text_input(value,width,max_length,key) - input_calls[#input_calls+1]={value=value,width=width,key=key} - if string.find(tostring(key),'wand.name.',1,true) then return typed_name,true end - return value,false -end - -local appearance={} -function appearance.snapshot(wand) local s=copy(state); s.wand=wand; return s,'ok' end -function appearance.spell_freeze_state() return spell_frozen,false,1 end -function appearance.set_name(player,wand,name,show) - if fail_name then return false,'name_fault' end - state.name=tostring(name or '') - state.show_name_in_ui=show==true and state.name~='' - return true,'ok' -end -function appearance.set_wand_frozen(player,wand,value) state.wand_frozen=value==true; return true,'ok' end -function appearance.set_spells_frozen(player,wand,value) spell_frozen=value==true; return true,'ok' end -function appearance.set_visual(player,wand,values) - for k,v in pairs(values or {}) do - if k=='sprite_file' then state.sprite_file=v; state.image_file=v else state[k]=v end - end - return true,'ok' -end -local history={perform=function(player,wand,label,callback,options) return callback() end} -local numeric={ - reset=function() reset_calls=reset_calls+1 end, - draw=function(key,label,value,options) return false,nil,tostring(value or ''),false end, -} -local skin_picker={draw=function() return false,nil end} - -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/ui/runtime.lua' then return ui end - if path=='mods/metamorph_creative_menu/files/features/wands/appearance.lua' then return appearance end - if path=='mods/metamorph_creative_menu/files/features/wands/history.lua' then return history end - if path=='mods/metamorph_creative_menu/files/ui/components/fixed_numeric_editor.lua' then return numeric end - if path=='mods/metamorph_creative_menu/files/ui/components/wand_skin_picker.lua' then return skin_picker end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -GuiLayoutBeginHorizontal=function() end -GuiLayoutEnd=function() end -GuiTooltip=function() end -GamePrint=function(message) game_prints[#game_prints+1]=message end -METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE=function(source,details) diagnostics[#diagnostics+1]={source,details} end - -local editor=assert(native_dofile(root..'/files/ui/components/wand_appearance_editor.lua')) -local function draw(width) - apply_index=0 - input_calls={} - local ok,reason=editor.draw(1,2,width) - assert(ok==true,'editor draw failed '..tostring(reason)) - return input_calls -end - --- The section opens with the name field present even at a narrow legal panel width. -local calls=draw(72) -local name_call=nil -for _,call in ipairs(calls) do if string.find(call.key,'wand.name.',1,true) then name_call=call end end -assert(name_call~=nil,'name field disappeared at narrow width') -assert(name_call.width>=36 and name_call.width<=60,'name field overflowed narrow panel: '..tostring(name_call.width)) -assert(name_call.value=='Old','initial name draft did not come from current wand') - --- A changed snapshot on the next frame must not replace an in-progress draft. -state.name='Synced Remote Name' -calls=draw(280) -name_call=nil -for _,call in ipairs(calls) do if string.find(call.key,'wand.name.',1,true) then name_call=call end end -assert(name_call and name_call.value=='Draft Ω','name draft was reset by snapshot refresh') -assert(name_call.width==268,'wide name field did not use available panel width') -assert(reset_calls==1,'numeric/name state reset unexpectedly between frames') - --- One real failure is surfaced once via user + diagnostics, never as a persistent layout row. -fail_name=true; click_name_apply=true -draw(160) -assert(#game_prints==1 and #diagnostics==1,'first appearance error was not reported once') -assert(diagnostics[1][1]=='wand.appearance' and diagnostics[1][2]=='name_fault','appearance diagnostic payload wrong') -draw(160) -assert(#game_prints==1 and #diagnostics==1,'same stale error repeated on retry/frame') -assert(wrapped_calls==0,'appearance error is still rendered permanently in the section') - --- A successful operation clears the old error, allowing a later real failure to report again. -fail_name=false; draw(160) -fail_name=true; draw(160) -assert(#game_prints==2 and #diagnostics==2,'successful operation did not clear prior error state') - -print('wand_appearance_editor=PASS name_visible=true draft_persists=true error_once=true') diff --git a/metamorph_creative_menu/tests/wand_appearance_mock.lua b/metamorph_creative_menu/tests/wand_appearance_mock.lua deleted file mode 100644 index c82ce94..0000000 --- a/metamorph_creative_menu/tests/wand_appearance_mock.lua +++ /dev/null @@ -1,166 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local sync_calls=0 -local scalar={sprite_file='old.png'} -local next_component=100 -local item_id,sprite_id,hotspot_id,spell_item_id=31,32,33,41 -local spell_action_id=42 -local active={[31]=true,[32]=true,[33]=true,[41]=true,[42]=true} -local values={ - [31]={item_name='Old',always_use_item_name_in_ui=true,is_frozen=false}, - [32]={image_file='old.png',offset_x=3,offset_y=4}, - [33]={offset={5,6}}, - [41]={is_frozen=false}, - [42]={action_id='A'}, - [90]={image_file='overlay.png',offset_x=90,offset_y=90}, -} -active[90]=true -local item_primary=true -local sprite_tagged=true -local hotspot_tagged=true -local extra_sprite=false -local sabotage_next_offset=false -local refreshes={} - -local function clone_fields(source) - local out={} - for k,v in pairs(source or {}) do - if type(v)=='table' then out[k]={v[1],v[2]} else out[k]=v end - end - return out -end -local function replace_component(old) - next_component=next_component+1 - local new=next_component - values[new]=clone_fields(values[old]) - active[old]=false; active[new]=true - return new -end -local function sync_replace() - sync_calls=sync_calls+1 - item_id=replace_component(item_id) - sprite_id=replace_component(sprite_id) - hotspot_id=replace_component(hotspot_id) - spell_item_id=replace_component(spell_item_id) - if sabotage_next_offset then - sabotage_next_offset=false - values[sprite_id].offset_x=-999 - end -end - -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/platform/noita/wand.lua' then - return { - ability=function(wand) return wand==2 and 21 or 0 end, - get_scalar=function(component,field) return scalar[field],true end, - set_scalar=function(component,field,value) scalar[field]=value; return true end, - } - end - if path=='mods/metamorph_creative_menu/files/features/wands/sync.lua' then - return {inventory=function(player) sync_replace() end} - end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -EntityGetFirstComponentIncludingDisabled=function(entity,kind,tag) - if entity==2 and kind=='ItemComponent' then return item_primary and item_id or 0 end - if entity==2 and kind=='SpriteComponent' then - if tag=='item' then return sprite_tagged and sprite_id or 0 end - return sprite_id - end - if entity==2 and kind=='HotspotComponent' then - if tag=='shoot_pos' then return hotspot_tagged and hotspot_id or 0 end - return hotspot_id - end - if entity==3 and kind=='ItemComponent' then return spell_item_id end - if entity==3 and kind=='ItemActionComponent' then return spell_action_id end - return 0 -end -EntityGetComponentIncludingDisabled=function(entity,kind) - if entity==2 and kind=='ItemComponent' then return {item_id} end - if entity==2 and kind=='SpriteComponent' then - if extra_sprite then return {sprite_id,90} end - return {sprite_id} - end - if entity==2 and kind=='HotspotComponent' then return {hotspot_id} end - if entity==3 and kind=='ItemComponent' then return {spell_item_id} end - if entity==3 and kind=='ItemActionComponent' then return {spell_action_id} end - return {} -end -EntityGetAllChildren=function(entity) return entity==2 and {3} or {} end -ComponentGetValue2=function(component,field) - if active[component]~=true then error('stale component read '..tostring(component)) end - local v=values[component] and values[component][field] - if type(v)=='table' then return v[1],v[2] end - return v -end -ComponentSetValue2=function(component,field,a,b) - if active[component]~=true then error('stale component write '..tostring(component)) end - values[component]=values[component] or {} - if b~=nil then values[component][field]={a,b} else values[component][field]=a end -end -EntityRefreshSprite=function(wand,component) - if active[component]~=true then error('stale sprite refresh '..tostring(component)) end - refreshes[#refreshes+1]=component -end -ModDoesFileExist=function(path) return path=='new.png' or path=='different.png' or path=='old.png' end - -local appearance=assert(native_dofile(root..'/files/features/wands/appearance.lua')) -local snap=assert(appearance.snapshot(2)) -assert(snap.name=='Old' and snap.sprite_file=='old.png' and snap.image_file=='old.png' and snap.offset_x==3 and snap.tip_y==6,'snapshot failed') - --- Missing primary/tagged components fall back only when the component type is unambiguous. -item_primary=false; sprite_tagged=false; hotspot_tagged=false -snap=assert(appearance.snapshot(2)) -assert(snap.item==item_id and snap.item_resolution=='unique_fallback','unique item fallback failed') -assert(snap.sprite==sprite_id and snap.sprite_resolution=='unique_fallback','unique sprite fallback failed') -assert(snap.hotspot==hotspot_id and snap.hotspot_resolution=='unique_fallback','unique hotspot fallback failed') -extra_sprite=true -snap=assert(appearance.snapshot(2)) -assert(snap.sprite==0 and snap.sprite_resolution=='ambiguous','ambiguous sprite fallback touched an unrelated sprite') -extra_sprite=false; item_primary=true; sprite_tagged=true; hotspot_tagged=true - --- Visual writes must survive sync component replacement and refresh the fresh visible sprite. -local old_sprite_id=sprite_id -assert(appearance.set_visual(1,2,{sprite_file='new.png',image_file='different.png',offset_x=7,offset_y=8,tip_x=9,tip_y=10})==true,'visual apply failed') -assert(sprite_id~=old_sprite_id,'sync did not replace sprite component in test') -snap=assert(appearance.snapshot(2)) -assert(snap.sprite_file=='new.png' and snap.image_file=='different.png' and snap.offset_x==7 and snap.offset_y==8 and snap.tip_x==9,'visual fields failed after sync') -assert(refreshes[#refreshes]==sprite_id,'fresh visible sprite was not refreshed after sync') -assert(values[90].offset_x==90,'unrelated SpriteComponent was modified') - --- Every flag must work in both directions even though sync replaces component IDs. -assert(appearance.set_name(1,2,'Custom',false)==true,'show-name off failed') -snap=assert(appearance.snapshot(2)); assert(snap.name=='Custom' and snap.show_name_in_ui==false,'show-name off snapshot stale') -assert(appearance.set_name(1,2,'Custom',true)==true,'show-name on failed') -snap=assert(appearance.snapshot(2)); assert(snap.show_name_in_ui==true,'show-name on snapshot stale') - --- Name writes and apply verification must share the same UTF-8-safe 160-byte result. -local long_cjk_name=string.rep('漢',54) -local safe_cjk_name=string.rep('漢',53) -assert(appearance.set_name(1,2,long_cjk_name,true)==true,'UTF-8 set_name failed') -snap=assert(appearance.snapshot(2)); assert(snap.name==safe_cjk_name and #snap.name==159,'set_name split a UTF-8 codepoint') -assert(appearance.apply(1,2,{meta={name=long_cjk_name,show_name_in_ui=true}})==true,'UTF-8 appearance apply failed') -snap=assert(appearance.snapshot(2)); assert(snap.name==safe_cjk_name and #snap.name==159,'appearance apply wrote or verified a different UTF-8 name') -assert(appearance.set_wand_frozen(1,2,true)==true,'wand lock on failed') -snap=assert(appearance.snapshot(2)); assert(snap.wand_frozen==true,'wand lock on snapshot stale') -assert(appearance.set_wand_frozen(1,2,false)==true,'wand lock off failed') -snap=assert(appearance.snapshot(2)); assert(snap.wand_frozen==false,'wand lock off snapshot stale') -assert(appearance.set_spells_frozen(1,2,true)==true,'spell lock on failed') -assert(select(1,appearance.spell_freeze_state(2))==true,'spell lock on state stale') -assert(appearance.set_spells_frozen(1,2,false)==true,'spell lock off failed') -local all,mixed,total=appearance.spell_freeze_state(2) -assert(all==false and mixed==false and total==1,'spell lock off state stale') - --- A post-sync verification failure rolls geometry back through freshly resolved components. -local before=assert(appearance.snapshot(2)) -sabotage_next_offset=true -local ok,why=appearance.set_visual(1,2,{offset_x=99}) -assert(ok==false and why=='visual_verify_failed','post-sync offset failure reported success') -local after=assert(appearance.snapshot(2)) -assert(after.offset_x==before.offset_x,'failed offset did not roll back') -assert(values[90].offset_x==90,'rollback touched unrelated SpriteComponent') - -assert(sync_calls>=8,'expected sync-backed appearance operations') -print('wand_appearance=PASS fallback=true post_sync=true toggles_both_ways=true visual_verify=true rollback=true') diff --git a/metamorph_creative_menu/tests/wand_blueprint_codec_mock.lua b/metamorph_creative_menu/tests/wand_blueprint_codec_mock.lua deleted file mode 100644 index 7bbc9be..0000000 --- a/metamorph_creative_menu/tests/wand_blueprint_codec_mock.lua +++ /dev/null @@ -1,28 +0,0 @@ -local root=assert(arg[1]) -local codec=assert(dofile(root..'/files/core/wand_blueprint_codec.lua')) -local source={ - mana=123.5,sprite_file='mods/test wand/skin%1.xml', - stats={slots=26,shuffle=false,never_reload=true,mana_max=999,spread_degrees=-3.25}, - meta={name='Custom Ω Wand',show_name_in_ui=true,wand_frozen=true,image_file='visual.png',sprite_offset_x=3,sprite_offset_y=4,tip_x=7,tip_y=8}, - spells={ - {action_id='LIGHT_BULLET',slot=0,slot_y=0,permanent=false,uses_remaining=12,frozen=false}, - {action_id='MOD:Ω|SPELL',slot=-1,slot_y=-1,permanent=true,uses_remaining=nil,frozen=true}, - } -} -local encoded=codec.encode(source) -assert(string.match(encoded,'^MCM_WAND_V2'),'V2 header missing') -assert(codec.current_version()==2,'current blueprint version wrong') -local decoded,reason=codec.decode(encoded) -assert(decoded~=nil and reason=='ok','blueprint decode failed') -assert(decoded.mana==123.5 and decoded.sprite_file==source.sprite_file,'scalar/string roundtrip failed') -assert(decoded.stats.slots==26 and decoded.stats.shuffle==false and decoded.stats.never_reload==true,'stat roundtrip failed') -assert(decoded.version==2 and decoded.meta.name=='Custom Ω Wand' and decoded.meta.show_name_in_ui==true and decoded.meta.wand_frozen==true,'V2 meta roundtrip failed') -assert(decoded.meta.image_file=='visual.png' and decoded.meta.sprite_offset_x==3 and decoded.meta.tip_y==8,'V2 visual metadata failed') -assert(#decoded.spells==2 and decoded.spells[1].action_id=='LIGHT_BULLET' and decoded.spells[1].uses_remaining==12,'spell roundtrip failed') -assert(decoded.spells[2].action_id=='MOD:Ω|SPELL' and decoded.spells[2].permanent==true and decoded.spells[2].frozen==true and decoded.spells[2].slot_y==-1,'escaped modded spell failed') -local legacy=table.concat({'MCM_WAND_V1','mana=n10','sprite=sold.png','stat:slots=n2','spell\tA\t0\t0\t\t0'},'\n') -local old,old_reason=codec.decode(legacy) -assert(old~=nil and old_reason=='ok' and old.version==1 and old.mana==10 and old.stats.slots==2,'V1 compatibility failed') -assert(type(old.meta)=='table' and #old.spells==1 and old.spells[1].slot_y==0,'V1 defaults failed') -assert(codec.decode('broken')==nil,'invalid version accepted') -print('wand_blueprint_codec=PASS deterministic=true unicode=true spells=true stats=true') diff --git a/metamorph_creative_menu/tests/wand_blueprints_v2_mock.lua b/metamorph_creative_menu/tests/wand_blueprints_v2_mock.lua deleted file mode 100644 index f9f88c8..0000000 --- a/metamorph_creative_menu/tests/wand_blueprints_v2_mock.lua +++ /dev/null @@ -1,137 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local player,wand=1,2 -local next_entity=100 -local alive={[wand]=true} -local parent={} -local comps={} -local values={} -local config={stats={slots=3,mana_max=100,shuffle=false},mana=40,sprite_file='old.png'} -local visual={name='Old Wand',show_name_in_ui=true,wand_frozen=false,image_file='old.png',offset_x=1,offset_y=2,tip_x=3,tip_y=4} -local fail_appearance=false -local sync_calls=0 - -local function make_spell(action_id,slot,permanent,uses,frozen,forced_id) - local e=forced_id or next_entity; if not forced_id then next_entity=next_entity+1 end - local item=e*10+1; local action=e*10+2 - alive[e]=true; comps[e]={ItemComponent=item,ItemActionComponent=action} - values[item]={inventory_slot={slot or 0,permanent and -1 or 0},permanently_attached=permanent==true,uses_remaining=uses,is_frozen=frozen==true,has_been_picked_by_player=true} - values[action]={action_id=action_id} - return e -end -local old_a=make_spell('OLD_A',0,false,2,false,10); parent[old_a]=wand -local old_b=make_spell('OLD_ALWAYS',-1,true,7,true,11); parent[old_b]=wand - -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/features/wands/service.lua' then - return { - snapshot=function(w) - if w~=wand then return nil,'not_wand' end - local stats={}; for k,v in pairs(config.stats) do stats[k]=v end - return {wand=wand,ability=20,stats=stats,mana=config.mana,sprite_file=config.sprite_file},'ok' - end, - apply_configuration=function(p,w,desired,options) - if w~=wand then return false,'not_wand' end - for k,v in pairs(desired.stats or {}) do config.stats[k]=v end - if desired.mana~=nil then config.mana=desired.mana end - return true,'ok' - end, - refresh=function() sync_calls=sync_calls+1 end, - } - end - if path=='mods/metamorph_creative_menu/files/features/wands/appearance.lua' then - return { - snapshot=function(w) - if w~=wand then return nil,'not_wand' end - return {sprite_file=config.sprite_file,name=visual.name,show_name_in_ui=visual.show_name_in_ui,wand_frozen=visual.wand_frozen,image_file=visual.image_file,offset_x=visual.offset_x,offset_y=visual.offset_y,tip_x=visual.tip_x,tip_y=visual.tip_y},'ok' - end, - apply=function(p,w,desired,options) - if fail_appearance then return false,'visual_fault' end - if desired.sprite_file~=nil and desired.sprite_file~='' then config.sprite_file=desired.sprite_file end - local m=desired.meta or {} - if m.name~=nil then visual.name=m.name end - if m.show_name_in_ui~=nil then visual.show_name_in_ui=m.show_name_in_ui==true end - if m.wand_frozen~=nil then visual.wand_frozen=m.wand_frozen==true end - if m.image_file~=nil then visual.image_file=m.image_file end - if m.sprite_offset_x~=nil then visual.offset_x=m.sprite_offset_x end - if m.sprite_offset_y~=nil then visual.offset_y=m.sprite_offset_y end - if m.tip_x~=nil then visual.tip_x=m.tip_x end - if m.tip_y~=nil then visual.tip_y=m.tip_y end - return true,'ok' - end, - } - end - if path=='mods/metamorph_creative_menu/files/features/spells/factory.lua' then - return {create=function(action_id) return make_spell(action_id,0,false,nil,false),'ok' end} - end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -EntityGetIsAlive=function(e) return alive[e]==true end -EntityGetAllChildren=function(e) - local out={}; if e~=wand then return out end - for child,p in pairs(parent) do if p==wand and alive[child] then out[#out+1]=child end end - table.sort(out); return out -end -EntityGetFirstComponentIncludingDisabled=function(e,kind) return (comps[e] and comps[e][kind]) or 0 end -ComponentGetValue2=function(c,field) - local v=values[c] and values[c][field] - if type(v)=='table' then return v[1],v[2] end - return v -end -ComponentSetValue2=function(c,field,a,b) - values[c]=values[c] or {} - if b~=nil then values[c][field]={a,b} else values[c][field]=a end -end -EntityAddChild=function(p,e) if not alive[e] then error('dead') end; parent[e]=p end -EntityRemoveFromParent=function(e) parent[e]=0 end -EntityGetParent=function(e) return parent[e] or 0 end -EntityKill=function(e) alive[e]=false; parent[e]=0 end -EntitySetComponentsWithTagEnabled=function() end -GameRegenItemActionsInContainer=function() end -GameRegenItemActionsInPlayer=function() end - -local blueprints=assert(native_dofile(root..'/files/features/wands/blueprints.lua')) -local before=assert(blueprints.capture(wand)) -assert(before.meta.name=='Old Wand' and #before.spells==2,'V2 capture metadata/spells failed') - -local desired={version=2,stats={slots=5,mana_max=250,shuffle=true},mana=123,sprite_file='new.png',meta={name='Saved Wand',show_name_in_ui=true,wand_frozen=true,image_file='new.png',sprite_offset_x=9,sprite_offset_y=10,tip_x=11,tip_y=12},spells={ - {action_id='NEW_A',slot=2,slot_y=0,permanent=false,uses_remaining=4,frozen=true}, - {action_id='NEW_ALWAYS',slot=-1,slot_y=-1,permanent=true,uses_remaining=1,frozen=false}, -}} -local ok,reason=blueprints.apply(player,wand,desired) -assert(ok==true and reason=='loaded','V2 apply failed '..tostring(reason)) -assert(alive[old_a]==false and alive[old_b]==false,'old cards survived successful commit') -assert(config.stats.slots==5 and config.mana==123 and config.sprite_file=='new.png','configuration not applied') -assert(visual.name=='Saved Wand' and visual.offset_x==9 and visual.tip_y==12 and visual.wand_frozen==true,'appearance not applied') -local new_children=EntityGetAllChildren(wand) -assert(#new_children==2,'new cards not attached') -local found_a,found_perm=false,false -for _,e in ipairs(new_children) do - local item=comps[e].ItemComponent; local action=comps[e].ItemActionComponent; local id=values[action].action_id - if id=='NEW_A' then found_a=values[item].inventory_slot[1]==2 and values[item].uses_remaining==4 and values[item].is_frozen==true end - if id=='NEW_ALWAYS' then found_perm=values[item].permanently_attached==true and values[item].inventory_slot[1]==-1 and values[item].uses_remaining==1 end -end -assert(found_a and found_perm,'card runtime/permanent state lost') - --- A later visual failure must restore the exact pre-attempt cards and non-spell state, --- while destroying every newly prepared candidate from the failed load. -local stable=assert(blueprints.capture(wand)) -local stable_children=EntityGetAllChildren(wand) -fail_appearance=true -local failed,why=blueprints.apply(player,wand,{version=2,stats={slots=2,mana_max=1},mana=1,sprite_file='bad.png',meta={name='Bad'},spells={{action_id='FAIL_NEW',slot=0,permanent=false}}}) -assert(failed==false and why=='visual_fault','faulted blueprint reported success') -fail_appearance=false -local after_fail=assert(blueprints.capture(wand)) -assert(config.stats.slots==stable.stats.slots and config.stats.mana_max==stable.stats.mana_max and config.mana==stable.mana,'config rollback failed') -assert(visual.name==stable.meta.name and config.sprite_file==stable.sprite_file,'appearance rollback failed') -local restored=EntityGetAllChildren(wand) -assert(#restored==#stable_children,'card count changed after failed load') -for i,e in ipairs(stable_children) do assert(parent[e]==wand and alive[e]==true,'old prepared card not restored') end -for e,_ in pairs(alive) do - if e>=100 and parent[e]==0 then assert(alive[e]==false,'failed candidate leaked alive') end -end -assert(sync_calls>=2,'final synchronization missing') -print('wand_blueprints_v2=PASS capture=true apply=true card_runtime=true rollback=true') diff --git a/metamorph_creative_menu/tests/wand_history_mock.lua b/metamorph_creative_menu/tests/wand_history_mock.lua deleted file mode 100644 index 731ac20..0000000 --- a/metamorph_creative_menu/tests/wand_history_mock.lua +++ /dev/null @@ -1,46 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local frame=100 -local state={version=2,stats={slots=2},mana=10,sprite_file='a.png',meta={name='A'},spells={}} -local apply_calls=0 -local function clone(v) - if type(v)~='table' then return v end - local out={}; for k,x in pairs(v) do out[k]=clone(x) end; return out -end - -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/features/wands/blueprints.lua' then - return { - capture=function(w) return clone(state),'ok' end, - apply=function(player,w,bp) apply_calls=apply_calls+1; state=clone(bp); return true,'loaded' end, - } - end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -GameGetFrameNum=function() return frame end -local history=assert(native_dofile(root..'/files/features/wands/history.lua')) - -assert(history.perform(1,2,'mana +',function() state.mana=20; return true,'ok' end,{coalesce_key='mana',coalesce_frames=20})==true,'first edit failed') -frame=105 -assert(history.perform(1,2,'mana ++',function() state.mana=30; return true,'ok' end,{coalesce_key='mana',coalesce_frames=20})==true,'second edit failed') -assert(history.can_undo(2) and history.undo_label(2)=='mana ++','coalesced undo record wrong') -assert(history.undo(1,2)==true and state.mana==10,'undo did not restore pre-coalesced value') -assert(history.can_redo(2) and history.redo(1,2)==true and state.mana==30,'redo did not restore final value') --- A held/repeated numeric edit may fire every frame. It must remain one undo record rather --- than filling the bounded history with dozens of nearly identical steps. -for index=1,100 do - frame=106+index - assert(history.perform(1,2,'held mana '..index,function() state.mana=30+index; return true,'ok' end, - {coalesce_key='held_mana',coalesce_frames=45})==true,'held edit failed at '..index) -end -assert(history.undo(1,2)==true and state.mana==30,'held/repeated edits did not coalesce into one undo record') -assert(history.redo(1,2)==true and state.mana==130,'redo did not restore final held/repeated value') -local calls=apply_calls -assert(history.perform(1,2,'noop',function() return true,'ok' end)==true,'noop operation failed') -assert(history.undo_label(2)=='held mana 100','noop created history entry') -history.clear(2) -assert(not history.can_undo(2) and not history.can_redo(2),'history clear failed') -assert(apply_calls==calls,'history query/noop unexpectedly applied blueprint') -print('wand_history=PASS undo=true redo=true coalesce=true noop=true bounded_state=true') diff --git a/metamorph_creative_menu/tests/wand_presets_copy_mock.lua b/metamorph_creative_menu/tests/wand_presets_copy_mock.lua deleted file mode 100644 index 4f98fde..0000000 --- a/metamorph_creative_menu/tests/wand_presets_copy_mock.lua +++ /dev/null @@ -1,124 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local player=1 -local next_entity=100 -local alive={[player]=true} -local parent={} -local position={[player]={100,200}} -local stored='' -local current={version=2,stats={slots=4,mana_max=250,shuffle=true},mana=123,sprite_file='saved_sprite.png',meta={name='Saved Wand',show_name_in_ui=true,wand_frozen=true,image_file='saved_image.png'},spells={{action_id='A',slot=0,slot_y=0,permanent=false},{action_id='B',slot=-1,slot_y=-1,permanent=true}}} -local applied=nil -local plan_mode='inventory' -local place_fail=false -local apply_fail=false -local notify_fail=false -local sync_calls=0 -local world_enabled={} -local notified={} -local attempts={} - -local function clone(v) - if type(v)~='table' then return v end - local out={}; for k,x in pairs(v) do out[k]=clone(x) end; return out -end -local function create_child(p) - next_entity=next_entity+1; local e=next_entity; alive[e]=true; parent[e]=p; attempts[#attempts+1]=e; return e -end -local function all_children(e) - local out={}; for child,p in pairs(parent) do if p==e and alive[child] then out[#out+1]=child end end - table.sort(out); return out -end - -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/features/wands/blueprints.lua' then - return { - capture=function() return clone(current),'ok' end, - apply=function(p,w,bp) - applied=clone(bp) - create_child(w) -- assembled spell candidate or a partial child on failure - if apply_fail then create_child(w); return false,'apply_failed' end - return true,'loaded' - end, - } - end - if path=='mods/metamorph_creative_menu/files/platform/noita/inventory_slots.lua' then - return { - preflight=function(p,e) - if plan_mode=='inventory' then return {name='inventory_quick',x=2,y=0},'ok' end - if plan_mode=='full' then return nil,'full' end - return nil,'inventory_missing' - end, - place_exact=function(p,e,name,x,y) - assert(name=='inventory_quick' and x==2 and y==0,'preset did not use planned quick slot') - if place_fail then return false,'attach_failed' end - parent[e]=50 - return true,'ok' - end, - enable_world=function(e) world_enabled[e]=true end, - } - end - if path=='mods/metamorph_creative_menu/files/features/wands/sync.lua' then - return {inventory=function() sync_calls=sync_calls+1 end} - end - if path=='mods/metamorph_creative_menu/files/integrations/ew/world_items.lua' then - return {notify_world_item=function(e) notified[e]=true; return not notify_fail end} - end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end - -ModSettingGet=function() return stored end -ModSettingSet=function(_,value) stored=value; return true end -EntityGetIsAlive=function(e) return alive[e]==true end -EntityGetTransform=function(e) local p=position[e]; if p then return p[1],p[2] end; return nil end -EntitySetTransform=function(e,x,y) position[e]={x,y} end -EntityLoad=function(path,x,y) - assert(path=='data/entities/items/starting_wand.xml','unexpected base wand path') - next_entity=next_entity+1; local e=next_entity; alive[e]=true; position[e]={x,y}; attempts[#attempts+1]=e - create_child(e) -- default child from the base wand, must also be cleaned on failure - return e -end -EntityGetAllChildren=all_children -EntityRemoveFromParent=function(e) parent[e]=0 end -EntityKill=function(e) alive[e]=false; parent[e]=0 end - -local presets=assert(native_dofile(root..'/files/features/wands/presets.lua')) -assert(presets.save('Saved',2)==true,'fixture save failed') - -local ok,reason,entity=presets.give(1,player) -assert(ok==true and reason=='given_inventory' and entity~=0,'inventory copy failed') -assert(parent[entity]==50 and sync_calls==1,'copy was not committed to planned quick inventory slot') -assert(applied.meta.name=='Saved Wand' and applied.stats.mana_max==250 and #applied.spells==2,'exact saved blueprint was not applied') - -plan_mode='full' -local world_ok,world_reason,world_entity=presets.give(1,player) -assert(world_ok==true and world_reason=='given_world','full inventory did not fall back to world') -assert(parent[world_entity]==0 and world_enabled[world_entity]==true and notified[world_entity]==true,'world fallback not enabled/synchronized') -assert(position[world_entity][1]==112 and position[world_entity][2]==192,'world fallback position wrong') - -local function assert_attempt_dead(from_index,message) - for i=from_index,#attempts do assert(alive[attempts[i]]~=true,message..' leaked entity '..tostring(attempts[i])) end -end - -plan_mode='inventory'; apply_fail=true -local start=#attempts+1 -local failed,why,failed_entity=presets.give(1,player) -assert(failed==false and why=='apply_failed' and failed_entity==0,'blueprint failure was not reported') -assert_attempt_dead(start,'blueprint failure cleanup') -apply_fail=false - -place_fail=true -start=#attempts+1 -failed,why,failed_entity=presets.give(1,player) -assert(failed==false and why=='attach_failed' and failed_entity==0,'inventory commit failure was not reported') -assert_attempt_dead(start,'inventory failure cleanup') -place_fail=false - -plan_mode='full'; notify_fail=true -start=#attempts+1 -failed,why,failed_entity=presets.give(1,player) -assert(failed==false and why=='world_sync_failed' and failed_entity==0,'world sync failure was not reported') -assert_attempt_dead(start,'world sync failure cleanup') - -print('wand_presets_copy=PASS blueprint_apply=true quick_slot=true full_world=true recursive_cleanup=true sync_failure_cleanup=true') diff --git a/metamorph_creative_menu/tests/wand_presets_persistence_mock.lua b/metamorph_creative_menu/tests/wand_presets_persistence_mock.lua deleted file mode 100644 index f0b62bb..0000000 --- a/metamorph_creative_menu/tests/wand_presets_persistence_mock.lua +++ /dev/null @@ -1,113 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local stored='' -local fail_setting_write=false -local current={version=2,stats={slots=3,mana_max=100},mana=50,sprite_file='a.png',meta={name='A'},spells={{action_id='A',slot=0,slot_y=0,permanent=false,uses_remaining=2,frozen=false}}} -local applied=nil -local function clone(v) - if type(v)~='table' then return v end - local out={}; for k,x in pairs(v) do out[k]=clone(x) end; return out -end - -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/features/wands/blueprints.lua' then - return { - capture=function(w) return clone(current),'ok' end, - apply=function(player,w,bp) applied=clone(bp); current=clone(bp); return true,'loaded' end, - } - end - if path=='mods/metamorph_creative_menu/files/platform/noita/inventory_slots.lua' then return {} end - if path=='mods/metamorph_creative_menu/files/features/wands/sync.lua' then return {} end - if path=='mods/metamorph_creative_menu/files/integrations/ew/world_items.lua' then return {} end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -ModSettingGet=function(key) return stored end -ModSettingSet=function(key,value) - if fail_setting_write then error('injected ModSettingSet failure') end - stored=value; return true -end - -local codec=assert(native_dofile(root..'/files/core/wand_blueprint_codec.lua')) -local function reload_presets() - METAMORPH_CREATIVE_MENU_WAND_PRESETS=nil - return assert(native_dofile(root..'/files/features/wands/presets.lua')) -end -local presets=reload_presets() -assert(presets.save(' Alpha ',2)==true,'initial save failed') -assert(string.find(stored,'MCM_PRESETS_V1',1,true)~=nil,'persistent wrapper missing') -local immediate=presets.all() -assert(#immediate==1 and immediate[1].name=='Alpha' and immediate[1].blueprint.meta.name=='A','new preset not visible immediately after save') - --- A settings write failure must not publish an unpersisted save into the session cache. -local before_failed_save_storage=stored -local before_failed_save=presets.all() -current.meta.name='Unsaved'; current.mana=11 -fail_setting_write=true -local failed_save,failed_save_reason=presets.save('New',2) -fail_setting_write=false -assert(failed_save==false and failed_save_reason=='settings_write_failed','failed save reason changed') -assert(stored==before_failed_save_storage,'failed save changed persistent storage') -local after_failed_save=presets.all() -assert(#after_failed_save==#before_failed_save and after_failed_save[1].name==before_failed_save[1].name and after_failed_save[1].blueprint.meta.name==before_failed_save[1].blueprint.meta.name,'failed save mutated in-memory cache') - --- A settings write failure must likewise leave delete atomic in memory and storage. -local before_failed_delete_storage=stored -local before_failed_delete=presets.all() -fail_setting_write=true -local failed_delete,failed_delete_reason=presets.delete(1) -fail_setting_write=false -assert(failed_delete==false and failed_delete_reason=='settings_write_failed','failed delete reason changed') -assert(stored==before_failed_delete_storage,'failed delete changed persistent storage') -local after_failed_delete=presets.all() -assert(#after_failed_delete==#before_failed_delete and after_failed_delete[1].name==before_failed_delete[1].name and after_failed_delete[1].blueprint.meta.name==before_failed_delete[1].blueprint.meta.name,'failed delete mutated in-memory cache') - --- presets.all() must isolate every nested blueprint table from the internal cache. -local exposed=presets.all() -exposed[1].blueprint.meta.name='CallerMutation' -exposed[1].blueprint.spells[1].action_id='MUTATED_SPELL' -exposed[1].blueprint.stats.slots=999 -local isolated=presets.all() -assert(isolated[1].blueprint.meta.name=='A','presets.all leaked nested meta table') -assert(isolated[1].blueprint.spells[1].action_id=='A','presets.all leaked nested spell record') -assert(isolated[1].blueprint.stats.slots==3,'presets.all leaked nested stats table') -assert(presets.load(1,1,2)==true and applied.meta.name=='A' and applied.spells[1].action_id=='A' and applied.stats.slots==3,'presets.load observed caller mutation from presets.all') - -current.meta.name='Changed'; current.mana=7 -presets=reload_presets() -local list=presets.all() -assert(#list==1 and list[1].name=='Alpha','preset did not survive cache reset') -assert(list[1].blueprint.meta.name=='A' and list[1].blueprint.mana==50 and list[1].blueprint.version==2,'V2 blueprint did not persist') -assert(presets.load(1,1,2)==true and applied~=nil and applied.meta.name=='A','persistent preset load failed') - -current.meta.name='Overwrite'; current.mana=99 -assert(presets.save('alpha',2)==true,'case-insensitive overwrite save failed') -presets=reload_presets(); list=presets.all() -assert(#list==1 and list[1].blueprint.meta.name=='Overwrite' and list[1].blueprint.mana==99,'same-name preset duplicated instead of overwriting') - --- Existing MCM_PRESETS_V1 data can contain the older wand blueprint payload. Loading it and --- saving another preset must preserve the user's legacy record rather than resetting storage. -local legacy=table.concat({'MCM_WAND_V1','mana=n10','sprite=sold.png','stat:slots=n2','spell\tOLD_SPELL\t0\t0\t\t0'},'\n') -stored='MCM_PRESETS_V1\n'..codec.escape('Legacy')..'\t'..codec.escape(legacy) -presets=reload_presets(); list=presets.all() -assert(#list==1 and list[1].name=='Legacy' and list[1].blueprint.version==1,'legacy MCM_PRESETS_V1 record failed to load') -assert(list[1].blueprint.mana==10 and list[1].blueprint.stats.slots==2 and list[1].blueprint.spells[1].action_id=='OLD_SPELL','legacy blueprint data changed on load') -current={version=2,stats={slots=4},mana=88,sprite_file='new.png',meta={name='New'},spells={}} -assert(presets.save('New',2)==true,'save beside legacy record failed') -presets=reload_presets(); list=presets.all() -assert(#list==2 and list[1].name=='Legacy' and list[2].name=='New','legacy preset was lost when storage was rewritten') -assert(list[1].blueprint.mana==10 and list[1].blueprint.spells[1].action_id=='OLD_SPELL','legacy preset payload was not preserved') - -assert(presets.delete(2)==true,'delete failed') -presets=reload_presets(); list=presets.all() -assert(#list==1 and list[1].name=='Legacy','delete removed the wrong preset') - --- Preset names keep the 80-byte storage cap without splitting a UTF-8 codepoint. -stored='' -presets=reload_presets() -local long_cjk=string.rep('漢',27) -assert(presets.save(long_cjk,2)==true,'UTF-8 preset save failed') -list=presets.all() -assert(#list==1 and list[1].name==string.rep('漢',26) and #list[1].name==78,'preset name split a UTF-8 codepoint') -print('wand_presets_persistence=PASS immediate=true settings=true atomic_failure=true isolated_blueprints=true overwrite=true cache_reset=true legacy_v1=true delete=true') diff --git a/metamorph_creative_menu/tests/wand_presets_ui_mock.lua b/metamorph_creative_menu/tests/wand_presets_ui_mock.lua deleted file mode 100644 index ffa75f6..0000000 --- a/metamorph_creative_menu/tests/wand_presets_ui_mock.lua +++ /dev/null @@ -1,82 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local images={} -local buttons={} -local save_calls=0 -local list={ - {name='Meta icon',blueprint={meta={image_file='meta.png'},sprite_file='sprite_a.xml'}}, - {name='Sprite icon',blueprint={meta={},sprite_file='sprite_b.xml'}}, - {name='Fallback icon',blueprint={meta={image_file='missing.png'},sprite_file='missing.xml'}}, -} -local next_id=0 -local scroll_calls=0 -local truncated={} - -local ui={} -function ui.gui() return 1 end -function ui.next_id() next_id=next_id+1; return next_id end -function ui.tr(_,fallback) return fallback end -function ui.text_input() return 'New preset' end -function ui.white_text() end -function ui.text_width(text) return #tostring(text or '')*5 end -function ui.truncate_text(text,width) truncated[#truncated+1]={text=text,width=width}; return text end -function ui.resolve(path) - if path=='missing.png' or path=='missing.xml' or path==nil then return nil end - return path -end -function ui.dimensions() return 16,16 end -function ui.button(_,_,label) buttons[#buttons+1]=label; return false end -function ui.button_grid(items) - for index,item in ipairs(items or {}) do - buttons[#buttons+1]=item.label - if item.label=='+ WAND PRESETS' then return index end - if item.label=='SAVE' and save_calls==0 then save_calls=save_calls+1; return index end - end - return nil -end -function ui.clear_error_notice() end -function ui.report_error_once() end -function ui.begin_scroll_viewport() scroll_calls=scroll_calls+1; error('wand presets must not create nested scroll') end -function ui.end_scroll_viewport() error('wand presets must not close nested scroll') end - -local presets={} -function presets.save(name,wand) - assert(name=='New preset' and wand==2,'save did not receive current name/wand') - list[#list+1]={name=name,blueprint={meta={},sprite_file='missing.xml'}} - return true,'ok' -end -function presets.all() return list end -function presets.load() return true,'loaded' end -function presets.give() return true,'given_inventory' end -function presets.delete() return true,'ok' end -local history={perform=function(_,_,_,callback) return callback() end} - -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/ui/runtime.lua' then return ui end - if path=='mods/metamorph_creative_menu/files/features/wands/presets.lua' then return presets end - if path=='mods/metamorph_creative_menu/files/features/wands/history.lua' then return history end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -GuiLayoutBeginHorizontal=function() end -GuiLayoutEnd=function() end -GuiLayoutAddVerticalSpacing=function() end -GuiZSetForNextWidget=function() end -GuiImage=function(_,_,_,_,path) images[#images+1]=path end - -local component=assert(native_dofile(root..'/files/ui/components/wand_presets.lua')) -component.draw(1,2,150) -assert(#list==4 and list[4].name=='New preset','saved preset was not rendered from updated same-frame list') -assert(images[1]=='meta.png','meta.image_file did not win icon selection') -assert(images[2]=='sprite_b.xml','sprite_file fallback was not used') -assert(images[3]=='data/items_gfx/handgun.xml' and images[4]=='data/items_gfx/handgun.xml','safe standard wand icon fallback missing') -local apply_count,copy_count=0,0 -for _,label in ipairs(buttons) do - if label=='APPLY' then apply_count=apply_count+1 end - if label=='GET COPY' then copy_count=copy_count+1 end -end -assert(apply_count==4 and copy_count==4,'apply/copy actions were not rendered for every preset') -assert(scroll_calls==0,'preset list created a nested scroll viewport inside WAND workspace') -for _,entry in ipairs(truncated) do assert(entry.width<=122,'preset name was allowed to occupy action area') end -print('wand_presets_ui=PASS same_frame=true icon_priority=true apply_copy=true narrow_card=true outer_scroll_only=true') diff --git a/metamorph_creative_menu/tests/wand_service_slot_limit_mock.lua b/metamorph_creative_menu/tests/wand_service_slot_limit_mock.lua deleted file mode 100644 index dc158bc..0000000 --- a/metamorph_creative_menu/tests/wand_service_slot_limit_mock.lua +++ /dev/null @@ -1,52 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile -local sync_calls=0 -local objects={ - gun_config={deck_capacity=6,actions_per_round=1,reload_time=10,shuffle_deck_when_empty=false}, - gunaction_config={fire_rate_wait=5,spread_degrees=0,speed_multiplier=1}, -} -local scalars={mana=50,mana_max=100,mana_charge_speed=20,item_recoil_recovery_speed=0,gun_level=1,never_reload=false,sprite_file='wand.png'} - -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/platform/noita/wand.lua' then - return { - ability=function(wand) return wand==2 and 21 or 0 end, - get_object=function(_,object,field) - local bucket=objects[object] or {} - return bucket[field],bucket[field]~=nil - end, - set_object=function(_,object,field,value) - objects[object]=objects[object] or {}; objects[object][field]=value; return true - end, - get_scalar=function(_,field) return scalars[field],scalars[field]~=nil end, - set_scalar=function(_,field,value) scalars[field]=value; return true end, - } - end - if path=='mods/metamorph_creative_menu/files/features/wands/sync.lua' then - return {inventory=function(player) assert(player==1); sync_calls=sync_calls+1 end} - end - if path=='mods/metamorph_creative_menu/files/features/wands/appearance.lua' then - return {set_visual=function() return true,'ok' end} - end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -EntityGetAllChildren=function() return {} end - -METAMORPH_CREATIVE_MENU_WAND_SERVICE=nil -local service=assert(native_dofile(root..'/files/features/wands/service.lua')) -assert(service.max_slots()==64,'reported editable slot maximum is not 64') -local def=assert(service.definition('slots')) -assert(def.min==1 and def.max==64 and def.integer==true,'slot definition bounds wrong') - -local ok,why=service.set_stat(1,2,'slots',1000) -assert(ok==true and why=='ok','oversized direct edit failed instead of clamping') -assert(objects.gun_config.deck_capacity==64,'direct slot edit exceeded 64: '..tostring(objects.gun_config.deck_capacity)) - -objects.gun_config.deck_capacity=6 -local ok2,why2=service.apply_configuration(1,2,{stats={slots=999}},{skip_sync=false}) -assert(ok2==true and why2=='ok','configuration slot edit failed instead of clamping') -assert(objects.gun_config.deck_capacity==64,'configuration path exceeded 64: '..tostring(objects.gun_config.deck_capacity)) -assert(sync_calls==2,'unexpected sync count '..tostring(sync_calls)) -print('wand_service_slot_limit=PASS max=64 direct_clamp=true configuration_clamp=true') diff --git a/metamorph_creative_menu/tests/wand_skin_catalog_mock.lua b/metamorph_creative_menu/tests/wand_skin_catalog_mock.lua deleted file mode 100644 index 4f049f7..0000000 --- a/metamorph_creative_menu/tests/wand_skin_catalog_mock.lua +++ /dev/null @@ -1,21 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local catalog={{category='WANDS',path='child.xml',name='Child wand',icon='icon.png'}} -local files={ - ['child.xml']=[[]], - ['base.xml']=[[]], -} -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/features/items/catalog.lua' then return catalog end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -ModTextFileGetContent=function(path) return files[path] or '' end -local skins=assert(native_dofile(root..'/files/features/wands/skins.lua')) -local entries=skins.entries() -assert(#entries==1,'skin catalog size wrong') -local e=entries[1] -assert(e.sprite_file=='child_ability.png' and e.image_file=='child_image.png' and e.offset_x==11 and e.offset_y==12,'local skin override failed') -assert(e.tip_x==7 and e.tip_y==8,'inherited shoot tip missing') -print('wand_skin_catalog=PASS inheritance=true geometry=true') diff --git a/metamorph_creative_menu/tests/wand_slot_limit_contract.py b/metamorph_creative_menu/tests/wand_slot_limit_contract.py deleted file mode 100644 index d735d44..0000000 --- a/metamorph_creative_menu/tests/wand_slot_limit_contract.py +++ /dev/null @@ -1,14 +0,0 @@ -from pathlib import Path -import sys -root=Path(sys.argv[1]).resolve() -service=(root/'files/features/wands/service.lua').read_text() -editor=(root/'files/ui/components/wand_editor.lua').read_text() -numeric=(root/'files/ui/components/fixed_numeric_editor.lua').read_text() -blueprints=(root/'files/features/wands/blueprints.lua').read_text() -assert 'local MAX_EDITABLE_SLOTS = 64' in service, 'wand slot safety cap is not 64' -assert 'max=MAX_EDITABLE_SLOTS' in service, 'slot definition does not use the safety cap' -assert 'function wand_service.max_slots()' in service, 'slot limit is not exposed to other write paths' -assert 'max=definition.max' in editor and 'min=definition.min' in editor, 'wand numeric UI does not receive service bounds' -assert 'normalize_candidate' in numeric and 'options.max' in numeric, 'numeric editor does not normalize bounded values' -assert 'requested_capacity > max_slots' in blueprints and 'highest_requested >= max_slots' in blueprints, 'blueprint path can bypass slot cap' -print('wand_slot_limit_contract=PASS max_slots=64 ui_clamp=true blueprint_guard=true') diff --git a/metamorph_creative_menu/tests/wand_stats_layout_mock.lua b/metamorph_creative_menu/tests/wand_stats_layout_mock.lua deleted file mode 100644 index c4f1542..0000000 --- a/metamorph_creative_menu/tests/wand_stats_layout_mock.lua +++ /dev/null @@ -1,140 +0,0 @@ -local root=assert(arg[1],'root required') -local native_dofile=dofile - --- First exercise the real fixed numeric editor with a tiny deterministic layout model. -local cursor=0 -local buttons={} -local fields={} -local ui={ - gui=function() return 1 end, - text_width=function(text) return #tostring(text or '')*5 end, - truncate_text=function(text,width) - text=tostring(text or '') - while #text>1 and #text*5>width-15 do text=string.sub(text,1,#text-1) end - return (#text*5<=width) and text or string.sub(text,1,1) - end, - white_text=function(_,_,text) cursor=cursor+#tostring(text or '')*5 end, - button=function(_,_,label) - buttons[#buttons+1]={label=label,x=cursor} - cursor=cursor+#tostring(label or '')*5+6 - return false - end, - text_input=function(before,width,max_chars,key,options) - fields[#fields+1]={key=key,x=cursor,width=width,value=before} - cursor=cursor+width - return before,false - end, - text_input_focus_key=function() return nil end, -} -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/ui/runtime.lua' then return ui end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -GuiLayoutBeginHorizontal=function() cursor=0 end -GuiLayoutAddHorizontalSpacing=function(_,amount) cursor=cursor+(tonumber(amount) or 0) end -GuiLayoutEnd=function() end -GuiTooltip=function() end - -METAMORPH_CREATIVE_MENU_FIXED_NUMERIC_EDITOR=nil -local numeric=assert(native_dofile(root..'/files/ui/components/fixed_numeric_editor.lua')) - -local function geometry(value,panel_width) - buttons,fields={},{} - numeric.reset('wand.layout.') - local changed,reason=numeric.draw('wand.layout.slots','SLOTS',value,{ - integer=true,min=1,max=64,label_width=94,value_width=58,value_chars=8,max_width=panel_width, - on_apply=function() return true,'ok' end, - }) - assert(changed==false and reason==nil,'layout-only draw unexpectedly mutated capacity') - assert(#buttons==2 and #fields==1,'numeric row did not draw exactly decrement/field/increment') - return {dec=buttons[1].x, field=fields[1].x, field_width=fields[1].width, inc=buttons[2].x, finish=cursor} -end - -for _,width in ipairs({120,260}) do - local one=geometry(1,width) - local twenty_six=geometry(26,width) - local sixty_four=geometry(64,width) - for _,g in ipairs({twenty_six,sixty_four}) do - assert(g.dec==one.dec,'capacity value moved decrement column at width '..width) - assert(g.field==one.field and g.field_width==one.field_width,'capacity value moved/resized field at width '..width) - assert(g.inc==one.inc,'capacity value moved increment column at width '..width) - end - assert(one.finish<=width and twenty_six.finish<=width and sixty_four.finish<=width, - 'numeric row exceeded viewport at width '..width) -end - --- Render the real wand stats shell with collaborators stubbed. The removed Current State --- component must not be linked, while unit-bearing stats keep localized tooltip descriptions. -local stat_calls={} -local sequence={} -local numeric_stub={ - reset=function() end, - draw=function(key,label,value,options) - stat_calls[key]={label=label,value=value,options=options} - sequence[#sequence+1]='stat:'..key - return false,nil - end, -} -local stats={ - slots=26,actions_per_round=1,reload_time=30,fire_rate_wait=12,spread_degrees=-3, - speed_multiplier=1,mana_max=100,mana_charge_speed=50,item_recoil_recovery_speed=0,gun_level=1, - shuffle=false,never_reload=false, -} -local editor_ui={ - tr=function(key,fallback) - local descriptions={ - ['$mcm_wand_recharge_desc']='recharge frames help', - ['$mcm_wand_cast_delay_desc']='cast frames help', - ['$mcm_wand_spread_desc']='spread degrees help', - } - return descriptions[key] or fallback - end, - button=function() return false end, - button_grid=function() sequence[#sequence+1]='toggles'; return nil end, - wrapped_text=function() end, -} -local service={ - snapshot=function() return {stats=stats},'ok' end, - definition=function(id) - if id=='slots' then return {integer=true,min=1,max=64,step=1} end - if id=='actions_per_round' or id=='reload_time' or id=='fire_rate_wait' or id=='gun_level' then return {integer=true,step=1} end - return {step=1} - end, - set_stat=function() return true,'ok' end, - set_boolean=function() return true,'ok' end, -} -local history_options={} -local history={perform=function(player,wand,label,callback,options) history_options[#history_options+1]=options; return callback() end} -local history_bar={draw=function() sequence[#sequence+1]='history'; return false,nil end} -local appearance={draw=function() sequence[#sequence+1]='appearance'; return true end} - -dofile=function(path) - if path=='mods/metamorph_creative_menu/files/ui/runtime.lua' then return editor_ui end - if path=='mods/metamorph_creative_menu/files/features/wands/service.lua' then return service end - if path=='mods/metamorph_creative_menu/files/features/wands/history.lua' then return history end - if path=='mods/metamorph_creative_menu/files/ui/components/wand_history_bar.lua' then return history_bar end - if path=='mods/metamorph_creative_menu/files/ui/components/fixed_numeric_editor.lua' then return numeric_stub end - if path=='mods/metamorph_creative_menu/files/ui/components/wand_appearance_editor.lua' then return appearance end - if path=='mods/metamorph_creative_menu/files/ui/components/wand_runtime_editor.lua' then error('runtime editor must not be linked by wand stats UI') end - local prefix='mods/metamorph_creative_menu/' - if string.sub(path,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(path,#prefix+1)) end - return native_dofile(path) -end -METAMORPH_CREATIVE_MENU_WAND_EDITOR_UI=nil -local editor=assert(native_dofile(root..'/files/ui/components/wand_editor.lua')) -assert(editor.draw(1,2,180)==true,'wand editor draw failed') -for id,expected in pairs({reload_time='recharge frames help',fire_rate_wait='cast frames help',spread_degrees='spread degrees help'}) do - local call=assert(stat_calls['wand.stat.2.'..id],'missing stat '..id) - assert(call.options.suffix==nil or call.options.suffix=='','visible technical suffix survived for '..id) - assert(call.options.label_tooltip_description==expected,'localized unit help missing for '..id) -end -assert(sequence[#sequence]=='appearance','appearance did not follow stats directly after runtime UI removal') -local slot_apply=assert(stat_calls['wand.stat.2.slots'].options.on_apply) -assert(slot_apply(27)==true and slot_apply(28)==true,'repeated capacity edits did not route through history') -assert(#history_options==2 and history_options[1].coalesce_key=='stat.slots' and history_options[2].coalesce_key=='stat.slots' - and history_options[1].coalesce_frames==45 and history_options[2].coalesce_frames==45, - 'wand stat repeat edits lost history coalescing contract') - -print('wand_stats_layout=PASS capacity_columns=1_26_64 narrow=true wide=true current_state_absent=true suffixes_hidden=true unit_tooltips=true') diff --git a/metamorph_creative_menu/tests/weather_clouds_mock.lua b/metamorph_creative_menu/tests/weather_clouds_mock.lua deleted file mode 100644 index eee9a97..0000000 --- a/metamorph_creative_menu/tests/weather_clouds_mock.lua +++ /dev/null @@ -1,27 +0,0 @@ -local root=assert(arg[1]) -local native_dofile=dofile -local values={intro_weather=true,rain=0,rain_target=0,fog=0,fog_target=0,wind=0,wind_speed=0,lightning_count=0,time=0,time_dt=0.01} -local frame=100 -local weather_sync={enabled=function() return false end,can_edit=function() return true,'singleplayer' end,publish=function() end,consume=function() return false end} -local runtime={emit_rain=function() end,update_lightning=function() end} -local time_owner={is_weather_frozen=function() return false end,set_weather_frozen=function() return true end,has_day_override=function() return false end} -dofile=function(p) - if p=='mods/metamorph_creative_menu/files/integrations/ew/weather_sync.lua' then return weather_sync end - if p=='mods/metamorph_creative_menu/files/features/weather/runtime_effects.lua' then return runtime end - if p=='mods/metamorph_creative_menu/files/features/world_rules/time_dt.lua' then return time_owner end - local prefix='mods/metamorph_creative_menu/' - if string.sub(p,1,#prefix)==prefix then return native_dofile(root..'/'..string.sub(p,#prefix+1)) end - return native_dofile(p) -end -function GameGetWorldStateEntity() return 1 end -function EntityGetFirstComponentIncludingDisabled(e,k) return e==1 and k=='WorldStateComponent' and 10 or 0 end -function ComponentGetValue2(c,f) assert(c==10); return values[f] end -function ComponentSetValue2(c,f,v) assert(c==10); values[f]=v end -function GameGetFrameNum() return frame end -METAMORPH_CREATIVE_MENU_WEATHER_SERVICE=nil; METAMORPH_CREATIVE_MENU_WEATHER_EDITOR=nil -local weather=assert(native_dofile(root..'/files/features/weather/service.lua')) -assert(weather.apply_preset('cloudy')==true,'CLOUDY preset failed') -assert(math.abs(values.rain-0.72)<1e-9 and math.abs(values.rain_target-0.72)<1e-9,'CLOUDY did not set cloud cover') -frame=101; weather.update() -assert(math.abs(values.rain-0.72)<1e-9 and math.abs(values.rain_target-0.72)<1e-9,'rainfall=0 temporarily zeroed CLOUDY cloud cover') -print('weather_clouds=PASS cloudy_cover_persists=true') diff --git a/metamorph_creative_menu/tests/weather_state_sync_mock.lua b/metamorph_creative_menu/tests/weather_state_sync_mock.lua index 07944be..975651d 100644 --- a/metamorph_creative_menu/tests/weather_state_sync_mock.lua +++ b/metamorph_creative_menu/tests/weather_state_sync_mock.lua @@ -4,15 +4,14 @@ local globals = {} local values = {time=0.12, time_dt=0.01, intro_weather=true, rain=0, rain_target=0, fog=0, fog_target=0, wind=0, wind_speed=0, lightning_count=0} local frame = 100 local is_host = false -local rain_updates, lightning_updates = 0, 0 local ew_runtime_stub = { enabled=function() return true end, mode=function() return is_host and "host" or "peer" end, } local runtime_effects_stub = { - emit_rain=function() rain_updates=rain_updates+1 end, - update_lightning=function() lightning_updates=lightning_updates+1 end, + emit_rain=function() end, + update_lightning=function() end, } dofile = function(path) @@ -73,10 +72,7 @@ globals.mcm_weather_remote_snapshot_v1 = storm_snapshot frame = frame + 1 weather.update() assert(weather.is_locked() == true, "remote weather snapshot was not adopted") -weather.update() -assert(rain_updates == 1 and lightning_updates == 1, - "duplicate same-frame weather call repeated particle/network-side runtime work") -local state = weather.debug_state() +local state = weather.state_snapshot() assert(state.fog == 0.5 and state.wind_speed == 48, "remote storm snapshot did not converge world fields") -- A peer taking over a remote active lock must retain its local pre-override time_dt. diff --git a/metamorph_creative_menu/tests/world_rules_click_deferred_scan_mock.lua b/metamorph_creative_menu/tests/world_rules_click_deferred_scan_mock.lua index aa0b25c..2ccbe81 100644 --- a/metamorph_creative_menu/tests/world_rules_click_deferred_scan_mock.lua +++ b/metamorph_creative_menu/tests/world_rules_click_deferred_scan_mock.lua @@ -15,7 +15,7 @@ local physics={ restore_rule=function() return true,"ok" end, reset_all=function() return true end, has_overrides=function() return false end, - debug_local_gravity=function() return {} end, + local_gravity_state=function() return {} end, has_persisted_local_recovery=function() return false end, recover_persisted_local=function() return true end, } @@ -32,7 +32,6 @@ local empty_adapter={ recover_persisted=function() return true end, } local stain={supported=function() return true end,apply=function() end,cleanup_stale=function() end,restore_all=function() return true end,has_overrides=function() return false end} -local time_dt={supported=function() return true end,apply_day_multiplier=function() return true,"ok" end,release_day_multiplier=function() return true,"ok" end,reassert=function() return true,"ok" end,has_day_override=function() return false end,has_persisted_recovery=function() return false end,recover_persisted=function() return true end} local stubs={ ["mods/metamorph_creative_menu/files/platform/noita/player_locator.lua"]={get=function() return 1 end}, ["mods/metamorph_creative_menu/files/platform/noita/input_guard.lua"]={heavy_updates_allowed=function() return true end}, @@ -43,7 +42,6 @@ local stubs={ ["mods/metamorph_creative_menu/files/features/world_rules/world_state.lua"]=empty_adapter, ["mods/metamorph_creative_menu/files/features/world_rules/stains.lua"]=stain, ["mods/metamorph_creative_menu/files/features/world_rules/magic_numbers.lua"]=empty_adapter, - ["mods/metamorph_creative_menu/files/features/world_rules/time_dt.lua"]=time_dt, } dofile=function(path) if stubs[path] ~= nil then return stubs[path] end diff --git a/metamorph_creative_menu/tests/world_rules_lifecycle_mock.lua b/metamorph_creative_menu/tests/world_rules_lifecycle_mock.lua index a4ac456..3411700 100644 --- a/metamorph_creative_menu/tests/world_rules_lifecycle_mock.lua +++ b/metamorph_creative_menu/tests/world_rules_lifecycle_mock.lua @@ -25,7 +25,7 @@ local physics = { restore_rule=function() physics_value=nil; return true,"ok" end, reset_all=function() reset_calls.physics=reset_calls.physics+1; physics_value=nil; return true end, has_overrides=function() return physics_value ~= nil end, - debug_local_gravity=function() return {} end, + local_gravity_state=function() return {} end, } local stain = { supported=function() return true end, apply=function() end, cleanup_stale=function() end, @@ -37,15 +37,6 @@ local magic = { reset_all=function() reset_calls.magic=reset_calls.magic+1; return true end, owns=function() return false end, has_overrides=function() return false end, } -local time_dt = { - supported=function() return true end, - apply_day_multiplier=function() return true,"ok" end, - release_day_multiplier=function() return true,"ok" end, - reassert=function() return true,"ok" end, - has_day_override=function() return false end, - has_persisted_recovery=function() return false end, - recover_persisted=function() return true end, -} local sync = { can_edit=function() return true,"ew_peer" end, mark_dirty=function() dirty_calls=dirty_calls+1 end, @@ -61,7 +52,6 @@ local stubs = { ["mods/metamorph_creative_menu/files/features/world_rules/world_state.lua"]=world_state, ["mods/metamorph_creative_menu/files/features/world_rules/stains.lua"]=stain, ["mods/metamorph_creative_menu/files/features/world_rules/magic_numbers.lua"]=magic, - ["mods/metamorph_creative_menu/files/features/world_rules/time_dt.lua"]=time_dt, } dofile = function(path) diff --git a/metamorph_creative_menu/tests/world_rules_physics_ownership_mock.lua b/metamorph_creative_menu/tests/world_rules_physics_ownership_mock.lua index 125ade6..ebf69fa 100644 --- a/metamorph_creative_menu/tests/world_rules_physics_ownership_mock.lua +++ b/metamorph_creative_menu/tests/world_rules_physics_ownership_mock.lua @@ -2,11 +2,9 @@ local root = assert(arg[1], "root required") local native_dofile = dofile local player = 1 local frame = 1 -local body_in_active_area = true -local body_in_restore_shell = true +local body_visible = true local body = { gravity=1.0, linear=0.2, angular=0.3 } local body_exists = true -local body_getter_calls = 0 local globals = {} function GlobalsGetValue(key, fallback) local v=globals[key]; if v==nil then return fallback end; return v end @@ -18,14 +16,10 @@ function EntityGetComponentIncludingDisabled() return {} end function EntityGetFilename() return "data/entities/player.xml" end function ComponentGetTypeName() return nil end function ComponentGetEntity() return nil end -function PhysicsBodyIDQueryBodies(min_x, min_y, max_x, max_y) - local half_width = (max_x - min_x) / 2 - if half_width <= 1024 then return body_in_active_area and {77} or {} end - return body_in_restore_shell and {77} or {} -end -function PhysicsBodyIDGetGravityScale(id) body_getter_calls=body_getter_calls+1; if id~=77 or not body_exists then error("gone") end; return body.gravity end +function PhysicsBodyIDQueryBodies() return body_visible and {77} or {} end +function PhysicsBodyIDGetGravityScale(id) if id~=77 or not body_exists then error("gone") end; return body.gravity end function PhysicsBodyIDSetGravityScale(id, value) if id~=77 or not body_exists then error("gone") end; body.gravity=value end -function PhysicsBodyIDGetDamping(id) body_getter_calls=body_getter_calls+1; if id~=77 or not body_exists then error("gone") end; return body.linear, body.angular end +function PhysicsBodyIDGetDamping(id) if id~=77 or not body_exists then error("gone") end; return body.linear, body.angular end function PhysicsBodyIDSetDamping(id, linear, angular) if id~=77 or not body_exists then error("gone") end; body.linear,body.angular=linear,angular end function PhysicsBodyIDApplyForce() end @@ -53,20 +47,20 @@ assert(math.abs(body.gravity-2.0)<1e-9, "gravity factor not applied") assert(math.abs(body.linear-0.4)<1e-9 and math.abs(body.angular-0.6)<1e-9, "damping factor not applied") -- Leaving the active neighbourhood must restore before releasing ownership. -frame=2; body_in_active_area=false; body_in_restore_shell=true +frame=2; body_visible=false physics.scan(player, 2.0, 2.0, frame) assert(math.abs(body.gravity-1.0)<1e-9, "offscreen body kept creative gravity") assert(math.abs(body.linear-0.2)<1e-9 and math.abs(body.angular-0.3)<1e-9, "offscreen body kept creative damping") assert(physics.has_overrides()==false, "offscreen restored body remained owned") -- Re-entering must capture the real native value again, not compound 2x -> 4x. -frame=3; body_in_active_area=true; body_in_restore_shell=true +frame=3; body_visible=true physics.scan(player, 2.0, 2.0, frame) assert(math.abs(body.gravity-2.0)<1e-9, "re-entry compounded gravity instead of reapplying from native") assert(math.abs(body.linear-0.4)<1e-9, "re-entry compounded damping instead of reapplying from native") -- Rule reset must restore a tracked body by ID even when the current spatial query no longer sees it. -body_in_active_area=false; body_in_restore_shell=true +body_visible=false local restored_g, reason_g=physics.restore_rule("physics_gravity") assert(restored_g==true and reason_g=="ok", "offscreen gravity restore failed") assert(math.abs(body.gravity-1.0)<1e-9, "restore_rule did not restore offscreen body gravity") @@ -79,14 +73,4 @@ assert(restored_d==true and reason_d=="ok", "offscreen damping restore failed") assert(math.abs(body.linear-0.2)<1e-9 and math.abs(body.angular-0.3)<1e-9, "restore_rule did not restore offscreen damping") assert(physics.has_overrides()==false, "restored body ownership leaked") --- A body destroyed between scans must be forgotten without invoking any native --- PhysicsBodyID getter. Those getters emit engine errors even when wrapped in pcall. -frame=4; body_exists=true; body_in_active_area=true; body_in_restore_shell=true -physics.scan(player, 2.0, 2.0, frame) -body_exists=false; body_in_active_area=false; body_in_restore_shell=false -local before_destroyed_cleanup=body_getter_calls -frame=5; physics.scan(player, 2.0, 2.0, frame) -assert(body_getter_calls==before_destroyed_cleanup, "stale native body id was queried during cleanup") -assert(physics.has_overrides()==false, "destroyed body ownership leaked") - -io.write("world_rules_physics_ownership=PASS offscreen_restore=true reentry_no_compound=true separate_rule_state=true stale_id_not_queried=true\n") +io.write("world_rules_physics_ownership=PASS offscreen_restore=true reentry_no_compound=true separate_rule_state=true\n") diff --git a/metamorph_creative_menu/tests/world_rules_restart_recovery_mock.lua b/metamorph_creative_menu/tests/world_rules_restart_recovery_mock.lua index cd939be..4e6cc65 100644 --- a/metamorph_creative_menu/tests/world_rules_restart_recovery_mock.lua +++ b/metamorph_creative_menu/tests/world_rules_restart_recovery_mock.lua @@ -122,6 +122,6 @@ assert(service.choice_index(service.rules()[1])==1 and service.choice_index(serv assert(service.has_overrides()==false,"restart recovery left in-memory rule ownership") assert(dirty_calls==0,"startup recovery was incorrectly published as a user Rules edit") assert(network_updates==1,"normal network sync did not continue after recovery") -assert(diagnostics==1,"restart recovery was not visible to diagnostics") +assert(diagnostics==0,"restart recovery unexpectedly called diagnostics from player runtime") io.write("world_rules_restart_recovery=PASS relations=0 pixel_gravity=350 recovery_clean=true\n") diff --git a/metamorph_creative_menu/tests/world_rules_time_dt_owner_mock.lua b/metamorph_creative_menu/tests/world_rules_time_dt_owner_mock.lua deleted file mode 100644 index 78cb4f8..0000000 --- a/metamorph_creative_menu/tests/world_rules_time_dt_owner_mock.lua +++ /dev/null @@ -1,72 +0,0 @@ -local root = assert(arg[1], "root required") -local native_dofile = dofile -local globals = {} -local time_dt = 0.01 - -local function nearly(a, b) return math.abs((tonumber(a) or 0) - (tonumber(b) or 0)) < 0.0000001 end - -dofile = function(path) - local prefix = "mods/metamorph_creative_menu/" - if string.sub(path, 1, #prefix) == prefix then return native_dofile(root .. "/" .. string.sub(path, #prefix + 1)) end - return native_dofile(path) -end -function GlobalsGetValue(key, fallback) local value=globals[key]; if value==nil then return fallback end; return value end -function GlobalsSetValue(key, value) globals[key]=tostring(value) end -function GameGetWorldStateEntity() return 1 end -function EntityGetFirstComponentIncludingDisabled(entity, kind) - if entity==1 and kind=="WorldStateComponent" then return 10 end - return 0 -end -function ComponentGetValue2(component, field) - assert(component==10 and field=="time_dt") - return time_dt -end -function ComponentSetValue2(component, field, value) - assert(component==10 and field=="time_dt") - time_dt=tonumber(value) -end - -METAMORPH_CREATIVE_MENU_WORLD_RULE_RECOVERY=nil -METAMORPH_CREATIVE_MENU_TIME_DT_OWNER=nil -local owner=assert(native_dofile(root.."/files/features/world_rules/time_dt.lua")) - -local ok=owner.apply_day_multiplier(2) -assert(ok==true and nearly(time_dt,0.02),"Day Speed did not use baseline*time multiplier") -ok=owner.set_weather_frozen(true) -assert(ok==true and nearly(time_dt,0),"Weather did not temporarily override Day Speed with time_dt=0") -ok=owner.apply_day_multiplier(4) -assert(ok==true and nearly(time_dt,0),"changing Day Speed while Weather freeze is active leaked through freeze priority") -ok=owner.set_weather_frozen(false) -assert(ok==true and nearly(time_dt,0.04),"Weather release did not resume the latest Day Speed multiplier") - --- RESET/NATIVE must be compare-and-swap. A later write from another mod wins. -time_dt=0.123 -ok=owner.release_day_multiplier() -assert(ok==true and nearly(time_dt,0.123),"Day Speed RESET overwrote a newer external time_dt write") -assert(owner.has_persisted_recovery()==false,"CAS external-preserve path left stale recovery metadata") - --- A clean release restores the baseline captured for this ownership session. -ok=owner.apply_day_multiplier(0.5) -assert(ok==true and nearly(time_dt,0.0615),"new Day Speed session captured the wrong baseline") -ok=owner.release_day_multiplier() -assert(ok==true and nearly(time_dt,0.123),"clean Day Speed release did not restore its baseline") - --- Crash/reload recovery restores only a value that still equals MCM's last verified write. -ok=owner.apply_day_multiplier(2) -assert(ok==true and nearly(time_dt,0.246),"pre-reload Day Speed write failed") -METAMORPH_CREATIVE_MENU_TIME_DT_OWNER=nil -local owner2=assert(native_dofile(root.."/files/features/world_rules/time_dt.lua")) -assert(owner2.has_persisted_recovery()==true,"time_dt recovery state was not persisted") -ok=owner2.recover_persisted() -assert(ok==true and nearly(time_dt,0.123),"startup recovery did not restore owned time_dt") - -ok=owner2.apply_day_multiplier(2) -assert(ok==true and nearly(time_dt,0.246),"second ownership session failed") -time_dt=0.777 -METAMORPH_CREATIVE_MENU_TIME_DT_OWNER=nil -local owner3=assert(native_dofile(root.."/files/features/world_rules/time_dt.lua")) -ok=owner3.recover_persisted() -assert(ok==true and nearly(time_dt,0.777),"startup recovery overwrote a newer external time_dt write") -assert(owner3.has_persisted_recovery()==false,"external startup CAS path left stale recovery") - -io.write("world_rules_time_dt_owner=PASS multiplier=true weather_priority=true cas=true restart_recovery=true\n") diff --git a/metamorph_creative_menu/tests/world_rules_ui_action_isolation_mock.lua b/metamorph_creative_menu/tests/world_rules_ui_action_isolation_mock.lua index 165c8fc..c784b70 100644 --- a/metamorph_creative_menu/tests/world_rules_ui_action_isolation_mock.lua +++ b/metamorph_creative_menu/tests/world_rules_ui_action_isolation_mock.lua @@ -4,8 +4,6 @@ local tile_calls=0 local white_labels={} local step_calls=0 local click_once=true -local diagnostics=0 -local prints=0 local service={ can_edit=function() return true,"singleplayer" end, has_overrides=function() return false end, @@ -28,20 +26,8 @@ local ui={ button=function() return false end, search_input=function(value) return value end, columns=function() return 4 end, - scroll_height=function() return 100 end, + grid_height=function() return 100 end, mark_hovered=function() end, - begin_scroll_viewport=function(_,_,_,_,width,height) return {content_width=math.max(20,(tonumber(width) or 100)-12),padding_left=2,padding_top=2,state={offset=0},height=height} end, - scroll_y=function(_,y) return y end, end_scroll_viewport=function() end, - confirm_button=function() return false end, - report_error_once=function(_,_,reason,scope) - if scope=="ui.rules.action" and string.find(tostring(reason),"intentional Rules mutation failure",1,true) then - diagnostics=diagnostics+1; prints=prints+1 - end - return true - end, - clear_error_notice=function() end, - rank_entries=function(_,entries) return entries end, - search_status=function() end, matches_search=function() return true end, tile=function() tile_calls=tile_calls+1 @@ -71,10 +57,8 @@ local ok2,err2=pcall(tab.draw,nil,220,180) assert(ok1 and ok2,"Rules action exception escaped tab draw: "..tostring(err1 or err2)) assert(step_calls==1,"probe did not execute exactly one failing Rules mutation") assert(tile_calls==2,"Rules grid stopped drawing after a mutation error") -assert(diagnostics==1,"Rules mutation exception was not reported to diagnostics") -for _,text in ipairs(white_labels) do - assert(not string.find(text,"RULE ERROR:",1,true),"Rules tab kept a stale permanent error row") -end -assert(prints==1,"Rules error was not reported exactly once to the transient user channel") +local saw_error=false +for _,text in ipairs(white_labels) do if string.find(text,"RULE ERROR:",1,true) then saw_error=true end end +assert(saw_error,"Rules tab did not surface bounded mutation failure") -io.write("world_rules_ui_action_isolation=PASS draw_survives=true second_frame=true transient_error=true\n") +io.write("world_rules_ui_action_isolation=PASS draw_survives=true second_frame=true\n") diff --git a/metamorph_creative_menu/tools/build_release.py b/metamorph_creative_menu/tools/build_release.py deleted file mode 100644 index ebaea39..0000000 --- a/metamorph_creative_menu/tools/build_release.py +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env python3 -"""Build the complete Metamorph: Creative Menu development/source archive.""" - -from __future__ import annotations - -import argparse -import re -import sys -import zipfile -from pathlib import Path - -ROOT_NAME = "metamorph_creative_menu" -EXCLUDED_DIRECTORIES = {"dist"} -EXCLUDED_DIRECTORY_NAMES = {"__pycache__", ".pytest_cache", ".git"} -EXCLUDED_FILENAMES = {".DS_Store", "Thumbs.db"} -EXCLUDED_SUFFIXES = {".pyc", ".pyo"} -REQUIRED_FILES = { - "BUILD.txt", - "CHANGELOG.txt", - "VERSION.txt", - "README.txt", - "LICENSE.txt", - "NOTICE.txt", - "compatibility.xml", - "dev_mode.lua", - "init.lua", - "mod.xml", - "mod_id.txt", - "settings.lua", - "translations.csv", - "NoitaPatcher/load.lua", - "NoitaPatcher/noitapatcher.dll", - "mcm_native_gameover.dll", - "mcm_native_gameover.lib", - "native_src/mcm_native_gameover.c", - "native_src/README.md", - "native_src/build_msvc.bat", - "files/diagnostics/service.lua", - "files/qa/controller.lua", - "tests/run_all.py", - "tests/TESTING.txt", - "tools/build_release.py", -} - - -def is_excluded(relative: Path) -> bool: - if relative.name in EXCLUDED_FILENAMES or relative.suffix.lower() in EXCLUDED_SUFFIXES: - return True - if any(part in EXCLUDED_DIRECTORY_NAMES for part in relative.parts): - return True - normalized = relative.as_posix() - return any(normalized == directory or normalized.startswith(directory + "/") for directory in EXCLUDED_DIRECTORIES) - - -def source_files(root: Path) -> list[Path]: - files = [] - for path in root.rglob("*"): - if path.is_symlink(): - raise RuntimeError(f"source tree contains a symlink: {path.relative_to(root)}") - if path.is_file() and not is_excluded(path.relative_to(root)): - files.append(path) - return sorted(files, key=lambda path: path.relative_to(root).as_posix()) - - -def validate(root: Path, files: list[Path]) -> str: - relative_files = {path.relative_to(root).as_posix() for path in files} - missing = sorted(REQUIRED_FILES - relative_files) - if missing: - raise RuntimeError("missing source files: " + ", ".join(missing)) - - patch_dir = root / "tools" / "player_release" - if not patch_dir.is_dir() or not any(patch_dir.glob("*.patch")): - raise RuntimeError("missing player release cleanup patches") - - version = (root / "VERSION.txt").read_text(encoding="utf-8").strip() - if re.fullmatch(r"[0-9]+(?:\.[0-9]+){0,2}", version) is None: - raise RuntimeError(f"invalid VERSION.txt: {version!r}") - if (root / "mod_id.txt").read_text(encoding="utf-8").strip() != ROOT_NAME: - raise RuntimeError("mod_id.txt does not match the mod directory name") - return version - - -def build(root: Path, output: Path) -> tuple[str, int, int]: - files = source_files(root) - version = validate(root, files) - output.parent.mkdir(parents=True, exist_ok=True) - if output.exists(): - output.unlink() - - total_bytes = 0 - with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: - for path in files: - relative = path.relative_to(root).as_posix() - data = path.read_bytes() - total_bytes += len(data) - info = zipfile.ZipInfo(f"{ROOT_NAME}/{relative}", (2020, 1, 1, 0, 0, 0)) - info.compress_type = zipfile.ZIP_DEFLATED - info.external_attr = 0o100644 << 16 - info.create_system = 3 - archive.writestr(info, data, compress_type=zipfile.ZIP_DEFLATED, compresslevel=9) - - with zipfile.ZipFile(output) as archive: - broken = archive.testzip() - if broken is not None: - raise RuntimeError(f"archive CRC verification failed: {broken}") - return version, len(files), total_bytes - - -def main() -> int: - root = Path(__file__).resolve().parent.parent - version = (root / "VERSION.txt").read_text(encoding="utf-8").strip() - major = version.split(".", 1)[0] if version else "source" - - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("output", nargs="?", type=Path, - default=root / "dist" / f"Metamorph-Creative-Menu-v{major}.zip") - args = parser.parse_args() - try: - built_version, count, total_bytes = build(root, args.output.resolve()) - except Exception as exc: - print(f"SOURCE_BUILD=FAIL {exc}", file=sys.stderr) - return 1 - print( - f"SOURCE_BUILD=PASS version={built_version} files={count} " - f"source_bytes={total_bytes} output={args.output}" - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/metamorph_creative_menu/tools/player_release/00-runtime-01.patch b/metamorph_creative_menu/tools/player_release/00-runtime-01.patch deleted file mode 100644 index 2f20a59..0000000 --- a/metamorph_creative_menu/tools/player_release/00-runtime-01.patch +++ /dev/null @@ -1,140 +0,0 @@ ---- metamorph_creative_menu/NoitaPatcher/noitapatcher/nsew/world_ffi.lua -+++ metamorph_creative_menu/NoitaPatcher/noitapatcher/nsew/world_ffi.lua -@@ -32,7 +32,7 @@ - struct Position bottom_right; - }; - --struct std_string { /* VC++ std::string */ -+struct std_string { - char *buffer; - char sso_buffer[12]; - size_t size; -@@ -162,7 +162,6 @@ - bool show_in_creative_mode; - bool is_just_particle_fx; - char padding9[2]; -- // struct grid_CosmeticParticleConfig *ParticleEffect; - }; - - enum CellType { -@@ -217,7 +216,6 @@ - void* field40_0xa0; - }; - --// In the Noita code this would be the ICellBurnable class - struct Cell { - struct Cell_vtable* vtable; - ---- metamorph_creative_menu/files/features/companion/player_clone.xml -+++ metamorph_creative_menu/files/features/companion/player_clone.xml -@@ -45,7 +45,5 @@ - - - -- - - ---- metamorph_creative_menu/files/features/creatures/service.lua -+++ metamorph_creative_menu/files/features/creatures/service.lua -@@ -2,7 +2,6 @@ - local classification = dofile("mods/metamorph_creative_menu/files/features/creatures/classification.lua") - local compatibility = dofile("mods/metamorph_creative_menu/files/features/creatures/compatibility.lua") - local transform_routing = dofile("mods/metamorph_creative_menu/files/features/creatures/transform_routing.lua") --local creature_diagnostics = dofile("mods/metamorph_creative_menu/files/features/creatures/diagnostics.lua") - local catalog_builder = dofile("mods/metamorph_creative_menu/files/features/creatures/catalog_builder.lua") - local ew_world_entities = dofile("mods/metamorph_creative_menu/files/integrations/ew/world_entities.lua") - local spawn_compat = dofile("mods/metamorph_creative_menu/files/features/creatures/spawn_compat.lua") -@@ -62,14 +61,6 @@ - function creature_service.warmup_step(budget) return catalog_builder.warmup_step(budget) end - function creature_service.catalog_version() return catalog_builder.catalog_version() end - --function creature_service.collect_diagnostics() -- return creature_diagnostics.collect(creature_service) --end -- --function creature_service.diagnostic_info_for_path(path) -- return creature_diagnostics.info(creature_service, path) --end -- - function creature_service.collect_prewarm_candidates() - - local result, seen = {}, {} -@@ -107,8 +98,6 @@ - - return tonumber(context.entity), "existing_kolmi" - end -- elseif not ok_before and type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then -- pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "creature.spawn_preflight", tostring(context)) - end - end - -@@ -116,20 +105,10 @@ - if entity == 0 then return 0, "load" end - - if type(spawn_compat) == "table" and type(spawn_compat.after_spawn) == "function" then -- local ok_compat, compat_ok, compat_reason = pcall(spawn_compat.after_spawn, entity_path, entity, tonumber(x), tonumber(y), spawn_context) -- if not ok_compat or compat_ok ~= true then -- if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then -- pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "creature.spawn_compat", -- ok_compat and tostring(compat_reason or "compat_failed") or tostring(compat_ok)) -- end -- end -+ pcall(spawn_compat.after_spawn, entity_path, entity, tonumber(x), tonumber(y), spawn_context) - end - -- local call_ok, tracked, track_reason = pcall(ew_world_entities.track, entity, {creative_creature=true}) -- if (not call_ok or tracked ~= true) and type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then -- pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "creature.ew_track", -- call_ok and tostring(track_reason or "track_failed") or tostring(tracked)) -- end -+ pcall(ew_world_entities.track, entity, {creative_creature=true}) - return entity, "spawned" - end - ---- metamorph_creative_menu/files/features/creatures/spawn_compat.lua -+++ metamorph_creative_menu/files/features/creatures/spawn_compat.lua -@@ -11,16 +11,7 @@ - - local ARENA_BOSS_RADIUS_SQ = 192 * 192 - local ARENA_REFERENCE_RADIUS_SQ = 768 * 768 --local REPLACED_COUNT = "mcm30_kolmi_existing_arena_root_replaced_v1" --local REUSED_REMOTE_COUNT = "mcm30_kolmi_existing_remote_root_reused_v1" --local REUSED_REFERENCE_COUNT = "mcm30_kolmi_existing_reference_reused_v1" -- --local function bump(key) -- if type(GlobalsGetValue) ~= "function" or type(GlobalsSetValue) ~= "function" then return end -- local ok, value = pcall(GlobalsGetValue, key, "0") -- value = ok and (tonumber(value) or 0) or 0 -- pcall(GlobalsSetValue, key, tostring(value + 1)) --end -+ - - local function alive(entity) - entity = tonumber(entity) or 0 -@@ -197,19 +188,15 @@ - local reference = nearest_reference(x, y) - local existing = existing_authored_kolmi(x, y) - if existing == 0 then -- if reference ~= 0 then bump(REUSED_REFERENCE_COUNT) end - return { kind = "kolmi", reference_id = reference } - end - - local retired, reason = quiet_retire_existing(existing) - if retired then -- bump(REPLACED_COUNT) -- if reference ~= 0 then bump(REUSED_REFERENCE_COUNT) end - return { kind = "kolmi", reference_id = reference, replaced_entity = existing } - end - - if reason == "remote_authority" then -- bump(REUSED_REMOTE_COUNT) - return { kind = "reuse", entity = existing, reference_id = reference, reason = reason } - end - -@@ -247,4 +234,5 @@ - spawn_compat.CREATIVE_REF_TAG = CREATIVE_REF_TAG - spawn_compat.BOOTSTRAP = BOOTSTRAP - spawn_compat.CONTROLLER = CONTROLLER -+ - return spawn_compat diff --git a/metamorph_creative_menu/tools/player_release/00-runtime-02.patch b/metamorph_creative_menu/tools/player_release/00-runtime-02.patch deleted file mode 100644 index e90a0be..0000000 --- a/metamorph_creative_menu/tools/player_release/00-runtime-02.patch +++ /dev/null @@ -1,86 +0,0 @@ ---- metamorph_creative_menu/files/features/forms/corpse_service.lua -+++ metamorph_creative_menu/files/features/forms/corpse_service.lua -@@ -14,12 +14,6 @@ - - local function ew_active() - return ew_runtime.enabled() == true --end -- --local function diagnostic_event(kind, details) -- if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_EVENT) == "function" then -- pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_EVENT, tostring(kind or "FORM"), tostring(details or "")) -- end - end - - local CORPSE_PLAYER_TAGS = { -@@ -172,8 +166,7 @@ - end - end) - if EntityGetIsAlive(entity) then pcall(EntityKill, entity) end -- diagnostic_event("FORM NETWORK BODY RETIRED", string.format("entity=%s source=%s reason=%s", -- tostring(entity), tostring(source_path or ""), tostring(reason or "death"))) -+ - return true - end - -@@ -209,10 +202,7 @@ - born=frame, next_index=1, checks={1,3,10,60}, - } - local gid = corpse_des_gid(entity) -- diagnostic_event("FORM CORPSE PENDING", string.format("entity=%s source=%s pos=%.1f,%.1f hp=%s/%s ew_synced=%s des=%s gid=%s release=%s", -- tostring(entity), tostring(source_path or ""), tonumber(x) or 0, tonumber(y) or 0, -- tostring(hp), tostring(max_hp), tostring(EntityHasTag(entity, "ew_synced")), -- tostring(EntityHasTag(entity, "ew_des")), tostring(gid or ""), tostring(frame + 1))) -+ - return true - end - -@@ -224,8 +214,7 @@ - local entity = tonumber(record.entity) or 0 - local alive = entity ~= 0 and EntityGetIsAlive(entity) - if not alive then -- diagnostic_event("FORM CORPSE FINALIZED", string.format("entity=%s source=%s age=%s native_death=true", -- tostring(entity), tostring(record.source or ""), tostring(frame - (tonumber(record.born) or frame)))) -+ - table.remove(pending_corpse_finalize, index) - elseif frame >= (tonumber(record.release_frame) or frame) and record.released ~= true then - local damage = EntityGetFirstComponentIncludingDisabled(entity, "DamageModelComponent") -@@ -235,9 +224,7 @@ - record.released = true - record.release_frame = frame - pcall(EntityRemoveTag, entity, "metamorph_creative_menu_form_corpse_pending") -- diagnostic_event("FORM CORPSE RELEASE", string.format("entity=%s source=%s hp=%s/%s des=%s gid=%s", -- tostring(entity), tostring(record.source or ""), tostring(corpse_health(entity)), -- select(2, corpse_health(entity)), tostring(EntityHasTag(entity, "ew_des")), tostring(corpse_des_gid(entity) or ""))) -+ - elseif record.released == true and frame - (tonumber(record.release_frame) or frame) >= 3 and record.forced ~= true then - - local damage = EntityGetFirstComponentIncludingDisabled(entity, "DamageModelComponent") -@@ -249,13 +236,12 @@ - end - freeze_pending_corpse(entity) - record.forced = true -- diagnostic_event("FORM CORPSE FORCE_RELEASE", string.format("entity=%s source=%s", tostring(entity), tostring(record.source or ""))) -+ - elseif record.released == true and frame - (tonumber(record.release_frame) or frame) >= 20 then - - local x, y = EntityGetTransform(entity) - local fallback = spawn_native_corpse_fallback(record, x, y) -- diagnostic_event("FORM CORPSE FALLBACK", string.format("old=%s fallback=%s source=%s", -- tostring(entity), tostring(fallback), tostring(record.source or ""))) -+ - if EntityGetIsAlive(entity) then pcall(EntityKill, entity) end - if fallback ~= 0 then - pending_corpse_watch[#pending_corpse_watch + 1] = { -@@ -278,10 +264,7 @@ - local x, y, hp, max_hp = 0, 0, nil, nil - if alive then x, y = EntityGetTransform(entity); hp, max_hp = corpse_health(entity) end - local gid = alive and corpse_des_gid(entity) or nil -- diagnostic_event("FORM CORPSE WATCH", string.format("age=%s entity=%s alive=%s source=%s pos=%.1f,%.1f hp=%s/%s sync_request=%s des=%s gid=%s", -- tostring(check_at), tostring(entity), tostring(alive), tostring(record.source or ""), tonumber(x) or 0, tonumber(y) or 0, -- tostring(hp), tostring(max_hp), tostring(alive and EntityHasTag(entity, "ew_synced") or false), -- tostring(alive and EntityHasTag(entity, "ew_des") or false), tostring(gid or ""))) -+ - record.next_index = (record.next_index or 1) + 1 - if not alive or record.next_index > #checks then table.remove(pending_corpse_watch, index) end - end diff --git a/metamorph_creative_menu/tools/player_release/00-runtime-03.patch b/metamorph_creative_menu/tools/player_release/00-runtime-03.patch deleted file mode 100644 index 642c373..0000000 --- a/metamorph_creative_menu/tools/player_release/00-runtime-03.patch +++ /dev/null @@ -1,195 +0,0 @@ ---- metamorph_creative_menu/files/features/forms/manager.lua -+++ metamorph_creative_menu/files/features/forms/manager.lua -@@ -48,18 +48,6 @@ - local runtime_error_log = {} - local RUNTIME_ERROR_REPEAT_FRAMES = 600 - --local function diagnostic_event(kind, details) -- if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_EVENT) == "function" then -- pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_EVENT, tostring(kind or "FORM"), tostring(details or "")) -- end --end -- --local function real_time_ms() -- if type(GameGetRealWorldTimeSinceStarted) ~= "function" then return nil end -- local ok, value = pcall(GameGetRealWorldTimeSinceStarted) -- return ok and tonumber(value) and tonumber(value) * 1000 or nil --end -- - local function protected_runtime_call(label, fn, ...) - if type(fn) ~= "function" then return false end - local ok, err = pcall(fn, ...) -@@ -74,9 +62,6 @@ - local last = runtime_error_log[signature] - if last == nil or frame - last >= RUNTIME_ERROR_REPEAT_FRAMES then - runtime_error_log[signature] = frame -- if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then -- pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "form." .. tostring(label), message) -- end - print("[Metamorph: Creative Menu] " .. tostring(label) .. " failed: " .. message) - end - return false -@@ -174,9 +159,7 @@ - orphan_probe_complete = true - orphan_probe_entity = current - form_manager.ensure_runtime_hooks({ bootstrap_if_installed = true }) -- diagnostic_event(owned_marker and "FORM RECOVERED" or "FORM ADOPTED", -- string.format("entity=%s target=%s backup=%s", tostring(current), tostring(target), -- tostring(session.allow_death_handoff))) -+ - return true - end - -@@ -269,13 +252,8 @@ - - function form_manager.handle_form_death(old_form, reason, responsible, damage, projectile) - old_form = tonumber(old_form) or 0 -- local death_started_ms = real_time_ms() - local source_path = session ~= nil and tostring(session.requested_target or session.target or "") or "" -- local x0, y0 = 0, 0 -- if old_form ~= 0 and EntityGetIsAlive(old_form) then x0, y0 = EntityGetTransform(old_form) end -- diagnostic_event("FORM DEATH", string.format("entity=%s source=%s reason=%s responsible=%s damage=%s projectile=%s pos=%.1f,%.1f", -- tostring(old_form), source_path, tostring(reason or "death"), tostring(responsible or 0), tostring(damage), tostring(projectile or 0), -- tonumber(x0) or 0, tonumber(y0) or 0)) -+ - if session == nil then return false end - - if session.kind ~= "polymorph" -@@ -321,10 +299,7 @@ - pending_return_frame = nil - protected_runtime_call("form_runtime.reset", form_runtime.reset) - local corpse_detached = detach_dead_form_as_corpse(old_form, source_path, reason, responsible) -- local death_finished_ms = real_time_ms() -- diagnostic_event("FORM HANDOFF", string.format("old=%s restored=%s corpse_detached=%s elapsed_ms=%s", -- tostring(old_form), tostring(restored), tostring(corpse_detached), -- death_started_ms ~= nil and death_finished_ms ~= nil and string.format("%.2f", death_finished_ms-death_started_ms) or "nil")) -+ - return true - end - -@@ -454,9 +429,7 @@ - transition_queue.clear() - return false, reason - end -- diagnostic_event("FORM SWITCH QUEUED", string.format("from=%s to=%s frame=%s", -- tostring(session ~= nil and (session.requested_target or session.target) or "external"), -- tostring(entity_path), tostring(GameGetFrameNum()))) -+ - return true, "queued_after_human" - end - -@@ -503,7 +476,6 @@ - pcall(ComponentSetValue2, component, "frames", 1) - end - pending_return_frame = GameGetFrameNum() -- if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_USER_ACTION) == "function" then pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_USER_ACTION, "form.return_binding", "result=true") end - return true - end - -@@ -516,8 +488,7 @@ - if pending == nil then return false end - if pending.human_frame == nil then - transition_queue.mark_human(frame) -- diagnostic_event("FORM SWITCH HUMAN FRAME", string.format("entity=%s target=%s frame=%s", -- tostring(current), tostring(pending.entity_path), tostring(frame))) -+ - return false - end - if not transition_queue.ready(frame) then return false end -@@ -526,12 +497,10 @@ - if request == nil then return false end - local ok, reason = start_transform_creature(current, request.entity_path, request.frames, request.options) - if not ok then -- diagnostic_event("FORM SWITCH FAILED", string.format("target=%s reason=%s frame=%s", -- tostring(request.entity_path), tostring(reason), tostring(frame))) -- return false -- end -- diagnostic_event("FORM SWITCH START", string.format("target=%s human_frame=%s frame=%s", -- tostring(request.entity_path), tostring(request.human_frame), tostring(frame))) -+ -+ return false -+ end -+ - return true - end - -@@ -540,7 +509,7 @@ - restore_transform_flash_suppression(false) - - if transition_queue.expire(frame) then -- diagnostic_event("FORM SWITCH EXPIRED", string.format("frame=%s", tostring(frame))) -+ - end - corpse_service.update() - -@@ -596,8 +565,7 @@ - if session.phase == "active" then protected_runtime_call("form_runtime.update", form_runtime.update, current, session) end - elseif #components == 0 then - local controls_restored = restore_human_controls(current) -- diagnostic_event("FORM RETURN", string.format("entity=%s controls_restored=%s path=native", -- tostring(current), tostring(controls_restored))) -+ - session = nil - pending_return_frame = nil - protected_runtime_call("form_runtime.reset", form_runtime.reset) -@@ -611,8 +579,7 @@ - if #components == 0 then - if pending_return_frame ~= nil then - local controls_restored = restore_human_controls(current) -- diagnostic_event("FORM RETURN", string.format("entity=%s controls_restored=%s path=unowned", -- tostring(current), tostring(controls_restored))) -+ - end - pending_return_frame = nil - if transition_queue.has_pending() and human_entity_ready(current) then ---- metamorph_creative_menu/files/features/possession/keybinds.lua -+++ metamorph_creative_menu/files/features/possession/keybinds.lua -@@ -28,7 +28,6 @@ - if not input_guard.actions_allowed() or inventory_open() or menu_open == true then return false end - if not action_bindings.consume("possession") then return false end - local success, reason = possession.possess_under_cursor(player_locator.get()) -- if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_USER_ACTION) == "function" then pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_USER_ACTION, "possession", "binding="..tostring(action_bindings.get("possession")).." result="..tostring(success).." reason="..tostring(reason)) end - if not success then - if reason == "no_target" then GamePrint(GameTextGetTranslatedOrNot("$mcm_possess_no_target")) - else GamePrint(GameTextGetTranslatedOrNot("$mcm_possess_failed") .. ": " .. tostring(reason or "")) end ---- metamorph_creative_menu/files/features/spells/slot_settler.lua -+++ metamorph_creative_menu/files/features/spells/slot_settler.lua -@@ -22,13 +22,6 @@ - local ok, x, y = pcall(ComponentGetValue2, item, "inventory_slot") - if not ok then return nil end - return item, tonumber(x), tonumber(y) or 0 --end -- --local function report(label, reason) -- if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then -- pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, -- "spells.slot_settle", tostring(label or "drag") .. ":" .. tostring(reason or "failed")) -- end - end - - local function inspect(commit) -@@ -184,12 +177,12 @@ - - commit.frame = frame - else -- report(commit.label, repair_reason) -+ - table.remove(pending, index) - failed = failed + 1 - end - else -- report(commit.label, reason) -+ - table.remove(pending, index) - failed = failed + 1 - end ---- metamorph_creative_menu/files/features/weather/runtime_effects.lua -+++ metamorph_creative_menu/files/features/weather/runtime_effects.lua -@@ -11,7 +11,6 @@ - - pcall(GameEmitRainParticles, particle_count, 1024, "water", 200, 220, 200, false, true) - state.last_rain_emit_frame = frame -- state.rain_emitted_total = (tonumber(state.rain_emitted_total) or 0) + particle_count - end - - function runtime_effects.update_lightning(state, world_component, write_value) diff --git a/metamorph_creative_menu/tools/player_release/00-runtime-04.patch b/metamorph_creative_menu/tools/player_release/00-runtime-04.patch deleted file mode 100644 index 03dbc63..0000000 --- a/metamorph_creative_menu/tools/player_release/00-runtime-04.patch +++ /dev/null @@ -1,168 +0,0 @@ ---- metamorph_creative_menu/files/features/weather/service.lua -+++ metamorph_creative_menu/files/features/weather/service.lua -@@ -16,7 +16,6 @@ - next_lightning_frame = 0, - lightning_clear_frame = 0, - last_rain_emit_frame = -1, -- rain_emitted_total = 0, - rain_stop_guard_until = 0, - last_lightning_update_frame = -1, - last_full_update_frame = -1, -@@ -301,24 +300,6 @@ - end - end - --function weather_service.debug_state() -- local component = world_component() -- local result = { -- active = state.active == true, -- rainfall = tonumber(state.rainfall) or 0, -- lightning = tonumber(state.lightning) or 0, -- last_rain_emit_frame = tonumber(state.last_rain_emit_frame) or -1, -- rain_emitted_total = tonumber(state.rain_emitted_total) or 0, -- rain_stop_guard_until = tonumber(state.rain_stop_guard_until) or 0, -- } -- if component ~= nil then -- for _, field_name in ipairs({ "rain", "rain_target", "fog", "fog_target", "wind", "wind_speed" }) do -- local read_succeeded, value = pcall(ComponentGetValue2, component, field_name) -- if read_succeeded then result[field_name] = tonumber(value) or value end -- end -- end -- return result --end - - METAMORPH_CREATIVE_MENU_WEATHER_SERVICE = weather_service - ---- metamorph_creative_menu/files/features/world_rules/physics.lua -+++ metamorph_creative_menu/files/features/world_rules/physics.lua -@@ -630,23 +630,6 @@ - return recovery.has("player_gravity") - end - --function physics_adapter.debug_local_gravity(player, factor) -- local rows = {} -- if player == nil or player == 0 or not EntityGetIsAlive(player) then return {player=0, factor=factor, rows=rows} end -- for _, pair in ipairs({{"CharacterDataComponent","gravity"},{"CharacterPlatformingComponent","pixel_gravity"}}) do -- for _, component_id in ipairs(EntityGetComponentIncludingDisabled(player, pair[1]) or {}) do -- local key = local_gravity_key(component_id, pair[2]) -- local baseline = physics_state.local_gravity_native[key] -- local read_succeeded, current_value = pcall(ComponentGetValue2, component_id, pair[2]) -- rows[#rows + 1] = { -- component=component_id, field=pair[2], current=read_succeeded and tonumber(current_value) or nil, -- native=baseline and baseline.native or nil, -- expected=(baseline and factor) and rule_math.scaled(baseline.native, factor) or nil, -- } -- end -- end -- return {player=player, factor=factor, rows=rows} --end - - function physics_adapter.has_gravity_overrides() - for _, record in pairs(physics_state.bodies) do ---- metamorph_creative_menu/files/features/world_rules/service.lua -+++ metamorph_creative_menu/files/features/world_rules/service.lua -@@ -67,10 +67,6 @@ - state.last_rule_reassert = -100000 - - restore_loaded_gold_lifetimes_if_disabled() -- -- if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then -- pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "world_rules.startup_recovery", "restored_stale_saved_overrides=true") -- end - return true - end - -@@ -247,18 +243,11 @@ - local function apply_network_snapshot(choices) - state.remote_authoritative = true - state.reset_pending = false -- local failures = {} - for index, rule in ipairs(RULES) do - local desired_index = math.max(1, math.min(#rule.choices, math.floor(tonumber(choices[index]) or 1))) -- if selected_index(rule) ~= desired_index then -- local applied, reason = apply_rule_index(rule, desired_index) -- if not applied then failures[#failures + 1] = tostring(rule.id) .. ":" .. tostring(reason) end -- end -+ if selected_index(rule) ~= desired_index then apply_rule_index(rule, desired_index) end - end - state.last_physics_scan = -100000 -- if #failures > 0 and type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then -- pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "world_rules.snapshot_apply", table.concat(failures, ",")) -- end - end - - update_network_sync = function(frame) -@@ -271,10 +260,6 @@ - end - - function world_rule_service.gravity_factor() return physics_factor("physics_gravity") end -- --function world_rule_service.local_gravity_debug() -- return physics_adapter.debug_local_gravity(player_locator.get(), world_rule_service.gravity_factor()) --end - - function world_rule_service.step(rule, direction) - if not recover_stale_saved_overrides() then return false, "startup_recovery" end ---- metamorph_creative_menu/files/features/world_rules/time_dt.lua -+++ metamorph_creative_menu/files/features/world_rules/time_dt.lua -@@ -253,14 +253,5 @@ - return true - end - --function owner.debug_state() -- return { -- baseline=state.baseline, -- last_written=state.last_written, -- day_multiplier=state.day_multiplier, -- weather_frozen=state.weather_frozen, -- } --end -- - METAMORPH_CREATIVE_MENU_TIME_DT_OWNER = owner - return owner ---- metamorph_creative_menu/files/platform/noita/native_gameover_patch.lua -+++ metamorph_creative_menu/files/platform/noita/native_gameover_patch.lua -@@ -3,14 +3,6 @@ - end - - local native_gameover_patch = {} -- --local PROFILE = { -- name = "adaptive_noita_pe32_gameover_native_r10", -- mode = "pe32_import_resolution_and_signature_scan", -- compatibility = "stock Noita x86 Game Over / Save Replay semantics", -- fixed_exe_hash = false, -- fixed_virtual_addresses = false, --} - - local MODULE_NAME = "mcm_native_gameover" - local MODULE_CPATH = "./mods/metamorph_creative_menu/?.dll" -@@ -142,28 +134,5 @@ - return value == true, tostring(reason or (value and "post_revive_cleaned" or "unknown")) - end - --function native_gameover_patch.status() -- local native_reason = nil -- if type(state.module) == "table" and type(state.module.reason) == "function" then -- local ok, value = pcall(state.module.reason) -- if ok then native_reason = tostring(value) end -- end -- return { -- installed = native_gameover_patch.is_installed(), -- load_reason = state.load_reason, -- install_reason = state.install_reason, -- native_reason = native_reason, -- profile = PROFILE.name, -- backend = "native_x86_lua_c_module_adaptive", -- revive_request_pending = native_gameover_patch.has_revive_request(), -- stock_button_builder = "save_replay_slot", -- network_disabled = ew_active(), -- } --end -- --function native_gameover_patch.profile() -- return PROFILE --end -- - METAMORPH_CREATIVE_MENU_NATIVE_GAMEOVER_PATCH = native_gameover_patch - return native_gameover_patch diff --git a/metamorph_creative_menu/tools/player_release/10-ew-01.patch b/metamorph_creative_menu/tools/player_release/10-ew-01.patch deleted file mode 100644 index 9f57c8e..0000000 --- a/metamorph_creative_menu/tools/player_release/10-ew-01.patch +++ /dev/null @@ -1,173 +0,0 @@ ---- metamorph_creative_menu/files/integrations/ew/bootstrap.lua -+++ metamorph_creative_menu/files/integrations/ew/bootstrap.lua -@@ -43,7 +43,7 @@ - world_rules.register(rpc, common) - rpc.opts_reliable() - rpc.opts_everywhere() --function rpc.sync_qa_state(...) end -+function rpc.reserved_protocol_slot_3(...) end - companion.register(rpc, common) - forms.register_pose(rpc, common) - perks.register(rpc, common) ---- metamorph_creative_menu/files/integrations/ew/boss_global_demote.lua -+++ metamorph_creative_menu/files/integrations/ew/boss_global_demote.lua -@@ -427,17 +427,4 @@ - return true, "installed" - end - --if rawget(_G, "METAMORPH_CREATIVE_MENU_TESTING") == true then -- demote._test = { -- prepare_new_entities = prepare_new_entities, -- prepare_native_gap_entities_before_update = prepare_native_gap_entities_before_update, -- restore_after_native_update = restore_after_native_update, -- pending_count = function() -- local n = 0 -- for _ in pairs(pending) do n = n + 1 end -- return n -- end, -- last_seen_entity_id = function() return last_seen_entity_id end, -- } --end - return demote ---- metamorph_creative_menu/files/integrations/ew/boss_lifecycle.lua -+++ metamorph_creative_menu/files/integrations/ew/boss_lifecycle.lua -@@ -9,6 +9,7 @@ - local ok, entity = pcall(ewext.find_by_gid, gid) - return ok, ok and (tonumber(entity) or 0) or 0 - end -+ - local function discover(entity) - if policy.is_boss(entity) then candidates[entity] = true end - end -@@ -109,14 +110,9 @@ - if pending[record.gid] then return end - local ok, tracked = resolve(record.gid) - if not ok or tracked ~= record.entity then return end -- local sent, failure = pcall(native_notify, record.entity, record.wait, -+ local sent = pcall(native_notify, record.entity, record.wait, - record.x, record.y, "", record.responsible or 0) -- if not sent then -- if record.error ~= tostring(failure) then -- record.error = tostring(failure) -- end -- return -- end -+ if not sent then return end - record.reason = reason - pending[record.gid] = record - end -@@ -225,6 +221,7 @@ - return result - end - installed = true -+ GlobalsSetValue("mcm32_boss_lifecycle_ready_v1", "1") - return true - end - ---- metamorph_creative_menu/files/integrations/ew/bridge/creative_perks.lua -+++ metamorph_creative_menu/files/integrations/ew/bridge/creative_perks.lua -@@ -172,8 +172,4 @@ - cleanup_entries() - end - --function bridge.debug_state() -- return #entries, last_sequence --end -- - return bridge ---- metamorph_creative_menu/files/integrations/ew/bridge/forms.lua -+++ metamorph_creative_menu/files/integrations/ew/bridge/forms.lua -@@ -5,52 +5,9 @@ - local last_remote_root_frame = {} - local last_remote_entity = {} - local remote_articulated_prepared = {} --local form_pose_sent, form_pose_received = 0, 0 --local profiling_enabled = false - local MCM_BOSS_DEATH_PRESSURE_KEY = "mcm_ew_boss_death_pressure_until_v1" - - local NETWORK_SOURCE_NAME = "metamorph_creative_menu_network_source" -- --local function profile_now_ms() -- if not profiling_enabled then return nil end -- if type(GameGetRealWorldTimeSinceStarted) ~= "function" then return nil end -- local ok, value = pcall(GameGetRealWorldTimeSinceStarted) -- value = ok and tonumber(value) or nil -- return value ~= nil and value * 1000 or nil --end -- --local function form_source(entity) -- if entity == nil or entity == 0 then return "" end -- for _, component in ipairs(EntityGetComponentIncludingDisabled(entity, "VariableStorageComponent") or {}) do -- local ok_name, name = pcall(ComponentGetValue2, component, "name") -- if ok_name and name == NETWORK_SOURCE_NAME then -- local ok_value, value = pcall(ComponentGetValue2, component, "value_string") -- if ok_value and type(value) == "string" then return value end -- end -- end -- return "" --end -- --local function publish_remote_prepare_metrics(entity, kind, started_ms, visited, disabled) -- if not profiling_enabled or type(GlobalsSetValue) ~= "function" then return end -- local finished_ms = profile_now_ms() -- local elapsed = started_ms ~= nil and finished_ms ~= nil and math.max(0, finished_ms - started_ms) or -1 -- pcall(GlobalsSetValue, "mcm_form_remote_prepare_ms_v1", string.format("%.3f", elapsed)) -- pcall(GlobalsSetValue, "mcm_form_remote_prepare_entities_v1", tostring(math.max(0, tonumber(visited) or 0))) -- pcall(GlobalsSetValue, "mcm_form_remote_prepare_components_v1", tostring(math.max(0, tonumber(disabled) or 0))) -- pcall(GlobalsSetValue, "mcm_form_remote_prepare_kind_v1", tostring(tonumber(kind) or 0)) -- pcall(GlobalsSetValue, "mcm_form_remote_prepare_source_v1", form_source(entity)) -- if type(GameGetFrameNum) == "function" then -- local ok, frame = pcall(GameGetFrameNum) -- if ok then pcall(GlobalsSetValue, "mcm_form_remote_prepare_frame_v1", tostring(tonumber(frame) or -1)) end -- end -- if type(print) == "function" then -- print("[MCM EW profile] remote_prepare ms=" .. string.format("%.3f", elapsed) -- .. " entities=" .. tostring(math.max(0, tonumber(visited) or 0)) -- .. " components=" .. tostring(math.max(0, tonumber(disabled) or 0)) -- .. " kind=" .. tostring(tonumber(kind) or 0) .. " source=" .. tostring(form_source(entity))) -- end --end - - local function boss_death_pressure_active() - if type(GlobalsGetValue) ~= "function" or type(GameGetFrameNum) ~= "function" then return false end -@@ -175,8 +132,7 @@ - if driver ~= nil and driver ~= 0 then return end - remote_articulated_prepared[entity] = nil - end -- local started_ms = profile_now_ms() -- local visited, disabled = disable_remote_ai(entity) -+ disable_remote_ai(entity) - local worm = EntityGetFirstComponentIncludingDisabled(entity, "WormComponent") - local driver = EntityGetFirstComponentIncludingDisabled(entity, "WormPlayerComponent") - if kind == 5 then -@@ -208,7 +164,6 @@ - if worm ~= nil and worm ~= 0 then pcall(EntitySetComponentIsEnabled, entity, worm, true) end - if driver ~= nil and driver ~= 0 then pcall(EntitySetComponentIsEnabled, entity, driver, true) end - remote_articulated_prepared[entity] = kind -- publish_remote_prepare_metrics(entity, kind, started_ms, visited, disabled) - end - - local function apply_motion_state(entity, kind, x, y, speed) -@@ -254,7 +209,6 @@ - rotation, scale_x, scale_y = tonumber(rotation) or 0, tonumber(scale_x) or 1, tonumber(scale_y) or 1 - if not finite_number(x) or not finite_number(y) or not finite_number(rotation) then return end - last_remote_pose_frame[sender] = frame -- form_pose_received = form_pose_received + 1 - - local kind = tonumber(motion_kind) or 0 - local articulated = kind == 3 or kind == 5 -@@ -333,7 +287,6 @@ - if ok and type(value) == "table" then phys_info = value end - end - end -- form_pose_sent = form_pose_sent + 1 - rpc.sync_form_pose(frame, x, y, rotation or 0, scale_x or 1, scale_y or 1, - phys_info, motion_kind, motion_x, motion_y, motion_speed) - end -@@ -344,6 +297,4 @@ - if entity ~= 0 then enforce_local_form_des_exclusion(entity) end - send_form_pose(frame) - end --function forms_bridge.metrics() return form_pose_sent, form_pose_received end --function forms_bridge.set_profiling_enabled(enabled) profiling_enabled = enabled == true end - return forms_bridge diff --git a/metamorph_creative_menu/tools/player_release/10-ew-02.patch b/metamorph_creative_menu/tools/player_release/10-ew-02.patch deleted file mode 100644 index 8007f71..0000000 --- a/metamorph_creative_menu/tools/player_release/10-ew-02.patch +++ /dev/null @@ -1,197 +0,0 @@ ---- metamorph_creative_menu/files/integrations/ew/bridge/materials.lua -+++ metamorph_creative_menu/files/integrations/ew/bridge/materials.lua -@@ -70,14 +70,6 @@ - local world_frame_world_number = nil - local active_world_number = nil - local relay_queue = new_queue(RELAY_QUEUE_CAPACITY) -- --local metrics = { -- chunks = 0, -- bytes = 0, -- relays = 0, -- overwritten_batches = 0, --} --local metrics_enabled = false - - local function clean_error(err) - if common ~= nil and type(common.clean) == "function" then return common.clean(err) end -@@ -288,9 +280,6 @@ - local function drain_outbox() - local next_sequence, newest = transport.first_available_sequence(last_sequence) - if next_sequence > last_sequence + 1 then -- if metrics_enabled then -- metrics.overwritten_batches = metrics.overwritten_batches + (next_sequence - last_sequence - 1) -- end - last_sequence = next_sequence - 1 - transport.mark_ack(last_sequence) - end -@@ -347,10 +336,6 @@ - if not string_ok or type(payload) ~= "string" then return false, payload or "encode_string" end - local send_ok, send_error = pcall(net.proxy_bin_send, KEY_WORLD_FRAME, payload) - if not send_ok then return false, send_error end -- if metrics_enabled then -- metrics.chunks = metrics.chunks + 1 -- metrics.bytes = metrics.bytes + #payload -- end - return true, "sent" - end - -@@ -569,24 +554,10 @@ - return - end - queue_pop(relay_queue) -- if metrics_enabled then metrics.relays = metrics.relays + 1 end - return - end - end - end -- --local function publish_metrics(frame) -- if not metrics_enabled or frame % 60 ~= 0 then return end -- GlobalsSetValue("mcm_material_sync_sent_chunks_v1", tostring(metrics.chunks)) -- GlobalsSetValue("mcm_material_sync_sent_bytes_v1", tostring(metrics.bytes)) -- GlobalsSetValue("mcm_material_sync_relay_entities_v1", tostring(metrics.relays)) -- GlobalsSetValue("mcm_material_sync_overwritten_batches_v1", tostring(metrics.overwritten_batches)) -- GlobalsSetValue("mcm_material_sync_backlog_v1", table.concat({ -- tostring(chunk_queue.size), tostring(relay_queue.size), -- }, ":")) --end -- --function materials_bridge.set_metrics_enabled(enabled) metrics_enabled = enabled == true end - - function materials_bridge.register(shared_rpc, shared_common) - rpc, common = shared_rpc, shared_common -@@ -603,7 +574,6 @@ - drain_outbox() - drain_chunks(frame) - drain_relays(frame) -- publish_metrics(frame) - end - - return materials_bridge ---- metamorph_creative_menu/files/integrations/ew/kolmi_lifecycle.lua -+++ metamorph_creative_menu/files/integrations/ew/kolmi_lifecycle.lua -@@ -5,6 +5,7 @@ - local observed, pending, retired = {}, {}, {} - local installed = false - local native_notify -+ - - local function alive(entity) - return EntityGetIsAlive(entity) == true -@@ -71,14 +72,9 @@ - local ok, entity = resolve(record.gid) - if not ok or entity ~= record.entity then return end - -- local sent, failure = pcall(native_notify, record.entity, record.wait, -+ local sent = pcall(native_notify, record.entity, record.wait, - record.x, record.y, "", responsible or 0) -- if not sent then -- if record.error ~= tostring(failure) then -- record.error = tostring(failure) -- end -- return -- end -+ if not sent then return end - record.reason = reason - pending[record.gid] = record - end ---- metamorph_creative_menu/files/integrations/ew/resilience.lua -+++ metamorph_creative_menu/files/integrations/ew/resilience.lua -@@ -27,8 +27,9 @@ - "data/scripts/items/potion_starting.lua", - } - -+local EW_CROSSCALL_FILES = {} -+ - local resilience_patches = dofile("mods/metamorph_creative_menu/files/integrations/ew/resilience_patches.lua") --local DEV_MODE = false - - ew_resilience.patch_crosscall_source = resilience_patches.patch_crosscall_source - ew_resilience.patch_wand_pickup_killself_source = resilience_patches.patch_wand_pickup_killself_source -@@ -38,7 +39,6 @@ - ew_resilience.patch_detour_source = resilience_patches.patch_detour_source - ew_resilience.patch_world_sync_source = resilience_patches.patch_world_sync_source - ew_resilience.patch_polymorph_death_source = resilience_patches.patch_polymorph_death_source --ew_resilience.patch_polymorph_profile_source = resilience_patches.patch_polymorph_profile_source - ew_resilience.patch_material_pixel_scene_source = resilience_patches.patch_material_pixel_scene_source - ew_resilience.patch_peer_perk_isolation_source = resilience_patches.patch_peer_perk_isolation_source - ew_resilience.patch_perk_helper_sync_source = resilience_patches.patch_perk_helper_sync_source -@@ -67,7 +67,7 @@ - world_sync = { - path = "mods/quant.ew/files/system/world_sync/world_sync.lua", - marker = "mcm_poly_world_sync_v5", -- patcher = function(content) return ew_resilience.patch_world_sync_source(content, DEV_MODE) end, -+ patcher = ew_resilience.patch_world_sync_source, - global_key = "mcm_compat_world_sync_patch_v1", - }, - material_scenes = { -@@ -129,10 +129,6 @@ - .. tostring(name) .. " (" .. tostring(status) .. ")" - print(diagnostic_message) - if type(GamePrint) == "function" then pcall(GamePrint, user_message) end -- if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then -- pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, -- "ew.compatibility." .. tostring(name), tostring(status)) -- end - end - end - end -@@ -162,25 +158,6 @@ - - local function patch_world_sync_file() - return apply_verified_patch("world_sync") --end -- --local function patch_polymorph_profile_file() -- local path = "mods/quant.ew/files/system/polymorph/polymorph.lua" -- local function finish(status) -- if type(GlobalsSetValue) == "function" then -- pcall(GlobalsSetValue, "mcm_compat_poly_profile_patch_v1", global_text.encode_diagnostic(status)) -- end -- return status -- end -- local ok, content = pcall(ModTextFileGetContent, path) -- if not ok or type(content) ~= "string" or content == "" then return finish("read_failed") end -- if string.find(content, "mcm_poly_profile_v1", 1, true) ~= nil then return finish("already_present") end -- local patch_ok, changed, count = pcall(ew_resilience.patch_polymorph_profile_source, content) -- if not patch_ok or type(changed) ~= "string" or (tonumber(count) or 0) <= 0 -- or string.find(changed, "mcm_poly_profile_v1", 1, true) == nil -- then return finish("anchor_mismatch") end -- if not pcall(ModTextFileSetContent, path, changed) then return finish("write_failed") end -- return finish("applied") - end - - local function patch_peer_perk_isolation_file() -@@ -245,6 +222,9 @@ - - local function patch_crosscall_files() - local count = 0 -+ for _, relative in ipairs(EW_CROSSCALL_FILES) do -+ count = count + patch_file("mods/quant.ew/" .. relative, ew_resilience.patch_crosscall_source) -+ end - - count = count + patch_file( - "mods/quant.ew/files/system/entity_sync_helper/scripts/killself.lua", -@@ -278,9 +258,7 @@ - end - apply_verified_patch("form_death") - patch_world_sync_file() -- if DEV_MODE then -- patch_polymorph_profile_file() -- elseif type(GlobalsSetValue) == "function" then -+ if type(GlobalsSetValue) == "function" then - pcall(GlobalsSetValue, "mcm_compat_poly_profile_patch_v1", "disabled") - end - apply_verified_patch("material_scenes") -@@ -310,9 +288,7 @@ - local crosscalls = patch_crosscall_files() - apply_verified_patch("form_death") - patch_world_sync_file() -- if DEV_MODE then -- patch_polymorph_profile_file() -- elseif type(GlobalsSetValue) == "function" then -+ if type(GlobalsSetValue) == "function" then - pcall(GlobalsSetValue, "mcm_compat_poly_profile_patch_v1", "disabled") - end - apply_verified_patch("material_scenes") diff --git a/metamorph_creative_menu/tools/player_release/10-ew-03.patch b/metamorph_creative_menu/tools/player_release/10-ew-03.patch deleted file mode 100644 index dd37471..0000000 --- a/metamorph_creative_menu/tools/player_release/10-ew-03.patch +++ /dev/null @@ -1,133 +0,0 @@ ---- metamorph_creative_menu/files/integrations/ew/resilience_patches.lua -+++ metamorph_creative_menu/files/integrations/ew/resilience_patches.lua -@@ -7,7 +7,7 @@ - local anchor = 'if not CrossCall("ew_is_wand_pickup") then' - local first, last = string.find(content, anchor, 1, true) - if first == nil then return content, 0 end -- local replacement = [[-- mcm_wand_pickup_crosscall_guard_v1 -+ local replacement = [[local mcm_wand_pickup_crosscall_guard_v1 = true - local mcm_pickup_ok, mcm_is_wand_pickup = pcall(CrossCall, "ew_is_wand_pickup") - if not mcm_pickup_ok or not mcm_is_wand_pickup then]] - return string.sub(content, 1, first - 1) .. replacement .. string.sub(content, last + 1), 1 -@@ -17,11 +17,9 @@ - if type(content) ~= "string" or string.find(content, "CrossCall", 1, true) == nil - or string.find(content, "mcm_safe_crosscall_v1", 1, true) ~= nil - then return content, 0 end -- local guard = [[-- mcm_safe_crosscall_v1 -+ local guard = [[local mcm_safe_crosscall_v1 = true - local mcm_crosscall_error_seen = {} - local function CrossCall(name, ...) -- -- Resolve the engine/EW global dynamically. Do not capture it while a LuaComponent -- -- is loading: some EW systems install their handler later in the same startup. - local mcm_raw_crosscall = _G and _G.CrossCall or nil - local mcm_ok, a, b, c, d, e - if type(mcm_raw_crosscall) == "function" then -@@ -38,15 +36,9 @@ - return type(GameHasFlagRun) == "function" and GameHasFlagRun(flag) or false - end - if name == "ew_banned_spells" or name == "ew_perk_ban_list" then return {} end -- -- Preserve the old nil fallback for compatibility, but surface truly unexpected -- -- EW failures once per (call,error) signature. This is intentionally diagnostic -- -- only: changing fallback semantics here could break optional EW systems. - local mcm_signature = tostring(name) .. "|" .. tostring(a) - if not mcm_crosscall_error_seen[mcm_signature] then - mcm_crosscall_error_seen[mcm_signature] = true -- if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then -- pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "ew.crosscall." .. tostring(name), tostring(a)) -- end - print("[Metamorph: Creative Menu] unexpected EW CrossCall failure " .. tostring(name) .. ": " .. tostring(a)) - end - return nil -@@ -59,11 +51,7 @@ - if type(content) ~= "string" or content == "" then return content, 0 end - if string.find(content, "mcm_kolmi_crosscall_failopen_v1", 1, true) ~= nil then return content, 0 end - if string.find(content, 'CrossCall("ew_kolmi_', 1, true) == nil then return content, 0 end -- local helper = [[-- mcm_kolmi_crosscall_failopen_v1 ---- EW's Kolmi append is transport glue layered on top of the vanilla boss coroutine. ---- A transport failure must never abort that coroutine: otherwise attacks, phase changes, ---- minion spawning and the final kill_now all stop together. Prefer the normal entity-VM ---- CrossCall registry, then the published NoitaPatcher bridge when this Lua state has it. -+ local helper = [[local mcm_kolmi_crosscall_failopen_v1 = true - local mcm_kolmi_crosscall_error_seen = {} - local function mcm_kolmi_crosscall(name, ...) - local ok, a, b, c, d = false, nil, nil, nil, nil -@@ -78,9 +66,6 @@ - local signature = tostring(name) .. "|" .. tostring(a) - if not mcm_kolmi_crosscall_error_seen[signature] then - mcm_kolmi_crosscall_error_seen[signature] = true -- if type(GlobalsSetValue) == "function" then -- pcall(GlobalsSetValue, "mcm25_kolmi_crosscall_failure_v1", signature) -- end - print("[Metamorph: Creative Menu] Kolmisilma EW CrossCall failed open: " .. signature) - end - return false, a -@@ -97,14 +82,11 @@ - local before = ' CrossCall("ew_spawn_kolmi", ComponentGetValue2(gid, "value_string"))' - local first, last = string.find(content, before, 1, true) - if first == nil then return content, 0 end -- local after = [[ -- mcm_kolmi_spawn_crosscall_failopen_v1 -+ local after = [[ local mcm_kolmi_spawn_crosscall_failopen_v1 = true - local mcm_ok = false - if type(CrossCall) == "function" then - mcm_ok = pcall(CrossCall, "ew_spawn_kolmi", ComponentGetValue2(gid, "value_string")) - end -- -- If the native cross-VM registry is split/unavailable, fail open into the -- -- vanilla pickup path. A missed network convenience call is preferable to a -- -- permanently inert final boss encounter. - if not mcm_ok then old(ent) end]] - local patched = string.sub(content, 1, first - 1) .. after .. string.sub(content, last + 1) - -@@ -130,14 +112,13 @@ - end) - end - --function resilience_patches.patch_world_sync_source(content, metrics_enabled) -+function resilience_patches.patch_world_sync_source(content) - if type(content) ~= "string" or string.find(content, "mcm_poly_world_sync_v5", 1, true) ~= nil then - return content, 0 - end - local original = content - content = string.gsub(content, "\r\n", "\n") - content = string.gsub(content, "\r", "\n") -- local changed = 0 - local function replace_once(source, before, after) - local first, last = string.find(source, before, 1, true) - if first == nil then return source, false end -@@ -145,35 +126,9 @@ - end - - local state_anchor = "local iter_slow_2 = 0" -- local metrics_literal = metrics_enabled == true and "true" or "false" - local state_block = [[local iter_slow_2 = 0 -- ---- mcm_poly_world_sync_v5 --local EWCM_METRICS_ENABLED = ]] .. metrics_literal .. [[ --local mcm_sent_chunks, mcm_sent_bytes = 0, 0 --local mcm_recv_chunks, mcm_recv_bytes = 0, 0 --local mcm_poly_scheduler_calls = 0 --local mcm_poly_scheduler_paused = 0 --local mcm_last_poly_cx, mcm_last_poly_cy = nil, nil --local MCM_BOSS_DEATH_PRESSURE_KEY = "mcm_ew_boss_death_pressure_until_v1"]] -- local ok -- content, ok = replace_once(content, state_anchor, state_block) -- if not ok then return original, 0 end -- changed = changed + 1 -- -- local send_anchor = [[ net.proxy_bin_send(KEY_WORLD_FRAME, str) -- end -- end --end --local int = 4 -- ctx.proxy_opt.world_sync_interval]] -- local send_block = [[ net.proxy_bin_send(KEY_WORLD_FRAME, str) -- if EWCM_METRICS_ENABLED then -- mcm_sent_chunks = mcm_sent_chunks + 1 -- mcm_sent_bytes = mcm_sent_bytes + #str -- end -- end -- end --end -+local mcm_poly_world_sync_v5 = true -+local MCM_BOSS_DEATH_PRESSURE_KEY = "mcm_ew_boss_death_pressure_until_v1" - - local function mcm_boss_death_pressure_active() - if type(GlobalsGetValue) ~= "function" then return false end diff --git a/metamorph_creative_menu/tools/player_release/10-ew-04.patch b/metamorph_creative_menu/tools/player_release/10-ew-04.patch deleted file mode 100644 index be08fd3..0000000 --- a/metamorph_creative_menu/tools/player_release/10-ew-04.patch +++ /dev/null @@ -1,176 +0,0 @@ ---- metamorph_creative_menu/files/integrations/ew/resilience_patches.lua -+++ metamorph_creative_menu/files/integrations/ew/resilience_patches.lua -@@ -184,68 +139,21 @@ - local function mcm_heavy_polymorph_active() - local entity = ctx.my_player ~= nil and ctx.my_player.entity or 0 - if entity == nil or entity == 0 or not EntityHasTag(entity, "polymorphed_player") then return false end -- -- Giant articulated/destructive forms are exactly the forms for which MCM's extra -- -- camera-independent chunk scheduler can outrun the network. EW still performs its -- -- normal world synchronization; we only suppress MCM's additional fast-form path. - return EntityGetFirstComponentIncludingDisabled(entity, "BossDragonComponent") ~= nil - or EntityGetFirstComponentIncludingDisabled(entity, "WormComponent") ~= nil - or EntityGetFirstComponentIncludingDisabled(entity, "BossHealthBarComponent") ~= nil - or EntityGetFirstComponentIncludingDisabled(entity, "StreamingKeepAliveComponent") ~= nil --end -- --local function mcm_publish_world_metrics() -- if not EWCM_METRICS_ENABLED or GameGetFrameNum() % 60 ~= 0 then return end -- GlobalsSetValue("mcm_world_sync_sent_chunks_v1", tostring(mcm_sent_chunks)) -- GlobalsSetValue("mcm_world_sync_sent_bytes_v1", tostring(mcm_sent_bytes)) -- GlobalsSetValue("mcm_world_sync_recv_chunks_v1", tostring(mcm_recv_chunks)) -- GlobalsSetValue("mcm_world_sync_recv_bytes_v1", tostring(mcm_recv_bytes)) -- -- Keep the historical keys so existing diagnostics remain readable. v4 has no -- -- persistent trail queue by design, therefore backlog/sent are always zero. -- GlobalsSetValue("mcm_world_sync_trail_backlog_v1", "0") -- GlobalsSetValue("mcm_world_sync_trail_sent_v1", "0") -- GlobalsSetValue("mcm_world_sync_trail_dropped_v1", "0") -- GlobalsSetValue("mcm_world_sync_poly_scheduler_calls_v1", tostring(mcm_poly_scheduler_calls)) -- GlobalsSetValue("mcm_world_sync_poly_scheduler_paused_v1", tostring(mcm_poly_scheduler_paused)) -- GlobalsSetValue("mcm_world_sync_last_poly_chunk_v1", tostring(mcm_last_poly_cx or "") .. ":" .. tostring(mcm_last_poly_cy or "")) --end -- --local int = 4 -- ctx.proxy_opt.world_sync_interval]] -- content, ok = replace_once(content, send_anchor, send_block) -- if not ok then return original, 0 end -- changed = changed + 1 -- -- local chunk_anchor = [[ local ocx, ocy = math.floor(px / CHUNK_SIZE), math.floor(py / CHUNK_SIZE) -- local n = 0]] -- local chunk_block = [[ local ocx, ocy = math.floor(px / CHUNK_SIZE), math.floor(py / CHUNK_SIZE) -- if EntityHasTag(ctx.my_player.entity, "polymorphed_player") then -- mcm_last_poly_cx, mcm_last_poly_cy = ocx, ocy -- else -- mcm_last_poly_cx, mcm_last_poly_cy = nil, nil -- end -- local n = 0]] -- content, ok = replace_once(content, chunk_anchor, chunk_block) -- if not ok then return original, 0 end -- changed = changed + 1 -- -- local upstream_far = [[ if ctx.spectating_over_peer_id ~= nil and ctx.spectating_over_peer_id ~= ctx.my_id then -+end]] -+ local ok -+ content, ok = replace_once(content, state_anchor, state_block) -+ if not ok then return original, 0 end -+ -+ local far_anchor = [[ if ctx.spectating_over_peer_id ~= nil and ctx.spectating_over_peer_id ~= ctx.my_id then - if GameGetFrameNum() % 3 ~= 2 then - get_all_chunks(cx, cy, pos_data, 16, false) - else - get_all_chunks(ocx, ocy, pos_data, 16, true) - end -- else -- wait = GameGetFrameNum() + 30 -- end]] -- local old_v3_far = [[ if ctx.spectating_over_peer_id ~= nil and ctx.spectating_over_peer_id ~= ctx.my_id then -- if GameGetFrameNum() % 3 ~= 2 then -- get_all_chunks(cx, cy, pos_data, 16, false) -- else -- get_all_chunks(ocx, ocy, pos_data, 16, true) -- end -- elseif EntityHasTag(ctx.my_player.entity, "polymorphed_player") then -- -- Keep upstream's own per-frame ring scheduler centred on the actual form. -- -- The trail queue below separately guarantees delivery of chunks we left. -- get_all_chunks(ocx, ocy, pos_data, 0, true) - else - wait = GameGetFrameNum() + 30 - end]] -@@ -256,69 +164,20 @@ - get_all_chunks(ocx, ocy, pos_data, 16, true) - end - elseif EntityHasTag(ctx.my_player.entity, "polymorphed_player") then -- -- Fast playable forms can outrun the camera. Keep exactly ONE instance of -- -- EW's normal ring scheduler centred on the form, rather than an additional -- -- persistent trail queue. During a boss-death pressure window, yield these -- -- optional world frames briefly so DES DeleteEntity/authority traffic wins. - if mcm_boss_death_pressure_active() or mcm_heavy_polymorph_active() then -- -- Do not generate an MCM-owned terrain stream for giant boss/worm forms. -- -- Their destruction volume can produce data faster than the observer can -- -- consume it, which manifests as lag that keeps growing until unpolymorph. -- mcm_poly_scheduler_paused = mcm_poly_scheduler_paused + 1 - wait = GameGetFrameNum() + 30 - else -- -- Only the central chunk (frame %% int == 0) and the immediate 3x3 ring -- -- (frame %% int == 2) are forced while the form outruns the camera. The -- -- radius-2/radius-3 phases are intentionally skipped here; they were the -- -- dominant bandwidth multiplier for huge destructive boss forms. - local phase = GameGetFrameNum() % int - if phase == 0 or phase == 2 then -- mcm_poly_scheduler_calls = mcm_poly_scheduler_calls + 1 - get_all_chunks(ocx, ocy, pos_data, 0, true) - end - end - else - wait = GameGetFrameNum() + 30 - end]] -- content, ok = replace_once(content, upstream_far, far_block) -- if not ok then content, ok = replace_once(content, old_v3_far, far_block) end -- if not ok then return original, 0 end -- changed = changed + 1 -- -- local tail_anchor = [[ end --end -- --local PixelRun_const_ptr]] -- local old_v3_tail = [[ end -- -- Keep draining after returning to human. Otherwise the last destroyed chunks of -- -- a fast form could remain queued forever at the exact moment the form ends. -- mcm_drain_trail() -- mcm_publish_world_metrics() --end -- --local PixelRun_const_ptr]] -- local tail_block = [[ end -- mcm_publish_world_metrics() --end -- --local PixelRun_const_ptr]] -- content, ok = replace_once(content, tail_anchor, tail_block) -- if not ok then content, ok = replace_once(content, old_v3_tail, tail_block) end -- if not ok then return original, 0 end -- changed = changed + 1 -- -- local recv_anchor = [[function world_sync.handle_world_data(datum) -- local grid_world = world_ffi.get_grid_world()]] -- local recv_block = [[function world_sync.handle_world_data(datum) -- if EWCM_METRICS_ENABLED then -- mcm_recv_chunks = mcm_recv_chunks + 1 -- if type(datum) == "string" then mcm_recv_bytes = mcm_recv_bytes + #datum end -- end -- local grid_world = world_ffi.get_grid_world()]] -- content, ok = replace_once(content, recv_anchor, recv_block) -- if not ok then return original, 0 end -- changed = changed + 1 -- return content, changed -+ content, ok = replace_once(content, far_anchor, far_block) -+ if not ok then return original, 0 end -+ return content, 2 - end - - function resilience_patches.patch_entity_sync_death_source(content) -@@ -329,12 +188,7 @@ - table.insert(dead, { entity, wait_on_kill, x, y, file, responsible }) - end)]] - local block = [[util.add_cross_call("ew_death_notify", function(entity, wait_on_kill, x, y, file, responsible) -- -- mcm_immediate_des_death_v1 -- -- Stock EW queues this CrossCall until on_world_update_post. Global bosses are -- -- authority-transfer entities, so only for BossHealthBar/StreamingKeepAlive roots -- -- make the native DES death decision while the dying entity still has ew_gid_lid. -- -- Ordinary enemies retain EW's stock deferred path. If the native API throws, -- -- preserve EW's original deferred queue exactly as a compatibility fallback. -+ local mcm_immediate_des_death_v1 = true - local submitted = false - local is_global_boss = EntityGetFirstComponentIncludingDisabled(entity, "BossHealthBarComponent") ~= nil - or EntityGetFirstComponentIncludingDisabled(entity, "StreamingKeepAliveComponent") ~= nil -@@ -357,10 +211,7 @@ - local anchor = [[ local ent = GetUpdatedEntityID() - local x, y = EntityGetTransform(ent)]] - local block = [[ local ent = GetUpdatedEntityID() -- -- mcm_boss_death_pressure_v1 -- -- This does NOT change EW death semantics. It only gives MCM's optional fast-form -- -- world-sync traffic a short back-pressure hint so DES deletion/authority messages -- -- are not queued behind large pixel-world frames when any global boss dies. -+ local mcm_boss_death_pressure_v1 = true - if EntityGetFirstComponentIncludingDisabled(ent, "BossHealthBarComponent") ~= nil - or EntityGetFirstComponentIncludingDisabled(ent, "StreamingKeepAliveComponent") ~= nil - then diff --git a/metamorph_creative_menu/tools/player_release/10-ew-05.patch b/metamorph_creative_menu/tools/player_release/10-ew-05.patch deleted file mode 100644 index e901a69..0000000 --- a/metamorph_creative_menu/tools/player_release/10-ew-05.patch +++ /dev/null @@ -1,172 +0,0 @@ ---- metamorph_creative_menu/files/integrations/ew/resilience_patches.lua -+++ metamorph_creative_menu/files/integrations/ew/resilience_patches.lua -@@ -379,11 +230,7 @@ - end]] - - local POLYMORPH_DEATH_BLOCK = [[ if has_hp_component and hp <= 0 and not gameover_requested then -- -- mcm_poly_death_intercept_v1: a creative MCM form death is a form-lifecycle -- -- transition, not an EW player death. Give MCM one synchronous chance to -- -- commit its serialized human backup before local_health/shared-health can -- -- create notplayer or trigger game over. Ordinary EW polymorphs keep the -- -- exact upstream path when the acknowledgement is absent. -+ local mcm_poly_death_intercept_v1 = true - local mcm_old_entity = ctx.my_player.entity - local function mcm_death_ack_matches() - if type(GlobalsGetValue) ~= "function" then return false end -@@ -396,13 +243,7 @@ - if not ok_frame then return true end - return math.abs((tonumber(frame) or 0) - (tonumber(ack_frame) or -100000)) <= 2 - end -- -- The entity-side death() callback may have run earlier in this same frame. -- -- Accept that recent committed acknowledgement even though corpse handoff has -- -- already removed the MCM player-form tag from the old body. - local mcm_poly_death_handled = mcm_death_ack_matches() -- -- EW replaces the global CrossCall in its own VM with a private local -- -- callback table. MCM's handler lives in the mod VM, so the synchronous -- -- fallback must use NoitaPatcher's native cross-VM entry point when present. - local mcm_cross_call = type(np) == "table" and type(np.CrossCall) == "function" - and np.CrossCall or CrossCall - if not mcm_poly_death_handled -@@ -432,108 +273,6 @@ - return string.sub(content, 1, first - 1) .. POLYMORPH_DEATH_BLOCK .. string.sub(content, last + 1), 1 - end - --function resilience_patches.patch_polymorph_profile_source(content) -- if type(content) ~= "string" or string.find(content, "mcm_poly_profile_v1", 1, true) ~= nil then -- return content, 0 -- end -- local original = content -- local function replace_once(source, before, after) -- local first, last = string.find(source, before, 1, true) -- if first == nil then return source, false end -- return string.sub(source, 1, first - 1) .. after .. string.sub(source, last + 1), true -- end -- -- local module_anchor = "local module = {}" -- local helpers = [=[local module = {} -- ---- mcm_poly_profile_v1 --local function mcm_poly_profile_now() -- if type(GameGetRealWorldTimeSinceStarted) ~= "function" then return nil end -- local ok, value = pcall(GameGetRealWorldTimeSinceStarted) -- value = ok and tonumber(value) or nil -- return value --end -- --local function mcm_poly_profile_source(entity) -- if entity == nil or entity == 0 then return "" end -- for _, component in ipairs(EntityGetComponentIncludingDisabled(entity, "VariableStorageComponent") or {}) do -- local ok_name, name = pcall(ComponentGetValue2, component, "name") -- if ok_name and name == "metamorph_creative_menu_network_source" then -- local ok_value, value = pcall(ComponentGetValue2, component, "value_string") -- if ok_value and type(value) == "string" then return value end -- end -- end -- local ok, filename = pcall(EntityGetFilename, entity) -- return ok and tostring(filename or "") or "" --end -- --local function mcm_poly_profile_kind(entity) -- if entity == nil or entity == 0 then return "unknown" end -- if EntityGetFirstComponentIncludingDisabled(entity, "BossDragonComponent") ~= nil then return "boss_dragon" end -- if EntityGetFirstComponentIncludingDisabled(entity, "WormComponent") ~= nil then return "worm" end -- if EntityGetFirstComponentIncludingDisabled(entity, "CharacterDataComponent") ~= nil then return "character" end -- if EntityGetFirstComponentIncludingDisabled(entity, "AdvancedFishAIComponent") ~= nil then return "fish" end -- return "other" --end -- --local function mcm_poly_profile_publish(prefix, entity, byte_count, started, finished) -- local tag_ok, is_mcm = false, false -- if type(EntityHasTag) == "function" then tag_ok, is_mcm = pcall(EntityHasTag, entity, "metamorph_creative_menu_network_form") end -- if not tag_ok or is_mcm ~= true then return end -- local elapsed_ms = -1 -- if started ~= nil and finished ~= nil then elapsed_ms = math.max(0, (finished - started) * 1000) end -- local bytes = math.max(0, tonumber(byte_count) or 0) -- local source = mcm_poly_profile_source(entity) -- local kind = mcm_poly_profile_kind(entity) -- if type(GlobalsSetValue) == "function" then -- pcall(GlobalsSetValue, prefix .. "_ms_v1", string.format("%.3f", elapsed_ms)) -- pcall(GlobalsSetValue, prefix .. "_bytes_v1", tostring(bytes)) -- pcall(GlobalsSetValue, prefix .. "_source_v1", source) -- pcall(GlobalsSetValue, prefix .. "_kind_v1", kind) -- if type(GameGetFrameNum) == "function" then -- local ok, frame = pcall(GameGetFrameNum) -- if ok then pcall(GlobalsSetValue, prefix .. "_frame_v1", tostring(tonumber(frame) or -1)) end -- end -- end -- if type(print) == "function" then -- print("[MCM EW profile] " .. tostring(prefix) .. " ms=" .. string.format("%.3f", elapsed_ms) -- .. " bytes=" .. tostring(bytes) .. " kind=" .. tostring(kind) .. " source=" .. tostring(source)) -- end --end]=] -- local ok -- content, ok = replace_once(content, module_anchor, helpers) -- if not ok then return original, 0 end -- -- if string.find(content, "mcm_poly_death_intercept_v1", 1, true) == nil then -- local death_patched, death_count = resilience_patches.patch_polymorph_death_source(content) -- if death_count ~= 1 then return original, 0 end -- content = death_patched -- end -- -- local serialize_anchor = [[ rpc.change_entity({ data = util.serialize_entity(ctx.my_player.entity) })]] -- local serialize_block = [[ local mcm_profile_serialize_started = mcm_poly_profile_now() -- local mcm_profile_serialized = util.serialize_entity(ctx.my_player.entity) -- local mcm_profile_serialize_finished = mcm_poly_profile_now() -- mcm_poly_profile_publish("mcm_ew_poly_serialize", ctx.my_player.entity, -- type(mcm_profile_serialized) == "string" and #mcm_profile_serialized or 0, -- mcm_profile_serialize_started, mcm_profile_serialize_finished) -- rpc.change_entity({ data = mcm_profile_serialized })]] -- content, ok = replace_once(content, serialize_anchor, serialize_block) -- if not ok then return original, 0 end -- -- local deserialize_anchor = [[ local ent = util.deserialize_entity(seri_ent.data)]] -- local deserialize_block = [[ local mcm_profile_deserialize_started = mcm_poly_profile_now() -- local ent = util.deserialize_entity(seri_ent.data) -- local mcm_profile_deserialize_finished = mcm_poly_profile_now() -- mcm_poly_profile_publish("mcm_ew_poly_deserialize", ent, -- type(seri_ent.data) == "string" and #seri_ent.data or 0, -- mcm_profile_deserialize_started, mcm_profile_deserialize_finished)]] -- content, ok = replace_once(content, deserialize_anchor, deserialize_block) -- if not ok then return original, 0 end -- -- return content, 1 --end -- - function resilience_patches.patch_material_pixel_scene_source(content) - if type(content) ~= "string" then return content, 0 end - if string.find(content, "mcm_material_brush_pixel_scene_v1", 1, true) then return content, 0 end -@@ -543,21 +282,14 @@ - if first == nil then return source, false end - return string.sub(source, 1, first - 1) .. after .. string.sub(source, last + 1), true - end - local anchor = [[ CrossCall( - "ew_sync_pixel_scene",]] -- local replacement = [[ -- mcm_material_brush_pixel_scene_v1: the MCM solid brush supplies its -- -- material dynamically through color_to_material_table. EW's stock pixel-scene -- -- RPC intentionally forwards only filenames/position, so replaying this MCM-owned -- -- mask remotely cannot preserve the selected material and breaks peers without MCM. -- -- The scene has already been applied locally above. Do not send a filename-only -- -- replay that cannot represent the selected material; stock EW world synchronization -- -- remains responsible for whatever resulting grid state its normal model can encode. -- -- All non-MCM pixel scenes keep the exact upstream path. -+ local replacement = [[ local mcm_material_brush_pixel_scene_v1 = true - if string.find(tostring(materials_filename or ""), - "mods/metamorph_creative_menu/files/features/materials/brushes/", 1, true) == 1 - then - return - end - CrossCall( - "ew_sync_pixel_scene",]] - local changed, ok = replace_material_scene_once(content, anchor, replacement) -@@ -580,8 +310,8 @@ - - if string.find(content, "mcm_peer_perk_remote_safety_v1", 1, true) == nil then - local ignore_anchor = "local perks_to_ignore = {" -- local ignore_block = [[local perks_to_ignore = { -- -- mcm_peer_perk_remote_safety_v1: exact ids with unsafe/non-replica mechanics. -+ local ignore_block = [[local mcm_peer_perk_remote_safety_v1 = true -+local perks_to_ignore = { - ABILITY_ACTIONS_MATERIALIZED = true, - RESPAWN = true, - SAVING_GRACE = true, diff --git a/metamorph_creative_menu/tools/player_release/10-ew-06.patch b/metamorph_creative_menu/tools/player_release/10-ew-06.patch deleted file mode 100644 index 2aa4a97..0000000 --- a/metamorph_creative_menu/tools/player_release/10-ew-06.patch +++ /dev/null @@ -1,99 +0,0 @@ ---- metamorph_creative_menu/files/integrations/ew/resilience_patches.lua -+++ metamorph_creative_menu/files/integrations/ew/resilience_patches.lua -@@ -595,10 +325,9 @@ - local count_anchor = [[ local perk_count = tonumber(GlobalsGetValue(perk_flag_name .. "_PICKUP_COUNT", "0")) - if perk_count > 0 then]] - local count_block = [[ local perk_count = tonumber(GlobalsGetValue(perk_flag_name .. "_PICKUP_COUNT", "0")) -- -- mcm_peer_perk_sender_filter_v1 - if global_perks[perk_id] then -- local mcm_hidden = math.max(0, tonumber(GlobalsGetValue("mcm_creative_perk_hidden_count_v1:" .. perk_id, "0")) or 0) -- if mcm_hidden > 0 then perk_count = math.max(0, perk_count - mcm_hidden) end -+ local mcm_peer_perk_sender_filter_v1_hidden = math.max(0, tonumber(GlobalsGetValue("mcm_creative_perk_hidden_count_v1:" .. perk_id, "0")) or 0) -+ if mcm_peer_perk_sender_filter_v1_hidden > 0 then perk_count = math.max(0, perk_count - mcm_peer_perk_sender_filter_v1_hidden) end - end - if perk_count > 0 then]] - local changed, ok = replace_peer_once(content, count_anchor, count_block) -@@ -610,8 +339,7 @@ - local anchor = [[ local current_counts = util.get_ent_variable(entity, "ew_current_perks") or {} - for perk_id, count in pairs(perk_data) do]] - local replacement = [[ local current_counts = util.get_ent_variable(entity, "ew_current_perks") or {} -- -- mcm_peer_perk_removal_v1: preserve removed ids as explicit zeroes for EW's -- -- existing negative-diff cleanup path. -+ local mcm_peer_perk_removal_v1 = true - for perk_id in pairs(current_counts) do - if perk_data[perk_id] == nil then perk_data[perk_id] = 0 end - end -@@ -623,7 +351,7 @@ - - local marker_anchor = "local global_perks = {" - local marked, marked_ok = replace_peer_once(content, marker_anchor, -- marker_anchor .. " -- mcm_peer_perk_sync_v4") -+ "local mcm_peer_perk_sync_v4 = true\n" .. marker_anchor) - if not marked_ok then return original, 0 end - return marked, 1 - end -@@ -640,15 +368,15 @@ - end - - local ok -- content, ok = replace_once(content, " if #ho ~= 0 then\n", " if true then -- mcm_perk_helper_empty_homunculus_v1\n") -- if not ok then return original, 0 end -- content, ok = replace_once(content, " if #lu ~= 0 then\n", " if true then -- mcm_perk_helper_empty_lukki_v1\n") -- if not ok then return original, 0 end -- content, ok = replace_once(content, " if #gh ~= 0 then\n", " if true then -- mcm_perk_helper_empty_ghost_v1\n") -+ content, ok = replace_once(content, " if #ho ~= 0 then\n", " if true then\n") -+ if not ok then return original, 0 end -+ content, ok = replace_once(content, " if #lu ~= 0 then\n", " if true then\n") -+ if not ok then return original, 0 end -+ content, ok = replace_once(content, " if #gh ~= 0 then\n", " if true then\n") - if not ok then return original, 0 end - - local function_anchor = "function homunculus.on_world_update()" -- local function_header = [[-- mcm_perk_helper_sync_v1 -+ local function_header = [[local mcm_perk_helper_sync_v1 = true - GlobalsSetValue("mcm_perk_helper_sync_loaded_v1", "1") - local mcm_had_perk_helpers = false - function homunculus.on_world_update()]] -@@ -659,9 +387,6 @@ - rpc.send_positions(ho, lu, gh, GameGetFrameNum()) - end]] - local send_block = [[ local mcm_has_perk_helpers = #ho ~= 0 or #lu ~= 0 or #gh ~= 0 -- -- Send one authoritative empty snapshot after the last helper disappears. While at -- -- least one class remains alive the existing per-frame packets now also reconcile -- -- any other class whose list became empty. - if mcm_has_perk_helpers or mcm_had_perk_helpers then - rpc.send_positions(ho, lu, gh, GameGetFrameNum()) - end -@@ -685,7 +410,7 @@ - local module_anchor = [[local module = {}]] - local helpers = [=[local module = {} - ---- mcm_perk_mutation_sync_v1 -+local mcm_perk_mutation_sync_v1 = true - GlobalsSetValue("mcm_perk_mutation_sync_loaded_v1", "1") - local mcm_mutation_baselines = {} - ---- metamorph_creative_menu/files/integrations/ew/world_items.lua -+++ metamorph_creative_menu/files/integrations/ew/world_items.lua -@@ -46,9 +46,6 @@ - if type(CrossCall) == "function" then - local crosscall_succeeded, crosscall_error = pcall(CrossCall, "ew_thrown", entity) - if crosscall_succeeded then return true, "direct" end -- if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then -- pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "item.ew_thrown", tostring(crosscall_error)) -- end - end - - outbox_sequence = math.max(outbox_sequence, tonumber(GlobalsGetValue(OUTBOX_SEQUENCE_KEY, "0")) or 0) + 1 ---- metamorph_creative_menu/files/integrations/ew/world_rules_sync.lua -+++ metamorph_creative_menu/files/integrations/ew/world_rules_sync.lua -@@ -35,9 +35,6 @@ - local signature = tostring(code) .. "\31" .. tostring(details or "") - if last_network_error.signature == signature and frame - last_network_error.frame < 600 then return end - last_network_error.signature, last_network_error.frame = signature, frame -- if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then -- pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "world_rules." .. tostring(code), tostring(details or "")) -- end - print("[Metamorph: Creative Menu] EW world-rules: " .. tostring(code) .. " - " .. tostring(details or "")) - end - diff --git a/metamorph_creative_menu/tools/player_release/20-ui-01.patch b/metamorph_creative_menu/tools/player_release/20-ui-01.patch deleted file mode 100644 index 28a72cf..0000000 --- a/metamorph_creative_menu/tools/player_release/20-ui-01.patch +++ /dev/null @@ -1,166 +0,0 @@ ---- metamorph_creative_menu/files/ui/components/wand_appearance_editor.lua -+++ metamorph_creative_menu/files/ui/components/wand_appearance_editor.lua -@@ -20,9 +20,6 @@ - last_error = reason - local message = ui.tr("$mcm_wand_appearance_error", "Could not change appearance") .. ": " .. reason - if type(GamePrint) == "function" then pcall(GamePrint, message) end -- if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then -- pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "wand.appearance", reason) -- end - end - - local function clear_error() ---- metamorph_creative_menu/files/ui/components/wand_editor.lua -+++ metamorph_creative_menu/files/ui/components/wand_editor.lua -@@ -10,7 +10,6 @@ - - local expanded = true - local tracked_wand = 0 --local last_error = nil - - local FIELDS = { - {id="slots", key="$mcm_wand_slots", fallback="SLOTS"}, -@@ -29,9 +28,7 @@ - } - - local function perform(player, wand, label, callback, options) -- local ok, reason = history.perform(player, wand, label, callback, options) -- last_error = ok and nil or reason -- return ok, reason -+ return history.perform(player, wand, label, callback, options) - end - - local function reset_controls(wand) -@@ -40,7 +37,7 @@ - - local function draw_stat(player, wand, snapshot, field, panel_width) - local definition = wand_service.definition(field.id) or {} -- local _, reason = numeric.draw("wand.stat." .. tostring(wand) .. "." .. field.id, -+ numeric.draw("wand.stat." .. tostring(wand) .. "." .. field.id, - ui.tr(field.key, field.fallback), snapshot.stats[field.id], { - integer=definition.integer == true, - step=field.step or definition.step or 1, -@@ -57,7 +54,6 @@ - end, {coalesce_key="stat." .. field.id,coalesce_frames=45}) - end, - }) -- if reason ~= nil then last_error = reason end - end - - function wand_editor.draw(player, wand, panel_width) -@@ -66,16 +62,12 @@ - if tracked_wand ~= wand then - tracked_wand = wand - reset_controls(wand) -- last_error = nil - end - -- local history_changed, history_error = history_bar.draw(player, wand) -+ local history_changed = history_bar.draw(player, wand) - if history_changed then - snapshot = select(1, wand_service.snapshot(wand)) or snapshot - reset_controls(wand) -- last_error = nil -- elseif history_error ~= nil then -- last_error = history_error - end - - local toggle = (expanded and "- " or "+ ") .. ui.tr("$mcm_wand_stats", "WAND STATS") -@@ -104,9 +96,6 @@ - - appearance_editor.draw(player, wand, panel_width) - -- if METAMORPH_CREATIVE_MENU_DEV_MODE == true and last_error ~= nil then -- ui.wrapped_text(0, 0, ui.tr("$mcm_wand_edit_error", "Could not change wand") .. ": " .. tostring(last_error), panel_width - 12) -- end - return true - end - ---- metamorph_creative_menu/files/ui/menu_controller.lua -+++ metamorph_creative_menu/files/ui/menu_controller.lua -@@ -3,7 +3,6 @@ - local menu_controller = {} - - local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") --local audit = ui.audit - local player_locator = dofile("mods/metamorph_creative_menu/files/platform/noita/player_locator.lua") - local input_guard = dofile("mods/metamorph_creative_menu/files/platform/noita/input_guard.lua") - local menu_inventory_guard = dofile("mods/metamorph_creative_menu/files/platform/noita/menu_inventory_guard.lua") -@@ -96,7 +95,7 @@ - end - active_tab = index - save_active_tab() -- audit("menu.tab", "id=" .. tostring(tabs[index].id) .. " source=" .. tostring(source or "ui")) -+ - return true - end - -@@ -269,13 +268,13 @@ - height=layout.height, - edges=edges, - } -- audit("menu.layout_begin", "kind=" .. tostring(kind)) -+ - end - - local function update_pointer_operation(screen_width, screen_height) - if pointer_operation == nil then return end - if not left_mouse("down") then -- audit("menu.layout_end", "kind=" .. tostring(pointer_operation.kind)) -+ - pointer_operation = nil - save_layout() - return -@@ -346,7 +345,7 @@ - - layout_reset_pending = true - pointer_operation = nil -- audit("menu.layout_reset_requested", "screen=" .. tostring(screen_width) .. "x" .. tostring(screen_height)) -+ - end - - local function draw_tab_bar(width) -@@ -530,9 +529,6 @@ - local signature = tostring(err) - if tab_error_signatures[tab.id] ~= signature then - tab_error_signatures[tab.id] = signature -- if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then -- pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, "ui." .. tostring(tab.id), signature) -- end - print("[Metamorph: Creative Menu] UI tab '" .. tostring(tab.id) .. "' failed: " .. signature) - end - else -@@ -593,7 +589,7 @@ - layout = panel_layout.create(screen_width, screen_height, {}, layout_outsets()) - last_panel_width = layout.width - save_layout() -- audit("menu.layout_reset", "width=" .. tostring(math.floor(layout.width + 0.5))) -+ - end - local before_x, before_y, before_width, before_height = layout.x, layout.y, layout.width, layout.height - panel_layout.clamp(layout, screen_width, screen_height, nil, layout_outsets()) ---- metamorph_creative_menu/files/ui/runtime.lua -+++ metamorph_creative_menu/files/ui/runtime.lua -@@ -85,12 +85,6 @@ - return { ui_runtime.translated(key), fallback or key, key } - end - --function ui_runtime.audit(action, details) -- if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_USER_ACTION) == "function" then -- pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_USER_ACTION, action, details) -- end --end -- - function ui_runtime.report_error_once(key, title, reason, diagnostic_scope) - if reason == nil then return false end - key = tostring(key or diagnostic_scope or "ui") -@@ -101,9 +95,6 @@ - local message = tostring(title or "Error") - if reason ~= "" then message = message .. ": " .. reason end - if type(GamePrint) == "function" then pcall(GamePrint, message) end -- if type(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE) == "function" then -- pcall(METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE, tostring(diagnostic_scope or key), reason) -- end - return true - end - diff --git a/metamorph_creative_menu/tools/player_release/20-ui-02.patch b/metamorph_creative_menu/tools/player_release/20-ui-02.patch deleted file mode 100644 index 33d84fa..0000000 --- a/metamorph_creative_menu/tools/player_release/20-ui-02.patch +++ /dev/null @@ -1,156 +0,0 @@ ---- metamorph_creative_menu/files/ui/tabs/creatures.lua -+++ metamorph_creative_menu/files/ui/tabs/creatures.lua -@@ -1,8 +1,5 @@ - local creatures_tab = {} --local DEV_MODE = METAMORPH_CREATIVE_MENU_DEV_MODE == true -- - local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") --local audit = ui.audit - local form_manager = dofile("mods/metamorph_creative_menu/files/features/forms/manager.lua") - local entity_catalog = dofile("mods/metamorph_creative_menu/files/features/creatures/ui_catalog.lua") - local player_avatar = dofile("mods/metamorph_creative_menu/files/features/companion/player_avatar.lua") -@@ -13,8 +10,6 @@ - local selected_filter = 1 - local icon_cache = {} - local search = "" --local review_mode = "play" --local review_marks = {} - local search_cache_key = nil - local search_cache_value = nil - local warmup_cursor = 1 -@@ -128,36 +123,16 @@ - local text = tostring(entry.display_description or "") - if text ~= "" then text = text .. "\n" end - text = text .. tostring(entry.path or "") -- if review_mode == "play" then -- if entry.special == "player" then -- text = text .. "\n" .. ui.tr("$mcm_player_clone_left", "LMB: create PLAYER clone") -- .. "\n" .. ui.tr("$mcm_player_clone_right", "RMB: human form (no change if human)") -- else -- text = text .. "\n" .. ui.tr("$mcm_left_spawn", "LMB: spawn nearby") -- .. "\n" .. ui.tr("$mcm_creature_drag_spawn", "DRAG: spawn at cursor") -- .. "\n" .. ui.tr("$mcm_right_transform", "RMB: transform") -- end -+ if entry.special == "player" then -+ text = text .. "\n" .. ui.tr("$mcm_player_clone_left", "LMB: create PLAYER clone") -+ .. "\n" .. ui.tr("$mcm_player_clone_right", "RMB: human form (no change if human)") - else -- local mode_key = "$mcm_mobs_mode_log_retest" -- local mode_fallback = "LOG RETEST" -- if review_mode == "safe" then mode_key, mode_fallback = "$mcm_mobs_mode_log_safe", "LOG SAFE" end -- if review_mode == "unsafe" then mode_key, mode_fallback = "$mcm_mobs_mode_log_unsafe", "LOG UNSAFE" end -- text = text .. "\n" .. ui.tr("$mcm_mobs_review_click", "CLICK: log") .. " " .. ui.tr(mode_key, mode_fallback) -- if creature_service ~= nil and type(creature_service.compatibility_status) == "function" then -- local checked, status, reason = pcall(creature_service.compatibility_status, entry.path) -- if checked then text = text .. "\nAUTO: " .. tostring(status or "unknown") .. " / " .. tostring(reason or "unknown") end -- end -- local marked = review_marks[tostring(entry.path)] -- if marked ~= nil then -- local marked_key, marked_fallback = "$mcm_mobs_mode_log_retest", "LOG RETEST" -- if marked == "safe" then marked_key, marked_fallback = "$mcm_mobs_mode_log_safe", "LOG SAFE" end -- if marked == "unsafe" then marked_key, marked_fallback = "$mcm_mobs_mode_log_unsafe", "LOG UNSAFE" end -- text = text .. "\n" .. ui.tr("$mcm_mobs_marked", "MARKED") .. ": " .. ui.tr(marked_key, marked_fallback) -- end -+ text = text .. "\n" .. ui.tr("$mcm_left_spawn", "LMB: spawn nearby") -+ .. "\n" .. ui.tr("$mcm_creature_drag_spawn", "DRAG: spawn at cursor") -+ .. "\n" .. ui.tr("$mcm_right_transform", "RMB: transform") - end - return { description = text } - end -- - local function point_inside(bounds, x, y) - return type(bounds) == "table" and tonumber(x) ~= nil and tonumber(y) ~= nil - and tonumber(x) >= tonumber(bounds.x or 0) and tonumber(x) <= tonumber(bounds.x or 0) + tonumber(bounds.width or 0) -@@ -191,8 +166,7 @@ - - if result.click == true then - local entity, reason = creature_service.spawn_near_player(player, payload.path, 32, -4) -- audit("creature.spawn", "path=" .. tostring(payload.path) .. " entity=" .. tostring(entity) -- .. " reason=" .. tostring(reason)) -+ - if entity == 0 then report_spawn_failure(payload.display_name) end - return - end -@@ -200,24 +174,23 @@ - if result.target ~= nil then return end - local release_x, release_y = tonumber(result.release_x), tonumber(result.release_y) - if release_x == nil or release_y == nil then -- audit("creature.drag.cancel", "path=" .. tostring(payload.path) .. " reason=release_position") -+ - return - end - local menu_bounds = type(ui.panel_bounds) == "function" and ui.panel_bounds() or nil - if point_inside(menu_bounds, release_x, release_y) then -- audit("creature.drag.cancel", "path=" .. tostring(payload.path) .. " reason=in_menu") -+ - return - end - - local world_x, world_y = tonumber(result.world_x), tonumber(result.world_y) - if world_x == nil or world_y == nil then -- audit("creature.drag.world", "path=" .. tostring(payload.path) .. " entity=0 reason=position") -+ - report_spawn_failure(payload.display_name) - return - end - local entity, reason = creature_service.spawn_at(payload.path, world_x, world_y) -- audit("creature.drag.world", "path=" .. tostring(payload.path) .. " entity=" .. tostring(entity) -- .. " reason=" .. tostring(reason) .. " x=" .. tostring(world_x) .. " y=" .. tostring(world_y)) -+ - if entity == 0 then report_spawn_failure(payload.display_name) end - end - -@@ -226,7 +199,7 @@ - local checked, status, reason = pcall(creature_service.compatibility_status, creature.path) - if checked and status == "unsafe" then - GamePrint(ui.tr("$mcm_creature_poly_failed", "Could not transform") .. ": " .. tostring(creature.display_name or creature.path)) -- audit("creature.transform.blocked", "path="..tostring(creature.path).." reason="..tostring(reason or "known_unsafe")) -+ - return false, tostring(reason or "known_unsafe") - end - end -@@ -257,9 +230,7 @@ - compatibility_reason = tostring(reason or "unknown") - end - end -- audit("creature.transform.begin", "path="..tostring(creature.path).." target="..tostring(target) -- .." transform_mode="..transform_mode.." transform_reason="..transform_reason -- .." compatibility="..compatibility_status.." compatibility_reason="..compatibility_reason) -+ - local ok, reason = form_manager.transform_creature(player, target, nil, true, { - requested_target=creature.path, - compatibility_mode=transform_mode, -@@ -293,22 +264,8 @@ - end - local clicked_filter = ui.button_grid(filter_buttons, panel_width - 10) - if clicked_filter ~= nil then selected_filter = clicked_filter end -- if DEV_MODE then -- GuiLayoutBeginHorizontal(ui.gui(), 0, 0, true) -- if ui.button(0, 0, ui.tr("$mcm_mobs_mode_play", "PLAY"), review_mode == "play") then review_mode = "play" end -- if ui.button(0, 0, ui.tr("$mcm_mobs_mode_log_safe", "LOG SAFE"), review_mode == "safe") then review_mode = "safe" end -- if ui.button(0, 0, ui.tr("$mcm_mobs_mode_log_unsafe", "LOG UNSAFE"), review_mode == "unsafe") then review_mode = "unsafe" end -- if ui.button(0, 0, ui.tr("$mcm_mobs_mode_log_retest", "LOG RETEST"), review_mode == "retest") then review_mode = "retest" end -- GuiLayoutEnd(ui.gui()) -- else -- review_mode = "play" -- end -- if review_mode == "play" then -- local help = ui.tr("$mcm_creature_controls_dynamic", "LMB: SPAWN DRAG: DROP RMB: TRANSFORM {BIND}: HUMAN") -- ui.wrapped_text(0, 0, string.gsub(help, "{BIND}", "[" .. bindings.label("return_human") .. "]"), panel_width - 12) -- else -- ui.wrapped_text(0, 0, ui.tr("$mcm_mobs_review_help", "REVIEW: click a mob to log its exact path; no transform/spawn occurs"), panel_width - 12) -- end -+ local help = ui.tr("$mcm_creature_controls_dynamic", "LMB: SPAWN DRAG: DROP RMB: TRANSFORM {BIND}: HUMAN") -+ ui.wrapped_text(0, 0, string.gsub(help, "{BIND}", "[" .. bindings.label("return_human") .. "]"), panel_width - 12) - search = ui.search_input(search, math.max(88, panel_width - 54), 64, "creatures") - - local results = search_values(selected_filter, search) -@@ -332,7 +289,7 @@ - ui.EMPTY_SLOT, entry_icon, CREATURE_ICON_FALLBACK, entry.display_name, p.description, false, - {target_size=18, max_scale=18, padding=0}) - if hovered and icon_cache[tostring(entry.path)] == nil then entry_icon = icon(entry, true) end -- if review_mode == "play" and entry.special ~= "player" then -+ if entry.special ~= "player" then - local bounds = drag_bounds(tile_x, tile_y, tile_w, tile_h) - if bounds ~= nil then - drag_drop.source("creatures.catalog." .. tostring(entry.path), { diff --git a/metamorph_creative_menu/tools/player_release/20-ui-03.patch b/metamorph_creative_menu/tools/player_release/20-ui-03.patch deleted file mode 100644 index c16dc4a..0000000 --- a/metamorph_creative_menu/tools/player_release/20-ui-03.patch +++ /dev/null @@ -1,170 +0,0 @@ ---- metamorph_creative_menu/files/ui/tabs/creatures.lua -+++ metamorph_creative_menu/files/ui/tabs/creatures.lua -@@ -341,25 +298,12 @@ - }, bounds, {x=scroll.x, y=scroll.y, width=scroll.width, height=scroll.height}) - end - end -- if review_mode ~= "play" and (clicked or right) then -- review_marks[tostring(entry.path)] = review_mode -- local compatibility_status, compatibility_reason = "unknown", "unavailable" -- if creature_service ~= nil and type(creature_service.compatibility_status) == "function" then -- local checked, status, reason = pcall(creature_service.compatibility_status, entry.path) -- if checked then compatibility_status, compatibility_reason = tostring(status or "unknown"), tostring(reason or "unknown") end -- end -- audit("creature.review", "verdict="..tostring(review_mode).." path="..tostring(entry.path).." id="..tostring(entry.id or "") -- .." name="..tostring(entry.display_name or "").." auto="..compatibility_status.." auto_reason="..compatibility_reason) -- local label = ui.tr("$mcm_mobs_mode_log_retest", "LOG RETEST") -- if review_mode == "safe" then label = ui.tr("$mcm_mobs_mode_log_safe", "LOG SAFE") end -- if review_mode == "unsafe" then label = ui.tr("$mcm_mobs_mode_log_unsafe", "LOG UNSAFE") end -- GamePrint(ui.tr("$mcm_mobs_review_logged", "MOBS REVIEW") .. " " .. label .. ": " .. tostring(entry.path)) -- elseif clicked and entry.special == "player" then -+ if clicked and entry.special == "player" then - local ok=spawn(player, entry) -- audit("creature.spawn", "path="..tostring(entry.path).." result="..tostring(ok)) -+ - elseif right then - local ok, reason=transform(player, entry) -- audit("creature.transform", "path="..tostring(entry.path).." result="..tostring(ok).." reason="..tostring(reason)) -+ - end - end - ui.end_scroll_viewport(scroll, math.ceil(#results / columns) * ui.ICON_STEP) ---- metamorph_creative_menu/files/ui/tabs/effects.lua -+++ metamorph_creative_menu/files/ui/tabs/effects.lua -@@ -1,7 +1,6 @@ - local effects_tab = {} - - local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") --local audit = ui.audit - local effect_service = dofile("mods/metamorph_creative_menu/files/features/effects/service.lua") - - local catalog = nil -@@ -104,7 +103,7 @@ - else - ok, reason = effect_service.add(player, selected, frames) - end -- audit("effect.add", "id="..tostring(selected.id or selected.path).." variant="..tostring(index).." result="..tostring(ok).." reason="..tostring(reason)) -+ - return ok, reason - end - -@@ -132,7 +131,7 @@ - if active and type(effect_service.set_remaining)=="function" then - local frames=DURATIONS[picked].frames - local ok,reason=effect_service.set_remaining(player,resolved,frames) -- audit("effect.duration", "id="..tostring(resolved.id or resolved.path).." value="..tostring(frames).." result="..tostring(ok).." reason="..tostring(reason)) -+ - end - end - return 13 -@@ -161,7 +160,7 @@ - selected_variant[key] = picked - if active and type(effect_service.switch_variant) == "function" then - local ok, reason = effect_service.switch_variant(player,entry,picked) -- audit("effect.variant", "id="..tostring(entry.id or entry.path).." variant="..tostring(picked).." result="..tostring(ok).." reason="..tostring(reason)) -+ - end - end - height = height + 13 -@@ -196,7 +195,7 @@ - ui.tr("$mcm_confirm", "CONFIRM") .. ": " .. remove_all_label, nil, math.max(32,panel_width-10)) - then - local removed=effect_service.remove_all(player) -- audit("effect.remove_all", "removed="..tostring(removed)) -+ - end - - local h=ui.scroll_height(screen_height, 142 + editor_height) -@@ -219,7 +218,7 @@ - selected_key=key - local removed=type(effect_service.remove_family)=="function" and effect_service.remove_family(player,entry) - or effect_service.remove(player,entry) -- audit("effect.remove", "id="..tostring(entry.id or entry.path).." family=true removed="..tostring(removed)) -+ - end - end - ui.end_scroll_viewport(scroll,math.ceil(visible/columns)*ui.ICON_STEP) ---- metamorph_creative_menu/files/ui/tabs/items.lua -+++ metamorph_creative_menu/files/ui/tabs/items.lua -@@ -1,7 +1,6 @@ - local items_tab = {} - - local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") --local audit = ui.audit - local item_service = dofile("mods/metamorph_creative_menu/files/features/items/service.lua") - local item_catalog = dofile("mods/metamorph_creative_menu/files/features/items/ui_catalog.lua") - local material_preview = dofile("mods/metamorph_creative_menu/files/platform/noita/material_preview.lua") -@@ -98,11 +97,11 @@ - if result.click == true then - if payload.kind == "catalog_liquid" then - local ok, reason = item_service.spawn_filled_flask(player, payload.material_id, false) -- audit("item.spawn_liquid", "id=" .. tostring(payload.material_id) .. " result=" .. tostring(ok) .. " reason=" .. tostring(reason)) -+ - if not ok then report_create_failure(payload.display_name) end - else - local entity, reason = item_service.spawn_near(player, payload.path) -- audit("item.spawn", "path=" .. tostring(payload.path) .. " entity=" .. tostring(entity) .. " reason=" .. tostring(reason)) -+ - if entity ~= 0 then - GamePrint(ui.tr("$mcm_created_nearby", "Spawned nearby") .. ": " .. tostring(payload.display_name or "")) - else -@@ -116,7 +115,7 @@ - local release_x, release_y = tonumber(result.release_x), tonumber(result.release_y) - local menu_bounds = type(ui.panel_bounds) == "function" and ui.panel_bounds() or nil - if release_x == nil or release_y == nil or point_inside(menu_bounds, release_x, release_y) then -- audit("item.drag.cancel", "kind=" .. tostring(payload.kind) .. " reason=in_menu") -+ - return - end - -@@ -128,7 +127,7 @@ - else - ok, reason = item_service.give_strict(player, payload.path, payload.category ~= "WANDS") - end -- audit("item.drag.native_inventory", "kind=" .. tostring(payload.kind) .. " result=" .. tostring(ok) .. " reason=" .. tostring(reason)) -+ - if ok then - GamePrint(ui.tr("$mcm_received", "Received") .. ": " .. tostring(payload.display_name or "")) - else -@@ -139,20 +138,18 @@ - - local world_x, world_y = tonumber(result.world_x), tonumber(result.world_y) - if world_x == nil or world_y == nil then -- audit("item.drag.world", "kind=" .. tostring(payload.kind) .. " result=false reason=position") -+ - report_create_failure(payload.display_name) - return - end - - if payload.kind == "catalog_liquid" then - local ok, reason = item_service.spawn_filled_flask_at(payload.material_id, world_x, world_y) -- audit("item.drag.world_liquid", "id=" .. tostring(payload.material_id) .. " result=" .. tostring(ok) -- .. " reason=" .. tostring(reason) .. " x=" .. tostring(world_x) .. " y=" .. tostring(world_y)) -+ - if not ok then report_create_failure(payload.display_name) end - else - local entity, reason = item_service.spawn_at(payload.path, world_x, world_y) -- audit("item.drag.world", "path=" .. tostring(payload.path) .. " entity=" .. tostring(entity) -- .. " reason=" .. tostring(reason) .. " x=" .. tostring(world_x) .. " y=" .. tostring(world_y)) -+ - if entity == 0 then report_create_failure(payload.display_name) end - end - end -@@ -241,7 +238,7 @@ - if right then - if is_liquid then - local ok, reason = item_service.spawn_filled_flask(player, entry.id, true) -- audit("item.take_liquid", "id="..tostring(entry.id).." result="..tostring(ok).." reason="..tostring(reason)) -+ - if not ok and reason == "full" then - GamePrint(ui.tr("$mcm_inventory_full_spawned", "Inventory full — spawned nearby") .. ": " .. entry.display_name) - elseif not ok then -@@ -249,7 +246,7 @@ - end - else - local ok, reason, entity, spawned_nearby = item_service.give(player, entry.path, entry.category ~= "WANDS") -- audit("item.take", "path="..tostring(entry.path).." result="..tostring(ok).." reason="..tostring(reason).." entity="..tostring(entity)) -+ - if ok then - GamePrint(ui.tr("$mcm_received", "Received") .. ": " .. entry.display_name) - elseif reason == "full" then diff --git a/metamorph_creative_menu/tools/player_release/20-ui-04.patch b/metamorph_creative_menu/tools/player_release/20-ui-04.patch deleted file mode 100644 index 80b7313..0000000 --- a/metamorph_creative_menu/tools/player_release/20-ui-04.patch +++ /dev/null @@ -1,178 +0,0 @@ ---- metamorph_creative_menu/files/ui/tabs/materials.lua -+++ metamorph_creative_menu/files/ui/tabs/materials.lua -@@ -1,7 +1,6 @@ - local materials_tab = {} - - local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") --local audit = ui.audit - local material_catalog = dofile("mods/metamorph_creative_menu/files/features/materials/catalog.lua") - local painter = dofile("mods/metamorph_creative_menu/files/features/materials/painter.lua") - local bindings = dofile("mods/metamorph_creative_menu/files/platform/noita/action_bindings.lua") -@@ -90,14 +89,14 @@ - if painter.is_enabled() then - if ui.button(0, 0, ui.tr("$mcm_material_paint_cancel", "CANCEL PAINT"), true) then - painter.set_enabled(false) -- audit("material.paint_mode", "enabled=false") -+ - end - local armed = ui.tr("$mcm_material_paint_armed_dynamic", "ARMED - close inventory; hold {BIND}") - ui.wrapped_text(0, 1, string.gsub(armed, "{BIND}", "[" .. bindings.label("paint_draw") .. "]"), panel_width - 12) - else - if ui.button(0, 0, ui.tr("$mcm_material_paint_start", "START PAINTING")) then - local ok, reason = painter.set_enabled(true) -- audit("material.paint_mode", "enabled=" .. tostring(ok) .. " material=" .. tostring(painter.get_material()) .. " reason=" .. tostring(reason)) -+ - if not ok and type(GamePrint) == "function" then - local message = ui.tr("$mcm_material_backend_failed", "Material brush unavailable") - pcall(GamePrint, "[MCM] " .. message .. ": " .. tostring(reason or "backend")) -@@ -145,7 +144,7 @@ - }) - if clicked then - local ok, reason = painter.set_material(entry.id, { solid=entry.categories and (entry.categories.SOLIDS == true or entry.categories.STATIC == true) }) -- audit("material.select", "id=" .. tostring(entry.id) .. " result=" .. tostring(ok) .. " reason=" .. tostring(reason)) -+ - end - end - ui.end_scroll_viewport(scroll, math.ceil(#values / columns) * ui.ICON_STEP) ---- metamorph_creative_menu/files/ui/tabs/perks.lua -+++ metamorph_creative_menu/files/ui/tabs/perks.lua -@@ -1,7 +1,6 @@ - local perks_tab = {} - - local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") --local audit = ui.audit - local perk_service = dofile("mods/metamorph_creative_menu/files/features/perks/service.lua") - local perk_catalog = dofile("mods/metamorph_creative_menu/files/features/perks/catalog.lua") - local drag_drop = dofile("mods/metamorph_creative_menu/files/ui/drag_drop.lua") -@@ -113,7 +112,7 @@ - - if result.click == true then - local ok, reason = apply_or_spawn(player, payload.data or {id=payload.id}, false) -- audit("perk.spawn", "id=" .. tostring(payload.id) .. " result=" .. tostring(ok) .. " reason=" .. tostring(reason)) -+ - if reason == "busy" then report_busy() elseif not ok then report_spawn_failure(payload.display_name) end - return - end -@@ -122,19 +121,18 @@ - local release_x, release_y = tonumber(result.release_x), tonumber(result.release_y) - local menu_bounds = type(ui.panel_bounds) == "function" and ui.panel_bounds() or nil - if release_x == nil or release_y == nil or point_inside(menu_bounds, release_x, release_y) then -- audit("perk.drag.cancel", "id=" .. tostring(payload.id) .. " reason=in_menu") -+ - return - end - local world_x, world_y = tonumber(result.world_x), tonumber(result.world_y) - if world_x == nil or world_y == nil then -- audit("perk.drag.world", "id=" .. tostring(payload.id) .. " result=false reason=position") -+ - report_spawn_failure(payload.display_name) - return - end - if active_job() ~= nil then report_busy(); return end - local ok, reason = perk_service.spawn_at(payload.data or {id=payload.id}, world_x, world_y) -- audit("perk.drag.world", "id=" .. tostring(payload.id) .. " result=" .. tostring(ok) -- .. " reason=" .. tostring(reason) .. " x=" .. tostring(world_x) .. " y=" .. tostring(world_y)) -+ - if not ok then report_spawn_failure(payload.display_name) end - end - -@@ -264,7 +262,7 @@ - local ok, reason - if perk.removable_state then ok, reason = non_perk_states.remove_one(player, perk.id) - else ok, reason = perk_service.remove_one(player, perk.data) end -- audit("perk.remove_one", "id="..tostring(perk.id).." result="..tostring(ok).." reason="..tostring(reason).." scope=peer_local") -+ - if not ok then GamePrint(ui.tr("$mcm_perk_remove_failed", "Could not safely remove perk") .. ": " .. perk.name) end - end - elseif can_remove and right_command and count > 0 then -@@ -274,7 +272,7 @@ - local queued, reason - if perk.removable_state then queued, reason = non_perk_states.remove(player, perk.id) - else queued, reason = perk_service.start_remove_all_job(player, perk.data) end -- audit("perk.remove_all.queue", "id="..tostring(perk.id).." queued="..tostring(queued).." reason="..tostring(reason).." scope=peer_local") -+ - if not queued then GamePrint(ui.tr("$mcm_perk_remove_failed", "Could not safely remove perk") .. ": " .. tostring(reason)) end - end - end -@@ -282,7 +280,7 @@ - - if right_command then - local ok, reason=apply_or_spawn(player, perk, true) -- audit("perk.take", "id="..tostring(perk.id).." amount="..tostring(take_amount).." result="..tostring(ok).." reason="..tostring(reason)) -+ - if reason == "busy" then report_busy() end - end - end ---- metamorph_creative_menu/files/ui/tabs/spells.lua -+++ metamorph_creative_menu/files/ui/tabs/spells.lua -@@ -1,7 +1,6 @@ - local spells_tab = {} - - local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") --local audit = ui.audit - local spell_service = dofile("mods/metamorph_creative_menu/files/features/spells/service.lua") - local spell_catalog = dofile("mods/metamorph_creative_menu/files/features/spells/catalog.lua") - local horizontal_strip = dofile("mods/metamorph_creative_menu/files/ui/widgets/horizontal_strip.lua") -@@ -117,7 +116,7 @@ - end - - local function record_result(scope, ok, reason) -- audit(scope, "result=" .. tostring(ok) .. " reason=" .. tostring(reason or "")) -+ - return ok, reason - end - -@@ -269,7 +268,7 @@ - end, - function(payload) - if payload.kind == "catalog_spell" then -- audit("spell.drag.discard_template", "action=" .. tostring(payload.action_id or "")) -+ - return true, "discarded_template" - elseif payload.kind == "inventory_spell" then - local layout = inventory_layout or select(1, spell_inventory_service.contents(player)) ---- metamorph_creative_menu/files/ui/tabs/weather.lua -+++ metamorph_creative_menu/files/ui/tabs/weather.lua -@@ -1,24 +1,13 @@ - local weather_tab = {} - local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") --local audit = ui.audit - local weather_service = dofile("mods/metamorph_creative_menu/files/features/weather/service.lua") - local advanced=false - - local function time(name) -- local ok,reason=weather_service.set_time_preset(name) -- audit("weather.time", "preset="..tostring(name).." result="..tostring(ok).." reason="..tostring(reason)) -+ weather_service.set_time_preset(name) - end - local function preset(name) -- local ok,reason=weather_service.apply_preset(name) -- local detail="" -- if type(weather_service.debug_state)=="function" then -- local good,state=pcall(weather_service.debug_state) -- if good and type(state)=="table" then -- detail=" rainfall="..tostring(state.rainfall).." rain="..tostring(state.rain).." rain_target="..tostring(state.rain_target) -- .." last_emit="..tostring(state.last_rain_emit_frame).." stop_guard_until="..tostring(state.rain_stop_guard_until) -- end -- end -- audit("weather.preset", "preset="..tostring(name).." result="..tostring(ok).." reason="..tostring(reason)..detail) -+ weather_service.apply_preset(name) - end - local function tile(icon,key,fallback,fn) - local clicked=ui.tile(0,0,ui.EMPTY_SLOT,icon,ui.EMPTY_SLOT,ui.tr(key,fallback),nil,false,{target_size=18,max_scale=2.5}) -@@ -63,13 +52,13 @@ - if weather_service.is_locked() then actions[#actions+1]={label=ui.tr("$mcm_weather_release","RELEASE")} end - local clicked=ui.button_grid(actions,math.max(32,panel_width-10)) - if clicked==1 then advanced=true -- elseif clicked==2 and weather_service.is_locked() then local ok=weather_service.release(); audit("weather.release", "result="..tostring(ok)) end -+ elseif clicked==2 and weather_service.is_locked() then weather_service.release() end - else - local actions={{label=ui.tr("$mcm_weather_back","BACK")}} - if weather_service.is_locked() then actions[#actions+1]={label=ui.tr("$mcm_weather_release","RELEASE")} end - local clicked=ui.button_grid(actions,math.max(32,panel_width-10)) - if clicked==1 then advanced=false -- elseif clicked==2 and weather_service.is_locked() then local ok=weather_service.release(); audit("weather.release", "result="..tostring(ok)) end -+ elseif clicked==2 and weather_service.is_locked() then weather_service.release() end - local scroll_height=ui.scroll_height(screen_height,72) - local scroll=ui.begin_scroll_viewport("weather.advanced",12100,0,0,panel_width-4,scroll_height) - for _,field in ipairs(weather_service.fields()) do diff --git a/metamorph_creative_menu/tools/player_release/20-ui-05.patch b/metamorph_creative_menu/tools/player_release/20-ui-05.patch deleted file mode 100644 index c2cbd2d..0000000 --- a/metamorph_creative_menu/tools/player_release/20-ui-05.patch +++ /dev/null @@ -1,36 +0,0 @@ ---- metamorph_creative_menu/files/ui/tabs/world_rules.lua -+++ metamorph_creative_menu/files/ui/tabs/world_rules.lua -@@ -1,6 +1,5 @@ - local world_rules_tab = {} - local ui = dofile("mods/metamorph_creative_menu/files/ui/runtime.lua") --local audit = ui.audit - local world_rule_service = dofile("mods/metamorph_creative_menu/files/features/world_rules/service.lua") - - local search = "" -@@ -34,7 +33,7 @@ - local call_ok, result, reason = pcall(fn, ...) - if not call_ok then - local detail=tostring(result) -- audit(action_name, "result=false reason=exception:" .. detail) -+ - ui.report_error_once("world_rules.action", ui.tr("$mcm_rules_error", "RULE ERROR"), detail, "ui.rules.action") - return false, "exception" - end -@@ -54,7 +53,7 @@ - ui.tr("$mcm_confirm", "CONFIRM") .. ": " .. reset_label, nil, math.max(32,panel_width-10)) - then - local ok, reason = run_rule_action("rules.reset", world_rule_service.reset) -- audit("rules.reset", "result="..tostring(ok).." reason="..tostring(reason)) -+ - end - if not can_edit then ui.white_text(0, 0, ui.tr("$mcm_rules_unavailable", "World-rule editing unavailable")) end - search = ui.search_input(search, math.max(68, panel_width - 28), 64, "world_rules") -@@ -84,7 +83,7 @@ - if can_edit and (clicked or right) then - local ok, reason - ok, reason = run_rule_action("rule.step", world_rule_service.step, rule, right and -1 or 1) -- audit("rule.step", "id="..tostring(rule.id).." direction="..tostring(right and -1 or 1).." result="..tostring(ok).." reason="..tostring(reason)) -+ - end - end - ui.end_scroll_viewport(scroll, math.ceil(visible / columns) * ui.ICON_STEP) diff --git a/metamorph_creative_menu/tools/player_release/30-data-01.patch b/metamorph_creative_menu/tools/player_release/30-data-01.patch deleted file mode 100644 index d4c713a..0000000 --- a/metamorph_creative_menu/tools/player_release/30-data-01.patch +++ /dev/null @@ -1,26 +0,0 @@ ---- metamorph_creative_menu/translations.csv -+++ metamorph_creative_menu/translations.csv -@@ -171,15 +171,7 @@ - mcm_rule_day_speed,DAY CYCLE SPEED,СКОРОСТЬ СУТОК,VELOCIDADE DO DIA,VELOCIDAD DEL DÍA,TAGESZYKLUS,VITESSE DU CYCLE JOUR,VELOCITÀ DEL GIORNO,SZYBKOŚĆ DOBY,昼夜循环速度,昼夜サイクル速度,낮밤 주기 속도,,, - mcm_rule_day_speed_desc,Multiplier for the natural day-night cycle speed,Множитель естественной скорости смены дня и ночи,Multiplica a velocidade natural do ciclo dia/noite,Multiplica la velocidad natural del ciclo día/noche,Faktor für die natürliche Tag-Nacht-Geschwindigkeit,Multiplicateur de vitesse naturelle jour/nuit,Moltiplica la velocità naturale giorno/notte,Mnożnik naturalnej prędkości dnia i nocy,自然昼夜循环速度倍率,自然な昼夜サイクル速度倍率,자연 낮밤 주기 속도 배율,,, - mcm_setting_possession_key,POSSESSION KEY,КЛАВИША ВСЕЛЕНИЯ,TECLA DE POSSESSÃO,TECLA DE POSESIÓN,BESITZERGREIFUNGS-TASTE,TOUCHE DE POSSESSION,TASTO POSSESSIONE,KLAWISZ OPĘTANIA,附身按键,憑依キー,빙의 키,,, --mcm_mobs_mode_play,PLAY,ИГРА,JOGAR,JUGAR,SPIELEN,JOUER,GIOCA,GRAJ,游玩,プレイ,플레이,,, --mcm_mobs_mode_log_retest,LOG RETEST,ЛОГ: ПОВТОР,LOG RETESTE,REG. REPRUEBA,LOG RETEST,LOG RETEST,LOG RITEST,LOG TESTUJ,记录复测,再テストログ,재검사 기록,,, --mcm_mobs_review_help,REVIEW: click a mob to log its exact path; no transform/spawn occurs,"ПРОВЕРКА: нажмите моба, чтобы записать точный путь в лог; спавн и превращение не выполняются",REVISÃO: clique num mob para registrar o caminho exato; sem spawn/transformação,REVISIÓN: pulsa un mob para registrar su ruta exacta; no se genera ni transforma,"PRÜFUNG: Mob anklicken, um den exakten Pfad zu protokollieren; kein Spawn/Verwandeln",REVUE : cliquez sur un mob pour journaliser son chemin exact ; aucun spawn/transformation,REVISIONE: clicca un mob per registrare il percorso esatto; nessuno spawn/trasformazione,"PRZEGLĄD: kliknij moba, aby zapisać dokładną ścieżkę; bez spawnu/przemiany",审查:点击生物以记录精确路径;不会生成或变形,確認: モブをクリックして正確なパスをログ記録。スポーン/変身はしません,검토: 몹을 클릭해 정확한 경로를 기록합니다. 소환/변신은 하지 않습니다,,, --mcm_mobs_review_click,CLICK: log,КЛИК: записать,CLIQUE: registrar,CLIC: registrar,KLICK: loggen,CLIC : journaliser,CLIC: registra,KLIK: zapisz,点击:记录,クリック: 記録,클릭: 기록,,, --mcm_mobs_marked,MARKED,ОТМЕЧЕНО,MARCADO,MARCADO,MARKIERT,MARQUÉ,SEGNATO,OZNACZONO,已标记,マーク済み,표시됨,,, - mcm_mobs_showing,SHOWING,ПОКАЗАНО,MOSTRANDO,MOSTRANDO,ANZEIGE,AFFICHÉS,VISUALIZZATI,POKAZANO,显示,表示,표시,,, --mcm_mobs_review_logged,MOBS REVIEW,ПРОВЕРКА МОБОВ,REVISÃO MOBS,REVISIÓN MOBS,MOBS-PRÜFUNG,REVUE MOBS,REVISIONE MOBS,PRZEGLĄD MOBÓW,生物审查,モブ確認,몹 검토,,, --mcm_mobs_mode_log_safe,LOG SAFE,ЛОГ: РАБОТАЕТ,LOG SEGURO,REG. SEGURO,LOG SICHER,LOG SÛR,LOG SICURO,LOG DZIAŁA,记录安全,安全ログ,안전 기록,,, --mcm_mobs_mode_log_unsafe,LOG UNSAFE,ЛОГ: НЕ РАБОТАЕТ,LOG INSEGURO,REG. INSEGURO,LOG UNSICHER,LOG RISQUÉ,LOG NON SICURO,LOG NIE DZIAŁA,记录不安全,危険ログ,위험 기록,,, - mcm_mobs_scanning,SCANNING...,ПРОВЕРКА...,A VERIFICAR...,ESCANEANDO...,PRÜFUNG...,ANALYSE...,ANALISI...,SKANOWANIE...,扫描中...,スキャン中...,스캔 중...,,, - mcm_perk_apply_failed,Could not apply perk,Не удалось применить перк,Não foi possível aplicar o benefício,No se pudo aplicar el perk,Perk konnte nicht angewendet werden,Impossible d’appliquer l’atout,Impossibile applicare il perk,Nie udało się zastosować perka,无法应用天赋,パークを適用できませんでした,퍽을 적용하지 못했습니다,,, - mcm_weather_unavailable,Weather editing unavailable,Редактирование погоды недоступно,Edição do clima indisponível,La edición del clima no está disponible,Wetterbearbeitung nicht verfügbar,Modification de la météo indisponible,Modifica del meteo non disponibile,Edycja pogody jest niedostępna,天气编辑不可用,天候編集を利用できません,날씨 편집을 사용할 수 없습니다,,, -@@ -298,7 +290,6 @@ - mcm_wand_level,LEVEL,УРОВЕНЬ,NÍVEL,NIVEL,STUFE,NIVEAU,LIVELLO,POZIOM,等级,レベル,레벨,,, - mcm_wand_shuffle,SHUFFLE,ПЕРЕМЕШИВАНИЕ,EMBARALHAR,BARAJAR,MISCHEN,MÉLANGER,MESCOLA,LOSOWANIE,乱序,シャッフル,셔플,,, - mcm_wand_never_reload,NO RELOAD,БЕЗ ПЕРЕЗАРЯДКИ,SEM RECARGA,SIN RECARGA,KEIN AUFLADEN,SANS RECHARGE,NESSUNA RICARICA,BEZ PRZEŁADOWANIA,无需充能,リロードなし,재충전 없음,,, --mcm_wand_edit_error,Could not change wand,Не удалось изменить жезл,Não foi possível alterar a varinha,No se pudo cambiar la varita,Zauberstab konnte nicht geändert werden,Impossible de modifier la baguette,Impossibile modificare la bacchetta,Nie udało się zmienić różdżki,无法修改法杖,杖を変更できませんでした,완드를 변경할 수 없습니다,,, - mcm_wand_presets,WAND PRESETS,СОХРАНЁННЫЕ ЖЕЗЛЫ,VARINHAS SALVAS,VARITAS GUARDADAS,STAB-VORLAGEN,BAGUETTES SAUVÉES,BACCHETTE SALVATE,ZAPISANE RÓŻDŻKI,法杖预设,杖プリセット,완드 프리셋,,, - mcm_wand_no_presets,No saved wands,Нет сохранённых жезлов,Nenhuma varinha salva,No hay varitas guardadas,Keine gespeicherten Stäbe,Aucune baguette sauvée,Nessuna bacchetta salvata,Brak zapisanych różdżek,没有已保存法杖,保存済みの杖なし,저장된 완드 없음,,, - mcm_wand_preset_error,Preset error,Ошибка сохранения,Erro de predefinição,Error de preajuste,Vorlagenfehler,Erreur de préréglage,Errore preset,Błąd presetu,预设错误,プリセットエラー,프리셋 오류,,, diff --git a/metamorph_creative_menu/tools/player_release/30-data-02.patch b/metamorph_creative_menu/tools/player_release/30-data-02.patch deleted file mode 100644 index e427ad6..0000000 --- a/metamorph_creative_menu/tools/player_release/30-data-02.patch +++ /dev/null @@ -1,23 +0,0 @@ ---- metamorph_creative_menu/translations.csv -+++ metamorph_creative_menu/translations.csv -@@ -357,20 +348,5 @@ - mcm_remove,REMOVE,УБРАТЬ,REMOVER,QUITAR,ENTFERNEN,RETIRER,RIMUOVI,USUŃ,移除,削除,제거,,, - mcm_effect_electrocution,Electrocuted,Наэлектризованность,Eletrocutado,Electrocutado,Stromschlag,Électrocution,Elettrocuzione,Porażenie prądem,触电,感電,감전,,, - mcm_effect_electrocution_desc,Electrical stun.,Электрическое оглушение.,Atordoamento elétrico.,Aturdimiento eléctrico.,Elektrische Betäubung.,Étourdissement électrique.,Stordimento elettrico.,Ogłuszenie elektryczne.,电击眩晕。,電撃スタン。,전기 기절.,,, --mcm_qa_title,Metamorph: Creative Menu QA,Metamorph: Creative Menu — проверка,Metamorph: Creative Menu QA,Control de Metamorph: Creative Menu,Metamorph: Creative Menu QA,Test de Metamorph: Creative Menu,Test Metamorph: Creative Menu,Test Metamorph: Creative Menu,Metamorph: Creative Menu 测试,Metamorph: Creative Menu QA,Metamorph: Creative Menu QA,,, --mcm_qa_summary,Result,Результат,Resultado,Resultado,Ergebnis,Résultat,Risultato,Wynik,结果,結果,결과,,, --mcm_qa_pass,pass,успех,sucesso,correcto,bestanden,réussite,superati,zaliczone,通过,成功,통과,,, --mcm_qa_warn,warn,предупр.,avisos,avisos,Warnungen,avert.,avvisi,ostrzeż.,警告,警告,경고,,, --mcm_qa_fail,fail,ошибки,falhas,fallos,Fehler,échecs,errori,błędy,失败,失敗,실패,,, --mcm_qa_already_running,Metamorph: Creative Menu QA is already running,Проверка Metamorph: Creative Menu уже запущена,O teste do Metamorph: Creative Menu já está em execução,La comprobación de Metamorph: Creative Menu ya está en ejecución,Metamorph: Creative Menu QA läuft bereits,Le test de Metamorph: Creative Menu est déjà en cours,Il test di Metamorph: Creative Menu è già in esecuzione,Test Metamorph: Creative Menu już działa,Metamorph: Creative Menu 测试已在运行,Metamorph: Creative Menu のテストは既に実行中です,Metamorph: Creative Menu QA가 이미 실행 중입니다,,, --mcm_qa_no_player,Metamorph: Creative Menu QA: no player,Проверка Metamorph: Creative Menu: игрок не найден,Metamorph: Creative Menu QA: jogador não encontrado,Control de Metamorph: Creative Menu: no se encontró jugador,Metamorph: Creative Menu QA: kein Spieler,Test de Metamorph: Creative Menu : joueur introuvable,Test Metamorph: Creative Menu: giocatore non trovato,Test Metamorph: Creative Menu: brak gracza,Metamorph: Creative Menu 测试:未找到玩家,Metamorph: Creative Menu QA:プレイヤーが見つかりません,Metamorph: Creative Menu QA: 플레이어를 찾을 수 없습니다,,, --mcm_qa_preflight_failed,Scenario preflight failed; diagnostic scan is still running,Предварительная проверка сценария не пройдена; диагностика продолжает работу,A verificação preliminar do cenário falhou; o diagnóstico continua,Falló la comprobación previa del escenario; el diagnóstico sigue ejecutándose,Szenario-Vorprüfung fehlgeschlagen; Diagnose läuft weiter,La vérification préalable du scénario a échoué ; le diagnostic continue,Controllo preliminare dello scenario fallito; la diagnostica continua,Wstępna kontrola scenariusza nie powiodła się; diagnostyka trwa,场景预检失败;诊断仍在运行,シナリオ事前チェックに失敗しました。診断は継続中です,시나리오 사전 검사가 실패했습니다. 진단은 계속 실행됩니다,,, --mcm_qa_items_incomplete,Representative item set is incomplete; diagnostic scan is still running,Набор тестовых предметов неполон; диагностика продолжает работу,O conjunto representativo de itens está incompleto; o diagnóstico continua,El conjunto representativo de objetos está incompleto; el diagnóstico sigue ejecutándose,Repräsentativer Gegenstandssatz ist unvollständig; Diagnose läuft weiter,Le jeu d’objets représentatif est incomplet ; le diagnostic continue,Il set rappresentativo di oggetti è incompleto; la diagnostica continua,Reprezentatywny zestaw przedmiotów jest niepełny; diagnostyka trwa,代表性物品集不完整;诊断仍在运行,代表アイテム一式が不完全です。診断は継続中です,대표 아이템 세트가 불완전합니다. 진단은 계속 실행됩니다,,, --mcm_qa_started,Started. Z runs the full scenario test; diagnostics run in parallel.,Запущено. Z запускает полный сценарный тест; диагностика идёт параллельно.,Iniciado. Z executa o teste completo; o diagnóstico roda em paralelo.,Iniciado. Z ejecuta la prueba completa; el diagnóstico se ejecuta en paralelo.,Gestartet. Z führt den vollständigen Szenariotest aus; Diagnose läuft parallel.,Démarré. Z lance le test complet ; le diagnostic s’exécute en parallèle.,Avviato. Z esegue il test completo; la diagnostica procede in parallelo.,Uruchomiono. Z wykonuje pełny test scenariusza; diagnostyka działa równolegle.,已启动。Z 运行完整场景测试;诊断并行运行。,開始しました。Z で完全なシナリオテストを実行し、診断も並行して行います。,시작되었습니다. Z는 전체 시나리오 테스트를 실행하며 진단도 병렬로 실행됩니다.,,, --mcm_diag_title,Metamorph: Creative Menu diagnostics,Диагностика Metamorph: Creative Menu,Diagnóstico do Metamorph: Creative Menu,Diagnóstico de Metamorph: Creative Menu,Metamorph: Creative Menu Diagnose,Diagnostic de Metamorph: Creative Menu,Diagnostica Metamorph: Creative Menu,Diagnostyka Metamorph: Creative Menu,Metamorph: Creative Menu 诊断,Metamorph: Creative Menu 診断,Metamorph: Creative Menu 진단,,, --mcm_diag_already_running,Metamorph: Creative Menu diagnostics are already running,Диагностика Metamorph: Creative Menu уже запущена,O diagnóstico do Metamorph: Creative Menu já está em execução,El diagnóstico de Metamorph: Creative Menu ya está en ejecución,Metamorph: Creative Menu Diagnose läuft bereits,Le diagnostic de Metamorph: Creative Menu est déjà en cours,La diagnostica Metamorph: Creative Menu è già in esecuzione,Diagnostyka Metamorph: Creative Menu już działa,Metamorph: Creative Menu 诊断已在运行,Metamorph: Creative Menu の診断は既に実行中です,Metamorph: Creative Menu 진단이 이미 실행 중입니다,,, --mcm_diag_started,Started (Z). Non-destructive scan and frame sampling are running.,Запущено (Z). Идут безопасное сканирование и замеры кадров.,Iniciado (Z). Varredura não destrutiva e amostragem de quadros em execução.,Iniciado (Z). Se ejecutan el análisis no destructivo y el muestreo de fotogramas.,Gestartet (Z). Zerstörungsfreier Scan und Frame-Sampling laufen.,Démarré (Z). Analyse non destructive et échantillonnage des images en cours.,Avviato (Z). Scansione non distruttiva e campionamento dei frame in corso.,Uruchomiono (Z). Trwa bezpieczne skanowanie i próbkowanie klatek.,已启动 (Z)。正在进行非破坏性扫描和帧采样。,開始 (Z)。非破壊スキャンとフレームサンプリングを実行中です。,시작됨 (Z). 비파괴 스캔과 프레임 샘플링을 실행 중입니다.,,, --mcm_diag_write_failed,Could not write diagnostic report,Не удалось записать отчёт диагностики,Não foi possível gravar o relatório de diagnóstico,No se pudo escribir el informe de diagnóstico,Diagnosebericht konnte nicht geschrieben werden,Impossible d’écrire le rapport de diagnostic,Impossibile scrivere il rapporto diagnostico,Nie udało się zapisać raportu diagnostycznego,无法写入诊断报告,診断レポートを書き込めませんでした,진단 보고서를 기록할 수 없습니다,,, --mcm_diag_done,Done,Готово,Concluído,Hecho,Fertig,Terminé,Fatto,Gotowe,完成,完了,완료,,, - mcm_kolmi_encounter_failed,MCM: Kolmisilma encounter failed,MCM: не удалось запустить бой с Колмисилмой,MCM: falha ao iniciar o encontro com Kolmisilma,MCM: no se pudo iniciar el encuentro con Kolmisilma,MCM: Kolmisilma-Begegnung fehlgeschlagen,MCM : échec du lancement du combat contre Kolmisilma,MCM: impossibile avviare lo scontro con Kolmisilma,MCM: nie udało się uruchomić starcia z Kolmisilmą,MCM:Kolmisilma 战斗启动失败,MCM:Kolmisilma 戦を開始できませんでした,MCM: Kolmisilma 전투를 시작하지 못했습니다,,, - mcm_kolmi_encounter_failed_desc,Could not start the stock boss encounter,Не удалось запустить штатный сценарий босса,Não foi possível iniciar o encontro original do chefe,No se pudo iniciar el encuentro original del jefe,Originale Boss-Begegnung konnte nicht gestartet werden,Impossible de lancer le combat de boss d’origine,Impossibile avviare lo scontro originale del boss,Nie udało się uruchomić oryginalnego starcia z bossem,无法启动原版 Boss 战流程,標準のボス戦を開始できませんでした,기본 보스 전투를 시작할 수 없습니다,,, diff --git a/metamorph_creative_menu/tools/player_release/README.md b/metamorph_creative_menu/tools/player_release/README.md deleted file mode 100644 index a703856..0000000 --- a/metamorph_creative_menu/tools/player_release/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# Player release cleanup rules - -This directory contains the release-only diffs applied after the full development tree has been copied into the temporary player-build stage. - -The repository keeps QA, diagnostics, tests, profiling hooks and other development support in the full source tree. The ready-to-install player archive intentionally removes that support without making the development checkout less useful. - -## Ordering - -Patch names define their application order: - -- `00-runtime-*` — general runtime cleanup and dev-only hooks; -- `10-ew-*` — Entangled Worlds instrumentation and development surfaces; -- `20-ui-*` — UI-only audit/debug plumbing; -- `30-data-*` — player-facing data and localization cleanup. - -The order is significant. A later patch may assume that an earlier cleanup has already changed the staged file. - -## What belongs here - -A release patch may remove development-only behavior that must remain available in the full source, for example: - -- QA or diagnostic wiring; -- profiling and metrics controls; -- test-only accessors; -- audit fields used only by development tools; -- localization strings used only by those development features. - -Release patches must not be used to hide ordinary gameplay fixes. A gameplay fix belongs in the source file itself so tests, development builds and player builds execute the same behavior. - -## Builder behavior - -`tools/build_user_release.py` first excludes development-only files/directories and strips Lua comments. It then applies the patches in this directory with zero fuzz. - -Before applying a patch, the builder performs a dry run. If the forward patch no longer applies but the complete patch applies cleanly in reverse, the builder treats that cleanup as already integrated into the source and skips the patch. This lets a later source revision permanently adopt a former release-only cleanup without requiring a dummy patch. - -If neither direction matches, the build stops. Do not make the patch command permissive just to get a release out: inspect the changed source and either port the cleanup to the new code or retire the patch when the source has genuinely absorbed it. - -## Updating a cleanup after source changes - -1. Run the complete source regression suite first. -2. Build the player archive with `tools/build_user_release.py`. -3. If a patch no longer matches, inspect the affected source and the intended player result. -4. Update or retire only that cleanup rule. -5. Run `tools/validate_user_release.py` on the resulting archive. -6. Re-run the full repository release workflow before publishing. - -For Entangled Worlds changes, preserve RPC registration order and protocol compatibility. A release cleanup may neutralize development-only protocol behavior, but it must not shift an existing reserved slot or silently reorder RPC allocation. - -## Final validation - -The patch set is not the final authority on cleanliness. `tools/validate_user_release.py` also rejects development directories and build artifacts, checks the minimal player `README.txt`, scans runtime text for known QA/debug residue, verifies archive-path safety and compiles every packaged Lua file with `texlua`. - -The release workflow publishes only after that validation succeeds. diff --git a/metamorph_creative_menu/translations.csv b/metamorph_creative_menu/translations.csv index 851bc76..812851c 100644 --- a/metamorph_creative_menu/translations.csv +++ b/metamorph_creative_menu/translations.csv @@ -4,10 +4,9 @@ mcm_tab_items,ITEMS,ПРЕДМЕТЫ,ITENS,OBJETOS,GEGENST.,OBJETS,OGGETTI,PRZED mcm_tab_perks,PERKS,ПЕРКИ,VANTAGENS,VENTAJAS,PERKS,ATOUTS,VANTAGGI,PERKI,天赋,パーク,퍽,,, mcm_slot,SLOT,СЛОТ,ESPAÇO,RANURA,SLOT,EMPL.,SLOT,SLOT,槽位,スロット,슬롯,,, mcm_delete,DELETE,УДАЛИТЬ,EXCLUIR,BORRAR,LÖSCHEN,SUPPR.,ELIMINA,USUŃ,删除,削除,삭제,,, +mcm_drop,DROP,ВЫБРОСИТЬ,SOLTAR,SOLTAR,FALLENL.,JETER,LASCIA,WYRZUĆ,丢出,捨てる,버리기,,, mcm_empty,Empty,Пусто,Vazio,Vacío,Leer,Vide,Vuoto,Pusto,空,空,비어 있음,,, -mcm_search_placeholder,Search...,Поиск...,Buscar...,Buscar...,Suchen...,Rechercher...,Cerca...,Szukaj...,搜索...,検索...,검색...,,, mcm_left_spawn,LMB: spawn nearby,ЛКМ: создать рядом,LMB: criar perto,LMB: generar cerca,LMB: in der Nähe erzeugen,Clic G: faire apparaître à côté,LMB: genera vicino,LPM: utwórz obok,左键:在附近生成,左クリック:近くに生成,좌클릭: 근처에 생성,,, -mcm_creature_drag_spawn,DRAG: spawn at cursor,ПЕРЕНОС: создать под курсором,ARRASTAR: criar no cursor,ARRASTRAR: generar en el cursor,ZIEHEN: am Zeiger erzeugen,GLISSER : créer au curseur,TRASCINA: genera al cursore,PRZECIĄGNIJ: utwórz przy kursorze,拖动:在光标处生成,ドラッグ:カーソル位置に生成,드래그: 커서 위치에 생성,,, mcm_spell_filter_all,ALL,ВСЕ,TODOS,TODOS,ALLE,TOUS,TUTTI,WSZYSTKIE,全部,すべて,전체,,, mcm_spell_filter_projectiles,PROJECT.,СНАРЯДЫ,PROJÉTEIS,PROYECT.,PROJEKT.,PROJECT.,PROIETT.,POCISKI,投射物,発射物,발사체,,, mcm_spell_filter_static,STATIC,СТАТИЧ.,ESTÁTICOS,ESTÁTICOS,STATISCH,STATIQ.,STATICI,STATYCZ.,静态,静的,고정,,, @@ -28,6 +27,7 @@ mcm_item_filter_bonuses,BONUSES,БОНУСЫ,BÔNUS,BONOS,BONI,BONUS,BONUS,BONUS mcm_item_filter_orbs,ORBS,ОРБЫ,ORBS,ORBES,ORBS,ORBES,SFERE,ORBY,宝珠,オーブ,오브,,, mcm_item_filter_quest,QUEST,КВЕСТ,MISSÃO,MISIÓN,QUEST,QUÊTE,MISSIONE,QUEST,任务,クエスト,퀘스트,,, mcm_item_filter_other,OTHER,ПРОЧЕЕ,OUTROS,OTROS,ANDERE,AUTRES,ALTRO,INNE,其他,その他,기타,,, +mcm_flask,Flask,Фляга,Frasco,Frasco,Flasche,Flacon,Fiaschetta,Butelka,烧瓶,フラスコ,플라스크,,, mcm_jar,Jar,Банка,Jarro,Tarro,Glas,Bocal,Vaso,Słoik,罐子,瓶,병,,, mcm_empty_flask,Empty flask,Пустая фляга,Frasco vazio,Frasco vacío,Leere Flasche,Flacon vide,Fiaschetta vuota,Pusta butelka,空烧瓶,空のフラスコ,빈 플라스크,,, mcm_flask_water,Flask: water,Фляга: вода,Frasco: água,Frasco: agua,Flasche: Wasser,Flacon : eau,Fiaschetta: acqua,Butelka: woda,烧瓶:水,フラスコ:水,플라스크: 물,,, @@ -61,29 +61,25 @@ mcm_creature_filter_animals,ANIMALS,ЖИВОТНЫЕ,ANIMAIS,ANIMALES,TIERE,ANIM mcm_creature_filter_machines,MACHINES,МАШИНЫ,MÁQUINAS,MÁQUINAS,MASCHINEN,MACHINES,MACCHINE,MASZYNY,机械,機械,기계,,, mcm_creature_filter_other,OTHER,ПРОЧИЕ,OUTROS,OTROS,ANDERE,AUTRES,ALTRO,INNE,其他,その他,기타,,, mcm_right_transform,RMB: transform,ПКМ: превратиться,RMB: transformar,RMB: transformar,RMB: verwandeln,Clic D: se transformer,RMB: trasformati,PPM: przemiana,右键:变身,右クリック:変身,우클릭: 변신,,, -mcm_player_clone_left,LMB: create PLAYER clone,ЛКМ: создать копию ИГРОКА,LMB: criar clone do JOGADOR,LMB: crear clon del JUGADOR,LMB: SPIELER-Klon erstellen,Clic G : créer un clone JOUEUR,LMB: crea clone GIOCATORE,LPM: utwórz klona GRACZA,左键:创建玩家克隆,左クリック:プレイヤークローンを作成,좌클릭: 플레이어 클론 생성,,, -mcm_player_clone_right,RMB: human form (no change if human),ПКМ: форма человека (без изменений для человека),RMB: forma humana (sem mudança se humano),RMB: forma humana (sin cambio si ya eres humano),RMB: Menschenform (keine Änderung als Mensch),Clic D : forme humaine (aucun changement si humain),RMB: forma umana (nessun cambio se umano),PPM: ludzka postać (bez zmian gdy już nią jesteś),右键:人类形态(已是人类时不变),右クリック:人間形態(人間なら変化なし),우클릭: 인간 형태 (이미 인간이면 변화 없음),,, mcm_creature_poly_failed,Could not transform,Не удалось превратиться,Não foi possível transformar,No se pudo transformar,Verwandlung fehlgeschlagen,Transformation impossible,Impossibile trasformarsi,Nie udało się przemienić,无法变身,変身できませんでした,변신하지 못했습니다,,, mcm_creature_transformed,Transforming into,Превращение в,Transformando em,Transformando en,Verwandlung in,Transformation en,Trasformazione in,Przemiana w,正在变成,変身先,변신 대상,,, mcm_creatures_failed,Creature list failed to load,Список существ не загрузился,Falha ao carregar a lista de criaturas,No se pudo cargar la lista de criaturas,Kreaturenliste konnte nicht geladen werden,Échec du chargement des créatures,Impossibile caricare l'elenco creature,Nie udało się wczytać listy stworów,生物列表加载失败,クリーチャー一覧を読み込めませんでした,생물 목록을 불러오지 못했습니다,,, -mcm_creature_spawn_failed,Could not spawn creature,Не удалось создать существо,Não foi possível criar a criatura,No se pudo generar la criatura,Kreatur konnte nicht erzeugt werden,Impossible de faire apparaître la créature,Impossibile generare la creatura,Nie udało się utworzyć stwora,无法生成生物,クリーチャーを生成できませんでした,생물을 생성하지 못했습니다,,, -mcm_perk_spawn_failed,Could not spawn perk,Не удалось создать перк,Não foi possível criar a vantagem,No se pudo generar la ventaja,Perk konnte nicht erzeugt werden,Impossible de faire apparaître l’atout,Impossibile generare il vantaggio,Nie udało się utworzyć perku,无法生成天赋,パークを生成できませんでした,퍽을 생성하지 못했습니다,,, mcm_creature_returning_human,Returning to human form,Возвращение в форму человека,Voltando à forma humana,Volviendo a la forma humana,Rückkehr zur Menschenform,Retour à la forme humaine,Ritorno alla forma umana,Powrót do ludzkiej postaci,正在恢复人类形态,人間形態に戻ります,인간 형태로 돌아가는 중,,, +mcm_creature_controls,LMB: SPAWN RMB: TRANSFORM TAB: HUMAN,ЛКМ: СОЗДАТЬ ПКМ: ПРЕВРАТИТЬСЯ TAB: ЧЕЛОВЕК,LMB: CRIAR RMB: TRANSFORMAR TAB: HUMANO,LMB: GENERAR RMB: TRANSFORMAR TAB: HUMANO,LMB: ERZEUGEN RMB: VERWANDELN TAB: MENSCH,Clic G: CRÉER Clic D: TRANSFORMER TAB: HUMAIN,LMB: GENERA RMB: TRASFORMA TAB: UMANO,LPM: UTWÓRZ PPM: PRZEMIANA TAB: CZŁOWIEK,左键:生成 右键:变身 TAB:人类,左クリック:生成 右クリック:変身 TAB: 人間,좌클릭: 생성 우클릭: 변신 TAB: 인간,,, mcm_inventory_full_spawned,Inventory full — spawned nearby,Инвентарь полон — создано рядом,Inventário cheio — criado por perto,Inventario lleno — generado cerca,Inventar voll — in der Nähe erzeugt,Inventaire plein — créé à proximité,Inventario pieno — generato vicino,Ekwipunek pełny — utworzono obok,物品栏已满 — 已在附近生成,インベントリが満杯 — 近くに生成しました,인벤토리가 가득 참 — 근처에 생성됨,,, -mcm_tab_effects,EFFECTS,ЭФФЕКТЫ,EFEITOS,EFECTOS,EFFEKTE,EFFETS,EFFETTI,EFEKTY,效果,効果,효과,,, -mcm_tab_weather,WEATHER,ПОГОДА,CLIMA,CLIMA,WETTER,MÉTÉO,METEO,POGODA,天气,天気,날씨,,, -mcm_effect_remove_all,REMOVE ALL,УБРАТЬ ВСЕ,REMOVER TUDO,QUITAR TODO,ALLE ENTFERNEN,TOUT RETIRER,RIMUOVI TUTTI,USUŃ WSZYSTKO,全部移除,すべて削除,모두 제거,,, -mcm_effect_controls,LMB: APPLY RMB: REMOVE,ЛКМ: ПРИМЕНИТЬ ПКМ: УБРАТЬ,LMB: APLICAR RMB: REMOVER,LMB: APLICAR RMB: QUITAR,LMB: ANWENDEN RMB: ENTFERNEN,Clic G : APPLIQUER Clic D : RETIRER,LMB: APPLICA RMB: RIMUOVI,LPM: ZASTOSUJ PPM: USUŃ,左键:应用 右键:移除,左クリック:付与 右クリック:削除,좌클릭: 적용 우클릭: 제거,,, -mcm_effects_failed,Effect list failed to load,Не удалось загрузить список эффектов,Falha ao carregar a lista de efeitos,No se pudo cargar la lista de efectos,Effektliste konnte nicht geladen werden,Échec du chargement des effets,Impossibile caricare l'elenco effetti,Nie udało się wczytać listy efektów,效果列表加载失败,効果一覧を読み込めませんでした,효과 목록을 불러오지 못했습니다,,, -mcm_weather_clear,CLEAR,ЯСНО,LIMPO,DESPEJADO,KLAR,DÉGAGÉ,SERENO,BEZCHMURNIE,晴朗,晴れ,맑음,,, -mcm_weather_night,NIGHT,НОЧЬ,NOITE,NOCHE,NACHT,NUIT,NOTTE,NOC,夜晚,夜,밤,,, -mcm_weather_storm,STORM,ШТОРМ,TEMPESTADE,TORMENTA,STURM,TEMPÊTE,TEMPESTA,BURZA,风暴,嵐,폭풍,,, -mcm_weather_time,Time of day,Время суток,Hora do dia,Hora del día,Tageszeit,Heure du jour,Ora del giorno,Pora dnia,时间,時間帯,시간대,,, -mcm_weather_clouds,Cloud cover,Облачность,Cobertura de nuvens,Nubosidad,Wolkendecke,Couverture nuageuse,Copertura nuvolosa,Zachmurzenie,云量,雲量,구름 양,,, -mcm_weather_fog,Fog,Туман,Névoa,Niebla,Nebel,Brouillard,Nebbia,Mgła,雾,霧,안개,,, -mcm_weather_wind,Wind,Ветер,Vento,Viento,Wind,Vent,Vento,Wiatr,风,風,바람,,, -mcm_weather_wind_speed,Wind speed,Скорость ветра,Velocidade do vento,Velocidad del viento,Windgeschwindigkeit,Vitesse du vent,Velocità del vento,Prędkość wiatru,风速,風速,풍속,,, -mcm_weather_lightning,Lightning,Молнии,Raios,Relámpagos,Blitze,Foudre,Fulmini,Błyskawice,闪电,雷,번개,,, +mcm_tab_effects,EFFECTS,ЭФФЕКТЫ,EFFECTS,EFFECTS,EFFECTS,EFFECTS,EFFECTS,EFFECTS,EFFECTS,EFFECTS,EFFECTS,,, +mcm_tab_weather,WEATHER,ПОГОДА,WEATHER,WEATHER,WEATHER,WEATHER,WEATHER,WEATHER,WEATHER,WEATHER,WEATHER,,, +mcm_effect_remove_all,REMOVE ALL,УБРАТЬ ВСЕ,REMOVE ALL,REMOVE ALL,REMOVE ALL,REMOVE ALL,REMOVE ALL,REMOVE ALL,REMOVE ALL,REMOVE ALL,REMOVE ALL,,, +mcm_effects_failed,Effect list failed to load,Не удалось загрузить список эффектов,Effect list failed to load,Effect list failed to load,Effect list failed to load,Effect list failed to load,Effect list failed to load,Effect list failed to load,Effect list failed to load,Effect list failed to load,Effect list failed to load,,, +mcm_weather_clear,CLEAR,ЯСНО,CLEAR,CLEAR,CLEAR,CLEAR,CLEAR,CLEAR,CLEAR,CLEAR,CLEAR,,, +mcm_weather_night,NIGHT,НОЧЬ,NIGHT,NIGHT,NIGHT,NIGHT,NIGHT,NIGHT,NIGHT,NIGHT,NIGHT,,, +mcm_weather_storm,STORM,ШТОРМ,STORM,STORM,STORM,STORM,STORM,STORM,STORM,STORM,STORM,,, +mcm_weather_time,Time of day,Время суток,Time of day,Time of day,Time of day,Time of day,Time of day,Time of day,Time of day,Time of day,Time of day,,, +mcm_weather_clouds,Cloud cover,Облачность,Cloud cover,Cloud cover,Cloud cover,Cloud cover,Cloud cover,Cloud cover,Cloud cover,Cloud cover,Cloud cover,,, +mcm_weather_fog,Fog,Туман,Fog,Fog,Fog,Fog,Fog,Fog,Fog,Fog,Fog,,, +mcm_weather_wind,Wind,Ветер,Wind,Wind,Wind,Wind,Wind,Wind,Wind,Wind,Wind,,, +mcm_weather_wind_speed,Wind speed,Скорость ветра,Wind speed,Wind speed,Wind speed,Wind speed,Wind speed,Wind speed,Wind speed,Wind speed,Wind speed,,, +mcm_weather_lightning,Lightning,Молнии,Lightning,Lightning,Lightning,Lightning,Lightning,Lightning,Lightning,Lightning,Lightning,,, mcm_perk_mode_add,ADD,ДОБАВИТЬ,ADICIONAR,AÑADIR,HINZUFÜGEN,AJOUTER,AGGIUNGI,DODAJ,添加,追加,추가,,, mcm_perk_mode_remove,REMOVE,УБРАТЬ,REMOVER,QUITAR,ENTFERNEN,RETIRER,RIMUOVI,USUŃ,移除,削除,제거,,, mcm_perk_remove_controls,LMB: -1 RMB: REMOVE ALL,ЛКМ: -1 ПКМ: УБРАТЬ ВСЕ,LMB: -1 RMB: REMOVER TUDO,LMB: -1 RMB: QUITAR TODO,LMB: -1 RMB: ALLE ENTFERNEN,Clic G: -1 Clic D: TOUT RETIRER,LMB: -1 RMB: RIMUOVI TUTTI,LPM: -1 PPM: USUŃ WSZYSTKIE,左键:-1 右键:全部移除,左クリック: -1 右クリック: 全削除,좌클릭: -1 우클릭: 모두 제거,,, @@ -95,7 +91,6 @@ mcm_effect_filter_all,ALL,ВСЕ,TODOS,TODOS,ALLE,TOUS,TUTTI,WSZYSTKIE,全部, mcm_effect_filter_status,STATUS,СОСТОЯНИЯ,STATUS,ESTADOS,STATUS,ÉTATS,STATI,STATUSY,状态,状態,상태,,, mcm_effect_filter_timed,EFFECTS,ЭФФЕКТЫ,EFEITOS,EFECTOS,EFFEKTE,EFFETS,EFFETTI,EFEKTY,效果,効果,효과,,, mcm_effect_duration,DURATION,ДЛИТЕЛЬНОСТЬ,DURAÇÃO,DURACIÓN,DAUER,DURÉE,DURATA,CZAS,持续时间,時間,지속시간,,, -mcm_effect_strength,STRENGTH,СИЛА,FORÇA,FUERZA,STÄRKE,INTENSITÉ,INTENSITÀ,SIŁA,强度,強さ,강도,,, mcm_effect_duration_10s,10s,10с,10s,10s,10s,10s,10s,10s,10秒,10秒,10초,,, mcm_effect_duration_30s,30s,30с,30s,30s,30s,30s,30s,30s,30秒,30秒,30초,,, mcm_effect_duration_60s,60s,60с,60s,60s,60s,60s,60s,60s,60秒,60秒,60초,,, @@ -119,11 +114,15 @@ mcm_weather_mode_local,LOCAL,ЛОКАЛЬНО,LOCAL,LOCAL,LOKAL,LOCAL,LOCALE,LOK mcm_weather_locked,LOCKED,ЗАКРЕПЛЕНО,FIXADO,FIJADO,FIXIERT,VERROUILLÉ,BLOCCATO,ZABLOKOWANE,已锁定,固定中,고정됨,,, mcm_creature_already_human,Already in player form,Уже в форме игрока,Já está na forma de jogador,Ya estás en forma de jugador,Bereits in Spielerform,Déjà sous forme de joueur,Già in forma giocatore,Już jesteś w formie gracza,已经是玩家形态,すでにプレイヤー形態です,이미 플레이어 형태입니다,,, mcm_creature_player,PLAYER,ИГРОК,JOGADOR,JUGADOR,SPIELER,JOUEUR,GIOCATORE,GRACZ,玩家,プレイヤー,플레이어,,, +mcm_creature_filter_objects,OBJECTS,ОБЪЕКТЫ,OBJETOS,OBJETOS,OBJEKTE,OBJETS,OGGETTI,OBIEKTY,对象,オブジェクト,오브젝트,,, mcm_weather_rainfall,Rain,Дождь,Chuva,Lluvia,Regen,Pluie,Pioggia,Deszcz,降雨,雨,비,,, -mcm_item_controls,LMB: SPAWN DRAG: DROP RMB: TAKE,ЛКМ: СОЗДАТЬ ПЕРЕТАЩИТЬ: БРОСИТЬ ПКМ: ВЗЯТЬ,LMB: CRIAR ARRASTAR: SOLTAR RMB: PEGAR,LMB: GENERAR ARRASTRAR: SOLTAR RMB: TOMAR,LMB: ERZEUGEN ZIEHEN: ABLEGEN RMB: NEHMEN,Clic G: CRÉER GLISSER: DÉPOSER Clic D: PRENDRE,LMB: GENERA TRASCINA: RILASCIA RMB: PRENDI,LPM: UTWÓRZ PRZECIĄGNIJ: UPUŚĆ PPM: WEŹ,左键:生成 拖动:放置 右键:获取,左クリック:生成 ドラッグ:配置 右クリック:取得,좌클릭: 생성 드래그: 놓기 우클릭: 획득,,, +mcm_tab_projectiles,PROJECTILES,СНАРЯДЫ,PROJÉTEIS,PROYECTILES,PROJEKTILE,PROJECTILES,PROIETTILI,POCISKI,投射物,投射物,발사체,,, +mcm_item_controls,LMB: SPAWN RMB: TAKE,ЛКМ: СОЗДАТЬ ПКМ: ВЗЯТЬ,LMB: CRIAR RMB: PEGAR,LMB: GENERAR RMB: TOMAR,LMB: ERZEUGEN RMB: NEHMEN,Clic G: CRÉER Clic D: PRENDRE,LMB: GENERA RMB: PRENDI,LPM: UTWÓRZ PPM: WEŹ,左键:生成 右键:获取,左クリック:生成 右クリック:取得,좌클릭: 생성 우클릭: 획득,,, +mcm_entity_form_failed,Could not transform into entity,Не удалось превратиться в сущность,Não foi possível transformar na entidade,No se pudo transformar en la entidad,Verwandlung in Entität fehlgeschlagen,Transformation en entité impossible,Impossibile trasformarsi nell'entità,Nie udało się przemienić w encję,无法变成实体,エンティティに変身できませんでした,엔티티로 변신하지 못했습니다,,, mcm_item_receive_failed,Item was not added to inventory,Предмет не был добавлен в инвентарь,O item não foi adicionado ao inventário,El objeto no se añadió al inventario,Gegenstand wurde nicht ins Inventar gelegt,L'objet n'a pas été ajouté à l'inventaire,L'oggetto non è stato aggiunto all'inventario,Przedmiot nie został dodany do ekwipunku,物品未加入背包,アイテムをインベントリに追加できませんでした,아이템이 인벤토리에 추가되지 않았습니다,,, mcm_tab_runtime_error,This section could not be drawn,Не удалось отрисовать этот раздел,Não foi possível desenhar esta seção,No se pudo mostrar esta sección,Dieser Bereich konnte nicht gezeichnet werden,Impossible d'afficher cette section,Impossibile disegnare questa sezione,Nie udało się wyświetlić tej sekcji,无法绘制此页面,このセクションを描画できませんでした,이 섹션을 표시하지 못했습니다,,, mcm_tab_rules,RULES,ПРАВИЛА,REGRAS,REGLAS,REGELN,RÈGLES,REGOLE,ZASADY,规则,ルール,규칙,,, +mcm_rules_title,WORLD RULES,ПРАВИЛА МИРА,REGRAS DO MUNDO,REGLAS DEL MUNDO,WELTREGELN,RÈGLES DU MONDE,REGOLE DEL MONDO,ZASADY ŚWIATA,世界规则,ワールドルール,세계 규칙,,, mcm_rule_gold_forever,GOLD NEVER EXPIRES,ЗОЛОТО НЕ ИСЧЕЗАЕТ,OURO NÃO DESAPARECE,EL ORO NO DESAPARECE,GOLD VERSCHWINDET NICHT,OR PERMANENT,ORO PERMANENTE,ZŁOTO NIE ZNIKA,金币不消失,金塊が消えない,금이 사라지지 않음,,, mcm_rule_gold_forever_desc,Dropped gold remains in the world,Выпавшее золото остаётся в мире,O ouro derrubado permanece,El oro caído permanece,Gefallenes Gold bleibt liegen,L’or lâché reste dans le monde,L’oro lasciato resta nel mondo,Upuszczone złoto pozostaje,掉落金币保留,落ちた金塊が残る,떨어진 금이 유지됨,,, mcm_rule_infinite_spells,UNLIMITED SPELL USES,БЕСКОНЕЧНЫЕ ЗАРЯДЫ,USOS ILIMITADOS,USOS ILIMITADOS,UNBEGRENZTE ZAUBER,SORTS ILLIMITÉS,USI ILLIMITATI,NIELIMITOWANE UŻYCIA,无限法术次数,呪文使用回数無制限,주문 사용 횟수 무제한,,, @@ -170,8 +169,11 @@ mcm_rule_joint_strength,JOINT STRENGTH,ПРОЧНОСТЬ СОЕДИНЕНИЙ,F mcm_rule_joint_strength_desc,Physics joint maximum-force multiplier,Множитель максимальной силы физических соединений,Multiplicador da força máxima das juntas,Multiplicador de fuerza máxima de uniones físicas,Multiplikator der maximalen Gelenkkraft,Multiplicateur de force maximale des joints physiques,Moltiplicatore della forza massima dei giunti,Mnożnik maksymalnej siły połączeń fizycznych,物理关节最大受力倍率,物理ジョイントの最大力倍率,물리 관절 최대 힘 배율,,, mcm_rule_day_speed,DAY CYCLE SPEED,СКОРОСТЬ СУТОК,VELOCIDADE DO DIA,VELOCIDAD DEL DÍA,TAGESZYKLUS,VITESSE DU CYCLE JOUR,VELOCITÀ DEL GIORNO,SZYBKOŚĆ DOBY,昼夜循环速度,昼夜サイクル速度,낮밤 주기 속도,,, mcm_rule_day_speed_desc,Multiplier for the natural day-night cycle speed,Множитель естественной скорости смены дня и ночи,Multiplica a velocidade natural do ciclo dia/noite,Multiplica la velocidad natural del ciclo día/noche,Faktor für die natürliche Tag-Nacht-Geschwindigkeit,Multiplicateur de vitesse naturelle jour/nuit,Moltiplica la velocità naturale giorno/notte,Mnożnik naturalnej prędkości dnia i nocy,自然昼夜循环速度倍率,自然な昼夜サイクル速度倍率,자연 낮밤 주기 속도 배율,,, -mcm_setting_possession_key,POSSESSION KEY,КЛАВИША ВСЕЛЕНИЯ,TECLA DE POSSESSÃO,TECLA DE POSESIÓN,BESITZERGREIFUNGS-TASTE,TOUCHE DE POSSESSION,TASTO POSSESSIONE,KLAWISZ OPĘTANIA,附身按键,憑依キー,빙의 키,,, +mcm_setting_possession_key,POSSESSION KEY,КЛАВИША ВСЕЛЕНИЯ,POSSESSION KEY,POSSESSION KEY,POSSESSION KEY,POSSESSION KEY,POSSESSION KEY,POSSESSION KEY,POSSESSION KEY,POSSESSION KEY,POSSESSION KEY,,, +mcm_setting_possession_key_desc,"Click the setting, then press a keyboard key or mouse button. TAB is reserved for menu/return.","Нажмите на настройку, затем нажмите клавишу или кнопку мыши. TAB зарезервирован для меню/возврата.","Click the setting, then press a keyboard key or mouse button. TAB is reserved for menu/return.","Click the setting, then press a keyboard key or mouse button. TAB is reserved for menu/return.","Click the setting, then press a keyboard key or mouse button. TAB is reserved for menu/return.","Click the setting, then press a keyboard key or mouse button. TAB is reserved for menu/return.","Click the setting, then press a keyboard key or mouse button. TAB is reserved for menu/return.","Click the setting, then press a keyboard key or mouse button. TAB is reserved for menu/return.","Click the setting, then press a keyboard key or mouse button. TAB is reserved for menu/return.","Click the setting, then press a keyboard key or mouse button. TAB is reserved for menu/return.","Click the setting, then press a keyboard key or mouse button. TAB is reserved for menu/return.",,, +mcm_setting_key_waiting,PRESS A KEY OR MOUSE BUTTON...,НАЖМИТЕ КЛАВИШУ ИЛИ КНОПКУ МЫШИ...,PRESS A KEY OR MOUSE BUTTON...,PRESS A KEY OR MOUSE BUTTON...,PRESS A KEY OR MOUSE BUTTON...,PRESS A KEY OR MOUSE BUTTON...,PRESS A KEY OR MOUSE BUTTON...,PRESS A KEY OR MOUSE BUTTON...,PRESS A KEY OR MOUSE BUTTON...,PRESS A KEY OR MOUSE BUTTON...,PRESS A KEY OR MOUSE BUTTON...,,, mcm_mobs_mode_play,PLAY,ИГРА,JOGAR,JUGAR,SPIELEN,JOUER,GIOCA,GRAJ,游玩,プレイ,플레이,,, +mcm_mobs_mode_log_remove,LOG REMOVE,ЛОГ: УБРАТЬ,LOG REMOVER,REG. QUITAR,LOG ENTF.,LOG RETRAIT,LOG RIMUOVI,LOG USUŃ,记录移除,削除ログ,제거 기록,,, mcm_mobs_mode_log_retest,LOG RETEST,ЛОГ: ПОВТОР,LOG RETESTE,REG. REPRUEBA,LOG RETEST,LOG RETEST,LOG RITEST,LOG TESTUJ,记录复测,再テストログ,재검사 기록,,, mcm_mobs_review_help,REVIEW: click a mob to log its exact path; no transform/spawn occurs,"ПРОВЕРКА: нажмите моба, чтобы записать точный путь в лог; спавн и превращение не выполняются",REVISÃO: clique num mob para registrar o caminho exato; sem spawn/transformação,REVISIÓN: pulsa un mob para registrar su ruta exacta; no se genera ni transforma,"PRÜFUNG: Mob anklicken, um den exakten Pfad zu protokollieren; kein Spawn/Verwandeln",REVUE : cliquez sur un mob pour journaliser son chemin exact ; aucun spawn/transformation,REVISIONE: clicca un mob per registrare il percorso esatto; nessuno spawn/trasformazione,"PRZEGLĄD: kliknij moba, aby zapisać dokładną ścieżkę; bez spawnu/przemiany",审查:点击生物以记录精确路径;不会生成或变形,確認: モブをクリックして正確なパスをログ記録。スポーン/変身はしません,검토: 몹을 클릭해 정확한 경로를 기록합니다. 소환/변신은 하지 않습니다,,, mcm_mobs_review_click,CLICK: log,КЛИК: записать,CLIQUE: registrar,CLIC: registrar,KLICK: loggen,CLIC : journaliser,CLIC: registra,KLIK: zapisz,点击:记录,クリック: 記録,클릭: 기록,,, @@ -185,192 +187,3 @@ mcm_perk_apply_failed,Could not apply perk,Не удалось применит mcm_weather_unavailable,Weather editing unavailable,Редактирование погоды недоступно,Edição do clima indisponível,La edición del clima no está disponible,Wetterbearbeitung nicht verfügbar,Modification de la météo indisponible,Modifica del meteo non disponibile,Edycja pogody jest niedostępna,天气编辑不可用,天候編集を利用できません,날씨 편집을 사용할 수 없습니다,,, mcm_rules_unavailable,World-rule editing unavailable,Редактирование правил мира недоступно,Edição das regras do mundo indisponível,La edición de reglas del mundo no está disponible,Weltregel-Bearbeitung nicht verfügbar,Modification des règles du monde indisponible,Modifica delle regole del mondo non disponibile,Edycja zasad świata jest niedostępna,世界规则编辑不可用,ワールドルール編集を利用できません,세계 규칙 편집을 사용할 수 없습니다,,, mcm_rules_error,RULE ERROR,ОШИБКА ПРАВИЛА,ERRO DE REGRA,ERROR DE REGLA,REGELFEHLER,ERREUR DE RÈGLE,ERRORE REGOLA,BŁĄD ZASADY,规则错误,ルールエラー,규칙 오류,,, -mcm_tab_materials,MATERIALS,МАТЕРИАЛЫ,MATERIAIS,MATERIALES,MATERIALIEN,MATÉRIAUX,MATERIALI,MATERIAŁY,材料,マテリアル,재료,,, -mcm_material_filter_all,ALL,ВСЕ,TODOS,TODOS,ALLE,TOUS,TUTTI,WSZYSTKIE,全部,すべて,전체,,, -mcm_material_filter_liquids,LIQUIDS,ЖИДКОСТИ,LÍQUIDOS,LÍQUIDOS,FLÜSSIGKEITEN,LIQUIDES,LIQUIDI,CIECZE,液体,液体,액체,,, -mcm_material_filter_sands,POWDERS,ПОРОШКИ,PÓS,POLVOS,PULVER,POUDRES,POLVERI,PROSZKI,粉末,粉末,가루,,, -mcm_material_filter_static,STATIC,СТАТИКА,ESTÁTICOS,ESTÁTICOS,STATISCH,STATIQUES,STATICI,STATYCZNE,静态地形,静的地形,정적 지형,,, -mcm_material_filter_gases,GASES,ГАЗЫ,GASES,GASES,GASE,GAZ,GAS,GAZY,气体,気体,기체,,, -mcm_material_filter_fires,FIRES,ОГОНЬ,FOGOS,FUEGOS,FEUER,FEUX,FUOCHI,OGIEŃ,火焰,火,불,,, -mcm_material_filter_solids,PHYSICS,ФИЗИКА,FÍSICOS,FÍSICOS,PHYSIK,PHYSIQUES,FISICI,FIZYCZNE,物理固体,物理固体,물리 고체,,, -mcm_material_filter_special,SPECIAL,ОСОБЫЕ,ESPECIAIS,ESPECIALES,SPEZIAL,SPÉCIAUX,SPECIALI,SPECJALNE,特殊,特殊,특수,,, -mcm_materials_failed,Material catalog failed to load,Не удалось загрузить каталог материалов,Falha ao carregar o catálogo de materiais,No se pudo cargar el catálogo de materiales,Materialkatalog konnte nicht geladen werden,Échec du chargement du catalogue de matériaux,Impossibile caricare il catalogo dei materiali,Nie udało się wczytać katalogu materiałów,材料目录加载失败,マテリアル一覧を読み込めませんでした,재료 목록을 불러오지 못했습니다,,, -mcm_material_brush,BRUSH,КИСТЬ,PINCEL,PINCEL,PINSEL,PINCEAU,PENNELLO,PĘDZEL,画笔,ブラシ,브러시,,, -mcm_material_selected,Selected,Выбрано,Selecionado,Seleccionado,Ausgewählt,Sélectionné,Selezionato,Wybrano,已选择,選択中,선택됨,,, -mcm_material_loading,Loading materials...,Загрузка материалов...,Carregando materiais...,Cargando materiales...,Materialien werden geladen...,Chargement des matériaux...,Caricamento materiali...,Wczytywanie materiałów...,正在加载材料...,マテリアルを読み込み中...,재료 불러오는 중...,,, -mcm_material_paint_start,START PAINTING,НАЧАТЬ РИСОВАНИЕ,COMEÇAR A PINTAR,EMPEZAR A PINTAR,MALEN STARTEN,COMMENCER À PEINDRE,INIZIA A DIPINGERE,ZACZNIJ MALOWAĆ,开始绘制,描画開始,그리기 시작,,, -mcm_material_paint_cancel,CANCEL PAINT,ОТМЕНИТЬ РИСОВАНИЕ,CANCELAR PINTURA,CANCELAR PINTURA,MALEN ABBRECHEN,ANNULER LA PEINTURE,ANNULLA PITTURA,ANULUJ MALOWANIE,取消绘制,描画を中止,그리기 취소,,, -,,,,,,,,,,,,,, -mcm_tab_players,TELEPORTATION,ТЕЛЕПОРТАЦИЯ,TELETRANSPORTE,TELETRANSPORTE,TELEPORTATION,TÉLÉPORTATION,TELETRASPORTO,TELEPORTACJA,传送,テレポート,순간이동,,, -mcm_tab_controls,CONTROLS,УПРАВЛЕНИЕ,CONTROLES,CONTROLES,STEUERUNG,COMMANDES,COMANDI,STEROWANIE,控制,操作,조작,,, -mcm_bind_section_interface,INTERFACE,ИНТЕРФЕЙС,INTERFACE,INTERFAZ,BENUTZEROBERFLÄCHE,INTERFACE,INTERFACCIA,INTERFEJS,界面,インターフェース,인터페이스,,, -mcm_bind_section_sections,OPEN SECTION,ОТКРЫТИЕ РАЗДЕЛОВ,ABRIR SEÇÃO,ABRIR SECCIÓN,BEREICH ÖFFNEN,OUVRIR UNE SECTION,APRI SEZIONE,OTWÓRZ SEKCJĘ,打开页面,セクションを開く,섹션 열기,,, -mcm_bind_section_gameplay,GAMEPLAY,ИГРОВЫЕ ДЕЙСТВИЯ,JOGABILIDADE,JUGABILIDAD,SPIELAKTIONEN,ACTIONS DE JEU,AZIONI DI GIOCO,ROZGRYWKA,游戏操作,ゲーム操作,게임 플레이,,, -mcm_bind_section_world,WORLD TOOLS,ИНСТРУМЕНТЫ МИРА,FERRAMENTAS DO MUNDO,HERRAMIENTAS DEL MUNDO,WELTWERKZEUGE,OUTILS DU MONDE,STRUMENTI DEL MONDO,NARZĘDZIA ŚWIATA,世界工具,ワールドツール,월드 도구,,, -mcm_bind_menu_toggle,Open / close creative menu,Открыть / закрыть творческое меню,Abrir / fechar menu criativo,Abrir / cerrar menú creativo,Kreativmenü öffnen / schließen,Ouvrir / fermer le menu créatif,Apri / chiudi menu creativo,Otwórz / zamknij menu kreatywne,打开/关闭创意菜单,クリエイティブメニューを開閉,크리에이티브 메뉴 열기/닫기,,, -mcm_bind_menu_close,Close creative menu,Закрыть творческое меню,Fechar menu criativo,Cerrar menú creativo,Kreativmenü schließen,Fermer le menu créatif,Chiudi menu creativo,Zamknij menu kreatywne,关闭创意菜单,クリエイティブメニューを閉じる,크리에이티브 메뉴 닫기,,, -mcm_bind_tab_previous,Previous section,Предыдущий раздел,Seção anterior,Sección anterior,Vorheriger Bereich,Section précédente,Sezione precedente,Poprzednia sekcja,上一页,前のセクション,이전 섹션,,, -mcm_bind_tab_next,Next section,Следующий раздел,Próxima seção,Siguiente sección,Nächster Bereich,Section suivante,Sezione successiva,Następna sekcja,下一页,次のセクション,다음 섹션,,, -mcm_bind_return_human,Return to human form,Вернуться в человеческую форму,Voltar à forma humana,Volver a forma humana,Zur Menschenform zurückkehren,Revenir à la forme humaine,Torna alla forma umana,Wróć do ludzkiej postaci,恢复人类形态,人間形態に戻る,인간 형태로 돌아가기,,, -mcm_bind_paint_toggle,Start / stop material painting,Включить / выключить рисование материалом,Iniciar / parar pintura de material,Iniciar / detener pintura de material,Materialmalen starten / stoppen,Démarrer / arrêter la peinture de matériau,Avvia / arresta pittura materiale,Włącz / wyłącz malowanie materiałem,开始/停止材料绘制,マテリアル描画を開始/停止,재료 그리기 시작/중지,,, -mcm_bind_paint_draw,Draw selected material,Рисовать выбранным материалом,Desenhar material selecionado,Dibujar material seleccionado,Ausgewähltes Material malen,Peindre le matériau sélectionné,Disegna materiale selezionato,Rysuj wybranym materiałem,绘制所选材料,選択したマテリアルを描画,선택한 재료 그리기,,, -mcm_bind_brush_smaller,Smaller material brush,Уменьшить кисть материалов,Diminuir pincel de material,Reducir pincel de material,Materialpinsel verkleinern,Réduire le pinceau de matériau,Riduci pennello materiale,Zmniejsz pędzel materiału,缩小材料画笔,マテリアルブラシを小さく,재료 브러시 줄이기,,, -mcm_bind_brush_larger,Larger material brush,Увеличить кисть материалов,Aumentar pincel de material,Aumentar pincel de material,Materialpinsel vergrößern,Agrandir le pinceau de matériau,Ingrandisci pennello materiale,Powiększ pędzel materiału,放大材料画笔,マテリアルブラシを大きく,재료 브러시 키우기,,, -mcm_bind_teleport_next,Teleport to next network player,Телепортироваться к следующему сетевому игроку,Teletransportar ao próximo jogador,Teletransportarse al siguiente jugador,Zum nächsten Netzwerkspieler teleportieren,Se téléporter au joueur réseau suivant,Teletrasportati al prossimo giocatore,Teleportuj do następnego gracza sieciowego,传送到下一名网络玩家,次のネットワークプレイヤーへテレポート,다음 네트워크 플레이어에게 순간이동,,, -mcm_controls_hint,Click a binding then press a key or mouse button. Modifiers are supported.,Нажмите на назначение и затем клавишу или кнопку мыши. Модификаторы поддерживаются.,Clique em um atalho e pressione uma tecla ou botão do mouse. Modificadores são aceitos.,Pulsa una asignación y después una tecla o botón del ratón. Se admiten modificadores.,Belegung anklicken und dann eine Tastatur- oder Maustaste drücken. Modifikatoren werden unterstützt.,Cliquez sur une attribution puis appuyez sur une touche clavier ou souris. Les modificateurs sont pris en charge.,Fai clic su un comando e premi un tasto o un pulsante del mouse. I modificatori sono supportati.,Kliknij przypisanie i naciśnij klawisz lub przycisk myszy. Modyfikatory są obsługiwane.,点击绑定后按键盘键或鼠标按钮。支持组合键。,割り当てをクリックしてキーまたはマウスボタンを押します。修飾キーも使用できます。,바인딩을 클릭한 뒤 키보드 키나 마우스 버튼을 누르세요. 조합 키를 지원합니다.,,, -mcm_controls_clear_hint,DELETE/BACKSPACE clears; ESC cancels capture.,DELETE/BACKSPACE очищает; ESC отменяет назначение.,DELETE/BACKSPACE limpa; ESC cancela a captura.,DELETE/BACKSPACE borra; ESC cancela la captura.,DELETE/BACKSPACE löscht; ESC bricht die Erfassung ab.,DELETE/BACKSPACE efface ; ESC annule la saisie.,DELETE/BACKSPACE cancella; ESC annulla l'acquisizione.,DELETE/BACKSPACE czyści; ESC anuluje przechwytywanie.,DELETE/BACKSPACE清除;ESC取消输入。,DELETE/BACKSPACEで消去、ESCで入力をキャンセル。,DELETE/BACKSPACE로 지우고 ESC로 입력을 취소합니다.,,, -mcm_controls_reset_confirm,CLICK AGAIN TO RESET ALL,НАЖМИТЕ ЕЩЁ РАЗ ДЛЯ СБРОСА,CLIQUE DE NOVO PARA REDEFINIR TUDO,PULSA DE NUEVO PARA RESTABLECER TODO,ERNEUT KLICKEN: ALLES ZURÜCKSETZEN,CLIQUEZ ENCORE POUR TOUT RÉINITIALISER,CLICCA ANCORA PER RIPRISTINARE TUTTO,KLIKNIJ PONOWNIE ABY ZRESETOWAĆ WSZYSTKO,再次点击以全部重置,もう一度クリックしてすべてリセット,다시 클릭하여 모두 초기화,,, -mcm_controls_reset_all,RESET ALL,СБРОСИТЬ ВСЁ,REDEFINIR TUDO,RESTABLECER TODO,ALLES ZURÜCKSETZEN,TOUT RÉINITIALISER,RIPRISTINA TUTTO,ZRESETUJ WSZYSTKO,全部重置,すべてリセット,모두 초기화,,, -mcm_controls_waiting,PRESS A KEY...,НАЖМИТЕ КЛАВИШУ...,PRESSIONE UMA TECLA...,PULSA UNA TECLA...,TASTE DRÜCKEN...,APPUYEZ SUR UNE TOUCHE...,PREMI UN TASTO...,NACIŚNIJ KLAWISZ...,请按键...,キーを押してください...,키를 누르세요...,,, -mcm_controls_waiting_short,PRESS...,НАЖМИТЕ...,PRESSIONE...,PULSA...,DRÜCKEN...,APPUYEZ...,PREMI...,NACIŚNIJ...,请按键...,入力...,입력...,,, -mcm_controls_clear,Clear binding,Очистить назначение,Limpar atalho,Borrar asignación,Belegung löschen,Effacer l'attribution,Cancella comando,Wyczyść przypisanie,清除绑定,割り当てを消去,바인딩 지우기,,, -mcm_controls_reset,Restore default,Вернуть значение по умолчанию,Restaurar padrão,Restaurar predeterminado,Standard wiederherstellen,Rétablir par défaut,Ripristina predefinito,Przywróć domyślne,恢复默认,初期値に戻す,기본값 복원,,, -mcm_controls_conflict,CONFLICT,КОНФЛИКТ,CONFLITO,CONFLICTO,KONFLIKT,CONFLIT,CONFLITTO,KONFLIKT,冲突,競合,충돌,,, -mcm_setting_keybind_desc,Click and press a key or mouse button. DELETE clears and ESC cancels.,Нажмите и выберите клавишу или кнопку мыши. DELETE очищает а ESC отменяет.,Clique e pressione uma tecla ou botão do mouse. DELETE limpa e ESC cancela.,Pulsa y presiona una tecla o botón del ratón. DELETE borra y ESC cancela.,Klicken und Tastatur- oder Maustaste drücken. DELETE löscht und ESC bricht ab.,Cliquez puis appuyez sur une touche clavier ou souris. DELETE efface et ESC annule.,Fai clic e premi un tasto o un pulsante del mouse. DELETE cancella ed ESC annulla.,Kliknij i naciśnij klawisz lub przycisk myszy. DELETE czyści a ESC anuluje.,点击后按键盘键或鼠标按钮。DELETE清除,ESC取消。,クリックしてキーまたはマウスボタンを押します。DELETEで消去、ESCでキャンセル。,클릭한 뒤 키보드 키나 마우스 버튼을 누르세요. DELETE로 지우고 ESC로 취소합니다.,,, -mcm_players_hint,Safe teleports to players and world locations,Безопасные телепорты к игрокам и игровым локациям,Teletransportes seguros para jogadores e locais do mundo,Teletransportes seguros a jugadores y ubicaciones del mundo,Sichere Teleports zu Spielern und Welt-Orten,Téléportations sûres vers les joueurs et les lieux du monde,Teletrasporti sicuri verso giocatori e luoghi del mondo,Bezpieczne teleporty do graczy i lokacji świata,安全传送到玩家和世界地点,プレイヤーやワールド地点への安全なテレポート,플레이어와 월드 위치로 안전하게 순간이동,,, -mcm_players_empty,No remote players found,Другие игроки не найдены,Nenhum jogador remoto encontrado,No se encontraron jugadores remotos,Keine entfernten Spieler gefunden,Aucun joueur distant trouvé,Nessun giocatore remoto trovato,Nie znaleziono innych graczy,未找到其他玩家,他のプレイヤーが見つかりません,다른 플레이어를 찾지 못했습니다,,, -mcm_players_player,Player,Игрок,Jogador,Jugador,Spieler,Joueur,Giocatore,Gracz,玩家,プレイヤー,플레이어,,, -mcm_material_paint_armed_dynamic,ARMED - close inventory; hold {BIND},ГОТОВО — закройте инвентарь; удерживайте {BIND},PRONTO — feche o inventário; segure {BIND},LISTO — cierra el inventario; mantén {BIND},BEREIT — Inventar schließen; {BIND} halten,PRÊT — fermez l'inventaire ; maintenez {BIND},PRONTO — chiudi l'inventario; tieni {BIND},GOTOWE — zamknij ekwipunek; przytrzymaj {BIND},已就绪—关闭物品栏并按住{BIND},準備完了—インベントリを閉じて{BIND}を長押し,준비됨 — 인벤토리를 닫고 {BIND} 유지,,, -mcm_material_controls_dynamic,Close inventory then hold {BIND} in the world. Reopen to stop.,Закройте инвентарь и удерживайте {BIND} в мире. Откройте снова для остановки.,Feche o inventário e segure {BIND} no mundo. Reabra para parar.,Cierra el inventario y mantén {BIND} en el mundo. Ábrelo de nuevo para parar.,Inventar schließen und in der Welt {BIND} halten. Zum Stoppen wieder öffnen.,Fermez l'inventaire puis maintenez {BIND} dans le monde. Rouvrez pour arrêter.,Chiudi l'inventario e tieni {BIND} nel mondo. Riapri per fermare.,Zamknij ekwipunek i przytrzymaj {BIND} w świecie. Otwórz ponownie aby zatrzymać.,关闭物品栏后在世界中按住{BIND}。重新打开即可停止。,インベントリを閉じてワールドで{BIND}を長押し。再度開くと停止します。,인벤토리를 닫고 월드에서 {BIND}를 누르세요. 다시 열면 중지됩니다.,,, -mcm_creature_controls_dynamic,LMB: SPAWN DRAG: DROP RMB: TRANSFORM {BIND}: HUMAN,ЛКМ: СОЗДАТЬ ПЕРЕНОС: ПОСТАВИТЬ ПКМ: ПРЕВРАТИТЬСЯ {BIND}: ЧЕЛОВЕК,LMB: CRIAR ARRASTAR: SOLTAR RMB: TRANSFORMAR {BIND}: HUMANO,LMB: GENERAR ARRASTRAR: SOLTAR RMB: TRANSFORMAR {BIND}: HUMANO,LMB: ERZEUGEN ZIEHEN: ABLEGEN RMB: VERWANDELN {BIND}: MENSCH,Clic G: CRÉER GLISSER : PLACER Clic D: TRANSFORMER {BIND}: HUMAIN,LMB: GENERA TRASCINA: RILASCIA RMB: TRASFORMA {BIND}: UMANO,LPM: UTWÓRZ PRZECIĄGNIJ: UPUŚĆ PPM: PRZEMIANA {BIND}: CZŁOWIEK,左键:生成 拖动:放置 右键:变身 {BIND}:人类,左クリック:生成 ドラッグ:配置 右クリック:変身 {BIND}:人間,좌클릭: 생성 드래그: 놓기 우클릭: 변신 {BIND}: 인간,,, -mcm_menu_drag,Drag menu,Перетащить меню,Arrastar menu,Arrastrar menú,Menü verschieben,Déplacer le menu,Trascina menu,Przeciągnij menu,拖动菜单,メニューを移動,메뉴 끌기,,, -mcm_menu_drag_hint,Hold and move the mouse,Зажмите и двигайте мышь,Segure e mova o mouse,Mantén y mueve el ratón,Maustaste halten und Maus bewegen,Maintenez et déplacez la souris,Tieni premuto e muovi il mouse,Przytrzymaj i przesuń mysz,按住并移动鼠标,押したままマウスを動かします,누른 채 마우스를 움직이세요,,, -mcm_menu_layout_reset,Reset menu position and size,Сбросить положение и размер меню,Redefinir posição e tamanho do menu,Restablecer posición y tamaño del menú,Menüposition und -größe zurücksetzen,Réinitialiser la position et la taille du menu,Ripristina posizione e dimensioni del menu,Zresetuj położenie i rozmiar menu,重置菜单位置和大小,メニューの位置とサイズをリセット,메뉴 위치와 크기 초기화,,, -mcm_menu_minimize,Hide window,Скрыть окно,Ocultar janela,Ocultar ventana,Fenster minimieren,Réduire la fenêtre,Riduci finestra,Ukryj okno,隐藏窗口,ウィンドウを隠す,창 숨기기,,, -mcm_key_mouse,MOUSE,МЫШЬ,MOUSE,RATÓN,MAUS,SOURIS,MOUSE,MYSZ,鼠标,マウス,마우스,,, -mcm_key_mouse_middle,MIDDLE,СРЕДНЯЯ,MEIO,CENTRAL,MITTE,MILIEU,CENTRALE,ŚRODKOWY,中键,中央,가운데,,, -mcm_material_backend_failed,Material painting is unavailable,Рисование материалом недоступно,A pintura de materiais não está disponível,La pintura de materiales no está disponible,Materialmalen ist nicht verfügbar,La peinture de matériaux est indisponible,La pittura dei materiali non è disponibile,Malowanie materiałów jest niedostępne,材料绘制不可用,マテリアル描画は利用できません,재료 그리기를 사용할 수 없습니다,,, -mcm_menu_title,CREATIVE MENU,ТВОРЧЕСКОЕ МЕНЮ,MENU CRIATIVO,MENÚ CREATIVO,KREATIVMENÜ,MENU CRÉATIF,MENU CREATIVO,MENU KREATYWNE,创意菜单,クリエイティブメニュー,크리에이티브 메뉴,,, -mcm_teleport_players,NETWORK PLAYERS,СЕТЕВЫЕ ИГРОКИ,JOGADORES EM REDE,JUGADORES EN RED,NETZWERKSPIELER,JOUEURS EN RÉSEAU,GIOCATORI IN RETE,GRACZE SIECIOWI,网络玩家,ネットワークプレイヤー,네트워크 플레이어,,, -mcm_teleport_coordinates,COORDINATES,КООРДИНАТЫ,COORDENADAS,COORDENADAS,KOORDINATEN,COORDONNÉES,COORDINATE,WSPÓŁRZĘDNE,坐标,座標,좌표,,, -mcm_teleport_coordinates_hint,Teleport to exact world coordinates; the destination is loaded first and adjusted only if the point is blocked.,"Телепортация по заданным координатам мира; область сначала загружается, а точка корректируется только если она занята.",Teleporte para coordenadas exatas do mundo; o destino é carregado primeiro e ajustado apenas se o ponto estiver bloqueado.,Teletranspórtate a coordenadas exactas; primero se carga el destino y solo se ajusta si el punto está bloqueado.,Teleportiere zu exakten Weltkoordinaten; das Ziel wird zuerst geladen und nur bei blockiertem Punkt angepasst.,Téléportation vers des coordonnées exactes ; la destination est chargée d’abord et ajustée seulement si le point est bloqué.,Teletrasporto a coordinate esatte; la destinazione viene caricata prima e corretta solo se il punto è bloccato.,"Teleportuj do dokładnych współrzędnych; obszar jest najpierw wczytywany, a pozycja korygowana tylko gdy punkt jest zablokowany.",传送到精确世界坐标;先加载目标区域,仅在目标点被阻挡时调整位置。,正確なワールド座標へテレポートします。先に目的地を読み込み、地点が塞がれている場合のみ位置を調整します。,정확한 월드 좌표로 순간이동합니다. 먼저 목적지를 불러오며 지점이 막힌 경우에만 위치를 조정합니다.,,, -mcm_teleport_coordinates_go,TELEPORT,ТЕЛЕПОРТ,TELEPORTAR,TELETRANSPORTAR,TELEPORTIEREN,TÉLÉPORTER,TELETRASPORTA,TELEPORTUJ,传送,テレポート,순간이동,,, -mcm_teleport_coordinates_go_hint,Teleport to the entered X/Y position,Телепортироваться в указанную позицию X/Y,Teleporte para a posição X/Y informada,Teletranspórtate a la posición X/Y indicada,Zur eingegebenen X/Y-Position teleportieren,Se téléporter à la position X/Y saisie,Teletrasportati alla posizione X/Y inserita,Teleportuj do podanej pozycji X/Y,传送到输入的 X/Y 位置,入力した X/Y 座標へテレポート,입력한 X/Y 위치로 순간이동,,, -mcm_teleport_locations,WORLD LOCATIONS,ЛОКАЦИИ МИРА,LOCAIS DO MUNDO,UBICACIONES DEL MUNDO,WELT-ORTE,LIEUX DU MONDE,LUOGHI DEL MONDO,LOKACJE ŚWIATA,世界地点,ワールド地点,월드 위치,,, -mcm_teleport_locations_hint,The destination is streamed first and adjusted to nearby free space.,Сначала загружается область назначения а затем выбирается свободное место рядом.,O destino é carregado primeiro e ajustado para um espaço livre próximo.,Primero se carga el destino y luego se ajusta a un espacio libre cercano.,Das Ziel wird zuerst geladen und dann auf freien Platz in der Nähe gesetzt.,La destination est d'abord chargée puis ajustée vers un espace libre proche.,La destinazione viene prima caricata e poi spostata in uno spazio libero vicino.,Cel jest najpierw wczytywany a potem dopasowywany do wolnego miejsca obok.,先加载目的地区域再调整到附近空位,目的地を先に読み込み近くの空き場所へ調整します,목적지를 먼저 불러온 뒤 근처 빈 공간으로 조정합니다,,, -mcm_teleport_loading,LOADING DESTINATION...,ЗАГРУЗКА ЛОКАЦИИ...,CARREGANDO DESTINO...,CARGANDO DESTINO...,ZIEL WIRD GELADEN...,CHARGEMENT DE LA DESTINATION...,CARICAMENTO DESTINAZIONE...,WCZYTYWANIE CELU...,正在加载目的地...,目的地を読み込み中...,목적지 불러오는 중...,,, -mcm_teleport_to_player,GO TO,К ИГРОКУ,IR ATÉ,IR CON,HINGEHEN,REJOINDRE,VAI DA,VAI,前往,移動,이동,,, -mcm_teleport_to_player_hint,Teleport safely beside this player,Безопасно телепортироваться рядом с этим игроком,Teletransportar com segurança ao lado deste jogador,Teletransportarse de forma segura junto a este jugador,Sicher neben diesen Spieler teleportieren,Se téléporter en sécurité près de ce joueur,Teletrasportati in sicurezza accanto a questo giocatore,Teleportuj bezpiecznie obok tego gracza,安全传送到该玩家旁边,このプレイヤーの隣へ安全にテレポート,이 플레이어 옆으로 안전하게 순간이동,,, -mcm_teleport_bring_player,BRING HERE,ПРИТЯНУТЬ СЮДА,TRAZER AQUI,TRAER AQUÍ,HIERHER HOLEN,AMENER ICI,PORTA QUI,PRZYWOŁAJ TUTAJ,传送过来,ここへ呼ぶ,여기로 데려오기,,, -mcm_teleport_bring_hint,Ask this peer to teleport safely to you,Попросить этого игрока безопасно телепортироваться к вам,Pedir que este jogador se teletransporte com segurança até você,Pedir a este jugador que se teletransporte contigo,Diesen Spieler sicher zu dir teleportieren,Demander à ce joueur de se téléporter vers vous,Chiedi a questo giocatore di teletrasportarsi da te,Poproś tego gracza o bezpieczny teleport do ciebie,请求该玩家安全传送到你身边,このプレイヤーを自分のそばへ安全にテレポート,이 플레이어를 내 옆으로 안전하게 순간이동,,, -mcm_location_start,Starting area,Стартовая зона,Área inicial,Zona inicial,Startgebiet,Zone de départ,Area iniziale,Obszar startowy,起始区域,スタート地点,시작 지역,,, -mcm_location_mines,Mines,Шахты,Minas,Minas,Minen,Mines,Mini,Kopalnie,矿井,鉱山,광산,,, -mcm_location_hm_mines,Holy Mountain after Mines,Святая гора после Шахт,Montanha Sagrada após as Minas,Montaña Sagrada tras las Minas,Heiliger Berg nach den Minen,Montagne Sacrée après les Mines,Montagna Sacra dopo le Mini,Święta Góra po Kopalniach,矿井后的神圣山,鉱山後の聖なる山,광산 뒤 신성한 산,,, -mcm_location_coal_pits,Coal Pits,Угольные шахты,Poços de Carvão,Pozos de Carbón,Kohlegruben,Mines de charbon,Pozzi di carbone,Kopalnie Węgla,煤矿层,炭鉱,석탄 갱도,,, -mcm_location_fungal,Fungal Caverns,Грибные пещеры,Cavernas Fúngicas,Cavernas Fúngicas,Pilzhöhlen,Cavernes fongiques,Caverne fungine,Jaskinie Grzybowe,真菌洞穴,真菌洞窟,곰팡이 동굴,,, -mcm_location_hm_coal,Holy Mountain after Coal Pits,Святая гора после Угольных шахт,Montanha Sagrada após os Poços de Carvão,Montaña Sagrada tras los Pozos de Carbón,Heiliger Berg nach den Kohlegruben,Montagne Sacrée après les Mines de charbon,Montagna Sacra dopo i Pozzi di carbone,Święta Góra po Kopalniach Węgla,煤矿层后的神圣山,炭鉱後の聖なる山,석탄 갱도 뒤 신성한 산,,, -mcm_location_snowy,Snowy Depths,Снежные глубины,Profundezas Nevadas,Profundidades Nevadas,Schneetiefen,Profondeurs enneigées,Profondità innevate,Śnieżne Głębiny,雪原深处,雪のどん底,눈 덮인 심층,,, -mcm_location_hm_snowy,Holy Mountain after Snowy Depths,Святая гора после Снежных глубин,Montanha Sagrada após as Profundezas Nevadas,Montaña Sagrada tras las Profundidades Nevadas,Heiliger Berg nach den Schneetiefen,Montagne Sacrée après les Profondeurs enneigées,Montagna Sacra dopo le Profondità innevate,Święta Góra po Śnieżnych Głębinach,雪原深处后的神圣山,雪のどん底後の聖なる山,눈 덮인 심층 뒤 신성한 산,,, -mcm_location_hiisi,Hiisi Base,База хииси,Base Hiisi,Base Hiisi,Hiisi-Basis,Base Hiisi,Base Hiisi,Baza Hiisi,希伊西基地,ヒーシの基地,히시 기지,,, -mcm_location_hm_hiisi,Holy Mountain after Hiisi Base,Святая гора после Базы хииси,Montanha Sagrada após a Base Hiisi,Montaña Sagrada tras la Base Hiisi,Heiliger Berg nach der Hiisi-Basis,Montagne Sacrée après la Base Hiisi,Montagna Sacra dopo la Base Hiisi,Święta Góra po Bazie Hiisi,希伊西基地后的神圣山,ヒーシの基地後の聖なる山,히시 기지 뒤 신성한 산,,, -mcm_location_jungle,Underground Jungle,Подземные джунгли,Selva Subterrânea,Selva Subterránea,Unterirdischer Dschungel,Jungle souterraine,Giungla sotterranea,Podziemna Dżungla,地下丛林,地下ジャングル,지하 정글,,, -mcm_location_hm_jungle,Holy Mountain after Jungle,Святая гора после Джунглей,Montanha Sagrada após a Selva,Montaña Sagrada tras la Selva,Heiliger Berg nach dem Dschungel,Montagne Sacrée après la Jungle,Montagna Sacra dopo la Giungla,Święta Góra po Dżungli,丛林后的神圣山,ジャングル後の聖なる山,정글 뒤 신성한 산,,, -mcm_location_vault,The Vault,Хранилище,O Cofre,La Bóveda,Der Tresor,La Voûte,Il Caveau,Skarbiec,保险库,金庫室,금고,,, -mcm_location_hm_vault,Holy Mountain after Vault,Святая гора после Хранилища,Montanha Sagrada após o Cofre,Montaña Sagrada tras la Bóveda,Heiliger Berg nach dem Tresor,Montagne Sacrée après la Voûte,Montagna Sacra dopo il Caveau,Święta Góra po Skarbcu,保险库后的神圣山,金庫室後の聖なる山,금고 뒤 신성한 산,,, -mcm_location_temple,Temple of the Art,Храм искусства,Templo da Arte,Templo del Arte,Tempel der Kunst,Temple de l'Art,Tempio dell'Arte,Świątynia Sztuki,艺术神殿,芸術の神殿,예술의 신전,,, -mcm_location_lab_portal,Laboratory portal,Портал в Лабораторию,Portal do Laboratório,Portal al Laboratorio,Laborportal,Portail du Laboratoire,Portale del Laboratorio,Portal do Laboratorium,实验室传送门,研究所へのポータル,연구소 포털,,, -mcm_location_kolmi,Kolmisilma room,Зал Колмисилмы,Sala de Kolmisilma,Sala de Kolmisilma,Kolmisilma-Raum,Salle de Kolmisilma,Stanza di Kolmisilma,Sala Kolmisilmy,三眼巨蛛房间,コルミシルマの部屋,콜미실마 방,,, -mcm_location_work,The Work,Работа,A Obra,La Obra,Das Werk,L'Œuvre,L'Opera,Dzieło,天界,仕事,작업장,,, -mcm_location_tree,Giant Tree,Гигантское дерево,Árvore Gigante,Árbol Gigante,Riesenbaum,Arbre géant,Albero gigante,Wielkie Drzewo,巨树,大木,거대 나무,,, -mcm_location_orb_zero,Mountain orb room,Сфера в горе,Sala do orbe da montanha,Sala del orbe de la montaña,Bergkugelraum,Salle de l'orbe de la montagne,Stanza della sfera montana,Sala kuli w górze,山中魔球房,山のオーブ部屋,산의 오브 방,,, -mcm_location_meat,Meat Realm,Мясное царство,Reino da Carne,Reino de la Carne,Fleischreich,Royaume de la Chair,Regno della Carne,Kraina Mięsa,血肉领域,肉の領域,고기 영역,,, -mcm_location_kivi,Kivi chamber,Зал Киви,Câmara de Kivi,Cámara de Kivi,Kivi-Kammer,Chambre de Kivi,Camera di Kivi,Komnata Kivi,奇维密室,キヴィの部屋,키비 방,,, -mcm_location_null_altar,Nullifying Altar,Алтарь обнуления,Altar de Anulação,Altar de Anulación,Annullierungsaltar,Autel d'annulation,Altare di annullamento,Ołtarz Unieważnienia,消除祭坛,無効化の祭壇,무효화 제단,,, -mcm_location_essence_laser,Essence of Light,Эссенция света,Essência da Luz,Esencia de Luz,Essenz des Lichts,Essence de Lumière,Essenza della Luce,Esencja Światła,光之精华,光のエッセンス,빛의 정수,,, -mcm_location_essence_eater,Essence Eater,Пожиратель эссенций,Devorador de Essências,Devorador de Esencias,Essenzfresser,Mangeur d'essences,Mangiaessenze,Pożeracz Esencji,精华吞噬者,エッセンスイーター,정수 포식자,,, -mcm_runtime_error,Mod runtime error,Ошибка выполнения мода,Erro de execução do mod,Error de ejecución del mod,Laufzeitfehler des Mods,Erreur d’exécution du mod,Errore di esecuzione del mod,Błąd działania moda,模组运行错误,MOD実行エラー,모드 실행 오류,,, -mcm_material_stopped,Material brush stopped,Кисть материалов остановлена,Pincel de materiais interrompido,Pincel de materiales detenido,Materialpinsel angehalten,Pinceau de matériaux arrêté,Pennello materiali arrestato,Pędzel materiałów zatrzymany,材质画笔已停止,マテリアルブラシを停止しました,재료 브러시가 중지됨,,, -mcm_ew_compat_failed,Entangled Worlds compatibility error,Ошибка совместимости с Entangled Worlds,Erro de compatibilidade com Entangled Worlds,Error de compatibilidad con Entangled Worlds,Kompatibilitätsfehler mit Entangled Worlds,Erreur de compatibilité avec Entangled Worlds,Errore di compatibilità con Entangled Worlds,Błąd zgodności z Entangled Worlds,Entangled Worlds 兼容性错误,Entangled Worlds 互換性エラー,Entangled Worlds 호환성 오류,,, -mcm_spell_unknown,Unknown/modded spell,Неизвестное/модовое заклинание,Feitiço desconhecido/mod,Hechizo desconocido/de mod,Unbekannter/Mod-Zauber,Sort inconnu/moddé,Incantesimo sconosciuto/mod,Nieznane/modowane zaklęcie,未知/模组法术,不明/Modの呪文,알 수 없음/모드 주문,,, -mcm_wand_stats,WAND STATS,ХАРАКТЕРИСТИКИ ЖЕЗЛА,ATRIBUTOS DA VARINHA,ESTADÍSTICAS DE VARITA,ZAUBERSTAB-WERTE,STATS DE BAGUETTE,STATISTICHE BACCHETTA,STATYSTYKI RÓŻDŻKI,法杖属性,杖の性能,완드 능력치,,, -mcm_wand_slots,SLOTS,СЛОТЫ,ESPAÇOS,RANURAS,PLÄTZE,EMPLACEMENTS,SLOT,MIEJSCA,槽位,スロット,슬롯,,, -mcm_wand_spells_per_cast,SPELLS/CAST,ЗАКЛ./ВЫСТРЕЛ,FEITIÇOS/DISPARO,HECHIZOS/DISPARO,ZAUBER/SCHUSS,SORTS/TIR,INCANTESIMI/COLPO,ZAKLĘCIA/STRZAŁ,每次施法,同時詠唱,시전당 주문,,, -mcm_wand_recharge,RECHARGE,ПЕРЕЗАРЯДКА,RECARGA,RECARGA,AUFLADEN,RECHARGE,RICARICA,PRZEŁADOWANIE,充能时间,リチャージ,재충전,,, -mcm_wand_recharge_desc,Recharge time in game frames.,Время перезарядки в игровых кадрах.,Tempo de recarga em quadros do jogo.,Tiempo de recarga en fotogramas del juego.,Aufladezeit in Spiel-Frames.,Temps de recharge en images de jeu.,Tempo di ricarica in frame di gioco.,Czas przeładowania w klatkach gry.,充能时间,单位为游戏帧。,リチャージ時間。単位はゲームフレーム。,재충전 시간. 단위는 게임 프레임.,,, -mcm_wand_cast_delay,CAST DELAY,СКОРОСТРЕЛЬНОСТЬ,ATRASO DE DISPARO,RETARDO DE DISPARO,ZAUBERVERZÖGERUNG,DÉLAI DE TIR,RITARDO DI LANCIO,OPÓŹNIENIE STRZAŁU,施法延迟,詠唱遅延,시전 지연,,, -mcm_wand_cast_delay_desc,Delay between casts in game frames.,Задержка между выстрелами в игровых кадрах.,Atraso entre disparos em quadros do jogo.,Retardo entre disparos en fotogramas del juego.,Verzögerung zwischen Schüssen in Spiel-Frames.,Délai entre les tirs en images de jeu.,Ritardo tra i lanci in frame di gioco.,Opóźnienie między strzałami w klatkach gry.,施法间隔,单位为游戏帧。,詠唱間隔。単位はゲームフレーム。,시전 간격. 단위는 게임 프레임.,,, -mcm_wand_spread,SPREAD,РАЗБРОС,DISPERSÃO,DISPERSIÓN,STREUUNG,DISPERSION,DISPERSIONE,ROZRZUT,散射,拡散,확산,,, -mcm_wand_spread_desc,Spread angle in degrees.,Угол разброса в градусах.,Ângulo de dispersão em graus.,Ángulo de dispersión en grados.,Streuwinkel in Grad.,Angle de dispersion en degrés.,Angolo di dispersione in gradi.,Kąt rozrzutu w stopniach.,散射角度,单位为度。,拡散角。単位は度。,확산 각도. 단위는 도.,,, -mcm_wand_speed,SPEED,СКОРОСТЬ,VELOCIDADE,VELOCIDAD,GESCHWINDIGKEIT,VITESSE,VELOCITÀ,PRĘDKOŚĆ,速度,速度,속도,,, -mcm_wand_mana_max,MANA MAX,МАКС. МАНА,MANA MÁX.,MANÁ MÁX.,MAX. MANA,MANA MAX,MANA MAX,MAKS. MANA,最大法力,最大マナ,최대 마나,,, -mcm_wand_mana_charge,MANA CHARGE,ВОССТ. МАНЫ,RECARGA DE MANA,RECARGA DE MANÁ,MANA-AUFLADUNG,RECHARGE MANA,RICARICA MANA,REGENERACJA MANY,法力回复,マナ回復,마나 충전,,, -mcm_wand_recoil_recovery,RECOIL REC.,ВОССТ. ОТДАЧИ,REC. DE RECUO,REC. DE RETROCESO,RÜCKSTOSS-ERHOLUNG,RÉCUP. RECUL,RECUPERO RINCULO,POWRÓT ODRZUTU,后坐恢复,反動回復,반동 회복,,, -mcm_wand_level,LEVEL,УРОВЕНЬ,NÍVEL,NIVEL,STUFE,NIVEAU,LIVELLO,POZIOM,等级,レベル,레벨,,, -mcm_wand_shuffle,SHUFFLE,ПЕРЕМЕШИВАНИЕ,EMBARALHAR,BARAJAR,MISCHEN,MÉLANGER,MESCOLA,LOSOWANIE,乱序,シャッフル,셔플,,, -mcm_wand_never_reload,NO RELOAD,БЕЗ ПЕРЕЗАРЯДКИ,SEM RECARGA,SIN RECARGA,KEIN AUFLADEN,SANS RECHARGE,NESSUNA RICARICA,BEZ PRZEŁADOWANIA,无需充能,リロードなし,재충전 없음,,, -mcm_wand_edit_error,Could not change wand,Не удалось изменить жезл,Não foi possível alterar a varinha,No se pudo cambiar la varita,Zauberstab konnte nicht geändert werden,Impossible de modifier la baguette,Impossibile modificare la bacchetta,Nie udało się zmienić różdżki,无法修改法杖,杖を変更できませんでした,완드를 변경할 수 없습니다,,, -mcm_wand_presets,WAND PRESETS,СОХРАНЁННЫЕ ЖЕЗЛЫ,VARINHAS SALVAS,VARITAS GUARDADAS,STAB-VORLAGEN,BAGUETTES SAUVÉES,BACCHETTE SALVATE,ZAPISANE RÓŻDŻKI,法杖预设,杖プリセット,완드 프리셋,,, -mcm_wand_no_presets,No saved wands,Нет сохранённых жезлов,Nenhuma varinha salva,No hay varitas guardadas,Keine gespeicherten Stäbe,Aucune baguette sauvée,Nessuna bacchetta salvata,Brak zapisanych różdżek,没有已保存法杖,保存済みの杖なし,저장된 완드 없음,,, -mcm_wand_preset_error,Preset error,Ошибка сохранения,Erro de predefinição,Error de preajuste,Vorlagenfehler,Erreur de préréglage,Errore preset,Błąd presetu,预设错误,プリセットエラー,프리셋 오류,,, -mcm_wand_preset_apply,APPLY,ПРИМЕНИТЬ,APLICAR,APLICAR,ANWENDEN,APPLIQUER,APPLICA,ZASTOSUJ,应用,適用,적용,,, -mcm_wand_preset_apply_hint,Apply to the held wand,Применить к удерживаемому жезлу,Aplicar à varinha equipada,Aplicar a la varita equipada,Auf gehaltenen Stab anwenden,Appliquer à la baguette tenue,Applica alla bacchetta impugnata,Zastosuj do trzymanej różdżki,应用到当前法杖,手持ちの杖に適用,들고 있는 완드에 적용,,, -mcm_wand_preset_copy,GET COPY,КОПИЯ,CÓPIA,COPIA,KOPIE,COPIE,COPIA,KOPIA,复制,コピー,복사,,, -mcm_wand_preset_copy_hint,Create a separate saved wand,Создать отдельную копию жезла,Criar uma cópia separada da varinha,Crear una copia separada de la varita,Separate Kopie des Stabs erstellen,Créer une copie séparée de la baguette,Crea una copia separata della bacchetta,Utwórz osobną kopię różdżki,创建一个独立的法杖副本,別の杖コピーを作成,별도의 완드 복사본 생성,,, -mcm_save,SAVE,СОХРАНИТЬ,SALVAR,GUARDAR,SPEICHERN,SAUVER,SALVA,ZAPISZ,保存,保存,저장,,, -mcm_load,LOAD,ЗАГРУЗИТЬ,CARREGAR,CARGAR,LADEN,CHARGER,CARICA,WCZYTAJ,载入,読込,불러오기,,, -mcm_confirm,CONFIRM,ПОДТВЕРДИТЬ,CONFIRMAR,CONFIRMAR,BESTÄTIGEN,CONFIRMER,CONFERMA,POTWIERDŹ,确认,確認,확인,,, -mcm_cancel,CANCEL,ОТМЕНА,CANCELAR,CANCELAR,ABBRECHEN,ANNULER,ANNULLA,ANULUJ,取消,キャンセル,취소,,, -mcm_wand_skin,SKIN,СКИН,VISUAL,ASPECTO,AUSSEHEN,APPARENCE,ASPETTO,WYGLĄD,外观,外見,외형,,, -mcm_apply,APPLY,ПРИМЕНИТЬ,APLICAR,APLICAR,ANWENDEN,APPLIQUER,APPLICA,ZASTOSUJ,应用,適用,적용,,, -mcm_wand_undo,UNDO,ОТМЕНИТЬ,DESFAZER,DESHACER,RÜCKGÄNGIG,ANNULER,ANNULLA,COFNIJ,撤销,元に戻す,실행 취소,,, -mcm_wand_redo,REDO,ПОВТОРИТЬ,REFAZER,REHACER,WIEDERHOLEN,RÉTABLIR,RIPETI,PONÓW,重做,やり直す,다시 실행,,, -mcm_wand_nothing_undo,Nothing to undo,Нечего отменять,Nada para desfazer,Nada que deshacer,Nichts rückgängig zu machen,Rien à annuler,Niente da annullare,Nie ma czego cofnąć,没有可撤销的操作,元に戻せる操作はありません,실행 취소할 작업 없음,,, -mcm_wand_nothing_redo,Nothing to redo,Нечего повторять,Nada para refazer,Nada que rehacer,Nichts zu wiederholen,Rien à rétablir,Niente da ripetere,Nie ma czego ponowić,没有可重做的操作,やり直せる操作はありません,다시 실행할 작업 없음,,, -mcm_wand_skins,SKINS,СКИНЫ,VISUAIS,ASPECTOS,SKINS,APPARENCES,ASPETTI,SKÓRKI,外观,外見,스킨,,, -mcm_no_results,No results,Нет результатов,Sem resultados,Sin resultados,Keine Ergebnisse,Aucun résultat,Nessun risultato,Brak wyników,无结果,結果なし,결과 없음,,, -mcm_spell_inventory,SPELL INVENTORY,ИНВЕНТАРЬ ЗАКЛИНАНИЙ,INVENTÁRIO DE FEITIÇOS,INVENTARIO DE HECHIZOS,ZAUBER-INVENTAR,INVENTAIRE DE SORTS,INVENTARIO INCANTESIMI,EKWIPUNEK ZAKLĘĆ,法术物品栏,呪文インベントリ,주문 인벤토리,,, -mcm_inventory_slot,INVENTORY,ИНВЕНТАРЬ,INVENTÁRIO,INVENTARIO,INVENTAR,INVENTAIRE,INVENTARIO,EKWIPUNEK,物品栏,インベントリ,인벤토리,,, -mcm_spell_workspace_catalog,CATALOG,КАТАЛОГ,CATÁLOGO,CATÁLOGO,KATALOG,CATALOGUE,CATALOGO,KATALOG,目录,カタログ,카탈로그,,, -mcm_spell_controls_hint,LMB: replace selected slot. Drag: slot / Always Cast / inventory.,ЛКМ: заменить выбранный слот. Перетащить: слот / Всегда при разряде / инвентарь.,Clique esq.: substitui o espaço selecionado. Arraste: espaço / Sempre Lançar / inventário.,Clic izq.: reemplaza la ranura elegida. Arrastra: ranura / Lanzamiento Siempre / inventario.,LMB: ersetzt den gewählten Slot. Ziehen: Slot / Immer Wirken / Inventar.,Clic G : remplace l’emplacement choisi. Glisser : emplacement / Toujours lancé / inventaire.,Clic sx: sostituisce lo slot scelto. Trascina: slot / Sempre attivo / inventario.,LPM: zastępuje wybrany slot. Przeciągnij: slot / Zawsze rzucane / ekwipunek.,左键:替换所选槽位。拖放:槽位 / 始终施放 / 物品栏。,左クリック:選択スロットを置換。ドラッグ:スロット / 常時詠唱 / インベントリ。,좌클릭: 선택 슬롯 교체. 드래그: 슬롯 / 항상 시전 / 인벤토리.,,, -mcm_spell_workspace_wand,WAND,ЖЕЗЛ,VARINHA,VARITA,STAB,BAGUETTE,BACCHETTA,RÓŻDŻKA,法杖,杖,완드,,, -mcm_spell_wand_slots,WAND SLOTS,СЛОТЫ ЖЕЗЛА,ESPAÇOS DA VARINHA,RANURAS DE VARITA,STAB-SLOTS,EMPL. DE BAGUETTE,SLOT BACCHETTA,SLOTY RÓŻDŻKI,法杖槽位,杖スロット,완드 슬롯,,, -mcm_wand_always_cast,ALWAYS CAST,ВСЕГДА ПРИ РАЗРЯДЕ,SEMPRE LANÇAR,LANZAMIENTO SIEMPRE,IMMER WIRKEN,TOUJOURS LANCÉ,SEMPRE ATTIVO,ZAWSZE RZUCANE,始终施放,常時詠唱,항상 시전,,, -mcm_wand_always_cast_empty,Drop a spell on + to make it Always Cast.,Перетащите заклинание на + чтобы сделать его Always Cast.,Arraste um feitiço para + para torná-lo Sempre Lançar.,Arrastra un hechizo a + para convertirlo en Lanzamiento Siempre.,Ziehe einen Zauber auf + um ihn zu Immer Wirken zu machen.,Déposez un sort sur + pour le rendre Toujours lancé.,Trascina un incantesimo su + per renderlo Sempre attivo.,Przeciągnij zaklęcie na + aby było zawsze rzucane.,将法术拖到 + 以设为始终施放。,呪文を + にドラッグすると常時詠唱になります。,주문을 +에 끌어 놓으면 항상 시전으로 설정됩니다.,,, -mcm_wand_appearance,APPEARANCE & LOCKS,ВНЕШНИЙ ВИД И БЛОКИРОВКИ,VISUAL E BLOQUEIOS,ASPECTO Y BLOQUEOS,AUSSEHEN & SPERREN,APPARENCE ET VERROUS,ASPETTO E BLOCCHI,WYGLĄD I BLOKADY,外观与锁定,外見とロック,외형 및 잠금,,, -mcm_wand_appearance_error,Could not change appearance,Не удалось изменить внешний вид,Não foi possível alterar o visual,No se pudo cambiar el aspecto,Aussehen konnte nicht geändert werden,Impossible de modifier l’apparence,Impossibile modificare l’aspetto,Nie udało się zmienić wyglądu,无法修改外观,外見を変更できませんでした,외형을 변경하지 못했습니다,,, -mcm_wand_name,NAME,ИМЯ,NOME,NOMBRE,NAME,NOM,NOME,NAZWA,名称,名前,이름,,, -mcm_wand_show_name,SHOW NAME,ПОКАЗЫВАТЬ ИМЯ,MOSTRAR NOME,MOSTRAR NOMBRE,NAME ZEIGEN,AFFICHER LE NOM,MOSTRA NOME,POKAŻ NAZWĘ,显示名称,名前を表示,이름 표시,,, -mcm_wand_lock,WAND LOCK,БЛОКИРОВКА ЖЕЗЛА,BLOQUEAR VARINHA,BLOQUEAR VARITA,STAB SPERREN,VERROUILLER BAGUETTE,BLOCCA BACCHETTA,BLOKADA RÓŻDŻKI,法杖锁定,杖をロック,완드 잠금,,, -mcm_wand_spell_lock,SPELL LOCK,БЛОКИРОВКА ЗАКЛИНАНИЙ,BLOQUEAR FEITIÇOS,BLOQUEAR HECHIZOS,ZAUBER SPERREN,VERROUILLER SORTS,BLOCCA INCANTESIMI,BLOKADA ZAKLĘĆ,法术锁定,呪文をロック,주문 잠금,,, -mcm_mixed,MIXED,СМЕШАННО,MISTO,MIXTO,GEMISCHT,MIXTE,MISTO,MIESZANE,混合,混在,혼합,,, -mcm_wand_sprite_x,IMAGE OFFSET X,СМЕЩЕНИЕ ИЗОБРАЖЕНИЯ X,DESLOCAMENTO DA IMAGEM X,DESPLAZAMIENTO DE IMAGEN X,BILDVERSATZ X,DÉCALAGE IMAGE X,SPOSTAMENTO IMMAGINE X,PRZESUNIĘCIE OBRAZU X,图像偏移 X,画像オフセット X,이미지 오프셋 X,,, -mcm_wand_sprite_y,IMAGE OFFSET Y,СМЕЩЕНИЕ ИЗОБРАЖЕНИЯ Y,DESLOCAMENTO DA IMAGEM Y,DESPLAZAMIENTO DE IMAGEN Y,BILDVERSATZ Y,DÉCALAGE IMAGE Y,SPOSTAMENTO IMMAGINE Y,PRZESUNIĘCIE OBRAZU Y,图像偏移 Y,画像オフセット Y,이미지 오프셋 Y,,, -mcm_wand_sprite_x_desc,Horizontal offset of the visible wand image in pixels.,Горизонтальное смещение видимого изображения жезла в пикселях.,Deslocamento horizontal da imagem visível da varinha em pixels.,Desplazamiento horizontal de la imagen visible de la varita en píxeles.,Horizontaler Versatz des sichtbaren Stabbilds in Pixeln.,Décalage horizontal de l’image visible de la baguette en pixels.,Spostamento orizzontale in pixel dell’immagine visibile della bacchetta.,Poziome przesunięcie widocznego obrazu różdżki w pikselach.,法杖可见图像的水平像素偏移。,表示される杖画像の水平方向のピクセルオフセット。,표시되는 완드 이미지의 가로 픽셀 오프셋입니다.,,, -mcm_wand_sprite_y_desc,Vertical offset of the visible wand image in pixels.,Вертикальное смещение видимого изображения жезла в пикселях.,Deslocamento vertical da imagem visível da varinha em pixels.,Desplazamiento vertical de la imagen visible de la varita en píxeles.,Vertikaler Versatz des sichtbaren Stabbilds in Pixeln.,Décalage vertical de l’image visible de la baguette en pixels.,Spostamento verticale in pixel dell’immagine visibile della bacchetta.,Pionowe przesunięcie widocznego obrazu różdżki w pikselach.,法杖可见图像的垂直像素偏移。,表示される杖画像の垂直方向のピクセルオフセット。,표시되는 완드 이미지의 세로 픽셀 오프셋입니다.,,, -mcm_wand_geometry_unavailable,NOT AVAILABLE FOR THIS WAND,НЕДОСТУПНО ДЛЯ ЭТОГО ЖЕЗЛА,INDISPONÍVEL PARA ESTA VARINHA,NO DISPONIBLE PARA ESTA VARITA,FÜR DIESEN STAB NICHT VERFÜGBAR,INDISPONIBLE POUR CETTE BAGUETTE,NON DISPONIBILE PER QUESTA BACCHETTA,NIEDOSTĘPNE DLA TEJ RÓŻDŻKI,此法杖不可用,この杖では使用できません,이 완드에서는 사용할 수 없음,,, -mcm_wand_tip_x,SHOOT TIP X,ТОЧКА ВЫСТРЕЛА X,PONTA DE TIRO X,PUNTA DE DISPARO X,SCHUSSPUNKT X,POINT DE TIR X,PUNTA DI TIRO X,PUNKT STRZAŁU X,发射点 X,発射点 X,발사점 X,,, -mcm_wand_tip_y,SHOOT TIP Y,ТОЧКА ВЫСТРЕЛА Y,PONTA DE TIRO Y,PUNTA DE DISPARO Y,SCHUSSPUNKT Y,POINT DE TIR Y,PUNTA DI TIRO Y,PUNKT STRZAŁU Y,发射点 Y,発射点 Y,발사점 Y,,, -mcm_results,Results,Результаты,Resultados,Resultados,Ergebnisse,Résultats,Risultati,Wyniki,结果,結果,결과,,, -mcm_perk_take_amount,TAKE,ВЗЯТЬ,PEGAR,TOMAR,NEHMEN,PRENDRE,PRENDI,WEŹ,获取,取得,획득,,, -mcm_perk_job_take,TAKING,ПОЛУЧЕНИЕ,PEGANDO,TOMANDO,NEHMEN,PRISE,RITIRO,POBIERANIE,获取中,取得中,획득 중,,, -mcm_perk_job_remove,REMOVING,УДАЛЕНИЕ,REMOVENDO,QUITANDO,ENTFERNEN,RETRAIT,RIMOZIONE,USUWANIE,移除中,削除中,제거 중,,, -mcm_perk_job_cancel,CANCEL,ОТМЕНА,CANCELAR,CANCELAR,ABBRECHEN,ANNULER,ANNULLA,ANULUJ,取消,キャンセル,취소,,, -mcm_perk_job_waiting,WAIT,ОЖИДАНИЕ,AGUARDE,ESPERA,WARTEN,ATTENTE,ATTESA,CZEKAJ,等待,待機,대기,,, -mcm_perk_job_failed,Perk job stopped,Задание перков остановлено,Tarefa de benefícios interrompida,Tarea de perks detenida,Perk-Auftrag gestoppt,Tâche des atouts arrêtée,Operazione perk interrotta,Zadanie perków zatrzymane,天赋任务已停止,パーク処理を停止しました,특전 작업이 중지됨,,, -mcm_perk_job_busy,Finish or cancel the current perk job first,Сначала завершите или отмените текущее задание перков,Conclua ou cancele a tarefa de benefícios atual primeiro,Termina o cancela primero la tarea de perks actual,Aktuellen Perk-Auftrag zuerst beenden oder abbrechen,Terminez ou annulez d’abord la tâche d’atouts en cours,Completa o annulla prima l’operazione perk corrente,Najpierw zakończ lub anuluj bieżące zadanie perków,请先完成或取消当前天赋任务,現在のパーク処理を完了またはキャンセルしてください,현재 특전 작업을 먼저 완료하거나 취소하세요,,, -mcm_setting_inventory_open_policy,Inventory opening behavior,Поведение при открытии инвентаря,Comportamento ao abrir o inventário,Comportamiento al abrir el inventario,Verhalten beim Öffnen des Inventars,Comportement à l’ouverture de l’inventaire,Comportamento all’apertura dell’inventario,Zachowanie przy otwieraniu ekwipunku,打开物品栏时的行为,インベントリを開いた時の動作,인벤토리 열기 동작,,, -mcm_setting_inventory_open_policy_desc,Choose whether the creative menu opens with the native inventory.,"Выберите, должно ли творческое меню открываться вместе с обычным инвентарём.",Escolha se o menu criativo abre junto com o inventário normal.,Elige si el menú creativo se abre junto con el inventario normal.,"Legt fest, ob das Kreativmenü zusammen mit dem normalen Inventar geöffnet wird.",Choisissez si le menu créatif s’ouvre avec l’inventaire normal.,Scegli se il menu creativo si apre insieme all’inventario normale.,"Wybierz, czy menu kreatywne ma otwierać się razem ze zwykłym ekwipunkiem.",选择创意菜单是否随原版物品栏一起打开。,クリエイティブメニューを通常のインベントリと一緒に開くか選択します。,크리에이티브 메뉴를 기본 인벤토리와 함께 열지 선택합니다.,,, -mcm_setting_inventory_always_open,Always Open,Всегда открывать,Sempre aberto,Siempre abierto,Immer öffnen,Toujours ouvert,Sempre aperto,Zawsze otwarte,始终打开,常に開く,항상 열기,,, -mcm_setting_inventory_always_closed,Always Closed,Всегда закрыто,Sempre fechado,Siempre cerrado,Immer geschlossen,Toujours fermé,Sempre chiuso,Zawsze zamknięte,始终关闭,常に閉じる,항상 닫기,,, -mcm_setting_inventory_remember,Remember Last State,Запоминать последнее состояние,Lembrar último estado,Recordar último estado,Letzten Zustand merken,Mémoriser le dernier état,Ricorda ultimo stato,Zapamiętaj ostatni stan,记住上次状态,最後の状態を記憶,마지막 상태 기억,,, -mcm_death_not_dead,I'M NOT DEAD!,Я НЕ УМЕР!,EU NÃO MORRI!,¡NO HE MUERTO!,ICH BIN NICHT TOT!,JE NE SUIS PAS MORT !,NON SONO MORTO!,JESZCZE ŻYJĘ!,我没死!,死んでない!,안 죽었어!,,, -mcm_remove,REMOVE,УБРАТЬ,REMOVER,QUITAR,ENTFERNEN,RETIRER,RIMUOVI,USUŃ,移除,削除,제거,,, -mcm_effect_electrocution,Electrocuted,Наэлектризованность,Eletrocutado,Electrocutado,Stromschlag,Électrocution,Elettrocuzione,Porażenie prądem,触电,感電,감전,,, -mcm_effect_electrocution_desc,Electrical stun.,Электрическое оглушение.,Atordoamento elétrico.,Aturdimiento eléctrico.,Elektrische Betäubung.,Étourdissement électrique.,Stordimento elettrico.,Ogłuszenie elektryczne.,电击眩晕。,電撃スタン。,전기 기절.,,, -mcm_qa_title,Metamorph: Creative Menu QA,Metamorph: Creative Menu — проверка,Metamorph: Creative Menu QA,Control de Metamorph: Creative Menu,Metamorph: Creative Menu QA,Test de Metamorph: Creative Menu,Test Metamorph: Creative Menu,Test Metamorph: Creative Menu,Metamorph: Creative Menu 测试,Metamorph: Creative Menu QA,Metamorph: Creative Menu QA,,, -mcm_qa_summary,Result,Результат,Resultado,Resultado,Ergebnis,Résultat,Risultato,Wynik,结果,結果,결과,,, -mcm_qa_pass,pass,успех,sucesso,correcto,bestanden,réussite,superati,zaliczone,通过,成功,통과,,, -mcm_qa_warn,warn,предупр.,avisos,avisos,Warnungen,avert.,avvisi,ostrzeż.,警告,警告,경고,,, -mcm_qa_fail,fail,ошибки,falhas,fallos,Fehler,échecs,errori,błędy,失败,失敗,실패,,, -mcm_qa_already_running,Metamorph: Creative Menu QA is already running,Проверка Metamorph: Creative Menu уже запущена,O teste do Metamorph: Creative Menu já está em execução,La comprobación de Metamorph: Creative Menu ya está en ejecución,Metamorph: Creative Menu QA läuft bereits,Le test de Metamorph: Creative Menu est déjà en cours,Il test di Metamorph: Creative Menu è già in esecuzione,Test Metamorph: Creative Menu już działa,Metamorph: Creative Menu 测试已在运行,Metamorph: Creative Menu のテストは既に実行中です,Metamorph: Creative Menu QA가 이미 실행 중입니다,,, -mcm_qa_no_player,Metamorph: Creative Menu QA: no player,Проверка Metamorph: Creative Menu: игрок не найден,Metamorph: Creative Menu QA: jogador não encontrado,Control de Metamorph: Creative Menu: no se encontró jugador,Metamorph: Creative Menu QA: kein Spieler,Test de Metamorph: Creative Menu : joueur introuvable,Test Metamorph: Creative Menu: giocatore non trovato,Test Metamorph: Creative Menu: brak gracza,Metamorph: Creative Menu 测试:未找到玩家,Metamorph: Creative Menu QA:プレイヤーが見つかりません,Metamorph: Creative Menu QA: 플레이어를 찾을 수 없습니다,,, -mcm_qa_preflight_failed,Scenario preflight failed; diagnostic scan is still running,Предварительная проверка сценария не пройдена; диагностика продолжает работу,A verificação preliminar do cenário falhou; o diagnóstico continua,Falló la comprobación previa del escenario; el diagnóstico sigue ejecutándose,Szenario-Vorprüfung fehlgeschlagen; Diagnose läuft weiter,La vérification préalable du scénario a échoué ; le diagnostic continue,Controllo preliminare dello scenario fallito; la diagnostica continua,Wstępna kontrola scenariusza nie powiodła się; diagnostyka trwa,场景预检失败;诊断仍在运行,シナリオ事前チェックに失敗しました。診断は継続中です,시나리오 사전 검사가 실패했습니다. 진단은 계속 실행됩니다,,, -mcm_qa_items_incomplete,Representative item set is incomplete; diagnostic scan is still running,Набор тестовых предметов неполон; диагностика продолжает работу,O conjunto representativo de itens está incompleto; o diagnóstico continua,El conjunto representativo de objetos está incompleto; el diagnóstico sigue ejecutándose,Repräsentativer Gegenstandssatz ist unvollständig; Diagnose läuft weiter,Le jeu d’objets représentatif est incomplet ; le diagnostic continue,Il set rappresentativo di oggetti è incompleto; la diagnostica continua,Reprezentatywny zestaw przedmiotów jest niepełny; diagnostyka trwa,代表性物品集不完整;诊断仍在运行,代表アイテム一式が不完全です。診断は継続中です,대표 아이템 세트가 불완전합니다. 진단은 계속 실행됩니다,,, -mcm_qa_started,Started. Z runs the full scenario test; diagnostics run in parallel.,Запущено. Z запускает полный сценарный тест; диагностика идёт параллельно.,Iniciado. Z executa o teste completo; o diagnóstico roda em paralelo.,Iniciado. Z ejecuta la prueba completa; el diagnóstico se ejecuta en paralelo.,Gestartet. Z führt den vollständigen Szenariotest aus; Diagnose läuft parallel.,Démarré. Z lance le test complet ; le diagnostic s’exécute en parallèle.,Avviato. Z esegue il test completo; la diagnostica procede in parallelo.,Uruchomiono. Z wykonuje pełny test scenariusza; diagnostyka działa równolegle.,已启动。Z 运行完整场景测试;诊断并行运行。,開始しました。Z で完全なシナリオテストを実行し、診断も並行して行います。,시작되었습니다. Z는 전체 시나리오 테스트를 실행하며 진단도 병렬로 실행됩니다.,,, -mcm_diag_title,Metamorph: Creative Menu diagnostics,Диагностика Metamorph: Creative Menu,Diagnóstico do Metamorph: Creative Menu,Diagnóstico de Metamorph: Creative Menu,Metamorph: Creative Menu Diagnose,Diagnostic de Metamorph: Creative Menu,Diagnostica Metamorph: Creative Menu,Diagnostyka Metamorph: Creative Menu,Metamorph: Creative Menu 诊断,Metamorph: Creative Menu 診断,Metamorph: Creative Menu 진단,,, -mcm_diag_already_running,Metamorph: Creative Menu diagnostics are already running,Диагностика Metamorph: Creative Menu уже запущена,O diagnóstico do Metamorph: Creative Menu já está em execução,El diagnóstico de Metamorph: Creative Menu ya está en ejecución,Metamorph: Creative Menu Diagnose läuft bereits,Le diagnostic de Metamorph: Creative Menu est déjà en cours,La diagnostica Metamorph: Creative Menu è già in esecuzione,Diagnostyka Metamorph: Creative Menu już działa,Metamorph: Creative Menu 诊断已在运行,Metamorph: Creative Menu の診断は既に実行中です,Metamorph: Creative Menu 진단이 이미 실행 중입니다,,, -mcm_diag_started,Started (Z). Non-destructive scan and frame sampling are running.,Запущено (Z). Идут безопасное сканирование и замеры кадров.,Iniciado (Z). Varredura não destrutiva e amostragem de quadros em execução.,Iniciado (Z). Se ejecutan el análisis no destructivo y el muestreo de fotogramas.,Gestartet (Z). Zerstörungsfreier Scan und Frame-Sampling laufen.,Démarré (Z). Analyse non destructive et échantillonnage des images en cours.,Avviato (Z). Scansione non distruttiva e campionamento dei frame in corso.,Uruchomiono (Z). Trwa bezpieczne skanowanie i próbkowanie klatek.,已启动 (Z)。正在进行非破坏性扫描和帧采样。,開始 (Z)。非破壊スキャンとフレームサンプリングを実行中です。,시작됨 (Z). 비파괴 스캔과 프레임 샘플링을 실행 중입니다.,,, -mcm_diag_write_failed,Could not write diagnostic report,Не удалось записать отчёт диагностики,Não foi possível gravar o relatório de diagnóstico,No se pudo escribir el informe de diagnóstico,Diagnosebericht konnte nicht geschrieben werden,Impossible d’écrire le rapport de diagnostic,Impossibile scrivere il rapporto diagnostico,Nie udało się zapisać raportu diagnostycznego,无法写入诊断报告,診断レポートを書き込めませんでした,진단 보고서를 기록할 수 없습니다,,, -mcm_diag_done,Done,Готово,Concluído,Hecho,Fertig,Terminé,Fatto,Gotowe,完成,完了,완료,,, -mcm_kolmi_encounter_failed,MCM: Kolmisilma encounter failed,MCM: не удалось запустить бой с Колмисилмой,MCM: falha ao iniciar o encontro com Kolmisilma,MCM: no se pudo iniciar el encuentro con Kolmisilma,MCM: Kolmisilma-Begegnung fehlgeschlagen,MCM : échec du lancement du combat contre Kolmisilma,MCM: impossibile avviare lo scontro con Kolmisilma,MCM: nie udało się uruchomić starcia z Kolmisilmą,MCM:Kolmisilma 战斗启动失败,MCM:Kolmisilma 戦を開始できませんでした,MCM: Kolmisilma 전투를 시작하지 못했습니다,,, -mcm_kolmi_encounter_failed_desc,Could not start the stock boss encounter,Не удалось запустить штатный сценарий босса,Não foi possível iniciar o encontro original do chefe,No se pudo iniciar el encuentro original del jefe,Originale Boss-Begegnung konnte nicht gestartet werden,Impossible de lancer le combat de boss d’origine,Impossibile avviare lo scontro originale del boss,Nie udało się uruchomić oryginalnego starcia z bossem,无法启动原版 Boss 战流程,標準のボス戦を開始できませんでした,기본 보스 전투를 시작할 수 없습니다,,, diff --git a/metamorph_creative_menu/workshop.xml b/metamorph_creative_menu/workshop.xml new file mode 100644 index 0000000..5c11d74 --- /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 diff --git a/tools/build_user_release.py b/tools/build_user_release.py deleted file mode 100644 index d039fa6..0000000 --- a/tools/build_user_release.py +++ /dev/null @@ -1,386 +0,0 @@ -#!/usr/bin/env python3 -"""Build the player-facing Metamorph: Creative Menu archive from the full source tree.""" - -from __future__ import annotations - -import argparse -import re -import shutil -import subprocess -import tempfile -import zipfile -from pathlib import Path - -ROOT_NAME = "metamorph_creative_menu" - -EXCLUDED_DIRS = { - "tests", - "files/qa", - "files/diagnostics", - "native_src", - "tools", - "dist", -} - -EXCLUDED_FILES = { - "BUILD.txt", - "CHANGELOG.txt", - "VERSION.txt", - "dev_mode.lua", - "mcm_native_gameover.lib", - "mod_id.txt", - "files/core/bounded_log.lua", - "files/features/creatures/diagnostics.lua", - "files/integrations/ew/bridge/qa.lua", - "files/integrations/ew/bridge/qa_reserved.lua", -} - -REQUIRED_RUNTIME_FILES = { - "mod.xml", - "init.lua", - "settings.lua", - "translations.csv", - "NoitaPatcher/load.lua", - "NoitaPatcher/noitapatcher.dll", - "mcm_native_gameover.dll", -} - - -def is_excluded(relative: Path) -> bool: - normalized = relative.as_posix() - if normalized in EXCLUDED_FILES: - return True - return any(normalized == directory or normalized.startswith(directory + "/") for directory in EXCLUDED_DIRS) - - -def long_bracket_open(text: str, index: int) -> tuple[int, int] | None: - if index >= len(text) or text[index] != "[": - return None - cursor = index + 1 - while cursor < len(text) and text[cursor] == "=": - cursor += 1 - if cursor < len(text) and text[cursor] == "[": - return cursor - index - 1, cursor + 1 - return None - - -def long_bracket_close(text: str, index: int, equals: int) -> int | None: - if index >= len(text) or text[index] != "]": - return None - cursor = index + 1 - for _ in range(equals): - if cursor >= len(text) or text[cursor] != "=": - return None - cursor += 1 - if cursor < len(text) and text[cursor] == "]": - return cursor + 1 - return None - - -def strip_lua_comments(text: str) -> str: - out: list[str] = [] - index = 0 - state = "code" - quote = "" - equals = 0 - - while index < len(text): - char = text[index] - if state == "code": - if char in {"'", '"'}: - quote = char - state = "string" - out.append(char) - index += 1 - continue - opened = long_bracket_open(text, index) - if opened is not None: - equals, end = opened - out.append(text[index:end]) - index = end - state = "long_string" - continue - if char == "-" and index + 1 < len(text) and text[index + 1] == "-": - opened = long_bracket_open(text, index + 2) - if opened is not None: - equals, end = opened - index = end - state = "block_comment" - continue - index += 2 - while index < len(text) and text[index] not in "\r\n": - index += 1 - continue - out.append(char) - index += 1 - continue - - if state == "string": - out.append(char) - index += 1 - if char == "\\" and index < len(text): - out.append(text[index]) - index += 1 - elif char == quote: - state = "code" - continue - - if state == "long_string": - end = long_bracket_close(text, index, equals) - if end is not None: - out.append(text[index:end]) - index = end - state = "code" - else: - out.append(char) - index += 1 - continue - - end = long_bracket_close(text, index, equals) - if end is not None: - index = end - state = "code" - else: - if char in "\r\n": - out.append(char) - index += 1 - - lines = [line.rstrip() for line in "".join(out).splitlines()] - compact: list[str] = [] - blank = False - for line in lines: - if not line: - if blank: - continue - blank = True - else: - blank = False - compact.append(line) - return "\n".join(compact).strip() + "\n" - - -def remove_marker_guard_blocks(text: str, marker: str) -> str: - lines = text.splitlines() - output: list[str] = [] - index = 0 - while index < len(lines): - line = lines[index] - if marker not in line or not line.lstrip().startswith("if "): - output.append(line) - index += 1 - continue - - depth = 0 - while index < len(lines): - current = lines[index].strip() - if re.match(r"^(if|for|while)\b.*\bthen\b", current) or current.startswith("function "): - depth += 1 - if current == "end" or current.startswith("end "): - depth -= 1 - index += 1 - if depth <= 0: - break - return "\n".join(output).strip() + "\n" - - -def clean_init(text: str) -> str: - text = re.sub( - r'local dev_mode_value = dofile\("mods/metamorph_creative_menu/dev_mode\.lua"\)\n' - r'local DEV_MODE = tonumber\(dev_mode_value\) == 1\n' - r'METAMORPH_CREATIVE_MENU_DEV_MODE = DEV_MODE\n+', - "", - text, - count=1, - ) - text = re.sub(r'^local diagnostics = DEV_MODE.*\n', "", text, flags=re.MULTILINE) - text = re.sub(r'^local qa_controller = DEV_MODE.*\n', "", text, flags=re.MULTILINE) - text = re.sub(r'^\s*if diagnostics ~= nil then protected_call\("diagnostics\.update".*\n', "", text, flags=re.MULTILINE) - text = re.sub(r'^\s*if qa_controller ~= nil then protected_call\("qa_controller\.update".*\n', "", text, flags=re.MULTILINE) - text = remove_marker_guard_blocks(text, "METAMORPH_CREATIVE_MENU_DIAGNOSTICS_CAPTURE") - text = re.sub( - r'\s*local ok_resilience, seed_fallbacks, biome_fallbacks, crosscall_guards =\n' - r'\s*protected_call\("ew_resilience\.post_init", ew_resilience\.post_init\)\n' - r'\s*if not ok_resilience then.*?\n' - r'\s*if \(tonumber\(seed_fallbacks\).*?\n' - r'\s*or \(tonumber\(crosscall_guards\).*?\n' - r'\s*then\n' - r'.*?\n' - r'.*?\n' - r'\s*end\n', - '\n protected_call("ew_resilience.post_init", ew_resilience.post_init)\n', - text, - count=1, - flags=re.DOTALL, - ) - text = re.sub(r'^\s*elseif DEV_MODE and tonumber\(prepared\) ~= nil then\n\s*print\([^\n]+\)\n', "", text, flags=re.MULTILINE) - return text - - -def clean_bootstrap(text: str) -> str: - text = re.sub( - r'local dev_mode = tonumber\(dofile\("mods/metamorph_creative_menu/dev_mode\.lua"\)\) == 1\n' - r'local qa = dofile\(dev_mode\n' - r'\s*and "mods/metamorph_creative_menu/files/integrations/ew/bridge/qa\.lua"\n' - r'\s*or "mods/metamorph_creative_menu/files/integrations/ew/bridge/qa_reserved\.lua"\)\n', - "", - text, - count=1, - ) - text = re.sub(r'^if type\(forms\.set_profiling_enabled\).*\n', "", text, flags=re.MULTILINE) - text = re.sub(r'^if type\(materials\.set_metrics_enabled\).*\n', "", text, flags=re.MULTILINE) - text = text.replace( - "qa.register(rpc, common)", - "rpc.opts_reliable()\nrpc.opts_everywhere()\nfunction rpc.sync_qa_state(...) end", - ) - text = re.sub(r'\n\s*if dev_mode then\n.*?\n\s*end\n', "\n", text, count=1, flags=re.DOTALL) - text = re.sub(r'^\s*if dev_mode then protected_update\("qa\.update".*\n', "", text, flags=re.MULTILINE) - return text - - -def clean_resilience(text: str) -> str: - return text.replace( - 'local DEV_MODE = tonumber(dofile("mods/metamorph_creative_menu/dev_mode.lua")) == 1', - 'local DEV_MODE = false', - ) - - -def clean_lua(relative: str, text: str) -> str: - text = strip_lua_comments(text) - if relative == "init.lua": - text = clean_init(text) - elif relative == "files/integrations/ew/bootstrap.lua": - text = clean_bootstrap(text) - elif relative == "files/integrations/ew/resilience.lua": - text = clean_resilience(text) - elif relative == "files/integrations/ew/bridge/protocol.lua": - namespace = re.search(r'NAMESPACE\s*=\s*"([^"]+)"', text) - if namespace is None: - raise RuntimeError("EW protocol namespace not found") - text = 'return {\n NAMESPACE = "' + namespace.group(1) + '",\n}\n' - return strip_lua_comments(text) - - -def write_stage(source: Path, stage_root: Path) -> None: - target = stage_root / ROOT_NAME - target.mkdir(parents=True, exist_ok=True) - for path in sorted(source.rglob("*"), key=lambda item: item.relative_to(source).as_posix()): - if not path.is_file() or path.is_symlink(): - continue - relative = path.relative_to(source) - if is_excluded(relative): - continue - data = path.read_bytes() - if relative.suffix.lower() == ".lua": - data = clean_lua(relative.as_posix(), data.decode("utf-8")).encode("utf-8") - destination = target / relative - destination.parent.mkdir(parents=True, exist_ok=True) - destination.write_bytes(data) - - (target / "README.txt").write_text("Creator: zerodancing\n", encoding="utf-8") - - -def run_patch(executable: str, patch_file: Path, stage_root: Path, *, reverse: bool, dry_run: bool) -> subprocess.CompletedProcess[str]: - command = [executable, "-p0", "--batch", "--fuzz=0"] - if dry_run: - command.append("--dry-run") - if reverse: - command.append("--reverse") - else: - command.append("--forward") - command.extend(["-i", str(patch_file.resolve())]) - return subprocess.run( - command, - cwd=stage_root, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - ) - - -def apply_release_patches(source: Path, stage_root: Path) -> None: - patch_dir = source / "tools" / "player_release" - patch_files = sorted(patch_dir.glob("*.patch")) if patch_dir.is_dir() else [] - if not patch_files: - raise RuntimeError("tools/player_release/*.patch is missing from the development source") - executable = shutil.which("patch") - if executable is None: - raise RuntimeError("the 'patch' utility is required to build the player release") - - for patch_file in patch_files: - forward_check = run_patch(executable, patch_file, stage_root, reverse=False, dry_run=True) - if forward_check.returncode == 0: - applied = run_patch(executable, patch_file, stage_root, reverse=False, dry_run=False) - if applied.returncode != 0: - raise RuntimeError( - f"player release patch {patch_file.name} passed dry-run but failed to apply:\n" - + applied.stdout.strip() - ) - continue - - # A future source version may permanently adopt a cleanup that used to be - # release-only. If the complete patch cleanly applies in reverse, the stage - # already contains that cleanup and no mutation is needed. - reverse_check = run_patch(executable, patch_file, stage_root, reverse=True, dry_run=True) - if reverse_check.returncode == 0: - continue - - raise RuntimeError( - f"player release patch {patch_file.name} no longer matches the source. " - "Update or retire this cleanup rule before publishing the new version.\n" - + forward_check.stdout.strip() - ) - - for backup in stage_root.rglob("*.orig"): - backup.unlink() - - -def build(source: Path, output: Path) -> tuple[int, int]: - if source.name != ROOT_NAME: - raise RuntimeError(f"source directory must be named {ROOT_NAME!r}") - - missing = sorted(path for path in REQUIRED_RUNTIME_FILES if not (source / path).is_file()) - if missing: - raise RuntimeError("missing runtime files: " + ", ".join(missing)) - - with tempfile.TemporaryDirectory(prefix="mcm-player-") as temporary: - stage_root = Path(temporary) - write_stage(source, stage_root) - apply_release_patches(source, stage_root) - target = stage_root / ROOT_NAME - files = sorted(path for path in target.rglob("*") if path.is_file()) - - output.parent.mkdir(parents=True, exist_ok=True) - if output.exists(): - output.unlink() - - total_bytes = 0 - with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: - for path in files: - relative = path.relative_to(target).as_posix() - data = path.read_bytes() - total_bytes += len(data) - info = zipfile.ZipInfo(f"{ROOT_NAME}/{relative}", (2020, 1, 1, 0, 0, 0)) - info.compress_type = zipfile.ZIP_DEFLATED - info.external_attr = 0o100644 << 16 - info.create_system = 3 - archive.writestr(info, data, compress_type=zipfile.ZIP_DEFLATED, compresslevel=9) - - with zipfile.ZipFile(output) as archive: - broken = archive.testzip() - if broken is not None: - raise RuntimeError(f"archive CRC verification failed: {broken}") - return len(files), total_bytes - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("source", nargs="?", type=Path, default=Path(ROOT_NAME)) - parser.add_argument("output", nargs="?", type=Path, default=Path("Metamorph-Creative-Menu.zip")) - args = parser.parse_args() - count, total_bytes = build(args.source.resolve(), args.output.resolve()) - print(f"PLAYER_BUILD=PASS files={count} source_bytes={total_bytes} archive={args.output}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/check_readme_consistency.py b/tools/check_readme_consistency.py deleted file mode 100644 index 177cd80..0000000 --- a/tools/check_readme_consistency.py +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env python3 -"""Validate the synchronized multilingual GitHub README set.""" - -from __future__ import annotations - -import re -import sys -from pathlib import Path - - -README_FILES = [ - "README.md", - "README.ru.md", - "README.pt-BR.md", - "README.es.md", - "README.de.md", - "README.fr.md", - "README.it.md", - "README.pl.md", - "README.zh-CN.md", - "README.ja.md", - "README.ko.md", -] - -REQUIRED_MARKERS = [ - "zerodancing", - "latest-build", - "Unsafe Mods", - "NoitaPatcher", - "Entangled Worlds", - "Game Over", - "ALWAYS CAST", - "metamorph_creative_menu/mod.xml", - "metamorph_creative_menu/tests/", - "Metamorph-Creative-Menu-v...zip", - "THIRD_PARTY_NOTICES.md", -] - -# Root READMEs document current behavior instead of a numbered MCM release. -# Match release/version wording tied to MCM, while allowing legitimate version -# numbers for dependencies, tools, game builds, protocols, and similar details. -MCM_VERSION_PATTERNS = [ - re.compile(r"\bcurrent\s+version\s*[::]?\s*\*{0,2}v?\d+\.\d+(?:\.\d+)?", re.IGNORECASE), - re.compile(r"\bversion\s+v?\d+\.\d+(?:\.\d+)?\b", re.IGNORECASE), - re.compile(r"\bMCM\s+v?\d+\.\d+(?:\.\d+)?\b", re.IGNORECASE), - re.compile(r"Metamorph:\s*Creative\s+Menu\s+v?\d+\.\d+(?:\.\d+)?", re.IGNORECASE), -] -HEADING_RE = re.compile(r"^(#{1,6})\s+\S", re.MULTILINE) - - -def fail(message: str) -> None: - print(f"README consistency: {message}", file=sys.stderr) - raise SystemExit(1) - - -def heading_shape(text: str) -> list[int]: - return [len(match.group(1)) for match in HEADING_RE.finditer(text)] - - -def find_mcm_version(text: str) -> str | None: - for pattern in MCM_VERSION_PATTERNS: - match = pattern.search(text) - if match: - return match.group(0) - return None - - -def main() -> None: - root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve() - - missing = [name for name in README_FILES if not (root / name).is_file()] - if missing: - fail("missing files: " + ", ".join(missing)) - - docs = { - name: (root / name).read_text(encoding="utf-8-sig") - for name in README_FILES - } - - english_shape = heading_shape(docs["README.md"]) - if not english_shape: - fail("README.md has no Markdown section headings") - - for name, text in docs.items(): - version_reference = find_mcm_version(text) - if version_reference: - fail(f"{name} contains a numbered MCM release reference: {version_reference!r}") - - for marker in REQUIRED_MARKERS: - if marker not in text: - fail(f"{name} is missing required topic marker {marker!r}") - - for linked_name in README_FILES: - if linked_name not in text: - fail(f"{name} is missing language link {linked_name!r}") - - shape = heading_shape(text) - if shape != english_shape: - fail( - f"{name} heading structure differs from README.md " - f"({len(shape)} headings vs {len(english_shape)})" - ) - - print( - "README consistency: PASS " - f"({len(README_FILES)} languages, {len(english_shape)} headings each)" - ) - - -if __name__ == "__main__": - main() diff --git a/tools/check_source_hygiene.py b/tools/check_source_hygiene.py deleted file mode 100644 index 61e8179..0000000 --- a/tools/check_source_hygiene.py +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env python3 -"""Reject development-process residue and stale public metadata from the repository.""" - -from __future__ import annotations - -import argparse -import re -from pathlib import Path - -TEXT_SUFFIXES = { - ".lua", ".py", ".md", ".txt", ".xml", ".yml", ".yaml", ".csv", - ".c", ".h", ".patch", ".bat", ".cmd", ".ps1", ".sh", -} -SKIP_PARTS = {".git", "__pycache__", ".pytest_cache", "dist"} -SKIP_FILES = { - "tools/check_source_hygiene.py", - "tools/validate_user_release.py", -} - -RULES = ( - ("numbered internal test chronology", re.compile(r"\bTEST\s+\d+\b", re.IGNORECASE)), - ("legacy test-only export", re.compile(r"\b[A-Za-z0-9_]+_for_test\b")), - ("conversation-specific wording", re.compile(r"\b(?:the\s+)?user['’]s\s+build\b", re.IGNORECASE)), - ("conversation-specific wording", re.compile(r"\bat\s+the\s+user['’]s\s+request\b", re.IGNORECASE)), - ("internal numbered runtime label", re.compile(r"\[MCM\s*(?:TEST\s*)?\d{2,}\]", re.IGNORECASE)), - ("AI tool/model attribution residue", re.compile( - r"\b(?:ChatGPT|OpenAI|Claude|Gemini|Copilot|LLM|AI[- ]generated|generated\s+by\s+AI)\b", - re.IGNORECASE, - )), -) - - -def relative_name(root: Path, path: Path) -> str: - return path.relative_to(root).as_posix() - - -def validate_metadata(root: Path, failures: list[str]) -> None: - mod_root = root / "metamorph_creative_menu" - version_file = mod_root / "VERSION.txt" - readme = root / "README.md" - source_readme = mod_root / "README.txt" - - if not version_file.is_file(): - failures.append("metamorph_creative_menu/VERSION.txt: missing") - return - - version = version_file.read_text(encoding="utf-8").strip() - if not re.fullmatch(r"[0-9]+(?:\.[0-9]+){0,2}", version): - failures.append(f"metamorph_creative_menu/VERSION.txt: invalid version {version!r}") - - if readme.is_file(): - text = readme.read_text(encoding="utf-8") - if "zerodancing" not in text: - failures.append("README.md: creator attribution to zerodancing is missing") - stale = re.findall(r"\bVersion\s+([0-9]+(?:\.[0-9]+){1,2})\b|\bCurrent version:\s*\*\*([^*]+)\*\*", text, re.IGNORECASE) - for first, second in stale: - mentioned = (first or second).strip() - if mentioned and mentioned != version: - failures.append(f"README.md: stale version {mentioned!r}; VERSION.txt is {version!r}") - - if source_readme.is_file() and "zerodancing" not in source_readme.read_text(encoding="utf-8"): - failures.append("metamorph_creative_menu/README.txt: creator attribution is missing") - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("root", nargs="?", type=Path, default=Path(".")) - args = parser.parse_args() - root = args.root.resolve() - - failures: list[str] = [] - checked = 0 - for path in sorted(root.rglob("*")): - if not path.is_file() or path.suffix.lower() not in TEXT_SUFFIXES: - continue - relative = relative_name(root, path) - if relative in SKIP_FILES or any(part in SKIP_PARTS for part in path.relative_to(root).parts): - continue - checked += 1 - try: - text = path.read_text(encoding="utf-8") - except UnicodeDecodeError: - continue - for line_number, line in enumerate(text.splitlines(), 1): - for label, pattern in RULES: - if pattern.search(line): - failures.append(f"{relative}:{line_number}: {label}: {line.strip()[:180]}") - - validate_metadata(root, failures) - - if failures: - print("SOURCE_HYGIENE=FAIL") - for failure in failures: - print(failure) - return 1 - - print(f"SOURCE_HYGIENE=PASS files={checked}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/import_source_archive.py b/tools/import_source_archive.py deleted file mode 100644 index 62095dd..0000000 --- a/tools/import_source_archive.py +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env python3 -"""Validate and import one complete MCM development-source archive.""" - -from __future__ import annotations - -import os -import re -import shutil -import tempfile -import zipfile -from pathlib import Path, PurePosixPath - -ROOT_NAME = "metamorph_creative_menu" -ARCHIVE_RE = re.compile( - r"^Metamorph-Creative-Menu-v([0-9]+)(?:[._-][0-9A-Za-z]+)*\.zip$", - re.IGNORECASE, -) -VERSION_RE = re.compile(r"[0-9]+(?:\.[0-9]+){0,2}") -MAX_ARCHIVE_FILES = 10_000 -MAX_UNCOMPRESSED_BYTES = 256 * 1024 * 1024 - -REQUIRED_SOURCE_FILES = { - "VERSION.txt", - "LICENSE.txt", - "NOTICE.txt", - "mod.xml", - "init.lua", - "dev_mode.lua", - "tests/run_all.py", - "files/qa/controller.lua", - "files/diagnostics/service.lua", - "native_src/mcm_native_gameover.c", - "native_src/README.md", - "native_src/build_msvc.bat", - "mcm_native_gameover.dll", - "mcm_native_gameover.lib", - "tools/build_release.py", - "NoitaPatcher/noitapatcher.dll", -} - - -def write_output(key: str, value: str) -> None: - output = os.environ.get("GITHUB_OUTPUT") - if output: - with open(output, "a", encoding="utf-8") as handle: - handle.write(f"{key}={value}\n") - - -def discover_archive(root: Path) -> tuple[Path | None, re.Match[str] | None]: - matches: list[tuple[Path, re.Match[str]]] = [] - for path in sorted(root.iterdir(), key=lambda item: item.name.lower()): - if not path.is_file(): - continue - match = ARCHIVE_RE.fullmatch(path.name) - if match is None or "-modworkshop" in path.name.lower(): - continue - matches.append((path, match)) - - if not matches: - return None, None - if len(matches) != 1: - raise RuntimeError( - "expected exactly one full source archive, found: " - + ", ".join(path.name for path, _ in matches) - ) - return matches[0] - - -def validate_members(archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]: - members = archive.infolist() - if len(members) > MAX_ARCHIVE_FILES: - raise RuntimeError(f"archive contains too many entries: {len(members)}") - - total_size = sum(info.file_size for info in members) - if total_size > MAX_UNCOMPRESSED_BYTES: - raise RuntimeError(f"archive is too large when unpacked: {total_size} bytes") - - seen: set[str] = set() - seen_casefolded: set[str] = set() - for info in members: - name = info.filename - if "\\" in name: - raise RuntimeError(f"unsafe archive path uses backslashes: {name}") - path = PurePosixPath(name) - if path.is_absolute() or ".." in path.parts: - raise RuntimeError(f"unsafe archive path: {name}") - if not path.parts or path.parts[0] != ROOT_NAME: - raise RuntimeError(f"unexpected archive path outside {ROOT_NAME}/: {name}") - if any(":" in part for part in path.parts): - raise RuntimeError(f"Windows-incompatible archive path: {name}") - - normalized = path.as_posix().rstrip("/") - if normalized in seen: - raise RuntimeError(f"duplicate archive path: {name}") - folded = normalized.casefold() - if folded in seen_casefolded: - raise RuntimeError(f"case-colliding archive path: {name}") - seen.add(normalized) - seen_casefolded.add(folded) - - file_type = (info.external_attr >> 16) & 0o170000 - if file_type == 0o120000: - raise RuntimeError(f"symlink is not allowed in source archive: {name}") - if file_type not in {0, 0o040000, 0o100000}: - raise RuntimeError(f"special filesystem entry is not allowed: {name}") - - broken = archive.testzip() - if broken is not None: - raise RuntimeError(f"archive CRC verification failed: {broken}") - return members - - -def validate_candidate(candidate: Path, archive_major: str) -> str: - missing = sorted( - relative for relative in REQUIRED_SOURCE_FILES - if not (candidate / relative).is_file() - ) - if missing: - raise RuntimeError("missing required source files: " + ", ".join(missing)) - - patch_dir = candidate / "tools" / "player_release" - if not patch_dir.is_dir() or not any(patch_dir.glob("*.patch")): - raise RuntimeError("missing player release cleanup patches") - - version = (candidate / "VERSION.txt").read_text(encoding="utf-8").strip() - if VERSION_RE.fullmatch(version) is None: - raise RuntimeError(f"invalid VERSION.txt: {version!r}") - if version.split(".", 1)[0] != archive_major: - raise RuntimeError( - f"archive name says v{archive_major}, but VERSION.txt says {version}" - ) - return version - - -def import_archive(repository_root: Path) -> tuple[bool, str | None]: - archive_path, match = discover_archive(repository_root) - if archive_path is None or match is None: - print("No full source archive found; nothing to import.") - return False, None - - with tempfile.TemporaryDirectory(prefix="mcm-source-import-") as temporary: - temporary_root = Path(temporary) - with zipfile.ZipFile(archive_path) as archive: - validate_members(archive) - archive.extractall(temporary_root) - - candidate = temporary_root / ROOT_NAME - version = validate_candidate(candidate, match.group(1)) - - target = repository_root / ROOT_NAME - replacement = repository_root / f".{ROOT_NAME}.incoming" - if replacement.exists(): - shutil.rmtree(replacement) - shutil.copytree(candidate, replacement) - - if target.exists(): - shutil.rmtree(target) - replacement.replace(target) - - archive_path.unlink() - return True, version - - -def main() -> int: - root = Path.cwd().resolve() - try: - changed, version = import_archive(root) - except (OSError, RuntimeError, zipfile.BadZipFile, UnicodeError) as exc: - print(f"SOURCE_IMPORT=FAIL {exc}") - return 1 - - write_output("changed", "true" if changed else "false") - if version is not None: - write_output("version", version) - if changed: - print(f"SOURCE_IMPORT=PASS version={version}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/test_repository_tools.py b/tools/test_repository_tools.py deleted file mode 100644 index 0a9a4a5..0000000 --- a/tools/test_repository_tools.py +++ /dev/null @@ -1,167 +0,0 @@ -#!/usr/bin/env python3 -"""Contract tests for repository import and release-support tooling.""" - -from __future__ import annotations - -import os -import tempfile -import unittest -import zipfile -from pathlib import Path -from unittest import mock - -import build_user_release as player_builder -import import_source_archive as importer -import validate_user_release as player_validator - - -class SourceArchiveTests(unittest.TestCase): - def make_source_tree(self, root: Path, version: str = "4.1") -> Path: - source = root / importer.ROOT_NAME - for relative in sorted(importer.REQUIRED_SOURCE_FILES): - path = source / relative - path.parent.mkdir(parents=True, exist_ok=True) - if relative == "VERSION.txt": - path.write_text(version + "\n", encoding="utf-8") - else: - path.write_bytes(b"placeholder\n") - patch = source / "tools" / "player_release" / "00-runtime.patch" - patch.parent.mkdir(parents=True, exist_ok=True) - patch.write_text("placeholder\n", encoding="utf-8") - return source - - def write_archive(self, repository: Path, name: str, source: Path) -> Path: - archive_path = repository / name - with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: - for path in sorted(source.rglob("*")): - if path.is_file(): - archive.write(path, path.relative_to(source.parent).as_posix()) - return archive_path - - def test_imports_valid_versioned_source_and_removes_archive(self) -> None: - with tempfile.TemporaryDirectory() as temp: - base = Path(temp) - repository = base / "repo" - staging = base / "staging" - repository.mkdir() - source = self.make_source_tree(staging, "4.1") - archive = self.write_archive( - repository, - "Metamorph-Creative-Menu-v4.1.zip", - source, - ) - old_target = repository / importer.ROOT_NAME - old_target.mkdir() - (old_target / "obsolete.txt").write_text("old", encoding="utf-8") - - changed, version = importer.import_archive(repository) - - self.assertTrue(changed) - self.assertEqual(version, "4.1") - self.assertFalse(archive.exists()) - self.assertFalse((old_target / "obsolete.txt").exists()) - self.assertEqual((old_target / "VERSION.txt").read_text().strip(), "4.1") - self.assertTrue((old_target / "tests" / "run_all.py").is_file()) - - def test_rejects_archive_major_version_mismatch_without_replacing_source(self) -> None: - with tempfile.TemporaryDirectory() as temp: - base = Path(temp) - repository = base / "repo" - staging = base / "staging" - repository.mkdir() - source = self.make_source_tree(staging, "5.0") - self.write_archive(repository, "Metamorph-Creative-Menu-v4.zip", source) - target = repository / importer.ROOT_NAME - target.mkdir() - marker = target / "keep.txt" - marker.write_text("keep", encoding="utf-8") - - with self.assertRaisesRegex(RuntimeError, "archive name says v4"): - importer.import_archive(repository) - - self.assertEqual(marker.read_text(encoding="utf-8"), "keep") - - def test_ignores_modworkshop_player_archive(self) -> None: - with tempfile.TemporaryDirectory() as temp: - repository = Path(temp) - (repository / "Metamorph-Creative-Menu-v4-Modworkshop.zip").write_bytes(b"not a source zip") - archive, match = importer.discover_archive(repository) - self.assertIsNone(archive) - self.assertIsNone(match) - - def test_rejects_path_traversal(self) -> None: - with tempfile.TemporaryDirectory() as temp: - repository = Path(temp) - archive_path = repository / "Metamorph-Creative-Menu-v4.zip" - with zipfile.ZipFile(archive_path, "w") as archive: - archive.writestr(f"{importer.ROOT_NAME}/../escape.txt", "bad") - - with zipfile.ZipFile(archive_path) as archive: - with self.assertRaisesRegex(RuntimeError, "unsafe archive path"): - importer.validate_members(archive) - - def test_rejects_case_colliding_paths(self) -> None: - with tempfile.TemporaryDirectory() as temp: - repository = Path(temp) - archive_path = repository / "Metamorph-Creative-Menu-v4.zip" - with zipfile.ZipFile(archive_path, "w") as archive: - archive.writestr(f"{importer.ROOT_NAME}/Files/A.lua", "return 1\n") - archive.writestr(f"{importer.ROOT_NAME}/files/a.lua", "return 2\n") - - with zipfile.ZipFile(archive_path) as archive: - with self.assertRaisesRegex(RuntimeError, "case-colliding archive path"): - importer.validate_members(archive) - - def test_main_reports_no_change_without_archive(self) -> None: - with tempfile.TemporaryDirectory() as temp: - with mock.patch("pathlib.Path.cwd", return_value=Path(temp)): - with mock.patch.dict(os.environ, {}, clear=False): - self.assertEqual(importer.main(), 0) - - -class PlayerReleaseToolTests(unittest.TestCase): - PATCH_TEXT = """--- metamorph_creative_menu/example.txt -+++ metamorph_creative_menu/example.txt -@@ -1 +1 @@ --development -+player -""" - - def prepare_patch_case(self, root: Path, staged_text: str) -> tuple[Path, Path, Path]: - source = root / "source" / player_builder.ROOT_NAME - patch_dir = source / "tools" / "player_release" - patch_dir.mkdir(parents=True) - (patch_dir / "00-example.patch").write_text(self.PATCH_TEXT, encoding="utf-8") - - stage = root / "stage" - target = stage / player_builder.ROOT_NAME - target.mkdir(parents=True) - staged_file = target / "example.txt" - staged_file.write_text(staged_text, encoding="utf-8") - return source, stage, staged_file - - @unittest.skipUnless(player_builder.shutil.which("patch"), "patch utility is required") - def test_release_patch_applies_forward(self) -> None: - with tempfile.TemporaryDirectory() as temp: - source, stage, staged_file = self.prepare_patch_case(Path(temp), "development\n") - player_builder.apply_release_patches(source, stage) - self.assertEqual(staged_file.read_text(encoding="utf-8"), "player\n") - - @unittest.skipUnless(player_builder.shutil.which("patch"), "patch utility is required") - def test_release_patch_skips_when_cleanup_is_already_integrated(self) -> None: - with tempfile.TemporaryDirectory() as temp: - source, stage, staged_file = self.prepare_patch_case(Path(temp), "player\n") - player_builder.apply_release_patches(source, stage) - self.assertEqual(staged_file.read_text(encoding="utf-8"), "player\n") - - def test_lua_comment_detection_ignores_double_dash_inside_strings(self) -> None: - self.assertFalse(player_validator.contains_lua_comment('return "https://example.invalid/a--b"\n')) - self.assertFalse(player_validator.contains_lua_comment("return '--not-a-comment'\n")) - - def test_lua_comment_detection_finds_line_and_block_comments(self) -> None: - self.assertTrue(player_validator.contains_lua_comment("local x = 1 -- comment\n")) - self.assertTrue(player_validator.contains_lua_comment("--[[ block ]]\nreturn 1\n")) - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/tools/validate_user_release.py b/tools/validate_user_release.py deleted file mode 100644 index 79b8fa8..0000000 --- a/tools/validate_user_release.py +++ /dev/null @@ -1,248 +0,0 @@ -#!/usr/bin/env python3 -"""Validate that the player archive contains runtime content only.""" - -from __future__ import annotations - -import argparse -import re -import shutil -import subprocess -import tempfile -import zipfile -from pathlib import Path, PurePosixPath - -ROOT = "metamorph_creative_menu" -FORBIDDEN_DIRS = ( - "/tests/", "/files/qa/", "/files/diagnostics/", "/native_src/", "/tools/", "/dist/", -) -FORBIDDEN_FILES = { - f"{ROOT}/BUILD.txt", - f"{ROOT}/CHANGELOG.txt", - f"{ROOT}/VERSION.txt", - f"{ROOT}/dev_mode.lua", - f"{ROOT}/mcm_native_gameover.lib", - f"{ROOT}/mod_id.txt", - f"{ROOT}/files/core/bounded_log.lua", - f"{ROOT}/files/features/creatures/diagnostics.lua", - f"{ROOT}/files/integrations/ew/bridge/qa.lua", - f"{ROOT}/files/integrations/ew/bridge/qa_reserved.lua", -} -FORBIDDEN_SUFFIXES = { - ".c", ".h", ".py", ".pyc", ".pyo", ".bat", ".cmd", ".ps1", ".sh", - ".md", ".lib", ".obj", ".pdb", ".exp", ".ilk", ".map", -} -REQUIRED_FILES = { - f"{ROOT}/mod.xml", - f"{ROOT}/init.lua", - f"{ROOT}/README.txt", - f"{ROOT}/LICENSE.txt", - f"{ROOT}/NOTICE.txt", - f"{ROOT}/NoitaPatcher/noitapatcher.dll", - f"{ROOT}/mcm_native_gameover.dll", -} -TEXT_SUFFIXES = {".lua", ".xml", ".csv", ".txt"} -FORBIDDEN_TEXT = ( - ("QA localization/runtime residue", re.compile(r"\bmcm_qa_|\bsync_qa|\bqa_reserved\b", re.IGNORECASE)), - ("diagnostic hook residue", re.compile(r"METAMORPH_CREATIVE_MENU_DIAGNOSTICS|\bmcm_diag_", re.IGNORECASE)), - ("debug API residue", re.compile(r"\bdebug_state\b|\b_for_test\b")), - ("profiling/metrics control residue", re.compile(r"\bset_profiling_enabled\b|\bset_metrics_enabled\b")), - ("numbered development log residue", re.compile(r"\[MCM\s*\d{2,}\]", re.IGNORECASE)), - ("numbered test chronology", re.compile(r"\bTEST\s+\d+\b", re.IGNORECASE)), -) - - -def long_bracket_open(text: str, index: int) -> tuple[int, int] | None: - if index >= len(text) or text[index] != "[": - return None - cursor = index + 1 - while cursor < len(text) and text[cursor] == "=": - cursor += 1 - if cursor < len(text) and text[cursor] == "[": - return cursor - index - 1, cursor + 1 - return None - - -def long_bracket_close(text: str, index: int, equals: int) -> int | None: - if index >= len(text) or text[index] != "]": - return None - cursor = index + 1 - for _ in range(equals): - if cursor >= len(text) or text[cursor] != "=": - return None - cursor += 1 - if cursor < len(text) and text[cursor] == "]": - return cursor + 1 - return None - - -def contains_lua_comment(text: str) -> bool: - index = 0 - state = "code" - quote = "" - equals = 0 - while index < len(text): - char = text[index] - if state == "code": - if char in {"'", '"'}: - quote = char - state = "string" - index += 1 - continue - opened = long_bracket_open(text, index) - if opened is not None: - equals, index = opened - state = "long_string" - continue - if char == "-" and index + 1 < len(text) and text[index + 1] == "-": - return True - index += 1 - continue - if state == "string": - index += 1 - if char == "\\" and index < len(text): - index += 1 - elif char == quote: - state = "code" - continue - end = long_bracket_close(text, index, equals) - if end is not None: - index = end - state = "code" - else: - index += 1 - return False - - -def validate_lua_syntax(lua_sources: list[tuple[str, bytes]], failures: list[str]) -> None: - texlua = shutil.which("texlua") - if texlua is None: - failures.append("texlua is required to validate player Lua syntax") - return - if not lua_sources: - failures.append("player archive contains no Lua files") - return - - with tempfile.TemporaryDirectory(prefix="mcm-player-lua-") as temporary: - root = Path(temporary) - checker = root / "check.lua" - checker.write_text( - "for i=1,#arg do " - "local f,e=loadfile(arg[i]); " - "if not f then io.stderr:write(arg[i]..': '..tostring(e)..'\\n'); os.exit(1) end " - "end\n", - encoding="utf-8", - ) - paths: list[str] = [] - names: list[str] = [] - for index, (name, data) in enumerate(lua_sources): - path = root / f"{index:04d}.lua" - path.write_bytes(data) - paths.append(str(path)) - names.append(name) - - result = subprocess.run( - [texlua, "--luaonly", str(checker), *paths], - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - ) - if result.returncode != 0: - output = result.stdout.strip() - for index, path in enumerate(paths): - output = output.replace(path, names[index]) - failures.append("Lua syntax validation failed: " + output[:2000]) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("archive") - args = parser.parse_args() - - failures: list[str] = [] - lua_sources: list[tuple[str, bytes]] = [] - with zipfile.ZipFile(args.archive) as zf: - broken = zf.testzip() - if broken is not None: - failures.append(f"CRC failure: {broken}") - - infos = [info for info in zf.infolist() if not info.is_dir()] - raw_names = [info.filename for info in infos] - if len(raw_names) != len(set(raw_names)): - failures.append("archive contains duplicate file names") - - for info in infos: - name = info.filename - path = PurePosixPath(name) - if "\\" in name or path.is_absolute() or ".." in path.parts: - failures.append(f"unsafe archive path: {name}") - mode = (info.external_attr >> 16) & 0o170000 - if mode == 0o120000: - failures.append(f"symlink is not allowed in player archive: {name}") - - names = set(raw_names) - roots = {PurePosixPath(name).parts[0] for name in names if PurePosixPath(name).parts} - if roots != {ROOT}: - failures.append(f"unexpected archive roots: {sorted(roots)}") - - missing = sorted(REQUIRED_FILES - names) - if missing: - failures.append("missing runtime files: " + ", ".join(missing)) - - leaked = sorted( - name for name in names - if name in FORBIDDEN_FILES - or PurePosixPath(name).suffix.lower() in FORBIDDEN_SUFFIXES - or any(part in name for part in FORBIDDEN_DIRS) - ) - if leaked: - failures.append("development files leaked: " + ", ".join(leaked[:20])) - - if f"{ROOT}/README.txt" in names: - readme = zf.read(f"{ROOT}/README.txt").decode("utf-8") - if readme != "Creator: zerodancing\n": - failures.append("README.txt must contain only 'Creator: zerodancing'") - - if f"{ROOT}/NOTICE.txt" in names: - notice = zf.read(f"{ROOT}/NOTICE.txt").decode("utf-8") - if "Original developer: zerodancing" not in notice: - failures.append("NOTICE.txt must preserve zerodancing attribution") - if "https://github.com/zerodancing/Metamorph-Creative-Menu" not in notice: - failures.append("NOTICE.txt must preserve the original GitHub project link") - - for name in sorted(names): - suffix = PurePosixPath(name).suffix.lower() - if suffix not in TEXT_SUFFIXES: - continue - data = zf.read(name) - try: - text = data.decode("utf-8") - except UnicodeDecodeError: - continue - - if suffix == ".lua": - lua_sources.append((name, data)) - if contains_lua_comment(text): - failures.append(f"{name}: Lua comment residue") - if "/*" in text or re.search(r"(?m)^\s*//", text): - failures.append(f"{name}: embedded C/C++ comment residue") - elif suffix == ".xml" and "